Skip to content

Flat v2 behaviour addendum — text scaling, motion, accessibility, platform (2026-10-09) ​

Status: Draft · Date: 2026-10-09 · Phase B addendum (B6–B9) from "What the plan was missing" in flat-grammar-v2-migration-plan.md. Extends flat-v2-component-library-2026-10-09.md (§2 behaviours, §13 resolutions) and the grammar. Owner: "design all of the components first, including all their interactive behaviours." Audience: Korean amateur tennis players and club treasurers, 30s–50s, who commonly raise the OS text size. Stack: React Native 0.86.3, Expo 57 (expo-router 57.0.24, native stack plus the JS bottom tabs), Tamagui 2.7.7, Reanimated 4.5.1, react-native-screens 4.26.2, Pretendard 400/500/600/700, ko + en.

How to read. Four sections, B6 text scaling · B7 motion · B8 accessibility semantics · B9 platform. Each is Rules (numbered, with numbers) · Per component · Board checks · Sources. Evidence tags: Code = read in this repo or in the installed node_modules (path and line); Doc = a primary document read for this addendum (URL); Derived = computed here from those inputs (method stated); Unverified = the page could not be read, or only a device can answer. No iOS build exists yet (compatibleIosBuildId: null in apps/mobile/ota-compatibility-baseline.json), so every iOS statement is Code or Doc, never device-observed. Annotation language is English; Korean appears only as app copy in label examples.

At a glance ​

  1. Text (B6). Follow the OS up to 200% for reading text; cap hero at 1.7, numerals and labels at 1.5, chrome (header, scope tabs, tab bar, FAB) at 1.3; iOS gets Apple's per-style curve through dynamicTypeRamp. Every fixed height becomes a floor; from a scale of 1.5 the trailing element drops under line 2, Cells stack, the roster goes 2-up, dialogs stack.
  2. Motion (B7). Effects use timing, not springs: 100 ms hairline / hover / exits, 150 ms fades, 200 ms toggles and exits, 250 ms reveals, 300 ms sheet rise, curves from Material. No layout animation in virtualized lists; the RSVP mover fades (100 out, 150 in) and siblings snap. Reduced motion removes translate, scale and layout and makes fades instant; loops stop after 5 s.
  3. Toast (B7.4, B8.5). 4000 / 6000 / 8000 ms by kind (was 2800), Android's accessibility timeout honoured, × on error and action toasts, announced on iOS (aria-live is Android only).
  4. Accessibility (B8). One focus stop per row with a comma-joined, spoken-form name built from the same model as the visible row; exactly one visible h1; state in aria-*, never in the name; focus returns to the trigger.
  5. Platform (B9). Predictive back stays off (React Native's own always-enabled back callback defeats it); iOS 26's full-screen swipe-back is on by default, so slider and wizard screens set fullScreenGestureEnabled: false; sheets get navigationBarTranslucent; the chrome stack uses 16 offsets over insets.bottom; native tabs are not adopted.
  6. Open questions are in the last table; two need a device (the 3-button navigation seam, Korean pronunciation of the spoken forms) and one needs the first iOS build.

0. What the research changed ​

0.1 Findings that drive the rules ​

#FindingEvidenceRule
F1React Native scales every Text on iOS by one flat table (0.823 … 3.571), not by Apple's per-style curves. Apple's Body reaches 1.94 / 2.35 / 3.12 at AX2 / AX3 / AX5 and Large Title only 1.41 / 1.53 / 1.76, so an unmanaged 40 pt numeral renders at 143 pt at AX5.Code react-native/React/CoreModules/RCTAccessibilityManager.mm:259-272; Doc Apple HIG Typography tablesB6.2
F2dynamicTypeRamp switches a Text to Apple's UIFontMetrics curve and maxFontSizeMultiplier still caps it (the smaller of the two wins). The RN docs list the ramp default as 'body'; the code treats "unset" as the flat table. Trust the code.Code Libraries/Text/RCTTextAttributes.mm:238-251B6.2
F3Android converts sp through TypedValue.applyDimension (non-linear since Android 14) and caps with min(scaled, size × density × max). In the Fabric text path lineHeight is converted without the cap, so a capped label can keep a taller line box than its glyphs. Unverified on a device.Code ReactAndroid/.../uimanager/PixelUtil.kt, views/text/TextAttributeProps.kt:42,167, TextLayoutManager.kt:343,524 (the capped TextAttributes.kt:83 is not on that path as read)B6.4
F4The app sets no allowFontScaling / maxFontSizeMultiplier / fontScale handling anywhere; three adjustsFontSizeToFit sites shrink text below the size the user chose.Code grep 2026-10-09: leaderboard-podium.tsx:71, activity-metric-calendar.tsx:177,215 (minimumFontScale 0.82 / 0.86), match-timeline-row.tsx:35B6.9
F5The JS tab bar already does the Apple thing on iOS (label allowFontScaling={false} plus the Large Content Viewer) and the wrong thing on Android (label scales uncapped inside a fixed-height bar).Code expo-router/build/react-navigation/bottom-tabs/views/BottomTabItem.js:12-17,105-106; apps/mobile/app/(tabs)/_layout.tsx:76-78B6.8
F6Reanimated 4.5.1 defaults every withTiming / withSpring / layout animation to ReduceMotion.System, read once at startup; the repo's useReducedMotion() is the live, event-driven gate and also covers RN Animated, Tamagui transition and web.Code react-native-reanimated/src/animation/util.ts:150-153, src/ReducedMotion.ts; packages/ui/src/motion.ts:147B7.7
F7The Tamagui springs are slower than the guidance and overshoot: quick settles in 378 ms (7.6% overshoot), medium 458 ms, slow 571 ms. They drive opacity layers in CollapsingTopBar and the DataSection fade, although Material says effects springs must not overshoot.Derived: integrated x'' = -(c/m)x' - (k/m)(x-1) for {20,250,1} {18,170,1} {15,100,1} (2% settle); Code apps/mobile/tamagui.config.ts:25-27, collapsing-top-bar.tsx:138,156,195, data-section.tsx:120B7.2
F8RN's Modal fade is platform-owned (Android config_shortAnimTime, accelerate), not the library's "150 ms". ModalPanel sets statusBarTranslucent only, although RN and the edge-to-edge README ask for navigationBarTranslucent too.Code react-native/ReactAndroid/src/main/res/views/modal/anim/catalyst_fade_in.xml; packages/ui/src/modal-panel.tsx:108-110; Doc RN ModalB7.3, B9.1
F9ToastHost uses aria-live, which RN implements on Android only, so iOS VoiceOver hears nothing; it truncates at two lines, auto-dismisses at 2800 ms and can only be dismissed by a swipe.Code toast-host.tsx:336-338,355, toast.store.ts:78; Doc RN accessibility (aria-live is Android)B7.4, B8.5
F10RN 0.86's ReactActivity registers an always-enabled OnBackPressedCallback when targetSdk is 36 or more, and react-native-screens 4.26.2 has no back-progress API. Android says an enabled default-priority callback stops the predictive animations. Expo writes enableOnBackInvokedCallback="false" explicitly.Code react-native/ReactAndroid/.../ReactActivity.java:29-37, @expo/config-plugins/build/android/PredictiveBackGesture.js, RNS source grep; Doc Android predictive backB9.2
F11react-native-screens 4.26.2 turns on the iOS 26 full-screen swipe-back by default (fullScreenSwipeEnabled undefined means true on iOS 26 and later). expo-router passes undefined. Our horizontal sliders drag rightwards, the same direction.Code react-native-screens/src/types.tsx:181-189, ios/RNSScreenStack.mm:801-850, expo-router/build/react-navigation/native-stack/views/NativeStackView.native.js:210, packages/ui/src/horizontal-pan-gesture.tsB9.3
F12PersonRow arithmetic: the text block is 22 + 4 + 18 = 44, taller than the 40 avatar, so with 12 padding the natural height is 68, not the specified 64.Derived from grammar §2–§4B6 board check
F13The only composed row label today joins with spaces and speaks symbols: [venue, "9/19 (토) 09:00~11:00", format, strip].join(' ').Code session-card-v2.tsx:335-338B8.2
F14Expo's native tabs are unstable-native-tabs in SDK 54–57, cannot report the bar height, ignore the background props on iOS 26 and support FlatList only partly.Doc Expo native tabsB9.7
F15AccessibilityInfo.setAccessibilityFocus is deprecated in favour of sendAccessibilityEvent(ref, 'focus'); HeaderActionsMenu still uses it. RN's iOS Modal already saves and restores VoiceOver focus around presentation.Code packages/ui/src/header-actions-menu.tsx:115, react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js:448, React/Fabric/Mounting/ComponentViews/Modal/RCTModalHostViewComponentView.mm:169,203,232-246B8.6

0.2 Amendments to the component library ​

Library locationTodayBecomesRule
§2 Toast2800 ms, swipe-down dismiss4000 / 6000 / 8000 ms by kind, Android accessibility timeout honoured, × on error and action toasts, announced on iOSB7.4, B8.5
§2 Reduced motiondrop the settle, keep the dim; shimmer pins .75one table: translate, scale and layout removed; cross-fades become instant; loops freeze (B7.7)B7.7
§2 FAB on scrollcollapses in 150 mswidth 200 ms, label out 100 ms / in 150 ms, direction dead-band (B7.3)B7.3
§2 ModalPanel, §8 Dialog / Menu"fade 150 ms", sheet "slide 200 ms quick"platform fade (not tunable), sheet rise 300 ms in / 200 ms out on timing tokens, menu 150 / 100 msB7.3
§3–§9 every fixed height that holds textHeader 56, Tab bar 56, Tabs 44, Day header 32, Section head 24, Block 144 / 160, Strip 80, rows 80 / 64 / 48, Tag 24, Choice 36, Button 52 / 44 / 36, Input 48, FAB 48the same numbers become minHeight (floors); content may grow (B6.4)B6.4
§5 PersonRow "64"pitch 64 with padding 12natural height is 68 at 100% (F12); the board must either measure 68 or change the line boxesB6
§3 Tab bar label 11/14one sizeiOS keeps the non-scaling label plus Large Content Viewer; Android gets a 1.3 cap through a custom tabBarLabelB6.8
§3 Tab bar "pressed .92/.78 on the item"not implemented by the JS tab bar (pressOpacity: 1)a custom tabBarButton built on Pressable variant="icon"B7.3
§8 Sheet "no drag, no grabber"no swipe dismissalopen owner question: HIG expects swipe-down on iOS (Q3)B9.2
§6 Live tag pulse1600 ms loop, indefiniteloops run 5 s per mount, then rest at the static frame (WCAG 2.2.2)B7.7

B6. Text scaling ​

What the user controls. iOS Dynamic Type has twelve sizes (xSmall to xxxLarge, then AX1–AX5) with body 17 pt at Large (Apple HIG Typography). Android has font scale up to 200% since Android 14, with a non-linear curve that scales large text less (Android 14 features). Both scale text only; icons, thumbnails and layout stay put, so the layout has to absorb the growth.

Rules ​

B6.1 Supported range: 85% to 200% of the base size, the same on both platforms. Follow the OS across its whole range for reading text. Android: 0.85–2.0 (the system maximum since Android 14). iOS: xSmall to AX2 follow Apple's per-style curves (body reaches 2.0× at AX2, where the cap first binds); AX3 to AX5 render the AX2 layout. Why 200: WCAG 1.4.4 asks that text resize "up to 200 percent without loss of content or functionality", WCAG2ICT lets non-web software stop at the platform's own range and lets naturally large text scale less "provided semantic meaning indicated through differences in text size is maintained", and Apple's Larger Text criteria accept "at least 200% or the maximum font size for the system" (Dynamic Type passes 200% for body at AX3). The cost of going further is stated in open question Q1: body at 2.65 (AX3) leaves a 350 pt rail about 8.8 Korean syllables per line, against 11.7 at 2.0.

B6.2 Five role classes, each with a cap and an iOS ramp. One table in packages/ui/src/text-scale.ts feeds the textRole variants (they set maxFontSizeMultiplier on every platform and dynamicTypeRamp on iOS) and the layout hook; a parity test walks every role. The ramp makes iOS use Apple's own per-style curve instead of RN's flat table (F1, F2); the cap is the cross-platform safety net and the smaller of the two wins. Apple's reason for caps: "Prioritize important content when responding to text-size changes … they don't always want to increase the size of every word on the screen", and the Larger Text criteria ask to "prioritize scaling the main content areas before repeated, predictable navigational elements". Tamagui must forward both props to the host Text; confirm with a unit test before relying on it.

ClassCapiOS dynamicTypeRampRoles (grammar §2)100%130%200%Basis
reading2.0headline, subheadline, footnote, caption1row L1 / L2, cell, input text, button labels lg / md, notice, toast, dialog, day header, section title, block eyebrow and meta15/2219.5/28.630/44WCAG 1.4.4; Apple Body 1.94× at AX2
hero1.7title1detail title, identity name24/3231.2/41.640.8/54.4Apple Title 1 = 1.71× at AX3, Large Title 1.76× at AX5
numeral1.5largeTitleblock numeral (display 40/48), stat-strip numeral (24/32)40/4852/62.460/72Apple Large Title 1.53× at AX3; WCAG2ICT note 3 (large text may stop short of 200%)
label1.5footnote, subheadlineTag, level numeral, Choice, small button, section action13/1816.9/23.419.5/27a label stays shorter than the line it annotates
chrome1.3title3, headline, subheadline, caption2tab-root header title (20/28), pushed header title (17/24), scope tab label, FAB label, tab-bar label (11/14), count tag, chart labels20/2826/36.426/36.4HIG: tab titles should not grow with the text; Larger Text criteria: Back and Edit stay small

Size columns are size/line-height in pt after the cap (the 100% value is the first role of the class); RN multiplies lineHeight by the same factor, so body's 22 line becomes 44 at 200%. Android bends large text: at the 200% setting 15 sp renders 27 (1.8×), 24 sp 36 (1.5×), 40 sp 46.9 (1.17×); at 130% 24 sp is 26.4 and 40 sp stays 40 (Derived from the AOSP conversion tables). The board's linear stops are therefore the conservative envelope: Android sits at or below them from 12 sp up at 200% and within 4% above them at 130% for 13–14 sp.

B6.3 Scaling is never switched off for content. allowFontScaling={false} appears in exactly two places: avatar initials inside the fixed 40 / 48 / 56 circles (a duplicate of the adjacent name, which scales) and the iOS tab-bar labels (B6.8). A lint rule flags every other use; the escape is a same-line // scale-exempt: reason (the // figure: and // motion-exempt: precedent).

B6.4 Heights are floors. Every library number that sits on a text-bearing box becomes minHeight with the padding held, never height. Android states the same hazard: "Don't use sp units for padding or define view heights assuming implicit padding: with nonlinear font scaling sp dimensions might not be proportional, so 4sp + 20sp might not equal 24sp." Code that must change: Button sizes use height 28 / 36 / 44 / 56 (packages/ui/src/button.tsx:191-222), the AppHeader ladder 56 / 64 (app-header-visual.ts:15-20), TOP_BAR_HEIGHT = 48, and the tab bar height: 56 + insets.bottom + 8 ((tabs)/_layout.tsx:76); ListRow already uses minHeight. Two things never grow: the header (title cap 1.3 gives 36.4 + 2 × 8 padding = 52.4, under the 56 floor) and the tab bar (6 + 24 + 2 + 18.2 = 50.2, under 56). Because Android's Fabric path does not cap lineHeight (F3), a capped label can sit in a taller line box than its glyphs need; the 200% frame on an Android emulator is the check.

B6.5 Stacked layout from a scale of 1.5. scale = min(useWindowDimensions().fontScale, 2.0) and stacked = scale ≥ 1.5, exposed as useTextScale() in text-scale.ts. 1.5 is crossed first by iOS AX1 (RN 1.786) and by Android's 150% step, and not by iOS xxxLarge (1.353) or Android 130%. Why there (Derived, widest case: a 마감 임박 Tag beside a club or venue name on the 282 pt text column): at 130% the name keeps 181 pt, 8.2 syllables per line at 22.1 pt; unstacked at 150% it would keep 170 pt for 25.5 pt text, 6.7 syllables, while stacking gives it the full 282 pt, 11.1 syllables. Apple: "When font size increases in a horizontally constrained context, inline items … can crowd text … consider using a stacked layout where text appears above secondary items … Reduce the number of columns" (HIG Typography); the Larger Text criteria show a sender / receiver / date row turning vertical; Toss reported moving buttons from side by side to stacked and dropping right-hand text below (Digital Today on SLASH23). No source gives a numeric pivot; 1.5 is this addendum's choice. Only the components below call the hook; nothing else branches on the scale.

ComponentUnder 1.5From 1.5
MediaRowthumb, L1 / L2, trailing count or Tag centred on L1trailing element moves under L2, left-aligned to the text column, 8 below; L2 may wrap to 3 lines
PersonRowavatar, L1 / L2, trailing figure or Tagfigure or Tag under L2, 4 below; the rank column stays
Celllabel left, value rightlabel above value, 4 apart, value left-aligned
Strip3 equal columns3 columns kept (numeral cap 1.5 fits); labels wrap to 2 lines
Roster4-up2-up
Segmented (form)equal columnsbecomes radio Cells, vertical
DialogCTA pair side by sidestacked, primary on top (HIG Alerts: default button at the top of a stack)
Notice, Toastaction at the rightaction under the text, left-aligned
Day headerdate left, relative day rightrelative day under the date
Section headtitle left, action rightunchanged: the title wraps, the action keeps its line
Scope tabs, FABcontent-width tabs; extended pillunchanged (cap 1.3 keeps them on one line)

B6.6 Wrap versus clamp. Names never cut (owner, NAME_LINES = 2); everything else wraps before it clamps; a clamp is allowed only where the full text is one tap away and the tap is signalled (WCAG 1.4.4: truncation is acceptable "if the component's full content is available on focus or after user activation and an indication that this information can be accessed" exists), and the accessible label always carries the full text.

TextUnder 1.5From 1.5Ellipsis allowed
Person names (NAME_LINES)wrap, 2 lineswrap, 3 lines (NAME_LINES_STACKED)never
Venue and club nameswrap, 2 lineswrap, 3 linesafter the last line; full name on the destination, row is pressable
L1 time, counts, amounts1 linewraps only if it mustnever
Button, Choice, Cell label and value, Notice, Toast (4 lines, was 2)wrapwrapnever; a toast that needs more is an inline Notice
Tag, count tag, level numeral1 line, flexShrink: 01 linenever; the row re-flows around it
Block meta, explanatory copy (EXPLANATION_LINES = 4)2 lines; 43 lines; 6after the last line; the block / row opens the full text
Header title, tab-bar label1 line1 lineheader title yes (the hero repeats it); tab labels are one word, so none occurs

B6.7 Glyphs and images beside text. Apple: "Increase the size of meaningful interface icons as font size increases." Inline glyphs next to text (hero-fact 20, Notice icon 20, Choice check 16, FAB icon 20) scale by min(scale, 1.5). These do not scale: header action glyphs (24 in a 44 target), tab-bar icons (24), chevrons (16), avatars (40 / 48 / 56), thumbs and map tiles (56), the 7 px dot. The level tag circle grows with its numeral: diameter = ceil(24 × min(scale, 1.5) / 4) × 4 → 24 / 32 / 36 / 36 at 100 / 130 / 150 / 200%.

B6.8 Chrome follows Apple: small, with an alternative. Tab bar on iOS: keep the library's allowFontScaling={false} plus accessibilityShowsLargeContentViewer and accessibilityLargeContentTitle (BottomTabItem.js:12-17,105-106). Apple's UILargeContentViewerInteraction: "Rely on the large content viewer only in situations where items must remain small due to unavoidable design constraints. For example, buttons in a tab bar remain small to leave more room for the main app content." Tab bar on Android: the fork scales the label uncapped (at 200% an 11 sp label renders 22, Derived from the AOSP table) inside a fixed bar, so pass a custom tabBarLabel that renders our Text with the chrome cap 1.3 (JS only; the fork exposes tabBarAllowFontScaling, not a cap). Header title, scope tabs, FAB label and count tags use the chrome class. The same Large Content Viewer props are available (RN accessibilityShowsLargeContentViewer, iOS 13+) for header icon buttons.

B6.9 No shrink-to-fit on content. adjustsFontSizeToFit undoes the user's setting (down to 0.82×) and goes. Podium names wrap (NAME_LINES); the activity-calendar cell numerals sit in cells with a minHeight and the chrome cap; the timeline numeral uses the numeral class and lets its row grow. A lint rule no-adjusts-font-size-to-fit (escape // scale-exempt:) prevents the pattern returning.

B6.10 Bold Text (iOS). Apple: "System fonts automatically support Dynamic Type … and respond when people turn on accessibility features, such as Bold Text. If you use a custom font, make sure it implements the same behaviors." RN exposes AccessibilityInfo.isBoldTextEnabled() and a boldTextChanged event. Map one step up within the four loaded Pretendard weights: 400 → 600, 500 → 600, 600 → 700, 700 → 700, inside Text only. Priority P2 (open question Q8).

B6.11 Web. Type is in px; the browser's default-font-size setting does not scale px, page zoom does, and reflow at 320 CSS px (WCAG 1.4.10) keeps zoom usable. Honouring the setting would need rem output from Tamagui's web build — unverified; deferred to the web phase.

B6.12 Test stops. The board draws 100 / 130 / 150 / 200%: 130 is a font scale Android ships a conversion curve for (1.3) and sits at about iOS xxxLarge (1.353); 150 is the stacking pivot; 200 is the cap. iOS ramps give small styles up to 1.55× at xxxLarge (caption2), above the board's 130 frame, which is why 150 is drawn too. One extra 320-wide frame at 200% is the WCAG reflow reference width. Device rung (typical forms, not verified in a primary document here): adb shell settings put system font_scale 2.0; xcrun simctl ui booted content_size accessibility-large (AX2; accessibility-extra-extra-extra-large is AX5 and must render the AX2 layout). Korean on Android has no word-break strategy prop (text.tsx notes it), so mid-word breaks in narrow columns at 200% are unverified.

Per component ​

Natural height in pt = content plus the held padding, Derived from the class table, each text line scaled by its class cap: MediaRow = max(56, L1 + 4 + L2) + 24 (stacked adds 8 + the Tag); PersonRow = max(40, 22 + 4 + 18 lines) + 24; Cell = one line + 26; Tag = line + 6; Button lg = line + 28, md = line + 22, sm = line + 14; Block = 20 + eyebrow + 8 + numeral + 8 + meta + 20; Strip = 12 + numeral + 4 + label + 12; Input = line + 26. 100% is the library floor.

ComponentFloor130%150%200%Rule
MediaRow8087.8138 (stacked)161 (stacked, L2 one line); 205 (L2 two lines)thumb stays 56; trailing under L2 from 1.5
PersonRow64 (natural 68)80125 (stacked)156 (stacked)resolve F12 on the board first
Cell4854.696 (stacked)118 (stacked)label over value from 1.5; long values wrap
Tag sm / md24 / 2829.4 / 34.633 / 3933 / 39one line; label class cap 1.5
Level tag24323636circle scales with text (B6.7)
Button lg / md / sm52 / 44 / 3659.2 / 50.6 / 42.664 / 55 / 4776 / 66 / 47minHeight; labels wrap rather than clip
Block home / ELO144 / 158170.4 / 181.2188 / 198208 / 216 (home with a two-line meta: 252)numeral cap 1.5; meta wraps to 3 lines; whole block still opens the destination
Header56565656title cap 1.3 never exceeds the floor
Tab bar56 + insetsamesamesameiOS non-scaling label; Android cap 1.3
Strip80931031123 columns stay
Day header3238.643 plus the stacked line54 plus the stacked linerelative day under the date from 1.5
Section head2431.23648action keeps its line
Choice3642.64747sets wrap with gap 8
Input4854.65970label and helper 13/18 scale with the reading class
FAB4848.648.648.6chrome cap
Notice, Toast (1 line)4852.65768action under the text from 1.5
Roster cell98 (pitch 100)111.4119 (2-up)128 (2-up)names wrap (NAME_LINES), never cut

Board checks (B6) ​

  1. The strip draws MediaRow, PersonRow, Cell, Tag, Button, Block, Header and Tab bar at 100 / 130 / 150 / 200% (plus Strip, Roster, Dialog, Notice, Segmented in the same frame set), each printing measured height against the table above (±1 pt).
  2. In every frame the DOM check passes: no clipped text (scrollHeight equals clientHeight), no overlapping sibling boxes, Tags on one line, header and tab bar height unchanged.
  3. The 200% frame measures each class at its cap: reading 2.0, hero 1.7, numeral 1.5, label 1.5, chrome 1.3 (computed style, not drawn by hand).
  4. The stacked layouts appear at 150 and 200 and not at 130; MediaRow is also drawn with a two-line venue and a 10-syllable person name (wraps, no ellipsis).
  5. PersonRow measures 68 (F12): either the board accepts 68 as the pitch or the line boxes change; the owner marks which.
  6. The English strings (longer: "Waitlisted", "Doubles NTRP 3.0–3.5") are drawn in the 200% frame; a 320-wide frame at 200% shows no horizontal scroll.
  7. An Android 200% emulator screenshot of the tab bar and one capped label is attached to confirm F3.

Sources (B6) ​

B7. Motion and transitions ​

Press physics, the toast drag and the shimmer are specified; everything else is platform default or one of four Tamagui named animations (three springs and one timing), and eight of the MOTION_DURATION tokens are unused (Code: fast, standard, entrance, exitStandard, modal, modalExit, liveDot, badgePulse have no consumer; toggle appears only in a comment). This section is the missing table.

Rules ​

B7.1 Principles, with their numbers.

  • Motion has a purpose or it is absent. Apple HIG Motion: "Add motion purposefully, supporting the experience without overshadowing it", "In apps, generally avoid adding motion to UI interactions that occur frequently", "Let people cancel motion … don't make people wait for an animation to complete".
  • Duration bands (NN/g): most animations 100–500 ms; checkbox or toggle feedback about 100 ms; a modal moving into view 200–300 ms; 500 ms "start to feel like a real drag"; entering a little longer than leaving (300 ms in, 200–250 ms out). Our bands: feedback 80–150, small state 100–200, surfaces 200–300, nothing above 300 except loops and the platform's own push.
  • Feedback starts within 100 ms of the touch (NN/g response times: 0.1 s is "instantaneous"); the press dim starts at 80 ms.
  • Ease out when entering and ease in when leaving (NN/g). Spatial motion may use springs or eased timing; effects (opacity, colour) use timing with no overshoot — Material: effects springs "animate properties such as color or opacity where the property's value should not be overshot" (Material Components Android, Motion).
  • Animate transform and opacity; a layout property only on a single element (the FAB width). Budget: "no more than 100 components for low-end Android devices and no more than 500 components for iOS" (Reanimated performance).

B7.2 Tokens. Reuse what exists, add one easing set, retire what has no role. All of this is JS (motion.ts, tamagui.config.ts): OTA.

Duration tokenmsMaterial slotUsed forState today
pressIn80between short1 and short2press dim inused
pressOut150short3press dim outused
exitFast100short2edge hairline, hover dim, menu out, toast out, Switch track / Choice / checkbox colour, FAB label out, FAB hide on keyboardused (toast drag)
fast150short3skeleton to content, scroll-linked header title, FAB label in, menu in, Notice dismiss, image load fade, Switch thumbunused, now used
toggle200short4chevron rotation, FAB width, Notice height collapsecomment only, now used
modalExit200short4sheet and dialog exit (same value as toggle, kept for meaning)unused, now used
entrance250medium1RevealStack item and ExpandReveal (the reveal animation), wizard progress fillunused, now used (reveal is 250)
modal300medium2sheet riseunused, now used
slow300medium2ProgressBar fill (existing)used once
shimmer, pulseDot800, 1600loopsskeleton half-cycle, live dotused; both capped at 5 s (B7.7)
retire——standard 220, exitStandard 180, liveDot 500, badgePulse 800, MOTION_STAGGER, staggerDelay: never consumed; 220 and 180 sit between 200 and 250 with no role; stagger has no virtualized-list use (canon)delete (knip then holds it)
Easing token (new MOTION_EASING)CurveMaterial nameUse
standardcubic-bezier 0.2, 0, 0, 1standardanything that stays on screen: fades, FAB width, fills
entercubic-bezier 0, 0, 0, 1standard deceleratethings entering: sheet rise, menu, toast
exitcubic-bezier 0.3, 0, 1, 1standard acceleratethings leaving: sheet, dialog, toast
emphasizedEnter (reserve)cubic-bezier 0.05, 0.7, 0.1, 1emphasized deceleratethe sheet rise if 300 ms on enter feels flat on a device

Curves and slots are from the Material Components Android Motion doc (16 durations 50–1000 ms, seven easings, six springs). The m3.material.io tokens page is script-rendered and could not be read (unverified); nothing here depends on its prose.

Spring (existing)ConfigSettles to 2%OvershootRole
pressIndamping 20, stiffness 400, mass 0.7247 ms9.5%press settle (a 0.03 swing, overshoot invisible)
pressOutdamping 15, stiffness 300, mass 0.7377 ms14.8%release
navSlidedamping 22, stiffness 280, mass 0.8311 ms3.2%scope-tab indicator
Material fast / default / slow spatialdamping ratio 0.9, stiffness 1400 / 700 / 300127 / 179 / 272 ms0.1%reference only, not adopted
Material fast / default / slow effectsdamping ratio 1, stiffness 3800 / 1600 / 80096 / 147 / 207 ms0%reference only: the case for timing on effects

Settle times and overshoot are Derived by integrating x'' = -(c/m)x' - (k/m)(x-1) (damping ratio × 2√(k·m) for the Material rows).

Tamagui named animation (tamagui.config.ts)ConfigUse
fade (new)timing 150, standardevery opacity cross-fade
fadeFast (new)timing 100, standardhairline, menu out, hover
sheetIn (new)timing 300, entersheet rise
reveal (existing)timing 250reveals and toast enter
quick / medium / slow (existing)springsfrozen: existing spatial uses only; the 12 current sites (10 quick, 2 medium) migrate by the strangler rule, none gain an opacity use

The Tamagui driver forwards duration and easing straight into Reanimated's withTiming (Code @tamagui/animations-reanimated/dist/cjs/createAnimations.native.js:380-402), so easing: Easing.bezier(...) in the named config works. A transition object also picks by animation state, { enter: 'reveal', exit: 'fadeFast' } (Code @tamagui/animation-helpers/dist/cjs/normalizeTransition.native.js:179-190), which is how the toast enters in 250 ms and leaves in 100.

B7.3 Interaction table. Each row's reduced-motion fallback is the class it belongs to in B7.7: translate, scale and layout rows are removed, opacity rows become instant, loops go static, gesture-tracking rows are unchanged, platform rows follow the system.

InteractionMotionDuration, easingNotes
Press (all Pressable)settle plus dim on the UI thread; row .98 / .80, button .97 / .92, pill .96 / .85, icon .92 / .78, fab .96 / .90, card .98 / .90springs above, dim 80 in / 150 outunchanged; reduced keeps the dim only
Hover (web)dim to .85100 ms, fadeFastreal pointers only
Focus ring (web)appears instantly0never animates (it must be visible the moment focus lands)
Screen push / popnative stack default, no animation overrideplatformiOS slide with the interactive swipe; Android 13+ translate 10% over 450 ms with an 83 ms fade, Android 12 and below scale .85 to 1 over 200 ms (Code react-native-screens/android/src/main/res/{v33/anim-v33,base/anim}/rns_default_*.xml); the push is not tunable (React Navigation); 450 ms is the platform's, accepted. Native transitions are the right default because "System components might also adjust their motion in response to factors like accessibility settings" (HIG Motion)
Bottom tab switchnone0the fork's animation default is 'none' (Code bottom-tabs/types.d.ts:213-221); screens detach
Scope-tab switchpane swap instant (canon: useDeferredValue instant switch); underline slidesnavSlide ≈ 311 msreduced: the underline jumps
Sheetplatform Modal fade (Android config_shortAnimTime, accelerate; iOS system) plus a 48 pt riserise 300 ms sheetIn; exit 200 msthe fade is not tunable (F8), so the library's "fade 150 ms" is dropped; reduced: no rise (existing code)
Dialogplatform fade only, no scaleplatform
Menufade, no translate or scale, anchored under the triggerin 150 fade, out 100 fadeFastreduced: instant
Toastrise 12 pt and fade in; fade out only (the exit style drops its y: 12); drag release springin 250 reveal, out 100 exitFastB7.4
Scroll-linked header titletitle and the solid header layer fade together; flips when the hero title's bottom edge passes the header's bottom edge, hysteresis 24 (COLLAPSE_FLIP_HYSTERESIS)150 ms fadereplaces the quick spring on three layers (collapsing-top-bar.tsx:138,156,195); aria-hidden flips with it (B8.3); reduced: instant
Edge hairline#D4D4D4 fades under header, tab row, tab bar, CTA band100 ms fadeFaston above scroll offset 8, off at 0 (an 8 pt band); reduced: instant
FAB collapse and expandcollapse after 120 pt of cumulative downward scroll, expand after 24 pt upward or at offset 24 and below; label out, then width; expand reverseslabel 100 out / 150 in, width 200 standardignore offset below 0 (iOS bounce) and programmatic scrolls; hides behind the keyboard in 100 ms; reduced: instant swap. Material: extended FAB "extend" and "shrink" animate the text, show and hide grow and fade (Material Android)
Pull to refreshnative RefreshControl, spinner only for a user pull, tint inkplatformcontent swaps instantly on completion; no custom animation
Switchthumb slides; track colour changesthumb 150 fade, track 100 exitFastreduced: instant
Choice, checkbox, radiofill colour changes; glyph appears instantly100 ms exitFastNN/g: about 100 ms for a checkbox
ExpandTogglechevron turns 180°; ExpandReveal enters with the 250 ms reveal sliding 8 ptchevron 200 toggle, reveal 250chevron moves from the quick spring to timing; at rest under reduced motion (existing)
Notice dismissopacity then height collapse100 + 150 ms (fast)reduced: instant
Skeleton to contentcross-fade; skeleton appears only after 400 ms of loading and stays at least 400 ms once shown150 ms fade; shimmer 800 half-cycleNN/g: no skeleton for loads under 1 s ("the quick flashing page can cause users to feel like they can't keep up"); replaces the medium spring (458 ms) in DataSection; reduced: static .75, instant swap
Wizard stepRevealStack reveal in place; progress line fill250 ms reveal; fill 250 standardstagger none
Image loadplaceholder pulse, then fade in150 ms fadereduced: static, instant
Chart scrubthe marker follows the fingernonegesture-tracking motion is exempt from reduced motion
Live dotpulse1600 ms loop, 5 s per mount, then solidB7.7

B7.4 Toast. Enter 250 ms reveal (rise 12 pt plus fade), exit 100 ms exitFast (fade only), drag follows the finger and releases on the pressOut spring, dismissal thresholds stay 40 pt or 500 pt/s (Code toast-host.tsx:164-165). Duration replaces the single 2800 ms: success and info 4000 ms; warning and error 6000 ms; any toast with an action 8000 ms. On Android take the larger of that and AccessibilityInfo.getRecommendedTimeoutMillis(duration) (the "Time to take action" setting; RN AccessibilityInfo). The timer pauses while the toast is touched (existing) and while a screen reader's focus is on it. Why: Material's snackbar timeout is "between 4000 and 10000 … Defaults to 5000" (MDC Web snackbar); Apple: "Views and controls that auto-dismiss on a timer can be problematic … Prefer dismissing views with an explicit action" (HIG Accessibility); the ARIA pattern: "it is also important to avoid designing alerts that disappear automatically" (APG Alert); WCAG 2.2.1's own 5 s toast example passes because "Users are able to identify the arrival of email through other means", which a failed payment toast cannot claim (Understanding 2.2.1). Dismissal without a gesture (HIG: "if you use a swipe gesture to dismiss a view, also make a button available"): a tap on a toast without an action dismisses it, and warning, error and action toasts carry a × (24 glyph in a 44 target). Reversible destructive actions use a toast with an undo action for 8000 ms instead of a confirm (the label is not in the terminology guide yet; 되돌리기 is the candidate) (HIG Alerts: "Avoid displaying alerts for common, undoable actions"). The shadow goes (library C11); text wraps to 4 lines.

B7.5 Lists: insert, remove, reorder.

WhereRuleWhy
Virtualized lists (FeedList, FlashList, FlatList: sessions, members, notifications, ranking)no entering, exiting or layout transitions, no stagger, no LayoutAnimation; data changes apply in one framecanon (styling.md): views recycle, so per-item entrances re-fire on scroll; FlashList: LayoutAnimation needs prepareForLayoutAnimationRender() and "is experimental on Android"; Reanimated: a FlashList mounted while animations run can blink ("positioned incorrectly for a split second"); recycling={false} is the standing Fabric fix
Short fixed stacks of at most 24 children (roster grid, avatar row, a Cell group)layout={LinearTransition.duration(200)} on each child wrapper (easing standard); the mover fades out 100 ms and in 150 ms after 50 msReanimated layout transitions: LinearTransition animates position and dimension; builders go outside components or in useMemo
Your own action (RSVP 예비 to 참석, cancel, remove)optimistic apply; in a stack the mover animates as above, in a list it snaps; success haptic; the toast announces the resultmotion confirms the action the user took
Someone else's change (poll, refetch, push)never animate; snapHIG: avoid motion on frequent interactions; focus-gated polling re-renders often
Move between sections (예비 group to 참석 group)the mover leaves the old place (100 ms) and arrives at the new one (150 ms); counts update instantly; siblings glide only in a stackone thing moves
Drag to reordernot a feature today; if added it is gesture-tracked with no timed animation—

B7.6 In-place changes and numbers.

  • Tag label or tone, count tag, Cell value, label swaps: instant. The state is the message. Interactive controls change fill over 100 ms (above).
  • Counters and scores ("4/8", live score): instant. Precedent in code: AnimatedCount dropped its exit-before-enter cross-fade because "for live scoreboards the value IS the message — instant update beats decoration" (motion/animated-count.tsx).
  • Block and strip numerals: instant on refresh. Optional, owner-gated (Q4): a 400 ms Easing.out(cubic) count-up on the ELO block only, after a recorded result, only when the screen is focused and the value changed since last seen, driven by an animated TextInput (Reanimated: "When implementing an animated number counter, don't use React state … use an animated TextInput"); reduced: instant.
  • Progress fills (ELO bar, wizard line): entrance 250 or slow 300 with Easing.out(Easing.cubic) (the current ProgressBar).

B7.7 Reduced motion and loops. Detection is useReducedMotion() (event-driven: iOS reduceMotionChanged, Android reads TRANSITION_ANIMATION_SCALE, web prefers-reduced-motion; Code packages/ui/src/motion.ts:147-172, react-native/ReactAndroid/.../accessibilityinfo/AccessibilityInfoModule.kt:101-110). Reanimated also snaps its own animations by default (F6), so a missed gate degrades to an instant change, not to motion. Policy (HIG Accessibility: "Replacing transitions in x-, y-, and z-axes with fades … Tightening animation springs … Tracking animations directly with people's gestures"; WCAG 2.3.3: non-essential interaction animation "can be disabled"):

ClassNormalReduced
Translate, scale, layout (sheet rise, FAB width, LinearTransition, indicator slide, ExpandReveal slide)timing or springremoved: the end state appears at once
Opacity cross-fades (header title, skeleton to content, menu, Notice)100–150 msinstant
Press feedbacksettle plus dimdim only (existing)
Gesture-tracking motion (toast drag, chart scrub, scroll-linked transforms)follows the fingerunchanged: it mirrors the user's own gesture
Loops (shimmer, live dot)pulsestatic frame (.75 opacity, solid dot)
Platform transitions (push, pop, Modal fade)systemthe system decides (iOS cross-fade, Android scale 0)

Loops also stop on their own: WCAG 2.2.2 (Level A) requires a way to pause, stop or hide moving content that starts automatically, lasts more than five seconds and is presented in parallel with other content, unless the movement is essential. Shimmer and the live dot run 5 s per mount and then rest at the static frame; LIVE stays conveyed by its text. NN/g: skeletons are for waits under 10 s, so after 10 s the region swaps to the slow-load Notice with 다시 시도 (proposal).

B7.8 What must never animate. Anything the user did not cause (refetches, polling, remote RSVPs); anything inside a virtualized list item (B7.5); live scores and counters; text while it is being typed; validation errors (no shake, no bounce); the focus ring; the tab switch; layout properties inside scroll handlers (transform and opacity only); more than 100 components at once; the sheet scrim on its own (it is part of the Modal fade).

Per component ​

ComponentChange
Pressablenone (physics decided); row dim .80 per library
CollapsingTopBarthree quick layers become fade 150; aria-hidden follows the flip; the same hook feeds the edge-hairline useScrollEdge
Skeletons (DataSection deleted 2026-11-13; the state helper decides)medium spring becomes fade 150; 400 ms delay and 400 ms minimum; shimmer stops at 5 s
ModalPanelsheet transition: 'sheetIn' with enterStyle y: 48; no scale on the dialog; navigationBarTranslucent (B9.1)
ToastHostB7.4: durations, ×, tap-dismiss, announce, no shadow, 4 lines
FAB (new)width and label animation, direction dead-band, keyboard hide
Tab barcustom tabBarButton on Pressable variant="icon" (the fork sets pressOpacity: 1, so iOS items have no press feedback today; Android has a ripple); tabBarHideOnKeyboard: true
Scope tabsnavSlide unchanged
HeaderActionsMenufade 150 in / 100 out, no shadow
Switch / TogglePillthumb 150, track 100
ExpandTogglechevron on 200 ms timing instead of the quick spring
PulseDot5 s cap
FeedListno animation props; a lint rule rejects entering / exiting / layout / LayoutAnimation inside list item renderers

Board checks (B7) ​

  1. The board prints the token tables above from motion.ts itself (the board build imports the module) and every CSS transition-duration on the board is read from it (a parity test, with a known-positive canary: a deliberate 220 ms transition fails the build).
  2. A motion board draws looping demos for: sheet in / out, header title plus hairline on a scrolling hero, FAB collapse, skeleton to content, RSVP move in a stack and in a list (the list demo shows the snap), toast in / out / drag, press, Switch, menu, ExpandToggle. Each demo prints its timeline in milliseconds.
  3. A "reduce motion" switch on the board (CSS prefers-reduced-motion) shows each row's fallback from B7.7.
  4. No board element animates longer than 300 ms except loops, and every loop freezes after 5 s.
  5. No animation adds a tap or a wait to the RSVP, pay and dues flows: every demo accepts input while it plays (HIG: "don't make people wait for an animation to complete").

Sources (B7) ​

B8. Accessibility semantics ​

Per-component roles already exist (selection-accessibility.ts, accessibility-state.ts, heading levels in canon/components.md). Missing: composed row names, a page outline, live regions that work on both platforms, and focus return. Platform facts that bound the design (Doc: RN accessibility, Code: installed 0.86.3): RN reads aria-* and role; aria-live is Android only and aria-modal iOS only; the native role list has no dialog, status or navigation; RN has no aria-level (the DOM gets it through Tamagui), so heading levels exist on web and a heading is just a heading on native; RN has no aria-invalid; an accessible touchable's default name is "constructed by concatenating all Text node children separated by spaces".

Rules ​

B8.1 One focus stop per row; the stop carries role, name and state. A row, block, roster cell, Strip column or Cell is one accessibility element; its children (thumb, Tag, count, chevron, glyphs, MetaBreak) are hidden from AT because the name already carries them. Apple: "Specify how elements are grouped, ordered, or linked … In the grouped example, VoiceOver describes each image with its respective caption" (HIG VoiceOver); Android: merge a natural group into its parent and "make only the parent container interactive … keep each description as short as possible" (Android accessibility principles). A row with a real second control (member ⋯, dues "mark paid") keeps two stops: the row, then the control, named for its owner ("김민준 메뉴"). Names never contain the control type ("버튼"): Apple's accessibilityLabel is "a very short, localized string that identifies the accessibility element, but does not include the type of the control" (UIAccessibilityElement). State goes in aria-selected / aria-checked / aria-pressed / aria-expanded / aria-busy (via selectionStateProps), never in the name; a switch's name does not change with its state (APG: "it is critical the label on a switch does not change when its state changes").

B8.2 Composing a name. The name is built by a function that sits beside the visible formatter and takes the same view-model the row renders (buildSessionInfoModel for sessions); nothing hand-types a second string. Android warns that aggregating child text by hand "makes the group's description brittle", so the builder and the render share one input and one test. The same Android note says that in a list context a screen reader consolidates the child text and "it is best to avoid modifying this announcement"; we override that on purpose, because our visible text is symbolic (4/8, 09:00~11:00, 3.0–4.0, MetaBreak) and reads badly aloud, and we pay for it with the shared input and the test. Order: urgent status word first (진행 중, 마감 임박), then when, where, kind, rating, count, and last the viewer's own state. Join with a comma and a space (a pause), not a bare space (F13). Spoken forms replace symbols and 24-hour digits (the Korean strings in this section illustrate the pattern; they are not shipped keys unless a key is named):

VisibleSpoken in the name
9월 19일 (토)9월 19일 토요일 (with 오늘 or 내일 first within a week, the existing getRelativeDateLabel words)
09:00~11:00오전 9시부터 11시까지 (30 minutes: 오후 7시 30분)
4/8 or 4/8명8명 중 4명 참석
NTRP 3.0–4.0NTRP 3.0부터 4.0
68%68퍼센트
30,000원, 3위, 1,482unchanged (the TTS reads these)
·, ~, /, en dash, MetaBreaknever spoken

The date stays in session rows even under a day header: the existing builder's own comment says "a screen reader needs the DATE even though the visual L2 states only the time" (session-card-v2.tsx:329-334). Label in Name (WCAG 2.5.3, Level A): "the name contains the text that is presented visually", best practice "at the start of the name". It binds controls that have one visible label (buttons, tabs, FAB, Choice): their name starts with the visible text. Rows have no single label; venue, club and person names appear in the name verbatim and numerals in their spoken form. Whether Korean speech-input users say "09:00" or "오전 9시" is unverified; the spoken form is assumed. Length: about six facts; urgency and identity first so truncated speech still helps.

ComponentNative role / webName pattern, example (ko)State, value
Header title (tab root)heading / h1the title: "경기"—
Header back, action iconsbutton"뒤로" (uiStrings().back), "검색", "알림 3개, 관리 필요 2건 포함" (existing bellA11yLabelWithObligation)—
Scope tabstablist + tab when panels exist; group + button for filters (existing PillNav rule)the visible label: "번개 찾기"aria-selected / aria-pressed
Tab bar itemiOS button, Android tab (the fork's FIXME), web navigationlabel plus badge tail: "홈, 진행 중인 일정 있어요"; "클럽, 읽지 않은 게시글 2개" (existing unreadCount)aria-selected; no "n of 5" text
Block (home)buttoneyebrow, figure and unit, meta: "내일 9월 19일 토요일, 일정 2개, 첫 시작 오전 9시, 올림픽공원"—
Block (ELO)button"ELO 1,482, NTRP 3.5부터 4.0, 마스터까지 518점 남음"; the progress bar is aria-hidden (the text carries it)—
Block (identity)button"김민준, NTRP 3.5, 강남 테니스 클럽 3년 차"; the edit icon is its own stop: "프로필 수정"—
Section headheading / h2, plus a linktitle; the action starts with its visible text: "전체 보기, 내일 일정"—
Day headerheading / h2 (h3 inside a section)relative day then date: "오늘, 9월 18일 금요일"—
MediaRow, sessionbutton / link"마감 임박, 내일 9월 19일 토요일 오전 9시부터 11시까지, 올림픽공원 테니스장, 복식, NTRP 3.0부터 4.0, 8명 중 7명 참석, 내 상태 참석"aria-selected in pickers
MediaRow, session (short form)same"9월 19일 토요일 오전 9시, 올림픽공원 테니스장, 복식, NTRP 3.0부터 4.0, 4명 중 4명 참석, 마감"—
MediaRow, clubbutton / link"강남 라켓클럽, 서초구, 멤버 42명, NTRP 3.0부터 4.0, 읽지 않은 게시글 2개"—
PersonRow, memberbutton / linkname, role, rating: "김민준, 총무, NTRP 3.5"—
PersonRow, duesbutton"김민준, 회비 30,000원, 미납" (terminology: 미납 / 완납)—
PersonRow, rankingbuttonrank first: "3위, 김민준, ELO 1,482, NTRP 3.5"—
ResultRowbuttonoutcome, score, opponents, date: "승, 6 대 4, 상대 박지훈 최유나, 9월 18일 금요일"—
Cell, statictext group"종목, 복식" (label then value)—
Cell, navigationbutton / linkthe label: "알림 설정"; the value joins it when present: "프로필 공개, 클럽 멤버에게만"—
Cell, toggleswitchthe visible label, constant: "경기 알림"aria-checked
Roster cellbutton"김민준, NTRP 3.5"; empty slots are hidden and the group head says "참석, 8명 중 6명, 2자리 남음, 예비 2명" (existing spotsLeft)—
Tag, count tag, level tagnone inside a row; text standalonepart of the row name; standalone level: "NTRP 3.5"; count: "읽지 않은 3개", 99+ spoken "99개 이상"—
Strip columntext group (button if linked)label then value: "총 경기 47", "승률 68퍼센트", "출석률 92퍼센트"—
Chartimg, plus stepping actions when scrubbable"ELO 추이, 최근 30일, 1,420에서 1,482로 62점 상승, 최고 1,490, 최저 1,415"; stepping reads "9월 3일, 1,452"aria-valuetext per point (adjustableActionProps)
Noticetext group; live only when inserted after load"오프라인이에요. 연결되면 자동으로 다시 불러와요" (title, then body); the action is its own stop—
Toastlive region, no focusthe message; an action is a separate stop (undo label pending, B7.4)B8.5
Sheet, dialognative Modal, aria-modal (iOS); web dialog / alertdialogfirst element is the title heading; close "닫기"; the scrim is hidden from AT—
Menutrigger button + menu / menuitemtrigger "더보기"; items by label; destructive lastweb: aria-haspopup="menu", aria-expanded
Switch, Segmented, Choiceswitch; radiogroup + radio; radio / checkbox / buttonvisible labels; the group has a labelaria-checked / aria-pressed (existing selectionStateProps)
Stepper, Slideradjustable / slider"값 입력"-style labels; value in words via aria-valuetext ("오후 7시")adjustableActionProps
Input, TextArea, Searchtext field; web search landmark for Searchlabel text; helper through the hint; clear "검색어 지우기"invalid: name suffix "입력 오류" (existing)
FAB, CTAbuttonvisible text first: "일정 만들기" for a "+ 일정" pill (also when collapsed to the icon)aria-busy while loading; the name does not change
Empty stateheading + text + buttontitle as h2; the glyph is hidden—
Skeletoncontainer onlyone label on the container: "불러오는 중"; shapes hiddenaria-busy

B8.3 Headings, landmarks, page outline. Levels (canon): 1 screen title, 2 hero title and section titles, 3 inside a section. Apple: "Use titles and headings to help people navigate your information hierarchy" (HIG VoiceOver); Android: headings can be navigated between "instead of between paragraphs or between words" (principles). Exactly one visible h1 per screen: on a detail page the hero title is the h1, and the collapsing header's title is aria-hidden until it is shown (the opacity-zero layer is not enough on iOS; unverified, so the flag is explicit). Day headers are h2 when a page has no section heads, h3 inside one. Web landmarks (APG landmark regions): header banner, nav labelled "주 메뉴" for the tab bar or rail, one main, aside for the desktop pane; a repeated landmark gets a unique label and the label never repeats the role. There is a "본문으로 건너뛰기" skip link on web.

TemplateOutline, in focus order
Hub, 홈h1 홈 · block (button) · h2 내일 일정 and its 전체 보기 · rows · h2 이번 주 · rows · FAB 일정 만들기 · tab bar
List, 경기h1 경기 · tablist 예정, 번개 찾기, 테니스장 · h2 per day header · rows · FAB · tab bar
Records, 기록h1 기록 · ELO block · tablist · Strip columns · h2 최근 ELO 추이 · chart · h2 클럽 랭킹 · rows
Profile, 프로필h1 프로필 · identity block and 프로필 수정 · Strip · h2 친구 24명 · avatar group · h2 최근 경기 · rows · h2 업적
Detail, sessionback · (hidden header title) · eyebrow · h1 hero title · three facts · h2 참석 · roster · h2 정보 · Cells · CTA band
Form, wizardback and skip · progress "4단계 중 2단계" · h1 step title · fields (label, control, helper) · CTA band
Sheet, dialogtitle heading (h2 on web inside dialog) · content · actions, least destructive first

B8.4 Focus order and grouping. Visual order, start to end, top to bottom (HIG VoiceOver: "VoiceOver reads elements in the same order people read content"): header, scope tabs, content, FAB, CTA band, tab bar. On iOS the order follows the frame positions, so the absolutely positioned FAB is reached after the list; that cost is accepted and the FAB's name makes it findable by Voice Control ("탭 일정 만들기"). Decorative images, glyphs and the 7 px dot are hidden ("Exclude purely decorative images from VoiceOver"). While a sheet, dialog or menu is open nothing behind it is reachable: iOS through the native Modal plus aria-modal, Android through the dialog window, web through a trap (B8.9).

B8.5 Announcements. aria-live reaches TalkBack only; VoiceOver needs an explicit announcement. WCAG 4.1.3 (Status Messages) asks that status messages be programmatically determinable "without receiving focus".

EventMechanismText
Toast showniOS announceForAccessibility (success and info queued with announceForAccessibilityWithOptions({queue: true}), warning and error interrupting); Android aria-live="polite" / "assertive" by kind; web role="status" for success and info, role="alert" for warning and error (the code mixes role="alert" with aria-live="polite" today, toast-host.tsx:336-337)the message, then the action label when there is one
Field error appearsthe existing pair, aria-live plus announceForAccessibility (create-club-step1.tsx:160-168), moved into Fieldthe helper text
Content finished loadingnone; the container carries aria-busy while loading—
Your RSVP / payment resultthe toast announces; the count has no live region (no double speech)"참석했어요" style result copy
Screen pushedthe header title is the first stop; on Android also announceForAccessibility(title) once on focus (the pattern in club-detail-screen.tsx:161); iOS behaviour for a JS header is unverifiedtitle
Sheet or dialog opensiOS Modal posts a screen-changed notification (Code RCTModalHostViewComponentView.mm:169,203,232-246); Android: move focus to the title with sendAccessibilityEvent(ref, 'focus') after mounttitle
Wizard step changesRevealStack announcement (exists)"4단계 중 2단계, 제목"
Offline strip appearsannounce once"오프라인이에요"
Pull to refresh finishednone unless it fails (then the Notice)—

B8.6 Focus management and return. Migrate AccessibilityInfo.setAccessibilityFocus (deprecated: "Prefer using sendAccessibilityEvent with eventType focus") to sendAccessibilityEvent(ref, 'focus'); the target needs accessible (RN docs).

SurfaceOn openOn close
Sheet, dialog (native)iOS: the title is the first accessible element and Modal already saved the focused view; Android: verify TalkBack lands on the titleiOS: Modal restores the saved view (Code); Android: onDismiss sends focus to the trigger (verify first, add only if TalkBack does not)
Confirm dialogfocus the least destructive action or the title (APG dialog: "set focus on the least destructive action"); Cancel precedes Confirm in ordertrigger (APG: "focus returns to the element that invoked the dialog")
Menufirst item (APG menu button); web aria-haspopup="menu", aria-expandedtrigger; Escape on web (APG menu button)
Row removed (cancel RSVP, delete)—the next row's stop, or the section head when none
Wizard next or backthe new step's title—
Search takeoverthe inputthe header search icon
Toast actionno focus change—

B8.7 Targets and gesture alternatives. Targets stay 44 with hitSlop to 44 (library), above WCAG 2.5.8's 24 CSS px minimum (Target Size); Apple's default is 44 and minimum 28, Android's design guide asks 48 dp "even if this extends past the UI element visual", so primary icon actions use 48 where space allows (header actions keep 44 with the 8 gap). Gestures always have an on-screen path (HIG: "if you use a swipe gesture to dismiss a view, also make a button available"; Android: "Don't rely on gestures to complete all actions"): toast swipe → tap or × (B7.4); sliders → adjustableActionProps; chart scrub → stepping actions; sheets → explicit close; pull to refresh → the system control. The payment-hold countdown is a server deadline (essential under WCAG 2.2.1).

B8.8 Language. The in-app locale (ko / en) is independent of the OS language, and nothing sets accessibilityLanguage (grep 2026-10-09). An English-OS user who picks Korean in the app would hear Korean labels through an English voice. Set accessibilityLanguage (iOS, BCP 47, RN accessibility docs) from the locale store on each screen root; inheritance by children is unverified. Android TalkBack uses the system TTS language and RN has no per-view prop, so the mismatch cannot be fixed there. Web: lang on html.

B8.9 Web keyboard patterns (APG). Tabs: roving tabindex, Left / Right, Home / End, automatic activation ("recommended … as long as their associated tab panels are displayed without noticeable latency", which our instant pane swap meets). Menu button: Enter, Space or Down opens and focuses the first item; Escape closes and returns focus. Dialog: Tab and Shift+Tab stay inside, Escape closes (on a confirm dialog Escape is its Cancel; only the scrim does not dismiss it), aria-modal, title through aria-labelledby, alertdialog for destructive confirms; mark a dialog modal only when "application code prevents all users from interacting in any way with content outside of it". Switch: Space toggles. Radio group and Segmented: arrows move and check. The 2 px ink ring is never covered by the sticky header or CTA band: set scroll-padding to their heights (WCAG 2.4.11).

B8.10 Forms. Field: the label is the input's name; the helper is the hint; a validation error shows inline under the field in danger colour, is announced when it appears (B8.5) and is never a toast. RN 0.86 still has no aria-invalid (Code grep), so the existing name suffix "입력 오류" stays. Choice groups carry a group label; Segmented is a radiogroup; the Date / time picker opens the native wheel, which is accessible by the OS; a picker Cell reads "{label}, {value}" and opens the sheet.

Board checks (B8) ​

  1. Every component state on the component board gets an annotation strip: role, name (Korean), state, stop count.
  2. The board runs axe-core in the local Chromium rung and prints violations; expected zero for name, role, label-in-name and target-size.
  3. An automated label-in-name test: for every control with visible text the accessible name contains it (case-insensitive).
  4. A heading-outline sidebar per page template; exactly one h1; the tab-root and detail outlines match the table in B8.3.
  5. The toast frame shows the announced text for a success and an error, and the 4000 / 6000 / 8000 ms timers.
  6. Device run (VoiceOver once an iOS build exists, TalkBack now): five flows — find a session and RSVP, pay the fee, confirm dues, advance a wizard step, trigger a toast with undo. Record stops per row (must be 1), the spoken label, focus after closing a sheet, and the Korean pronunciation of 오전 9시, 8명 중 7명 참석 and NTRP 3.0부터 4.0 (unverified until run).

Sources (B8) ​

B9. Platform ​

The Android preview build is runtime 0.11.0; no iOS build exists. Each rule says whether it ships as an OTA or needs an EAS build (B9.9).

Rules ​

B9.1 Android system bars and edge-to-edge.

  • Facts. Android: "Once you target SDK 35 or higher on a device running Android 15 or higher, your app is displayed edge-to-edge"; for apps targeting 36 the opt-out attribute is "deprecated and disabled" (edge-to-edge, Android 16 behavior changes). Expo SDK 57 lists "fixes and improvements to edge-to-edge support on Android" for RN 0.86 (changelog). app.json still lists the react-native-edge-to-edge plugin; its README tells RN 0.81 and later apps to "consider using the built-in edgeToEdgeEnabled=true", so the plugin may be redundant (unverified; leave it until the next native build).
  • Bottom-pinned chrome paints its own canvas behind the system navigation bar and adds insets.bottom (numbers in B9.6); no safe-area wrapper clips it.
  • Status bar: expo-status-bar styled from the in-app theme (exists, apps/mobile/app/_layout.tsx:312-320: the in-app toggle can disagree with the system scheme). Dark icons on the #FFFFFF canvas, light icons on the #0F1211 dark canvas.
  • Navigation bar: gesture navigation is transparent; 3-button navigation gets a translucent scrim, and "the colors of the system icons and the scrim are adjusted based on the system light or dark theme" (Android edge-to-edge doc), not the app's. With the in-app theme different from the system theme, 3-button mode can therefore show a contrast seam under our tab bar. Verify on an emulator (3-button, system light with app dark and the reverse). If it shows, the fix is native: enforceNavigationBarContrast: false in the plugin options plus SystemBars styled from the app theme (README: "you will need to manage the navigation bar style"), an owner-gated EAS build (Q5). 3-button navigation is a plausible default for 40–50s Android users; the share is unverified.
  • Modals: ModalPanel sets statusBarTranslucent only (modal-panel.tsx:110). RN: "statusBarTranslucent also needs to be set to true to make navigation bar translucent" and the edge-to-edge README says to set both. Add navigationBarTranslucent (JS) and verify the scrim covers the navigation-bar band and that insets.bottom is not double-counted inside the modal.
  • Gesture zones: Android's back gesture is "an inward swipe from either the left or the right edge"; SeekBar opts out automatically, custom controls do not (gesture navigation), and the edge-to-edge doc says to "move or pad swipeable views away from the edges". Our sliders start 20 from the edge. Drag each thumb at its minimum and maximum under gesture navigation; if the system wins, inset the track (rail 20 to 28). No source gives the zone width, so this is a device check.

B9.2 Back: predictive back stays off; sheets keep their explicit close. Decision: android.predictiveBackGestureEnabled: false (the Expo default; Expo writes android:enableOnBackInvokedCallback="false" explicitly — Code @expo/config-plugins/build/android/PredictiveBackGesture.js). All four reasons are read, not inferred:

  1. Android: "If your app enables an OnBackPressedCallback or an OnBackInvokedCallback with PRIORITY_DEFAULT or PRIORITY_OVERLAY, the predictive back animations don't run and you must handle the back event"; the system animates back-to-home only "when the back stack is at its root destination" with no enabled callback (predictive back).
  2. RN 0.86.3's ReactActivity adds an always-enabled OnBackPressedCallback when targetSdk is 36 or more (ReactActivity.java:29-37, "Due to enforced predictive back on targetSdk 36 …"), so even true gives no back-to-home preview.
  3. react-native-screens 4.26.2 registers OnBackPressedCallbacks and has no BackEvent progress handling; expo-router passes gestureEnabled: false to Android screens and handles back in JS (NativeStackView.native.js:210), so there is no in-app predictive animation either.
  4. Android 16 turns the system animations on by default for apps targeting 36 unless the attribute is false; the explicit false keeps behaviour stable.

No user-visible gain and a non-zero risk. Revisit when RN or RNS ship progress callbacks; tests then: root back-to-home preview, stack-pop preview, a sheet closing on back, the unsaved-changes guard. Behaviours that must hold on Android today: back closes the topmost of menu, sheet or dialog, search takeover, screen (Modal onRequestClose); with the keyboard open the first back hides it (system); the wizard shows its guard. iOS sheets: HIG Sheets says "Support swiping to dismiss a sheet. People expect to swipe vertically to dismiss a sheet instead of tapping a dismiss button", while ModalPanel has an explicit close, a scrim tap (when allowed) and no swipe (canon: no drag). Recommendation (Q3): add a swipe-down on the sheet's title row only, iOS only, so the scroll body has no scroll-versus-drag conflict, reusing the toast's thresholds (40 pt or 500 pt/s); sheets with unsaved input keep a confirm. Not adopted until the owner decides.

B9.3 iOS swipe-back and horizontal controls. The native stack gives the edge swipe. On iOS 26 and later react-native-screens turns the content-area swipe on by default (its own type comment: unset means off before iOS 26 and on from iOS 26; Code src/types.tsx:181-189; expo-router passes it unset, F11). It is UIKit's interactiveContentPopGestureRecognizer, which "recognizes a gesture on the entire content area … and initiates an interactive pop" (Apple), and RNS cancels touches in the content when it begins (RNSScreenStack.mm:800-850). A rightward drag on a slider is the same direction. Rule: every screen that hosts a horizontal-pan control — all consumers of createHorizontalPanGesture (band sliders, single-band slider, hue slider, the SparkLine scrub) and the wizard steps with TimeBandSlider — sets fullScreenGestureEnabled: false in its Stack.Screen options; the edge swipe stays. Other screens keep the default. OTA. On the first iOS build: drag a slider rightwards from its minimum, swipe back from the left edge, swipe back on a list; the interaction with horizontally scrolling scope tabs is unverified. Before iOS 26 nothing changes.

B9.4 Keyboard.

  • Baseline, shipped and known good: Android softwareKeyboardLayoutMode: "pan"; RN KeyboardAvoidingView behavior="padding" in WizardShell and ModalPanel; useScrollRevealTarget. Do not flip pan to resize globally: the library says "A combination of adjustResize + edge-to-edge mode will result in behavior similar to adjustNothing" (KeyboardController).
  • Activation (JS only; the native module is already in the 0.11.0 binary as react-native-keyboard-controller 1.22.5): KeyboardProvider at the root with preload={false} (docs: preloading "may cause the keyboard to briefly appear on launch" and our first screen has no input), per-screen KeyboardController.setInputMode(SOFT_INPUT_ADJUST_RESIZE), then WizardShell (body KeyboardAwareScrollView, footer KeyboardStickyView) and ModalPanel (library KeyboardAvoidingView with automaticOffset, which "handles modals automatically").
  • Upstream status on 2026-10-09 (GitHub API): #1612 worklet registration race closed 2026-09-09, shipped in 1.22.5, which is installed; #1013 top-inset override closed 2026-04-10; #1604 Android Fabric release-build crash on KeyboardProvider mount still open (one reporter, labelled "repro required", the maintainer asked for a repro on 2026-08-14); #1215 (KAV flicker in expo-router), #1137 and #1050 (KeyboardAwareScrollView in Tabs and nested stacks) still open. The gating from the adoption note stands: first a RELEASE-build boot smoke on Medium_Phone_API_36.1, then the PortOne WebView insets, then the three expo-router entry paths (tab switch, stack push, modal).
  • iOS caveat in the docs: KeyboardStickyView can skip its animation on the iOS new architecture when a React commit precedes the keyboard event; the library's fix is the Reanimated static flag DISABLE_COMMIT_PAUSING_MECHANISM (build time, EAS). Do not enable the *_SYNCHRONOUSLY_UPDATE_UI_PROPS flags without a Pressable hit-test pass: Reanimated says they "affect the touch detection system for components with animated transforms", and Pressable animates a scale transform (Reanimated performance).
SurfaceBehaviourMechanism after activation
Wizard or form screenthe focused input stays visible with 16 clear above the keyboard; the CTA band rides the keyboard; the band hides while a search field owns the keyboard (existing)KeyboardAwareScrollView plus KeyboardStickyView
Sheet with inputslifts with the keyboard; its ScrollView shrinks; the CTA stays visiblelibrary KeyboardAvoidingView, automaticOffset
Search takeoverthe field is focused on open; dragging the list dismisses the keyboardScrollView keyboardDismissMode, keyboardShouldPersistTaps="handled" (exists in ModalPanel)
Toastabove max(bottom chrome, keyboard) + 16 (existing logic, new gap)Keyboard events (exists)
FABhidden while the keyboard is open (100 ms)Keyboard events
Tab barhidden while the keyboard is opentabBarHideOnKeyboard: true (the fork's option, default false)
DM composerpinned above the keyboardsticky view

The 16 clearance is a design choice (the grammar's 16 step), not a sourced figure.

B9.5 Haptics: outcomes only, never primitives. Unchanged policy; the code already complies (47 call sites in mutation hooks: success 20, error 20, warning 2, light 4, selection 1). Apple: "Use system-provided haptic patterns according to their documented meanings", "Avoid overusing haptics", "Make haptics optional", "Prefer using haptics to complement other feedback" (HIG Playing haptics). A haptic is never the only signal: on iOS the Taptic Engine "will do nothing" in Low Power Mode, when the user disabled it, with the camera active or during dictation (Expo Haptics). Expo also says Android's Vibrator path "is not recommended" and points to performAndroidHapticsAsync, which "does not require VIBRATE permission"; expo-haptics 57.0.3 has it (Code build/Haptics.d.ts:30), so the switch is OTA. The library's own manifest declares VIBRATE, so the permission stays.

OutcomeiOSAndroid (performAndroidHapticsAsync)
Mutation succeeded (RSVP confirmed, payment confirmed, saved)notification successConfirm
Mutation failednotification errorReject
Cautionary outcome (placed on the waitlist instead of confirmed)notification warningReject (no distinct warning effect)
Optimistic acknowledgement (RSVP tap, reaction, DM send, alert set)impact lightVirtual_Key
Choosing a value (poll vote)selectionSegment_Tick
Neverpress, tab switch, scroll, sheet open, navigation, toast shown—

The Android effect names are a choice by meaning; feel is unverified until a device run.

B9.6 Safe areas and the chrome stack. Insets are read at runtime (useSafeAreaInsets); the 34 in the grammar is the iPhone home-indicator example, and Android gesture and 3-button navigation differ (the 3-button bar is typically taller; unverified).

ElementPositionNotes
Tab barheight 56 + insets.bottom; canvas fill continues under the system barpublishes 56 + inset to the chrome store; today's extra +8 goes ((tabs)/_layout.tsx:47,75-76)
CTA band16 + 52 + 16 + insets.bottom; hairline only while content lies belowscreens without a tab bar
FABright 20; bottom = tab-bar top + 16 on tab screens, insets.bottom + 16 elsewhereone per screen
Toastcentred; bottom = max(top of bottom chrome, top of keyboard) + 16; above the FAB when present: FAB top + 16TOAST_CHROME_GAP 8 becomes 16 (library K1)
Sheetpadding bottom 20 + insets.bottomexisting
Headerstatus inset on top of the 56topInset

B9.7 iOS 26 Liquid Glass; native tabs. We draw the header, tab bar, sheets and buttons in JS, so they do not become Liquid Glass. System-owned surfaces do when the binary links the iOS 26 SDK — Apple: "standard components like bars, sheets, popovers, and controls automatically adopt this material" (Adopting Liquid Glass); for us that is likely Alert.alert, the native date picker (@react-native-community/datetimepicker), the share sheet and system menus (inferred from Apple's sentence; there is no iOS build to look at). We do not imitate glass in JS (Apple: "Avoid overusing Liquid Glass effects"; our grammar is flat with no shadows) and we put no custom backgrounds on native bars. UIDesignRequiresCompatibility is only a temporary opt-out ("Temporarily use this key while reviewing and refining your app's UI") and "the system ignores this key when you build for iOS 27 or later"; Expo notes that apps built with the iOS 27 SDK must use the UIKit scene-based lifecycle (SDK 57: expo@57.0.23, opt-in ios.enableSceneSupport in expo-build-properties). No plan depends on the key. The HIG tab bar page now describes a bar that "floats above content … on a Liquid Glass background" and can minimize on scroll; we keep our own flat bar.

Native tabs (expo-router/unstable-native-tabs in SDK 54–57, stable in SDK 58; Expo) would give the glass bar, minimize on scroll (minimizeBehavior="onScrollDown"), native tab semantics and a search tab. Not adopted, because: the API is unstable in SDK 57; it "cannot measure the tab bar height", which our FAB, toast and CTA stacking depends on (B9.6); on iOS 26 backgroundColor, blurEffect, shadowColor have no effect and the bar "does not follow a color scheme that exists only in JavaScript", while we have an in-app theme toggle; FlatList support is partial (no scroll-to-top, no minimize) and our lists are FlashList / FlatList; a badge is a short string or a dot. Android's five-tab limit would fit. Revisit at SDK 58.

B9.8 Large Android screens. For apps targeting Android 16, orientation, resizability and aspect-ratio restrictions "no longer apply" on displays whose smallest width is 600dp or more (Android 16). Our orientation: "portrait" no longer bounds the layout on foldable inner screens and tablets (iOS supportsTablet is false). The window-size classes (window-size.ts) and the desktop work apply there; verify the phone layout on a 600 dp-plus emulator before the Play release, with text scaling at 200%.

B9.9 OTA or EAS.

ChangeShips asNote
Text caps and ramps, useTextScale, min-heights, stacked layouts, custom tabBarLabel and tabBarButton, tabBarHideOnKeyboardOTAJS only
Motion tokens, Tamagui named animations, toast timing, skeleton delay, loop capsOTAJS only
Names, announcements, focus calls, heading and landmark changesOTAJS only
navigationBarTranslucent on ModalPanelOTARN prop
fullScreenGestureEnabled: false per screenOTAscreen option
KeyboardProvider, setInputMode, wizard and modal migrationOTAnative module is in the 0.11.0 binary; needs the release-build smoke
performAndroidHapticsAsyncOTAexpo-haptics 57.0.3 includes it
predictiveBackGestureEnabledEAS (manifest)stays false (B9.2)
Edge-to-edge plugin options, removing the pluginEASQ5
Reanimated static feature flags (DISABLE_COMMIT_PAUSING_MECHANISM, *_SYNCHRONOUSLY_UPDATE_UI_PROPS)EASnot set today; hit-test pass first
UIDesignRequiresCompatibility, ios.enableSceneSupportEAS (Info.plist / native)with the first iOS build
Native tabsnot adoptedrevisit at SDK 58
First iOS preview buildEASpending; every iOS rule above is verified there

Board checks (B9) ​

  1. A chrome-stack board draws three 390 × 844 frames (iPhone inset 34, Android gesture navigation, Android 3-button with a taller inset), each with tab bar, FAB, toast and CTA band, plus the keyboard-open state. Measured offsets must be the 16 steps in B9.6 and the tab bar 56 + inset.
  2. A sheet-with-input frame shows the CTA above the keyboard and a 16 clearance above the keyboard to the focused input.
  3. Device and emulator list with expected outcomes: Android 3-button × system theme × app theme (seam check), gesture navigation with sliders at min and max, a 600 dp emulator, the release-build boot with KeyboardProvider; iOS (first build): slider versus swipe-back, sheet swipe if Q3 passes, haptics in Low Power Mode (silent by design).

Sources (B9) ​

Open for the owner ​

Each row carries the recommendation and the cost of going the other way.

#QuestionRecommendationCost of the other way
Q1Body cap 2.0 (B6.1) or 2.65 (iOS AX3)2.0 now; revisit after the first 200% device pass2.65 leaves about 8.8 Korean syllables per line on the 350 pt rail and three-line rows
Q2Stacking pivot at 1.5 (B6.5)keepa later pivot clips MediaRow at iOS AX1
Q3iOS sheet swipe-down on the title row (B9.2)yes, iOS only, toast thresholdsiOS users expect it (HIG); the canon "no drag" stays otherwise
Q4ELO count-up (B7.6)off: instanta 400 ms count-up is one component and one place, easy to add later
Q53-button navigation seam fix (B9.1)decide after the emulator checknative change: EAS build, plugin options
Q6Toast durations 4 / 6 / 8 s, × on error and action toasts, tap to dismiss (B7.4)adopt2.8 s is below Material's 4 s minimum and fails the time-boxed guidance
Q7fullScreenGestureEnabled: false on slider and wizard screens (B9.3)yes (OTA)a rightward slider drag can pop the screen on iOS 26
Q8Bold Text weight mapping (B6.10)yes, later (P2)custom fonts ignore the setting without it
Q9PersonRow pitch 64 or 68 (F12)68 (17 × 4): natural height with 12 padding64 needs 10 padding (off the 4-grid) or 40 pt of text
Q10Undo label for toastsadd 되돌리기 to the terminology guidean unlabelled action fails B7.4 and B8

Gates and hand-off ​

Every gate ships with a known-positive canary (a gate that cannot fail has checked nothing).

  • text-scale.ts parity test: every textRole maps to a class; canary: a role without one fails. Tamagui forwards maxFontSizeMultiplier and dynamicTypeRamp to the host Text (unit test).
  • Lint: no-allow-font-scaling-false and no-adjusts-font-size-to-fit, escape // scale-exempt: reason; canaries are two fixture files.
  • Motion: a gate that rejects literal durations and easings outside motion.ts; canary: a literal 220; lint that rejects entering, exiting, layout and LayoutAnimation inside list item renderers.
  • Accessibility: one label-builder test per row type that asserts the visible text and the name from the same model; a test that showToast announces on iOS (mocked) and sets the live region on Android; the board runs axe in the screenshot rung.
  • Canon and AGENTS rows to rewrite in D0: styling.md (motion tokens, stagger paragraph), components.md (composed-name law, heading outline, toast), a new text-scale row.
  • D1 gallery: the live mirror of the boards at 100 / 130 / 150 / 200% and with reduced motion.

Markdown remains the source of truth. Run yarn docs:check before handoff.