Flat v2 component library
Status: Active · Version: 2.1, consolidated 2026-10-11 · One entry per component with its current values only. This edition folds the layered 2026-10-09 edition (§0–§23, now archived at history) and the owner review 1 rulings into one specification. The reasoning behind a value is in the decision log and the research notes under research/; the foundations' rationale is in the grammar. The wireframe kit (docs/twomore_design_system/wireframes/flat-v2-kit*.jsx) implements these entries one-for-one, and the boards compose from it (contract §8–§9).
How to read
- Values are at a 390 pt phone on the 4pt grid. A height that holds text is a minimum height (it grows with text scaling, §2.4).
- Type is written size/line·weight, for example 15/22·600. Colours are tokens (§1.7), never literal hexes in code.
- Each entry has the same order: Job, Anatomy, Variants, States, Content, Accessibility, Motion, Replaces in code.
- A component's states are drawn on its library board. Its use on screens is drawn on the page boards.
Contents
- 1. Foundations — 1.1 Frame and safe areas · 1.2 Rail and content width · 1.3 Grid and spacing ladder · 1.4 Type · 1.5 Heading spacing · 1.6 Seams · 1.7 Colour (scheme S2, Ball & Ink) · 1.8 Medal set · 1.9 Radii · 1.10 Icons · 1.11 Targets · 1.12 Motion tokens · 1.13 Leading-marker rule and inline adornments · 1.14 Page grammar · 1.15 Navigation · 1.16 No meta lines · 1.17 Page levels · 1.18 Actions · 1.19 Words · 1.20 One item type per list · 1.21 Questions · 1.22 Forms
- 2. Behaviours — 2.1 Press physics · 2.2 Hover and focus · 2.3 Disabled, soft-disabled, loading · 2.4 Text scaling · 2.5 Motion · 2.6 Accessibility semantics · 2.7 Platform · 2.8 Skeleton and loading timing · 2.9 Toast timing
- 3. Chrome and navigation — Header · Tabs · Tab bar · FAB · CTA band · Sticky edges · MediaBand · Live row · WizardShell header and progress row · Search takeover · Header icon badge · Coachmark
- 4. Surfaces and content — Block · Block, 홈 day · Block, ELO · Block, dues · Block, dues, mine · Block, season · Block, identity · Match state block · Club hero · Strip · Hero (detail) · Notice · Caption row · Band · Chart · Activity heatmap · Calendar · Podium · Form strip · Window bar · PhotoGrid · Shelf · Brand lockup · Thumb · Trust (신뢰)
- 5. Rows and cells — Feed item · MediaRow · PersonRow · Text row · Cell · Day header · Section head · ExpandToggle · Roster · Figure column · Row trailing control · Leading marker slot · SwipeAction · Select mode · Team lines · ResultRow · Task Cell · AnswerPair
- 6. Labels and avatars — Tag · Level tag · Count tag · Live tag · Unread marker · Avatar · Medal set
- 7. Controls and fields — Button · IconButton · Choice · Door pill · Switch · Slider · Stepper · Search field · Checkbox and radio rows · Date and time picker · Time range wheel · Field · Input · Number field · TextArea · Picker Cell · Choice set · Field hint · Info hint · Input counter · Field error and validation · Required and optional marker · Field note · Field skeleton · Map face in a form · Composer photo tiles · Cover grid · Consent group · ProviderButton
- 8. Overlays and feedback — The overlay rule · Dialog · MenuSheet · Sheet · Sheet, value picker · Sheet, score · Edit Sheet · FilterSheet · Document page · Toast · EmptyState · Skeleton · Offline strip · CountdownNotice · Spinner
- 9. Conversation and social — PostRow · Comment · MessageBubble · Composer · Reactions
- 10. Scoring — Result states · Transitions · Races · Who may do what · Result policy · Pushes · ScoreEntry · Host review sheet · Match row states · Live pages · Bracket · Group table · Group card · Score particles
- 11. Retired
1. Foundations
1.1 Frame and safe areas
| Item | Value |
|---|---|
| Reference frame | 390 × 844 pt (iPhone 12 to 14 class). |
| Status area | 44 on the reference frame; the real top inset is read at runtime. The header's 56 sits below it. |
| Bottom inset | 34 on an iPhone (home indicator). Android gesture and 3-button insets differ; read at runtime. |
| Paint-once law | A bottom-pinned element paints the device inset exactly once, by whatever reaches the physical edge (the shell's safe area, the tab bar, or the band). A band's bottom padding is max(16, inset), so the inset is the padding where there is one. |
| Orientation | Portrait on phones until the first iOS build decides; the WCAG 1.3.4 departure is recorded. From 600 pt Android ignores the lock, so every layout reflows. |
| Elevation | None. Separation is a scrim, an edge hairline or an ink surface; no shadows anywhere. |
Pinned chrome stack (insets read at runtime):
| Element | Position |
|---|---|
| Header | status inset + 56 (64 with a subtitle) |
| Tab bar | 64 + bottom inset (98 at inset 34); the canvas fill continues under the system bar; publishes its height to the chrome store |
| CTA band | 16 + helper (22 per line + 8, when present) + 52 + max(16, bottom inset): 102 at inset 34, 132 with one helper line, 154 with two; on screens without a tab bar |
| FAB | right edge on the rail; bottom = tab-bar top + 16 on tab screens (114 at inset 34), inset + 16 elsewhere (50) |
| Toast | centred; bottom = max(top of the bottom chrome, top of the keyboard) + 16 (offsets from the screen bottom at inset 34: 114 over a tab bar, 118 over a CTA band, 186 over a FAB on a tab screen); above a FAB: FAB top + 16 |
| Sheet | footer in the CTA band's geometry (16 above, max(16, inset) below); with no footer, 20 + inset under the last row |
1.2 Rail and content width
| Window width | Rail | Content width |
|---|---|---|
| under 390 (SE, mini, Android 360) | 16 | width − 32 (328 at 360, 343 at 375) |
| 390 to 599 (phones top out at 440) | 20 | width − 40 (350 at 390, 390 at 430, 400 at 440) |
| 600 and up (tablet, web) | 24 | width − 48, capped by ContentColumn (LAYOUT_WIDTH: reading 680, wizard 640, shell 1200) |
- One token,
SEAM.rail, is width-aware. Block padding equals the rail, so a block's inner edge lines up with the page's text edge. - In code, the rail is the content column's (
useRail():railForofuseContentWidth(), not the window's), and since the flip (K4b, 2026-11-02) it reads 16 below 390, 20 to 599 and 24 from 600 of column width; jest keeps 16 through the chrome mocks; on web a 600–679 window shows the medium structure while its column reads 20, and the first server paint is 16 until the column measures. Every reader takes it from the hook: the Header, WizardHeader, Sheet, Dialog and menu clamp; the Cell and its hairline,FlatColumn,RailBleedandRailPad, the Divider bleed,BottomCtaBand, EmptyState, ExpandToggle, ChoiceSet's room, the Section head, MediaHero, TypeHero and the skeleton page; and the rail-bearing scroll presets throughuseScrollContent('page' | 'railedList' | 'railedBanded')(acontentContainerStylecannot call a hook; the vertical-only presets stay constants). A literalpaddingHorizontal="$4"is not the rail yet. A shell draws its Header inside the column, so a bar reads the column's rail; the fill of the bar is the canvas the shell paints edge to edge, and the edge it draws once scrolled is the column's width. - Grids derive from the content width: seven equal day cells, four roster cells, n equal strip columns. Never a fixed 50 or 100.
- Keep-out: nothing but a full-bleed surface (photo, band, hairline, block fill) enters the margin (§1.13, LM1).
- Why: Apple's own margin is 16 up to 375 pt and 20 from 390 pt (measured 20.00 at 414 and 440); Material and 당근 use 16, Toss and the banks 24. Lane A §4.
1.3 Grid and spacing ladder
- Every box size, gap and position is a multiple of 4. Line heights are multiples of 2 that stack onto 4.
- Exempt, and listed per component: a height set by text lines (n × 22) and a computed media size (photo-grid tiles, composer tiles, equal-pitch columns). Pads, gaps and seams are never exempt.
| Step | Role |
|---|---|
| 4 | line 1 to line 2 in a row; numeral to unit; field to support line |
| 8 | icon to text; Tag padding; eyebrow to figure in a block; CTA helper to button; gap inside an answer pair; Choice wrap gap |
| 12 | row vertical padding; thumb to text; section title to first row; day header to first row; CTA pair gap; answer pair under line 2; wizard progress row clear of the header buttons |
| 16 | tab row to a row; chart inset; block internal gap; CTA band top padding and bottom floor |
| 20 | rail; block padding; a Sheet's top (its sides are the rail, not 20); FAB offset from the right edge |
| 24 | block or hero to the next section head; hero top; tab row to a title or day header; day header above; band above and below; step title under the progress bar |
| 32 | section seam (above a section head); hero to first section |
| 40 | scroll tail above a bottom CTA band |
1.4 Type
Pretendard. Size / line · weight.
| Role | Spec | Scale class (cap) | Use |
|---|---|---|---|
| display | 40/48 · 700 | numeral (1.5) | the block figure; never two on a screen |
| hero | 24/32 · 700 | hero (1.7) | detail title, identity name, strip figures, wizard step title |
| title | 20/28 · 700 | reading (2.0); the tab-root header title is chrome (1.3) | tab-root header title, section title |
| headline | 17/24 · 600 | reading (2.0); the pushed header title is chrome (1.3) | row line 1, dialog and sheet titles, lg button label |
| reading | 16/24 · 400 (500 to emphasise) | reading (2.0) | comment and post body, message bubbles, dialog and notice body, toast, coachmark, terms, descriptions, input and textarea text, placeholder |
| body | 15/22 · 400 or 500 | reading (2.0) | single-line UI text: row line 2, Cell label and value, md and sm button label, tab label, section action, roster names, CTA helper |
| footnote | 13/18 · 500 or 600 | label (2.0) | Tag, eyebrow, field label, support line, relative day, links row; one line only |
| footnote, wrapping | 13/20 · 500 | label (2.0) | helper, caption row, meta that can wrap |
| caption | 12/18 · 500 | reading (2.0) | non-essential text only: chart ticks, counters, timestamps, legends |
| tab label | 12/16 · 500 or 600 | chrome (1.3) | tab-bar label |
| count | 12/16 · 700 | chrome (1.3) | digits in a count Tag only |
Minimums:
| Text | Minimum |
|---|---|
| reading text (anything people must read) | 16 |
| primary single-line UI text | 15 |
| informational labels (Tag, eyebrow, action, helper) | 13 |
| non-essential text | 12 |
| tab-bar label | 12 |
| digits inside a badge | 12, weight 700 |
| any Hangul | 12 |
- Weight: 400 only from 15 up; 500 or heavier at 13 and below.
- Line height: at least 1.5 wherever small text can wrap (16/24, 13/20, 12/18). 15/22 (1.47) is the body size and counts as met; 13/18 is a one-line role.
- Colour of text: ink for primary, secondary for meta from 13 up, tertiary only at 16 and up or for non-essential strings,
$errorTextfor red at 14 and below. - Hangul advance is 0.864 em: a 350 column holds 27 syllables at 15, 25 at 16.
- Weight ladder: 400 body · 500 meta and values · 600 headline and Tags · 700 figures and titles. No bold below line 1 of a row.
1.5 Heading spacing
| Element | Spec |
|---|---|
| Section head | min-height 28; title 20/28 · 700 ink; door a Door pill at the right (36 tall, 15/22 · 500 ink, a 16 chevron, 44 target); 32 above, 12 below to the first row |
| Day header | a 22 line box, no fixed height; date 15/22 · 600 ink; relative day 13/18 · 500 secondary at the right; 24 above, 12 below; no marker on the line |
| Step title (wizard) | 24/32 · 700, 24 under the progress bar |
| First heading under tabs | 24; the heading's own space above is replaced by the tab seam, never added to it |
| First heading under the Header | 12 (the page levels, 2026-10-20: 알림's rhythm made the rule); a 36 Choice or command overhangs into the 12, never above the Header; a pinned first head pads its top by the overhang instead |
- The space above a heading is about twice the space below, so the title belongs to its own rows. Peers measure 33.8 above and 16.8 below, ink to ink; this ladder gives 37 and 17.
- Why: lane A §2 (11 measured headings in 6 products).
1.6 Seams
| Seam | Rule | Value |
|---|---|---|
| Row to row (media 80, person 68) | no line; the 12 padding is the rhythm | none |
| Cell to cell (single-line 48 cells) | quiet hairline, inset to the rail on both sides, none after the last cell | cell tier #E5E5E5, 1 px |
| Cell group to a Section head or the next section | a Cell's 48 box carries 13 of air above and below its text; against a head and the next section that air is trimmed to the 12 a row list uses | −12 above the first Cell and below the last (FvkCellGroup) |
| Section to section | whitespace and the section title, no line; the last row's bottom padding is trimmed | 32 above the next section head |
| Zone change (hero to content, content to comments) | one full-bleed band, at most one per screen | #F2F2F2, 12 tall, 24 above and 24 below |
| Block or hero to content | spacing only | 24 |
| Tab row to content | spacing | 24 before a title or day header, 16 before a row |
| Sticky header or tab row | hairline only while content scrolls under it (see Sticky edges); a frame drawn scrolled draws its Header scrolled, and at rest nothing of the page starts above the Header's bottom (the verifier's under-header) | edge tier #D4D4D4, full-bleed |
| Tab bar and CTA band | hairline only while content scrolls under it; no shadow | edge tier #D4D4D4 |
| Dark mode | edge white α0.14, cell white α0.09; the band is darker than the canvas | band #000000 |
Two hairline tiers exist, edge and cell. Nothing else draws a line. Test: delete the line and keep the gap; if the two things still read as separate groups, there is no line. A line is allowed at a sticky or tab edge, between single-line cells of one group, and as the one band.
The rhythm canon (owner review 49). The seams above are the rule, and scripts/lib/flat-v2-rhythm-canon.json is their one source as data: one gap for each pair of neighbouring page blocks (177 pairs). Each block is measured the way the eye reads it: rows, Cells, Fields and chrome by their box (a row's padding is its rhythm), a Section head by its title line, anything else by what it paints.
- The verifier holds it. The
rhythmcheck fails a gap more than 2 off its pair's value. It also fails a pair the canon does not list, so a new component in a new place gets its seam decided once, in the canon, for every page, never by the page that first draws it. - The insert law. A block that appears on demand (a Notice, the filter Choice row, or anything a page marks
data-insert) has the same space above and below, a step of the ladder. That step is 12 when the block is first under the Header, the Tabs or a Section head, and 24 between other blocks. The verifier'sinsertcheck holds it. - Pages place no seams of their own. A page composes kit blocks, and any spacer between two of them carries the canon's value for that pair. Where a component's own space disagrees with the canon, the kit is fixed, not the page.
- App chrome has one place. The Offline strip is drawn by the frame (§ 8 Offline strip), never in a page's flow.
- Why. The owner wrote on 2026-10-29: "Whenever we introduce an on-demand component in the middle of two components that are appropriately spaced, we need to make sure there is still equal and canonical spacing above and below", and "We have a system for spacing and alignment. Please use it." Before the canon, 395 gaps on 59 pages were off their pair's value, and 119 on-demand blocks had unequal space around them (owner review 49 record, S1).
1.7 Colour (scheme S2, Ball & Ink)
† marks a derived dark value, accepted and held by the dark contrast gate.
| Role | Token | Light | Dark | Use |
|---|---|---|---|---|
| Canvas | $background | #FFFFFF | #0F1211 | page ground; cards and sheets use it too |
| Surface | $card | #FFFFFF | #1A201B | dark card and sheet |
| Ink | $text | #0D100E | #DFE4DC | text, controls, focus ring; the label on ball in both themes |
| Secondary | $textSecondary | #4F5851 (7.38:1) | #A9B3AB | meta, labels from 13 up, inactive tab-bar items |
| Tertiary | $textTertiary | #626C64 (5.46:1 on white, 4.87 on gray100) | #8F968F † (5.47:1 on the dark card) | text at 16 and up (placeholder), chevron, pencil and lock glyphs, non-essential strings |
| gray100 | none | #F2F2F2 | #363E38 † | tonal buttons, search and track fills, skeleton, empty-state disc, avatar ground, the band (dark band #000000) |
| Cell hairline | none | #E5E5E5 | white α0.09 | single-line cell groups |
| Edge hairline | none | #D4D4D4 | white α0.14 | sticky edges, tab rows |
| Control boundary | $borderControl | #8E8E93 (3.26:1) | white α0.40 † (3.83:1 on the canvas) | input, textarea and picker-field boundary; Switch off track |
| Ball | none | #D6F04A | unchanged | the one action (FAB, CTA, primary button), block figure, level accent; ink label 14.96:1; 1.28:1 on white, so a ball control relies on its label |
| Brand-weak | none | #EFF9B7 (ball at 40% over white) | derived † | 모집 중 Tag tint |
| Block | none | #15181A | #2A302B † | the ink block; on it: label white α0.8 (11.65:1), meta α0.85, break α0.4, track α0.2, figure ball (13.95:1) |
| Brand mark | none | #006D37 | unchanged | the mark and the grass court tile only |
| Success | $success, $successText | fill and icon #00695C (white on it 6.61:1), text #014B40, weak fill #CBEBE5 | text #75D1C6, dot and glyph fill #00796A | done and settled: paid, confirmed, a win (the result scale's win hue) |
| Warning | $warningFill, $warningText | fill #F5B301 with ink text, text #6B4A00 | text #E1B75C, fill #E2B13C | needs attention: a deadline, money owed, a task waiting on you (the colour canon) |
| Live | $live | #A7135E for the Live tag, the live dot, ring and status bar, and live text (white on it 7.25:1; 37 ΔE from the danger red under deuteranopia) | #EE82C0 (ink on it 7.83:1) | play happening now, its own colour off the error red (owner review 50); always with the word 진행 중 and its dot |
| Danger | $error | #A3261F for fills, borders and red text (white on it 7.36:1) | #FA9B94 | error, destructive, unread (the colour canon); a loss in the result scale |
| Error text | $errorText | #A3261F (7.36:1 on white, 6.57 on gray100): the same red as $error, kept as the text role | #FA9B94 | red text at 14 and below (field error, asterisk, danger counter) and the danger CTA helper |
| Info | none | #4B0082, weak fill #E8DCFB | #C9B2F2 | something waits on someone else: 승인 후 가입, the applicant's 승인 대기 (the colour canon) |
| Court | $courtHard, $courtClay, $courtGrass | hard #089AC3, clay #8D3D00, grass #83971F | #67D3FC, #DA875A, #BBD068 | court dot and tile (a category code; the venue name is always present) |
| Series | $series1 to 5 | #3566B8 #8D3D00 #83971F #883170 #089AC3 | #6C9EF1 #DA875A #BBD068 #D57CB8 #67D3FC | charts, calendar dots |
| Backdrop | none | ink α0.4 | black α0.6 † | behind a sheet or dialog |
| Photo controls | none | the one on-photo fill, ink α0.6 (α0.8 under Reduce Transparency), for the circles, the photo counter and +N; top scrim ink α0.35 to 0 over the first 100 | unchanged | MediaBand overlay |
| Inverse surface | inverse, onInverse (palette keys) | inverse ink #0D100E; onInverse white | inverse #DFE4DC, a light surface over the dark canvas (14.6:1 from it); onInverse ink #0D100E (14.8:1 on the surface) | toast, coachmark, tooltip: the fill, and the text and glyphs on it |
Usage rules:
- Ball is the screen's one coloured action (FAB or CTA), plus the block figure and the level accent. Every control state is ink or grey; the focus ring is ink.
- At most one Block per hub, and none inside a club page. The tab bar and scope tabs never carry brand colour.
- Every state has a non-colour second cue (weight, fill, glyph, chevron), and every colour code has a glyph or a label (WCAG 1.4.1).
- Inline links are ink and underlined.
- At most seven hue families per viewport. Brand is never a status; a status is never a category.
- Text is at least 4.5:1 on its real ground, and 6.5:1 at 14 and below (white on the danger fill is 7.36:1, on the success solid 6.61:1). A control boundary, a switch track and a state-bearing fill are at least 3:1 (WCAG 1.4.11). In dark a soft fill (gray100, brand-weak, success-weak, info-weak) is at least 1.5:1 against the page and against a card, text on one is 4.5:1 and never tertiary (owner 2026-11-15).
Result scale (the owner, 2026-10-20, on the activity grid: "three shades of colors - wins, losses or neutral"; the app's own rule, activityMetricTone in packages/app/src/presentation/utils/period-recap.ts, which colours a day on 기간 기록's calendar by its wins against its losses). Wherever colour shows a result, 승 takes the success hue, 패 the danger hue, and an even result (as many wins as losses, or draws alone) the neutral fill, gray100. A margin deepens it in three steps, a third, two thirds and the whole way from the neutral fill to the solid, so the deepest step is the success or danger solid of each theme:
| Theme | 패, 3 or more | 패 2 | 패 1 | Even | 승 1 | 승 2 | 승, 3 or more |
|---|---|---|---|---|---|---|---|
| Light | #A3261F | #BD6A65 | #D8AEAC | #F2F2F2 | #A1C4C0 | #51978E | #00695C |
| Dark | #FA9B94 | #B2756F | #6B4E49 | #363E38 | #3E605A | #5A9990 | #75D1C6 |
Adjacent steps are 17 to 22 L* apart, and a number or letter on a step is ink or the canvas, whichever reads better (4.93:1 at the least, ink on light's 패 2); scripts/__tests__/flat-v2-result-scale.test.mjs holds the solids, the spacing, the contrast and a day's step. Its users are the activity heatmap's days and the Form strip's cells (the solids). The first steps and the neutral fill sit under 3:1 on the canvas, so the scale never carries a result alone: a letter, a readout or a sentence carries each one (rule 3). Colour that compares (the heatmap's days, the Form strip's cells) shows both sides; a word keeps its own tones: in a Tag or a fact only 승 takes one (the ResultRow's outcome Tag in success weak, the Feed item's facts in the success text), and 패 and 무 stay neutral, since a loss asks nothing of the reader.
The colour canon (owner review 50; the owner, on 일정 상세 in play: "For sessions in progress, the status bar should take on the live color. Can we now do a full color pass on all pages to ensure we have a concrete grammar and every page abides by it?"). A colour is a meaning, and one table holds the meanings: scripts/lib/flat-v2-colour-canon.json, as the rhythm canon holds the seams (§ 1.6). The live state had been red on its Tag and purple on its bar, the info purple meant both "live" and "a task waiting on the host", and the danger red six things, because each page chose a tone.
- A page states a meaning, never a hue. A domain status is
domain.value(session.in_progress,dues.unpaid,sessionPayment.pending,recruitment.almostFull), and the canon'sstatusmirrors the app's status maps (status-variant-maps.ts), so a board can never colour a status apart from the app (scripts/__tests__/flat-v2-colour-canon.test.mjsholds the parity). Anything else is a UI meaning, each with its note in the canon: live (its own tone, owner review 50); error, destructive, unread, required, sunday and loss (danger); deadline, task, attention and shortfall (warning); waiting (info); done, win and online (success); open (brand). - The grammar in a line each. Live: play happening now (its own hue, apart from the red that means something is wrong). Danger: something wrong, something that removes or ends, unseen items. Warning: something needs attention, a task waiting on you, money owed, a time limit near. Info: something waits on someone else (승인 후 가입, the applicant's 승인 대기). Success: done and settled, a win. Brand: you can join. Neutral: everything else.
- In the kit. FvkTag, FvkNotice and FvkT take
meaning; a feed fact takes{ text, meaning }. A piece whose meaning is built in states it itself: a danger Button and a destructive Cell (destructive), the red count and dot (unread), the required mark (required), a field in error (error), the live ring, the Live tag and a live Notice (live; a live Notice leads with the live dot, never the alert glyph), Sunday (sunday), a record's lost points (loss), the rating change (elo.up,elo.down, as the app'seloDeltaTone). - Held by the verifier (
colour): a status hue painted with no meaning fails, a meaning painted in another tone fails, and a status tone set with no meaning fails. Colours that are a category or an illustration are exempt by name: courts, maps, photos, club marks, provider brands, faces, and the result scale above, which has its own test. - A new meaning is a line in the canon with its note, decided once for every page, never a tone on a page.
1.8 Medal set
One set feeds the glyph tile, the Podium rings and every medal use. Dark mode keeps the same fills, except platinum, whose dark fill is #56738B (3.01:1 or more as a mark on all three dark grounds; the white glyph 4.98:1). Light stays #4A6378.
| Tier | Fill | Glyph on the fill | Contrast |
|---|---|---|---|
| bronze | #B2580B | white | 4.89:1 |
| silver | #8E8E93 | ink #15181A | 5.47:1 |
| gold | #D9A00F | ink | 7.63:1 |
| platinum | #4A6378 (dark #56738B) | white | 6.27:1 (dark 4.98:1) |
An earned tile is the solid fill with its glyph; a locked or hidden tile has no fill: a 1.5 $border outline at the tile's edge (same radius) and the glyph, or the "?", in secondary. Earned is filled and locked is not, so they never look alike, and silver (a grey) is never read as locked.
The ladder (ornament in the glyph's own colour, inside the tile; each step contains the one before, so the tier is countable without colour): bronze is the plain tile; silver adds an inner frame (inset 4, 1.5, corner radius the tile's minus the inset) and one star centred on its top line; gold the frame and two stars; platinum a second 1 line at inset 7 and three stars, the middle one 1 larger. A star is five points, filled, 8 in the 56 tile and 6.5 in the 48, 11 and 8.5 centre to centre (platinum 9 and 7.5, so the run stays on the frame's straight top edge where it can); the line is broken 2 outside each star. The glyph stays 24 and centred. No tier word is printed. Numbers: thumb-visual.ts (MEDAL_ORNAMENT, ORNAMENT, medalOrnament), held to the kit's fvkMedalOrnament by thumb-visual.test.ts.
1.9 Radii
6 Tag · 8 thumb and photo tile · 12 button, club logo, notice, menu, toast, input, map face, provider button · 16 block, sheet, dialog · full avatar, pill, Choice, FAB.
1.10 Icons
| Size | Use |
|---|---|
| 24 | actions: header, tab bar, IconButton; the map pin |
| 20 | hero facts and inline glyphs: FAB icon, notice glyph, leading Button glyph |
| 16 | chevrons, edit and lock cues, the alert glyph in a field error or a danger helper |
Lucide, stroke 2, in the colour of the label beside it; header actions are ink (white over a photo or block). Inline glyphs beside text grow with it up to 1.5×; header glyphs, tab-bar icons and chevrons do not scale (§2.4). The Info hint's glyph is help (lucide circle-help), 20 in tertiary ink. A glyph the code draws is in the registry under its lucide name. The session 관리 sheet's doors take the code's (host-menu-actions.ts): 참석자 관리 users, 경기 규칙 scroll-text, 호스트 관리 layout-dashboard, 참석 확인 clipboard-check, 일정 종료 flag, 일정 취소 calendar-x, 일정 삭제 trash-2; 일정 수정 (pencil) and 참가비 관리 (card) are the design's, the code's host menu having neither.
1.11 Targets
| Control | Target |
|---|---|
| icon button (header, IconButton, tab-bar item) | 48 |
| primary full-width button | 52 tall |
| FAB | 56 |
| Switch | 52 × 32 track inside a 48 target |
| smaller visual (sm Button 36, Choice 36, Door pill 36 and 24, Tag, text action) | 44 floor by hit slop, (44 − visual) / 2 per side |
- Adjacent 48 targets sit with no gap (pitch 48); a trailing control never shares its row's press area.
- The WCAG 2.5.8 minimum of 24 is the absolute floor. A visual under 24 needs an equivalent control.
1.12 Motion tokens
Durations (ms):
| Token | ms | Used for |
|---|---|---|
pressIn | 80 | press dim in |
pressOut | 150 | press dim out |
exitFast | 100 | edge hairline, hover, menu out, toast out, Switch track, Choice and checkbox colour, FAB label out and hide |
fast | 150 | skeleton to content, scroll-linked header title, FAB label in, menu in, Notice dismiss, image fade, Switch thumb, wizard progress |
toggle, modalExit | 200 | chevron turn, FAB width, Notice height collapse; sheet and dialog exit |
entrance | 250 | RevealStack item, ExpandReveal, toast enter |
modal, slow | 300 | sheet rise, takeover rise; ProgressBar fill |
shimmer, pulseDot | 800, 1600 | skeleton half-cycle, live dot; both stop after 5 s per mount |
Nothing animates longer than 300 ms except loops and the platform's own push. Feedback starts within 100 ms of the touch.
Easing: standard cubic-bezier(0.2, 0, 0, 1) for anything that stays on screen · enter (0, 0, 0, 1) for things entering · exit (0.3, 0, 1, 1) for things leaving · emphasizedEnter (0.05, 0.7, 0.1, 1) held in reserve for the sheet rise.
Springs: pressIn {damping 30, stiffness 400, mass 0.7} · pressOut {26, 300, 0.7} (ζ 0.9, no visible overshoot, settle about 196 and 226 ms) · navSlide {22, 280, 0.8} (settles in about 300 ms). Effects (opacity, colour) use timing and never overshoot.
Named animations: fade (timing 150, standard) · fadeFast (100) · sheetIn (300, enter) · reveal (250). The Tamagui springs quick, medium and slow drive spatial motion only.
Animate transform and opacity; a layout property only on a single element (the FAB width). At most 100 animated components at once.
1.13 Leading-marker rule and inline adornments
| # | Rule |
|---|---|
| LM1 | Keep-out: only a full-bleed surface (photo, band, hairline, block fill) enters the margin. No dot, glyph, Tag or text enters the margin on either side. System chrome that spans the frame in equal items (status bar, tab bar, keyboard) keeps each label inside its own item instead. |
| LM2 | A leading marker takes the first slot of the row, starting on the rail. Slot = marker + gap: dot 8 + 8 = 16 (content edge x 36); checkbox 24 + 12 = 36; rank column 24, 32 or 40 + 12. |
| LM3 | The slot is reserved on every row of a list that can show the marker, so all text edges match. A list that never shows one has no slot. In Select mode every row's slot is the checkbox slot and the dot is hidden. |
| LM4 | Headings take no slot. Day header, section head and page title stay on the rail; a mark, if ever wanted, follows the date by 8. |
| LM5 | A row marker is an 8 circle, unread in $error. A dot that rides an icon or a tab (tab bar, header badge) is 6. Never a ball dot. |
| LM6 | The marker attaches through the line slot (IA1): it centres on line 1, not on the row. Its top sits (line − 8) / 2 below the text-box top (7 for 15/22, 8 for 17/24; a computed offset, exempt from the 4pt grid), and the slot follows the scaled line, so a three-line row keeps the marker beside line 1 at every text size. |
| LM7 | Order: [marker 8][8][avatar 40][12][text] (text edge 88) and [marker][8][thumb 56][12][text] (text edge 104). Presence is a different marker: a 10 dot on the avatar's bottom-right corner with a 2 canvas ring. Counts stay trailing. |
| LM8 | The dot is hidden from assistive tech; the row's one name begins "읽지 않음, …". Under text scaling the dot and gap stay 8 and follow line 1's centre. No announcement when the dot clears. |
Cost: 16 of text width on rows that carry a marker (4.6% of 350).
Inline adornments. Everything that sits beside text and is not text (a Tag, a count, a Level tag, a dot, a glyph, a spinner) attaches the same way, so none of them carries a hand-set offset.
| # | Rule |
|---|---|
| IA1 | An adornment beside a label attaches through one primitive, the line slot: a box exactly as tall as the label's first line that flex-centres the adornment and anchors to the label's top. The row aligns its children to the start, never to the centre, and no site adds a margin. |
| IA2 | The slot's height is the label's own line at the current scale: 22 for 15/22 (29 at 130%, 44 at 200%), 18 for 13/18 (23, 36). The adornment's top is (slot − adornment) / 2 from the label's top, negative when it is taller: it overflows and never grows the row (a 24 Tag beside a 22 line pokes out 1 above and below). A row that must hold the taller adornment sets its own minimum height. |
| IA3 | For a label that wraps, the slot stays at the top: the adornment stays beside line 1 (a Tag after a name that wraps to two lines sits beside the first line). |
| IA4 | Sizes by scale: a glyph beside text grows by min(scale, 1.5); the count Tag and the Level tag grow with their numeral (box = ceil(20 × min(scale, 1.3) / 4) × 4 and ceil(24 × min(scale, 1.5) / 4) × 4); a dot never scales. The slot follows the label, so no adornment needs an offset. |
| IA5 | A corner adornment (a badge on an icon or a tab) is not inline: it anchors to its host's top-right corner with fixed offsets (Header icon badge). |
| IA6 | An adornment that centres on its container by design (the trailing glyph of a row that centres its trailing control) is marked box-centred, and the board check asserts that it is centred on its parent's box. An exemption is asserted by geometry, never granted to a name. |
| IA7 | The law of the chevron (review 4, D1). A right chevron is the suffix of a text that names where it goes: a Door pill ("전체 보기 ›", a Block's door "내 일정 ›"; owner review 48), a Cell whose label names the place, or the pager's icon button. It never stands alone and never sits at a container's edge. A row that opens the item itself (a match, a session, a person, a thread, a document) carries none; a fold uses its own up and down glyphs. The board check chevron fails a chevron outside those hosts, inside a Block's edge lane or alone in a row. |
| IA8 | The law of the command (review 6, D1): a command is a Button. Anything that does something (saves, sends, marks, stops, retries, clears) is an FvkButton, ghost when it should be quiet (sm 36 in a head, a notice or a bubble; its 12 of padding bleeds so the label sits on the rail). A text action only goes somewhere (kind="link", the default: "전체 보기 ›", a Block's door, 이용약관). A sentence or a question is never a control. The one exception is the Toast's action (되돌리기, 처음부터): the snackbar convention puts a text action in the bar, so kind="command" is legal there and nowhere else. Disclosures (답글 n개 더 보기, ExpandToggle) expand in place and are not commands. The board check text-command fails a command-kind text action outside a Toast, and ships a canary. |
Text beside text of another size shares the baseline instead (a Section head's title, its count; a glyph or a control beside that head, such as its Door pill or its Choice, still takes a line slot). In code: a LineSlot beside GlyphSlot in packages/ui (its height is the label's line height times min(font scale, the class cap)), and a Badged host in place of CornerCountBadge.
Why: review 3, lane A §2 (a hand-set offset is right at one text size only; the slot is exact at 100, 130 and 200%).
1.14 Page grammar (approved 2026-10-16; owner reviews 30–43)
Job. The order of a flat page and what it does while it scrolls, so every hub, list and detail page is composed the same way and its scroll behaviour follows from its parts. Drawn on the hub board, section "Page grammar". The dated reasoning is in the rulings of reviews 30–43.
Zones, top to bottom. Every page uses these zones in this order. A page may omit any zone except the Header.
| Zone | What it is | On scroll |
|---|---|---|
| Header | 56, below the status bar | Always pinned. Solid on a tab root; transparent over a MediaBand until the band leaves (MediaBand); over a detail hero its title fades in (Hero) |
| Lead | One bold surface that answers the page: a Block (홈, 기록, 프로필, and the 회비 roster; three rows, §4 Block) or a MediaBand with its hero (클럽 홈, 장소, 일정 상세). Complete in itself: nothing hangs under it, and it absorbs its own detail (기록's 30-day line is inside the ELO Block, not a chart under it) | Scrolls away |
| Panes | Tabs, only when the page changes as a whole by pane | Pin under the Header once they reach it |
| Modules | 0 to 3, each one question in the form that fits it (the Feed's own row, a Strip, faces side by side, a Badge shelf, rows for a ranked list). Bounded: three rows at most (the preview law) or one fixed graphic. A fixed order per page; absent when empty; two neighbouring Modules never share a form | Never pin |
| Feed | At most one, always last: the page's open-ended list (소식, 경기 기록). Its head carries the title and the filter Choice; no count and no 전체 보기, because it is the whole list. A Feed of mixed kinds uses the Feed item (§5); a Feed of one kind uses that kind's own row (경기 기록: ResultRow) | Its head pins under the Header when the page has no Panes; it loads the next page a screen before the end (skeleton rows) and ends with one end line and a door |
| Bottom | The Tab bar on a tab root, the CTA band on a detail page or form (a non-member's join action); the FAB above it | Pinned. The FAB collapses after 120 down and expands after 24 up (FAB) |
The pinned stack.
- The Header is always pinned, and at most one context layer pins under it: the first that applies of the pane Tabs, the current day header of a day-sectioned Feed, and the Feed's head.
- Panes outrank the Feed's head. On 클럽 홈 the Tabs pin and the 소식 head scrolls away; to change the filter, scroll back to it.
- The lowest pinned layer draws the edge hairline (Sticky edges); nothing above it does.
- When the Feed's head is the pinned layer (a page with no Panes: 리더보드), it is the lowest layer, so it draws the hairline and the Header none.
- The Lead, the Modules and the Feed's items never pin.
- Why one layer: the header 56, one 48 layer and the tab bar (64 + inset) already take about a quarter of an 844 screen; each further layer costs a row of content on every screen. On 경기, where the Tabs pin, day headers stay in the flow.
Scrolling.
- One vertical scroll per page, never nested. Horizontal scrolling only in a Shelf (its Card, Tile, Cell and Badge presets; 업적 is a Badge shelf), or in a Tabs row that passes the width.
- Pull to refresh at the top. Re-tapping the active tab scrolls to the top.
- Restoration: a hub returns at its last offset with its Feed's loaded pages. Switching panes while the Tabs are pinned lands at the top of the new pane, under them.
- Live mode has no Tab bar; its page follows the same zones (§10, principle 7).
Composition rules.
- Panes own the zones below them. On a paned page, the Modules and the Feed belong to each pane; the Lead and the Panes are the page's.
- A scope that changes one question belongs to that Module, not to page Tabs (기록's 클럽 · 친구 · 지역 · 전국 is 랭킹's Choice). 대진표's round is the 경기 Feed head's Choice, and the board-wide 할 일 stays above that Feed for every round. A filter that scopes the whole page sits on the page's first head (기간 기록's 필터 on 요약: it scopes the record, the 활동, the 상대 and the list, as the app does; 선수별 전적's format); a Feed's own filter sits on the Feed's head and narrows that Feed alone.
- Form follows the question: a ranked list is rows, people are faces side by side, achievements are a Badge shelf, figures are a Strip; from 1.5× text the side-by-side forms reflow to rows.
- A page holds only what is its own. 프로필 has no match Strip or match list (they are 기록's); 클럽 홈 has no 사진 Shelf (the MediaBand is the album). Analysis and rarely used tools sit one door away (기록 › 분석: bests, formats, 연습 기록).
- On a hub the Feed follows the Modules with the section head's own air (32) and its head; no Band. The Band stays for the change from content to discussion on a detail page (§4 Band).
- A Feed of mixed kinds (§5 Feed item): one layout for every kind; no reactions on an item (the footer shows the counts; reacting and a post's commands are on its page or a long press); nothing addressed to the viewer (that is the bell's job); chronological by posted time, except 일정, which runs by when the session happens; more than three items in a row from one source collapse. A list of clubs, sessions, venues or 대항전 is a list of one kind that also uses the Feed item, with a who row (§5; proposed, review 46).
- 알림 keeps no 할 일 list; its filter has 처리할 일 (the owner, 2026-10-20: "I don't think we need to have a separate todo list. We can add options in the alerts drop down filter ... to only show oustanding issues"). The filter on 알림's first head offers 전체, 안 읽음 and 처리할 일: the alerts that ask the viewer to act while their request is open (입금 확인, 가입 신청, 사유 인정 요청, 신고, a pending 친구 요청, a 참가비 입금 that is due), in the same time sections; a row leaves that view when its request is settled. 모두 읽음 is that head's command, a tonal sm Button. 홈 previews three 할 일 rows, and its 전체 보기 opens 알림 at 처리할 일. 할 일 is state: reading never touches it.
- A pane may open with a Block of its own when it has one answer for the whole club or for me: 기록's season (§4 Block, season) and, for a member, 회비's open period (§4 Block, dues, mine). The Block follows the three-row law; the page's Lead stays the MediaBand and hero, and the pane's Modules follow under their own heads.
- A detail page's 정보 is the object's own facts, not a preview. One Cell group under one head, a Cell per fact, as long as the object needs, counts as one Module; the three-row law bounds a section that previews a list of its own (Section head, Preview). Facts that share a page stay in that one group (장소 상세: 코트, 요금, 운영 시간, 예약, 편의시설), never a run of headed Cell lists.
- A fact a person keeps private drops its row; a club is not private. 공개 프로필 greys and locks nothing: a fact its person keeps private is not drawn, and the photo is the initials. The private fields are the photo, 지역, 구/군, 활동 지역, 성별 and 출생연도 (설정 › 프라이버시); the primary club is always visible (migration 00503), so the club Cell stays. A stranger (no shared club, session or friendship) sees the initials too, unless the person opted in to
public. - A list creates with its FAB; the CTA band belongs to a detail page and a form (Bottom). 연습 기록, 테니스장 관리 and the proposal lists add an entry with the FAB, as 경기 and 일정 관리 do. The FAB's label is the noun of what it creates and its spoken name adds the verb (매치 → 매치 만들기, 연습 → 연습 추가, 테니스장 → 테니스장 추가, 장소 추가 → 없는 장소 추가, 소유권 신청); a pushed list places it 16 above the home inset, a tab root 16 above the Tab bar; its Toast pins above it (
fab-plain). - The Feed's own head has no count; the heads that section a Feed (a role, a month) may carry their group's count. A post's 댓글 head is one: the post's footer already says how many. A state of the list rides the head's note, never a count of it (대진표's round with held matches: 시작할 수 있는 경기 3 | 대기 2).
- Pages with no Header: the 시작 landing, the version gate (EmptyState, Full screen) and the web pages a shared link opens outside the app. Every page inside the app keeps the Header, and a pushed page's title lives in it, never as a heading in its content.
- A ranking page's scope is its Feed head's Choice, the same values as the Module that opens it (리더보드: 클럽 · 친구 · 지역 · 전국, as 기록's 랭킹), never page Tabs; a figure that describes the scope (지역's tiles) sits above the head.
- A head holds one Choice. The Feed's head carries a single Choice, the scope or the filter. A second control of the same Feed (리더보드's 기간) is a group of that Choice's own value Sheet (the Sheet titles its groups, 대상 and 기간), not a second Choice; the Choice goes ink while a non-default group is set, and the head's note says what it filters ("이번 달 경기한 멤버만 보여요"). On a discovery list that one Choice is 필터 ▾, and the filters that are on are not more controls of the head: they are the removable Choices on the row under it, drawn only while one is on (FilterSheet).
- A queue of requests is one Module above the Feed (친구 목록's 친구 요청): at most three rows, the received first, each answering in its row, then the sent (their time, "어제 보냄", and 요청 취소); past three the head shows the total and 전체 보기 ›, which opens the whole set in the same order. The weekly work stays on the page; a door Cell with the count would put the answer pair one tap away (§ 1.15, rule 2). The Feed under it is the friends alone, its head with no count.
- A search over a tab with panes lists every match as one list of mixed kinds (경기's header search): the Feed item, each row's overline naming its kind (매치, 테니스장), no group per kind with a 전체 보기 each. It loads as it scrolls and ends like any Feed.
- One grammar for every discovery list (the owner's page review, 2026-10-27: 클럽 › 탐색, 경기 › 예정, 매치 찾기 and 테니스장, and 기록's 경기 기록). Search is the Header's icon at the top right, and no list draws a field inline. The filter is the list's first Section head's one control, 필터 ▾ at rest and 필터 N ▾ in the selected look while N are on. The filters that are on show as one row of removable Choices directly under that head, and only while one is on. A filter that is on by default is on like any other and shows in the row (테니스장's 가까운 순, 매치 찾기's saved region). 클럽 › 내 클럽 has no search, because the app searches only the clubs a member could join; 기록's 경기 기록 has no search (its Header holds 공유), and its filter sits on the Feed's head. The facets, the groups and the reset are FilterSheet's.
Zone states. Each zone owns its loading, empty, error and offline state, so a slow or failed part never blanks the page:
- Lead: a skeleton of its own shape while loading.
- Modules: absent when there is nothing to show; an empty Module is never drawn, except a Module whose scope the viewer chose (기록's 랭킹 under 친구 with no friends), which keeps its own compact empty state and action.
- Feed: skeleton rows of the Feed's own row while a page loads, never a spinner; one line and a door when it is empty; an inline retry line when a page fails. Its end: once the last page has loaded the Feed ends with one end line, a Caption row (attach object, 8 above) that says what ended or what bounds the list ("예정된 일정은 여기까지예요", "순위는 상위 50명까지 보여요", "30일이 지난 알림은 사라져요"), and under it, 20 below, its door: a task Cell that names where to go on (예정 › 매치 찾기, 매치 찾기 › 테니스장 찾기, 장소 정보 제안 › 소유권 신청 내역, 테니스장 › 없는 장소 추가 and 제안 내역). A list with no place to go on ends with the line alone (소유권 신청 내역; 리더보드, whose rows open profiles and whose head holds 티어 안내 ›); an empty list keeps its door without the line; a list that has not loaded has neither. A door never repeats the page's FAB: a list creates with its FAB, so 매치 찾기's door is not 매치 만들기. The 40 under the door is the page's tail.
- The page-level EmptyState only for a cold start with nothing loaded (offline or error).
Large text. From 1.5× any multi-column Module reflows to one column (a tile grid one tile a row, side-by-side faces and badges rows; a Strip uses its own two-up reflow), and a Block's graphic drops under its figure.
Per page.
| Page | Lead | Panes | Modules | Feed |
|---|---|---|---|---|
| 홈 | the Block: your next game | none | 할 일 (when any work waits: a Feed item per kind of work, the sum in the head's count, §5 Feed item) | 소식: 일정, 결과 and 게시글 from your clubs and interests |
| 클럽 홈 | the MediaBand (the album) and hero | 홈 · 채팅 · 기록 · 멤버 · 회비 | 홈 pane: 홈 코트, a Section head over the venue's MediaRow with its map (it opens 장소 상세; absent when the club has none) | 홈 pane: 소식, everything the club does, sessions included; a non-member: the same page, no Panes, public items only |
| 기록 | the ELO Block, with its 30-day line | none | 전적, 랭킹, 파트너와 천적 | 경기 기록, your matches (ResultRow; its head carries 필터 ▾, the app's four facets 결과, 종목, 경기 유형 and 출처, and the filters that are on show as removable Choices under the head) |
| 프로필 | the identity Block | none | 신뢰, 친구, 업적 | none: your matches are 기록's |
| 공개 프로필 | the person's hero (§1.17, L2: FvkClubHero kind="person", no band: the face, or the initials where the photo is not the viewer's to see; the name; NTRP and 구력 as its facts), then the person's club as a nav Cell under it (a person in several clubs has still one Cell, the first club and 외 N곳, which opens a Sheet of the clubs) | none | in 프로필's order: 신뢰 (the score Cell, 신뢰 점수 72점; for a club-mate, a session-mate or a friend its evidence on line 2), 업적 (a Badge shelf), then 전적 (the match summary for other viewers: the Strip, 총 경기 · 승률 · ELO, then 나와의 전적 when you have played together), so 신뢰's tile and 전적's Strip never neighbour | none |
| 경기 | none | 예정 · 매치 찾기 · 테니스장 | none | each pane is a Feed, and the Header's search icon (top right) searches all three; 예정, your own sessions by day (the Feed head 내 일정 carries the pane's one filter, 필터 ▾: its first group 보기 is 전체 or 저장됨, the sessions you saved, then the same eight facets as 매치 찾기; 저장됨 shows as a removable Choice under the head while it is on); 매치 찾기, every open 매치 (the pane with the FAB; its head carries 필터 ▾, and the member's saved region is on when the page opens); 테니스장, the venues nearest first or by province (the first head carries 필터 ▾; 가까운 순, on by default once the location is granted, and 저장됨 are filters of its 보기 group, so the pane has no control row; the recent searches are on the search page) |
| 알림 | none | none | 할 일 (the viewer's own, whole; absent when empty) | the last 30 days in three time sections: 오늘, 이번 주, 이전 알림; the first head carries 모두 읽음 and the filter Choice |
프로필's 신뢰 (owner review 46, M6; the owner's page review, 2026-10-27). 신뢰 is its score, its facts and one door (owner review 48: the tiers became 신뢰 점수). The head names the score as its count (72점; none before five sessions), and its one control is the door 내 신뢰 기록 ›. Directly under the head stands a private Notice, only when there is something to do; then three tiles (§4 Trust). There is no Task Cell and no records section of its own, and 점수 안내 is not on this page: it is inside 내 신뢰 기록.
Settings page: 알림 설정 (the forms pass, 2026-10-19). A settings page has no Lead, Panes, Modules or Feed: the Header, then Cell groups under Section heads. 알림 설정 is the one place for every push, in this order: 조용 시간, a value Cell (22:00~08:00) that opens the time range wheel, whose second line says pushes stay silent then and names the exception, the payment pushes (입금 알림); the head 푸시로 받을 알림 over one toggle Cell per family, each with its one-line description: 일정, 경기, 회비와 참가비, 클럽, 사람, 시스템 (the 13 push categories regrouped; never 13 rows), then 메시지, push only (chat stays out of the 알림 inbox, so its line says it comes by push alone); the head 클럽별 알림 over one value Cell per club whose value is that club's level, 전체 / 공지만 / 끔, which opens a value Sheet. A Switch writes at once, so the page has no Save, and 설정 keeps one door to it. The kit holds the families and their lines (FVK_NOTIF_FAMILIES). Example: 회비와 참가비 reads "회비 납부, 입금 확인, 환불" and its payment pushes ignore the quiet hours; 강남 라켓클럽 set to 공지만 sends only that club's 공지. The 클럽별 알림 head carries the note "클럽 소식을 푸시로 받는 범위예요"; a club's level Sheet is a value Sheet titled with the club's name, each level with its meaning on line 2: 전체 "클럽 소식을 모두 푸시로 받아요", 공지만 "클럽 공지만 푸시로 받아요", 끔 "클럽 소식은 푸시로 받지 않아요". A level governs the club's low-severity pushes (review 46, D5); a person with no club has no 클럽별 알림 section. The quiet-hours Cell's line names the exception: "입금 알림만 빼고 이 시간에는 푸시가 오지 않아요". 설정's own 알림 group is that one door and nothing beside it: the switches that sat there move here (푸시 알림 becomes the page's first Cell, a master switch that greys the families while it is off; 일정 리마인더 is the 일정 family's 시작 전 안내; 알림에 메시지 내용 표시 sits under 메시지, shown while 메시지 is on). While 푸시 알림 is off no push comes at all, so everything under it is inert (.5) and keeps its choice: the families, 메시지 내용 표시, 조용 시간 and the club levels (the lead's call, 2026-10-20). 알림에 메시지 내용 표시 is a plain toggle Cell, off by default, the last Cell of the families group.
1.15 Navigation (owner review 46, M28)
Job. Which page opens which, so the pages of a club and of a pickup are reached by rules and not by memory. The map is docs/workflows/page-links.json, drawn at wireframes/page-map.html and checked by the board build (the cohesion pass, 2026-10-25); each link is drawn on the page that holds its row.
Rules.
- Five panes at most, no sixth. 클럽 홈's strip is 홈 · 채팅 · 기록 · 멤버 · 회비, one list in the kit (
FVK_CLUB_PANES) that the hub, list-a and the forms board read. - Weekly work is never only in a sheet. It has a row where the person already is: the FAB (만들기), a 할 일 row (가입 신청, 입금 확인), a pane's first row.
- Monthly work is the 관리 sheet plus a pane-end Cell with its count (멤버 관리 at the end of 멤버, 회비 관리 with its count at the end of 회비).
- Rare work lives in 설정. The 관리 sheet has two groups from one registry (
FVK_ADMIN_DOORS, drawn byFvkAdminSheeton the hub, list-a and the forms board, so no board lists a door): 운영, the work that comes back (멤버 관리, 일정 관리 with the count of 입금 확인, 회비 관리 with its count, 출석 현황, 신고 관리 with its count), and 설정, the rare work (테니스장 관리, 클럽 대항전, 성장 분석, 클럽 설정); then 클럽 나가기. The group titles show while two non-destructive groups show (§8 MenuSheet). The session page's gear opens the same sheet from a second registry (FVK_SESSION_DOORS,FVK_SESSION_GROUPSandFVK_SESSION_FLAGS, drawn by the sameFvkAdminSheet, so the detail board lists no door either): 운영, with 일정 수정 first (the host's; it edits the page in place, so it keeps its pencil and has no chevron), then 참석자 관리, 경기 규칙, 참석 확인 with the count of the rows still unanswered (a neutral Tag), 호스트 관리 with the applicants waiting (the host of a session that takes applicants) and 참가비 관리 with the 입금 to confirm; then the ends, 일정 종료 · 일정 취소 · 일정 삭제, the host's alone, each asking its Dialog. An official has the tools and no ends. - Every object a page names is a visible row that opens it. Never an image tap and never a chip that presses nothing: a venue is a row (홈 코트's MediaRow, 장소's Cell), a person is a row or a Cell with a chevron, a club is a row, a session is a row. The viewer is not a door to themselves: on the host's own 일정 상세 the 호스트 fact stays plain (their profile is the 프로필 tab). A club or a venue named in a hero's eyebrow becomes a row under it, so an eyebrow is only ever a label (§5 Hero).
- Pages used one after another share a switcher (§3 Tabs › Switcher).
- A page is never a dead end. It has a row that goes on: 출석 현황 ends in the Cell 멤버 관리; a 클럽 대항전 row opens the club it names; the 앨범's group heads open the upload's 일정 or 게시글; a 회비 row's menu opens 클럽 기록 보기; the 순위 peek's named people and its two doors open 프로필 and 나와의 전적.
Pairs that are mutual. 클럽 홈 → 장소 상세 (the 홈 코트 section's row) · 일정 상세 ⇄ its club (the eyebrow becomes a row: the first Cell of the 정보 group, none for a pickup), its 장소 (a nav Cell under the hero, the door alone: the hero's pin fact states the address; the map opens it too) and its host (a nav Cell, plain on the host's own page) · 매치 미리보기 carries the same Cells (장소 and 같은 장소 매치 under the hero, then the club of a club's public session and 호스트); a host with applicants waiting sees the Notice 신청 N건, which opens 호스트 관리 · 장소 상세 → its next sessions and open 매치 (three, then 전체 보기), 매치 만들기, and the clubs with it as their home court; the club's court and the directory's venue are two views of one place and each offers the other as a Cell · a member row or a 순위 row → 공개 프로필 → 나와의 전적 (a nav Cell under the tiles; 선수별 전적 filtered to that person) · 클럽 홈's 회비 → 참가비 내역 · 참석자 관리 ⇄ 참석 확인 · 멤버 관리 ⇄ 출석 현황 (the Task Cell 출석 주의 N명 → 출석 현황; the pane-end Cell 멤버 관리 back) · 클럽 설정 ⇄ 성장 분석 (성장 분석 ends in the Cell 클럽 설정, where the 모집 계획 it measures is set, and in 멤버 초대) · 공개 프로필 ⇄ its club (a nav Cell under the identity Block; a person in several clubs has still one Cell, the first club and 외 N곳, which opens a Sheet of the clubs, each row opening its 클럽 홈) · 제안 접수 내역 and 장소 소유권 → their 장소 (a nav Cell under the hero, the door to 장소 상세). 나와의 전적 opens 선수별 전적 filtered to that person and titled as its door is, 나와의 전적: the person's row first (it opens 공개 프로필), the two records as tiles (같은 편 and 상대, each with its rate; under 단식 the 같은 편 tile leaves), 최근 5경기 with them, then the matches together by day; back returns to the page that opened it (the lead's call, 2026-10-20).
Prevention. An edge list generated from the routes.* call sites fails on a route with no inbound link (/courts today) and on a pair above that is not mutual.
1.16 No meta lines (the owner, 2026-10-20)
The owner's words: "We generally never put meta data unless an error occured during an input process." Their examples: "무승부는 승률에 넣지 않아요" under a Strip, and "납부 문의는 총무에게 연락해 주세요" under 입금 완료 알리기.
Rule. A page shows its data and its actions, never a line about them: no footnote under a figure, a group or a Strip; no line beside a CTA; no hint or note under a field; no section note or page line that says what something is, how it is counted or ordered, who sees it, what happens next or who to ask; no legend ("* 필수 항목"). The one support line is an input's error, once it occurs, and its text carries the rule the input broke.
Not meta lines, so they stay.
- A state that stands in for content: an Empty state (an empty Module's own line included), an error or offline state, a Notice that is the page's state, and a shortfall the numbers imply that asks for action (성장 분석's 정원보다 3명 부족해요; its all-clear line leaves).
- A Dialog's body: it is the question.
- A choice's own meaning: a radio row's second line defines its option.
- Data the page or its action is about: the total a press commits (money, as 전자상거래법 requires), a live total or count of what the person picked or marked ("합계 90,000원", "미응답 2명", "3명 중 1명"), the price of joining on a club's join band, and a section's own figures when no row holds them ("1인당 20,000원 | 매월 5일까지").
- A provider's attribution its terms require (네이버 제공) and legal text counsel holds verbatim (consents, terms).
- An action's failure (the CTA band's danger helper).
Instead. An instruction the input cannot do without is its label ("“탈퇴합니다”를 입력해 주세요"); a format is the error's text when it is broken; a measure is the title ("최근 20회 출석", "7일 상승세"); a consequence that changes the decision is the confirmation Dialog's body; a term with its own explainer keeps its "?" (Explainer trigger). A list's bound is its head's count (상위 50명), and a list's end draws no line: the Feed end keeps only its doors (the owner, 2026-10-28, on "클럽의 테니스장은 여기까지예요": "We probably don't need meta data like" it).
Prevention. The board lint's meta-line rule fails a Caption row, a Section head note, a field hint and a helper (a CTA band's, a Sheet footer's, a band object's) on the seven page boards; what stays carries a visible pragma with its reason (// kit-only: allow meta-line -- …), so every exception is listed and none rots.
1.17 Page levels (the owner, 2026-10-20)
The owner's words: "I want there to be a consistent grammar across pages across the hierarchy." Every page of the signed-in app belongs to one of four levels, and each level has one grammar (§1.14's zones, filled one way); the pages before sign-in and the public previews are a fifth, L0 Entry. A page's level is its kind, not its depth: 알림 is one tap from 홈 and a detail page; 공개 프로필 can be three taps deep and is an object page.
| Level | Pages | Header | Lead | Panes | Then | Bottom |
|---|---|---|---|---|---|---|
| L1 · Tab root | the five bottom tabs: 홈, 클럽, 경기, 기록, 프로필 | the tab's title and its icons | one Block, the screen's answer; or the Tabs, when the root switches between views (경기) | only in place of a Block | Modules (bounded: three rows and 전체 보기), then the one Feed | the tab bar, and a FAB where it creates |
| L2 · Object page | one thing opened from a row: a club (클럽 홈), a session (일정 상세, 매치 미리보기), a venue (장소 상세), a person (공개 프로필) | back, over the hero | the object's hero: a photo or map band and its identity (name and facts) | only when the object has views of its own (클럽 홈) | Modules, then its Feed | the object's one action (the CTA band) or a FAB |
| L3 · Detail page | a list, a record or a task of something: 알림, 리더보드, 회비 관리, 참석자 관리, 내 신뢰 기록, 멤버 기록, 성장 분석, 기간 기록, 선수별 전적 | back and the page's title | by its family, below: a summary's Section head over the Strip, tiles face; a list at once; a task's row; a live row; a text | none: one exception, two sibling pages of one object used one after another share a switcher (참석자 관리, 참석 확인) | Modules and its lists | its one action, when it has one |
| L4 · Form | a wizard, an edit page, a settings form (클럽 만들기, 일정 만들기, 프로필 수정, 알림 설정) | back or close, the form's title, the step | none: the first question | none | fields and Cells in groups (§7, WizardShell) | the CTA band |
- No Block below L1, but for the person's (owner review 50, 51). A Block is a tab root's answer; the exceptions are Block, identity, which leads every page about a person at any level (공개 프로필, 가입 심사, 신뢰 기록's officials' view), so a person reads the same everywhere, and a match in play, which is its Match state block wherever it shows (홈's Lead, 스코어카드's courts, a venue's 진행 중), since a match in play is its own door and reads the same everywhere. The board verifier's grammar check holds every page's canonical frame to this (block-level), to the summary family's headed Strip (strip-head) and to § 1.22's group order (form-order). An object page's hero leads it, and a detail page sums up with tiles (no Block inside a club page, the owner, 2026-10-20; 회비 관리 leads with tiles for the same reason, the owner, 2026-10-21). A member's dues are 클럽 홈's 회비 pane: 내 회비 folded into it (the owner, 2026-10-21), so a 회비 push or door opens 클럽 홈 at 회비.
- No hero below L2. A detail page starts under its header; a Hero (detail) never leads one. A person's hero (공개 프로필) is the person's Block (Block, identity), with no band (owner review 50).
- No Tabs below L2, but for the one switcher between sibling pages. A detail page's views are its one filter, the Choice in its first Section head (알림, 일정 관리, 장소 정보 제안 내역); a set held apart is a door row on top only when it must stay apart (메시지 요청: a message not yet accepted stays out of the list); what merely waits leads the list (§1.20).
- Overlays (Sheet, Dialog) are not pages: they belong to the page that opens them.
Families. Pages of one level are compared within their family, which fixes the lead:
| Level | Family | Lead | Pages (examples) |
|---|---|---|---|
| L0 | Entry | the brand lockup, or one reading line; a public preview leads as the object it previews (L2) | 시작, 온보딩, 공유 링크 |
| L3 | Summary | the Section head over the Strip, tiles face; then a one-line alert of numbers when something needs attention (FvkNotice, a title of figures: 불참 1/2건 | 2027년 9월 5일 해제); one line of progress when tiles would only restate the heads below (업적) | 내 신뢰 기록, 멤버 기록, 회비 관리, 기간 기록 |
| L3 | List | the list at once; its one filter is the Choice in the first Section head; what waits leads the list as its first group, in the list's own rows (멤버 관리's 가입 신청; owner review 48) | 알림, 메시지, 친구 목록, 일정 관리 |
| L3 | Task | the one thing to act on, as its row (a person, a venue) with its facts; then its records; its action is the CTA band | 가입 심사, 제안 접수 내역, 장소 소유권, 참석자 관리 |
| L3 | Live | the live row, then the match | 스코어카드, 대진표, 토너먼트 |
| L3 | Reading | the text | 게시글, DM 스레드, 이용약관 |
| L4 | Wizard | WizardShell, one question at a time (§7) | 일정 만들기, 클럽 만들기 |
| L4 | Form | fields in groups, and the CTA band; a form about one thing names it first as its row (클럽 가입하기's club, 소유권 신청's venue; the owner, 2026-10-21), never its hero | 글쓰기, 프로필 수정, 회비 설정, 클럽 가입하기 |
| L4 | Settings | Cells in groups; a Switch writes at once, so there is no band | 설정, 알림 설정, 클럽 설정 |
Exceptions (each one named, so no other page copies it): 참석자 관리 and 참석 확인 share a switcher (two sibling pages used one after another); 스코어카드 keeps its Tabs and the match Block (a live page that switches between the schedule, the results and the standings while a match is played; the owner, 2026-10-21). 클럽 대항전 (L3-10) is deferred until its feature is built (the owner, 2026-10-28): its page keeps its code and leaves the review.
Codes. Every page has a code, its level then its number (L1-01 홈 … L4-20), listed in docs/workflows/page-levels.json with its board, anchor, canonical frame, route and family. A board shows the code beside its page section's name (FvkSection); a page drawn inside another page's section (회비 설정 in 회비 관리's, 가입 신청 in 멤버 관리's) shows its code on each of its frames' captions (FvkFrame). Codes are stable: a page that leaves keeps its number unused, listed under retired (L3-12 내 회비, folded into 클럽 홈's 회비 pane, 2026-10-21), and the build refuses a retired code. The board build fails when a page section has no code or a code names a section or frame that is not drawn, and writes the levels index, wireframes/page-levels.html: every page's first screen, by level and family, side by side. The record page lists the same codes.
1.18 Actions (the owner, 2026-10-25)
The owner's words: "We have too many locations where user interactions happen. The bottom is the CTA; do we need the top row? A user can simply tap to select or deselect. Why do we need 선택 button next to 확인 대기? Let's streamline our actions in all sections." And: "no visual clutter, no loud components scattered everywhere."
Every action has one home, decided by what it reaches, and a page draws it once. A screen has one loud thing, the band's primary; everything else is quiet (a row, a Cell, a ghost or tonal control).
| Action | Its one home | Never |
|---|---|---|
| The page's job (its most common next step) | the CTA band: one primary, a verb that names the result, with the count when it acts on several (3명 납부 확인); a second, tonal button only for the other side of the same decision (승인 · 거절). It follows the page's state (the next step for that state) and is absent when nothing is due | a header button, a section command, a row button or a Notice action doing the band's job |
| Picking rows for that job | the rows of the one section the job acts on carry checkboxes, the evidenced rows ticked; a tap anywhere on the row ticks or unticks it; the band counts, and is soft at zero | a Select mode, a select bar (취소, n명 선택됨, 전체 선택), a 선택 command, a 전체 선택 |
| One row's own commands | the row's tap. A row opens its object (its page; a person's 공개 프로필). On a page where the viewer manages people or money (회비 관리, 참석자 관리, 멤버 관리, 가입 신청, 호스트 관리, 신고 관리), a row opens that person's Sheet: their name row first (it opens their page), the time-bound commands, the rare ones, the destructive group last | a ⋯ on the row, a verb button on the row, the same command in two places |
| An answer a row asks for | the row's AnswerPair (§5): 수락 · 거절, 승인 · 거절, 인정 · 거절 | a band that repeats it (전체 승인 goes) |
| The one verb a list exists for | its row's sm Button (친구 찾기's 친구 신청, 차단한 사용자's 차단 해제, 연결된 계정's 연결, 회비 관리's 미납 group's 알림) | anywhere else |
| The page's rare commands | the header ⋯ (MenuSheet): 지출 추가, 회비 설정, 알림 설정, 신고, 차단 | a section head, a Cell at the page's end, a second ⋯ |
| Sharing | the header's 공유 icon on an object page (L2) and on a page whose subject is shared (리더보드, 기록) | a Strip, a ⋯, a 링크 복사 beside it |
| Making something | the FAB on the page that lists what the viewer makes, its label the noun (매치, 일정, 글, 테니스장, 연습, 장소, 친구); a viewer who makes more than one kind on one page gets 만들기 and its MenuSheet (클럽 홈's officials, 클럽's 클럽 만들기 · 초대 코드로 가입) | a header +, a section command, an end-of-list button repeating the FAB |
| A section's scope | the first head's Choice (§1.17, lists) | Tabs below L2 (§1.17), a second Choice row |
| A section head | the title, its count and one verb at most, drawn as pills: a door (the Door pill: 전체 보기 ›, or an explainer, 점수 안내 ›), the filter Choice ▾, or both, since a Choice chooses the scope and the door is the way on (티어 안내 › + 기간 ▾: one verb, its scope and its way on). No command (owner review 48: a row is read when it is opened, so 알림's 모두 읽음 left), but one: a group whose rows carry the list's one verb carries it in bulk at its head as the tonal sm Button (회비 관리's 미납: 알림 on each row, 모두 알리기 at the head; the owner, 2026-10-28: "alert each or all members that haven't paid the fees directly on the list") | a mode switch, a create command, a text link, two verbs |
- One control per destination. A page draws one door to each place it opens: no Shelf tile beside the head's 전체 보기, no ⋯ row repeating a Task Cell or the switcher, no Cell repeating the Strip's 전화. A Block that is a door keeps its door, the sm Door pill (D1).
- An action cue is a control, never bare text (the owner, 2026-10-28: "elevating action cues so that they are never just texts"). A door is the Door pill (§7): a section head's, a Block's (sm, on the ink surface), a Cell's trailing 보기 and a viewer's 앨범 전체 보기. More of a list is the tonal Button at its end. A band's second way (이메일로 로그인, 다른 방법으로 인증) and a form's (비밀번호 재설정) are tonal md Buttons. A text link stays only where a document is named in a footer (이용약관 · 개인정보 처리방침) and as a Toast's action. The verifier's
text-cuecheck fails any other. - Counts live in heads. No line above the sections restates their counts (참석자 관리's 8명 | 입금 확인 중 3 | 입금 필요 2, 참석 확인's 8명 | 도착 7 | 먼저 감 1), and no Cell above them repeats a fact the page's object already shows (참가비 10,000원 is 일정 상세's).
- A Notice carries a state, not an instruction. Its title is the state, with one action when there is a way out; it never says what the viewer may do or how something works, and never repeats the band (a disabled 신청 마감 band needs no 마감된 일정이에요 Notice).
- More of a list. 전체 보기 ›, the head's Door pill, opens a page that holds the whole list; N개 더 보기, the tonal Button at the list's end, opens the rest in place; a door never opens a page that is not drawn.
- Forms. A Sheet's button reads 완료 while it fills a form that the band submits, and 저장 while it writes at once (a settings page). On a settings page every value is a value Cell that opens its Sheet (no pencils; pencils are for a create flow's review), and a Switch that turns a group off hides what depends on it.
A decision per person is answered on that person's row (owner review 49, the owner on 참석자 관리: "for pages like these where we have to check and respond to each individual, it might be best not to have a single CTA at the bottom ... in-line buttons at the end of the row").
- The row. Where a manager decides about each person (a transfer, an arrival, an application), each waiting row ends in one sm button that names the result (입금 확인, 도착 확인), or an answer pair when there are two answers (승인 · 거절).
- After the press. The confirmed row moves to its done group and keeps its own 되돌리기, because a Toast holds only the last of several quick confirmations.
- Nothing in bulk at the bottom. These pages have no checkboxes and no CTA band. A command for the whole group is at most the group head's quiet control (모두 확인), never a CTA. For money it states the count and the amount first.
- Where. 회비 관리, 참석자 관리 and 참석 확인.
- Why. Research: inline actions under three per row and batch only from a selection (Carbon); per-request answers (Slack, Naver Works).
1.19 Words (the owner, 2026-10-25)
The owner's words: "a user can never feel out of place as they navigate in and out because they clearly know what to expect from a consistent and clear visual grammar and cues." One thing has one word, on every page:
| Thing | The word | Not |
|---|---|---|
| A door | the title of the page it opens: 차단한 사용자, 멤버 기록, 장소 정보 제안 내역, 가입 신청 | 차단 목록, 클럽 기록 보기, 제안 내역, 가입 심사 (for the list) |
| An answer between people (a friend, a message) | 수락 · 거절 | 승인 |
| An admission (a club, a session) | 승인 · 거절, each in one tap, 거절 asking a Dialog | a Dialog on 승인 |
| An excuse | 인정 · 거절 | |
| Money states (회비 and 참가비 alike) | 확인 대기 (paid, waiting for an official) · 미납 · 완납 | 입금 확인 중, 입금 필요 |
| The reminder | 알림 보내기 | 입금 알림 보내기 |
| Asking for friendship | 친구 신청 (cancel: 신청 취소, asking a Dialog everywhere) | 친구 추가, 친구 요청 (as a verb) |
| A change in rating | ELO 변화 | ELO 변동 |
| A saved thing, as a view | 저장됨 (the sessions or the venues a member saved, a 보기 of the filter) | 저장한 코트, 저장한 테니스장 |
| The place | 테니스장 (a row, a Feed's overline, a search); 장소 only in a page's own name (장소 상세, 장소 정보 제안) | 코트 for the place (코트 is a court) |
| The band's verb | 만들기 for a new thing (일정 만들기), 저장 for an edit, 보내기 for what goes to someone (제안 보내기), else the verb with 하기 (가입하기, 신청하기, 초대하기, 변경하기, 탈퇴하기) or the action itself (올리기) | a noun (신청 제출, 변경, 탈퇴, 초대, 게시) |
| An L3 page's header | its title is the page's name; what it is about is its sub (a club; a session as place | day; a person) | the format (복식), a period (이번 주) or a person's name as the title |
| An open game anyone may join (owner review 48, 2026-10-29) | 매치 (매치 찾기, 매치 만들기, 매치 미리보기; 복식 매치); 경기 stays a played match and the games tab, and English keeps Pickup (code pickup) | 번개, 오픈 매치, 소셜 매치, 픽업 |
| A ranked list (the language pass, 2026-10-28) | 순위 (클럽 순위, 친구 순위, 지역 순위, 전국 순위); its page 순위표 | 랭킹, 리더보드 |
| The best partner | 최고의 파트너 | 베스트 파트너 |
| Mixed doubles | 혼합복식 (혼성 stays the 구성 value) | 혼성복식 |
| The rating | ELO | 레이팅 |
| Kudos | 칭찬 (칭찬 남기기, 칭찬해 준 사람) | 매너 태그, 칭찬한 사람 |
| The NTRP band | 수준 (수준 범위, 수준 무관); 실력별 복식 and 실력 차이 keep 실력 | 실력, 레벨 (for the band) |
| How a club admits · how a session admits | 가입 방식: 바로 가입 · 승인 후 가입 · 초대 전용 · 참석 방식: 바로 참석 · 승인 후 참석 | 승인제, 가입 가능, 승인 필요, 참가 방식, 승인 방식 |
| My application, waiting · the ones to answer | 승인 대기 · 가입 신청 | 신청 중, 신청 검토 중, 심사 중, 심사 대상 |
| The RSVP deadline | 신청 마감 | 응답 기한 |
| The 클럽 tab's second view | 클럽 찾기 | 탐색 |
| Ending a session or a tournament | 끝내기 · 끝난 일정 | 종료, 종료된 일정 |
| What a member still owes | 남은 금액 (잔액 only for the ledger's balance) | 잔액, 잔액 전액, 기납부 |
| A dialog's dismiss · a wall's title | 닫기 · 운영진만 볼 수 있어요, 호스트만 볼 수 있어요 | 참석 유지, 신청 유지 · 접근 권한이 없어요, 권한이 없어요 |
| The house forms | 해요체 and 할까요? (no 시-honorific) · the active voice when the user acted · 박지훈님 · 날짜* · 지역(선택) · 2027년 8월 30일 · 시간당 12,000원 · N일 뒤 | 하시겠어요?, 신청됐어요, 박지훈 님, 2027.08.30, 12,000원/시간, N일 후 |
The board lint's copy-words rule (scripts/lib/flat-v2-board-lint-words.mjs) keeps the retired words and forms off the page boards; the full record is docs/design/copy-plans/2026-10-28-flat-v2-language-pass.md.
1.20 One item type per list (the owner, 2026-10-28)
The owner's words: "having different items in the same page is generally unadvisable, especially if we have lists that can get long. If we are using different items, the items need to be compact enough to fit on a horizontal line. In case we don't have to and stick to a single item, we can just keep the vertical lists." Refined the same day: "Items of the same kind render as a horizontal list if the page has multiple lists of different kinds. Items of the same kind render as a vertical list if there is only one list of the same kind."
Rule. A page whose lists are all of one kind draws them as vertical lists: Feed items, people, media rows, text rows or matches. A page with lists of different kinds draws each of them as a horizontal list, a Shelf of compact items whose head's door (전체 보기 ›) opens the whole list, vertical, on a page of its own. A list's groups (확인 대기, 미납, 완납) are one list of one kind, and the Feed's kinds of activity (일정, 공지, 사진) are one kind, the Feed item. One item of another kind is not a list: it sits on one line (a summary Cell with its figure, 장부 잔액 1,240,000원 ›).
Instead.
- Lists of two kinds on one page: each becomes a Shelf with its door, and each door opens that list on its own page (기록: 순위, 최고의 파트너와 천적, 경기 기록). A list that belongs to another job leaves for its page behind one line (회비 관리's 장부).
- A section above the list for work that waits on people: those people lead the list as its first group, in the list's own rows (멤버 관리's 가입 신청).
- One item of another kind above a list: it joins the object's facts as one line (클럽 홈's 홈 코트).
- Work that waits, above a feed: its own horizontal lane between the Lead and the feed, each card opening its task and leaving when settled (홈's 할 일; owner review 49, S7: the one feed mixed priorities, so the lanes go by who ends an item). It replaced the to-do option of the feed's filter. With nothing waiting the lane is absent, head and all (owner review 51: "the to-do list shouldn't exist if there is nothing to do"; it replaces review 50's quiet card); a first run holds the ways in as cards (클럽 찾기, 초대 코드로 가입, 매치 찾기); only an error or a cold offline page, which knows nothing, draws no lane.
- Lists of three kinds on an object page: three Shelves under the hero, then its facts (장소 상세: 다음 일정, 홈 코트로 쓰는 클럽, 사진; 가입 심사's people are two Shelves of faces).
- A list of another kind that is short and one answer: one Cell with its count, opening the list (기간 기록's 상대 5명).
Not lists. Facts in a Cell group, a Notice (a state), and a summary: a Strip's tiles, a form strip, a line of Choices.
Large text. At 150% and 200% a horizontal list reflows into rows (§1.14); the rule reads a page at 100%.
Prevention. The verifier's one-item-type check counts a page frame's vertical rows by kind (Feed item, person, media row, text row, match; a kind counts at two rows) and its horizontal lists (a Shelf; a face line is people): a frame with lists of two kinds or more fails when any of them is vertical. The page's one open-ended Feed may stay vertical under lanes that are all horizontal (§ 1.14: the Feed is always last; 홈's 소식 under its 할 일 lane). A frame at a larger text scale is skipped. Batch 2 of owner review 48 redrew every page the check named, so nothing is pending: a new mixed page fails (docs/design/owner-review-48-synthesis-2026-10-28.md, P1).
1.21 Questions (owner review 49)
A question the app asks on more than one page is asked with one shared composition, the same on every page. The owner, on 행사 만들기: "Make sure we use shared components (date and time selector, for example) ... This is a shared component, single source of truth pass on all pages." No page board draws a control of its own; the drift was one level up, where the same question was answered by a different kit composition per page.
| Question | The one composition | Where it is asked |
|---|---|---|
| When | the 일시 Cell, which opens the shared date picker, then the time range on the same Sheet (a start alone when the end is open) | 일정 만들기, 행사 만들기, the filters' 날짜 (date only) |
| Where | a Picker Cell that opens the Search takeover. It lists the registered venues first, then Naver's results. For a place that is not a court (a 행사's 고깃집) it is Naver's place search alone. The pick shows as the Map face | 일정 만들기's 테니스장, 행사 만들기's 장소, 클럽 만들기 |
| Arrival | the shortcut Choices (지금, 시작할 때, 15분 늦게, 30분 늦게), then 직접 → the time selector | 일정 상세 (도착했어요), 참석 확인 |
| How much | the fee fork (무료 · 유료) with the amount nested under 유료 | 일정 만들기, 행사 만들기, 회비 설정 |
| Level | the NTRP band with its Stepper | 일정 만들기, 클럽 만들기, the filters |
1.22 Forms (owner review 50)
One grammar for every form, the one 일정 만들기 speaks (the owner, on 경기 규칙, 테니스장 추가, 단톡방 만들기, 프로필 수정 and 입금 계좌: "All the functionalities are there, but how can we make it seem more polished and in line with our ui/ux revamp decisions so far?"). Each mixed three grammars, Fields with labels above, label-left Cells and full-width bordered steppers, with fields floating between groups and a grey 접기 button. Research: Toss ListRow and TextField, Apple's lists, disclosure controls and settings, Material's settings pattern (research A § 4).
- A form that edits answers that exist is one list of value rows, the list 일정 만들기 ends on (the live-draft model): label left, the answer right, and the answer's pencil (or a picker's chevron) opening its editor, the Edit Sheet or the picker Sheet, which asks the question with the rules below. A count may keep its compact Stepper in its row; a fact that cannot change keeps its lock. 경기 규칙, 프로필 수정 and a set 입금 계좌 are such forms. The rules below are for asking answers that do not exist yet (a creation form, a wizard step, an editor Sheet). When a page opens on this list (the owner, 2026-10-31, on 클럽 만들기: "not sure how and when that grammar gets applied"): when every answer already has a value, so an edit page, or a creation begun from a template or from defaults (일정 만들기 from 정기 일정, 대진 짜기 from its session). A creation whose answers do not exist yet asks them first: a Wizard of one topic per step that ends on this list as its review (일정 만들기, 클럽 만들기), or one screen when it has one topic (행사 만들기). A wizard's page code shows its first step; the review list is a frame of the same page. So a page that opens on the list says "these answers exist", and a page that opens on a question says "tell us".
- What you choose, toggle or count is a row; what you type is a Field. Choosing, a yes or no and a count sit in a Cell group, label left and the value or a compact control right: the Picker Cell, the compact Stepper in its Cell, a yes or no in a Save form as a check row with its square trailing (never a Switch, which applies at once). Only typed text (a name, an address, an account number, a note) is a Field with its label above. A quantity whose unit is fixed is a count row (the owner, 2026-10-31, on 일정 만들기: "if we are deciding on the duration of a match, instead of presets or free text input, we can simply give them a stepper + number only input field ... make our input fields as specific and tailored as possible"): the typed Stepper in its Cell, the unit in the label (한 경기 시간 분, 최대 인원 명, 코트 면, 세트 수, 신청 마감 시간 전), whose value cell is typed on the number pad and whose ends step it, so a near value is two taps and a far one is typed; never presets for a unit the app already knows (30분 | 45분 | 60분 | 직접 입력 is gone), never a free Field. Presets stay only where the answer has no unit (a choice: 종목, 경기 방식). Money is the amount Field with 원 (its magnitude is free, and a step on it would be arbitrary), and a year is the number pad with 년.
- Every form is grouped. Each group is one topic under a Section head; no Field floats alone between groups; a short form of one topic needs no head. Inside a group the rows come first, as one Cell group, then its Fields (owner review 51: 회비 설정 read row, field, row, row and 행사 만들기 field, row, row, map, field, field, so no two forms read alike): a group never alternates a field and a row. A map shows a place row's answer, so it sits in that row's expand slot (rule 3), never between groups. A value list (rule 0) is grouped the same way, and its groups are the topics its creation asks one per step, so a wizard and its review read in one order (일정 만들기: 일시와 장소, 경기, 참가); a group never holds one row whose label its head repeats. In a wizard step the step's question heads its first group.
- What depends on an answer nests under its row (the Cell's expand slot).
- Rare options sit behind one disclosure row: 고급 설정, a Cell whose chevron turns and whose label never changes. Never a 접기 or 펼치기 button, and one per page.
- Two to five short options are a Choice set; longer options are check rows with their meaning on line 2.
- A people picker shows the picked people as a strip of removable faces on top, then the search field, then the people as rows with the check square trailing; its action names the count (8명과 단톡방 만들기).
- One action in the band, naming the result.
1.23 The match note and 테니스 여정 (owner review 52)
The owner, 2026-10-31, on 프로필: "we don't need to worry about practice sessions just yet. Let's remove anything related to practice entirely. We can add a journal/journey section so that people can make notes about their matches. We will give subscribers an ability to summarize their tennis journey through the use of AI (for individuals as well as club members)." So the practice log (연습 기록, its page, its rows and its Sheet) is gone from every board, and two things take its place, grounded in how match journals work (Tennispreneur, GameSetMatch, matchmind: a note and a mood after a match, private by default, shared with a coach on purpose; Strava: a subscriber-only AI summary over the record):
- The match note. One note per match, written where the match ends: the result page's door 노트 남기기 › opens the Edit Sheet 경기 노트, a text Field (500 characters) over a three-way mood Choice (좋았어요 · 보통 · 아쉬워요), 저장 in the Sheet's footer. A note is private; nothing of it reaches the opponent or the club. The result row then carries the note glyph in its line slot.
- 테니스 여정 (L3-08,
/profile/journey, the list family). 프로필's closing Cell, where 연습 기록 was. The page is the person's notes newest first under day headers, each a text row (the match as the overline, the mood as its glyph, the note as the text) that opens that match's result; its Strip under the head 이번 달 counts the month's matches and notes. The head carries one door, AI 요약 ›, which a subscriber (the player plan or above) opens to a reading page of the season's story written from the notes and the record; a free account opens it to the plan's own page. Notes stay private (no 클럽에 공유 control anywhere): the club's 여정 (L3-51 클럽 테니스 여정,/clubs/[clubId]/journey, the basic and pro plans) is told from the club's own sessions, results and photos by day, under the month's Strip (일정, 경기, 사진), never from a member's note.
2. Behaviours
2.1 Press physics
| Variant | Scale | Dim | Used by |
|---|---|---|---|
| button | .97 | .92 | Button |
| pill | .96 | .85 | Choice |
| row | .98 | .80 | rows, Cells, roster cells, Strip columns |
| card | .98 | .90 | Block (the whole block presses) |
| icon | .92 | .78 | IconButton, header icons, tab-bar items |
| fab | .96 | .90 | FAB |
| tab | none | .85 | scope tabs, text actions, ProviderButton (a provider's proportions are fixed), the map band |
- Springs in
pressIn, outpressOut(§1.12); dim 80 ms in and 150 ms out; both run on the UI thread. - A toggle (Switch) has no press physics: the toggle is the feedback. A map band presses with dim .85 and no settle.
- Haptics mark outcomes only, never primitives (§2.7).
2.2 Hover and focus
| State | Value |
|---|---|
| Hover (web, real pointers only) | opacity .85 over 100 ms, cursor: pointer |
| Focus (web, keyboard) | 2 ink ring, offset 2, drawn outside the control, appears instantly and never animates; on a ball button the ring is still ink. An Input shows a 2 ink border instead. Sticky chrome never covers the ring (scroll-padding equals the bar heights). |
2.3 Disabled, soft-disabled, loading
| State | Look | Behaviour | Use |
|---|---|---|---|
| Disabled | opacity .5, not-allowed | inert, aria-disabled; a disabled primary has no reason line (1.16): the page's own facts say why, and a soft-disabled one shows the fields' errors on a press | only where nothing the person can do on this screen helps (a role wall), and for siblings of an action in flight |
| Soft-disabled | a pale ball fill (brand-weak #EFF9B7, dark #3A4220), secondary 600 label (6.6:1; 4.9:1 dark, an inactive control), same size as the primary, so it never reads as the tonal button beside it | focusable, aria-disabled="true", announced with its reason. A press gives a haptic, focuses the missing control and shows its danger line. Becomes ball when the requirement is met. | the primary of a step with exactly one pending decision whose missing part is visible (one field, one option, one consent) |
| Enabled and validating | the normal primary | a press validates, focuses and announces the first invalid field | two or more pending inputs, or the missing part is off-screen (long forms, the review page) |
| Loading | small spinner beside the in-progress label ("저장 중…"), full opacity, width held; ink on ball, tonal and ghost, white on danger | inert, aria-busy; the accessible name follows the visible label | any action that waits on the network |
2.4 Text scaling
- Range. 85% to 200% on both platforms, following the OS. iOS xSmall to AX2 follow Apple's per-style curves; AX3 to AX5 render the AX2 layout. Android 0.85 to 2.0.
- Classes. Each text role belongs to one class, which sets
maxFontSizeMultiplieron every platform anddynamicTypeRampon iOS; the smaller of the two wins. One table inpackages/ui/src/text-scale.tsfeeds thetextRolevariants and a parity test walks every role.
| Class | Cap | iOS dynamicTypeRamp | Roles |
|---|---|---|---|
| reading | 2.0 | headline, subheadline, footnote, caption1 | reading, body, headline, title (as a content heading), caption; day header, section title, row text, Cell, input text, lg and md button label, notice, toast, dialog, block meta |
| label | 2.0 | footnote, subheadline | footnote and wrapping footnote: Tag, level numeral, Choice, sm button label, section action, field label, support line, block eyebrow |
| hero | 1.7 | title1 | hero |
| numeral | 1.5 | largeTitle | display, strip figures |
| chrome | 1.3 | title3, headline, subheadline, caption2 | tab-root and pushed header title, header mode controls, scope-tab label, FAB label, tab-bar label, count Tag, chart labels, calendar and heatmap day numbers |
- Never off.
allowFontScaling={false}appears only on avatar initials inside their fixed circle and on the iOS tab-bar label. A lint flags every other use; the escape is a same-line// scale-exempt: reason.adjustsFontSizeToFitis banned. - Heights are floors. Every number on a text-bearing box is a
minHeightwith the padding held. The header (56) and the tab bar (64 + inset) never grow; everything else grows with its text. - Stacked layout is fit-based.
scale = min(fontScale, 2.0)fromuseTextScale(). A trailing Tag, figure or Tag-and-score cluster stays beside line 1 while the longest word of any text line, the element and 12 fit the text column (at 200% a six-syllable name fits beside a four-syllable Tag in the 298 PersonRow column: 156 + 112 + 12 = 280). Otherwise the element drops under the text block at the text edge, 4 below the last line; nothing else may own a row. A caller that cannot give both widths stacks from 1.5×. Only the components below branch on scale.
| Component | Default | Stacked |
|---|---|---|
| MediaRow | thumb, L1 and L2, trailing element beside L1 | the element under the last line at the text edge, 4 below; L2 may take 3 lines |
| PersonRow | avatar, L1 and L2, trailing figure or Tag | the figure or Tag under the last line, 4 below; the rank column stays |
| Match row | header line: the facts, then the trailing Tag, Button or text | the facts wrap whole beside the element; the team lines keep one team per line, names wrapping whole |
| Cell | label left, value right | from 1.5×: label over value, 4 apart |
| Strip | n equal cells | the fit test (Strip entry) reflows to rows of two; tiles: 8 apart, less their padding |
| Roster | one row of four | from 1.5×: two rows of two; the door stays the last cell |
| Segmented | equal columns | from 1.5×: radio Cells, vertical |
| Dialog | pair side by side | from 1.5×: stacked, primary on top |
| AnswerPair | two equal columns | from 1.5× or when a label + 32 exceeds its half: stacked full width, 8 apart, the yes answer on top |
| Notice, Toast | one-line action at the right | the action under the text (a Notice wraps it; a Toast stacks it from 1.5×) |
| Day header | date left, relative day right | from 1.5×: relative day under the date |
| Badge shelf | a sideways row of 76-wide badges, the next one peeking | from 1.5×: rows (MediaRows, at most three) |
| Section head | title left, action right | unchanged; the title wraps, the action keeps its line; a count wraps under a title it does not fit |
| Scope tabs, FAB | content-width tabs; pill | unchanged (the 1.3 cap keeps them on one line) |
- Wrap versus clamp. Names never cut (person names wrap to 2 lines, 3 from 1.5×). Everything else wraps before it clamps; a clamp is allowed only where the full text is one press away and the press is signalled, and the accessible label always carries the full text.
| Text | Rule |
|---|---|
| venue and club names | wrap 2 lines (3 from 1.5×), then tail ellipsis; the destination shows the full name |
| L1 time, counts, amounts | one line, never an ellipsis |
| Button, Choice, Cell label and value, Notice, Toast | wrap, never an ellipsis; a toast that needs more is a Notice |
| Tag, count Tag, level numeral | one line, flexShrink: 0, attached through the line slot; the row reflows around it |
| block meta and explanatory copy | 2 lines (3 from 1.5×); ellipsis after the last line; the block opens the full text |
| header title | one line with tail ellipsis; the hero repeats it |
- Glyphs and images. Inline glyphs beside text (hero fact 20, notice glyph 20, Choice check 16, FAB icon 20) grow by
min(scale, 1.5). Header glyphs, tab-bar icons, chevrons, avatars, thumbs, map tiles and dots do not scale. - Adornments. An adornment beside text attaches through the line slot (§1.13 IA1): the slot is as tall as the label's first line at the current scale, so the adornment stays centred on line 1 at every size. The count Tag grows with its numeral (box = ceil(20 × min(scale, 1.3) / 4) × 4: 20 at 100%, up to 28 at the 1.3 cap) and the Level tag with its numeral (24 / 32 / 36 / 36 at 100 / 130 / 150 / 200%); the 16 badge form (tab bar, header icon) keeps its size.
- Chrome. The iOS tab-bar label does not scale and relies on the Large Content Viewer (
accessibilityShowsLargeContentViewer,accessibilityLargeContentTitle); the same props are available to header icon buttons. On Android a customtabBarLabelcaps the label at 1.3. - Bold Text (iOS). Map one step up inside the four loaded weights (400 to 600, 500 to 600, 600 and 700 to 700) in
Textonly. - Web. Type is in px; page zoom and reflow at 320 CSS px keep it usable.
- Stops to verify. 100, 130, 150 and 200%, plus a 320-wide frame at 200%.
2.5 Motion
Principles: motion has a purpose or it is absent; effects use timing, spatial moves may use springs; nothing makes a person wait (every demo accepts input while it plays). Tokens are in §1.12.
| Interaction | Motion | Duration and easing | Reduce Motion |
|---|---|---|---|
| Press | settle and dim | springs and dim of §2.1 | dim only |
| Hover (web) | dim to .85 | 100 ms fadeFast | unchanged |
| Focus ring | appears | 0 | unchanged |
| Screen push and pop | native stack default | platform (iOS slide with swipe; Android translate 450 ms or scale 200 ms) | the system decides |
| Tab-bar switch | none; screens detach | 0 | unchanged |
| Scope-tab switch | pane swap instant; underline slides | navSlide (about 300 ms) | underline cross-fades 150 ms |
| Sheet | 48 rise and platform fade | rise 300 ms sheetIn, exit 200 ms | fade 150 ms, no rise |
| Dialog | platform fade, no scale | platform | unchanged |
| Menu (600 and up) | fade, no translate or scale | in 150 fade, out 100 fadeFast | unchanged |
| Toast | rise 12 and fade in; fade out; drag follows the finger | in 250 reveal, out 100 exitFast | fade in 150 and out 100, no rise |
| Scroll-linked header title | title and solid layer fade together | 150 ms fade; flips with hysteresis 24 | unchanged |
| Edge hairline | fade | 100 ms fadeFast | unchanged |
| FAB collapse and expand | label out, then width; expand reverses | label 100 out and 150 in, width 200 standard | instant width, label fades 150 ms |
| Pull to refresh | native RefreshControl, spinner only for a user pull, tint ink | platform | unchanged |
| Switch | thumb slides, track recolours | thumb 150 fade, track 100 exitFast | thumb jumps, track fades 100 ms |
| Choice, checkbox, radio | fill colour changes; glyph appears at once | 100 ms exitFast | unchanged |
| ExpandToggle | chevron turns 180°; reveal slides 8 | chevron 200 toggle; reveal 250 reveal | chevron at its end state; reveal fades 150 ms |
| Notice dismiss | opacity then height collapse | 100 + 150 ms | opacity only, 100 ms |
| Skeleton to content | cross-fade | 150 ms fade | unchanged; the shimmer is static at .75 |
| Wizard step | RevealStack reveal in place; progress segments recolour | reveal 250 reveal; segments 150 fade | reveal fades 150 ms; the continuous bar is instant |
| Image load | placeholder pulse, then fade in | 150 ms fade | static placeholder, fade 150 ms |
| Chart scrub | the marker follows the finger | none | unchanged |
| Live dot | pulse | 1600 ms loop, 5 s per mount, then solid | solid |
Reduce Motion replaces, it does not cut. Translate, scale and layout are removed, and the same element appears with a 100 to 150 ms fade (sheet, dialog, menu, toast, FAB label, header title). Opacity fades keep their duration. Press feedback is dim only. Loops rest on a static frame. Gesture-tracking motion (toast drag, chart scrub, scroll-linked transforms) is unchanged because it mirrors the person's own gesture. Platform transitions follow the system (prefersCrossFadeTransitions on iOS).
Lists.
- Virtualised lists (
FeedListand the rest) take noentering,exitingorlayouttransition, no stagger and noLayoutAnimation; data changes apply in one frame. A lint rejects them inside list item renderers. - A short fixed stack of at most 24 children (roster grid, avatar row, a Cell group) may move siblings with a 200 ms
LinearTransition; the mover fades out 100 ms and in 150 ms. - Your own action (RSVP, cancel, remove): optimistic apply, success haptic, the toast announces. Someone else's change (poll, push): never animate; snap.
In-place changes. A Tag label or tone, a count, a Cell value, a score and a live counter change instantly: the state is the message. The ProgressBar fill (the ELO bar) uses slow 300 ms with Easing.out(Easing.cubic); the wizard bar uses fast 150 ms.
Never animate. Anything the person did not cause; anything inside a virtualised list item; live scores and counters; text while it is typed; validation errors (no shake); the focus ring; the tab switch; layout properties inside scroll handlers.
2.6 Accessibility semantics
One stop per row. A row, block, roster cell, Strip column or Cell is one accessibility element; its children (thumb, Tag, count, chevron, glyphs, MetaBreak) are hidden from assistive tech because the name carries them. A row with a real second control keeps two stops, the row then the control, the control named for its owner. A name never includes the control type ("버튼"). State goes in aria-selected, aria-checked, aria-pressed (web; React Native has no aria-pressed, so on native a pressed toggle folds into aria-selected), aria-expanded and aria-busy, never in the name; a switch's name does not change with its state.
Composed names. A function beside the visible formatter builds the name from the same view model the row renders; nothing hand-types a second string. Order: urgent status first (진행 중, 마감 임박), then when, where, kind, rating, count, and last the viewer's own state; about six facts; joined with a comma and a space. Symbols are replaced by speech:
| Visible | Spoken in the name |
|---|---|
9월 19일 (토) | 9월 19일 토요일 (오늘 or 내일 first within a week) |
09:00~11:00 | 오전 9시부터 11시까지 (오후 7시 30분 on a half hour) |
4/8 | 8명 중 4명 참석 |
NTRP 3.0–4.0 | NTRP 3.0부터 4.0 |
68% | 68퍼센트 |
30,000원, 3위, 1,482 | unchanged |
·, ~, /, en dash, MetaBreak | never spoken |
A control with one visible label starts its name with that text (WCAG 2.5.3): buttons, tabs, FAB, Choice. A loading button's name follows its in-progress label.
Headings and landmarks. Levels: 1 the screen title, 2 hero title and section titles, 3 inside a section. Exactly one visible h1 per screen: on a detail page the hero title is the h1 and the header's title is aria-hidden until it fades in. Day headers are h2 on a page with no section heads, h3 inside one. Web: header banner, nav labelled "주 메뉴", one main, aside for a desktop pane, a unique label on a repeated landmark, and a "본문으로 건너뛰기" skip link.
Focus order is the visual order: header, scope tabs, content, FAB, CTA band, tab bar. The FAB is reached after the list; its name keeps it findable by Voice Control. While a sheet, dialog or menu is open nothing behind it is reachable. Decorative glyphs and dots are hidden.
Status and alert.
| Kind | Mechanism | Used for |
|---|---|---|
| Status (polite) | role="status"; Android aria-live="polite"; iOS queued announcement | a result toast, a Notice inserted after load, the offline strip, a field error that appears on leaving the field |
| Alert (assertive) | role="alert"; Android aria-live="assertive"; iOS interrupting announcement | a failure the person caused: the CTA danger helper, a warning or danger Notice; on submit the first invalid field takes focus and its error is announced |
| Busy | aria-busy on the container; no announcement | content loading; a finished load and a finished refresh say nothing unless they fail |
aria-live reaches TalkBack only, so iOS announces explicitly (announceForAccessibility). A toast shows its message and then its action label.
Focus management.
| Surface | On open | On close |
|---|---|---|
| Sheet | the title is the first element; iOS Modal restores the prior focus; Android: verify TalkBack lands on the title | the trigger |
| Confirm dialog | the least destructive action (or the title); the safe action precedes the confirming one in order | the trigger |
| Menu | the first item | the trigger; Escape on web |
| Search takeover | the input | the trigger row |
| Wizard next or back | the new step's title | none |
| Removed row | none | the next row, or the section head when none remains |
| Submit with errors | the first invalid field, with its error announced | none |
Use sendAccessibilityEvent(ref, 'focus'); setAccessibilityFocus is deprecated.
Targets and gesture alternatives. Icon buttons are 48 and smaller visuals keep a 44 floor (§1.11). Every gesture has an on-screen path:
- Toast swipe: a tap, or the × on an action toast.
- Sliders (TimeBandSlider, RangeBandSlider, Slider): tap the track to move the nearest thumb, plus the 44 Stepper for ± (WCAG 2.5.7), and
adjustableActionPropsfor screen readers. - Photo-tile reorder: 앞으로 and 뒤로 이동 in the tile's menu.
- Chart scrub: tap a point to set the readout, and stepping actions.
- Sheets: an explicit close. Pull to refresh: the system control.
Language. The in-app locale is independent of the OS: set accessibilityLanguage from the locale store on each screen root (iOS); web sets lang on html.
Web keyboard. Tabs: roving tabindex, Left and Right, Home and End, automatic activation. Menu button: Enter, Space or Down opens and focuses the first item; Escape closes and returns focus. Dialog: Tab stays inside, Escape closes (on a confirm dialog Escape is its cancel; the scrim does not dismiss it), aria-modal, title through aria-labelledby, alertdialog for destructive confirms. Switch: Space. Radio group and Segmented: arrows move and check.
Forms. The label is the input's name. The support line carries the error (no hint, 1.16), which is inline, announced when it appears, and never a toast. React Native has no aria-invalid, so the name suffix "입력 오류" stays. The required asterisk is hidden from assistive tech and "필수" joins the name; no legend explains the asterisk (1.16). Choice groups carry a group label; Segmented is a radiogroup.
Also required.
- OS settings we read: Reduce Motion, the cross-fade preference, and Bold Text. With Increase Contrast or a high-contrast text setting on, tertiary text and the cell hairline are promoted to the secondary and edge tiers.
- Wizards never ask the same value twice (prefill from the profile or the last session). Email sign-in allows paste and password managers. A help entry sits in the same place on every screen.
- An iOS build gate runs five tasks (find and RSVP, pay the fee, confirm dues, advance a wizard step, enter a score) under VoiceOver, Voice Control, Larger Text at 200%, Increase Contrast with Bold Text and Reduce Transparency, Reduced Motion, and grayscale, and records which accessibility labels the app may declare.
- On the web build, a text-spacing check (line 1.5, paragraph 2, letter 0.12, word 0.16) covers three real screens.
2.7 Platform
| Topic | Rule | Ships as (OTA or build) |
|---|---|---|
| Android edge-to-edge | Bottom-pinned chrome paints its own canvas behind the system navigation bar and takes the runtime inset as its bottom padding (a band: max(16, inset)). The status bar is styled from the in-app theme (dark icons on the canvas, light on the dark canvas). A 3-button bar may show a contrast seam when the app and system themes differ; verify on an emulator. | OTA, native fix later |
| Modals | ModalPanel sets statusBarTranslucent and navigationBarTranslucent; the scrim covers the navigation-bar band and the inset is not counted twice. | OTA |
| Back | Predictive back stays off (predictiveBackGestureEnabled: false), Android's temporary opt-out (checked 2026-10-11): the predictive-back migration is planned before the API 37 target. Back closes the topmost of menu, sheet or dialog, search takeover, then the screen; with the keyboard open the first back hides it; a wizard shows its guard. The header back and the hardware back are one action. | manifest |
| iOS swipe-back | A screen that hosts a horizontal-pan control (band sliders, hue slider, chart scrub) and every wizard step with a slider sets fullScreenGestureEnabled: false; the edge swipe stays. | OTA |
| Sheets on iOS | Swipe-down on the title row dismisses (toast thresholds: 40 pt or 500 pt/s); a sheet with unsaved input asks a Dialog first. Android is unchanged. | OTA |
| Keyboard | Android keeps softwareKeyboardLayoutMode: "pan"; per screen, setInputMode resize with KeyboardProvider (preload={false}); never a global pan-to-resize flip. The focused input keeps 16 clear above the keyboard. | OTA, release-build smoke first |
| Keyboard, by surface | Wizard or form: KeyboardAwareScrollView, the CTA band rides the keyboard in KeyboardStickyView. Sheet with inputs: lifts with the keyboard, the CTA stays visible. Search takeover: field focused on open, dragging the list dismisses the keyboard (keyboardShouldPersistTaps="handled"). Toast: above max(bottom chrome, keyboard) + 16. FAB and tab bar hide while the keyboard is open (100 ms). Composer: pinned above the keyboard. | OTA |
| Haptics | Outcomes only: success (RSVP confirmed, payment confirmed, saved) · error (failed) · warning (a result that landed on the waitlist) · impact light (an optimistic acknowledgement: RSVP tap, reaction, send) · selection (choosing a value). Never for a press, tab switch, scroll, sheet open, navigation or toast. Android uses performAndroidHapticsAsync. A haptic is never the only signal. | OTA |
| iOS 26 and native tabs | The JS header, tab bar, sheets and buttons are not Liquid Glass and imitate none. System-owned surfaces (alert, native date picker, share sheet) adopt it. Native tabs are not adopted: they cannot report the bar height the FAB, toast and CTA stack needs. | none |
| Large screens | At 600 dp and up Android ignores the orientation lock; the phone layout is verified on a 600 dp emulator at 200% text before the Play release. | verify |
2.8 Skeleton and loading timing
| Elapsed since the request | What shows |
|---|---|
| under 300 ms | nothing: no skeleton, no spinner; the previous content or the empty frame stays |
| 300 ms to 10 s | one skeleton per screen at the row pitches (media 80, person 68, cell 48, block 144, strip 80, chart 160), gray100 pulsing .6 to .9; once shown it stays at least 500 ms, then cross-fades to content in 150 ms |
| over 10 s | a determinate indicator with an estimate (a 17/24·600 line naming the work, 12, the 8 track in gray100 with an ink fill, 8, the estimate 15/22 secondary: "약 10초 남았어요"; progressbar with its value), or the slow-load Notice with 다시 시도 |
| over 5 s of shimmer | the shimmer rests on its static frame (WCAG 2.2.2) |
A spinner appears inside a button only. Pull to refresh uses the native control, and a failed refresh shows a Notice.
The 300 ms start and the 500 ms hold are a deliberate departure from Seed's loading pattern (nothing under 1 s, a skeleton or spinner from 1 to 4 s, a determinate indicator past 10 s).
2.9 Toast timing
| Kind | Duration |
|---|---|
| result (saved, joined, done) | 4 s |
| result with 되돌리기 | 8 s |
| error | never a toast: a failure goes next to the button (CTA danger helper) or in a Notice that stays, or a Dialog |
- On Android the duration is the larger of the table and
AccessibilityInfo.getRecommendedTimeoutMillis. - The timer pauses while the toast is touched and while a screen reader's focus is on it. One toast at a time. A toast never holds the only copy of something the person needs later.
- Dismiss: swipe down (past 40 pt or 500 pt/s) or a tap; a toast with an action also carries a ×. No glyph on a toast.
3. Chrome and navigation
Header
Job. The 56 bar at the top of every screen: where you are, the way back, and at most two universal actions or one mode's controls. The page's main action is the CTA band, never the header.
Anatomy.
- Box: min-height 56 below the status inset, canvas fill, no divider at rest; the edge hairline appears while content scrolls under it (Sticky edges).
- Tab root: title 20/28 · 700 ink at the rail, one line, no back. Pushed screen: back at the rail, then the title 17/24 · 600 ink left-aligned at x 56, one line with tail ellipsis. Hero page: no title at rest (scroll-linked title below).
- Back and close: a 24 glyph on the rail in a 48 target centred on the glyph (the target bleeds 12 into the margin; no ink there).
- Trailing icons: registry glyphs, 24, ink, in 48 targets with no gap, the last glyph's edge on the rail; at most two, most-used leftmost, ⋯ rightmost. Footprint 96.
- Subtitle variant: min-height 64; a second line 13/18 · 500 secondary, one line with ellipsis, names the context (club, session) on pages reached from it. Several facts are
Meta(a page's provenance reads "강남 라켓클럽 | 정기 일정에서"): a state of the whole page lives here, never in a Tag row of its own. The session's tool pages, the 관리 sheet's destinations (참석자 관리, 참석 확인, 호스트 관리), are pages of their own, so their subtitle names the session as any page does: its place and its day asMeta("강남 라켓클럽 | 9월 19일 (토)"; a pickup has no club, so its tool page names the venue, then the day), never a venue and a format joined into a name. Only the 관리 sheet, which sits on the session's own page, is titled by the day and the start time. - Registry, closed at eleven glyphs: ‹ 뒤로 · × 닫기 · 검색 · 공유 · 저장 (bookmark, outline to filled when saved) · 알림 (bell) · 메시지 · 설정 (gear) · 추가 (+) · 더보기 (⋯) · 할 일 (clipboard with a check; the operator’s scorecard, W4 batch 12). Each has one spoken name that is also its web tooltip and its coachmark text. A new glyph needs a ruling; a lint on the header slot rejects any other.
Variants.
- Mode bar (edit, select, search). Text appears only to leave or commit a mode: 취소 · 완료 · 저장 · 건너뛰기 (no select mode, § 1.18), drawn as sm Buttons (36 tall, 44 target by hit slop). Tonal for 취소, 건너뛰기 and 전체 선택; primary (ball) for 완료 and 저장, one per bar, trailing. Edit mode replaces the whole bar: 취소 leading in the back button's place, 완료 or 저장 trailing. Select mode: 취소 left, the title "n명 선택됨", 전체 선택 right; the bulk action is the CTA band. Search mode: the Search field replaces the title and icons, 취소 trailing. Mode controls are chrome class (cap 1.3), so the bar holds 56 at every text size. A wizard bar follows the WizardShell header.
- 관리 entry. A gear named "관리" that opens the 관리 MenuSheet, with a Header icon badge when anything is pending. Guard for officials (U-05): each club pane ends with its labelled tool Cell; the officials' 할 일 section lists pending work; a one-time Coachmark points at the gear when a member becomes an official. Acceptance test: five newly appointed officials are asked to change the club's bank account; pass if at least four reach the 관리 sheet within 10 s unaided. If two or more fail, the pre-approved fallback is a sm tonal 관리 Button in the header, never bare text.
- ⋯ (header). Exists only with three or more rows, the one rule for every ⋯, a row's included (see MenuSheet): with one or two commands they sit on the header or the row as buttons. It is never the only route to something an official must find. It opens a MenuSheet on phones (the anchored Menu from 600 pt). A two-row ⋯ is not allowed: promote 공유 to an icon and put 나가기 in the sheet it belongs to.
- Close on a terminal page. A page that ends a flow without being one of its steps (the receipt of 클럽 만들기, when the club exists but its setup did not finish) carries its title and, in the back's place, ×. The × is the page's way out: the content holds no ghost Button for it, and when leaving clears something it asks first ("여기서 끝낼까요?", "끝내면 이 기기에 남은 복구 정보만 지워요").
- Scroll-linked title. A hero page shows no title at rest. When the hero title's bottom edge passes the header's bottom edge, the title and a solid header layer fade in together (150 ms
fade, hysteresis 24) and the edge hairline appears. The header's own title isaria-hiddenuntil then. - Over a photo or map. The MediaBand owns the header (see MediaBand).
- Entries that left the header. 초대 코드: the 클럽 tab's + opens a three-row MenuSheet (클럽 만들기 · 초대 코드로 가입 · 클럽 찾기). 선택 and 모두 읽음 on lists: gone (§1.18: no Select mode; owner review 48: a row is read when it is opened). 프로필 수정: not on the profile page (the identity Block has no pencil; it is a statement, not a door) but in 설정 › 프로필 수정, a form page with its own 저장 band.
States.
- Rest · pressed (icon .92 / .78) · hover (.85, tooltip with the glyph's name) · focus (ink ring on the target).
- Long title: tail ellipsis; the accessible label carries the full text.
- 200% text: the bar stays 56; the title is capped at 1.3 (26/36); the subtitle variant stays 64 with the subtitle capped at 1.3.
Content. A pushed title names the screen or the object. English header titles are at most 22 characters (one line at 20/28).
Accessibility. The tab-root title is the screen's h1. Back is "뒤로". Each icon button's name is its registry name, with its badge appended ("알림 3개, 관리 필요 2건 포함"). Mode buttons are named by their visible text. Focus order: back or cancel, title, trailing icons left to right. Header icon buttons offer the iOS Large Content Viewer.
Motion. Hairline 100 ms fadeFast; title and solid layer 150 ms fade. Reduce Motion: unchanged (both are fades).
Replaces in code. AppHeader and app-header-visual.ts (centred 17 title, permanent hairline), the MainTabShell title block (main-tab-shell.tsx), HeaderIconButton (44 target, muted tone, stroke 1.8: header-icon-button.tsx), HeaderTextAction (retired: header-text-action.tsx, ClubJoinHeaderAction in packages/features/clubs/src/shared/club-join-header-action.tsx, the 관리 shortcut in club-detail-screen.tsx, the Edit/Done pair in packages/features/sessions/src/session-detail/session-detail-header-right.tsx), HeaderActionsMenu (header-actions-menu.tsx, becomes the MenuSheet trigger), CollapsingTopBar with use-collapsing-top-bar.ts and collapsing-header-layer.tsx (all in packages/ui/src). Built in K4a as Header (header.tsx, with header-glyphs.ts, Badged in badged.tsx and useScrollEdge). K4b moved the shells onto it and deleted the old bar: DetailShell, MainTabShell, the venue search takeover and WizardShell (as WizardHeader) draw it from title, sub, actions and mode, inside their content column, at z 10; AppHeader, HeaderIconButton, HeaderTextAction and HeaderActionsMenu are gone (the ⋯ is HeaderMenu in header-menu.tsx, opened by the Header itself from an action's menu). A sub fact can be { live } (the pulsing dot and "진행 중", for a session being played; the kit has no leading slot). An action can be coveredByWebNav (hidden on the web from 600 up, where the nav carries it) and a nav-root page passes navRoot so the back is hidden there too; a toggle (the bookmark) carries selected. The scroll edge reaches the Header through ScrollEdgeProvider: FeedList and GroupedFeedList report their own vertical scroll, and a page with a scrolling body of its own renders ShellScrollView (or spreads useReportScrollEdge()). The over-photo bar stays CollapsingTopBar behind header="collapsing" (bare white glyphs on the scrim, the owner's 2026-09-27 choice, not the kit's circles) until the MediaBand wave; it draws the same actions. Search mode waits for the Search field (K6).
Tabs
Job. Switches the panes or filters of one screen (예정 · 매치 찾기 · 테니스장). It is not app-level navigation (Tab bar) and not a form choice (Choice, Segmented).
Anatomy.
- Row min-height 48 (the target rule). Labels are content-width, the first label's text on the rail, 24 between labels (each tab carries 12 of invisible hit padding per side).
- Label 15/22: active 600 ink with a 2 ink underline the width of the label and its count (one row; the underline is 12 under the line); inactive 500 secondary. Scrolls horizontally when the labels pass the width; a label never shrinks or wraps.
- Spacing below the row: 24 to a title or day header, 16 to a row. Above the row: 0 under the Header and 8 under a Lead such as a club's hero, so the Tabs read with what they switch (the owner, 2026-10-20: 클럽 홈's Tabs sat 24 under the hero).
Variants.
- Pane tabs:
tablistandtab,aria-selected, one panel each. - Filter tabs:
groupand buttons witharia-pressedon web (on nativearia-selected: React Native has noaria-pressed); nevertabwithout panels. - With count: a count Tag 4 after the label, attached through a line slot of the label's role (chrome class), so it is centred on the label's first line at every text size. Label and count are one row, and the active underline spans that row, label, 4 and count (the board check
tabUnderline). It grows with its numeral (20, up to 28 at the 1.3 cap); neutral, danger when it needs action. - Switcher (owner review 46, M28): pages of one session or one club that are used one after another share the Tabs row under their Headers, each page keeping its own title in the Header: 참석자 관리 and 참석 확인 draw
참석자 | 참석 확인, for the host and officials only (a member has neither the row nor the second page). It is the pane Tabs' row, so it pins and scrolls as they do; a tap replaces the page with its sibling, so back leaves both. A ⋯ on these pages keeps a sibling's row only because a ⋯ needs three rows.
States. Rest · pressed (dim .85, no settle) · hover (.85) · focus (ink ring) · selected (underline and 600) · 200% text (chrome cap 1.3, the row grows to fit and scrolls) · long labels (scroll, never wrap).
Content. Korean: a short noun phrase (예정, 매치 찾기). English: one word, at most 8 characters. A count caps at 99+.
Accessibility. The name is the visible label, then the count in speech. Web: roving tabindex, arrows, Home and End, automatic activation. The pane swap is instant.
Motion. The underline slides on navSlide (about 300 ms). Reduce Motion: the underline cross-fades in 150 ms.
Replaces in code. PillNav in both variants on list surfaces (pill-nav.tsx, pill-nav-underline.tsx, pill-nav-shared.ts) and SegmentedTabs (segmented-tabs.tsx, whose pane machinery stays) in packages/ui/src. The underline Tabs take a count through the line slot (pill-nav-underline.tsx draws only a 6 dot beside the label today).
Tab bar
Job. The five app destinations (홈 · 클럽 · 경기 · 기록 · 프로필), always at hand on the tab roots. It is neutral and never carries brand colour.
Anatomy.
- Min-height 64 + bottom inset (98 at inset 34), canvas fill continuing under the system bar. Five equal items.
- Item: 12 above a 24 icon (stroke 2), 2, then the label 12/16, 10 below. Active: filled glyph, ink, 600. Inactive: outline glyph, secondary, 500.
- Badge on an icon: a 6 danger dot with a 1.5 canvas ring, or a count Tag 16 tall (Header icon badge geometry), at the icon's top-right through the badged host. The ring's top clears the bar's top edge by at least 8 (8.5 with the icon 12 down).
- A badge on at most two tabs at once; a count only where the number is actionable (the 클럽 tab's "읽지 않은 게시글 2개"); 홈's 진행 중 is a dot.
- Edge hairline while content scrolls under the bar; none at rest; no shadow.
Variants. None.
States. Rest · pressed (.92 / .78 on the item, through a custom tabBarButton on Pressable variant="icon") · selected · focus · hidden while the keyboard is open (tabBarHideOnKeyboard) · 200% text (the bar holds 64 + inset; the iOS label does not scale, Android caps it at 1.3).
Content. Labels are one word (1 to 3 Korean syllables, at most 8 English characters).
Accessibility. iOS button, Android tab, web navigation labelled "주 메뉴". The name is the label plus the badge tail ("홈, 진행 중인 일정 있어요"; "클럽, 읽지 않은 게시글 2개"). aria-selected; no "n of 5".
Motion. None between tabs (screens detach). Press feedback only; hairline 100 ms.
Built 2026-11-02 (K7, additive): TabBar is packages/ui/src/tab-bar.tsx (numbers in tab-bar-visual.ts, held to FVK_CH_TABS / FvkTabBar): items of key / label / icon / badge (a dot, or a count) / active / onPress, the bar's spoken label and each item's spoken tail as props, the sticky edge on scrolled (the pages decide; no scroll context yet). The Expo tabBar and the web phone-width bar convert in W4 (the web bar is 56 today and its 클럽 icon Users; the kit's is 64 and a shield). The web landmark keeps t().common.mainNavigation (주요 메뉴).
Replaces in code. The inline bar in apps/mobile/app/(tabs)/_layout.tsx (brand tint, permanent border, 11/600 labels, extra 8 of padding, DOT_SIZE.XS badge) and CornerCountBadge (packages/ui/src/corner-count-badge.tsx), whose corner offsets move to a Badged host.
Why: review 3, lane A §3 (Material 3 leaves 14 to 16 above a badge, KakaoTalk 15, 당근 8.4): the bar's headroom is the icon's distance from its top.
FAB
Job. The screen's single create action on a list root (홈 + 일정, 경기 + 매치; 클럽 has none). It is the screen's one coloured action, so a page with a CTA band has no FAB.
Anatomy.
- Extended pill, min-height 56, padding 0 20, radius full; icon 20, 8, label 15/22 · 600; ball fill, ink icon and label.
- Right edge on the rail; bottom = tab-bar top + 16 (an offset of 114 from the screen bottom over the 98 tab bar at inset 34), inset + 16 without a tab bar (50). The list's tail clears it: the FAB's offset, its 56 and 16 (the kit's
fabTail), so the last row scrolls clear and the FAB never sits on a row's own control (owner review 48: 멤버 관리's last 초대 sat under the FAB 초대). The verifier'sfab-clearcheck holds every capture to it. - Collapsed: a 56 circle with the icon only.
Variants. Extended and collapsed. The extended pill is the rest state.
States.
- Pressed .96 / .90 · hover (.85) · focus (ink ring) · disabled .5 (rare).
- Collapse after 120 pt of cumulative downward scroll; expand after 24 pt upward or at offset 24 and below. Ignore offsets under 0 (iOS bounce) and programmatic scrolls. Hidden behind the keyboard in 100 ms.
- 200% text: the label is chrome class (cap 1.3); the pill widens and keeps 56.
Content. The label is the noun created (일정, 매치).
Accessibility. button. The name is the visible text first ("일정 만들기" for a "+ 일정" pill), also when collapsed. Reached after the list in focus order. aria-busy while loading.
Motion. Label out 100 ms, width 200 ms standard, label in 150 ms. Reduce Motion: the width changes at once and the label fades 150 ms.
Replaces in code. None today; only the physics entry variant="fab" exists (packages/ui/src/pressable-visual.ts). The fab coachmark key spotlights a discovery card on 홈 (packages/features/home/src/views/home-today-view.tsx), not a floating button.
CTA band
Job. The one pinned primary action at the foot of a detail page, form or sheet. One band, one primary, never a second action.
Anatomy.
- Canvas fill; padding 16 top, max(16, inset) bottom, the rail on the sides: the inset is the bottom padding where there is one (34 on an iPhone, so the button rests 21 above the home indicator), and 16 where there is none or the keyboard is up. Button lg 52, full width of the content.
- Height: 16 + 52 + max(16, inset) = 102 at inset 34.
- Helper line (when warranted): above the button, 15/22 · 400 secondary, at the rail, 8 above the button, at most 2 lines. Band 132 with one line, 154 with two.
- Edge hairline while content scrolls under the band; none at rest; no shadow. Content above ends 40 clear of it.
Helper rules.
- A helper is warranted only by (the owner, 2026-10-20; 1.16): the total the press commits (money, as 전자상거래법 requires: "참가비 10,000원"); a live total or count of what the person picked or marked on the screen ("합계 90,000원", "미응답 2명", "3명 중 1명"); the price of joining on a club's join band; or the action's failure (the danger helper). Never an instruction, a reason, a consequence, who to ask or a figure the page already shows (입금 완료 알리기 has none: the Block above states the amount); a disabled button's reason is the fields' errors on the press. Otherwise there is no helper and the band is 102.
- Decision facts only. Numbers and dates, one sentence or one
Metaline. Money is the total, never a part ("참가비 10,000원", not "10,000원부터"). No promotion, no echo of the button label, no consent folded into a label or pre-ticked, no link to long legal text (a Cell "취소 규정" opens it), no second action, and the same words as the field's own line. - One line by default (about 23 syllables at 15 on 350), two at most; more is a Cell. A fee: "참가비 10,000원"; its cancellation terms are a Cell ("취소 규정"), never a helper line. A missing requirement is the field's error on the press, and a gate is the session's own fact (its level band) beside a disabled button.
- Why: lane B point 16 (Baymard, GOV.UK, 전자상거래법 dark-pattern rules).
Variants.
- Base: one primary, no helper.
- Helper: a neutral helper line above the button.
- Danger helper: a failure that belongs to the action (provider failed, RSVP failed, payment declined): a 16 alert glyph (in a line slot, 8 before the text) and the message 15/22 · 600
$errorText, one line, directly above the button. The button stays enabled for the retry; heights are the same as the neutral helper. - Soft-disabled primary and enabled-and-validating primary: per §2.3.
- Pair: the tonal Button one third and the primary two thirds of the line (set explicitly; see Button), gap 12, in a Sheet footer that needs two answers (FilterSheet: 초기화 and "결과 n개 보기"; the Host review sheet's 그대로 유지 · 이 점수로 바꾸기). A wizard footer and an edit sheet's footer are always a single CTA.
- Entry (sign-in only: the first onboarding screen and the web
/login): the provider set (one ProviderButton per provider, lg geometry, 12 apart; while one provider's sign-in runs only its button shows the spinner, and the other buttons and the ghost wait inert, .5, until it settles), 12, a ghost md Button (이메일로 로그인), 12, then a links row (이용약관 · 개인정보 처리방침, 13/18 · 500 secondary, 18 tall, 44 targets by hit slop) that opens the document pages; then max(16, inset); no hairline. The sets: iOS Kakao, Naver, Apple; Android and web Kakao, Naver, Google. Heights at inset 34: 188 with one provider, 316 with three (306 on an Android gesture inset of 24); each further provider adds 64. - Danger tone: the band's one button in danger (white label) for a destructive bulk action, always preceded by a Dialog unless a Toast undoes it or a typed phrase already confirms it (회원 탈퇴: the phrase is the confirmation, so no second Dialog repeats the page; the forms pass, 2026-10-20): 알림 n개 삭제 commits at once and ends in "알림 3개를 삭제했어요" with 되돌리기 for 8 s.
- Gate: a prerequisite that another page supplies, found at the moment the person would commit. The page shows a warning Notice (T3) whose title is the instruction ("입금 계좌를 먼저 등록해 주세요") and whose action is the door to the page that supplies it ("계좌 등록"); the band's primary is soft-disabled with no helper (1.16); a press shows the missing row's error. Example: 일정 만들기 with a paid fee and no 입금 계좌 on file (payments.md). When there are several doors (회원 탈퇴 for a person who is 대표 of clubs: each must hand over first) the page draws the Notice, a Section head over one nav Cell per door, and no band: it shows why and where to go instead of a disabled form.
Check-in (owner review 48). On 일정 상세, from 30 minutes before the start until the player stamps, a confirmed player's band is 도착했어요, ahead of a fee due (the session states' order: cancelled, check-in open, fee due …). Before the start a press stamps now (a Toast with 되돌리기); from the start it opens the Sheet 언제 도착했어요? of shortcut Choices (지금, 시작할 때, 15분 늦게, 30분 늦게, 직접 → the shared time selector as its second step). Lateness is the stamp against the player's own start, never typed. 참석 취소 moves to the header ⋯ while check-in is open.
States. Rest · pressed (button .97 / .92) · soft-disabled · disabled (a role wall, with no line under it: 1.16) · loading (spinner beside "…중") · keyboard up (the band rides the keyboard; the inset is 0, so 16 below) · 200% text (the button grows to 76, the helper wraps in full and never ellipsizes; the band grows).
Content. Button label: the specific action, a verb that names the result, aimed at 2 to 5 syllables and up to 7 when the verb needs its object (일정 만들기 is 5; 입금 완료 알리기 is 7, because 알리기 alone does not say what is told). A press that only signs in says so and no more ("로그인": the join follows on the page it returns to); a press that sends a proposal is named for it ("제안 보내기"). 가입 신청하기 (6) stays: 신청하기 needs its object. English: at most two words. A helper that is more than two lines is rewritten, never clamped.
Accessibility. The button is the only action. A soft-disabled button is focusable with aria-disabled and its reason spoken. A danger helper is role="alert" when it appears. Loading sets aria-busy and the name follows the visible label.
Motion. Hairline 100 ms fadeFast. A helper change is instant. Press per §2.1.
Replaces in code. BottomCtaBand (bottom-cta-band.tsx, bottom-cta-band-visual.ts: 16 sides, permanent hairline; the bottom padding becomes Math.max(insets.bottom, 16)), the WizardShell footer and its primaryHint (wizard-shell.tsx), and the fee and gate helpers in packages/features/sessions/src/session-detail/actions/primary-action.tsx.
Why: Toss's BottomCTA lets the safe-area inset be the padding (max(safe-area-inset-bottom, 20px)), so the button rests on the safe-area boundary (review 3, lane A §4).
Sticky edges
Job. The one line the flat grammar draws on chrome: a hairline on the edge of a bar while content passes under it.
Anatomy.
- 1 full-bleed line in the edge tier (
#D4D4D4; dark white α0.14). No shadow. - Header and scope tabs: the bottom edge. Tab bar and CTA band: the top edge. Wizard header: the bottom of the progress row, so the pair moves as one.
- A Day header or a Feed head that is the pinned layer (§1.14, the pinned stack): the bottom edge of the head. The Section head draws it with
stuck, as the Day header does. - Visible when content scrolls under the bar: on above offset 8, off at 0 (an 8 band of hysteresis; measured from the bottom for a bottom bar). A screen whose content fits draws none. The cell hairline is not a sticky edge.
Variants. Top-edge and bottom-edge, same line.
States. Hidden · shown.
Content. None.
Accessibility. Decorative; hidden from assistive tech.
Motion. 100 ms fadeFast. Reduce Motion: unchanged.
Replaces in code. The permanent borders on AppHeader, BottomCtaBand and the tab bar. One useScrollEdge(offsetY) hook, fed by the same scroll source as use-collapsing-top-bar.ts.
MediaBand
Job. The full-bleed photo or map at the top of a place-like page (club hub and preview, venue, a session whose venue has a map). It carries only ‹ and at most two icons; everything else is in the body.
Anatomy.
- Photo and map share one geometry: full-bleed, cover-fit, 2.4:1 on the 4 grid, the Block's ratio, for one proportion across the bold surfaces (owner review 6). The rule: image height = round(width ÷ 2.4 ÷ 4) × 4, plus the status bar the band runs under (164 + 44 = 208 at 390); a band is sized by its width, never a literal. A top scrim of ink α0.35 fading to 0 over the first 100 keeps the overlay legible.
- The header is transparent over the band. ‹ at the left; at most two registry icons at the right, share first, then save or ⋯. Each is a 40 circle in the one on-photo fill, ink α0.6 (α0.8 under Reduce Transparency), with a white 24 glyph, in a 48 target; 12 between neighbouring circles (pitch 52; Apple's ~12 of padding around bezeled controls, review 43); the outer circle edges sit on the rail. Nothing else is drawn on the photo except the photo counter.
- Map band: a 24 ink pin centred in the visible area under the header (70 above the band's bottom edge, y 154 of 208 at 390). No Tag or chip. A tap opens 장소 상세 (the venue page); directions live there. On a session's page (일정 상세, 매치 미리보기) the band carries the venue's door, the Door pill 장소 보기 › on the photo surface, at the right rail and 12 above the bottom edge (owner review 48: the 장소 Cell under the hero left); from 150% text the pin gives way to it, and the error tile draws no door.
- Photo band, club: COVER-ALWAYS. The band always shows a photo: the album photo, else the club's banner, else the club's assigned default cover.
- The photos (review 43): up to five (
selectMediaBandPhotos: the club's featured set first,club_mediakindfeaturedpicked by officers, then the latest approved to fill five; the plan tier's featured cap only limits what officers pick), the first the cover, then the album frame: the last photo at ink α0.6 with the door 앨범 전체 보기 › and "사진 12장". NN/g: keep a set to five or fewer, show the count and the position, never auto-advance. No photo: the default cover at the same height. - The status bar (owner decision D3): light over the photo, dark over a bright cover (the mean luminance of the cover's top 100 above 0.6, derived from the cover's ThumbHash:
club_media.thumbhashorclubs.banner_thumbhash, generated on the device at upload;isBrightCover). The ThumbHash is also the photo's loading placeholder (RemoteImage thumbhash). - The photo counter (review 43).
- Look: "1 / 5" in 13/18·600 white tabular numerals on the same on-photo fill as the circles, ink α0.6, 24 tall, r12, 8 each side, its outer edge on the rail, 12 above the band's bottom. One fill for everything on a photo (owner: "Why is the counter different from icons?"): α0.6 is the least ink that keeps white 13 text at 4.5:1 over a white sky (4.8:1, WCAG 1.4.3), and it gives the circles' glyphs more than their 3:1 (1.4.11), so the circles moved from α0.5 to α0.6 instead of the counter differing. It belongs to the photo, not the theme, so it is the same in dark. The earlier neutral solid Tag turned light in dark and fell to about 3.4:1 on a bright photo. The numerals grow with text; the circles do not.
- Behaviour: shown only with two photos or more; never on the album frame, the default cover or while loading; it changes the moment a swipe settles, without motion. It is not a control and is hidden from assistive tech: the band is one adjustable element named "클럽 사진, 5장 중 1번째", and a swipe up or down moves it. A tap on a photo opens the album viewer at that photo.
- Place actions are labelled and in the body: a venue shows an action row 길찾기 · 공유 · 전화 under its title; a session's 단톡방, 문의하기 and 신고 are 정보 Cells.
- Scroll: after the photo's last 100 has passed under the header (at offset 108, the band's height less 100) the header turns white in 150 ms, the circles lose their fill, the glyphs turn ink, the title fades in and the edge hairline appears. The status bar is light over the photo and dark after.
Variants (icons on the photo, right side).
| Page | Right icons |
|---|---|
| Club hub, member | 공유, ⋯ (신고 · 나가기 · 클럽 알림) |
| Club hub, official | 공유, gear 관리 with a badge |
| Club page, non-member | 공유 |
| Venue | 저장, ⋯ (지도 앱에서 열기 · 정보 제보 · 신고) |
| Session with a map band | member: 공유, 저장. Host or official: 공유, gear 관리 with a badge |
States. Rest · map pressed (dim .85, no settle) · focus (ring inset 2) · loading (gray100) · error (the court tile for a map; the default cover for a club) · scrolled (white header) · 200% text (glyphs and circles do not scale).
Content. First content starts 24 below the band. The band carries no copy.
Accessibility. Each circle is a registry button named by its registry name. The map band is one button named for the venue and its destination ("올림픽공원 테니스장, 장소 상세"). The album counter is spoken as a position ("사진 12장 중 1장"). The photo itself is decorative.
Motion. Header turn and title 150 ms fade; image fade 150 ms. Reduce Motion: unchanged (fades).
Replaces in code. MediaHero (media-hero.tsx, including its mediaHint pill), CollapsingTopBar over a photo with PhotoScrim (photo-scrim.tsx), ClubAlbumBanner (packages/features/clubs/src/shared/club-album-banner.tsx: pager dots and the 앨범 N pill), club-place-header.tsx, and the session map band in SessionHeroSection. Build note: packages/ui/src/media-hero-visual.ts HERO_BAND_ASPECT becomes 2.4, with its test.
Live row
Job. The one line under the header that says a session is being played now, on the 스코어카드 and the 대진표. It replaces the solid LIVE band.
Anatomy. Min-height 24, directly under the header, 8 above and 8 below. The Live tag (per Labels), attached through a line slot of the meta's role so it stays centred on the meta's first line at every text size, 8, then the date and venue as Meta 13/20 · 500 secondary at the rail.
Variants. None.
States. Rest · pulse (the tag's 6 white dot pulses 1600 ms for 5 s per mount, then rests solid) · 200% text (the tag stays one line; the meta wraps).
Content. The date and venue only. The venue may crop; the date never.
Accessibility. One text group, read as "진행 중, 9월 19일 토요일, 올림픽공원 테니스장". The dot is hidden. Not pressable.
Motion. The pulse only. Reduce Motion: solid dot.
Replaces in code. SessionLiveAnchor (packages/app/src/presentation/components/sessions/session-live-anchor.tsx) and the page-level use of SessionLiveBand (session-strip-ui.tsx).
WizardShell header and progress row
Job. The chrome above a wizard step: movement and departure live in the header, orientation in the progress row, and the footer holds one CTA.
Anatomy.
- Header, min-height 56 with 8 of top padding, so the controls sit 12 below the status area and 12 above the bar. Left, one button: a 40 × 40 r12 gray100 square with a 20 ink chevron in a 48 target (the square runs y 12 to 52 from the row's top; × on the first step, which asks the exit Dialog). Right: 건너뛰기 as a sm tonal Button on a skippable step (y 14 to 50). Nothing in the centre.
- Progress row, 8 below the header: segments 4 tall, r2, gap 4; ink for done and current,
#E5E5E5for the rest. The count "3/6" sits at the right rail, 13/18 · 600 secondary, tabular, 12 after the last segment. The bar is 12 clear of the header buttons (the 40 square ends at y 52, the bar starts at y 64). - At most 7 segments. Beyond 7 steps the row is one continuous bar of the same height, filled to the current step, with the same count.
- The step title (hero 24/32 · 700) starts 24 below the bar. Chrome above the title is 92.
- Footer: the single CTA band and its helper only where 1.16 keeps one. Hardware back mirrors the header back through
useWizardBackHandler. - The edge hairline, when content scrolls under, is drawn under the progress row.
Variants.
- Back, labelled: during an edit-from-review detour the square becomes a content-hugging pill of the same fill and radius: a 20 chevron and the destination noun (e.g. 검토, 15/22 · 600).
- Close: × on the first step and on a gate. Onboarding's under-14 stop keeps the header and its progress row; its × asks the exit Dialog as on the first step, and the 로그아웃 in the content does the same job. A terminal page that is not one of the steps (a creation's receipt) is not a wizard step and uses the Header's × (§ 3 Header, Variants).
- Skip on a last step with no CTA. A last step whose Cells are the answers and that has no footer CTA (onboarding's 첫 시작) is skippable: 건너뛰기 in the header is its way out, where a ghost 나중에 할게요 in the content stood (R3, the lead's call 9, decision log 2026-10-20). It is disabled while a Cell is in flight and while the page is offline. A last step that holds the review's CTA has no skip.
States. Rest · pressed (.97 / .92) · focus (ink ring) · skip disabled while the primary is in flight · 200% text (header buttons are chrome class, cap 1.3; the bar and the count stay on one line).
Reveal. A step draws its first section only; each next section appears, in place, when the one above is answered: a choice on its tap, a text field once it has its minimum (a club name, two characters). A section with a real default (a defaulted axis, a band, a Stepper, a nested field of a choice already made) is answered from the start, so it appears with the section above it, and a step with no required section draws all of its sections. A revealed section never hides again: clearing an earlier answer shows that field's own error, not a shorter step. Nothing names the first section still missing before a press (1.16); a press on the soft-disabled primary shows that section's error. The board draws one frame for each reveal (the frame before and the frame after the answer that reveals it) and one all-answered frame; loading, error, 200%, dark, English, resumed and closing frames use the all-answered one. A one-page form that creates something and has no WizardShell (행사 만들기, 장소 정보 제안, 소유권 신청) runs its sections the same way.
Content. Step titles are one sentence in 해요체. The skip label is 건너뛰기.
Accessibility. Back and skip are real buttons named by their label ("뒤로", "건너뛰기"; close "닫기"). The progress row speaks "6단계 중 3단계" and each step change announces "6단계 중 3단계, 제목". Completed segments are buttons named "n단계로 이동", with a 44 hit zone from the header's bottom edge (y 56 to 100) so it never overlaps the header targets. Focus moves to the new step's title.
Motion. Segments recolour in 150 ms fade; the continuous bar grows in 150 ms; no motivational animation. Reduce Motion: the bar changes at once.
Replaces in code. WizardShell (packages/ui/src/wizard-shell.tsx): the inline 4 px segments between back and skip, its "n / N" count, and the footer primaryHint placement. Built in K4b as WizardHeader (wizard-header.tsx, numbers in wizard-header-visual.ts, held to FvkWizardHeader by wizard-header-visual.test.ts); WizardShell draws it inside the wizard column on the canvas, keeps its BottomCtaBand footer until K6, and a step's ShellScrollView draws the edge under the progress row. The header's title (the step's hero line) is not used by a host yet.
Why: 12 above and 12 below the controls balances the header as the footer's max(16, inset) balances the band (review 3, lane A §4).
Search takeover
Job. The full-screen search layer for an open-ended search inside a flow (the one venue-pick surface). It replaces an in-scroll search field.
Anatomy.
- A full-screen layer rising 300 ms with the keyboard up and the field focused.
- Header row, min-height 56: the Search field (40 tall, r12, gray100, 20 search glyph, 8, input text 16/24, placeholder tertiary, a clear × 20 in a 44 target when filled) inside a 44 target filling the row from the rail; 8; 취소 as a sm tonal Button at the right rail.
- Body: recent searches as Text rows with a trailing × control; then results as MediaRows. Empty: the filtered-empty EmptyState plus the contribution row ("찾는 곳이 없나요?").
- Footer (staged mode): the CTA band with the one confirm, present once a row is staged. A row tap stages; only the CTA commits.
- The step keeps a search-field-shaped trigger row that opens this layer.
Variants. Staged (default): a row stages, the CTA confirms ("이 테니스장으로" for one, "N곳 선택" for several). Immediate: a host that owns per-pick choreography (the multi-pick club step) commits on each row tap and shows no CTA.
States. Rest (recents) · typing (results) · empty · loading (skeleton rows) · offline (the strip) · 200% text.
Content. The keyboard's 검색 key commits free text inside the layer.
Accessibility. Focus lands on the input; on close it returns to the trigger row. Dragging the list dismisses the keyboard (keyboardShouldPersistTaps="handled"). System back closes the layer.
Motion. Rise 300 ms sheetIn. Reduce Motion: fade 150 ms.
Replaces in code. VenueSearchTakeover (packages/app/src/presentation/components/venue-search-takeover.tsx) hosting VenueFinder in pick intent.
Header icon badge
Job. A mark on a header icon that says something there needs attention, or how many.
Anatomy.
- Dot: a 6
$errorcircle with a 1.5 canvas ring. Count Tag: 16 tall, min width 16, radius full, 12/16 · 700 white on$error, 1.5 canvas ring, 99+ cap; it does not grow with the text. - A corner adornment (§1.13 IA5): the badged host anchors it to the glyph's top-right corner. The ring's outer corner sits 3.5 above and 3.5 beyond the 24 glyph box (the fill overhangs it by 2).
- Clearance: whatever carries the icon keeps the ring's top at least 8 below its own top edge (the tab bar's icon sits 12 down, so 8.5).
- A dot means "something is pending" (the 관리 gear, a live tab); a count means a number the person acts on (알림, 메시지). Never both. A badge on at most two tabs of the tab bar at once.
Variants. Dot and count Tag. The tab bar uses the same marks.
States. Absent · present. A count changes instantly.
Content. Counts are digits; 99+ above 99.
Accessibility. Hidden from assistive tech; the icon's name carries the state ("알림, 새 소식 3개", "관리, 처리할 일 있어요").
The 알림 count (owner review 26; revised by review 46, M14). The bell counts the unread rows of 알림, one per group (a repeat is one row), and nothing else: never 할 일 (the 홈 section's, which is state, not news) and never chat (메시지 has its own badge and its own push). So it equals the 안 읽음 value of 알림's filter. An urgent arrival counts: a cancelled session is one unread row. Reading a row clears no task in 할 일, and a task never adds to the bell. The client no longer nets critical and high rows out of the count.
Motion. None.
Replaces in code. CornerCountBadge (packages/ui/src/corner-count-badge.tsx: an 18 circle, amber and red tones) and the pill tab BadgeDot (pill-nav-shared.ts), by a Badged host that owns the corner offsets.
Coachmark
Job. A one-time pointer at a control that a person would not find alone (the 관리 gear for a new official, the create FAB). It points; it never blocks a flow.
Anatomy.
- Inverse surface (
inverse: ink#0D100E; dark#DFE4DC), radius 12, padding 16, an 8 arrow toward the target, width at most 280. - Body 16/24
onInverse(white; ink in dark), one or two sentences. Footer row min 44 with the action at the right: a sm primary Button (ball on the ink surface, 14.96:1; a command is a Button, IA8); a multi-step mark shows its position ("1/3") at the left. - The spotlight overlay around the target is the tour library's.
Variants. Single step (action 확인) and multi step (다음 until the last).
States. Shown · dismissed. A coachmark fires once, ever, per key; two never stack; Settings can reset them (둘러보기 다시 보기).
Content. Body only, no title, no icon. The text for a header icon is that icon's registry name plus one sentence.
Accessibility. The text is announced when it appears (polite). The action is a button. System back or Escape dismisses and marks it seen.
Motion. Fade in 150 ms, out 100 ms; no slide. Reduce Motion: unchanged.
Replaces in code. CoachmarkTooltip (packages/app/src/presentation/components/coachmark-tooltip.tsx: title, body, 확인 pill), tour-ref-injector.tsx and the CoachmarkKey store (coachmark-seen.store.ts, which gains a key per new coachmark).
4. Surfaces and content
Block
Job. The screen's one answer: a label, one figure and its qualifier on the ink surface, pressed as a whole. One per hub; where the numbers are not the screen's answer, use a Strip or a Cell group. No Block inside a club page (the owner, 2026-10-20: "club pages will show very little"): its MediaBand and hero already lead it, and a 150 Block under them leaves a pane little room, so a pane's summary is a Section head over the tiles face (클럽 홈's 기록 season, a member's 회비). A Block leads a tab root only (§1.17, L1: 홈, 기록, 프로필); every page below sums up with a Section head over the tiles face (회비 관리, 멤버 기록; a member's 회비 on 클럽 홈).
The three-row law (owner review 40). Every Block is three rows and no more: A (the label and its door), B (the figure, with at most one graphic at its right), C (one line of facts). At 1× it is 150 tall: 20 · A 24 · 8 · B 48 · 8 · C 22 · 20.
- Why three, and why 150: a Block is a glance card. Apple sizes a medium widget on a 390 × 844 phone at 338 × 158 and says a small widget "typically show[s] a single piece of information"; a Lock Screen Live Activity runs 84–160 tall, with the guidance "Focus on important information that people need to see at a glance. Your Live Activity doesn't need to display everything… they can tap… to open your app" (HIG, Widgets and Live Activities). Metric cards elsewhere carry one metric with its context (a change, a sparkline) and a link to the detail (Shopify's metric card, PatternFly's dashboard cards). The 홈 Block's four rows (196) fell outside that band.
- Not the Feed item's 128: three rows at the display figure need 134 even with no gaps, so 128 would cut the figure from 40/48 (
display, the Block's own step) to 24/32 (hero) and the padding off the rail. The Block would then read as a dark Feed item, the first row of the list instead of the page's answer. - Anything more sits behind the door. From 1.5× the graphic drops under the figure and rows may wrap (the reflow rule, §1.14).
Anatomy. r16 · pad = rail (20) · fill #15181A (dark #2A302B) · content width (350 at 390) · min-height 144 · one left text axis · 24 from the block to the content below. Three zones, top to bottom, 8 between them (BG1):
- A, eyebrow row: 13/18·600 white α0.8, 18 tall. It may carry one Tag at its trailing end: 8 after the eyebrow, never shrinking. The Tag (or the door below) holds the top right of row 1 at every text size, in a line slot as tall as line 1; the eyebrow beside it wraps for a date or a sentence and crops to one line with a tail ellipsis for a place. The row is 24 tall only while a Tag or door is present.
- B, figure row: numeral 40/48·700 in ball
#D6F04A; its unit 15/22·600 white on the same baseline, 4 apart. - B's graphic: at most one, 16 after the figure at the row's right, centred on it: the rating's 30-day line (112 × 40, ball, the last point marked), the faces of who comes (32, each over the one before by 10, ringed 2 in the block's ink) with their count, or the progress ring of a share (48 across, 6 thick: the track white α0.2, the arc ball from 12 o'clock with round ends; the dues Block). It is
aria-hidden; the Block's name says it. - C, facts: one line, 15/22·500 white α0.85. No progress bar: it would be a fourth band.
- Door (D1, the law of the chevron, §1.13 IA7): a Block that opens somewhere names the place at the end of zone A, the sm Door pill on the block surface (24 tall, the label 13/18·600 white α0.8, a white α0.4 border and a 16 chevron; owner review 48), 8 after the eyebrow, on row 1 at every text size (R2: the door never wraps under the eyebrow; the eyebrow wraps or crops beside it; the verifier's
doorcheck holds its line slot to the first line's top within 0.5, at 100% and 200%). There is no chevron at the Block's edge and no lane for one: the Block's padding is the rail on every side. A door that opens a choice instead of a place (the dues Block's month) takes the Choice's down chevron: a right chevron always means going somewhere. A Block with no destination draws no chevron. Zone A is 24 tall while it carries a door or a Tag.
Ratios (BG2): figure : eyebrow 3.1 (once per hub; a Strip figure is 1.85); unit 0.375 × figure; never two 40 figures on one screen. Affordance (BG3, BG4): a Block with a door is pressed as a whole and its Door pill is the one visible cue (the door is part of the target, not a second stop); a Block without a door is not pressed, and neither is the dues Block, a statement whose one control is its period door; no other buttons inside. Contrast: eyebrow 11.65:1, meta 13.0:1, numeral 13.95:1.
Variants. The five compositions below; they differ only in what fills A, B and C.
States. One table for every block (BG6). Pressed .98/.90 (the card physics); focus is the standard 2px ink ring, offset 2, outside the block.
| State | Zone A | Zone B | Zone C | Press and a11y |
|---|---|---|---|---|
| Loading | bar 40% (13/18) | bar 24% (40/48) | bar 60% (15/22) | gray100 bars on the block fill, pulse .6 ↔ .9 stopping at 5 s; aria-busy; not pressable |
| Empty (first run) | stays | a 24/32·700 sentence ("일정 없음", "랭킹 전"), never a "0" | one next step as text ("클럽 일정이 올라오면 소식에 보여요") | pressable when the step has a destination |
| Error | stays | "—" at α0.6 | "불러오지 못했어요" 15/22·500, not red | press retries; spoken "다시 시도" |
| Offline | stays | last known figure | the meta plus one more segment, "16:50 기준" | the offline strip is the only alert; press works on cached data |
| Long text | a date or sentence wraps, a place crops to 1 line with a tail ellipsis; the door stays on row 1 | the numeral never truncates and never shrinks | one line; a place crops | the block opens the full text |
| 200% | reading cap 2.0 | numeral cap 1.5 (60/72); from 1.5 the unit may wrap and the graphic drops under it | wraps | the block grows; the door stays on row 1 beside line 1; zones keep their order |
Content. The eyebrow names what the figure is, the figure answers, the meta says what to do or know next. Places may crop; person names never do. English uses the short date and count formats (Sat, Sep 19, 4 courts).
Accessibility. button, one stop. The name is composed from the three zones in reading order (eyebrow, figure with unit, facts); B's graphic is aria-hidden because the name carries it. Loading sets aria-busy.
Motion. Press settle plus dim; numerals update instantly (no count-up); the skeleton pulse stops at 5 s. Reduced motion: dim only, static skeleton.
Replaces in code. HeroCard (packages/features/records/src/cards/hero-card.tsx, the ELO hero), the TypeHero identity header and EloFactRow in ProfileScreen (packages/features/profile/src/profile-screen.tsx), SummaryCard (packages/ui/src/summary-card.tsx, unused), StatGrid as a hero (packages/ui/src/stat-grid.tsx). One Block primitive with eyebrow, figure, meta slots and a state prop; the four compositions are configurations of it.
Block, 홈 day
Job. The viewer's next game on 홈 (owner reviews 28, 34 and 40; approved with 홈): when, who comes and where, in three rows.
Anatomy. Block grammar, 150.
- A: "다음 일정 | 내일, 9월 19일 (토)" (Meta) and the door 일정 보기 ›.
- B: the time as the figure, "09:00" in ball. At its right, the faces of who comes (four of 32) and "6/8".
- C: the place with a 16 pin, one line (it crops), and at the far end the weather in the app's own form ("☀️ 18°").
States. One state at a time, in order: cancelled, check-in open, fee due, waitlisted, confirmed (review 28).
- A fee due: C is "참가비 10,000원" and, at the far end, "오늘 18:00까지"; the door is 입금하기 ›. The place is one tap away, on the session. English: C reads "Fee ₩10,000" and, at the far end, "By 18:00 today"; the door is Pay ›.
- Nothing ahead: "다가오는 일정 | 일정 없음", the next step in C ("클럽 일정이 올라오면 소식에 보여요"), and the door 매치 찾기 ›.
- Offline: the far end of C is the time the Block was true ("16:50 기준").
- Otherwise as the Block table.
Press. The whole Block opens the session (입금 when a fee is due).
Accessibility. "다음 일정, 내일 9월 19일 토요일 오전 9시, 올림픽공원 테니스장, 8명 중 6명 참석, 맑음 18도".
Replaces in code. The 홈 hero cards (packages/features/home/src/views).
Block, ELO
Job. The viewer's rating on 기록: the figure, its 30-day line and the distance to the next tier (review 40).
Anatomy. Block grammar, 150.
- A:
Meta("ELO", "NTRP 3.5"): the one NTRP the rating maps to, the same as the identity's Level tag (owner review 2; a band appears only where a session states the range it accepts). The door 티어 안내 › ends zone A. - B:
1,482(no unit), and at its right the 30-day line. The trend chart section of earlier drafts lives here now (review 39). - C:
Meta("최근 30일 +94", "마스터까지 518점").
The tier progress bar of earlier drafts is gone. It was a fourth band, and C says the distance in words. Press opens the tier explainer sheet (the one TrustTierInfoSheet-style explainer, five tiers with the viewer's marked), not a rating detail page.
States. As the Block table. Empty: B "랭킹 전", C "경기 N번 더 기록하면 보여요", no line.
Accessibility. "ELO 1,482, NTRP 3.5, 최근 30일 94점 상승, 마스터까지 518점 남음"; the line is aria-hidden.
Replaces in code. HeroCard (packages/features/records/src/cards/hero-card.tsx), its TriRingProgress and ProgressBar, and EloTrendCard.
Block, dues
Job. The month's dues on the 회비 page (owner review 46, the owner's check of 2026-10-19: "for the dues summary, we need to bring more visual emphasis to the summary because it is the most important"): how much of the amount due has come in, and who has paid, in three rows. It leads the page, so the month's progress is the first thing a 총무 reads.
Anatomy. Block grammar.
- A: "9월 회비" and the period door 월 선택 ▾, the grammar's door with the down chevron of a Choice, because it opens a Sheet of months. The month Choice that sat in the 납부 현황 head is this door.
- B: the amount collected as the figure ("370,000", with 원 as its unit) and, after it, what it is out of ("/ 570,000원", 15/22·500 white α0.85, 8 after the unit); at the right, the progress ring (collected / due). At 390 the figure, its unit and the pair are 275 wide and 246 is left beside the ring, so the pair wraps whole under the figure and B is 70 (the Block 172); where it fits beside the ring the Block is 150.
- C: who has paid, one line: "19명 중 12명 납부 | 미납 7명".
Press. None: the Block states the month, and its one control is the door.
States. As the Block table. Empty (a month with no dues): B "회비 없음" (a sentence, never a 0), C "회비를 만들면 납부 현황이 보여요", the door stays so another month can be chosen, and the page's first-run EmptyState below carries the button. Offline: C ends with "16:50 기준". English: "Sep dues", "Month ▾", "₩370,000", "/ ₩570,000", "12 of 19 paid | 7 unpaid".
Accessibility. A group, not a button: "9월 회비, 570,000원 중 370,000원 납부, 19명 중 12명 납부, 미납 7명"; the door is its own stop, "월 선택"; the ring is aria-hidden.
Replaces in code. The month selector of AdminDuesListHeader (packages/features/clubs/src/club-dues/sections/dues-list-headers.tsx); the summary it dropped in round 4 (DuesSummary's StatStrip and ProgressBar) returns as this Block, on the roster page only: 클럽 홈's 회비 pane keeps the one-line What's left of the Strip section.
Block, dues, mine
Job. What a member owes by the club's own plan (owner review 46, M17): the open period, what it costs and when, as a statement. The club's month is the officials' Block above; this one is mine.
Anatomy. Block grammar, no door and no ring, not pressed.
- A: the plan and its period, as
Meta: "3분기 회비 | 7월~9월", "9월 회비", "2026년 연회비", "정모 회비 | 3회 미납", "총회 회식비 | 한 번만". - B: the amount I owe as the figure ("90,000", 원 as its unit).
- C: the due day, the days left and the state: "9월 25일까지 | D-6 | 미납"; overdue "9월 5일까지 | 14일 지남 | 미납"; paid "9월 3일 납부 | 완납". A date more than a week away shows no D-n (the Feed item's rule).
States. Paid: the same Block says 완납 and the account and the band go. No plan yet: no Block, one calm line ("아직 회비가 없어요", "총무가 회비를 정하면 여기에 나타나요").
Accessibility. A group: "3분기 회비, 7월부터 9월, 90,000원, 9월 25일까지, 6일 남음, 미납".
Block, season
Job. The club's 기록 pane (owner review 46, M10): which season it is and how much was played in it.
Anatomy. Block grammar.
- A: "2026 가을 시즌" and the door 시즌 선택 ▾, the grammar's door with the down chevron of a Choice, because it opens a Sheet of seasons (newest first).
- B: the matches played, "184" with 경기 as its unit.
- C: "활동 멤버 38명 | 평균 20회 중 17회 참석". A club-mate reads attendance as a count, never a rate (§4 Trust).
Press. The whole Block opens the season Sheet.
Accessibility. "2026 가을 시즌, 184경기, 활동 멤버 38명, 평균 20회 중 17회 참석".
Block, identity
Job. Who the viewer is on 프로필: face, name, level and club.
Anatomy. Block grammar, min-height 144. One row centred in the block (24 above and below the avatar at the floor): avatar 56 · 12 · a text column. Its first line is the name 24/32·700 white (wraps to two lines, never cut) followed by the Level tag and, at the line's right end, the 신뢰 door (the owner, 2026-10-31, on 프로필: "treat the trust score as a small label ... at the right of the top row", as 당근 keeps its 매너온도 beside a profile's identity rather than at its centre; NTRP is the person's headline): the sm Door pill on the Block surface (a door is a Door pill, never bare text: owner review 48), 신뢰 72점 ›, or the steps before five sessions (신뢰 3/5회 ›), centred on the name's first line through the line slot; it opens 신뢰 기록, and a stranger reads the score alone. On 신뢰 기록 itself the block draws no door. Then 4, then C as Meta(club, "구력 3년") 15/22·500 α0.85, the same facts on every person page: the club (an official's role after it, 강남 라켓클럽 총무; several clubs as the first and 외 N곳; 아직 가입한 클럽이 없어요 when there is none), then the 구력 (while the rating is not settled, NTRP 자가 평가 or NTRP 임시 stands in its place). The club is a fact, not a door (one door is enough on a Block, and a door is a Door pill), so 공개 프로필 no longer draws the club as a nav Cell under the Block (the owner, 2026-10-31: "club information can be part of the block"). The kit composes C from club and tenure, so no page passes facts of its own. The block holds no ELO or region (ELO is a 전적 tile) and no relation to a club (an applicant's 게스트 is 가입 심사's 가입 신청 section). The public profile is a Cell in 설정 › 계정, 공개 프로필 보기, beside 프로필 수정. What a public profile tells (owner review 52: "people can view these profiles to get a sense of the kind of a player that person is"; grounded in Playtomic, whose profile is the level, its reliability and the win rate, and UTR, whose profile is a singles rating and a doubles rating with the results behind them): under the Block, the 전적 Strip (총 경기, 승률, ELO) and then 플레이 (단식 ELO and 복식 ELO as tiles, 주 종목 and 선호 시간 as a facts line from what the person set), 최근 경기 (the last five results as match rows, each opening its 스코어카드 where the viewer may see it), 나와의 전적 for a club-mate or friend, and 업적. The 전적 head's door 전체 보기 › opens 기간 기록 (L3-04) read as that person's: one page for one subject (owner review 50), with a userId and only what the person's visibility settings allow (the identity, ELO and 신뢰 are always visible; the rest follows profile_visibility). A stranger sees the Block, the 전적 Strip and 업적. Every person page leads with it (owner review 50: "I think all profile pages lead with a block"): 프로필, 공개 프로필, 가입 심사 (the applicant's) and 신뢰 기록's officials' view. A Block is the app's statement of a page's one subject, so every page about a person opens on the same object, and the facts another player weighs before playing (who, how good, which club, whether they show up) keep one place, as Strava, LinkedIn and 당근 lead theirs. The face is the person's photo as this viewer may see it: it reaches a viewer only through public.can_view_face (the person, a shared active club, an accepted friend, or the person's explicit public opt-in; never an anonymous viewer), and a NULL face is the initials (Faces, data-and-hooks). The initials stand in the same 56 circle: 20/28·700 white on a white α0.2 disc, as the day Block's faces on ink; a Hangul name gives its first syllable, a Latin name its first and last initials ("MK"). The Block never draws a lock, a blur or a stand-in portrait. A stranger on 공개 프로필, a viewer outside the person's audience and a person who kept the photo private all see the initials; the person sees their own photo.
States. As the Block table. Empty: the name unset shows "이름을 입력해 주세요" in the name slot. By member state (the owner's page review, 2026-10-27; from user-state.rules.ts and profile.entity.ts): the Block states the facts that exist and none that do not. The Level tag follows the name once there is a rating. C is the club, an official's role after it (강남 라켓클럽 총무, 강남 라켓클럽 대표), several clubs as the first and 외 N곳, then the 구력. While the rating is not settled, what it rests on stands where the 구력 would, so the ball is never read as more than it is: NTRP 자가 평가 (chosen by the player, no match yet) or NTRP 임시 (under 10 matches). A new player with no club and no rating has no Level tag, and C says so in words: 아직 클럽이 없어요. 클럽 찾기에서 가입해 보세요 (the Block presses nothing). A player with no photo gets the initials in the same circle.
Accessibility. One text group, not a button: "김민준, NTRP 3.5, 강남 라켓클럽 3년 차".
Replaces in code. The TypeHero identity header (avatar leading, titleBadge, facts) and EloFactRow in ProfileScreen (packages/features/profile/src/profile-screen.tsx).
Match state block
Job. The door to one match, on the scorecard: the court and the two teams, and the one thing this viewer can do next. It replaces the live scoreboard; no score in it is live. The live signal is time (owner review 48): the line says how long the viewer's match has been played, or, while the viewer rests, how long the match before theirs on that court has run.
Anatomy. Block grammar, 144 in every state; the Live row (Live tag, date and venue) sits above it under the header. A score arriving or changing state never moves the layout.
- Zone A, 13/18·600 α0.8 (24 tall while a door or a Tag is present): the court name (a place: it crops to one line); a held match says why instead (below; a sentence, it wraps). Its end is the door, the viewer's next action as the sm Door pill: 점수 입력 › when no score is in, 점수 검토 요청 › on an entered score (D1); with no action there is no door and no chevron. When there is a state and no door, one state Tag (sm 24; the Match Tag map of §10) trails the row, 8 after the eyebrow. A door already is the state for this viewer, so no Tag is drawn beside it.
- Zone B: Team lines (context
block), one team per line, a 32 line, 8 apart, with the faces (32, ringed in the block's ink, initials white on white α0.2, dropped from 1.5× text); the block stays 144 (24 + 8 + 72 + 40). Names 15/22·600 white α0.85 (a doubles pair as two names with the 1px break; they wrap whole, never cut); once a score is in, each team's games per set in 32 wide tabular columns on its own line, 24/32·700 ball. The viewer's team is first and the viewer's name takes "(나)". The winner is emphasised on an entered score, one under review included (the score stands until the host changes it): the winner's numeral is ball and its names white, the loser's numeral and names white α0.7 (about 9:1). A void block shows both teams equal and draws no numerals. - No zone C. No control and no live numerals are drawn: the whole block is the one press target, and the door text names where it goes.
- Entered (H3) shows no Tag: the score stands and is rated. The door 점수 검토 요청 › is the appeal's one entry point, open to a player until 7 days after the session; a spectator sees no door. Under review (H4) wears the Tag 검토 중 (warning weak) and no door, since a review is already open; the entered score stands. Not counted (H5) wears 기록 제외 and draws no numerals.
- Held (D4, derived): zone A reads 이전 경기 점수 입력 후 시작 (shortened from "확정 후" so the sentence stays one line at 390), or for the blocked player 내 이전 경기 점수가 필요해요 with the door they owe on their earlier match, 점수 입력 ›; the Tag 대기 for the others; no numerals; no line. Both held sentences fit one line at 390 (checked in Korean and English); a longer one wraps inside the 144 rather than growing the block.
- Stopped (D5): the court, the games so far (none for a walkover or a cancelled match), the Tag of the outcome (기권, 부전승, 실격, 일시 중단, 미완료, 취소됨), the winner emphasised only where the outcome has one; a suspended block adds the line "6-4 3-1에서 멈췄어요", an abandoned or cancelled one "레이팅에 반영되지 않아요".
- State line: a Caption row (attach object, 8 above) directly under the block, then the block's 24 tail. It is derived from the door and the state, never passed; a state its door, Tag or zone A sentence already says has no line.
Variants. By the viewer's next action and the state:
| Block | Zone A | Tag at its end | Zone B | State line |
|---|---|---|---|---|
| nothing to do, no score | court name, no door | none | teams | none |
| in play (owner review 48) | court name, no door | none | teams | 경기 18분째 |
| the viewer's next match, resting (owner review 48) | 다음 경기 and the court, no door | none | teams | 앞 경기 18분째예요 |
| 점수 입력 | court name, door 점수 입력 › | none | teams | 경기가 끝났어요 |
| 점수 입력, still marked playing past its scheduled end | court name, door 점수 입력 › | none | teams | 예정 시간이 지났어요 |
| entered (H3), a player | court name, door 점수 검토 요청 › | none | teams and the score, winner emphasised | none |
| entered (H3), a spectator or after 7 days | court name, no door | none | teams and the score, winner emphasised | none |
| under review (H4) | court name | 검토 중 (warning weak) | teams and the score, winner emphasised | none |
| not counted (H5) | court name | 기록 제외 (neutral) | teams, no numerals | 레이팅에 반영되지 않아요 |
| held (D4) | 이전 경기 점수 입력 후 시작 | 대기 (neutral) | teams, no numerals | none |
| held, the blocked player | 내 이전 경기 점수가 필요해요, door 점수 입력 › | none | teams, no numerals | none |
| suspended (D5) | court name | 일시 중단 (warning) | teams, the games so far, no winner | 6-4 3-1에서 멈췄어요 |
| retired, defaulted | court name | 기권, 실격 (neutral) | teams, the games so far, winner marked | none |
| walkover | court name | 부전승 (neutral) | teams, no numerals, winner marked | none |
| abandoned, cancelled | court name | 미완료, 취소됨 (neutral) | teams (the games so far for 미완료) | 레이팅에 반영되지 않아요 |
No live numerals, no serve glyph, no tiebreak column and no "호스트가 점수를 기록하고 있어요". The door names where the press goes: 점수 입력 › opens ScoreEntry; 점수 검토 요청 › opens ScoreEntry in its appeal mode, the stored score in the cells; on a held block the door opens the blocked player's earlier match; a block with no door opens the match (the read-only 대진표) and draws no chevron.
States. As the Block table (BG6). 200%: names wrap, the 32 columns stay, numerals cap 1.5, the door or Tag keeps the top right of row 1 and the eyebrow yields first.
Content. Names never cut. English: "Enter score", "Ask for a review", "Court 2", "Under review"; state lines "The match is over", "It's past its scheduled end", "This match won't affect ratings"; held "Starts once the earlier score is in", "I need my earlier match's score".
Accessibility. One button. The name is the door text, the zone A text, then the Team lines label from the viewer's side, then the Tag: "점수 검토 요청, 2번 코트, 내 팀 김민준, 이서연 6, 승. 상대 팀 박지훈, 최유나 4." A winner is spoken ("승") on an entered score. No live region.
Motion. Press settle plus dim; nothing animates (the Live tag pulse is capped at 5 s).
Replaces in code. LiveRow (packages/features/sessions/src/scorecard/live-row.tsx) and MatchScoreboard (packages/app/src/presentation/components/sessions/match-scoreboard.tsx) on the live surfaces; the ±1 steppers of LiveMatchRow (packages/features/sessions/src/scorecard/live-tab/live-match-row.tsx) retire with the live score.
Why: review 3, lane B §3 (six of seven peers put no status on a row of its own; the Block's Tag trails zone A and owns no row).
A match in play (owner review 49; the owner: "All ongoing matches should be rendered inside a block ... instead of a meta data on the time elapsed at the bottom, it should be part of the block. And we need the live badge inside the banner").
- The block. Every match in play is this block, the viewer's and every other court's. Its first row carries the live Tag with the minutes ("진행 중 18분") at its end. A live ring sits 2 outside the block.
- The ring's motion. It breathes three times when play starts or the match changes, then holds. Under reduced motion it holds still. It never loops: WCAG 2.2.2 asks a pause for motion that runs over five seconds beside other content.
- The viewer's next match carries "앞 경기 18분째" as a neutral Tag in the same place.
- No line under the block.
- Research: ATP's live card carries its pill and elapsed time in its header row (owner review 49 research).
Club hero
Job. A club's identity under its MediaBand: the logo, the name and three facts (where, what level, how many). It is its own component, not a Block: no ink, no zones, no chevron. Members, officials and non-members see the same hero (owner review 43).
Anatomy. No overlap with the band (owner review 43, option B). 24 under the band, the logo 56 r12 at x = rail (an uploaded logo keeps its $borderSubtle hairline) and, beside it at x = rail + 68, top-aligned: the name 24/32·700 (wraps, never cut), 4, then one Meta line 15/22 secondary of three facts, the region, the level band and the members ("강남구 | NTRP 3.0–4.0 | 멤버 42명"; the level band is what a visitor decides on). An optional tagline (clubs.tagline) sits 8 under the facts, 15/22 ink, one line, cropping. No glyph facts. 82 tall with a one-line name and no tagline; the text sets the height.
No faces. The hero carries no member faces (owner review 2 asked; 5 of 7 observed club heroes keep faces out, and a third group beside logo, name and meta competes with the logo). Faces live in the 멤버 section, for members; a non-member sees the count. If social proof is wanted, a third segment 친구 3명 (a count, no faces).
States. Loading: logo skeleton 56 r12 + name bar 170 × 20 + meta bar 170 × 14.
Content. The club's own logo, else its monogram tile (see Thumb). English facts use Gangnam, NTRP 3.0–4.0 and 42 members.
Accessibility. The name is the screen's one h1 ("강남 라켓클럽, 제목 1"), then the facts "강남구, NTRP 3.0부터 4.0, 멤버 42명", then the tagline; the logo is hidden (the name beside it says it).
Motion. None of its own; the name fades into the header title as the band scrolls away.
Replaces in code. The photo seam and hero in ClubPlaceHeader (packages/features/clubs/src/club-detail/club-place-header.tsx).
Strip
Job. Two to four headline numbers side by side (records, club analytics); when the numbers are the screen's answer use the Block; with five or more use a Cell group. A Strip is a summary, never a second copy: numbers that only restate the counts of the sections or rows under it are not drawn as a Strip (see What's left, below). No line under a Strip explains it (the owner, 2026-10-20: "it needs no meta data (like 무승부는 승률에 넣지 않아요)"; 1.16): a tile's label names its measure.
Anatomy. One grid for the cells face (the stat face before the tiles face below replaced it) and the action face: n equal cells (n = 2 to 4) of content width / n (175, 116.7 and 87.5 at 390; 109.3 and 82 at 360), min 80 (100 with deltas). Each cell is a vertical stack centred in its cell: label 13/18·500 secondary · 4 · figure 24/32·700 ink tabular · an optional delta line, with 12 above and below (12 + 18 + 4 + 32 + 12 = 78, floor 80). Figure : label 1.85. At 390 with n = 3 the contents centre at x 78.3, 195.0 and 311.7; in the Korean samples the largest empty run in the row is 71, 120 and 61 for n = 3, 2 and 4.
The law:
- One grid for both faces: the stat face and the action face are the same cell.
- Each cell is a vertical stack, centred in its cell.
- No dividers: equal cells and the minimum gap group the columns.
- The fit test below decides the reflow.
- A linked cell presses as a whole (.98/.80); its target is the full cell (116.7 × 80 at n = 3).
- Deltas are plain signs, never red or green, never arrows, centred under the figure.
- The layout is a function of n and the text scale alone, never of the data.
- A loading Strip is drawn from a count (
loading count={n}, bars at the loaded geometry,aria-busy), never from invented cells; a real cell of the tiles and cells faces must state its figure, and the types refuse one that does not.
Variants.
- With deltas: a delta line 12/16·600 secondary, 4 under the figure ("+3", "−2"; 12 + 18 + 4 + 32 + 4 + 16 + 12 = 98, floor 100). Zero shows "0". A cell with no comparison data keeps its empty delta line (height kept, never a dash). If no cell has data the Strip returns to 80 and the caption is omitted. The comparison is named once, in the section head's title ("지난달 대비 활동"), never in a line under it (1.16).
- Linked: a cell with a destination presses as a whole; unlinked cells are plain text.
- Action face: each cell is a button with a 24 glyph, 8, then a 13/18·500 secondary label, centred; the same cell and the same 80; used for the labelled venue action row (길찾기 · 공유 · 전화).
- Tiles face (owner review 46, M3; accepted 2026-10-19): the face of a stat summary on every page (기록's 전적, 신뢰 기록's 신뢰, 출석 현황, 클럽 대항전, the 순위 record Sheet, 연습 기록, 리더보드's 지역 standing, a venue's facts, 공개 프로필, 기간 기록, 일일 요약, 선수별 전적, 성장 분석); no page draws the cells face for a stat summary any more. The 신뢰 tiles are 20회 중 참석, 연속 참석 and 칭찬한 사람; each label is one line at 100% (§4 Trust). The same n cells given an edge: n tiles (n = 2 to 4) from the left rail to the right rail, 8 apart, each (content width − 8 (n − 1)) / n wide (171, 111.3 and 81.5 at 390; 160, 104 and 76 at 360), gray100 (dark
#232824), r12, pad 12, min 80 (100 with deltas). Inside, start-aligned: the label 13/18·500 secondary on top and the figure 24/32·700 ink tabular at the bottom (a delta line 4 under it), at least 4 between them, so a label that wraps never moves a neighbour's figure (12 + 18 + 6 + 32 + 12 = 80). No divider and no shadow: the fill is the container. Under a header the tiles keep 8 above them, and 16 below before a Cell or a Podium that has no seam of its own (the Strip'sleadandtail). Laws 1, 2 and 4 to 7 hold with the tile for the cell (the stack is start-aligned in its tile instead of centred); law 3 gives way to the fill. A linked tile presses whole (.98/.80, ring r12) and its target is the tile (111.3 × 80 at n = 3). A record with its rate (선수별 전적 filtered to one person, §1.15). The delta line of a tile holds a comparison ("+3", "−2") or the rate that belongs to the figure above it: the tile 같은 편 reads its label, the record as its figure ("3승 1패") and "승률 75%" under it, in the delta line's 12/16·600 secondary, and the tile 상대 reads the same. A rate is the record's own fact, not a sign, and no line under the Strip says what it leaves out (the owner, 2026-10-20: "it needs no meta data (like 무승부는 승률에 넣지 않아요)"). A record that does not exist is not drawn as 0승 0패: under 단식, and for a person never partnered, the 같은 편 tile leaves and the 상대 tile alone spans the content width (a Strip of one, as the Score Sheet's total is). A Strip of one. A single trust fact is a tile of its own, spanning the content width: 공개 프로필's 신뢰 draws 20회 중 참석 alone, label on top and "19회" at the bottom, as the 상대 tile spans the width of 나와의 전적 when there is no partner record. Two or more tiles are the rule (n = 2 to 4); one is for a Module whose whole answer is one figure. The board checktileRailsfails a row whose first tile is not on the left rail or whose last tile is not on the right rail (0.5). - What's left (owner review 46, M17, M19, M25, M26; accepted 2026-10-19): the counts that the section heads or rows under a Strip already state are not drawn twice. Where a Strip would only restate them (회비's 확인 대기 · 미납 · 완납, a fee list's 입금 확인 중 · 입금 필요 · 입금 완료), the page draws no Strip and opens with one line that says what is left to do: one
Metaline at the rail, 15/22·600 ink in tabular figures, the facts apart with the break (never a middle dot), the progress first and then what is open ("370,000원 / 570,000원 | 12/19명" on 클럽 홈's 회비 pane, whose full page leads with the dues Block instead; "8명 | 입금 확인 중 3 | 입금 필요 2" on 참가비; "8명 | 불참 2 | 미확인 2" on 참석 확인; "8개 중 4개 달성 | 남은 업적 4개" on 업적, whose group heads say 4/8). With nothing left it says so ("8명 | 모두 입금 완료"). Two more: 참석 확인's "8명 | 도착 7 | 먼저 감 1" and 회비 관리's ledger line "잔액 1,240,000원 | 이번 달 지출 165,000원" (where the club stands, then what this month spent). The same line states a form's defaults (경기 규칙: the facts of the standard rules). It sits where the Strip sat: directly under a Section head (the head's 12 is the gap) or 16 under a header or a Caption row, then the next element's own seam (12 before a Cell or a row, 32 before a Section head); at large text it wraps by fact. English: "₩370,000 / ₩570,000 | 12/19 paid". A Strip that mixes a true summary with restated counts keeps the summary numbers and takes the tiles face (출석 현황: 가입 후 출석률 and 추적, 주의 being the head under it; 일일 요약: ELO 변동 and 최종 레이팅, the match count being the hero's fact; 선수별 전적: 승률 and 전적, the players being the 모든 선수 head's count).
States. Loading: per cell a label bar 52 × 12 and a figure bar 40 × 22, gray100, pulse .6 ↔ .9; on the tiles face the same bars on the tile, one step darker (the edge tone); the What's left line is a 15 bar at 60% in its 22 line. Pressed (linked cell), disabled .5 (action face).
Fit test (reflow). Let w be a cell's widest line: its figure, or its label's longest word (a label wraps at its spaces first; a word never breaks; a figure never shrinks and never ellipsizes). A figure must fit its cell, a label word must fit its cell less 16 (labels keep 8 of padding each side), and every neighbour pair must keep a gap of at least 16: (cell − w₁) / 2 + (cell − w₂) / 2 ≥ 16. If a test fails, the Strip reflows to rows of two equal cells, 12 apart, an odd last cell spanning both columns (n = 2 stays one row).
Tiles fit. The same test runs on the tile: the figure and the label's longest word must fit the tile less its 24 of padding (87.3 at n = 3 on 390); the neighbour-gap test goes, because the padding and the 8 between two tiles keep their content at least 32 apart; a failing test reflows to rows of two equal tiles, 8 apart both ways, an odd last tile spanning both columns; two tiles never reflow. At 200% the figure is 36/48, the label 26/36 and a tile grows to 112 (12 + 36 + 4 + 48 + 12): three tiles hold "68%" (78.5 in 87.3), while "1,482" (104.4) goes 2 by 2.
Content. Label one line (two when scaled); English "Attendance" is 69 px.
Accessibility. One text group per cell (button when linked), label then value: "총 경기 47", "승률 68퍼센트", "출석률 92퍼센트"; a delta is read after the value as a signed number. The tiles face reads the same. The What's left line is one plain-text phrase with its breaks hidden and the progress spoken as "of": "570,000원 중 370,000원, 19명 중 12명".
Motion. Numerals update instantly. Press only.
Replaces in code. StatStrip (packages/ui/src/stat-strip.tsx), CellRow / CellRowGrid (packages/ui/src/cell-row.tsx; the equal-column primitive becomes Strip), StatTile / StatGrid (packages/ui/src/stat-tile.tsx, stat-grid.tsx), ActionIconRow (packages/ui/src/action-icon-row.tsx, the action face). One Strip primitive: n equal centred cells, label-first slots and the fit-test reflow, on the equal-cell grid shared with Roster, Podium and Calendar.
Why: review 3, lane B §4 (the action face, Peloton, Strava and Sofascore centre equal cells; a divider only helps figures squeezed under 16 apart, which the fit test reflows instead). Review 46, lane A §1 (the empty space around a Strip's figures has been moved four times: start, hug-last, centred, and now front and end; with n equal cells it only changes place, so the tiles give it an edge, as Toss 증권 does with two gray tiles 8 apart; and a Strip that restates the counts under it is a second copy, so it becomes one line).
Hero (detail)
Job. The identity of a detail page that has no media band: eyebrow, title and up to three facts. Its title is the screen's h1.
Anatomy. Top 24 (below the header, or 24 below a MediaBand) · eyebrow 13/18·500 secondary in a 20 row · 4 · title 24/32·700 (wraps, never cut) · 16 · facts · 32 below. Facts: one to three, each at least 24 tall with 8 between, 17/24·500 ink; a 20 glyph (calendar, map-pin, card; stroke 2, the label's colour; it grows with the text to 1.5×) + 8 sits in a line slot on the text's first line. The hero is as tall as its facts: 160 with three (20 + 4 + 32 + 16 + 88), and no placeholder fact is drawn. With no facts the block and the 16 above it go.
Variants. One viewer-state Tag (sm) may follow the fact it qualifies, 8 after it, in a line slot on the fact's first line, so a wrapped fact keeps the Tag beside line 1 (the fee fact "참가비 15,000원" followed by the Tag 입금 완료). At most one per hero; it counts toward the object's two Tags. A score fact may stand among the facts: the text 신뢰 점수 followed by the score as text (72점; a score is never a Tag), which opens 점수 안내. A person with fewer than five recorded sessions has none.
States. Loading: eyebrow bar 96 × 12, title bar 70% × 20, three fact bars. Long text: title and facts wrap; inline glyphs scale by min(scale, 1.5).
Content. Facts wrap, never truncate. English date and time is one fact: Meta("Sat, Sep 19", "09:00–11:00"). The eyebrow is a label (the kind of page: 매치, 테니스장, 제안 접수, 소유권 신청), never a name that opens something. A club or a venue a hero would name is a Cell under it (§1.15, rule 5): 일정 상세 has no eyebrow, its club is the first Cell of the 정보 group and its venue the Cell under the hero. That Cell is the door alone, 장소 and the venue's name: the hero's pin fact already states the address, so the Cell has no line 2 (매치 미리보기 the same; the lead's call, 2026-10-20). A receipt's venue is that Cell too (제안 접수 내역 and 장소 소유권: 장소 and the venue's name, under the hero); a claim's title is the venue it is about, so its eyebrow, 소유권 신청, does not repeat it. 공개 프로필 no longer draws a Hero: its eyebrow 구력 became the Block's meta.
Accessibility. The title is the screen's one visible h1 (the collapsing header's title is hidden until it shows). Order: eyebrow, title, facts. Glyphs hidden. A Tag is read with its fact ("참가비 15,000원, 입금 완료").
Motion. None of its own; the title fades into the header (150 ms) as it scrolls under.
Built 2026-11-02 (K7, additive): Hero is packages/ui/src/hero.tsx (numbers in hero-visual.ts, held to FvkHero and FvkCFact): eyebrow, title (the h1), up to three facts of icon / text (a string, or an array for a Meta line) / one tag; a fourth fact or a second Tag throws; loading draws the Skeleton's hero. no-legacy-surfaces retires every TypeHero (the profile screens' to the identity Block); a leading graphic or a titleBadge has no slot, so those sites are the pages' call under the ratchet.
Replaces in code. TypeHero (packages/ui/src/type-hero.tsx), the lead of SessionFactsSection (packages/features/sessions/src/session-detail/sections/session-facts-section.tsx), GlyphFactRow chip density (packages/ui/src/glyph-fact-row.tsx), VenueHero (packages/app/src/presentation/components/sessions/venue-hero.tsx, unused).
Notice
Job. A state or consequence worth a box, inline on a screen. For one field's value use the field's support line; for a result or an undo use a Toast; for an irreversible or financial choice use a Dialog.
Anatomy. Content width · r12 · pad 12 16 · min-height 48. Read in this order: title, body, action. One layout: the content is a flex-wrap line; the glyph, the trailing node and the action ride the first line through the line slot; the content block is centred in the 48 floor, so a one-line Notice is balanced.
- Title: 15/22·600 in the tone's text colour; it states the state ("마감이 가까워요"). Required on every tone but neutral.
- Body: 16/24·400, optional, two lines at most: the consequence, then the way out. Ink (white on danger).
- Action: one ghost sm Button (a command is a Button, IA8), its label in the tone's title colour, its 12 of padding bled so the label keeps the text edge. It trails the title at the right while the two fit one line; otherwise it drops under the body, left-aligned at the text edge.
- Trailing: one optional node at the title's end (the CountdownNotice's tabular time), 15/22·600.
- Close: IconButton (24 glyph in a 48 target) at the right,
aria-label닫기. Allowed per the tier table (information shown once; a neutral note only if it can recur); never on warning or danger. - Glyph: none by default (the text starts at the 16 pad). Two kinds are allowed and nothing else: the severity glyph (
alert20 in the tone's text colour, + 12) on warning and danger only; and a specific glyph that adds what the words do not (wifi-off, lock, clock, card, pin) from a typed allow-list. Never the generic ⓘ or a check-circle, never emoji; illustration only at T4. A glyph scales with the text by min(scale, 1.5). - Tones (fill / title): neutral gray100 / ink · success
#CBEBE5/#014B40(7.95:1) · info#E8DCFB/#4B0082(9.91:1) · warning#F5B301/ ink (10.32:1) · danger#A3261F/ white (7.36:1). Dark: the tone at 24% over the dark canvas (1.5:1 or more against it); dark info text#C9B2F2. - Heights: one line 48; title + body 70 (text-driven); the action adds 30 (8 + 22).
Tiers. Escalation is by how much the message demands, not by how alarming it is.
| Tier | Component | Fill | Title | Glyph | Action | Close | Cap per screen | Use when |
|---|---|---|---|---|---|---|---|---|
| T0 | retired (1.16): a labelled line that needs no action is not drawn | none | none | none | none | no | n/a | (none) |
| T1 | Notice, neutral | gray100 | optional | none (a specific glyph allowed) | at most 1 | only if it can recur | 2 in total (T1 + T2) | a tip, a consequence, a field note (참석이 제한돼요 on 내 신뢰 기록's 불참 기록: why and the way out) |
| T2 | Notice, success or info | weak tone | required (the state word) | none (a specific glyph allowed) | at most 1 | info only, once-only | 2 in total (T1 + T2) | an outcome or state worth a box |
| T3 | Notice, warning or danger | amber / solid red | required | alert 20 | 1, or the exact instruction | never; persists until resolved | 1 | it blocks or time-boxes the primary action, or a failure the user caused |
| T4 | Dialog or Sheet | white | 17/24·600 | optional 48 tone disc with a specific glyph | 2 buttons; for a system warning the safe one is primary | per sheet rules | 1 | irreversible, financial, safety |
T3 is the only tier with the amber and solid-red fills, so those slabs are rare. Every T3 carries its action or states the exact instruction; if neither is possible it is not drawn (1.16). Warnings and errors are never dismissible. Why: peers lead with the message, not a generic icon, and every source that states a cap allows one prominent notice per screen.
Variants.
- One line (title only, action trailing) · with body (action under the body) · with trailing (a node at the title's end) · with close.
- Under a field: retired with the Field note (1.16): nothing under a field says what an answer does.
- Offline strip and payment-hold countdown are compositions of this Notice (neutral with the wifi-off glyph; warning with the clock glyph) with their own entries.
States. Rest · inserted after load · dismissed · long text (the body wraps to two lines; more is a Cell or a sheet) · 200%: the one-line action drops under the text like a body Notice and the Notice grows.
Content.
- Title: a state in 12 syllables or fewer, 해요체 ("입금 대기 중", "마감이 가까워요", "오프라인이에요"); never a bare category word ("안내", "주의").
- Body: the consequence, then the way out; about 38 syllables over two lines (318 px column, 1 em per syllable); more is a Cell or a sheet.
- Action: a verb of 2–5 syllables that names the result ("지금 신청하기", "다시 시도"), never 닫기.
- The same words wherever the message appears (field line, announcement). English: sentence case; titles wrap, never truncate.
Accessibility. A text group read as title, then body. A Notice inserted after load is a status message (role=status, polite); role=alert only for a T3 failure the user caused. The action is its own focus stop (44 target); the close has aria-label 닫기; neither moves focus on insertion. Colour is never the only signal: the title states the state and T3 adds the glyph.
Motion. Dismiss: opacity 100 ms, then height 150 ms. Reduced motion: a 100 ms fade, no height animation.
Replaces in code. Banner (packages/ui/src/banner.tsx), Callout (callout.tsx), Well (well.tsx, well-tone.ts), NetworkBanner (packages/app/src/presentation/components/network-banner.tsx), AttachedPanel when it holds only a sentence (attached-panel.tsx; a control that depends on its parent becomes a nested Field). One Notice in @twomore/ui with a glyph prop typed to the allow-list (severity, wifi-off, lock, clock, card, pin) and a lint rule on it.
Caption row
Narrowed (the owner, 2026-10-20; 1.16). The row now draws only what 1.16 keeps: a provider's attribution (네이버 제공), legal text counsel holds, and a shortfall that asks for action (Tone, below). Its other roles retire: no state or support line under an object, no footnote under a group or a Strip, no note under a Choice, no section note and no page legend. The rules below stand for what remains.
Job. One quiet line (or two) of context at the rail, in one of three roles: a state or support line under an object, a section note, or a page legend.
Anatomy. Text 13/20·500 secondary (the wrapping footnote; its one exception is Tone, under Content), at most two lines (31 Hangul syllables per line at 350; past two the copy is rewritten, never clamped). No fill, no action and no padding of its own: the role sets the gap above the text box, and the caller's seam follows it. At the rail, the object's outer edge, never an inner text edge (under a Block it is x 20, not the block's text). An optional 16 specific glyph (lock, pin) leads, 8 before the text. The labelled form puts a 13/20·600 ink prefix first, only for a real noun the reader knows ("공지"); "안내" is filler.
| Role | Where | Above the text box | Below | Drawn as |
|---|---|---|---|---|
| Object line | what 1.16 keeps: a provider's attribution (네이버 제공), legal text, a shortfall that asks for action | 8 | the caller's seam: 24 before a new section, 12 before more rows of the same section | Caption row, attach object |
| Section note | retired (1.16): a section's own figures only, as the Section head's note | 4 under the title | 12 to the first row | the Section head's note |
| Page line | retired (1.16): no legend and no intro under a header | none | none | none |
States. One line (28 as an object line, 32 as a page line) · two lines (text-driven) · 200%: wraps and grows.
Content. One idea, no link, never a title. Seven rules:
- 해요체, contracted past (됐어요, 바뀌었어요).
- One sentence: no final period. Two or more: a period after each, including the last.
- A fact or a consequence ("~면 ~돼요", "~예요"). No imperative -세요; a request is "~해 주세요", and only where the person must act.
- One wording per concept, everywhere.
- No line under a Tag, a title or an eyebrow (1.16).
- Plain words: 변경 → 바뀌었어요, 처리 → drop, 프리셋 → 설정.
- Counts as the house style writes them: "47경기 중 4경기", not "4/47경기 표시" (the slash form is for RSVP counts such as 6/8). Two facts are two nodes with the break: "방문 1회 | 한도 3회", never "방문 1회 / 한도 3회".
Tone. A Caption row is secondary, with one exception: a shortfall that the numbers above it imply reads in warnText at the same 13/20·500 (tone="warn"). 성장 분석 puts it under the plan's Strip of tiles: "정원보다 3명 부족해요" (English "3 short of your cap") when 필요 정원 is over 정원; when the numbers add up there is no line, and the assumption behind the numbers is not stated (1.16). The app draws the line in $warning (club-planning-section.tsx); the library's text colour for it is warnText. A caption is never red or green, and never warns of something the numbers above it do not show.
A line directly under a code field is the Field's support line (13/18, 4 under), not a Caption row. English wraps and never truncates: sentence case, a period only with two sentences, a "Notice" label only where Korean keeps 공지.
Accessibility. Plain text, no role; the prefix is read first.
Motion. None.
Replaces in code. None (new). SectionEyebrow (packages/ui/src/section-eyebrow.tsx, unused) retires.
Why: review 3, lane B §5 (TDS ListHeader makes a section's note a slot of its head; the shipped copy writes one sentence without a final period and two with one: 417 and 25 strings).
Band
Job. Marks the one change of kind on a page (hero zone to content; content to comments or discussion). Everything else is separated by air and the section head.
Anatomy. Full-bleed, 12 tall, gray100 #F2F2F2 (dark: #000000, darker than the canvas), 24 of air above and below. At most one per screen. No hairline.
States. Static.
Accessibility. Decorative; hidden from assistive technology.
Motion. None.
Built 2026-11-02 (K7, additive): Band is packages/ui/src/band.tsx (numbers in band-visual.ts, held to FvkBand): 12 tall at the width it is given (a RailBleed under a padded column), gray100 in light and black in dark, 24 of air above and below (air={false} where the page keeps its own seams), hidden from assistive technology. no-legacy-surfaces retires <Divider seam="section">; FlatColumn's own section dividers leave with the pages.
Replaces in code. Divider seam="section" (packages/ui/src/divider.tsx) and the section dividers FlatColumn draws (packages/ui/src/flat-column.tsx); tinted bands.
Chart
Job. One trend over time (the ELO path, a metric) with an exact readout on demand.
Anatomy. Content width × 160; plot inset 16; one ink line 2, no fill. Axis ticks 12/18 secondary, non-essential (the readout and the spoken summary carry the values). Scrub: a 1px edge-hairline guide #D4D4D4, an 8 ink marker on the line and a readout Count tag (ink tone) above it. The marker follows a drag, and a tap on a point sets the readout (no drag is required).
States. Loading: gray100 block 160. Empty: a 48 glyph and a 15/22 secondary line, centred. Offline: the last known line.
Accessibility. img with a spoken summary: "ELO 추이, 최근 30일, 1,420에서 1,482로 62점 상승, 최고 1,490, 최저 1,415". Adjustable actions step the readout point by point ("9월 3일, 1,452" via aria-valuetext).
Motion. The marker tracks the finger (gesture-tracking motion is exempt from reduced motion).
Replaces in code. SparkLine (packages/ui/src/charts/SparkLine.tsx, 120 × 48) and the trend card around it (packages/features/records/src/cards/elo-trend-card.tsx).
Activity heatmap
Job. One month of results at a glance: the days you played, and whether you won more, lost more or broke even. The numbers live in its text equivalent and its readout. One month at a time (owner review 46, M5; the owner's check of 2026-10-19: "The activity grid can probably be compacted into a monthly grid. We don't have to show all these months all at once."): an older month is a tap away, never on screen together with this one. A day takes its result's colour (the owner, 2026-10-20: "color gradients for the heatmap so that we can indicate the # of wins or losses. So we have three shades of colors - wins, losses or neutral."), as the app's 기간 기록 calendar already colours a day.
Anatomy. The Calendar's month grid, and no second grid of its own (one month grid in the app serves both): the header min 48 (the month label 17/24·600 at the rail, ‹ › IconButtons, 48 targets, at the right), the weekday row 32 (13/18·500 secondary; 일 in $errorText, 토 in $series1) and seven equal day columns on the equal-cell grid (50 × 44 at 390). A day is the Calendar's 36 disc with its number 15/22·500, in the chrome class (cap 1.3). A day you played is its disc in the result scale (1.7): more wins than losses in the success hue, more losses than wins in the danger hue, a third, two thirds or the whole way from the neutral fill to the solid as the margin is 1, 2, or 3 or more; an even day (as many wins as losses, or draws alone) in the neutral fill. A day without a match is its number alone, as on the Calendar. The number is ink or the canvas, whichever reads better on the fill (4.93:1 at the least). A day outside the month is an empty cell; a future day of the current month is blank (no disc, no number), so the current month's grid ends in the row of today, and a past month is drawn whole (5 or 6 rows). › is disabled on the current month; ‹ opens the month before. Legend below the grid: "패", seven 12 swatches (gap 4) from the deepest loss through the neutral fill to the deepest win, "승", 12/18 secondary. Scrub readout like the Chart's (a Count tag, ink tone, above the day's disc): the day's count alone, its record from t().common.recordValue ("1승 2패"; draws only when not 0), since the day is the number in the disc under it. It never scrolls and never shows more than one month. Text equivalent: one sentence under the legend, "9월에 N일 활동, 9승 6패" (N the days of the month shown with a match, in the current month those up to today; then the month's record), which is also the grid's accessible label: the colours carry nothing that the sentence and the readout do not.
States. Loading: a gray100 block the height of the header and the grid. Empty: no day is filled; the sentence states the zero ("9월에는 활동한 날이 없어요"). A past month: › is enabled and every day is drawn. 200% text: the numbers cap at 1.3 inside their discs and the legend and the sentence scale; the discs and the swatches stay.
Accessibility. The weekday row and the days are an img whose label is the sentence; ‹ › are buttons named "이전 달" and "다음 달" (› disabled on the current month) and stay stops. Adjustable actions move the readout day by day, each spoken with its day ("9월 13일, 1승 2패"). The board check heatmapMonth fails a day number that leaves its disc or a disc that leaves its cell at any text scale, a day cell that is not 44 tall or a seventh of the grid, and a grid off the rails.
Motion. The readout tracks the finger; nothing else animates.
Built 2026-11-02 (K7, additive): Heatmap is packages/ui/src/heatmap.tsx + heatmap-parts.tsx (numbers in heatmap-visual.ts, held to FvkHeatmap) on the Calendar's own head, weekday row and disc (calendar-parts.tsx), with the result scale in result-scale.ts (the kit's fvkResultTones runs beside it in the test; the win solid is the app's $success Tag fill, the loss solid $error, the number held at 4.5:1 in both themes) and a CountTag label readout. The sentence is the kit's, "9월에 N일 활동, 9승 6패" (not 9월 중). App: heatmapMonth(entries, year, month, today) tallies MatchHistoryEntry.result by local day. ActivityHeatmapCard converts in W4 (retired by no-legacy-surfaces).
Replaces in code. ActivityHeatmapCard (packages/features/records/src/cards/activity-heatmap-card.tsx); a day's result comes from the result each match it already reads carries (MatchHistoryEntry.result), the wins and losses activityMetricTone compares.
Calendar
Job. Pick a day or a range, or read activity by day. The month grid is for choosing and history; the week strip is the quick day pick.
Anatomy, month. Header min 48: the month label 17/24·600 at the rail, prev and next IconButtons (48 targets) at the right · weekday row 32 (13/18·500 secondary; 일 in $errorText, 토 in $series1) · seven equal day columns on the equal-cell grid (50 × 44 at 390), the numeral 15/22·500 in a 36 circle. Today: a 1.5px ink ring. Selected: ink fill, white 600. Range: ink ends joined by a gray100 band. Up to three 4px activity dots under the numeral. Days outside the month are not drawn. The activity heatmap draws on this same month grid (its header, weekday row and day cells), with a day's result colour in the disc in place of the selection.
Anatomy, week strip. Seven equal columns of the same grid (50 at 390), height 64: a weekday label 12/18·500 secondary over a 36 numeral circle 15/22·600. Today and selected as the month. No activity dots (the cell has no room). Swipes by week, and a header row of 48 carries the week label with prev and next IconButtons, so a single tap reaches every week (WCAG 2.5.7).
States. Disabled dates .5 and not pressable · pressed .98/.80 per cell · focus ring ink · selected · range.
Content. Numerals sit in cells with a floor height and scale to the chrome cap 1.3 (no shrink-to-fit). Weekday and month names come from the locale formatter.
Accessibility. role="grid"; each cell is named by its full date ("9월 19일 토요일"), with a count when it has activity, and carries aria-selected. The week strip's swipe has adjustable actions "이전 주" and "다음 주".
Motion. Selection fills over 100 ms. The week swipe translates with the finger; reduced motion: the week changes with a 100 ms fade.
Replaces in code. ActivityMetricCalendar (packages/app/src/presentation/components/activity-metric-calendar.tsx), DayDotsRow (packages/ui/src/day-dots-row.tsx), the 홈 date pickers DayCalendarPicker and WeekMonthPicker (packages/features/home/src/components/), PeriodScrubberShell (packages/ui/src/period-scrubber-shell.tsx). The native time wheel stays.
Podium
Job. The top three of a ranking as faces; the list below starts at rank 4.
Anatomy. Three equal cells of the equal-cell grid (content width / 3), centred, ordered 2위 · 1위 · 3위. Avatars: 1위 56 raised 16; 2위 and 3위 48; each with a 2px medal ring (gold #D9A00F, silver #8E8E93, bronze #B2580B). Under each: rank 13/18·600 secondary · name 15/22·600 (two lines, never cut) · ELO 17/24·700 tabular · the Level tag (the Podium always shows it). Height 168 is a floor; a wrapped name grows its column.
States. Pressed .98/.80 per column · loading skeleton columns · fewer than three entrants: the missing columns are not drawn.
Content. Names wrap to two lines (three from 1.5); no shrink-to-fit.
Accessibility. Each column is a button, rank first: "1위, 김민준, ELO 1,482, NTRP 3.5". The ring is additive; the rank is printed text.
Motion. Press only.
Replaces in code. LeaderboardPodium (packages/app/src/presentation/components/leaderboard-podium.tsx, leaderboard-podium-layout.ts).
Form strip
Job. Recent form in a head-to-head or a player record: the last five results in order. Head-to-head and player records only, never in rows (colour-only dots fail WCAG 1.4.1); in a row, recent form is line-2 text ("최근 5경기 3승 2패").
Anatomy. Five cells 24 × 24, r6, gap 4, chronological left to right from the rail (a cell grows with the text: 28 at 1.15×, 36 from 1.5×, so its letter never outgrows it). 승: the success solid, 패: the danger solid (the result scale's deepest steps, as the activity heatmap colours a day), the letter 12/16·700 in white (in dark, the canvas; 10.5:1 and 9.1:1). The count 3승 2패 sits at the end of the row, right-aligned on the right rail, at least 16 after the last cell, 15/22·600 ink tabular, centred on the cells, from t().common.recordValue (English "3W 2L"; draws shown only when not 0), never red or green. It fits on one line at 200% text, where the row grows to 44 and the cells stay centred in it. With fewer than five completed matches only those cells are drawn and the count counts what exists; with none the strip is omitted. Data: the last five completed head-to-head matches from the existing head-to-head query.
Accessibility. One text group, the count first: "최근 5경기 3승 2패: 승, 승, 패, 승, 패".
Motion. None.
Replaces in code. None (new); used by packages/features/records/src/head-to-head-screen.tsx.
Window bar
Job. One person's hours on the session's time axis (owner review 46, M26): when they come and when they go, so a host sees at a glance who is there when and match generation can read it.
Anatomy. Inside the window Sheet (시간 바꾸기, a player's 참석 시간), rail to rail, above its Picker Cells: a 4 track in gray100 for the session's hours and, on it, a 4 ink bar from the arrival to the departure; the window as an ink tag above the bar's middle ("19:30~21:00", 13/18·600 in the canvas colour, r6). The first row of a list may carry the axis labels at its two ends (19:00, 21:30). Read-only; a host who moves an end uses the Sheet 시간 바꾸기 (the exact hours as Picker Cells).
Content. The window is always the player's own times, in the locale's range form (09:00~11:00 in Korean, an en dash in English). No list draws bars since owner review 48: 참석 확인 lists the exceptions first, and a person's window is one tap away in their Sheet; match generation still reads every window.
Accessibility. A group named for the person: "한소희, 19:30부터 21:00까지".
Kit: FvkWindowBar (band, tag, label), 44 tall: the tag 24, 8, the 4 track and 8 under it; nothing on it presses. The range Slider (FvkSlider band) stays the control inside the Sheet 시간 바꾸기, where an end is dragged; a list of windows never shows thumbs.
PhotoGrid
Job. A page of photos: the album, or a post's preview. A tile opens the viewer.
Anatomy. Three columns, full-bleed (edge to edge), gap 2, square tiles = (screen width − 4) / 3 (128.7 at 390; a computed media size, exempt from the 4pt grid). A preview grid ends with a "+N" tile: 20/28·700 white on the on-photo fill, ink α0.6. The album page groups its tiles by the upload they came with, the unit the 클럽 홈 Feed shows as one 사진 item: a Section head per group (the upload's name, its count "6장" and the door "일정 보기" or "게시글 보기"), then that group's grid, so the album is not a dead end (§1.15). A group's door opens where the photos came from; a tile opens the viewer.
States. Pressed (dim .80, no settle) · loading: gray100 squares (no Select mode: a tile opens the photo, § 1.18).
Accessibility. A list; each tile is a button named by its caption, else "사진 3 / 12".
Motion. Image load: placeholder pulse, then a 150 ms fade-in; reduced motion: instant.
Replaces in code. FeedPhotoStrip in PostFeedRow (packages/features/clubs/src/shared/post-row.tsx) and the album grid maths (packages/features/clubs/src/club-album-grid.ts).
Shelf
Job. A bounded horizontal row of optional browse content (photos, next sessions, recommendations) on a place surface. It is never the sole path to the content: a complete vertical page is one tap away. It is also § 1.20's horizontal list: on a page with lists of different kinds every list is a Shelf, people and matches included (기록's 순위 and 경기 기록, 장소 상세's 다음 일정 and clubs; owner review 48). Elsewhere the roster, the matches and the viewer's own data are never shelves, except the viewer's earned trophies (업적, the Badge preset).
Anatomy. Head: the Section head (title, optional count suffix) with its door at the right, the Door pill 전체 보기 › (44 target; none when another door on the page already opens the whole set); from 600 pt wide, ‹ › buttons join the head row above the shelf (never beside or on it, never hover-only). Items scroll natively, the first on the rail, content running under the right edge so the next item peeks. Five presets; the four browse presets each leave about half an item visible at 390:
| Preset | Item | Gap | Shown at 390 | Use |
|---|---|---|---|---|
| Card | 240 wide; media 4:3 (240 × 180, r12) above two lines | 12 | 1 card plus 118 (49%) of the next | 다음 일정, 추천 |
| Compact | 200 wide, gray100, r12, 12 of padding: the over line 13/18·500 secondary (it sits on gray100; tertiary is 3.64:1 on the dark fill) with an optional sm Tag at its end, the title 15/22·600, the meta 15/22·500 secondary, one line each; a match puts its two scores (700) at the right | 8 | 1 card and most of the next | a session, a club or a match on a page of mixed lists (§ 1.20; owner review 48) |
| Tile | 140 square, r8 | 8 | 2 tiles plus 74 (53%) | 사진 |
| Cell | 76 wide: avatar 48, name 13/20·500, up to 2 lines; an optional line 2, a figure (1위, 64%) | 8 | 4 cells plus 34 (45%) | people, where a shelf is allowed (§ 1.20) |
| Badge | 76 wide: tile 64 r12, name 15/22·600 up to 2 lines, when 13/18·500 tertiary | 8 | 4 badges plus 34 (45%) | earned trophies (업적), the Shelf test's one exception |
At most five items. The head's door is the one way to the whole list: no door card or tile ends the row (owner review 48, § 1.18). One item: full width. Two items that fit: no peek. Zero: no head. Cards snap to item starts; tiles, cells and badges scroll freely; no auto-advance, no dots, no parallax. A Card's two lines follow the object's MediaRow hierarchy (a session: venue, then time; a club: name, then meta). With four badges or fewer nothing scrolls and they stay start-aligned on the rail. From 1.5× text a Badge shelf is rows (MediaRows, at most three, the preview law), and a Compact shelf's cards stack full width, at most three: the side-by-side forms reflow. A card's text rides with the card under the screen's edge (the kit's bleed), its Tag's label included.
Shelf test (all four must be yes, except on a page with lists of different kinds, where § 1.20 makes each list a Shelf): the content is optional and visual; every item is reachable on a vertical page one tap from the head; there are at most five; it is not the viewer's own obligation or data (회비, 내 일정, 예정, 알림) and not what the CTA acts on. The one exception to the viewer's-own-data clause is the viewer's earned trophies (업적): optional, visual, and the gallery is one tap away, so they may be a Badge shelf.
States. Rest · loading (skeleton items of the preset) · one item · two items.
Accessibility. A list labelled with its total ("사진, 12장"); one focus stop per item with the item's own spoken label; the 전체 보기 control comes first in focus order; arrow keys move focus on web; off-screen items stay in the accessibility tree. A custom pan carousel (not the native scroll view) needs prev and next buttons (WCAG 2.5.7).
Motion. Native scroll and snap. Nothing to remove under reduced motion.
Platform. On iOS 26 a rightward drag on a horizontal control is the swipe-back direction, so every screen that hosts a shelf sets fullScreenGestureEnabled: false or starts the shelf after an inset; verify on the first iOS build. Session detail hosts no shelf and keeps its full-screen back swipe.
Built 2026-11-02 (K7, additive): Shelf is packages/ui/src/shelf.tsx + shelf-items.tsx (numbers in shelf-visual.ts, held to FvkShelf): the Section head with its door and the ≥ 600 pager, a sideways native scroll of at most five items in the five presets with the kit's gaps, skeletons and the 1.5× forms. Two lines above are not drawn by the kit and so not by the piece: one item widens only the card preset, and "two that fit, no peek" has no rule. Zero items draws nothing, head included. The horizontal-ScrollView lint named above does not exist yet (ten sites would trip it); it waits for W4. No caller converts now: PhotoStrip is already a 3-up grid and the club home preview rails are gone, so the first shelves are the W4 pages'.
Replaces in code. PhotoStrip (packages/features/sessions/src/directory-venue-detail/parts/photo-strip.tsx) and the club home preview rails. One Shelf primitive wraps ScrollView horizontal, which no other component may use except Tabs (lint).
Brand lockup
Job. The brand mark slot on sign-in, the version gate and the web pages.
Anatomy. The mark slot of the brand-touchpoints doc: stacked (mark over the wordmark, 180 wide) on sign-in and the version gate; horizontal (28 tall) on the web pages. No mark is drawn until the owner's new mark exists; the slot keeps its box.
Accessibility. An image named once ("투마"); hidden where the screen's title already says it.
Motion. None.
Replaces in code. Logo (packages/ui/src/logo.tsx).
Thumb
Job. The identity image at the start of a row: the venue's own photo, a map tile, a court tile, a club logo, a glyph tile or a person's face.
Anatomy. 56 × 56, r8 (club logo and glyph tile r12); 48 × 48 r8 in the compact 홈 row.
- Venue cascade: the venue's own photo, else the static map tile, else the court tile. A photo only when it is venue-specific. One box (size, radius, aspect) for all three; the map and court tiles carry a 1px inset
#E5E5E5. A session row therefore draws the venue's map; the court tile appears only for a venue without coordinates. The map's pin takes the court surface's colour (hard#089AC3, clay#8D3D00, grass#83971F; owner review 27, after review 26 drew a ring, a band and the pin), red only where the surface is unknown. In dark, a black scrim at α0.2 dims the map (NAVER's static map has no dark style: dim, never invert) and the pin is drawn above it at full colour (review 32;FVK_MAP_DIM, checked on a device). It is a hint for regulars and colour alone, so the surface is still spoken in the row's name and written where people choose (session detail, venue rows). In code the static map is requested without its marker (the static-map function'smarker=false) and the pin is drawn over its centre, so its colour is ours. - Court tile: the court-surface colour (hard
#089AC3, clay#8D3D00, grass#83971F) with white lines on a pale surround. - Club logo: the club's image, else a flat monogram tile in the club's identity hue (white monogram, 20/28·700). Identity is never colour-only.
- Face: a person's photo filling the square, else their initials on gray100, both drawn with the Avatar's own sources; r8, no inset edge. A Feed item sizes it 96 (56 from 1.5× text), where a map would repeat on every row (the author inside a club, a result's top winner, a 매치's host). The Avatar stays the circle for people in rows.
- Glyph tile: gray100 with the object's 24 glyph centred. It is the object's identity image, so the no-glyphs-in-rows rule (which covers inline fact glyphs) does not apply. Achievement: earned = the solid medal fill with the glyph in its on-colour (see Medal set); locked = gray100 with a secondary glyph and the row's trailing figure shows the progress ("7/10" 15/22·600 tabular); hidden = "?" 20/28·700 secondary with the line "숨겨진 업적". No weak tint fills.
States. Loading: a gray100 square (r8 or r12). Image error: the next step of the cascade.
Content. If a row ever shows the court tile without the venue name, it adds the surface word (the tile says the surface by colour alone).
Accessibility. Hidden from assistive technology; the row's name carries the venue, the surface and any achievement progress.
Motion. Image load: placeholder pulse, then a 150 ms fade-in; reduced motion: instant.
Replaces in code. VenueTile (packages/app/src/presentation/components/sessions/venue-tile.tsx), ClubAvatar and ClubLogoBubble (packages/app/src/presentation/components/club-avatar.tsx, club-logo-bubble.tsx), VenueMonogram (packages/app/src/presentation/components/venue-monogram.tsx), DateTile (packages/ui/src/date-tile.tsx), CourtLinesTile (packages/ui/src/charts/CourtLinesTile.tsx). RemoteImage (packages/ui/src/remote-image.tsx) stays as the loader. Built 2026-11-02 (K5a, additive): Thumb is packages/ui/src/thumb.tsx (numbers in thumb-visual.ts), the eight kinds on the loader and the court tile. It imports no app hook: the venue cascade stays app-side, the app's VenueTile resolves the static map through useStaticMapImage and hands Thumb the resolved src, its onError and the court surface the tile falls back to. The club kind is the photo, else the flat monogram in an identity hue (the six hues are theme tokens: the court hues are series2 and series5); ClubAvatar keeps its generated flag medallion at the hero sizes (56 and up) until the club hub page wave rules, so it is not retired with the rest. The pin is drawn by the static-map function, not over the image; the dark scrim is α0.2. The medals draw the app's medal tokens with an ink glyph (the kit's medal hexes differ).
Trust (신뢰)
Job. What other players can know about whether a person shows up, as one score whose every point is on the record, and what the person can see and contest about themselves. 신뢰 점수, 0 to 100 (the owner, 2026-10-29: "How about a point system?", then "Yes, points"; docs/design/research/owner-review-48-research-2026-10-28.md § 7). It replaced the four named tiers. It is a number with its unit and its evidence, never a percentage, a bar or a rank. Owner review 46, M6 (lane D) set what is measured; the owner's decisions of 2026-10-19 hold: attendance is recorded in every club and every pickup, and a stranger sees the score alone.
What is measured. Five things. Four move the score; 정산 is for officials.
| Measure | It is | Counted over | The person reads | Points |
|---|---|---|---|---|
| 참석 | showing up to a session they said yes to; an absence is a no-show (weight 1) or a late cancel (0.5) | the rate: the last 20 counted records; the score: 12 months | 최근 20회 중 참석 N회 · 연속 참석 · every record | +1 a session; −4 an absence of weight 1, −2 of 0.5 |
| 매너 | praise left after a shared match, counted as distinct people who shared a match and capped per giver | 12 months | 칭찬해 준 사람 N명 | +1 a new praise |
| 신고 | a report a 총무 has upheld (a report counts only once upheld, for 12 months) | 12 months | its row on the record, never the reporter or a count | −10 |
| 시간 | arriving by the player's own start (the player's 도착했어요 stamp, or the host's 도착 for one who did not stamp; M26) | 12 months | a private nudge, then the session's row (12분 늦음) | −1 at 10 minutes or more (the session nets 0) |
| 정산 | holds paid in time, dues | — | — | never; officials only |
| 공정 | v1 the 실력이 정확해요 praise (inside 칭찬); later, host-upheld score corrections (private) | — | inside 칭찬 | inside 칭찬 |
Lateness moved: review 46 kept it for officials and out of the tier; the point system counts it (−1), and the person reads it on their own record after the private nudge.
Records. attendance_records is written for every club and pickup (the club's tracking switch decides enforcement only). A record names the rule that was applied, from the cancel time against the session's start (private.classify_rsvp_cancel, 00639: the app-wide 24 h window and the 12 h late window):
| What happened | Record | The row reads | Counts | Points |
|---|---|---|---|---|
| Came | attended | 참석 (12분 늦음 when 10 minutes or more late) | one of the 20 | +1 (late: 0) |
| Cancelled 24 h or more before | free_cancel | 하루 전 취소 | 기록 없음 | nothing | 0 |
| Cancelled 12 to 24 h before | late_cancel | 12~24시간 전 취소 | 불참 0.5 | an absence, weight 0.5 | −2 |
| Cancelled inside 12 h | no_show | 12시간 안 취소 | 불참 1 | an absence, weight 1 | −4 |
| Did not come | no_show | 무단 불참 | 불참 1 | an absence, weight 1 | −4 |
| The host cancelled, a hold expired, they left the club, or an official excused | excused | 호스트가 취소 / 입금 시간 만료 / 클럽 탈퇴 / 운영진이 인정 | 사유 인정 | nothing | 0 |
The facts of a row are separate nodes with the break (Meta), never a middle dot and never one sentence: "12~24시간 전 취소 | 불참 0.5".
The club's setting. On 클럽 설정 the group 출석 says so in its head note ("출석은 항상 기록되고, 아래 설정은 제한만 정해요") over two switches: 불참 제한 ("불참이 2건이면 새 일정에 참석할 수 없어요") and, only while it is on, 취소 페널티 ("늦은 취소도 불참 제한에 포함돼요"). The old 출석 관리 and its "스트라이크" leave the copy; the person reads 불참 N건.
Window and aging. Only the last 20 counted records (attended and absences) within 12 months count for the rate. A free cancel and an excused record are kept on the page but neither counted nor held against the streak: 연속 참석 neither breaks nor grows on them. An absence says the day it drops off the record (12 months after its session). Two absences stop new RSVPs, counted as records whatever their weight; an excusal, or the oldest absence dropping off, lifts it. The weights (0.5 and 1) feed the officials' 출석 주의 flag at a weighted 3. The score counts the same 12 months, so a deduction leaves with its record, on the day the row names.
The score. Everyone starts at 50; the floor is 0 and the ceiling 100. The score is 50 plus the points of the last 12 months (the records, the new praise and the upheld reports), held between 0 and 100. It shows from the fifth recorded session; before that everyone reads 기록을 쌓는 중이에요 with the count (참석 3회), never a low number. The weights are the attendance policy's (an absence of weight 1 is −4, of 0.5 is −2), so the score and the RSVP stop never disagree; they are a first guess, to tune with data. Partner matching reads it as score ÷ 100 × 15. A score is text, 72점, never a Tag and never a colour; a record's points are its row's trailing figure (the kit's fvkPoints: +1, −2, −4, 0; a deduction in the error ink, 0 in ink3).
Who sees what. The can_view_face pattern: the server decides, the client never infers.
| Viewer | Sees |
|---|---|
| The person | the score, 최근 20회 중 참석, 연속 참석, 칭찬해 준 사람, every record with its points on 신뢰 기록, and a private Notice only when there is something to do |
| A shared club, a shared session, or an accepted friend | the score and one fact, 최근 20회 중 참석 |
| A stranger; an anonymous viewer (the share card) | the score only: no rate and no session count |
| A list row (members, participants, ranking, search) | 신뢰 72점 on line 2; none before five sessions. A host's or official's participant row reads a neutral Tag 첫 참석 for someone with no recorded session |
| A club's officials | the above, plus that club's record, payments and lateness (the officials' view) |
| No one | reporter identities and counts · another person's absences and cancel history (an official sees their own club's) · payment and dues state outside officials · who left a praise · club-private notes. Nothing goes to lenders |
The officials' view. An official is the club's owner or admin (a pickup's host, for the people in their session). It is 신뢰 기록 seen by an official (owner review 48 folded 멤버 기록 into it): /clubs/[clubId]/members/[userId], from 출석 현황's rows and from a 멤버 관리 member's Sheet (멤버 기록). The header reads 멤버 기록 with the member and the club as its sub; then, for that member and that club only, the score Cell, the tiles 가입 후 13회 중 참석 · 연속 참석 · 지각, the Cell 회비와 참가비 (미납 2건 ›, officials only and never in the score), and the one list of sessions, each row with its points; a 사유 인정 요청 waits in its row with the AnswerPair 인정 · 거절 (declining is 거절, and the person then reads 거절됨), and a record with no request keeps the Button 사유 인정. A neutral Notice 출석 주의 stands under the list's head when the weighted absences are 3 or more. Never shown, to anyone: a reporter's identity or the number of reporters; the report an official upholds (확정) counts for 12 months and one dismissed (기각) for nothing.
A 사유 인정 요청 reaches an official as a 할 일 on 홈 and as a row in 알림 (the 사람 family, 관리 for officials), and opens the officials' view at the request.
Anatomy. Six parts, every one a composition of the kit; none draws a page-local piece.
- The 신뢰 Module on 프로필. Section head: title 신뢰, the count is the score (72점; none before five sessions), and the head's one control is the door 내 신뢰 기록 ›. Directly under the head, when there is something to do, the private Notice: a one-line alert of figures (불참 2/2건 | 2027년 8월 9일 해제) with no action of its own. Then the Strip, tiles face: 최근 20회 중 참석 ("19회"), 연속 참석 ("7회"), 칭찬해 준 사람 ("12명"). Before five sessions the head's note reads 기록을 쌓는 중이에요, the tiles show the count (참석 3회), and the door appears once there is a record.
- 신뢰 기록 (
/profile/trust, a pushed detail page; no band, no tab bar). Header (back, 내 신뢰 기록) · the 신뢰 Section head, its one control the door 점수 안내 › · the score Cell (ink: label 신뢰 점수, value 72점,line2the evidence, 최근 20회 중 19회 참석 · 칭찬 12) · the tiles · the 참석 기록 Section head and the one list of sessions, newest first, each a record with its points, then an ExpandToggle · the Cell 기록 문의 with the contact as its value (the named contact for an explanation, PIPA 37-2). With two absences a neutral Notice (참석이 제한돼요, with why and the way out) stands under the 참석 기록 head. - A record is a MediaRow of two lines at most: the venue's map as its thumbnail, l1 the session's place (its club, or a pickup's venue), l2 the day and its one deciding fact ("9월 5일 | 불참 0.5", "9월 12일 | 참석 | 칭찬 1", "9월 2일 | 12분 늦음", "8월 22일 | 사유 인정"), and its points as the trailing figure (
fvkPoints). A request's state (확인 중, 거절됨) is a Tag after the fact on line 2. The row opens the record's Sheet: title the place,subthe day, then Cells 적용 규칙 (12~24시간 전 취소), 점수 (−2), 기록에서 빠지는 날 (an absence only) and 일정 보기; its footer is 사유 인정 요청 for an absence with no request yet, and a Tag Cell once asked. - 사유 인정 요청 is the record Sheet's second step (a Sheet never sits over a Sheet), with a back arrow: a required Field 사유 with a TextArea (up to 200 characters), the footer 요청 보내기 soft-disabled until a reason is written. Sending shows the Toast 요청을 보냈어요 and the row 확인 중. The request goes to the session's host or the club's 총무, once per record; the answer is a push; excusing is the official's act, and the row's points become 0.
- 점수 안내, the one explainer: the kit's Explainer Sheet (
FvkExplainerSheet, as 추천 점수 안내) titled 신뢰 점수 안내, the group title 이렇게 오르고 내려가요, and one two-line Cell per event, the event as the label, its points at the right and its rule on line 2 (참석 +1점 · 칭찬 +1점 · 10분 이상 지각 −1점 · 12~24시간 전 취소 −2점 · 당일 취소와 무단 불참 −4점 · 사유 인정 0점 · 확정된 신고 −10점); its Caption note says how it counts: 50점에서 시작해 최근 12개월 기록으로 계산해요 (0점에서 100점 사이), 참석 5회부터 보여요. It is rules, not the viewer's points: the score Cell carries those. It opens from the 신뢰 head's 점수 안내 › on 신뢰 기록 and from the Info hint of the 신뢰 점수 Cell on 공개 프로필. - 공개 프로필 shows the 신뢰 section: its head (the door 기록 보기 › for a viewer who may open the record) and the score Cell (label 신뢰 점수 with the Info hint, value 72점,
line2the evidence for a viewer who shares a club or a session or is a friend); a stranger sees the Cell with its value alone. The 전적 Strip (총 경기, 승률, ELO) is the match summary and holds no trust fact.
States. Before five sessions (기록을 쌓는 중이에요 · 참석 3회; the Cell has no value) · ideal · something to do (the Notice) · clean (no absence: a high score and no deduction rows) · requested (확인 중, 거절됨) · loading (the head, the Cell, the tiles and four MediaRows as skeletons) · offline, cached (the strip under the header) · 200% (the row's two lines wrap and the row grows; the points stay on line 1) · English (Trust score, "72"; a row's points "+1"). With fewer than 20 records the number in the tile's label is the number of records ("8회 중 참석").
Content. One wording per concept, in 해요체: 신뢰 점수 with its unit 점 (72점), 점수 안내, 불참 (an absence; 불참 N건), 사유 인정 and 사유 인정 요청, 칭찬 and 칭찬해 준 사람 N명, 연속 참석. A row's points carry their sign (+1, −4; a true minus), and 0 has none. No percentage in the score, no decimal, no bar, no rank. The four tier names (새싹, 활동 멤버, 신뢰 멤버, 베테랑 멤버) and 등급 leave the copy; 활동 멤버 N명 as a count of a club's active members stays.
Accessibility. The score Cell reads "신뢰 점수 72점". A tile reads its label then its value: "최근 20회 중 참석 19회". A record is one stop, "강남 라켓클럽, 9월 5일, 불참 0.5, 2점 감점" (a gain "1점 추가"), that opens its Sheet. The page's h1 is its header title. The Notice is a status message.
Pushes. Only a deduction notifies, naming its cause, and opens 신뢰 기록 at the record: 무단 불참으로 신뢰 점수가 4점 내려갔어요 · 확정된 신고로 신뢰 점수가 10점 내려갔어요 · 불참이 2건이라 새 일정에 참석할 수 없어요 · 사유가 인정됐어요 · 사유 인정 요청이 거절됐어요. A gain is a quiet row of the record, never a push.
Replaces in code. AttendanceSection (packages/features/profile/src/attendance-section.tsx, own profile: rate, streak, strike callout), MannerTagsSection (manner-tags-section.tsx) and the attendance StatCell of PublicProfileScreen; TrustTierBadge and TrustTierInfoSheet (packages/app/src/presentation/components/), to the score Cell and 점수 안내; profiles.trust_tier and private.recalculate_trust_tier, to the score (docs/canon/trust.md); computeTrustScore (partner-matching.rules.ts), to score ÷ 100 × 15; new TrustRecordScreen (apps/mobile/app/profile/trust.tsx).
Why: review 46, lane D § 3 (the stranger's real risk is the empty court, not skill; every record visible and contestable; Airbnb, Uber and Superhost count a window and exclude causes outside the person's control; PIPA 37-2 asks for published criteria and an explanation on request); owner review 48 (the named tiers set aside for points, every point a row: research § 4 to § 7).
The 신뢰 meter (owner review 49; the owner: "Can we render 신뢰 in a more progress-bar type deal? The point, 72, alone doesn't really tell much of a story").
- What it shows. The score told as a place on its scale, as 당근's 매너온도 tells 36.5: the figure (72점, hero), one line of its story (지난달보다 3점 올랐어요), then a 0 to 100 bar in one hue.
- The 시작 tick. The bar has a tick at 50 labelled 시작, where everyone starts, so 72 reads as above it. 0 and 100 are labelled at the ends.
- Before five sessions there is no score: the bar is the steps to it (3/5), and the line says when it shows.
- Accessibility. It is a meter (role meter, its value in words: "신뢰 점수 72점, 100점 만점, 시작 50점"). The figure and the labels carry it without colour.
- Where. On 신뢰 기록 alone, above the three facts, which say why (owner review 50: "신뢰 can be a label inside the block, and opening up the 신뢰 page can then render the progress bar"). The person pages carry the score as their Block's 신뢰 door, which opens it. A row's second line keeps the text (신뢰 72점).
- In the kit:
FvkTrustMeter.
MediaRow
Job. The list row for things you open: a session, a club, a venue. For people use PersonRow; for label/value facts use a Cell. Proposed (owner review 46, M12, M13, M18, M21, M22): in a list of clubs, sessions, venues or 대항전 these are Feed items with a who row (§5, Feed item); the MediaRow stays for pickers, the same-court list and the compact 홈 row, and the session hierarchy below is that list's.
Anatomy. Content width × min 80 (pitch 80) · pad 12 · thumb 56 (see Thumb) + 12 · text column · trailing element, 12 from the text. No divider, no fill, no bookmark (saving lives in the detail header). The thumb is centred on the text block.
- L1: 17/24·600 ink: the venue or club name.
- L2: 15/22. Session rows: the time range, 500 ink, tabular. Club and venue rows: 400 secondary
Meta. - L3 (discovery only): 13/18·500 secondary
Meta, 4 under L2. - Trailing, one element, centred on L1: a count (
6/8, 15/22·600 ink tabular) or one Tag. When a state outranks the count the Tag wins, in this order: live, then 참석 / 예비 / 호스트, then 마감 임박. Club rows: an unread Count tag (내 클럽) or a 모집 중 Tag (탐색). - Leading marker, when the list can show one:
[marker 8][8][thumb 56][12][text](see Leading marker slot). - Text column at 390: 242 beside a figure, about 193 beside a Tag.
Session hierarchy. One hierarchy for every session list; only the day prefix and the trailing element change.
| Line | Content | Type |
|---|---|---|
| L1 | the venue or court name, one line at 100% (places may crop; names may not) | 17/24·600 ink |
| L2 | the time range through the one range formatter (19:00~21:00; English 19:00–21:00); a day word ("오늘", "내일", "토요일") is prefixed only when the list is not grouped by day | 15/22·500 ink, tabular |
| L3 | discovery only: sessionTitle() and level, Meta("복식", "NTRP 3.0–4.0") | 13/18·500 secondary |
| Trailing | 6/8, or one Tag | as above |
| Context | Row |
|---|---|
| 매치 찾기 (discovery, by day) | thumb 56; L1 venue; L2 내일 19:00~21:00 when ungrouped, 19:00~21:00 under a day header; L3 format and level; row 96 (12 + 24 + 4 + 22 + 4 + 18 + 12) |
| 예정 (my schedule) | day headers; L1 venue; L2 time; no L3 (the person already joined); trailing state Tag; row 80 |
| Club 일정 | day headers; L1 venue; L2 time; L3 only for 매치; row 80 (96 with L3); the same-court exception applies |
| 홈 under the day Block | compact: tile 48; L1 venue 16/24·600; L2 time 13/18·500 ink; trailing 6/8; row 72 (the tile sets the height) |
Same-court exception. When every row of a list is at the same court (a one-court club, a venue page's session list) the court is named once in the list head and the rows drop it: no thumb; L1 becomes the time range 17/24·600; L2 is sessionTitle() and level 15/22·500 secondary; min-height 68 (74 at these line boxes, text-driven). Why: a repeated venue gives the left edge nothing to scan, and 11 of 13 comparable lists lead with the title or venue.
Variants. Session · club · venue · compact (홈) · no thumb (same court) · selected (trailing check 16 and L1 600, no fill) · picked, with a leading ring (select='radio'): a picked venue in a group where exactly one is the home court (클럽 만들기 step 2), [ring 24][12][thumb 56][12][text] and the row's one control at the end, the remove trash (a draft court goes at once with a 되돌리기 Toast). The ring is the kit's radio ring (2px secondary edge, a 12 ink dot when chosen). It rides line 1 through the line slot, as the unread marker does (§5 Leading marker slot), while the thumb stays centred on the text block; the text edge is then 104 from the rail. The remove trash is the row's one control: a second stop outside the press area, centred on the row, its 20 glyph's edge on the rail. The kit takes the chosen row as checked and the trash as control, or as an IconButton in the trailing slot, which the row maps to a control.
States. Rest · pressed .98/.80 · focus ring · selected · loading (a 56 square, a 17/24 line at 60%, a 15/22 line at 40%, plus an L3 line at 30% in discovery) · long text · 200%: the trailing element stays beside L1 while the longest word of any line and the element fit the text column with 12 to spare; otherwise it moves under the last line at the text edge, 4 below, and L2 may wrap to three lines.
Content.
- L1 is one line at 100%; at larger text it wraps to two lines (three from 1.5) and the ellipsis comes only after the last line; the full name is on the destination. In the venue directory, where the name is the choice, L1 wraps to two lines at 100% too.
Metawraps whole segments and draws each break as the leading edge of the segment that follows, clipped where a segment starts a line, so a break never ends or starts a line. - L1 reads the venue field;
sessionTitle()returns the format and event type ("복식", "복식 매치"), never the venue, and belongs on L3. - A session named on another page (an alert, a record, a fee, a request's
sub, a sheet's title, a page's header subtitle, the name a group chat opens with, a shared link's card and page) is its place and its day, never a name: the place is the club for a club's session and the venue for a pickup, the day as the one formatter writes it ("9월 19일", its weekday where it stands alone, below; the start time where one day holds two). Where the page or the row already names the place (멤버 기록 is one club's; an alert's line 2 leads with its club), the day stands alone;sessionTitle()adds the type only where it matters ("복식 매치"). A made-up name such as 토요 정기 복식 is no rule's and never appears (the lead's call, 2026-10-20). - English: L2 carries at most 2 segments beside a Tag and 3 beside a figure. The flex segment (venue or club name) truncates; fixed tokens (time, format, level, count) never do. Venue names pass through
shortName("Olympic Park Tennis Courts" → "Olympic Park"), drop "-gu" ("Seocho"), and rows omit the "NTRP" prefix (3.0–4.0; detail and hero keep it). DatesSat, Sep 19; 24-hour clock (09:00–11:00); counts6/8; relative timeNmin.
Accessibility. One stop. The name is composed from the row's own view model: urgent status first, then when, where, kind, rating, count, the viewer's own state. The date stays even under a day header: "마감 임박, 내일 9월 19일 토요일 오전 9시부터 11시까지, 올림픽공원 테니스장, 복식, NTRP 3.0부터 4.0, 8명 중 7명 참석, 내 상태 참석". Club: "강남 라켓클럽, 서초구, 멤버 42명, NTRP 3.0부터 4.0, 읽지 않은 게시글 2개". Selection is aria-selected. A picked row (a leading ring) is a radio in a group (aria-checked), spoken with 선택됨 when chosen ("올림픽공원 테니스장, 홈 코트, 송파구, 코트 4면, 선택됨"); its trash is the next stop, named for its owner ("올림픽공원 테니스장 지우기").
Motion. Press settle plus dim. No layout animation in virtualised lists: a row that moves fades out (100 ms) and in (150 ms) and its siblings snap.
Replaces in code. SessionCardV2 and SessionCardFromEntry (packages/app/src/presentation/components/sessions/session-card-v2.tsx, session-card-from-entry.tsx), SessionCompactRow (session-compact-row.tsx), ClubCard (packages/features/clubs/src/shared/club-card.tsx), DirectoryVenueCard (packages/app/src/presentation/components/sessions/directory-venue-card.tsx), VenueSearchResultRow and VenueRow (packages/app/src/presentation/components/venue-search-result-row.tsx, venue-row.tsx). Retired 2026-11-02 (K5b): SessionCompactRow, VenueSearchResultRow and VenueRow are deleted; the MediaRow's sites are the app adapters SessionMediaRow, SearchedVenueRow and VenueMediaRow (packages/app/src/presentation/components/), the venue thumbnail cascade (photo, static map, court tile, monogram) resolved in VenueMediaRow.
PersonRow
Job. The list row for people: members, dues, rankings, friends, applicants, and 알림, whose rows lead with who or what they came from. The default tap opens the profile; a notification opens its object.
Anatomy. Content width × min 68 (12 + 22 + 4 + 18 + 12) · pad 12 · avatar 40 + 12. The leading visual is a person's Avatar, a club's logo (Thumb, club, at 40) or the glyph disc (Avatar, Glyph); the slot is 40 + 12 whichever, so the text edge never moves. L1 15/22·600 ink (the name; two lines at most, never cut; a doubles pair as two names with the 1px break). L2 13/18·500 secondary is optional (Line 2 below). Without L2 the row is 64 (12 + 40 + 12), the name centred on the avatar; with an AnswerPair it grows by 48 (12 + 36): 116 with an L2, 94 without. One trailing element:
- an amount or score, 15/22·700 ink tabular;
- an ELO figure with its Level tag (one cluster, counted as one element);
- one Tag;
- trailing meta: 13/18·500 secondary text (a time, a rate) in place of a figure or Tag.
Trailing alignment. Text, figures and Tags (≤ 24 tall) centre on L1; a control taller than 24 (a 36 Choice, sm Buttons) centres on the row. One trailing element either way.
Line 2. L2 answers the question the list exists for. The screen passes a context and facts, never an L2 string. Six rules:
- L2 answers the list's question; one configuration per context (the table).
- At most two facts, one line, the decisive one first. An
extraquote under L2 is the one allowed third line (a report's reason, a request's message). - Never a repeat: a fact is dropped when it equals the Section head, the filter value, the trailing element or the Tag; NTRP is dropped beside a Level tag.
- A constant is not a row fact: a fact that is the same on every row of a list moves to the head (a Section head count or note, or the filter).
- The same kind of fact on every row of a list, so the eye can scan down it; a row that lacks the fact omits L2 and substitutes nothing.
- No L2, no space: a row with no fact is 64.
| Context | Where | L2 facts (at most two, in order) | Omit when | Trailing |
|---|---|---|---|---|
members | 멤버 관리, 멤버 목록 | 조 (the group; 수동 is part of it), 신뢰 점수 (신뢰 72점, the only trust fact on a list row; none before five sessions) | no 조 in the club and the default tier | ⋯ control (officials); an official's flagged row adds the Tag 출석 주의 (warning, weighted absences of 3 or more) before it |
participants | 참석자 (the full list) | none | always | Level tag; one Tag (호스트 > 게스트 > 친구 > 예비 > 첫 참석, the last on a host's or official's list only) |
waitlist | 예비 | 예비 N번 | never (it is the position) | Level tag |
applicants | 가입 신청, 게스트 신청, 심사 대상 | 신청 시점 ("10분 전 신청"), then the one decisive basis (함께 N명 > 매너 N > 공개 일정 N회); the message as extra | no basis data: the time alone | AnswerPair below; Tag 신청 or 지명 when mixed |
suggestions | 추천할 만한 게스트, 친구 추천 | the reason, one ("함께 8회") | never | one Button (추천, 친구 추가) |
suggestions, 복식 파트너 추천 | 복식 파트너 추천 (a ranked list) | the strongest factor's fact, never an adjective: the factor with the most points against its own maximum, chemistry only with a shared match ("20회 중 19회 참석", "신뢰 84점", "같은 편 15경기 12승 3패", "나와 ELO 51점 차") | never | the total as the figure with its unit (44 wide, "86점"), the Level tag where a level run starts; the row opens 추천 점수 (§8 Sheet, score) |
dues | 회비 (a member's own, the ledger) | the state with its date ("9월 17일 입금 완료 신청"), 잔액 | the section already says it (완납, 미납) and nothing varies | a select checkbox in Select mode; a figure only for a partial balance |
fees | 참가비, and 참석자 관리 where fees and participants are one page | the state with its time ("9월 18일 21:30 신청") | the section says it | the Level tag and at most one Tag (호스트 > 게스트 > 첫 참석), as participants; amount or Tag on a fee-only list |
ranking | 리더보드, the club's 순위 | the record "N승 N패", then the 7-day change (▲40 in the success text colour, ▼12 in the error text colour; omitted at 0); in 지역 and 전국 the club name takes the change's place | the change at 0 (the record stays) | rank column, Level slot, ELO figure |
friends | 친구 목록 | 지역 | no region set | Level tag; a row control (친구 삭제) |
requests | 친구 요청, 메시지 요청 | 시점, then the first message line (one line, cropped) or the 지역 | no message | AnswerPair below |
threads | 메시지 목록 | the last message, one line, cropped (the sender's name only in a group thread) | never | time (13/18 secondary); the unread dot in the marker slot |
people | 선수별 전적 (review 41) | a callout first (최고의 파트너, 라이벌, 천적, 최다 대결), then 같은 편 N승 N패 and 상대 N승 N패 (only the records that exist; 단식 drops 같은 편) | a person with one record shows that one | the matches played with or against them (figure) |
opponents | 상대 전적 (superseded by people) | a callout first (라이벌, 천적, 최다 대결, 최고 승률), then 최근 5경기 N승 N패 (varies by row) | fewer than 5 matches: count what exists | the overall record (figure) |
results | a person-led results list | the date | never | Tag |
picker | 단톡방, 멤버 초대 | none; a friend who is also a member shows the shared club | the grouping already names the relation | the check when selected |
search | people results | 클럽, 지역 (disambiguators only) | one match | none |
reports | 신고 (officials) | 신고 날짜, 분류; the quoted reason as extra (two lines, clamped) | never | none |
notifications | 알림 | where (the club), then the one specific thing (which session, as its day: "9월 19일", never a name; how much); a row that names no club (trust, a venue edit) has the thing alone; a title that is the club drops the club fact | the title says it all | the time as meta (13/18 secondary), or a request group's fold chevron in its place; the row's ⋯ (Variants); the AnswerPair under a pending 친구 요청; the unread dot in the marker slot, reserved on every row |
attendance | 출석 현황 | 불참 3.5 (the weighted sum: a no-show or a cancel inside 12 hours weighs 1, a cancel 12 to 24 hours before 0.5) | an unflagged row (every other row) | the rate as the figure (15/22·700, right-aligned in one column; no Level tag); every row opens 멤버 기록 |
attendanceCheck | 참석 확인 (before and during a session) | 확인할 사람: the start and the minutes past it ("19:30 시작 | 45분 지남"); 도착: the stamp, then 늦음 from 10 minutes ("19:12 도착 | 12분 늦음"), or the time they left ("20:00 먼저 감") | arriving on time (a plain stamp) | the AnswerPair 도착 · 불참 in a 확인할 사람 row (불참 asks a Dialog: it costs points); no ⋯ (a tap opens the person's Sheet: 시간 바꾸기, 불참으로 표시); no Window bar on the list |
blocked | 차단한 사용자 | none | always | the one control 차단 해제 (an sm tonal Button) |
accounts | 설정 (the signed-in account), linked accounts | the email, else the provider | neither is known | none; a linked account may carry a control |
A played match is not a people list: ResultRow and the Match row (Team lines) draw it, with no avatar.
Variants.
- Rank: the rank 13/18·600 secondary tabular, left-aligned at the rail in a column as wide as the widest rank (20 / 28 / 40), 12 before the avatar, with the figure and the level slot at the right (see Figure column).
- Marked: a leading marker
[marker 8][8][avatar 40][12][text]puts the text edge at 88. - Tag exception (BADGE-EXCEPTION): a Tag appears only where the row differs from its list's default (a roster's default is 참석, so only 호스트, 게스트, 친구 and 예비 carry one, and on a host's or official's participant list a neutral 첫 참석 marks a person with no recorded session; precedence 호스트 > 게스트 > 친구 > 예비 > 첫 참석; the viewer's own row takes the name suffix 나, not a Tag, and keeps 게스트 if a guest). At most one per row.
- Notifications (알림; owner review 46, M14):
[marker 16][visual 40][12][text][time], each row carrying its ⋯ as the row's own second stop, named for its sentence ("{line 1} 메뉴"; the kit'snotificationscontext and the shipped row draw it, 2026-11-02 ruling; a swipe is not built). The ⋯ steps aside in Select mode, on a folded group (the chevron has its place) and on a row that answers in place (the pair is its action). The visual says who it came from. A face when a person did it (a payer, an applicant, a commenter, a friend); the club's avatar (its logo at 40, a rounded square, so a club never reads as a person) for a club or an object event (a session, a notice, dues, a draw); a gray glyph disc for the system, trust and a rating (Avatar, Glyph). Never a thumbnail. Line 1 is one 해요체 sentence, the actor first ("이서연님이 입금을 알렸어요"). A repeat of one kind on one object is one row with the latest actor's face and 외 N명 ("박지훈님 외 2명이 댓글을 남겼어요"), and it opens the object; a request group (several 친구 요청) folds open in place and its children answer in their own rows. Line 2 is the club, then the one specific thing. Unread is the dot alone: no tint, no bold, no chip. Opening a row clears its dot and the row stays where it is; opening 알림 clears the bell's badge. There is no read control (owner review 48: no 모두 읽음, no 읽음으로 표시); the filter's 안 읽음 lists what is still dotted, and the ⋯ menu's 삭제 removes a row with a 되돌리기 Toast. - A pair (the club 기록's 베스트 파트너, 라이벌과 천적; owner review 46, M10): the group avatar (two faces), L1 two names with the break, L2 the facts (경기 | 승패; for a rivalry the callout first, 라이벌 or 천적, then 경기 and the record from the first name's side), one figure at the end (승률) when the list ranks. A pair needs 5 matches together; a row opens the pair's record Sheet.
States. Rest · pressed .98/.80 · focus ring · selected (trailing check 16, L1 600) · disabled .5 · loading (a 40 circle and two lines) · Select mode (checkbox slot) · 200%: the figure or Tag stays beside L1 while the longest word of any line and the element fit with 12 to spare; otherwise it drops under the last line, 4 below; the rank column stays; names wrap to three lines.
Content. Names never cut. L2 is one line (English Treasurer | Group A fits). English trailing figures: money as ₩30,000 in tabular figures, relative time as 5min, counts as 6/8.
Accessibility. One stop. Member: "김민준, 총무, NTRP 3.5". Dues: "김민준, 회비 30,000원, 미납". Ranking, rank first: "3위, 김민준, ELO 1,482, NTRP 3.5, 18승 12패, 7일간 40점 상승". A row with a real second control (⋯, 친구 추가) keeps two stops: the row, then the control, named for its owner ("김민준 메뉴"). A notification: "읽지 않음, 이서연님이 입금을 알렸어요, 강남 라켓클럽, 10,000원, 5분 전", then the ⋯ as its own stop, "이서연님이 입금을 알렸어요 메뉴"; a group says its sentence whole ("박지훈님 외 2명이 댓글을 남겼어요") and a request group adds "펼치기" or "접기". 삭제 and 이런 알림 끄기 are also custom actions on the row.
Motion. Press settle plus dim.
Replaces in code. PersonRow (packages/app/src/presentation/components/person-row.tsx, on ListRow), LeaderboardRow (packages/app/src/presentation/components/leaderboard-row.tsx), renderRankRowItem and MyRankRow (packages/features/records/src/leaderboard-rank-row.tsx, my-rank-row.tsx), MemberListItemRow (packages/features/clubs/src/club-members/member-row.tsx), DuesRow (packages/features/clubs/src/dues/dues-row.tsx), DmThreadRow (packages/app/src/presentation/components/dm/dm-thread-row.tsx), the 알림 row (packages/features/home/src/notification-center/notification-row.tsx; the v1 NotificationRowShell and NotificationAdminAttentionSection, with their 13 category chips and the tint and bold for unread, are already gone). PlayerIdentityChip and PlayerIdentityCard stay as the siblings for bylines and scoreboard slots. Built 2026-11-02 (K5a, additive): PersonRow, TextRow, MediaRow and FeedItem are packages/ui/src/person-row.tsx, text-row.tsx, media-row.tsx and feed-item.tsx over one shell (row-shell.tsx); the app's PersonRow (above) keeps its name and export as a thin wrapper (userId to the profile route and prefetch, nothing else) when the sweep reaches it, so two same-named exports exist and features import the app's. Rows own the rail (padding 0 rail) and sit in RailBleed inside a railed column. The 24 contexts and the line-2 fact copy are domain, not ui: packages/app/src/presentation/utils/person-row-model.ts (personRowModel(context, data, copy)) returns the row's line 2, trailing parts, quote, marker and control, and a screen passes a context and facts, never an L2 string. The level tag, the answer pair and the figure column are level-tag.tsx, answer-pair.tsx and row-trailing.tsx. Retired 2026-11-02 (K5b): the entity ListRow (list-row.tsx, list-row-person.tsx, list-row-visual.ts), SelectionCheck, flat-text.tsx and the PERSON_ROW pitch are deleted; the app PersonRow is the wrapper over the ui row and nothing is built on ListRow any more.
Text row
Job. An object in a list that has no leading media: documents, receipts, history entries (동의 이력, 제안 내역), recent searches. A Cell is a label/value pair inside a settings group; a Text row is an object in a list.
Anatomy. The PersonRow text block without leading media: pad 12, L1 15/22·600 ink, L2 13/18·500 secondary (one fact, or Meta), min-height 68, one trailing element (a Tag, a figure or a Row trailing control such as the × on a recent search). A Text row opens the item itself, so it carries no chevron (§1.13 IA7). A state of the object that is not the row's one trailing element is a Tag after line 1 (l1Tag): when the trailing slot holds the figure, the Tag follows the title's first line through the line slot, 8 after it (§6 Tag, Placement, home 2, as a Cell's Label Tag). It stays beside line 1 while the title's longest word, the Tag and the 8 between them fit and the trailing element still sits beside the text; at large text the title wraps and the Tag stays on its first line. One Tag per row. The figure column stays the row's end, so the amounts align down the list. (A record in 내 신뢰 기록 is a MediaRow since 2026-10-20, §4 Trust.) Three more usages (owner review 46): a ledger entry on 회비 관리's 장부 (l1 the entry, l2 the day | the category | 영수증 when a receipt is attached, the signed amount trailing: "+370,000원", "−120,000원"); a record on 멤버 기록 (§4 Trust, the officials' view): the day, the rule applied and its weight, and extra holds the person's reason (two lines, clamped) and, under a request, the AnswerPair 인정 · 거절; a session fee on 참가비 내역 (owner review 46, M28; the kit-fees lane): l1 the session's place (the club; a pickup's venue: a session named on another page), l2 the day and the type (sessionTitle(): "9월 19일 | 복식"; a pickup "9월 1일 | 복식 매치"), the amount trailing ("10,000원", English "₩10,000"), and a Tag after line 1 only while the fee is not settled (§6 Tag, session fees). A fee that is settled without a payment says so where the amount would be, because an amount there would read as money paid: a waived fee "면제", an acknowledged refund "환불 확인됨" (English "Waived", "Refund confirmed"). A row opens its 일정 상세, where 입금 완료 알리기 and 환불 받았어요 live; the page asks for nothing.
States. Pressed .98/.80 · loading · long text (L1 wraps).
Accessibility. One stop composed of L1, L2 and the trailing text; a trailing control is a second stop.
Motion. Press settle plus dim.
Replaces in code. Ad hoc ListRow (packages/ui/src/list-row.tsx) compositions for document and history lists. Retired 2026-11-02 (K5b): ListRow is deleted; its last text sites became TextRow (venue blog reviews), a DoorPill (the partner suggester's profile door) and a MediaRow glyph thumb (the public club profile's next session).
Cell
Job. A label/value pair or a single setting inside a group: facts, settings, pickers, toggles. For an object in a list use a Text row or a PersonRow.
Anatomy. Content width × min 48 · label 15/22·400 secondary at the left · value 15/22·500 ink at the right (wraps, right-aligned, never ellipsized) · 12 between. The cell hairline (#E5E5E5, 1px, rail to rail) runs between the Cells of one group and none follows the last. One trailing slot per Cell; it centres on the Cell's row, so a chevron or pencil stays centred when the label or the value wraps (the two-line rule).
Variants.
| Variant | The one difference |
|---|---|
| Static | label and value; not pressable |
| Nav | trailing chevron 16 (a right chevron always means navigation) |
| Value + chevron | the value sits before the chevron; opens a picker Sheet |
| Toggle | trailing Switch (52 × 32); the row press toggles it |
| Input | the value is the field: 15/22·500 ink, right-aligned, caret ink at its end; the error is the support line under the Cell, at the rail |
| Destructive | the label in danger ($error) |
| Selected | trailing check 20 and a 600 ink label, no fill (pickers) |
| Tag value | one Tag sm (24) in the value slot; the Cell stays 48; a navigating Cell keeps its chevron after the Tag |
| Label Tag | one Tag sm, 8 after the label (신규); the value slot then holds text only; counts toward the object's two Tags |
| Edit | trailing pencil (the shared EditCue: a 16 glyph in tertiary, the size and tone of the chevron), never beside a chevron; spoken "장소, 수정"; opens the field's bottom edit Sheet (× in the title row, one 저장 in the footer) |
| Locked | the Cell with its value and a trailing 16 lock glyph in tertiary, in the chevron's slot; no reason line (1.16); not pressable |
| Two-line | min 68 (12 + 22 + 4 + 18 + 12): a 13/18·500 secondary second line under the label (dates, captions, reasons) |
| Trailing slot | exactly one of: value text · a Tag · a Choice ▾ (sm 36, opens a Sheet) · an sm tonal Button or an IconButton (Row trailing control) · a Switch · a count Tag before the chevron (danger when it needs action) |
| Consent | a leading 24 checkbox (2px #4F5851 edge; checked = ink fill and a white check), the label with its [필수] / [선택] prefix 15/22, and a trailing "보기 ›" (the Door pill, its own focus stop, 44 by hit slop) that opens the Document page. Details below |
| Command | a plain pressable row (a menu row): an ink 400 label, an optional leading 20 icon (icon, growing to 1.5×, in the label's colour), an optional count Tag and a 16 chevron trailing; Destructive is the same row in danger |
| Option | a choice row in a Sheet or a settings page: the chosen row's label turns 600 and a 20 ink check trails (a tap that picks and closes); several: a trailing 24 square |
Leading mark (lead) | a leading 24 checkbox (check) or radio ring (radio: a 2px secondary edge, a 12 ink dot when chosen) at the rail, the content at 36; it rides line 1 through the line slot. The ring leads wherever a footer CTA follows the choice (Dispute, Decide); the trailing check stays for pickers that select and close |
Expand (expand) | content that belongs to the row (the score table under 직접 입력) sits under it, 8 below the row and 16 above the next; it owns its padding and the cell hairline follows it |
Inset (inset) | the side padding is 16 instead of the rail and the hairline runs full width (the anchored Menu from 600 pt) |
| Busy | a trailing 20 spinner replaces the chevron while the Cell's action runs; sibling Cells are inert (.5) until it settles; spoken "처리 중" |
Copy (copy) | the action Cell that copies its value (the account number): the value and, in the trailing slot, a copy icon button (the IconButton, a 20 glyph in its 48 target); details below |
| Disclosure | see ExpandToggle |
Consent Cell details. The master row "전체 동의" is 600, ticks or clears every item and shows the mixed state when partly ticked; a 16 gap and a cell hairline separate it from the items. Marketing sits last in the same rhythm as the required rows: no extra gap (owner review 2 read the old 12 as the third row's padding); its [선택] prefix sets it apart. The labels are the shipped legal strings until counsel reviews them; the (광고) prefix belongs on the advertising push itself, never on the consent row. Nothing is pre-ticked.
동의 관리. On the consent-management page (동의 관리, 설정 › 프라이버시) every row carries the sign-up's text prefix, [필수] or [선택], in its label (never a Tag), and the page has no empty state (the required consents exist from sign-up). An optional consent is an action Cell: the label with its [선택] prefix, the date it was granted (or 동의하지 않았어요) on line 2, and the sm tonal Button at the end, 철회 while granted and 활성화 while not. Its text is one tap away before any grant: the row carries a 보기 door under it (the Cell's expand slot holding a link with a 16 chevron, its own focus stop, named "{purpose} 보기"), which opens the consent's text on a Document page. The Button never grants silently: 활성화 and 철회 each open the consent Dialog (§ 8 Dialog, Consent), and the grant Dialog repeats the shipped sentence. A required consent has no Button and no grant path; its line says it ends with the account and the nav Cell 회원 탈퇴 follows.
Copy Cell details (owner review 46, M11; the owner's check of 2026-10-19). The one account row wherever an account number shows: 은행, 계좌번호 with its copy icon button, 예금주 (the 입금 안내 Sheet and 클럽 홈 › 회비 draw exactly this); the account entry fields of the forms are inputs, not copy rows. It is the action Cell with its control drawn by the Cell, so no page composes its own: the value is the number, 15/22·500 ink, and the control is the kit's IconButton with the copy glyph (lucide copy) in the trailing slot, because the owner asked for "a copy icon button, as is the case in many other apps" in place of a "복사" Button: a 20 glyph in the 48 target, its visible edge on the rail (the target reaches 14 into the margin), so the row stays 48. A glyph has no text, so the button is named by the label and "복사" ("계좌번호 복사"). The Toast "계좌번호를 복사했어요" is the page's own state frame (a result, 4 s), not part of the Cell. The native clipboard module arrives with the next native build (W5); until then the button opens the share sheet. 클럽 설정 draws the same three rows under the head 입금 계좌, with the command 수정 at the head's end; the rows stay read-only there and the page that edits them is a pushed page (Edit Sheet, Content).
Required Cell in a form. The label carries the one asterisk ($errorText, hidden from assistive technology, with "필수" in the accessible name). An unset picker reads "선택" + chevron. The error is the support line under the Cell (a 16 alert glyph and a 13/18·600 $errorText message); the Cell has no border to tint.
Current row (the app's web list-detail; the kit draws none). In the web layout where a list sits beside the page it opens (settings, messages), the row of the open page takes current: spoken as the current page (aria-current="page") on every platform, and on the web only a gray100 fill in the row's own box (the 8 of reach, radius 8), because a phone shows one page at a time. This is the app's web affordance, not a kit drawing, so no board holds it; cell-visual.test.ts does.
States. Rest · pressed .98/.80 (nav, value, edit; a toggle presses its Switch) · focus ring · disabled .5 · loading (a label bar 40% and a value bar 20%) · selected · error (support line) · long text (label and value wrap; in a two-line Cell the value takes at most half the row and wraps there, so it never squeezes the label and its line 2) · 200%: from 1.5 the label sits over the value, 4 apart, the value left-aligned, and a second line (a two-line Cell's line 2) goes 4 under the value, so it still ends the Cell as it does at 100% and the value stays next to its label; the trailing element (chevron, lock) centres on the Cell, the two-line rule.
Content. The label is one line at 100% (English ≤ 26 characters); the value wraps, never truncates.
Accessibility. Static: a text group, "종목, 복식". Nav: a button named by the label, with the value when present ("프로필 공개, 클럽 멤버에게만"). Toggle: switch with a constant label, state in aria-checked. Edit: "장소, 수정". Locked: the label, its value when it has one, then 잠김. Consent: a checkbox named "필수, 이용약관 동의" and a separate stop "이용약관 보기". Copy: a text group "계좌번호, 3333-01-1234567" and the icon button as its own stop, "계좌번호 복사". Busy: aria-busy, "처리 중".
Motion. Press settle plus dim; the Switch thumb moves in 150 ms and the track recolours in 100 ms. Reduced motion: both instant.
Replaces in code. FactRow / FactList / FactSegments (packages/ui/src/fact-row.tsx), the settings and picker uses of ListRow (packages/ui/src/list-row.tsx), SettingsRow (packages/features/profile/src/settings/components.tsx), AdminNavRow (packages/features/clubs/src/club-detail/admin-nav-row.tsx), PickerRow (packages/ui/src/picker-row.tsx), EditFieldRow (packages/ui/src/edit-field-row.tsx, with its EditCue pencil). Retired 2026-11-01 (K2b part 2, batch 3): all of them are deleted (SettingsRow, AdminNavRow, PickerRow, EditFieldRow and EditCue, FactRow / FactList / FactSegments with fact-row-visual.ts); the last callers, the deep-link share previews apps/mobile/app/s/[sessionId].tsx and apps/mobile/app/m/[matchId].tsx, are static Cells. The edit Cell draws its own pencil, so the Field (K6) does not need EditCue. (EloFactRow, packages/app/src/presentation/components/elo-fact-row.tsx, is a Block, not a Cell: it goes with the Block, W3/K6, and no Cell codemod touches it.)
Day header
Job. Names the day above a day-grouped list.
Anatomy. A 22 line box with no fixed height: the date 15/22·600 ink at the rail, the relative day 13/18·500 secondary at the right ("오늘" in ink 600); 24 above and 12 below (ink gaps about 28.5 above and 16.5 below). No mark in or beside it: the day header takes no leading slot and has no today dot, because "오늘" already says it. Sticky in day-grouped lists, with the edge hairline #D4D4D4 while content scrolls under it.
States. Rest · stuck (edge hairline, 100 ms fade).
Content. Korean 9월 18일 (금); English Fri, Sep 18 with Today or Tomorrow; no year within the current year (Sep 19, 2027 otherwise).
Accessibility. A heading (h2; h3 inside a section), the relative day first: "오늘, 9월 18일 금요일". From 1.5 the relative day drops under the date.
Motion. Hairline fade 100 ms; reduced motion: instant.
Replaces in code. SessionDayHeader (packages/app/src/presentation/components/sessions/session-day-header.tsx). Built 2026-11-02 (K5a): DayHeader is packages/ui/src/day-header.tsx (the date 15/22·600, the relative day beside it, 24 above and 12 below, first, and stuck with the StickyEdge); SessionDayHeader keeps its name and delegates to it, with the date and the 오늘 / 내일 qualifier. K3b had drawn it as a Section head (20/28·700) as a stand-in; it is the kit's 15/22 again.
Section head
Job. Titles a section and offers its one way onward.
Anatomy. Min-height 28 · title 20/28·700 ink at the rail · every text of the head shares the title's baseline: the count after the title and, at the right one control, never text: the door (the Door pill, §7: the Choice's 36 pill with a 16 right chevron, 44 target; owner review 48) or a Choice, which with the expand chevron and the Shelf's pager rides the title's first line through the line slot (a Choice after the door, see Variants) · 32 above and 12 below (the edge rule trims the first row's top padding); directly under Tabs the 24 tab gap is the seam and is not added to the 32. Why: the space above is about twice the space below, so the title belongs to its own rows (peers measure about 33.8 above and 16.8 below, ink to ink).
Variants.
- Count suffix: the title followed by a count, 15/22·500 secondary with its unit ("24명") or earned out of total ("6/20"), 8 apart on the title's baseline ("친구 24명") (the board check
headBaseline). The title and the count wrap as whole pieces: at large text the count moves under the title, and a count of several facts wraps by fact. - No command (owner review 48): 알림's 모두 읽음 left (a row is read when it is opened). A group of read-only Cells that are one answer opens its edit page from its door, 수정 › (클럽 설정's 입금 계좌); the Cells themselves carry no pencil. The one bulk verb a head may carry is its group's own (§1.18: 회비 관리's 미납, 모두 알리기).
- ExpandToggle slot: the section's one chevron collapse.
- Shelf head: title, optional count suffix and its door, the Door pill 전체 보기 › (or the list's own word: 순위표 ›, 선수별 전적 ›); no door when another door on the page already opens the whole set; from 600 pt wide the ‹ › buttons.
- Preview (owner review 26): on a hub page (홈, 클럽 홈, 기록, 프로필), and on a detail page's section that previews a list of its own (일정 상세's 경기, 장소 상세's 다음 일정, 기간 기록's 상대, 토너먼트's 순위), a section shows at most three rows. The action "전체 보기 ›" and the count suffix (the total, "5개") appear only when the section hides rows; a section that shows everything has neither. The action opens the same set in the same order (할 일 › 알림, whose 할 일 group lists the whole set; a day or week of sessions › 경기 › 예정 at that day). Pushed lists and tab panes never cap: they virtualise.
- Time sections (알림; owner review 46, M14): a Feed of dated items is sectioned by 오늘, 이번 주 and 이전 알림 (the last 30 days), newest first, in plain Section heads. A section with no rows is not drawn, and the first one drawn is the Feed's head: it carries the filter Choice (no 모두 읽음, owner review 48). Reading never moves a row from one section to another. The current time head pins as a day header does (§1.14, the pinned stack): the Choice rides the first head and leaves with it when the next head takes its place, as the 소식 filter does on 클럽 홈. The list ends 40 under its last row, with no end line (owner review 48).
- Month sections (참가비 내역; owner review 46, M28): a dated list that runs across months is sectioned by month, newest first, in plain Section heads, the title the month ("9월"; the year only for another year: "2025년 12월", English "September", "December 2025"), no count and no action. The order is the session's day, not the row's
created_at. The current month head pins as a day header does (§1.14, the pinned stack); a month with no rows is not drawn, and the first head drawn isfirst(12 under the Header, as 알림's). The list ends 40 under its last row. - Stuck: a Section head that is the pinned layer (the Feed's head on a page with no Panes) draws the edge hairline on its bottom edge while content scrolls under it, as the Day header's
stuckdoes; the Header above it draws none. - Roster head: the count
6/8and "2자리 남음" (see Roster). - Info hint: a title whose rule has its own Sheet carries the Info hint (§7) after its title and count (추천 순서 "?"): 4 after the title, on its first line through the line slot, before the action and the Choice, which stay at the right. The hint is the head's sibling, not inside the heading, so the heading's name stays the title. The ELO Block's 티어 안내 › and the 신뢰 head's 점수 안내 › (on 내 신뢰 기록) are Door pills like every door (owner review 48).
- Note: data only (the owner, 2026-10-20; 1.16): a section's own figures when no row holds them ("1인당 20,000원 | 매월 5일까지") or an empty Module's state line ("일정에 참석하면 신뢰가 쌓여요"), never what a section is, how it is counted or ordered, or who sees it; a measure goes into the title ("최근 20회 출석"). It sits 4 under the title, 13/20·500 secondary, at most two lines, and the head's 12 bottom padding follows it. A section note is never a separate Caption row.
- Choice: the trailing slot may hold a Choice ▾ (normally the filter, "필터 ▾"), centred on the title's first line; a lone filter Choice never takes a row of its own. A head may carry a door and a Choice together (티어 안내 › + 기간 ▾): the door first, the Choice last, both centred on the title's line, 12 between.
States. The Door pill's and the Choice's.
Content. The title wraps; the door keeps its line.
Accessibility. A heading (h2) with the door as a link whose name starts with its visible text: "전체 보기, 내일 일정".
Motion. None.
Replaces in code. SectionHeader and SectionBlock (packages/ui/src/section-header.tsx, section-block.tsx, section-header-metrics.ts). Retired 2026-11-02 (K3b part 2B): all three are deleted with their metrics test; every caller is a SectionHead (a head pads its own rail, so a head in a railed column or list bleeds through RailBleed), and GroupedFeedList's default section header and DataSection's title draw one the same way (DataSection lost titleRight, which had no caller).
ExpandToggle
Job. Expand or collapse content in place: a lone row for "show more", a disclosure Cell for FAQ-style content.
Anatomy.
- Lone row: a full-width tonal md Button (44, gray100, the label 15/22·600 ink) in a 60 row (12 above, 4 below), stating its count ("4개 더 보기" / "접기"), then a 16 chevron 8 after the label that rotates 180° (owner review 48: more of a list is a control, never bare text). Never a bare chevron.
- Disclosure Cell: a Cell whose trailing chevron points down (closed) or up (open); an optional value (a date) sits before it. Opening reveals the body under the label: 16/24·400 ink, 12 below the label, 16 at the bottom. Cells open independently; opening one never collapses another (auto-collapse makes the content jump under the finger). A text-driven box.
- Form details (forms pass, X11): a form of ten or more controls opens with the answer that decides the rest, the defaults on one line (the What's-left line, one Meta line: "6게임 | 2게임 차이 | 듀스 | 타이브레이크 7점 | 무승부 없음") and a lone row that opens the rest in place, "세부 규칙 7개 더 보기". Open, the rest takes the row's place and the row ends it as "접기". A press that finds a value out of range opens it. It is one door for a detailed set, drawn once per form; it is not a chevron on each defaulted axis (wizards.md's law against expanders on a defaulted axis stands for those). A preset that decides its own details has nothing to hide and draws its short page open. Example: 경기 규칙 and step 6 of 일정 만들기, which is the same page.
States. Closed · open · the Button's pressed and focus.
Accessibility. aria-expanded on the toggle; focus stays on it. The label is the visible text.
Motion. Chevron 180° in 200 ms; the body reveals in 250 ms sliding 8 pt. Reduced motion: no rotation or slide, a 100–150 ms fade.
Replaces in code. ExpandToggle, ExpandChevron and ExpandReveal (packages/ui/src/expand-toggle.tsx). Retired 2026-11-02 (K3b part 2B): the chevron-only row, its summary, inset and accordion children are deleted (with resolveExpandSummary, the 3-name cap and the listAndMore string); ExpandToggle is the count form only (label, openLabel), an accordion is a disclosure Cell, and the error boundary's details toggle is the count form with 자세히 보기 / 접기. ExpandChevron and ExpandReveal stay.
Roster
Job. Who is coming, on the session page, as one row of faces. The full list is one tap away.
Anatomy. Four equal cells of the equal-cell grid (content width / 4 = 87.5 at 390), content centred in each: avatar 48 · 16 · name 15/22·600 (two lines, three from 1.5; never cut) · 4 · Level tag (24 circle), alone on its line (min cell 114). The role is a pill, not a chip beside the Level (owner review 6, D2): a sm Tag (24, 2 ring in the canvas colour) centred on the avatar's bottom edge, covering its bottom 12 and hanging 12 below; the 16 under the face reserves the hang in every cell, so names start at one y (the gap grows with the pill from 1.5×). 호스트 is the solid neutral tier, 게스트 the weak one. In a club or recurring session that has members, a guest's cell carries the 게스트 pill; members are the default and carry nothing, and a pickup marks nobody (owner review 2). One pill per cell: 호스트 > 게스트 > 친구 > 예비. Order: the host first, then the viewer's friends, then the rest. Up to four people fill the row. With more than four people the row shows three and a door as the fourth cell: a 48 gray100 circle with +N 15/22·600 ink and the name line "외 N명". Empty slots are not drawn. Cell min-height 102 with one-line names (pitch 104), 124 with two-line names (pitch 126).
The head is the Section head "참석" with the count 6/8 and 2자리 남음 (and 예비 2명 when anyone waits); its action 전체 보기 and the door both open 참석자. Each cell opens that person's profile. Why: none of eight peers scrolls people sideways, people miss items past the edge, and the roster is what the join button acts on; one row (116) replaces two (216).
States. Rest · pressed .98/.80 · loading (four 48 circles and a 15/22 bar at 60%) · empty (the head only; the CTA is the invitation) · 200%: from 1.5 the row becomes two columns and names wrap.
Content. Names never cut.
Accessibility. The group is named by its head: "참석, 8명 중 6명, 2자리 남음, 예비 2명". Each cell is a button ("김민준, 호스트, NTRP 3.5"; a guest "박지훈, 게스트, NTRP 3.5"; the Level circle is no longer hidden from the reader, since it carries the level); the door is a button ("외 5명, 전체 보기"); empty slots do not exist.
Motion. Your own change (you join, you cancel) in this short fixed stack: a 200 ms layout transition, the mover fades out (100 ms) and in (150 ms). Someone else's change snaps. Reduced motion: the fade only.
Built 2026-11-02 (K7, additive): Roster is packages/ui/src/roster.tsx + roster-cell.tsx on the equal-cell grid cells-grid.tsx (numbers in roster-visual.ts, held to FvkRoster and FvkCCells): the cell is 114 at least (48 + 16 + 22 + 4 + 24; the 102 / 124 above was stale), the role pill a sm Tag in a 2 canvas ring on the face's bottom edge, the door cell past four, the kit's loading cells; the words (호스트, 게스트, "외 N명", 전체 보기) and the group label are props; a head count below the people or a door without a handler throws. The Level numeral comes from ntrpLevelNumeral(tier, subTier) in packages/app, shared with the identity Block. ParticipationSection converts in W4; EmptySlotRow is deleted.
Replaces in code. The PersonRow preview list of ParticipationSection (packages/features/sessions/src/session-detail/sections/participation-section.tsx); AvatarStack and EmptySlotRow (packages/ui/src/avatar-stack.tsx, empty-slot-row.tsx).
Figure column
Job. Keeps the digits of ranked and scored lists aligned down the page.
Anatomy. A ranked or scored list right-aligns its trailing figure (15/22·700 tabular) in a column as wide as the widest figure on the page. The Level tag, where a level run starts, sits in a reserved 24 slot, 8 before the figure column, so a row without a Tag keeps the same figure position. The rank column is as wide as the widest rank, measured at 13/18·600 tabular ("99" 16.7, "999" 25.0, "1,005" 36.9) and rounded up to the grid: 20 (1–99), 28 (100–999), 40 (1,000–9,999). Digits are left-aligned at the rail, 12 before the avatar (owner review 2: right-aligned, a single digit started 36 in). The width comes from the total ranked count, which the RPC knows, not from the loaded rows, so it never jumps when the next page loads; at larger text it grows with the label scale, rounded up to the 4 grid.
States. Follows the row.
Content. The Level tag shows only where a level run starts (BADGE-EXCEPTION); the Podium always shows it.
Accessibility. Part of the row's name, rank first.
Motion. None.
Replaces in code. The fixed 28 px rank column of LeaderboardRow (packages/app/src/presentation/components/leaderboard-row.tsx).
Row trailing control
Job. A row's one trailing element when it is a control, only where § 1.18 allows it: the one verb a list exists for (친구 찾기's 친구 신청, 차단한 사용자's 차단 해제), a remove × in a form, 사진 변경. Never a menu: a row's commands are its tap (its page, or on a managing page the person's Sheet).
Anatomy. An IconButton (a 20 glyph in a 48 target) or an sm tonal Button. It centres on the row. A row has one trailing control; a second command on the same object never becomes a MenuSheet of two (S-2). Changing an image is one pattern (the lead's call, 2026-10-20): the row's one control, 사진 변경, opens a three-row MenuSheet, 사진 찍기 · 앨범에서 고르기 and, in the destructive group, the removal (사진 삭제 on 프로필 수정; 문양 사용 for a club logo, the app's word for its generated mark), which asks its Dialog. The club banner follows the same pattern with its own source added: 사진 찍기 · 앨범에서 고르기 · 기본 사진 선택, then 배너 삭제. A profile with no photo, or a club logo still on its generated 문양, has nothing to remove, so its sheet holds the two sources only and no removal (it is the one two-row MenuSheet, allowed because the control that opens it does not change with the state); a banner still on the default likewise holds its three sources only (사진 찍기 · 앨범에서 고르기 · 기본 사진 선택), and 배너 삭제 appears once a banner is uploaded. A draft image (the club logo in 클럽 만들기, before the club exists) is removed at once with a 되돌리기 Toast, as the IconButton rule says; a saved image asks its Dialog. The row's press area excludes the control's target, and pressing the control never presses the row.
States. As the control (pressed .92/.78 for an icon).
Accessibility. Two stops: the row's composed label, then the control, named for its owner ("정우진, 친구 추가").
Motion. As the control.
Replaces in code. Hand-placed trailing buttons in friend, applicant and recent-search rows.
Leading marker slot
Job. Gives a row's leading element (an unread dot, a checkbox, a rank) a place inside the rail, so nothing enters the side margin and the text edge stays put.
Anatomy. Nothing except full-bleed surfaces (photo, band, hairline, block fill) enters x < rail or x > width − rail (LM1). A leading marker takes the first slot of the row, starting on the rail (LM2):
| Leading element | Occupies | What follows starts at (rail 20) |
|---|---|---|
| none | none | 20 |
| marker dot 8 | 8 + 8 = 16 | 36 |
| checkbox 24 (Select mode, consent) | 24 + 12 = 36 | 56 |
| rank column 20 / 28 / 40 | + 12 | the avatar at 52 / 60 / 72 |
| avatar 40 | 40 + 12 = 52 | the text at 72 (88 with a marker) |
| thumb 56 | 56 + 12 = 68 | the text at 88 (104 with a marker) |
- Whole list (LM3): the slot is reserved on every row of a list that can show the marker, so all text edges match; a list that never shows the marker has no slot. In Select mode every row's slot is the checkbox slot and the dot is hidden (the state stays in the spoken name).
- Headings take no slot (LM4): a day header, section head or page title stays on the rail.
- Size and colour (LM5): an 8 circle for a row marker; unread = danger
$error(#A3261F, 7.36:1); never the brand ball as a dot fill on white. A 6 dot stays for dots that ride an icon or a tab (tab bar, header badge). - Vertical (LM6): attached through the line slot: centred on line 1, top = (line − 8) / 2 (8 for 17/24, 7 for 15/22; a computed offset, exempt from the grid), following line 1 at every text size. Not centred on the row.
- With avatars and thumbs (LM7):
[marker 8][8][avatar 40][12][text],[marker][8][thumb 56][12][text]. A person's presence is a different marker: a 10 dot on the avatar's bottom-right corner with a 2px canvas ring. Counts stay trailing. - Cost: 16 of text width on rows that carry a marker.
Why: Apple's own apps keep ink off the margin (first ink measures 20.00 at 414 and 440 pt), and a text edge that jumps between rows of one list reads as hierarchy that is not there.
States. Marked · unmarked (the slot stays reserved) · hidden in Select mode.
Accessibility (LM8). The dot is hidden from assistive technology; the row's one name begins "읽지 않음, …". No announcement when the dot clears. Under text scaling the dot and its gap stay 8 / 8 and the dot follows line 1's centre at the scaled line height.
Motion. None; the dot appears or clears instantly.
Replaces in code. The hung unread and today dots (Dot in packages/ui/src/dot.tsx) and the DOT_SIZE 7 (packages/ui/src/box-sizes.ts).
SwipeAction
Job. A shortcut on a row for its most common action (delete, archive). An accelerator only.
Anatomy. A left swipe reveals one 72-wide action carrying its verb: danger fill for delete, ink for archive. A full swipe commits and shows an undo Toast (되돌리기, 8 s).
States. Revealed · committed · disabled in Select mode.
Accessibility. Every swipe action also exists as a custom accessibility action on the row and in Select mode or the row's MenuSheet; it is never the only path.
Motion. The row follows the finger. Reduced motion: no elastic overshoot.
Replaces in code. None (new).
Picking rows (Select mode retired, the owner, 2026-10-25)
Job. Acts on several rows at once when that is the page's job: confirm dues (회비 관리) or fees (참석자 관리). The owner: "A user can simply tap to select or deselect."
Anatomy. No mode to enter and no bar: the rows of the one section the band's job acts on carry the leading 24 checkbox in the checkbox slot (24 + 12 = 36, content edge 56): 2px #4F5851 edge; checked = ink fill and a white check. The evidenced rows open ticked (확인 대기: the people who said they paid). A tap anywhere on the row ticks or unticks it. The CTA band names the job with the count ("3명 납부 확인"), soft-disabled at zero (the press says what is missing). Rows of the other sections stay plain: a tap opens the person's Sheet (§1.18). No 선택 command, no select bar (취소, n명 선택됨, 전체 선택), no long-press. Deleting alerts is not a job: one alert is swiped away.
States. Ticked · unticked · count zero.
Content. A money action asks a Dialog with the count and the total before it commits.
Accessibility. Each picking row is role="checkbox" with aria-checked; the band's count is announced politely.
Motion. Checkbox fill 100 ms; reduced motion: instant.
Team lines
Job. Two teams with their players and their games, in every place a match is drawn: the Match row and its request row, ResultRow, the Match state block (zone B), the Host review sheet and the ScoreEntry table.
Owner approval (2026-10-14). The one match grammar (team lines with faces, singles and doubles) is approved.
Match grammar (review 5, R1). One anatomy, drawn by FvkTeamLines, on every surface that draws a match: the Match row (the round card, the scorecard, the request rows), the Match state block, the sheets (the Host review sheet), the number pad (ScoreEntry), the ResultRow and the profile's history. One team per line, the viewer's team first with "(나)", a face for every player, each name its own tap stop; only the line height changes with the context.
Anatomy. One team per line, the two lines 12 apart (8 on the block and the number pad). Each name is its own node; partners share their team's line, separated by the 1px break (12 tall, 8 each side, edge tier; α0.4 on ink), which is the only partner separator, and the teams are separated by the line itself. A name keeps its inner spaces together, a break never starts or ends a visual line, and names wrap whole and are never cut.
| Part | Rule |
|---|---|
| Viewer | The viewer's team is first wherever the viewer plays (rows included), and the viewer's name takes the suffix "(나)" ("(You)"), the roster's suffix. Everyone else sees slot order. |
| Set columns | 32 wide each (48 in the cells context), right-aligned at the rail, tabular 700, centred on their own team's line (on the team block when names wrap). |
| Tiebreak | The loser's tiebreak points are a 12/18·600 superscript beside the numeral, secondary (white α0.8 on ink), the slot reserved on both lines so the numerals stay in one column. |
| Winner | On an entered score (an entry under review included; it stands until the host changes it) and in history: the winner's names 600 ink and numerals ink, the loser's names 400 secondary and numerals secondary (7.38:1 on white). On the Block: the winner's numeral ball and names white, the loser's numeral and names white α0.7. Never a hue, never a glyph. |
| Equal | A proposed score and a void result: both lines equal, so a proposal never looks like a result. |
| Separator word | None. |
| Faces | On by default in every context (faces={false} is a board's deliberate text-only specimen, data-faces="off", never a surface). A 32 face: a singles team one face, a doubles team two whole faces overlapping 8 with a 2 px ring in the surface colour (canvas, or the block's ink), in a 56 slot so names start at one x, then 12, then the names, then the set columns. The line is max(text, 32): a row is 128 at 390 (12 + 24 + 4 + 32 + 12 + 32 + 12), a block 144, a sheet or number-pad line 48. Dropped from 1.5× text. A face shows only where can_view_face allows, otherwise initials, drawn the same as a missing face. The 40 face and the 40 footprint pair are retired. Verified by the faces check. |
| Name stops | Each name is its own tap stop to that player's profile (data-hit, role link, 44 tall and at least 44 wide; the 44 pitch is the 32 line plus the 12 gap), in the row and sheet contexts, a sibling stop after the row's press, never nested; the rest of the row opens the match's page, where the pair's record and head-to-head live (a team stat; the backend needs a pair-keyed stats model: noted, not built). Names stay tappable at 200%, where the faces drop. |
| Outcome | outcome: { kind, by } for a match that stopped (D5), by the slot (0 or 1) that retired, did not come or was defaulted, so the winner is the other side. The games so far render as given; a walkover and a cancelled match draw no numerals. The winner is emphasised only where the outcome has one (retired, walkover, defaulted), never for suspended, abandoned, cancelled or not counted. The written score is fvkOutcomeScore: "6-4 3-1 기권" / "6-4 3-1 ret.", "부전승" / "W/O"; the spoken label appends the outcome word. |
When a face shows (Faces; canon data-and-hooks.md § Faces). The board flat-v2-match-row § 12 draws it. A face reaches a client only through public.can_view_face.
| The person has a photo and the viewer… | Shows |
|---|---|
| is the person | Photo |
| shares an active club with them | Photo |
| is an accepted friend | Photo |
| the person chose "public" | Photo |
| anyone else (a pickup co-player who shares no club, not a friend) | Initials |
| is logged out (never true for a logged-out viewer) | Initials |
Initials look the same whether there is no photo or the viewer may not see it, so the app never reveals that a photo exists. In an overlapping pair each face is decided separately, and initials are one letter. From 150% text no face circle is drawn at all on match surfaces; the names carry it.
| Context | Names | Numerals | Line | Used by |
|---|---|---|---|---|
row | 15/22 | 17/24·700 | 32 with the face (24 or 22 once it drops) | Match row, ResultRow, the profile history |
block (on ink) | 15/22 α0.85, winner α1 | 24/32·700 | 32, faces | Match state block, zone B |
sheet | 15/22 beside the same 32 face | 24/32·700 | 48, lines 12 apart | the Host review sheet |
cells | 15/22 | 24/32·700 in 48 × 48 Input cells, 8 apart; set labels 13/18·500 above | 48 | ScoreEntry, the Host review sheet's 점수 고치기 keypad |
States. Equal · entered (winner emphasis) · no numerals (names only: S2, S5, S6, upcoming) · with a tiebreak · long names (wrap) · 200%: numerals cap at 1.5 (the 32 columns hold), names wrap to three lines.
Content. In rows there is no word between the teams. In running text a matchup is " vs " between teams and " & " between partners ("김민준 & 이서연 vs 박지훈 & 최유나"); 대 appears only where a score is read aloud ("6 대 4"). One spelling of vs, lower-case, in both languages.
Accessibility. One text group (img with a label where the names are not stops; a group with the label in the row and sheet contexts, where each name is a link; the cells context is a group of Inputs). Spoken from the viewer's side: "내 팀 김민준, 이서연 6. 상대 팀 박지훈, 최유나 4." (a full stop between teams, commas between partners; several sets read "1세트 6, 2세트 6"; a tiebreak "6, 타이브레이크 4"; a winner "승", on an entered score). A spectator hears the teams without the two prefixes. English: "Your team Minjun Kim, Seoyeon Lee 6. Opponents Jihun Park, Yuna Choi 4." One builder, teamLinesLabel(model), feeds every surface's accessible name.
Motion. None.
Replaces in code. MatchScoreboard (packages/app/src/presentation/components/sessions/match-scoreboard.tsx; its TeamRow and ScoreCell are the starting point), PLAYER_JOINER = ' & ' (match-history-display.ts), recordDetail.myTeamLabel and opponentTeamLabel, LiveMatchRow, CompletedRow and MatchHistoryRow; joinNames() becomes the one place names are joined.
Why: review 3, lane C §4 (seven of ten peers put each team on its own line, none writes a word between teams in a list row; the shipped copy already writes vs ${names}).
ResultRow
Job. One played match in a history: the viewer's team, the opponents, the date and the result.
Anatomy. The Match row shape (header first; Match row states, §10) with a date header: the header line holds the date (or the round and court) and the outcome Tag, 승 (success weak) · 패 and 무 (neutral weak); under it the Team lines, the viewer's team first with "(나)", each team's games in 32 wide columns on its own line. History lists entered scores, so the winner is emphasised: its names 600 ink and numerals ink, the loser's names 400 secondary and numerals secondary (the Tag already says who won). A score under review (the 경기 rows of 일정 상세, or the history) swaps the 승 / 패 Tag for 검토 중 (warning weak) and keeps the entered score and its winner standing. Faces by default (Team lines): the row is 128 tall at 390 under a Tag (12 + 24 + 4 + 32 + 12 + 32 + 12), with each name its own stop; photos names the opponents' portraits.
Outcome words (D5). A stopped match takes outcome: { kind, by }: the Tag is the outcome, in the viewer's own words where the viewer is a side and there is a winner: 기권승 / 기권패, 부전승 / 부전패, 실격승 / 실격패 (Win by ret. / Loss by ret., W/O, def.; success weak for the win, neutral weak for the loss). A suspended match reads 일시 중단 (warning), an abandoned one 미완료, a cancelled one 취소됨, a not-counted one 기록 제외 (neutral). A neutral reader sees the outcome Tag itself (기권, 부전승, 실격). The meta line carries the written score when the outcome has numerals ("9월 18일 (금) | 6-4 3-1 기권"); the winner is emphasised only where the outcome has one.
States. As the Match row.
Accessibility. One stop, outcome first: "승, 9월 18일 금요일, 내 팀 김민준, 이서연 6, 승. 상대 팀 박지훈, 최유나 4."
Motion. Press settle plus dim.
Built 2026-11-02 (K7, additive): ResultRow is packages/ui/src/result-row.tsx on the Match shell (match-shell.tsx) and TeamLines (team-lines.tsx, contexts row / block / sheet / cells; teamLinesLabel the one spoken form), with the outcomes in match-outcome.ts (the kit's eight, noShow 양측 불참 included, which D5 above does not list) and the Tags in match-tag.ts; held to FvkResultRow, FvkMatchShell, FvkTeamLines, FVK_OUTCOMES and FVK_MATCH_TAGS. One Tag only: the chips and the ELO delta of today's history row are not in it. The words are a prop bundle (matchWords() in packages/app). The stored outcome is Match.outcome with stoppedSide (no {kind, by}); matchHistoryResultRowProps maps a MatchHistoryEntry plus the stored facts the page supplies. CompletedRow, MatchHistoryRow, MatchScoreboard convert in W4.
Replaces in code. CompletedRow (packages/features/sessions/src/scorecard/completed-row.tsx) and MatchHistoryRow (packages/app/src/presentation/components/match-history-row.tsx).
Feed item (owner reviews 33–39; approved 2026-10-16)
Job. One item of a Feed that mixes kinds (홈's 소식, 클럽 홈's 소식). It uses the same layout, the same order of facts and the same height whatever its kind: a session, a 매치, a post, photos or a result. A Feed of one kind uses that kind's own row (기록's 경기 기록 is ResultRows), except the event-like objects: a club, a session, a venue and a 대항전 are Feed items in every list of them, where a second facts row takes the footer's place (review 46, "The who row" below; the owner, 2026-10-20: "All feed items should have at most 4 rows of facts").
- The layout follows 당근's home, where every listing shares one skeleton.
- The order follows the list item's overline, headline and supporting text (Material 3, as Android's
ListItemexposes them), and the common guidance that keeps a posted time apart from an event's date. - The compaction (review 36): three rows of content and the footer, as tall as the thumbnail.
Anatomy.
- The row: 16 above and below; the rail at both sides. The item is 128 tall, the thumbnail's 96 plus the padding, and every kind fits it. The whole item is one press target, with no button and no hairline; its one Tag is its state (State, below).
- The thumbnail: a plain 96 square at the left (56 from 1.5× text), r8, with the inset hairline on maps and tiles: the venue's map, the first photo, else the source's face (the club tile on 홈, the author's face inside a club, where a club tile would repeat on every row). Nothing is drawn on it. Text over an image has unpredictable contrast (NN/g, "Text over images"), and the overline already names the kind. The face is the person's photo filling the square, or their initials (20/28·700 ink) on gray100, the avatar ground, when there is no photo or the viewer may not see it (the same square either way: never a lock or a blur); it carries no hairline.
- The text column, 16 after the thumbnail:
- Overline: the label, then the source ("매치 | 정우진"); 13/18·600 secondary, Meta, one line. The label is a noun from the data (Kinds, below). The source is the nearest owner that is not the page itself: the club on 홈, the author or host inside a club, a 매치's host.
- Title: the subject, 2 below; 16/24·600 ink, one line. A post has no facts row, so its title takes two lines.
- Facts: 2 below; 15/22·500 ink, Meta, one line. At most two facts:
- first, when (or, for a kind without an event, its main fact);
- then the companion fact: a deadline that falls before the event ("D-3 참석 마감"), else the kind's qualifier (복식, the NTRP band, n장). The row's state (live, full, seats left, the viewer's own, an official's attention) is not a fact: it is the one Tag after the title (State, below).
- A deadline that is the event's own start (a 매치 recruits until it starts) is not shown.
- Footer (홈 and 클럽 홈; in a list of one kind the who row takes its place, so an item never has five rows): at the bottom, the posted time at the left ("1시간 전") and the counts at the right (a 16 glyph and 13/18·500 tertiary: people 6/8, comments, hearts; each only above zero).
Dates.
- An event's date is absolute and short: 오늘 or 내일, a short weekday within the week ("토 19:00"), else "10.18 (토)".
- A posted time is relative under 7 days: 방금, n분 전, n시간 전, 어제, n일 전. After that it is "9월 19일", with the year only when it is not this year.
- A deadline counts down: D-n within 7 days and D-DAY on the day; it leaves once passed.
- The posted time and an event's date never share a line.
- In English an event is "Tomorrow 19:00" ("Today 19:00" on the day), a short weekday within the week ("Tue 20:00"), else "Sat 10.18"; a posted time is "3h ago", "2d ago" or "Yesterday" (the terminology guide's relative chips Nm, Nh, Nd), then "Sep 19"; a deadline reads "Closes in 3 days"; the viewer's own state reads "Going"; the NTRP band loses its prefix on a row ("3.0–4.0").
Kinds (reviews 37 and 39). Three kinds, each one aggregation rule over data that exists (the writers are traced in review 39). The filter picks a kind; the overline shows the item's label, a noun from the data, never 새 or 내.
| Kind | Labels (the overline) | What it gathers | Leaves |
|---|---|---|---|
| 일정 | the session's event type: 일정, 매치, 대회 (a session with a bracket), 리그, 대항전 (sessions.event_type); and 행사, a club event (club_events: a dinner, a social; owner decision D2), titled by its own name, facts when | the place, a calendar tile, members only | every upcoming session that concerns the viewer: the ones they are in (참석 or 예비, as a member, a guest or in a 매치; useMyParticipatingSessions), their clubs' sessions, sessions of a club they are a standing guest of (club_guests) that are open to guests, and open 매치 at their saved courts (saved_venues) or in their play regions (profile_play_regions), within their level band when set | when it ends (a recorded session becomes a 결과) or is cancelled; one they are not in also when it starts or closes full |
| 결과 | 경기 결과 | one item per session from the viewer's 일정 when its record closes (every match settled, review 27); a casual session (기록 안 함) has none. Thumbnail: the top winner's face; title: the session; facts: n경기 | 최다승 name n승 | never (it scrolls away) |
| 게시글 | the post's category: 공지, 자유, 경기후기, 소개 (club_posts.category; 운영진 for officers only); 사진 (club_media, one item per upload) | the viewer's clubs' posts and album uploads. Title: two lines of text (사진: the album, then n장 | who added them, or the session it came from) | never |
- Your own sessions are in 일정 (owner, review 39): a session the viewer joins stays, and its deciding fact becomes their state ("내일 09:00 | 참석"). The next one is also the 홈 Block.
- The automatic session posts (
session_created,session_series_created,session_updated) never show: the 일정 item stands for the session. - Not in the Feed: anything addressed to the viewer (the bell, 할 일); member joins (멤버), dues (회비), chat (메시지), clubs recruiting (클럽 › 탐색).
- No data source today, so not drawn: tournaments outside the app (a district association's; nothing ingests them) and a session between two clubs (대항전 is a label one club sets;
inter_club_challengesis a stat contest over dates). - 행사 is not a game: it appears in 소식 and in the club's Feed, never in the 홈 Block or 경기 › 예정, which are games.
Where each kind also lives.
| Kind | 홈 | 클럽 홈 | 경기 | 기록 |
|---|---|---|---|---|
| 일정 | the Block (your next game); 소식 | 소식 (this club; a non-member sees the public ones) | 예정 (yours only, by day); 매치 찾기 (every open 매치) | none |
| 결과 | 소식 | 소식 (this club; members only) | none | your matches are its Feed |
| 게시글 | 소식 | 소식 (this club; a non-member sees the public 사진 only) | none | none |
Order, filter and muting (reviews 37–39).
- Order: 전체, 결과 and 게시글 run newest first by posted time, with no ranking; 일정 runs by when the session happens, soonest first. More than three items in a row from one source collapse into one line ("강남 라켓클럽 소식 4개 더").
- The filter is the Feed head's Choice (전체 ▾): on 홈, 전체 · 일정 · 결과 · 게시글; in a club, the same kinds and then the board's categories (공지 · 자유 · 경기후기 · 소개 · 사진; 운영진 for officers), a value Sheet (review 41). Filtered, the Choice shows the value in its selected look ("일정 ▾" on ink). In English the filter values are All, Sessions, Results and Posts (the hub board draws All and Sessions; Results and Posts are proposed).
- Muting a club, a host or a label is a long press on an item (a MenuSheet), never a button on it; in a club the same long press opens the reactions and the post's commands. The filter sheet lists what is muted, with the interests (play regions, saved courts, level band; set at onboarding, edited on the profile).
The who row (owner review 46, M12, M13, M18, M21, M22; proposed 2026-10-19, drawn on the rows library board, List A, List B and the detail board). The owner asked for club, session, venue and 대항전 rows that match the Feed item and say what they lack (gender, level, format; which courts are booked). The item gains an optional second facts row, who, under the facts row: what, then who, and the who row takes the footer's place (the owner, 2026-10-20: "All feed items should have at most 4 rows of facts. Streamline what need to be presented."). Every item is four rows and 128: the overline, the title, the facts row, then the footer (홈 and 클럽 홈) or the who row (the lists of one kind below), never both. A list item shows no posted time and no counts: an event's posted time says little, a session's seats are its deciding fact when two or fewer are left or it is full, and a club's size is its join gate (n자리 남음, 정원 마감). The thumbnail stays 96 (at 112 the text column would be 222 and format, level and courts, 235, would not fit; at 96 it is 238).
| Object | Overline (the label, then the source) | Title | Facts (what) | Who | Footer |
|---|---|---|---|---|---|
| Club (클럽 › 내 클럽, 탐색; 소속 클럽 on a profile) | region, then focus (친목형, 경쟁형, 균형형, 성장형) | the name; two lines may wrap | level, composition, format | rhythm, dues, the join gate: 승인제, 초대 전용, 4자리 남음, 정원 마감 (muted). 신청 중 and 게스트 are the viewer's relation and take the gate's place; a member's own club says its 다음 일정 and their state | none (the last activity and the member count leave); the unread Count tag stays after the name (내 클럽) |
| Session (경기 › 매치 찾기, 예정; a club's 모집 중, 진행 중, 지난 일정) | the kind (일정, 매치, 대회, 리그, 대항전), then the host | the venue, one line | the time, then the deciding fact: live, full, seats left at two or fewer, the viewer's state, an official's attention, the games | format, composition (only when it restricts), level, booked courts | none (the posted time, friends going and seats taken leave; seats stay as the deciding fact when two or fewer are left or it is full) |
| Venue (경기 › 테니스장, search, 테니스장 관리) | 테니스장, then the district | the name; two lines may wrap | the distance first while 가까운 순 is on, then courts, surface, indoor | open now, price, then the rating (★4.3); in 테니스장 관리 the club's own courts there come first | none: the distance and the rating moved up; a ⋯ control in 테니스장 관리 sits on the overline |
| 대항전 (클럽 › 관리 › 클럽 대항전) | 대항전, then the rule (총 승수, 승률, ELO 상승, 경기 수) | vs the opponent | the period, then the days left (within 7) | the standing: 2-1 앞서는 중, 승 3-2 (success), 받은 신청 (warn) | none (the posted time leaves); the thumbnail is both clubs' logos |
- Fit. A facts row is one line, about 238 at 390; the facts that fit are drawn whole, never cropped. In Pretendard 15/500 a break costs 17, "NTRP 3.0–4.0" 98, "3~4번 코트" 75, "복식" 28: format, level and courts fit (235), a fourth fact does not. The data layer lists a session's facts in priority order and the row draws as many as fit: composition (when it is the club's own) gives way first, then courts in discovery or level once joined (a joined session keeps its format and courts), and the format never; 혼합복식 (55) with a level and courts does not fit, so its courts wait for the detail. A fact equal to an active filter drops. The English wording obeys the same fit: a fact is written to fit its row, never cropped. The Results item's deciding fact (최다승 박지훈 4승) reads "Jihun Park, 4 wins" (124 px beside "8 matches" and the break: 213 of 238); "Most wins: Jihun Park, 4" runs to 255 and was cut in the browser. One English wording per Korean one, on 홈 and on 클럽 홈 alike.
- Booked courts.
sessions.used_courts(migration 00499, entered throughCourtUsagePanel) is written 3~4번 코트 (consecutive with ~, else 1, 3번 코트; more than three scattered, 코트 5면), in English "Courts 3-4" (an en dash between digits reads as a score to the verifier); a count only when the numbers are unknown (코트 1면), never invented. The session's fact block states them (정보 › 코트), 일정 수정 edits them, and 테니스장 관리 shows the club's own courts at each venue (court_venues.used_courts).SessionInfoModelgainswhere.usedCourts; no migration. - State (the owner, 2026-10-31, on 경기 and 일정 관리: "use the live badge that is used elsewhere"; "use shared components for recurring states"). A row's state is one Tag after the title, in the line slot (§1.13), drawn by the state's own component and never as coloured text: live is the Live tag (진행 중; the round count is the live page's, not the row's); the viewer's own state a Tag of its meaning (참석 and 호스트
rsvp.confirmed, 예비 n번rsvp.waitlisted, 입금 전sessionPayment.pending); seats left at two or fewerrecruitment.almostFull(1자리 남음); fullrecruitment.closed(마감); an official's attentiontask(입금 확인 3건). One per item, the first that applies in that order, and always the state of the item's own object, its title: a club row's is the viewer's relation to the club (승인 대기waiting), never the state of something the club holds (its next session's 참석 is a fact of the who row). Facts stay ink: a tally is a plain fact (게스트 2명, a finished 대항전's 승 3-2). This replaces review 46's Tones, which had turned the same states into coloured text, so the same state read differently from the Live tag on 홈 and 스코어카드. - Pieces.
unreadis a Count tag after the title through the line slot;controlis a ⋯ IconButton, the item's second stop, on the overline's line with its glyph's edge on the rail (a management list); a 대항전's thumbnail is both clubs' logos on a gray100 square (Thumbpair); a rating is a star glyph and a number in the footer. - Lists. Day headers and Section heads sit above the items as above rows; the first item trims its 16 against the head and the last against the next section. A skeleton is the thumbnail and four bars, 128.
- 홈's 할 일 (the owner's page review, 2026-10-27). One row per kind of work, in the Feed item's own grammar so the Module sits with the 소식 Feed under it: a glyph tile of the thumbnail's size, the club as the overline, the kind's name as the title, the specifics as the facts (the session and the sum, or just how many) and, for the one kind that has a deadline (참가비 입금), the deadline as plain text in the warning tone in the footer. There is no count bubble and no deadline Tag: the count is the Section head's (할 일 2), the sum of every kind. A kind with nothing waiting is absent; rows with a deadline come first, then the kind order (점수 입력, 참가비 입금, 회비 납부, then an official's 가입 신청, 입금 확인, 회비 미납, 신고, 게스트:
HOME_TASK_KIND_ORDER). 홈 shows the first three rows, and 전체 보기 opens 알림 at 처리할 일. Each row opens where its work is done (가입 신청, 참석자 관리, 회비 관리, 클럽 홈 at 회비, 일정 상세, 스코어카드). With nothing waiting, the Module is absent: no head, no quiet card (the owner, 2026-10-31: "the to-do list shouldn't exist if there is nothing to do"). - On a venue page. 장소 상세 draws the sessions at the venue (the open 매치 and the club sessions: three, then 전체 보기 with the count) and the clubs that call it their home court as the Session and Club items above; the club's court page and the directory page draw the one Module. A session's title is the venue and its thumbnail the venue's map, so on a venue page every session row repeats the page's own title and tile. The item is one grammar wherever it shows (the same session is the same row in 경기, in 클럽 홈 and here), so the repeat is kept; the alternative is the compact MediaRow the same-court list already uses. A Module with nothing to show is not drawn: a venue with no session keeps 매치 만들기 alone, and a venue marked closed draws no session and no 매치 만들기.
- Left as they are, for the owner. The same-court list of a one-court club (no thumbnail, the time leads) stays a MediaRow, since a Feed item would repeat one map tile on every row; pickers, the venue search takeover's Naver results and the add-a-venue takeover keep the MediaRow (their control is a bookmark or a check); an archived club is the item without a who row (128).
States. Rest · pressed (settle and dim, the whole item) · loading (a 96 skeleton square and four bars, 128 with or without a who row) · dark (maps dimmed, §4 Thumb).
Large text. From 1.5× the thumbnail is 56, and the title, the facts and the who row may wrap to two lines.
Accessibility. One button, named in the item's own order ("일정, 강남 라켓클럽, 대치유수지체육공원, 내일 오후 7시, 복식, 1시간 전, 8명 중 6명"); with a who row it reads on in the same order ("매치, 정우진, 대치유수지체육공원, 오후 6시부터 8시까지, 진행 중 3라운드 중 2라운드, 복식, NTRP 3.0부터 4.0, 2번부터 3번 코트"; no footer to read). The thumbnail and the glyphs are hidden.
Replaces in a Feed. PostRow, ResultRow and the session row as Feed items. A post's full body, reactions and comments live on its page. In a list of events (proposed, review 46) it also replaces ClubCard, SessionCardV2, DirectoryVenueCard, VenueRow and ChallengeBlock.
Feed end
Job. Closes a Feed once its last page has loaded: the doors on (§1.14, Zone states), and no line (owner review 48: what ended or what bounds a list is a line about the data, §1.16; a list's bound is its head's count). Every list ends the same way, whether its rows are Feed items, a kind's own row or Text rows, so the end is one piece and no page draws its own.
Anatomy.
- The door: 32 under the last row, a task Cell (§5 Task Cell) for each way on, grouped as one Cell group (the 12 of air a Cell carries above and below its text trimmed, as under a Section head). It names where to go on: 예정 › 매치 찾기, 매치 찾기 › 테니스장 찾기, 장소 정보 제안 › 소유권 신청 내역, 테니스장 › 없는 장소 추가 and 제안 내역. A door never repeats the page's FAB: a list creates with its FAB, so 매치 찾기's door is not 매치 만들기.
- The tail: the 40 under the last Cell (or under the last row when there is no door) is the page's tail.
States. A list with no place to go on ends in its tail alone (소유권 신청 내역; 순위표, whose rows open profiles). An empty list has its door, 32 under the Empty state. A list that has not loaded (loading, error, offline) has no Feed end; while a page loads, the Feed's own skeleton rows stand in its place. The end shows once, after the last page.
Large text. The Cell grows with the text; the 32 and the 40 stay.
Accessibility. Each door is a button, read after the last row: "매치 찾기".
Motion. None; a door has the Cell's press.
Replaces in code. FeedList and GroupedFeedList (packages/ui/src/feed-list/) draw no end-of-list affordance today: hasMore is accepted and not read (types.ts: "a visual addition would need owner sign-off", which this is). The Feed end is their footer once the last page has loaded, with the doors from the screen. The venue finder already mounts one such footer, "찾는 곳이 없나요?" with 없는 장소 추가 and 제안 내역 as two links (venue-finder-contribution-row.tsx); it becomes the 테니스장 Feed's two doors. Retired 2026-11-02 (K3b part 2B): FeedEnd's line is deleted (알림 was its one caller and now ends with no line); FeedEnd takes doors only, and no screen mounts one yet.
Task Cell
Job. A pending-work entry in the 관리 sheet and where a page names a job of its own (대진표's 검토 N경기). 홈's 할 일 is not drawn with it: each kind of work there is a row of the Feed's own item (§5 Feed item, 홈's 할 일), the sum in the head's count, and 알림's 처리할 일 filter holds the whole set that 홈 previews three of. A Task Cell is not a Module's door either: 프로필's 신뢰 has its door in the head (내 신뢰 기록 ›). It also draws the steps of a setup list (회비 시작하기): a step already done is a Cell with the success Tag 완료 (입금 계좌 등록), the steps left are Task Cells (회비 설정, 첫 일정 만들기, 멤버 초대), the first of them the next thing to do. On the 멤버 pane an official's one row 가입 신청 (with its count) is a Task Cell: it appears while an application waits, opens 가입 심사 and goes when done.
Anatomy. A Cell with an ink 15/22·500 label and a count Tag before the chevron (danger when the work needs action, neutral otherwise).
States. As Cell.
Accessibility. "참가비 관리, 확인할 항목 3개".
Motion. Press settle plus dim.
Replaces in code. AdminNavRow (packages/features/clubs/src/club-detail/admin-nav-row.tsx).
AnswerPair
Job. Answers a request in the row itself (수락 · 거절, 참석 · 불참). Request lists only, a pending 친구 요청 in 알림, and a 사유 인정 요청 under its record on 멤버 기록 (인정 · 거절).
Anatomy. Two sm (36) tonal Buttons in two equal columns of (text column − 8) / 2 (145 at 390), gap 8, on the text edge under the last line: 12 above, 36, 12 below (the row's own bottom padding). Never trailing: a trailing pair squeezes the name, and equal widths keep 수락 and 거절, Accept and Decline, the same size. From 1.5× text, or when a label + 32 exceeds its half, the two stack full width, 8 apart, the yes answer on top, still 12 above and below. Both are neutral so the screen's one coloured action is unaffected. The row grows by 48 (12 + 36): 116 with an L2, 94 without; each button has a 44 target by hit slop (4 above and below). A single trailing control (친구 추가) is not an AnswerPair and stays trailing.
States. Pressed .97/.92 · disabled .5 while the request is in flight.
Content. A bare row tap never records an answer (for 참석 확인, the explicit pair is the only way).
In 알림 (owner review 46, D3). A pending 친구 요청 is answered inside the inbox, as Instagram answers follow requests in its Activity. The row keeps its place: the pair goes, the dot clears, and line 1 says what happened ("한소희님과 친구가 됐어요"; after 거절, "한소희님의 친구 요청을 거절했어요"). Nothing with consequences is answered there: money (입금 확인), 가입 심사, scores and a 사유 인정 요청 open their own pages, where their facts are. The row has no ⋯ while the pair is on it.
Accessibility. Each button is its own stop after the row's, named from its visible text first: "수락, 정우진".
Motion. Button press physics.
Replaces in code. Inline accept and decline buttons in request rows.
Why: review 3, lane B §8 (Facebook: 12 above, 37 tall; LinkedIn: 16 above, 32 tall; a Material card: 24 above).
6. Labels and avatars
Tag
Job. A passive label of state, category or exception on a row, hero or Cell. It is not a control (a selectable chip is a Choice) and not a fact (facts are Meta text).
Anatomy. sm (default): 24 tall, r6, 13/18·600, pad 0 8. md: 28 tall, r8, 15/22·600, pad 0 10 (hero and identity block only). Passive: no press, no aria-pressed. At most one per row and two per object. The label is one or two words on one line and the Tag never shrinks (flexShrink: 0; the row re-flows around it).
| Tone | Weak (fill / text) | Solid (fill / text) | Used for |
|---|---|---|---|
| neutral | gray100 / secondary | #4F5851 / white | role, pending, metadata |
| brand | #EFF9B7 / ink | none | 모집 중, an invitation |
| success | #CBEBE5 / #014B40 | #00695C / white | 완납, 참석 확정, a win |
| warning | #F5B301 / ink | same | 마감 임박, 미납 |
| danger | #A3261F / white | same | live (with the pulse dot), cancelled, overdue |
| info | #E8DCFB / #4B0082 | #4B0082 / white | informational |
Trust (§4 Trust). 신뢰 점수 is text (72점), never a Tag. A 사유 인정 요청: 확인 중 and 거절됨, neutral. 첫 참석 (a host's or official's participant list): neutral.
Session fees (§5 Text row, 참가비 내역). A fee's Tag is a state that is not settled and waits on someone: 입금 필요 (warning: it is the member's to do; sessionPaymentGateState "reserved"), 입금 확인 중 (neutral: it waits on the host; "submitted") and 환불 대기 (info: a refund owed to the member after the session was cancelled, which they confirm with 환불 받았어요 once it has arrived; refundState pending). Paid, waived and an acknowledged refund are settled and carry no Tag. English: Payment due, To confirm, Refund due. The Tag follows line 1 of the row (§5 Text row), never its end.
Match states (D4, D5, review 5). One map serves the Match row, the ResultRow and the Match state block (FVK_MATCH_TAGS): 검토 중 (warning weak; an open review), 기록 제외 (neutral), and the held and stop-outcome ones: 대기 (neutral weak; a match held behind a player's earlier match, derived, never stored), 기권 Retired, 부전승 Walkover, 실격 Default, 미완료 Unfinished, 취소됨 Cancelled (all neutral weak: they state what happened, not a call to act) and 일시 중단 Suspended (warning: it needs a resume plan or an end). The outcome Tag says what happened; the viewer-relative words (기권승, 부전패) appear only in a person's history (ResultRow).
Weak in repeated rows; solid over photos and for strong attention. Dark: the ledger's dark tone values (success #75D1C6, warning text #E1B75C and fill #E2B13C, danger #FA9B94, info #C9B2F2); weak tints are the tone at 24% over the dark canvas. The tone is chosen by the domain's XxxStatusBadge wrapper, never at the call site.
Placement. A Tag, a count or a level numeral never owns a row: it shares a line with text. Its homes, in order: (1) the trailing slot of line 1 (rows, Cells and Hero facts); (2) inline after line 1's first fact: a Block's eyebrow row takes one Tag at its trailing end (the eyebrow yields first, the Tag never shrinks), a header line ends in the row's one trailing element, and a label's Tag follows its first line through the line slot; (3) a fact in the header subtitle, for a state of the whole page (a form's provenance, "강남 라켓클럽 | 정기 일정에서"). Never a bottom row; never an eyebrow row of its own above the title. Exempt: centred stacks (a Podium column, a Roster cell, an avatar-over-label cell), where every element is a line by grammar. At large text the Tag stays beside its text while the text's longest word and the Tag fit (§2.4); otherwise it drops under the text block, never onto a line of its own. No line under a Tag adds to it (1.16).
States. Static. 200%: the Tag keeps its floor and grows with its label (label class, cap 2.0); one line, never truncated.
Content. Korean: one or two words. English: ≤ 12 characters, 1–2 words, ≤ 80 px at 13/600, never truncated; the copy is shortened instead: "Recruitment closed" → Closed · "Awaiting confirmation" → To confirm · "Refund pending" → Refund due · "Partial payment" → Part paid · "Waitlist open" → Waitlist · "Awaiting reply" → Pending. Already short enough: Waitlisted, Payment due, Closing soon.
Accessibility. In a row a Tag has no stop of its own; it is part of the row's name. Standalone (a hero or Cell value) it is text read with its fact.
Motion. Label and tone changes are instant.
Replaces in code. Badge (packages/ui/src/badge.tsx, badge-visual.ts), MarkerPill in rows (packages/ui/src/marker-pill.tsx), ReliabilityPill (packages/app/src/presentation/components/reliability-pill.tsx), TierRangeBadge (packages/app/src/presentation/components/status/tier-range-badge.tsx), the club badges ClubJoinabilityBadge, ClubRecruitBadge, ChallengeStatusBadge and ClubAlarmBadge (packages/app/src/presentation/components/status/badges/club.tsx, club-alarm.tsx), SessionLeadBadge (packages/app/src/presentation/components/sessions/session-strip-ui.tsx), ContextChip (packages/features/sessions/src/add-round/feasibility-tiles.tsx), and the hand-drawn pills. The XxxStatusBadge wrappers stay as the canonical domain maps.
Level tag
Job. A player's level at a glance: the NTRP band numeral in a ball circle.
Anatomy. A 24 circle, ball #D6F04A, numeral 13/18·700 ink, a 1px #D4D4D4 edge on white (none on ink). Shown on each Roster cell, always on the Podium, in the identity block after the name, and on ranking rows only where a level run starts (BADGE-EXCEPTION). The circle grows with its numeral: diameter = ceil(24 × scale / 4) × 4 with the scale capped at 1.5, the numeral class (24 / 32 / 36 / 36 at 100 / 130 / 150 / 200%).
States. Static.
Accessibility. Standalone: "NTRP 3.5". Inside a row it is part of the name.
Motion. None.
Replaces in code. EloTierBadge and TierBadgeChrome (packages/app/src/presentation/components/elo-tier-badge.tsx, tier-badge-utils.tsx) as level chrome.
Count tag
Job. A count on a row, a tab or a chart readout.
Anatomy. 20 tall, min-width 20, r full, numeral 12/16·700. It grows with its numeral (chrome cap 1.3): the box is ceil(20 × min(scale, 1.3) / 4) × 4, 20 at 100% and 28 at the cap. Tones: danger solid (unread), neutral (a plain count), ink (the chart readout). Counts above 99 read "99+". Beside a label it attaches through the line slot (§1.13). On the tab bar and as a header icon badge it is 16 tall with a 1.5 canvas ring and does not grow.
States. Static.
Content. Digits only; the label class does not apply (chrome cap 1.3).
Accessibility. Spoken with its owner: "읽지 않은 3개"; 99+ is "99개 이상".
Motion. None; counts change instantly.
Replaces in code. CornerCountBadge (packages/ui/src/corner-count-badge.tsx).
Live tag
Job. Says a session or match is being played now.
Anatomy. A danger solid Tag (sm) with a 6 white pulse dot before the live label (white on #A3261F is 7.36:1). In the Live row (specified with the chrome) and in the trailing slot of rows.
States. Pulsing for 5 s per mount (1600 ms loop), then it rests on the static dot (WCAG 2.2.2). The word carries "live" without the pulse.
Accessibility. Part of the row's name ("진행 중"); the dot is hidden.
Motion. Pulse as above. Reduced motion: the static dot.
Replaces in code. SessionLiveBand on rows (packages/app/src/presentation/components/sessions/session-strip-ui.tsx), PulseDot (packages/ui/src/pulse-dot.tsx).
Unread marker
Job. Says "new" on a row of a list that can show it. Counts are Count tags, not dots.
Anatomy. An 8 circle, danger ($error), in the leading marker slot (8 + 8), centred on line 1 through the line slot (see Leading marker slot).
States. Shown · cleared (instant; no announcement).
Accessibility. Hidden; the row's name begins "읽지 않음, …".
Motion. None.
Replaces in code. The hung 7 px unread dot (DOT_SIZE.XS in packages/ui/src/box-sizes.ts, packages/ui/src/dot.tsx).
Avatar
Job. A person, or a group of people, as a circle; with a glyph, a source that is not a person (Glyph).
Anatomy. Circle 40 (rows, the default) · 48 (Roster) · 56 (identity block). 32 only inside Tag-sized contexts (DM thread avatars). Initials 15/22·600 (40), 17/24·600 (48), 20/28·700 (56), ink on gray100; a photo when present. Initials do not scale with text (the circle is fixed and the adjacent name scales).
Privacy rule. A non-member sees the monogram, never the photo. The photo shows only to the person themself, to someone who shares an active club or is an accepted friend, and to any signed-in viewer when the owner opted in to public (an anonymous viewer never gets one); the server decides (public.can_view_face, migration 00691) and sends no URL otherwise. A hidden photo is therefore the same circle as no photo: the monogram, never a lock, a blur or a placeholder glyph. Group and Avatar-row variants apply it per member.
Variants.
- Avatar row: 40 × n, gap 8, from the rail; "+N" as a 40 gray100 circle, 13/18·600; the row is pressable (.98/.80).
- Group: two member avatars at 0.6× inside the avatar box, offset diagonally: the back one top-left, the front one bottom-right inside a 2px canvas ring. Inner avatars are 20 in a 32 box and 24 in a 40 box. Photos when present, initials otherwise, never a generic glyph.
- Anonymous: a gray100 circle with a user glyph in secondary (20 in 40, 16 in 32). Used where privacy hides names: a non-member's preview shows counts, never names.
- Glyph: where no person did it (a system notice, a trust change, a rating change), a gray100 circle with a named glyph in secondary, 20 in 40 (the Anonymous disc with its own glyph: shield, chart, pin). It stands for a source and never for a person: a person whose photo is hidden stays the monogram.
- Presence: a 10 dot on the avatar's bottom-right corner with a 2px canvas ring (a person's state, not a row's, so it is not in the marker slot).
States. Loading: a gray100 circle. Image error: initials.
Accessibility. In a row the avatar is decorative (the name beside it says it). Group: spoken as the member names. Anonymous: "참석자". Glyph: hidden (the row's sentence names the source).
Motion. Image load: a 150 ms fade-in; reduced motion: instant.
Replaces in code. AvatarBubble (packages/ui/src/avatar-bubble.tsx, avatar-bubble-visual.ts). AvatarStack (packages/ui/src/avatar-stack.tsx, overlap −6) is not replaced here: it stays for the Roster's sites until the Roster is built (§ 5 Roster lists it as replaced there), and a doubles line takes Avatar fit="overlap" (at most two). Built 2026-11-02 (K5a, additive): Avatar is packages/ui/src/avatar.tsx (numbers in avatar-visual.ts): the kit's roles and one gray ground, the footprint group, fit="overlap", presence, onInk, the silhouette and the glyph; a photo is the loader over the base face. AvatarBubble retires by codemod in K5b (tone dropped, the size ladder mapped, outlined becomes the ring). The initials roles and the single ground are a visible change on every face: a Hangul name shows its first character only, the sizes read foot, body, headline and title as the table says, and the three tones go. Retired 2026-11-02 (K5b): AvatarBubble (avatar-bubble.tsx, avatar-bubble-visual.ts) is deleted. AvatarStack is rebuilt on Avatar (the footprint fit is the Avatar group, the overlap row keeps its +N disc, sizes snap to the ladder so XS 28 reads 32, and tone and overflowTone are gone).
Medal set
The one definition is Foundations §1.8 (fills, on-colours, earned and locked tiles). Labels never carry the tier by colour alone: the tier is in the name, and the tile tells it by its frame and star count (§1.8).
7. Controls and fields
Button
Job. The action. One ball primary per screen region: the screen's one action. A tonal Button sits beside it only in a pair (Dialog, wizard confirm step, FilterSheet footer, the Host review sheet's 그대로 유지 · 이 점수로 바꾸기); tonal is also a band's or a form's second way (이메일로 로그인, 다른 방법으로 인증, 비밀번호 재설정) and more of a list (N개 더 보기, 답글 N개 더 보기; owner review 48); ghost is the quiet command inside content: a Notice's action, the message bubble's 다시 보내기 and 삭제 (the law of the command, §1 IA8: a command is a Button, never a text action).
Anatomy. One size scale for every tone: sm 36 · r10 · 15/22·600 · pad 0 16 | md 44 · r12 · 15/22·600 · pad 0 20 | lg 52 · r12 · 17/24·600 · pad 0 20. Tones: primary ball fill, ink label · tonal gray100 fill, ink label · ghost no fill, ink label, pad 0 12 at every size so the text edge lands on the rail · danger $error fill, white label. Optional leading glyph 20 + 8; optional trailing glyph 16, 8 after the label (trail: 더 보기's chevron, with aria-expanded). lg is for the CTA band and full-width submits; md for inline actions (empty states, notices, sheet content); sm inside rows and notices. Full width on the rail, or content width. Pair: the tonal Button takes one third of the line and the primary or danger two thirds (set explicitly: (line − 12) / 3 and 2 × (line − 12) / 3), gap 12, so the committing button is the larger and sits on the thumb's side. This is a ratified departure from HIG, which asks for equal-size buttons and marks the preferred one by style (112.7 : 225.3 in a Sheet footer, 99.3 : 198.7 in a Dialog). The one-third button keeps 12 of side padding, so a four-syllable label ("계속 작성") stays on one line.
Variants. Tone × size: all twelve take the same states. Icon-leading. Pair.
States.
- Rest · pressed (scale .97, dim .92) · hover .85 with a pointer cursor (web) · focus (2px ink ring, offset 2; the ball button takes the same ink ring).
- Soft-disabled (primary only): a pale ball fill (brand-weak
#EFF9B7, dark#3A4220, as §2.3),$textSecondary600 label (6.64:1 light, 4.91:1 dark),aria-disabled, focusable. A press answers: a warning haptic, the missing control takes focus and shows its error line. Used for a step with exactly one pending decision whose missing part is on screen (one field, one option, one required choice, a consent). The fill becomes ball the moment the requirement is met. - Validate on press: with two or more inputs pending, or a missing part off screen (long forms, the review page), the button stays enabled; a press marks every invalid control, scrolls to the first and focuses it. A step or form that reveals its sections (WizardShell, Reveal) never has two inputs pending: the primary is soft-disabled with the helper naming the next section, and the press marks that one; its error summary is for a form that draws three or more fields at once (비밀번호 변경).
- Disabled (hard): opacity .5, not-allowed, inert,
aria-disabled. Only while the action is in flight, or when nothing on this screen can fix it (a role wall), with no line under it (1.16). - Loading: 20 spinner (ink; white on danger, because ink on
#A3261Fis 2.6:1) + 8 + the progressive label ("저장 중…"), width held at the wider of the two labels,aria-busy, presses ignored. Used for every action that can outlast 1 s. - 200% text: labels wrap and never clip; min-heights grow to lg 76 · md 66 · sm 58; a Dialog pair stacks with the commit on top.
Content. A one- or two-word verb that names the result (참석하기, 입금하기, 저장). Never 확인 for a commit, never a bare 취소, never a consent folded into the label, never a repeat of the helper line. English: at most two words, sentence case.
Accessibility. button; the one spoken label is the visible label (Label in Name), so while loading it follows the progressive label ("저장 중…") and the wait is spoken; state also lives in aria-busy and aria-disabled; the soft-disabled hint is the missing requirement. Targets 52 / 44 / 36 with slop to 44.
Motion. Press settle and dim on the UI thread (80 ms in, 150 ms out); soft-disabled to primary is a 100 ms fill change; reduced motion keeps the dim only. Haptics belong to the mutation outcome; the one exception is the soft-disabled press (warning).
Replaces in code. packages/ui/src/button.tsx and button-visual.ts (variants primary, secondary, outline, ghost, danger, destructive and sizes xs to lg become four tones × three sizes: secondary and outline become tonal, destructive becomes danger), action-button.tsx (ActionButton), action-pill.tsx (deleted 2026-11-02, K3b part 2B), hand-drawn pressable pills, variantFor in confirm-sheet.tsx.
Why: a .5 ball primary is 1.14:1 against white and a press does nothing (owner review 1, point 16; lane B A10).
IconButton
Job. An action whose glyph is its whole label: header, row and sheet controls. Never the only route to something an official must find.
Anatomy. 48 × 48 target, 24 glyph centred (20 inside rows, sheets, composers, comments), ghost (no fill). Tones: ink · secondary · danger · inverse (white, on a Block) · onInverse (the inverse surface's text colour: the Toast ×). Two IconButtons side by side touch edge to edge (96 for the pair); in a header the last glyph's right edge is on the rail and the target bleeds 12 into the margin.
Variants. Tone. Danger = remove (a saved object asks a Dialog naming it; a draft item removes at once and offers 되돌리기 in a Toast). Toggle (outline → filled glyph, never colour alone). On a photo: a 40 circle in the on-photo fill (ink α0.6) with a white 24 glyph inside the 48 target; the MediaBand entry owns placement. Square: a 40 gray100 square r12 with a 20 glyph in a 48 target (the wizard's back); it presses like a Button (.97 / .92). Bleed (start, end, both): the visible edge (the glyph, or the 40 circle or square) lands on the rail while the 48 target reaches into the margin by (48 − visible) / 2: 12 for a 24 glyph, 14 for a 20 glyph, 4 for a 40 circle or square; both hangs it over both sides (a button between two neighbours, such as the composer's attach). No row computes the offset by hand.
States. Rest · pressed (.92 / .78) · hover .85 · focus ring following the circle (ball on an ink Block) · disabled .5 · selected (filled glyph, aria-pressed).
Content. aria-label required: a verb or noun of at most three syllables ("공유", "검색", "알림 3개"); the web tooltip repeats it.
Accessibility. button; the spoken label is the aria-label, which carries a badge ("알림 3개, 관리 필요 2건 포함"); toggles use aria-pressed. The target is the visual: nothing needs extra slop.
Motion. Settle and dim; reduced motion keeps the dim.
Replaces in code. header-icon-button.tsx (HeaderIconButton), action-icon-button.tsx (ActionIconButton), remove-icon-button.tsx (RemoveIconButton, its built-in confirm becomes the Dialog rule), packages/app/src/presentation/components/sessions/save-button.tsx (SaveButton).
Why: Android asks 48 dp even where it extends past the visual (owner review 1, point 22).
Choice
Job. One of a small set, or a control that opens a Sheet of values. Never a status (Tag) and never navigation (Tabs).
Anatomy. 36 tall (44 inside forms) · r full · 15/22·500 · pad 0 16 (0 20 at 44) · resting 1px #D4D4D4 on white · selected = ink fill, white 700 label, leading check 16. Hit slop to 44. Sets wrap left-aligned with gap 8 and never scroll sideways; a wrap never leaves one Choice alone on its last row (see Choice set).
Variants.
- Set: single choice is a radio group, several is a group of toggle buttons. A one-line set of two to four Choices may fill the line (each flex 1): this is the segmented look, with the selected state at 19:1.
- Choice ▾: a chevron 16 after the label; it shows the current value or the active count ("10월 ▾", "필터 2 ▾") and opens a bottom Sheet of radio Cells. Never a Menu. With an active value it takes the selected look (ink fill) without the check glyph, the chevron kept.
- Removable: a × 16 after the label in its own 44 target; it removes only itself.
- Range: opens a Sheet; the label shows the range ("NTRP 3.0–4.0 ▾").
States. Rest · pressed (.96 / .85) · hover .85 · focus ring · selected · disabled .5 · long text (the label wraps to two lines and the pill grows) · 200% text (label cap 2.0, the set wraps).
Content. One or two words, at most 14 characters in English. A set holds at most five options; a longer list is a Picker Cell.
Accessibility. Set: radiogroup + radio with aria-checked and arrow keys on web (several: buttons with aria-pressed); the group has a label. Choice ▾: button with aria-expanded. The spoken name starts with the visible text; a removable's × is named "{label} 삭제".
Motion. Fill changes over 100 ms; the check appears with the fill; reduced motion: instant.
Replaces in code. selection-chip.tsx (SelectionChip, 56 files), dropdown-chip.tsx, range-chip.tsx, removable-chip.tsx, chip-picker-panel.tsx (the Sheet shell), the PillNav pill variant and the segmented control inside forms.
Why: the segmented form control is retired in favour of the Choice set. Two exclusive-choice controls would duplicate one job; its selected state (a white pill on gray100) reaches only 1.1:1 where WCAG 1.4.11 asks 3:1; and at 1.5× text it already turns into radio Cells. The fill variant keeps the equal-column look with the selected state at 19:1 (lane B A7).
Door pill
Job. A door drawn as a control (owner review 48: an action cue is a control, never bare text; the owner, 2026-10-28, "elevating action cues so that they are never just texts"). It names the place it opens: a section head's door (전체 보기 ›, 점수 안내 ›, 기간 기록 ›), a Block's door (내 일정 ›, 점수 입력 ›), a Cell's trailing door (a consent's 보기 ›) and a viewer's (앨범 전체 보기 ›). A head's one control is then always a pill: ▾ chooses (the Choice), › goes (the Door pill).
Anatomy. The Choice's geometry: md 36 tall, a 1px edge border (#D4D4D4, dark α0.14), r999, 16 of side padding, the label 15/22·500 ink, then a 16 chevron 4 after it. sm (a Block's zone A, whose row is 24): 24 tall, 10 of padding, the label 13/18·600. Surfaces: canvas; block, the ink Block (the label white α0.8, the border white α0.4); photo, a photo or a map (the photo-circle fill α0.6, a white label, no border). The chevron is right (it goes to a page); down for a Block's door that opens a choice of where (a period); none when the label already says it. The target is 44 tall by hit slop, at least 44 wide.
States. Rest · pressed (the pill physics) · hover · focus (the ink ring) · disabled (.5).
Accessibility. A link whose name is the visible label ("전체 보기"); a down chevron makes it a button with aria-expanded.
Motion. The pill press; none of its own.
Prevention. The verifier's text-cue check fails a text link that is not a legal footer link, and its chevron check counts the Door pill as a host for the right chevron.
Replaces in code. The text links of SectionHeader's action and the Block's door (packages/ui/src/section-header.tsx); the app's ActionPill when it opens a page. Retired 2026-11-02 (K3b part 2B): section-header.tsx and action-pill.tsx are deleted (ActionPill had no JSX caller); a page-opening door is a head's action or a DoorPill.
Switch
Job. An immediate on or off. The toggle is the feedback; use it for a setting that applies at once, not for one that needs a Save. A Switch never sits in a form that ends in Save (an Edit Sheet, a wizard step, a Save-gated page): there the answer is a Choice set, or check rows when the options are sentences, and what depends on the answer nests under its option. Example: 참가비 is a 무료 | 유료 Choice set with the amount under 유료, not a 별도 참가비 Switch beside a 저장. The one exception is the wizard's quick-start toggle (wizards.md): it is a mode that seeds answers and shortens the path, not an answer, and turning it off clears the flag only. On a settings page a Switch that turns a group off hides what depends on it, as in a wizard: 클럽 설정's 게스트 허용 off hides 게스트 참가비 and 방문 횟수 제한 (components canon, the hide rule).
Anatomy. Track 52 × 32 · r full · off $borderControl (3.26:1) · on ink. Thumb 24, white, 1px #D4D4D4 edge, 4 in from the track (travel 20). Inside a toggle Cell the whole 48 row is the target.
Variants. One.
States. Off · on · pressed (none: the thumb's travel is the feedback) · hover .85 · focus ring around the track · disabled .5 and inert.
Content. The Cell label names the setting ("경기 알림"); it never names the state and never changes with it.
Accessibility. role="switch" with aria-checked; Space toggles on web; the Cell is the one stop.
Motion. Thumb 150 ms fade, track colour 100 ms; reduced motion: the thumb snaps and the track still fades over 100 ms. Haptics only through the mutation outcome.
Replaces in code. toggle-pill.tsx (TogglePill, display-only 44 × 24; retired 2026-11-01, K2b part 2 batch 3: deleted, its save-form sites are CheckRows and its immediate sites toggle Cells) and every hand-rolled switch.
Why: a #D4D4D4 off track is 1.48:1 against white and WCAG 1.4.11 asks 3:1; Material 3 sets 52 × 32.
Slider
Job. A value or a band dialled by drag with the live value always on screen. A drag shortcut, never the only way.
Anatomy. Track 4 #E5E5E5 · fill ink · thumb 24 ink (19:1 on the canvas, 15:1 on the track) · live value tag (ink fill, white text, 24 tall) riding above the thumb. A band has three grab surfaces: left thumb, right thumb and the band body; each is a 44 target centred on the visual. The track starts 20 from the edge. The domain is fitted, snapped and labelled live so a drag never shares an axis with a scroll.
Variants. Single (a bounded value) · Band (a range; the time band) · Hue (the same core on a hue bar for club colours).
States. Rest · dragging (the tag follows the thumb) · focus ring around the thumb, arrow keys step on web · disabled .5.
Content. The tag reads the live value in its unit ("19:00~21:00", "70%"; English "19:00–21:00").
Accessibility. Adjustable (slider), increment and decrement by one snap, value in words through aria-valuetext ("오후 7시"). Alternatives to dragging (WCAG 2.5.7): a tap on the track sets the nearest thumb to the tapped snap; a 44 Stepper beside the readout steps one snap (every Slider has it: a row above the track with the readout on the rail, which takes the place of the tag that rides above the thumb so the value shows once, and the Stepper with its right edge on the rail); on a band one step moves the whole band by one snap with its length kept (the time band 30 minutes, the level band one tier — a tier is 0.5 or 1.0 NTRP wide, so the readout, not the step, says the numbers), the Stepper's number is the band's start and its ends dim at the domain's edge; a tap on the track moves the nearest end, so the two together reach every band without a drag. The Stepper's ends are named for their effect ("시간 30분 앞당기기", "시간 30분 늦추기", "수준 한 단계 낮추기", "수준 한 단계 높이기"; the thumbs are spoken by the readout, "수준 하한, NTRP 3.0"). Every screen that hosts a horizontal-pan control sets fullScreenGestureEnabled: false (iOS 26); if the Android edge-back wins the device check, the track insets to 28.
Motion. The thumb follows the finger; snap settle 150 ms; reduced motion: no settle (gesture tracking is unchanged).
Built 2026-11-02 (W4 batch 2, the band mode): BandSlider is packages/ui/src/band-slider.tsx (numbers in band-slider-visual.ts, held to the kit's FvkSlider in band mode): the ink track and band, two 24 thumbs in 44 hit boxes and the body hit box, the readout row with the compact Stepper on the rail; LevelBand (level-band.tsx) wraps it over the five tiers the caller passes (the ui never imports the domain): one step moves the whole band one tier, the thumbs are spoken by the readout, the Stepper's verbs are 한 단계 낮추기 / 높이기. First consumer: 클럽 설정's 수준 범위 Sheet (settings/tier-range-editor-modal.tsx). The single-value, time and hue modes are not built yet; TierRangeSelector, RangeBandSlider, TimeBandSlider, ValueSlider and HueSlider stay for their callers (L4-01, L4-02, the session editors) until those pages land.
Built 2026-11-08 (W4 batch 3, the hue and point modes): HueBand (packages/ui/src/hue-band.tsx, numbers in hue-band-visual.ts, held to the kit's hue slider): track 12, thumb 24 on the canvas with a 4 ink ring, 44 hit, twelve steps of 30°, no readout and no Stepper of its own (the host Cell's compact Stepper steps it); first consumer 클럽 만들기's 대표 색상. hue-slider.tsx and its tone pad are gone. BandSlider gained the point mode (one thumb, the ink fill from the track start, the same Stepper row), which DateTimeSheet uses for a start.
Replaces in code. value-slider.tsx, time-band-slider.tsx, range-band-slider.tsx (through band-slider-core.tsx and single-band-slider-core.tsx), hue-slider.tsx.
Why: a white thumb with a 1px edge on the track is 1.26:1 and vanishes at low values (lane B A9).
Stepper
Job. A bounded integer dialled up or down: counts and limits. Not for scores (ScoreEntry) and not for open ranges (Slider).
Anatomy. A 136 × 36 pill (gray100, r full): − end 44, value cell 48 (15/22·600 tabular, fixed so digits do not jitter), + end 44; glyphs 20; each end is a 44 target (slop 4 above and below). In a Cell the pill's right edge is the rail.
Variants. Compact (default). Fill: a lone Stepper in a Field stretches to the line as a 48 bordered row (r12, 1px $borderControl), ends 48 wide, value centred 16/24·600. Typed (owner review 52): the value cell is a numeric field on the surface colour (r8, 28 tall), tapped to type on the number pad (inputMode numeric, the caret and the focus ring on the cell), and the ends still step it; the one control for every count with a fixed unit (§ 1.22 rule 1), so a count is never presets and never a lone Field.
States. Rest · an end pressed (.92 / .78; the pill never moves) · focus ring on the focused end · at a bound the end that cannot go further dims to .5 and goes inert.
Content. The value is a number; the unit lives in the Cell label ("참가 인원").
Accessibility. Group with a spin value (min, max, now, text); each end is a button named for its effect and the field ("참가 인원 줄이기", "참가 인원 늘리기"); Up and Down arrows step on web.
Motion. Glyph settle and dim only.
Built 2026-11-08 (W4 batch 3): the count row is CountCell (packages/ui/src/count-cell.tsx, held to the forms board's MrCount): a choice Cell with the typed Stepper as its trailing, the Stepper named by the label without its unit, a dash while the value is below min. The Stepper itself can be unset (nullable: a dash, + from the dash sets min, − at min returns to the dash), which 행사 만들기's 최대 인원 uses for 제한 없음.
Replaces in code. stepper.tsx (32 px circle buttons, fullWidth bordered row).
Search field
Job. A find field. On a list screen it lives behind a header search glyph and takes over the title; in a takeover or a sheet it fills the bar.
Anatomy. The field is 40 tall, r12, gray100, drawn inside a 44 target (the hit area stays 44; the visual follows the grammar's radii). Search glyph 20 at 12 from the edge + 8 · text 16/24 ink · placeholder 16/24 $textTertiary (as every field) · clear × 20 in a 48 target, shown once there is text · when focused the field shrinks and 취소 appears at the right as an sm tonal Button.
Variants. Header takeover (the glyph in the header swaps the title for the field in place, the actions yield to 취소, the keyboard opens with it, back closes it) · in-page field · the bar of a full-screen search takeover.
States. Rest · focus (the field stays gray100, the caret is ink, no border change) · filled (clear shown) · disabled .5.
Content. Placeholder names what is searched ("코트, 클럽, 사람 검색"). The keyboard's return key reads 검색.
Accessibility. The web search landmark; label "검색"; clear is "검색어 지우기"; focus lands in the field on open and returns to the header glyph on close.
Motion. The title-to-field swap and the field's shrink to make room for 취소 are instant; no animation, so nothing changes under reduced motion.
Replaces in code. packages/ui/src/search-bar.tsx (SearchBar: $card fill, $borderSubtle border, 44-tall pill).
Why: a borderless gray100 field stays identified by its glyph and placeholder; add a 1px $borderControl only if low-vision testing shows it is missed (lane B A25).
Checkbox and radio rows
Job. Choosing from a list inside a Sheet or a settings page: a trailing check for one, a 24 square for several; the leading box in Select mode and in the Consent group.
Anatomy. A Cell 48 with the cell hairline #E5E5E5 inset to the rail between rows and none after the last. Single: the chosen row's label turns 600 and a 20 ink check sits at the end (no fill, no tint). Single, leading ring: wherever a footer CTA follows the choice (Dispute, Decide) every row leads with a 24 ring (2px $textSecondary edge, a 12 ink dot when chosen) at the rail, the content shifted 36, so the unchosen state is always drawn; the trailing check stays for pickers that select and close (S-3). A row may carry an expand slot (the score table under 직접 입력): it owns its padding (8 above, 16 below) and the hairline follows it. Several: a 24 square r6; unchecked = a 2px $textSecondary edge (7.4:1; the 1px hairline is 1.48:1); checked = ink fill with a white 16 check; mixed = ink fill with a white 16 minus. The square trails in a picker; it leads at the rail, with the content shifted 36, in Select mode and in the Consent group. A checked row carries what depends on it in its expand slot, full width under the row (타이브레이크: 시작 시점, 점수, 점수 차; 시간 제한: 제한 시간 and 시간 초과 시), which shows only while the row is checked. The options of 시간 초과 시 are phrases ("서든데스 1게임", "현재 점수로 종료"), so they are radio rows with a leading ring, never Choices. A pair of options that has a named alternative (듀스, 노애드) is a Choice set, not a lone check row. Options of three words or more, or that need their meaning said, are radio rows with the meaning on line 2, never Choices: 가입 방식 is 바로 가입 ("누구나 바로 멤버가 돼요"), 승인 후 가입 ("총무가 확인하면 멤버가 돼요") and 초대 전용 ("초대 코드가 있어야 멤버가 돼요"): in 클럽 만들기 a leading ring on each row (a footer CTA follows), in 클럽 설정 the value Sheet's two-line options (the lead's call, 2026-10-20).
Variants. Single (trailing check) · single with a leading ring · several · leading square (Select mode, Consent).
States. Rest · pressed (.98 / .80 on the row) · hover .85 · focus ring inside the row · checked · mixed · disabled .5.
Content. The label is the option; no helper.
Accessibility. radio or checkbox with aria-checked (mixed for the master); the whole row is the target; arrow keys move a radio group on web.
Motion. Fill 100 ms; the glyph appears with it; reduced motion: instant.
Replaces in code. selection-card.tsx lists, selection-check.tsx (the consent step and the friend picker moved off it on 2026-11-01; it stays for list-row-person.tsx's Select mode, the notification list, until the person row moves in K4b / K5).
Why: Material 3, Seed and HIG all draw the empty radio; a choice that waits for a CTA shows nothing otherwise (review 3, lane C §5).
Date and time picker
Job. One date or one time chosen on the platform wheel inside the standard bottom Sheet. A range uses the Time range wheel.
Anatomy. Sheet; the title row names the field (17/24·600) with the × at the right; the picker; the footer is one primary lg 완료. On iOS the Sheet holds the UIDatePicker wheel. On Android the picker hands over to the system date or time dialog (the Material calendar and clock). For a date more than a week out the Sheet draws the Calendar (§4) on both platforms, since a wheel is the slowest way to reach a distant date. The value it commits is shown in 24-hour HH:MM.
Variants. Date (the wheel, or the Calendar more than a week out) · time (minute interval per field).
States. Open · committed (완료) · dismissed (scrim, × or back: nothing is applied).
Content. The title is the field name ("시작 시간").
Accessibility. The wheel is accessible by the OS; the Sheet's title is the first stop; focus returns to the Picker Cell.
Motion. The Sheet's rise and exit; the wheel is the platform's.
Built 2026-11-08 (W4 batch 3, the 일시 Sheet): DateTimeSheet (packages/ui/src/date-time-sheet.tsx, held to the forms board's csEditor('date') and StartBand) is the composition the boards draw for 일시, not the wheels above: an Edit Sheet (done) over the calendar and the start on the band slider's point mode, a 30-minute snap on a 06:00–24:00 track, the Stepper 06:00–23:30. Start mode only; 일정 만들기 adds the range mode to the same piece.
Replaces in code. Raw react-native-modal-datetime-picker (DateTimePickerModal, six files).
Time range wheel
Job. Pick a start and an end time in one Sheet with one wheel (quiet hours, opening hours).
Anatomy. A Sheet titled with the field name. Tabs 시작 and 종료 (52 tall, the Tabs grammar) show their current values in 24-hour HH:MM; one native time wheel edits the selected tab; the footer is one primary 완료 that commits both. A range that cannot cross midnight and has an end before the start shows the danger helper "종료가 시작보다 빨라요" above 완료 and 완료 is soft-disabled. A range that may wrap (방해 금지 시간) accepts it, and the end value reads "다음 날 07:00".
Variants. Fixed-day range (cannot wrap) · wrapping range (past midnight).
States. Open · invalid (helper shown, 완료 soft-disabled) · committed.
Content. The tab values are times; the wrap note is the value text, not a separate line.
Accessibility. The two tabs are a tablist; each reads "시작, 19:00"; the wheel is accessible by the OS; the helper is announced when it appears.
Motion. The Tabs underline slides to the other tab (navSlide; reduced motion: it jumps); the Sheet's rise and exit.
Replaces in code. The single-value DateTimePickerModal rows that set a start and an end separately: the quiet-hours settings (packages/features/profile/src/settings-signal-preferences-screen.tsx) and the venue-hours composer (packages/features/sessions/src/venue-hours-composer.tsx).
Field
Job. The wrapper of every form control: one grammar for text, number, text area, picker Cell and Choice set. Every field is label · control · support line.
Anatomy. Top to bottom: label 13/18·600 ink (marker per Required and optional marker) → 4 → control → 4 → support line 18, drawn only when it holds an error or a counter. Fields sit 32 apart in a wizard.
- Control (text, number, text area): min 48 · r12 · 1px
$borderControl· pad 0 16 · text 16/24 ink. Focus: a 2px ink border (padding drops by 1 so the text does not move). Error: a 2px$errorborder, at rest and in focus. - Support line: the error at the left (no hint, 1.16) (a glyph, 16 growing to 1.5×, sits in a line slot 8 before the text), the counter at the right, 13/18·500
$textSecondary(7.38:1); a message that wraps takes 13/20 and grows. With the keyboard up the scroll-into-view target is control bottom + 4 + 18 + 16, so the support line and a 16 gap clear the CTA band.
| Part | Text | Number | Text area | Picker Cell | Choice set |
|---|---|---|---|---|---|
| Label | 13/18·600 above | same | same | the Cell's own label 15/22 | the set's legend, label style |
| Control | Input 48 | Input 48, numeric keypad, unit suffix | min 120, grows to 200 | Cell 48: value or "선택" + chevron | Choices 44, wrap, gap 8, at most 5 |
| Counter | from 80% of the limit | none | always | none | none |
| Error | 2px border + line | same | border + line, counter stays | line only | line only |
| Start state | empty | empty | empty | "선택" | none selected |
| Height (bare / with line) | 70 / 92 | 70 / 92 | 142 / 164 | 48 / 70 | 118 / 140 (two rows) |
Variants. The five types in the table; each has its entry below.
States. Rest · focus · filled (unchanged from rest) · error · disabled (.5, not-allowed) · loading (Field skeleton) · 200% text (the control grows to the scaled line + 24; label and support line scale with the reading class).
Content. The label is a noun that fits one line at 100% ("클럽 이름", "확인한 내용"); never an instruction and never a question ("무엇을 확인했나요?" is 확인한 내용). A legend names what its options count ("제한 시간"), not their unit ("분"); a unit every option shares sits on the option ("30분", "45분"). The support line is empty until there is an error (1.16).
Accessibility. The label is the control's name ("클럽 이름, 필수"); the error, when there is one, is its description; an error adds the name suffix "입력 오류" (React Native has no aria-invalid) and is announced when it appears. No shake, no bounce.
Motion. Border width changes over 100 ms; the error appears in the 18 line at once, so nothing moves; reduced motion: no change.
Replaces in code. packages/ui/src/labeled-field.tsx (LabeledField and StyledInput: caption and error render as one slot, error ?? caption, never both), labeled-field-logic.ts, picker-row.tsx (retired 2026-11-01), the labelRight counter.
Input
Job. One line of text.
Anatomy. The Field control. Leading or trailing 20 glyphs only when they name the input type (search, place); a trailing clear × (48 target) when the field is filled and focused; show and hide on a password. No success check on an ordinary field. After an asynchronous check (a name, an invite code) only a failure speaks, as the field's error ("이미 있는 이름이에요"); a success shows nothing (1.16), never a mark in the box.
Placeholder rules. An example value in tertiary (5.46:1 on white, 3.5:1 away from typed ink), 16/24: "예: 강남 라켓클럽". Allowed on free-text fields where an example prevents a wrong guess. Never an instruction, never a substitute for the label, never "(선택)", never on a picker or a numeric field. An account number is a numeric field, so it has none (the numeric keypad names it); the holder's name takes an example ("예: 김민준"), never an instruction ("예금주 이름").
Variants. Plain · password (show and hide) · with a leading glyph · cell (a 48 × 48 box, r12, the numeral 24/32·700 centred: a set score) · compact (a 48 × 36 box, r10, 17/24·700 centred, in a 44 target: the tiebreak points, one set column wide). Both share the field box and the caret of the plain Input.
States. Rest · focus (2px ink border, ink caret) · filled · error (2px danger border, caret stays ink) · disabled · loading.
Content. Keyboard type fits the content (email, phone, number); autofill and paste are allowed. A fixed-format value (phone, birth year, invite code) formats itself as typed and may use a hard maxLength.
Accessibility. Name = label (+ "필수"); the placeholder is never the name; the error is the description.
Motion. Border 100 ms.
Replaces in code. StyledInput single-line in labeled-field.tsx; the bare inputs of the composers.
Why: a 1px #D4D4D4 border is 1.48:1 and WCAG 1.4.11 asks 3:1 (lane B A1).
Number field
Job. A number the person types that no step suits: an amount of money (참가비, 회비, 게스트 참가비) and a year (출생 연도, 테니스를 시작한 해). Every count with a fixed unit is the typed Stepper instead (owner review 52, § 1.22 rule 1), since its value cell is this field with the ends around it; before review 52 a far count (정원, 모집 목표, 최대 인원, 매월 납부일) was a lone Number field, with the range as the error's text when it is crossed: 정원 and 모집 목표 (4 to 120, "4명에서 120명까지 정해 주세요"), 최대 인원 (2 to 32) and 매월 납부일 (1 to 28, 일 as the suffix; the lead's call, 2026-10-20).
Anatomy. The Input with the numeric keypad (inputMode numeric; never type="number"). The unit is a non-editable 15/22 $textSecondary suffix at the right inside the box, and is part of the accessible name ("참가비, 원"); it is never a support line. Amounts format with separators as typed.
Variants. Integer · amount (₩).
States. As Input. Out-of-range input shows the field error with the range.
Content. The unit is one syllable where possible (원, 명, 회).
Accessibility. Name = label + unit + "필수" when required.
Motion. As Input.
Replaces in code. won-amount-field.tsx, suggested-count-field.tsx, numeric StyledInput uses.
TextArea
Job. Free text: the Input's border and states with room to grow.
Anatomy. Min 120 (12 + 4 × 24 + 12), text 16/24, grows with its lines to 200 and then scrolls inside (a thin indicator shows at the right). The counter sits on the support line, never inside the box, and is always shown.
Variants. One.
States. As Input. Past the limit: soft limit (Input counter).
Content. A club bio is often pasted from KakaoTalk, so a long paste is kept and flagged, never cut.
Accessibility. Name = label; the counter is spoken at 80% and at the limit.
Motion. Height follows the lines instantly.
Replaces in code. StyledInput multiline, the in-box counter.
Picker Cell
Job. A field whose value is chosen elsewhere: a Cell with a value and a chevron that opens a Sheet or the platform wheel.
Anatomy. Cell 48: label 15/22·400 $textSecondary at the left (the required marker rides on it at 15/22), value 15/22·500 ink at the right followed by a 16 chevron. An empty required value reads 선택 in secondary (the bare word; "(선택)" is only the optional suffix on a label). The support line sits under the Cell at the rail (22 = 4 + 18). Error: no border, the line only. Cells in a group are separated by the cell hairline inset to the rail.
Variants. Value picker (a Sheet of radio Cells) · wheel (date, time) · search takeover (venue).
States. Rest · pressed (.98 / .80) · hover .85 · focus ring inside the row · error · disabled .5.
Content. The value is the chosen option, never a sentence. A region value is one of the 17 시/도 ("서울"; the kit's FVK_SIDO, the app's order); a district (강남구, 송파구) belongs to a court's line and never to a club's or a person's region. The region Sheet lists all 17 and scrolls inside its cap. 납부 주기 has the domain's four cycles, 매월, 분기마다, 6개월마다 and 매년, so it is a Choice set (owner review 50: the wizards are the app's; the six values of 2026-10-20 retired with 일정마다 and 한 번, which the domain does not have).
Accessibility. button named "{label}, {value}" ("지역, 서울 강남구") that opens a Sheet.
Motion. Pressed settle; the Sheet's rise.
Replaces in code. picker-row.tsx (PickerRow, bordered field row) and edit-field-row.tsx (EditFieldRow): both retired 2026-11-01 (K2b part 2, batch 3), and EditCue with them.
Choice set
Job. Pick one or several from a small list inside a form.
Anatomy. The legend above (13/18·600, the label style, the marker on it) → 4 → Choices at 44 tall, r full, wrapped left-aligned with gap 8 both ways. At most five options, or seven for a fixed series (요일); a longer list is a Picker Cell. Two rows is 118. A venue's court numbers are such a series (일정 만들기 step 2, 사용하는 코트, review 46 M21): up to seven courts are a multi-select set, one Choice per number ("3번" and "4번" chosen), and the review row reads the picked numbers as one fact ("3~4번 코트"); a venue with more than seven courts draws a Picker Cell with that value into a Sheet of check rows, and a venue with no court count on record draws a Stepper, 사용 코트 수, instead.
Wrap rule. A set of several never leaves one Choice alone on its last row (a lone small element does not take a row of its own). The rows are laid out from the Choices' measured widths, and when the last row would hold one, the row before gives it its last Choice: 4 + 1 becomes 3 + 2, 3 + 1 becomes 2 + 2, 3 + 3 + 1 becomes 3 + 2 + 2. A set whose rows cannot spare one (two to a row, an odd one out) holds sentences, not labels, so it is drawn as check rows (Cells with a check lead), as the manner-tag sheet is. A single Choice by itself is fine. The same rule holds for every wrapping set: a FilterSheet group, an Edit Sheet's Choices and the filter row under a header. A set that starts defaulted (an axis with a real default) shows its default selected; a required set with no default starts with none selected.
Variants. Single (radio group) · several (toggle buttons) · fill (a one-line set of two to four shares the line) · sheet picks (ScoreEntry): 36 Choices in n equal columns (4), 8 apart, no check glyph (a pick that applies at once shows by its fill).
States. As Choice. Error: no border, the line only under the set.
Content. One or two words per option.
Accessibility. radiogroup or a group of toggle buttons with a group label; arrow keys on web.
Motion. As Choice.
Replaces in code. SelectionChip groups in wizards, paired SelectionCard forks.
Field hint
Retired (the owner, 2026-10-20: "We generally never put meta data unless an error occured during an input process."; 1.16). A field has no hint. Its support line holds its error, once it occurs, and the counter at the right. What a hint carried goes elsewhere: a rule the person cannot guess is the error's own text when it is broken ("영문과 숫자를 섞어 8자 이상 입력해 주세요"); an exact phrase to retype is the label ("“탈퇴합니다”를 입력해 주세요"); a consequence that changes the decision is the confirmation Dialog's body; a scale the label cannot hold joins the label; an example is not drawn.
Replaces in code. LabeledField caption, every use.
Info hint
Job. The "?" beside a term that has its own explainer Sheet, so a number or a word is never met without its rule one tap away (terminology guide, First Encounter, rule 1): 추천 순서 → 추천 점수 안내, 실력 → 티어 안내, 신뢰 점수 → 점수 안내.
Anatomy. A 20 help glyph (a circle with a question mark; lucide circle-help) in tertiary ink, in a 44 target, 4 after the label it explains (and its count), on the label's first line through the line slot. The glyph is the layout box and the 44 target an overlay centred on it, as a text action's is, so the 4 is measured to the glyph and no row needs a negative margin. One per label. It sits in a Section head's slot after the title and count, before the action and the Choice, or beside a field's label (labelRight).
States. Rest · pressed (.85) · focus (ink ring).
Content. The trigger has no text; its spoken name is the term and 안내: "추천 점수 안내".
Accessibility. A button; opens the explainer Sheet, which returns focus to it. In a Section head it is the heading's sibling, not inside it.
Replaces in code. InfoHintButton (packages/ui/src/info-hint-button.tsx), modified in place (K3a): the lucide Info at 14 becomes the 20 CircleHelp in $textTertiary in a 20 round box with a 44 target and a tab press, and a term prop builds the spoken name ("추천 점수 안내"); the required aria-label stays accepted for a name that is not "{term} 안내"; its two callers (create-club-step4, create-session-step3) moved onto term in K3b (2026-11-02). A Section head draws it as the heading's sibling (SectionHead info).
Input counter
Job. How much of a limited field is used.
Anatomy. "12/20" in 12/18·500 $textSecondary, right-aligned on the support line. Shown from 80% of the limit on single-line fields and always on a TextArea. Typing past the limit is allowed and never blocked: the counter turns $errorText and reads the overflow as −n ("−3"), the text is kept, the field error appears on leaving or on the press, and the CTA becomes soft-disabled. A hard maxLength is only for fixed-format fields (phone, birth year, codes).
Variants. Count of characters (default).
States. Hidden below 80% (single-line) · normal · over the limit.
Content. Characters, not bytes.
Accessibility. Spoken at 80% and at the limit ("20자 중 16자") and on overflow ("3자 초과"); never a live region on every keystroke.
Motion. None.
Replaces in code. The labelRight "123 / 500" counter, the in-box TextArea counter, hard maxLength on free-text fields.
Why: GOV.UK tested a soft limit with low-digital-skill users: they type or paste the full answer, then edit it down.
Field error and validation
Job. Say what is wrong where it is wrong, and when to say it. Reward early, punish late.
Anatomy. Three cues that do not depend on colour: the 2px $error border; a 16 alert glyph + 8 + the message in 13/18·600 $errorText; the weight. The message takes the 18 support line and the counter stays at the right. The label is not recoloured. A Choice set, a Picker Cell or a Stepper has no border to tint: the line alone. A Stepper whose value is outside its range (a game count saved by an earlier version) shows the range in the instruction form, "한 세트 게임 수는 2에서 20 사이로 맞춰 주세요", and its screen keeps the primary enabled: the press found it, so no CTA helper repeats it.
| Moment | Rule |
|---|---|
| First time typing | No error. Live feedback only for the counter, a password rule checklist and an availability check ("확인 중" in the support line). |
| On leaving the field | Validate a non-empty value for format, length and range; asynchronous checks (name taken, invite code) run here. |
| An empty required field | No error on leaving. It is flagged when the CTA is pressed, or on a later visit to the step after a press. |
| After a field has shown an error | Re-validate on every change and clear the moment it is valid. |
| CTA pressed with something missing | Validate everything, mark every invalid field, scroll to the first and focus it, announce "N개 항목을 확인해 주세요" once; keep the keyboard as it was and never clear input. |
| The server rejects | The same lines; the input is kept. |
| Three or more fields, or the first error off screen | An error summary: a danger Notice at the top of the scroll area, "확인할 항목이 N개 있어요", one tappable line per item ("클럽 이름: 2자 이상 입력해 주세요") that scrolls to and focuses the field, worded exactly like the field line. |
Variants. Field line · summary.
States. Error rest · error focus (the border stays 2px danger).
Content. The fix plus the rule, in the instruction form: empty "[항목]을 입력해 주세요" or "[항목]을 골라 주세요" · length "[항목]은 2자 이상 입력해 주세요" · format "[항목]을 [형식]으로 입력해 주세요" · taken "이미 있는 [항목]이에요. 다른 [항목]을 입력해 주세요". The same words in the field line, the announcement and the summary.
Accessibility. The control's name carries the suffix "입력 오류"; the line is a polite live region plus one explicit announcement (VoiceOver needs it); never a Toast; no shake.
Motion. None (errors never animate).
Replaces in code. Toast and banner validation, hand-drawn red hints, LabeledField showing caption and error together.
Why: $errorText #A3261F is 7.36:1 at 13 px, over the 6.5:1 floor (lane B A2).
Required and optional marker
Job. The one sign that a field must be answered.
Anatomy. The one asterisk (owner LAW): after the label, inside the label's own text, the label's size (13/18·600 on a Field, 15/22 on a Cell), colour $errorText; hidden from assistive technology, with "필수" in the accessible name ("클럽 이름, 필수"). No legend explains the asterisk: "* 필수 항목" is not drawn (the owner, 2026-10-20; 1.16). Optional fields that sit beside required ones end their label with a quiet "(선택)" (13/18·500 $textSecondary); it never appears in a placeholder.
Variants. Field label · Cell label · Choice set legend.
States. A required control starts genuinely unset (no literal default; an empty Choice set; a Stepper below its range); nothing names it before a press, which shows its error (1.16). In a sequential step the unset frame draws only the first section; each next section appears when the one above is answered (WizardShell, Reveal). A section with a real default is answered from the start, so it appears together with the section above it: 일정 만들기's 종목 is the one required control of its step, and 경기 유형, 구성 and 실력 appear with it when it is answered. No legend is drawn (1.16), so an unset step of one required section has none. A control that cannot represent unset (a hue slider) is exempt: required there means it cannot be skipped.
Content. A step whose every field is optional (a skippable step) marks none: its 건너뛰기 already says so. An either/or group (fields of which one must be answered, whichever) says so on its head, "둘 중 하나는 필요해요", and its fields drop (선택): each is optional alone and required only as the pair, so none claims to be optional while one is not. Example: 장소 정보 제안's evidence, the Section head 확인한 근거 over 확인한 내용 and 공개 참고 링크.
Accessibility. The glyph is aria-hidden; the name carries "필수"; the legend is text.
Motion. None.
Replaces in code. The LabeledField required asterisk (labeled-field.tsx, read aloud as "별표"); "(선택)" inside placeholders (clubs.ts, sessions.ts, venues.ts, dm.ts).
Why: the asterisk is red text at 13 px, so it takes the text role $errorText (7.36:1, over the 6.5:1 floor); it stays the one shared visual (lane B A2, A19).
Field note
Retired (the owner, 2026-10-20; 1.16). A field carries no note. What an answer does is not a line under the field: a cost the press commits is the CTA band's total, a consequence that changes the decision is the confirmation Dialog's body, and who sees an answer is the 공개 범위 page's. A control that depends on its parent (a pay-day Stepper under a monthly fee) stays a nested Field.
Replaces in code. attached-panel.tsx (AttachedPanel) when it holds only a sentence.
Field skeleton
Job. The placeholder of a form field while its value loads.
Anatomy. A label bar 12 tall at 30% width inside the 18 label line → 4 → a 48 gray100 r12 box: 70 per field, 32 between fields. One shimmer per screen: every bar pulses together, opacity .6 ↔ .9 over an 800 ms half-cycle.
Variants. Text · Picker Cell (a 48 bar).
States. Loading; swaps to the field with a 150 ms cross-fade. Appears after the Skeleton delay.
Content. None.
Accessibility. One label on the container ("불러오는 중"), aria-busy; the bars are hidden.
Motion. Pulse stops after 5 s; reduced motion: static .75.
Replaces in code. Field-shaped Skeleton blocks in the edit and settings screens.
Map face in a form
Job. Show the picked venue on a map under its Cell.
Anatomy. After a venue is picked: a 350 × 140 r12 static map with a 24 ink pin, a 1px inset #E5E5E5, 8 under the 장소 Cell. A tap reopens the search takeover.
Variants. Map · court tile (when the map fails).
States. Loading gray100 · loaded · error (the court tile).
Content. None; the venue name is in the Cell.
Accessibility. button named "{venue}, 장소 변경".
Motion. Image load: a 150 ms fade; reduced motion: instant.
Built 2026-11-08 (W4 batch 3): MapFace (packages/ui/src/map-face.tsx, held to the kit's FvkMapFace): the 350 × 140 r12 tile, the 24 ink pin, the inset hairline, map | loading | error → the court tile, a button named {place}, 장소 변경; the app passes the static map's URI. First consumer 행사 만들기's 장소 row (PickerCell gained the expand slot).
Replaces in code. packages/app/src/presentation/components/picked-venue-card.tsx.
Composer photo tiles
Job. The photos attached to a post being written: see them, remove one, add more, put them in order.
Anatomy. 4-up wrapping rows, gap 8; tile = (350 − 3 × 8) / 4 = 81.5 (computed; a listed exemption from the grid), r8. Remove: a 24 ink α0.6 circle with a white 16 × at the top-right, inset 4, inside a 48 target. The add tile is gray100 with a 24 + and the count against the content's real limit ("2/4": a club post holds four). Long-press drag reorders.
Variants. Photo tile · add tile.
States. Rest · dragging · the add tile is hidden at the limit.
Content. The count is n/limit, read from the content type.
Accessibility. Each tile is a button named "사진 2/4"; a tap opens a MenuSheet with 앞으로 이동 · 뒤로 이동 and, in the destructive group, 삭제 (the alternative to dragging, WCAG 2.5.7); removing a draft photo is immediate with a 되돌리기 Toast.
Motion. The drag follows the finger; tiles reflow instantly; reduced motion: unchanged.
Built 2026-11-02 (W4 batch 1, additive): PhotoTiles is packages/ui/src/photo-tiles.tsx (numbers in photo-tiles-visual.ts, held to the kit's FvkPhotoTiles and the 글쓰기 board): 4-up, × the one removal, the tile's menu moves it 앞으로 / 뒤로, the add tile reads n/limit, an uploading tile is a spinner with its × dimmed; words are a prop bundle. The board's SPEC_WP rules win over the paragraph above where they differ: there is no destructive 삭제 in the menu, no 되돌리기 Toast and no long-press drag.
Replaces in code. The attachment strip in packages/features/clubs/src/post-create-screen.tsx.
Cover grid
Job. Pick one picture from a fixed set: a club's default covers.
Anatomy. Two columns of 16:9 tiles on the rail, gap 8, r8. The chosen tile carries a 2px ink ring and a 24 ink check badge at its top right, inset 8.
States. Rest · pressed (the tile dims to .80) · chosen.
Accessibility. A radio group named for the field; each tile is a radio named "기본 사진 n", its own stop.
Motion. None: the ring and the check are the state.
Built 2026-11-02 (W4 batch 2): CoverGrid is packages/ui/src/cover-grid.tsx (numbers in cover-grid-visual.ts, held to the kit's FvkCoverGrid): the grid only, hosted by 클럽 설정's 기본 사진 선택 Sheet (settings/photos-group.tsx), which closes and writes the banner on a pick; all 19 covers of CLUB_DEFAULT_COVERS show. settings/default-cover-picker.tsx is deleted.
Replaces in code. packages/features/clubs/src/settings/default-cover-picker.tsx.
Consent group
Job. Agree to the terms one by one or all at once, and read each before agreeing.
Anatomy. A master row 전체 동의 (label weight 600; ticks or clears every item; shows the mixed state when partly ticked), then 16 and a cell hairline, then the items. Each item is a Cell 48: a leading 24 checkbox at the rail, the label with its [필수] or [선택] prefix 15/22 (content shifted 36), and a trailing sm ghost 보기 › (chevron 16; its own focus stop; 44 by slop) that pushes the Document page. Marketing sits last in the same rhythm as the required rows: no extra gap (owner review 2 read the old 12 as the third row's padding); its [선택] prefix sets it apart. The labels are the shipped legal strings until counsel reviews them; the (광고) prefix belongs on the advertising push itself, never on the consent row. Nothing is pre-ticked.
Variants. The sign-up consent step (master + required items + marketing group), step 2 of 3 after 본인인증 (owner review 4, D6; the labels stay the shipped legal strings, verbatim). The owner kept that order on 2026-10-28 ("as ruled before"), so the board draws it as step 2 of 3 again; the first pass's one screen with the start of 본인인증 is gone.
States. Unticked · ticked · mixed (master) · the CTA follows the Button rule (soft-disabled while one required item remains, validate on press while several do); a press shows the missing item's error, and no helper names it (1.16).
Content. The prefix is the shipped [필수] / [선택]; one wording per concept; a consent is never folded into a button label.
Accessibility. Each item is a checkbox named with its prefix ("[필수] 이용약관"); 보기 is its own stop ("이용약관 보기"); the master reads "전체 동의" with aria-checked true, false or mixed.
Motion. Checkbox fill 100 ms; reduced motion: instant.
Replaces in code. packages/features/onboarding/src/onboarding-step-consent.tsx (one SelectionCheck row per purpose; replaced 2026-11-01, K2b part 2 batch 2). SelectionCheck itself was deleted 2026-11-02 (K5b).
ProviderButton
Job. The sign-in buttons, one per provider, each drawn as its service requires inside our geometry. Together they are the provider set of the CTA band's Entry variant.
Anatomy. The lg Button geometry for every provider: 52 tall (80 from 150% text, so the label stays at most a third of the container), full width on the rail, radius 12, 12 apart in a set. The provider's symbol sits in a 20 box (Naver's N 16), 8 before the label, which is 17/24·500 in the system font. Brand fills are exempt from the one-ball rule, so the set carries no ball.
| Provider | Fill, symbol, label | Label (ko / en) | Rules |
|---|---|---|---|
| Kakao | container #FEE500; symbol #000000 (left-aligned or centred with the label); label #000000 at α0.85 | 카카오 로그인 / Login with Kakao | radius 12; the symbol is mandatory and its shape, ratio and colour are fixed (Kakao's corporate logo may not stand in); no other colour; Kakao's colour is never left unspecified so that another provider's button stands out |
| Naver | #03A94D; white N (at least 16); white label | 네이버 로그인 / Login with Naver | the complete form: logo and label centred, 8 apart; the wording may change; used on both the sign-in and the sign-up screens; white on the green is 3.1:1, a brand exception like Kakao's yellow (the guide fixes both colours) |
| Apple | #000000 with a white mark and label (dark: #FFFFFF with black); a custom button is only black or white | Apple로 로그인 / Sign in with Apple | only "Sign in with Apple", "Sign up with Apple" or "Continue with Apple"; at least 140 × 30 and no smaller than the other sign-in buttons; reachable without scrolling; the radius may match the others; Apple's logo artwork only |
#FFFFFF with a 1 inside stroke #747775, text #1F1F1F (dark: #131314, stroke #8E918F, text #E3E3E3); the full-colour G | Google 로그인 / Sign in with Google | "Sign in with Google", "Sign up with Google" or "Continue with Google"; localising is permitted; displayed at least as prominently as the other third-party options, roughly the same size; the G keeps its aspect ratio |
The label's font size is at most one third of the container's height: at large text it stops at the 1.5 cap and the container grows with it.
Variants. The sets: iOS Kakao, Naver, Apple; Android and web Kakao, Naver, Google. The order within a set is fixed so a second visit finds the same thing. A fourth provider adds 64 to the Entry band. The interim email sign-in is the ghost md Button beneath the set.
Sign-up and linked accounts (owner review 4, D6). The set is the same for sign-up and sign-in: tapping a provider is the first step of the sign-up flow, which then runs 본인인증 (our 본인인증 screen says why before it asks and that 주민등록번호는 받지 않아요, then offers PASS, 토스, 네이버 and 카카오 as four equal provider buttons, each going straight to that provider through KG이니시스 통합인증 on PortOne (directAgency), and 다른 방법으로 인증 as a quiet link to the vendor's own window (금융인증서, the banks, 휴대폰 본인확인); Danal is the fallback channel and Kakao's CI needs its own paperwork; there is no result screen of our own; frames vi-nophone, vi-foreign and vi-14-18 cover a missing own-name phone, the foreigner flag taken from phone or PASS only, and 14 to 18, who are allowed with the card rail blocked below 19; under 14 stops), 약관 and 첫 시작. A provider gives only its stable id, a nickname and the email where it offers one; the verified name and birth date come from the 본인인증, and one person has one account (keyed by the CI). The set is also what 설정 › 연결된 계정 lists: one action Cell per provider of the platform (label the provider, line 2 the nickname or email it gave, an sm tonal Button 연결 or 해제 at the end). The last connected login's 해제 is disabled and the Cell's helper says how to enable it ("다른 로그인 방법을 연결하면 해제할 수 있어요"); 해제 asks a Dialog; a failed connect is a line on its own Cell. A second provider that verifies to an existing CI meets 이미 가입한 계정이 있어요, which names the first provider and offers to sign in with it and connect. Naver is Supabase custom OAuth (custom:naver); a spike confirms the mapping. Drawn on flat-v2-auth (the 온보딩 steps) and flat-v2-forms (연결된 계정). Needs counsel: the order (본인인증 before 약관), the CI's storage and retention, and an under-14's verified data. The owner confirmed the order on 2026-10-28 ("as ruled before"): 본인인증, then 약관, then 첫 시작, with stops for failure, a phone not in the member's name, under 14 and a duplicate account (decision log, 2026-10-28).
App Review 4.8. An iOS app that signs in with a third-party or social login must also offer an equivalent login service that limits data to name and email, lets the person keep the email private and collects no interactions for advertising without consent. Sign in with Apple is that service; the app's own email sign-in does not satisfy it. It is shown at no smaller than the other buttons, without scrolling.
States. Rest · pressed (dim .85, no settle: a provider's proportions are fixed) · loading (a spinner in the symbol's colour beside the label; only the tapped provider shows it, the others are disabled until it settles) · disabled (.5, offline: the strip says why) · the same in dark mode (Apple white, Google #131314; Kakao and Naver keep their colours).
Content. English reads "Login with Kakao", Kakao's mandated label, which overrides "Sign in" on this button; Naver follows it ("Login with Naver"), Apple and Google read "Sign in with …".
Accessibility. button; name = the visible label. A cancelled provider flow shows a 15/22·600 danger helper line above the set (8 above the first button, the same size as every action-attached helper), not a Notice.
Motion. Dim only.
Replaces in code. The Kakao button in packages/app/src/presentation/screens/auth/login-screen.tsx.
Why: the four provider guides (Kakao Login, the Naver login button guide, Google branding, Apple HIG) allow one layout, the same height and width with each provider's own fill; App Review 4.8 (review 3, lane A §5).
8. Overlays and feedback
The overlay rule
Scrim. Behind a Sheet or a Dialog the page dims to the $backdrop token: ink at alpha .4 in light, black at alpha .6 in dark (rgba(13,16,14,0.4), rgba(0,0,0,0.6); s2-palette.ts, FvkScrim in the kit). ModalPanel.Overlay reads it since K4a, so every modal dims the same way; it replaces $overlayBackdrop (black .5 in both modes), which photo-viewer overlays still use. A Dialog's scrim does not dismiss; a Sheet's does.
Pick the surface by what the person is doing. One rule, seven cases.
| Rule | The person is… | Surface |
|---|---|---|
| S-1 | confirming a consequential action | Dialog: centred, names the object and the consequence, at most two buttons (one acknowledgement only when it blocks). An undoable action gets a Toast with 되돌리기 instead. |
| S-2 | choosing a command for an object (⋯, gear, a row ⋯, a long-press, +) | MenuSheet: a bottom list titled with the object. From 600 pt the same rows render as the anchored Menu. On phones there is no anchored Menu. Three or more commands. |
| S-3 | choosing one value (Choice ▾, 정렬, a month) | Sheet of radio Cells. |
| S-4 | entering or editing data (a field, a filter, a score) | Sheet (Edit Sheet, FilterSheet, ScoreEntry) with × in the title; the Edit Sheet ends in one 저장; dismissing with changes asks a Dialog; one sheet at a time. |
| S-5 | reading at length or running a multi-step flow | Page or takeover: 약관, wizards, search. |
| S-6 | picking between the consequences of an action just chosen | MenuSheet with a question title, at most three rows (정기 일정 취소 → "이 일정만 / 이후 일정 모두"). |
| S-7 | being told what happened | Toast (with 되돌리기 when reversible). A failure goes next to the button (CTA helper in danger), in a Notice that stays, or in a Dialog when it blocks. Never a Toast. |
| Trigger | Surface | Rule |
|---|---|---|
| 관리 gear on the club hub or the session page | MenuSheet (운영 group, then the destructive group) | S-2 |
| ⋯ on a club, session, venue or profile (three or more rows) | MenuSheet | S-2 |
| ⋯ on a post, a comment, a person row (three or more commands) | MenuSheet | S-2 |
| a long-press on a message | MenuSheet with the Reactions quick row first | S-2 |
| + on the 클럽 tab | MenuSheet: 클럽 만들기 · 초대 코드로 가입 · 클럽 찾기 | S-2 |
| 일정 삭제, 일정 취소, 클럽 나가기, 로그아웃 (a row of a MenuSheet) | Dialog naming the object | S-1 |
| 참석 취소 (CTA band) | Dialog | S-1 |
| 정기 일정 취소 | MenuSheet with a question title | S-6 |
| 10월 ▾, 정렬 ▾ | Sheet of radio Cells | S-3 |
| 필터 | FilterSheet | S-4 |
| a field's pencil | Edit Sheet; dismissing with changes asks a Dialog | S-4 |
| 점수 입력 | ScoreEntry Sheet | S-4 |
| 점수 검토 요청 › (the Match state block's door) | ScoreEntry Sheet, appeal mode: the stored score in the cells, then 검토 요청 | S-4 |
| 검토 (the host's Task Cell) | Host review Sheet: the entered score, 그대로 유지 · 이 점수로 바꾸기 (기록에서 제외 behind ⋯) | S-4 |
| 검토 N경기 (several reviews) | a Sheet of the n Match rows; a row opens its Host review Sheet | S-3 |
| 외 N곳 (the 클럽 Cell of 공개 프로필) | a Sheet of the person's clubs, a MediaRow each; a row opens its 클럽 홈 | S-3 |
| 기록에서 제외 (⋯ in the Host review sheet, a MenuSheet row, S-2) | danger Dialog: 기록에서 제외할까요? 취소 · 제외 | S-1 |
| 모두 확정 | Dialog naming the count | S-1 |
| 확인 요청 sent, 점수 저장 | Toast with 되돌리기 | S-7 |
| 이용약관 보기 | Document page | S-5 |
| 입금 확인 saved, 참석 확정 | Toast | S-7 |
Rules for every overlay: one at a time (a Dialog may sit over a Sheet as a guard; a Sheet never sits over a Sheet); backdrop ink α0.4; system back closes the topmost layer, and with the keyboard open the first back hides it; focus returns to the trigger on close; never ask "정말 …?" for anything a 되돌리기 Toast can reverse; never a MenuSheet or Menu for one or two commands: they sit on the row or the page as buttons, and a row's ⋯ is held to the same three-or-more rule as every ⋯.
Dialog
Job. A decision that cannot be postponed (S-1): confirm a consequential action, or acknowledge a fact that blocks.
Anatomy. Centred · width min(content width, 560) (350 at 390) · r16 · a 1px $hairlineEdge edge in both themes (the ModalPanel frame draws the same; never two) · pad 20 · title 17/24·600 ink · 8 · body 16/24 $textSecondary (two lines at 100%) · 20 · the button pair: two lg Buttons, the tonal one third and the primary or danger two thirds (set explicitly; see Button), gap 12, so the committing button is the larger and sits on the right (thumb side). Min height 192 (20 + 24 + 8 + 48 + 20 + 52 + 20). Backdrop ink α0.4 (dark: black α0.6); the backdrop does not dismiss.
Variants.
- Confirm: the commit is primary (ball).
- Destructive: a confirm the person started (삭제, 탈퇴, 일정 취소): the commit is danger.
- System warning: the app warns of a risk the person has not weighed (a flagged account, a payment that will fail): the safe action is the primary and the risky one is tonal.
- Guard (leaving with unsaved input): "나갈까요?" / "지금 나가면 입력한 내용이 사라져요" with 계속하기 (tonal) and 나가기 (danger); an edit form asks "수정을 그만둘까요?" / "바꾼 내용은 저장되지 않아요" with 계속 수정 and 그만두기. A page that only ticks (멤버 초대) says what is lost in its body: "고른 친구는 저장되지 않아요".
- Consent (revoking a purpose, or granting one from its text): two choices of equal size, shape and colour, so neither is nudged: two tonal lg Buttons, each half the line, in reading order (닫기 and 철회); neither is primary and neither is danger. From 1.5× text they stack, the first on top. Revoking names the purpose in the title ("위치 정보 이용 동의를 철회할까요?") and its one-sentence body names the way back ("철회해도 동의 관리에서 다시 활성화할 수 있어요"); granting asks the question in the title ("마케팅 푸시 알림에 동의할까요?") and its body is the consent's shipped sentence, verbatim, 합니다체 included ("마케팅 푸시 알림 수신에 동의합니다"), until counsel reviews the legal text (EXT-2). The buttons are 닫기 and 철회 or 동의, tonal and half the line each. A second use: 신고 관리's outcome. 해결 처리 asks "이 신고를 어떻게 처리할까요?" with the report repeated (the reported member, the day, the category, the words; never who filed it) and two equal choices, 확정 (counts for 12 months in the tier) and 기각.
- One action: an acknowledgement that blocks (a version gate, a rule that must be read): title + body + one full-width primary 확인 (52). Never used where a Toast would do.
States. Open · closed · committing (the commit Button shows its loading state, both buttons inert). From 1.5× text the pair stacks with the commit on top.
Content. The title names the object and the consequence ("10월 14일 복식 일정을 취소할까요?", "정우진님을 친구에서 삭제할까요?"); the body is one consequence sentence. Two facts join in the one sentence with a comma, never a second sentence ("보관 후 30일 안에는 복구할 수 있고, 그 후에는 영구 삭제돼요"; "끝내면 이 기기에 남은 복구 정보만 지워요"). A fact the page behind already states (the unconfirmed receipt's title) is not repeated in the body. Buttons are named by action (참석 확정, 일정 취소, 계속 작성, 나가기); a pure dismissal is 닫기; never a bare 취소 or 확인 that could mean the person's task. A consent dialog gives its two choices equal size, shape and colour. English: "Keep writing" / "Leave".
Accessibility. alertdialog on web, aria-modal on iOS; the title is the first element; initial focus on the tonal (least destructive) button so Enter does not delete; Escape and system back act as the tonal button; focus returns to the trigger.
Motion. The platform fade (not tunable), no scale; reduced motion: the platform decides.
Replaces in code. packages/ui/src/confirm-sheet.tsx (ConfirmProvider, useConfirm: outline cancel + primary or destructive; the API stays), native Alert.alert, ModalPanel placement="center" used as a confirm. Built in K4a as Dialog (dialog.tsx, on ModalPanel's root and Overlay), and confirm-sheet.tsx now renders it in place: useConfirm, useConfirmRemove and useOptionalConfirm keep their APIs; a cancel becomes the tonal left, a destructive the danger right, a lone button the full primary (one), equal: true the consent pair, and a third button is refused by the type (K4b judges any 3+ confirm: the third becomes the ⋯ or a Sheet). committing (loading commit, both buttons inert, the Dialog stays open) is Dialog's; the useConfirm press still closes first (look-only). The guards are useLeaveGuardDialog and useEditGuardDialog (guard-dialogs.ts), their words uiStrings().leaveGuard and editGuard; the app's common.backExit* and sessionDetail.editDiscard* keys migrate in K4b.
Why: Toss drops 취소 because it reads as cancelling the person's task, and HIG reserves Cancel for the button that cancels the alert's own action (lane B A13); the safe-action-primary rule follows the 카카오뱅크 scam-warning sheet and HIG's buttons guidance.
MenuSheet
Job. The commands for one object (S-2), from a header gear or ⋯, a row ⋯, a long-press, or +. It is the generic sheet for any object's actions.
Anatomy. A bottom Sheet: top radius 16 · pad 20 · title row 48: the object's name 17/24·600 (wraps, never cut) + × (IconButton) · body of groups of Cells 48 (each row a command Cell; the destructive group destructive Cells): leading 20 icon optional and consistent within one sheet, label 15/22·400 ink, a 16 chevron only on a Cell that opens another screen, a count Tag before the chevron on a task Cell · cell hairline #E5E5E5 inside a group · 24 between groups · a group title (13/18·600 $textSecondary, 8 below) only when the sheet has two or more non-destructive groups; a single group and the destructive group are untitled · the destructive group is last, labels in $error, each row opening its own Dialog (unless a Toast undoes it) · no CTA band · opens at content height, cap 88%, scrolls only beyond it · bottom padding 20 + the home inset. At 600 pt and up the same rows render as the anchored Menu: right-aligned to the rail 4 below the trigger, 240 wide, r12, 1px #D4D4D4 edge, no shadow, rows 48 (glyph 20 + 15/22; each a Cell inset to 16, its hairline full width), the destructive rows after one cell hairline, scrolling past the available height.
Variants.
- Object menu (default): title = the object's name.
- Question title (S-6): the title is the question about an action just chosen, with at most three consequence rows ("이 일정만 / 이후 일정 모두"); no group titles.
- With the Reactions quick row: the six reactions sit between the title row and the first group; the title row shows the sender or author and a one-line excerpt of the message or post it acts on.
- Tool menu (관리): task Cells with count Tags, the 운영 group first, the destructive group last. The club's (클럽 나가기 last) and the session page's (the ends 일정 종료 · 일정 취소 · 일정 삭제 last; every row with its leading icon, and 일정 수정 with no chevron because it opens no other screen) are registries of the kit, both drawn by
FvkAdminSheet(doors,groups,can,counts). A count is a danger Tag unless its door names a tone: 참석 확인's is neutral, because it informs and asks nothing of the host. The club's sheet is titled by the club's name; the session page's by the session's day and start time ("9월 19일 (토) 09:00"), because the page above it names the place; the pages it opens name the session in their subtitle by its place and its day (§3 Header, Subtitle variant).
States. Rising · open · pressed row (.98 / .80) · a destructive row opens its Dialog.
Content. Row labels are 2–5 syllable verbs or nouns (공유, 링크 복사, 수정, 신고, 삭제). A row that names an object by facts (a 정기 일정: when, where) draws them as Meta in the label slot, "토 09:00 | 올림픽공원", never one glued string. Three or more rows, the rule for every ⋯ (a row's included): with one or two commands they sit on the row as buttons or its own pencil, never in a MenuSheet. Example: a court row with two commands, 홈 코트 and remove, draws a radio ring and a remove control on the row instead of a ⋯. Every destructive row asks a Dialog that names the object; the one exception is a row that a Toast undoes (삭제 on a notification).
Accessibility. Native Modal, aria-modal on iOS; the title heading is the first element; close is "닫기"; the scrim is hidden. At 600 pt and up: trigger with aria-haspopup="menu" and aria-expanded, menu / menuitem, arrow keys, Escape closes and returns focus. The trigger is named by its object ("김민준 메뉴", "더보기"). A long-press has an accessibility action that opens the same sheet.
Motion. Rise 300 ms (sheetIn, enter curve), exit 200 ms; iOS: a swipe down on the title row dismisses (40 pt or 500 pt/s); the Menu fades in 150 ms and out 100 ms. Reduced motion: a 100–150 ms fade replaces the rise.
Replaces in code. packages/ui/src/header-actions-menu.tsx (HeaderActionsMenu, the shadowed anchored dropdown, which becomes the presentation switch: a sheet below 600 pt, the anchored Menu from 600 pt; breakpoints in window-size.ts), the local row menus (the ⋯ menu in packages/features/clubs/src/shared/post-row.tsx, member-kebab-modals.tsx), the host menu rows (packages/features/sessions/src/session-detail/actions/host-menu-actions.ts), and the name ActionSheet. A two-row kebab such as edit-delete-action-modal.tsx (수정 / 삭제) becomes buttons or the row's own pencil, not a MenuSheet (K4b: it retired; a 정기 일정 or a 조 row opens its editor Sheet, whose title row carries the rare command, 삭제, as the Sheet's ⋯ more, and the section asks the confirm Dialog). Built in K4a: MenuSheet (menu-sheet.tsx, on Sheet with bleed: groups of command Cells, titles at two or more non-destructive groups, question, a count Tag, a chevron, a glyph) and the anchored Menu (menu-sheet-anchored.tsx: 240, r12, a 1 edge, no shadow; the position is resolveHeaderMenuLayout); HeaderActionsMenu keeps its API as the trigger and the presentation switch on the content width (a sheet below 600, the anchored Menu from 600). The quick Reactions row waits for Reactions (K6). K4b moved the local menu sheets: the club 관리 sheet, the 알림 row menu, a feed post's ⋯, a member's, a guest's, a gallery photo's and a dues row's menu are MenuSheets (the post and notification rows build theirs with groupFlaggedRows); the last, the dues row's, opens a Sheet for the one command that needs a field (메모).
Why: Apple's "action sheet" is S-6 only; Seed and Material both use a bottom list as the phone menu (lane C point 15).
Sheet
Job. The one bottom overlay for a scoped task on the page behind it: pick a value, edit a field, filter, enter a score.
Anatomy. Top radius 16 · pad 20 above and the rail at the sides (16, 20 or 24: not a fixed 20) · width min(frame width, 640) · title row 48: the title 17/24·600 (the field or object name, wraps) with the × IconButton at the right (bleed end: its glyph's right edge on the rail); a one-line sub (13/18·500 secondary; the match, for a score sheet) sits under the title inside the same 48 (3 + 24 + 18 + 3) · no description slot (1.16) · content in a scroll body · footer = the CTA band geometry when the Sheet has a primary (lg Button, 16 above, max(16, inset) below); a Sheet with no footer keeps 20 + the inset under its last row. No grabber and no drag handle. Auto-height, capped at 88%; the body shrinks and scrolls past the cap; the footer is a normal sibling, never absolute. The keyboard lifts the Sheet (and with an input the footer stays above it). Backdrop ink α0.4.
Variants. Value picker · Edit Sheet · FilterSheet · ScoreEntry · Host review sheet · Date and time picker · Time range wheel · Request Sheet (사유 인정 요청, §4 Trust: a required Field and a TextArea, the primary soft-disabled until the reason is written; the keyboard docks it as the Edit Sheet) · Club invite Sheet (FvkClubInviteSheet, below).
Club invite Sheet (FvkClubInviteSheet, opened by every door to the invite: the club home's 회비 pane, 회비 설정, the member list's FAB, 성장 분석's closing Cell, 클럽 설정's 초대 코드 Cell, the club home's header 공유 (member frames only) and the owner Notice's 초대 링크 보내기; the hub routes those two through this Sheet. A visitor's header 공유 stays the OS share sheet with the public club page: a visitor is not a member and never sees the invite code). A pinned Sheet with bleed, titled by the club ("강남 테니스 클럽에 초대"); the 초대 코드 as a copy Cell (the code in tabular figures, then the copy IconButton; the Toast "초대 코드를 복사했어요" is the page frame's, as on the 입금 안내 Sheet), the 초대 링크 as a plain fact (the URL as a second line, no button), and the footer's one primary, 공유 (the OS share sheet, useShareClubInvite); no note under the rows (1.16). The Sheet is the one home of the club invite (1.18): no second primary, no share Cell above the footer. Replaces in code: the invite row of info-section.tsx, the invite calls in club-detail-screen.tsx and public-club-profile-screen.tsx; the DM add-members form (L4-10) stays.
States. Rising · open · dismissing. The scrim, ×, system back and (iOS) a swipe down on the title row all dismiss; with unsaved input they ask the guard Dialog instead.
Content. The title names the field or object, never "수정" alone. sub is the object's excerpt (the match of a score sheet) and nothing else; an explanation or an instruction ("활동 중인 멤버 중 새 대표를 골라 주세요") is not drawn (1.16). A Notice in a sheet spans the content width, rail to rail (20 each side; the kit's Notice, never a hand-drawn box): 12 below the title row and the standard 24 before the next section. A sheet with bleed passes the Notice as is; one with padded content cancels the padding so the Notice keeps its own rail.
Accessibility. Native Modal, aria-modal; the title heading is the first element and the first stop; close is "닫기"; the scrim is hidden; focus returns to the trigger.
Motion. Rise 300 ms (sheetIn: a 48 pt rise over the platform's fade), exit 200 ms; reduced motion: a 100–150 ms fade.
Built 2026-11-02 (W4 batch 2, the club invite Sheet): ClubInviteSheet is packages/ui/src/club-invite-sheet.tsx (numbers in club-invite-sheet-visual.ts, held to the kit's FvkClubInviteSheet): the code as a tabular Cell (a copy button only when the host passes onCopy, which waits for W5's expo-clipboard), the link without its scheme, one primary 공유 (the house form; the boards and the kit say the same). First consumer: 클럽 설정's 초대 코드 Cell (settings/join-group.tsx). The club home's and the visitor page's share calls are unchanged until their pages are built; a visitor's share is the page link, never the code (useShareClubPage).
Replaces in code. packages/ui/src/modal-panel.tsx (ModalPanel placement="bottom"; the Frame's close × is always present), chip-picker-panel.tsx. Built in K4a as SheetPanel and renamed Sheet in K4b (sheet.tsx, with SheetFooter and ButtonPair; the dev panel imports Tamagui's own from tamagui), on ModalPanel's root and Overlay, whose scrim is now the $backdrop token (ink α0.4, black α0.6 in dark; it was $overlayBackdrop, black α0.5 in both). The ModalPanel.Frame consumers moved in K4b; two stay, the post's reaction picker and a DM message's action sheet, until the quick Reactions row (K6). A Sheet with tall fills the 88% cap and gives its body a bounded, non-scrolling box for a widget that scrolls itself (the Daum address search); a Sheet with onBack and noClose is the back-arrow form of the score sheets; a Dialog replaced the two answer-or-later sheets (tier-up, the push permission prompt).
Sheet, value picker
Job. Choose one value from a list (S-3): a Choice ▾, 정렬, a month, a level.
Anatomy. A Sheet titled with the field name. Radio Cells 48: the chosen row's label turns 600 with a 20 ink check at the end (no fill, no tint). A Two-line Cell (min 68, 13/18·500 secondary second line) gives each option its meaning (공개 범위). A row may carry a count (15/22 secondary, tabular) in a figure column before the check; the check's 20 slot is kept on every row so the counts align (알림 필터: 전체 12, 안 읽음 2, 관리 3 for officials, then each family that has rows in the kit's order, 일정 3, 경기 2, …; a family with no rows is not listed, and 안 읽음 is the number on the bell). No footer: a tap selects and closes. Order: "전체" first when empty is allowed; months newest first with the current month checked, every month since dues began reachable.
Variants. One-line options · two-line options · options with counts · two groups for view options (정렬 and 묶기 in one Sheet), each under a 13/18·600 secondary group title, 24 between groups.
States. Rest · pressed (.98 / .80) · chosen.
Content. Option labels are values, not sentences.
Accessibility. Radio group; the title names the field; the trigger Choice ▾ reads its current value.
Motion. As Sheet.
Replaces in code. DropdownChip panels and RangeChip panels (dropdown-chip.tsx, range-chip.tsx). Built in K4a: ValueSheet (value-sheet.tsx) is now a Sheet of radio CheckRows with a second line (sub), a count in the figure column and groups (titles at two or more, 24 apart); its options form is unchanged, so the notification filter keeps working. The chip panels still use ChipPickerRow.
Sheet, score
Job. Explain one number as the sum of its parts (owner review 46, M24: a partner's 추천 점수), for one person against the viewer. Read-only; × closes it.
Anatomy. A Sheet titled for what is scored (추천 점수), bleed: the person row (PersonRow: avatar, name, Level tag; it opens 공개 프로필) · the total in one tile (Strip, tiles face: the label 총점 and the figure "86점 / 100점" in hero numerals) · 16 · one two-line Cell per part, in the rule's order: the label, the points over the part's maximum at the right ("36/40") and the fact behind them on line 2 (several facts are separate nodes with the break) · a labelled Caption row, 합계, with the sum as plain arithmetic ("36 + 23 + 10 + 14 = 83").
Content. The points read the rule's own fraction; a fact about a person's conduct is a count and never a percentage (§4 Trust): 신뢰 reads its score (72점) and 출석 reads "20회 중 19회 참석". A rate of matches may be a percentage: "같은 편 12경기 9승 3패 (75%)". A small sample says so on its Cell: "함께 친 적이 없어요 | 기본 15점" with none, "3경기뿐이라 30%만 반영" for one to nine; a candidate with no attendance record is not punished for it either: "참석 기록이 없어요 | 기본 8점" (the lead's call, 2026-10-20: half of 15, as chemistry's 15 is half of 30; the code scores it 0 today). The Sheet has no door to the rules: a Sheet never opens over a Sheet, so the rules are one tap from the list's head.
Explainer Sheet (the rules). A Sheet titled "… 안내" (추천 점수 안내): a group title ("100점을 이렇게 나눠요") and one two-line Cell per part, the label ink 500 and the maximum at the right ("40점"), the rule in one sentence on line 2; a Caption row for what is left out ("친목 경기와 부전승은 계산에서 빼요"). The Info hint after the list's title opens it (the Score Sheet carries no door to it). The body of 점수 안내 (신뢰 점수) with the points at the right.
Accessibility. The Sheet's title first; the total reads "추천 점수, 이서연, 100점 만점에 86점"; a Cell reads "실력 차이, 40점 만점 중 36, 나 ELO 1,482, 이서연 1,523, 41점 차".
Replaces in code. The expanding bars of PartnerCandidateCard (partner-suggester-screen.tsx, four value/max bars). The Score Sheet is the new PartnerScoreSheet and the Explainer Sheet the new PartnerScoreInfoSheet.
Edit Sheet
Job. Edit one answer in place: opened by a Cell's pencil. An answer may have parts that belong together (a date and its times, a range, an account); they share the Sheet and its one 저장, because the write is one.
Anatomy. A Sheet titled with the field name and × in the title row; the control (an Input, Number field, Choice set or picker, without its own label); a footer with one 저장 (primary lg). 저장 is soft-disabled until the value differs from the saved one. Inline validation sits above 저장 in danger. With the keyboard up the Sheet lifts and 저장 stays visible above it.
Variants. Text · number · choice · picker.
States. Clean (× closes silently) · dirty (×, the scrim, back or a swipe asks the guard Dialog "수정을 그만둘까요?") · saving (저장 loading) · saved (the Sheet closes and a result Toast says "저장했어요").
Content. One answer per sheet; its parts are shown together and never split across Sheets, and two unrelated answers are two Sheets. Examples in 일정 만들기: 일시 is the date with its start and end times (one Sheet, the time band with its Stepper); 반복 is how often and until when (the end appears only once a repeat is picked); 최대 인원 is the cap with the minimum under it (the cap typed, its range the error's text when crossed, the minimum on a Stepper); 참가비 is 무료 | 유료 with the amount under 유료. 실력 and 구성 are two answers, so two Cells and two Sheets. An answer with a part that itself opens a Sheet (a bank Picker Cell) cannot be an Edit Sheet, because a Sheet never opens over a Sheet: it is an edit page with one 저장, opened from the Cell that shows the answer. Example: 클럽 설정's 입금 계좌 is one Cell (the bank and the number as its value) that pushes the page that edits the three together, 은행 being a Picker Cell into a Sheet of banks (the board flat-v2-forms#ks-bank; the payer's account row with its copy icon is 클럽 홈 › 회비's).
Accessibility. As Sheet; the pencil Cell is spoken "{field}, 수정".
Motion. As Sheet.
Built 2026-11-02 (W4 batch 1, additive): EditSheet is packages/ui/src/edit-sheet.tsx (numbers in edit-sheet-visual.ts, held to FvkEditSheet and the forms board's sheet): × in the title, one button (commit: 'save' | 'done'; '적용' is retired), soft until dirty, a dirty ×, scrim or back asks the edit guard, an error line above the button that shows what the caller passes and stays open — the sheet catches nothing. The app's EditControlModal / EditFieldModal are thin wrappers over it (their callers unchanged; the bare catch gone). The first pages on it: 프로필 수정 (L4-11) and 입금 계좌 (L4-21), both done.
Replaces in code. packages/app/src/presentation/components/edit-field-modal.tsx (EditControlModal, EditFieldModal: the 취소 · 저장 pair and the missing ×).
Why: KRDS, Seed and HIG all put a close in the sheet's title row; one footer button keeps the one-CTA law (owner review 1 rulings).
FilterSheet
Job. Narrow a list by facets in one Sheet with the result count live on the button (S-4).
Anatomy. A Sheet titled "필터", built only from the forms' shared field pieces (the owner's page review, 2026-10-28: "in all these filter modal, we need to ensure we use shared components"), so a filter reads as the same field it is in 일정 만들기 and 클럽 만들기. Fields 32 apart, the forms' seam, in one order on every surface:
- 보기, where the page has one (가까운 순 and 저장됨 on 테니스장; 전체 and 저장됨 on 예정): a Choice set.
- Where and when, Picker Cells, full-bleed, one after another. 날짜 opens the shared date picker: a Sheet with the app's presets (오늘, 내일, 이번 주, 이번 달) as a Choice set over the month Calendar, which shows a day as selected and a week or a month as a range, and 적용; it sets the app's
DateSelection(a day, a week or a month, and an anchor). 지역 is one cell over the app'sRegionDistrictFilterpicker (시/도, then 시/군/구, several values), its value "서울 서초구", "서울 강남구 외 1" or "전국". Never a set of date or province chips. - What, a Choice set per facet (36 Choices, the set's small size), plain labels: no count inside a Choice, since the live count is on the button. A facet takes one value where the app's does (every facet but the region and the level on 클럽 탐색, 매치 찾기 and 예정), several where the app's does (기록's).
- How much, band Sliders, each a Field with the slider's tag and its Stepper (WCAG 2.5.7). 수준 is the level band, the one the wizards draw (the app's
TierRangeSelectoroverRangeBandSlider), over the five NTRP bands (2.0–3.0, 3.0–3.5, 3.5–4.0, 4.0–4.5, 4.5 이상): two knobs snapped to the band edges, never empty, the readout "전체", "NTRP 3.0–3.5" or "NTRP 4.5 이상". 참가비 is the same slider over the app's fee stops (무료, 1만, 2만, 3만, 5만, 10만원), the readout "무료–2만원". Never a set of level or fee chips.
The kit draws every kind through FvkFilterSheet's group kinds, which render only the shared pieces (FvkChoiceSet, FvkPickerCell, FvkField with FvkSlider or FvkLevelBand; the date picker is FvkDateFilterSheet), and the wizards draw the same FvkLevelBand, so a board cannot draw a filter any other way. The facets are the app's (a surface draws no facet the app does not have; a new one is the owner's call): 클럽 › 탐색, 종목, 지역, 수준, 요일, 시간대, 가입 방식; 경기 › 매치 찾기 and 예정, 날짜, 종목, 경기 유형, 지역, 수준, 경기 방식, 코트 유형, 참가비 (예정 opens with 보기); 경기 › 테니스장, 보기 (가까운 순, 저장됨), 종류, 지역, 시설; 기록 › 경기 기록, 결과, 종목, 경기 유형, 출처.
The footer is a pair: 초기화 (tonal, one third; soft-disabled when there is nothing to reset) + "결과 n개 보기" (primary, two thirds, the count live). 초기화 returns the facets to the page's defaults (테니스장's 가까운 순, 매치 찾기's saved region): the lead's call of 2026-10-28 after the owner's "we activate 가까운 순 by default"; the app clears everything today and follows when the page is built.
Trigger: the discovery grammar (the owner's page review, 2026-10-27). 필터 ▾ is the one control of the list's first Section head: 필터 at rest, 필터 N in the selected look (ink fill) while N are on. The filters that are on show as one row of removable Choices directly under that head, one per active value in the Sheet's group order (the region is one Choice, "서울 강남구 외 1"; the level band is one Choice, "NTRP 3.0–3.5"), and only while one is on: with none on the row is not drawn. A filter that is on by default (가까운 순 on 테니스장 once the location is granted; the member's saved region on 매치 찾기) is on like any other: it counts in N and shows in the row, removable. Search is the Header's icon at the top right (§1.14), never an inline field. Sort is not a second control: a sort with a single alternative (가까운 순) is a filter in the 보기 group; a sort with several is its own Choice and value-picker Sheet, placed before 필터.
Variants. One.
States. No filter · filters active (defaults included) · zero results ("결과 0개 보기" stays pressable and shows the filtered-empty EmptyState; the row under the head stays).
Content. Group titles are nouns (종류, 코트 유형, 시설).
Accessibility. Each group is a labelled group of toggle buttons (aria-pressed on web, aria-selected on native) or radios; the live count is the button's label.
Motion. As Sheet.
Replaces in code. packages/app/src/presentation/components/expandable-filter-bar.tsx (the inline expandable panel) with its DropdownChip / RangeChip contents (packages/features/clubs/src/shared/discover-filters.tsx), and match-history-scope-sheet.tsx.
Document page
Job. A legal or long document read at length (S-5): 이용약관, 개인정보 처리방침.
Anatomy. A pushed page (owner review 48: Kakao, 당근, Toss; 개인정보 보호법 제30조): the pushed Header (back, the document name) · one line with the 시행일 ("2026년 9월 1일 시행") and the Door pill 이전 버전, which opens a Sheet of the earlier versions (each opens that text under a Notice saying it is not the current one; absent with one version) · for the privacy policy a 요약 of six Cells (수집 항목, 목적, 보관 기간, 제3자 제공과 위탁, 정보주체의 권리, 개인정보 보호책임자) · a 목차 head (an ExpandToggle, closed by default) whose rows jump to each article · the numbered articles: headings 17/24·600 ink, paragraphs 16/24·400 ink, tables as label / value Cell pairs. It scrolls; there is no CTA band. Reading never ticks a consent. The legal text is counsel's, verbatim. The detail board composes it (DtlDocPage) from kit pieces until FvkDocPage takes a lead slot for the line, the summary and the contents.
Variants. Terms · privacy · any versioned policy · a consent's text (the 보기 door of 동의 관리 and of the sign-up Consent group): a page of one article whose body is the shipped consent sentence, verbatim, until counsel supplies more.
States. Loading (page skeleton) · loaded · offline (the Offline strip over cached text).
Content. Headings are the document's own.
Accessibility. The document name is the h1, articles are h2; reading order is document order.
Motion. The platform push; none inside.
Replaces in code. The document Sheet; packages/features/profile/src/components/policy-document-page.tsx is the renderer (terms-screen.tsx, privacy-screen.tsx).
Why: lengthy content does not belong in a bottom sheet (NN/g; HIG); the "보기 ›" in the Consent group pushes this page.
Toast
Job. Report the result of what the person just did, or offer to undo it (S-7). Results and undo only. Never an error, never a validation message, and never the only copy of something the person will need later.
Anatomy. Inverse surface (ink in light; in dark a light surface #DFE4DC with ink text) · r12 · min 48 · pad 12 16 · text 16/24 onInverse (two lines at 100%; it wraps up to four at large text, never truncated) · no glyph · no shadow. An optional action at the right (the one text action that is a command: the snackbar convention, IA8; kind="command" is legal here and nowhere else): a real verb, 15/22·600 in ball on the ink surface (ink on the dark theme's light surface; under the text from 1.5× text). A toast with an action also carries a × (24 glyph in a 48 target, tone onInverse). 16 above the tab bar, the CTA band or the keyboard, whichever is tallest, and 16 above a FAB when one is on screen (offsets from the screen bottom at inset 34: 114 over a tab bar, 118 over a CTA band, 186 over a FAB on a tab screen); centred on the content width.
Variants. Result · with an action (되돌리기 or 보기).
States. Entering · held · dragging · leaving. One at a time: a new toast replaces the old.
Timing. A result stays 4 s; a toast with an action stays 8 s. On Android the larger of that and AccessibilityInfo.getRecommendedTimeoutMillis. The timer pauses while the toast is touched and while a screen reader's focus is on it. A tap on a toast without an action dismisses it; a swipe down (past 40 pt or 500 pt/s) dismisses any toast.
Content. State or consequence first, then one verb: "참석을 취소했어요" + 되돌리기. One sentence, about 20 syllables a line (15 beside an action); a toast that needs more is a Notice. 닫기 is a dismissal, not an action. English: "Attendance cancelled" + "Undo". More: "알림을 삭제했어요", "알림 3개를 삭제했어요" and "일정 푸시 알림을 껐어요", each with 되돌리기 (English "Notification deleted", "Schedule push alerts are off"); a result with no action, "요청을 보냈어요" (4 s).
Accessibility. A live region without focus: on iOS an explicit announcement (queued), on Android aria-live="polite", on web role="status"; the message is read, then the action label; the action is its own stop and takes no focus on insertion.
Motion. Enter 250 ms (reveal: a 12 rise and a fade), leave a 100 ms fade; a drag follows the finger and releases on a spring. Reduced motion: a 150 ms fade in and a 100 ms fade out, no rise; the drag is unchanged.
Replaces in code. packages/app/src/presentation/components/toast-host.tsx and packages/app/src/presentation/stores/toast.store.ts (a shadowed pill with a status glyph). createMutationHook (packages/app/src/presentation/hooks/create-mutation-hook.ts, onError) gets an errorSurface option ('helper', 'notice', 'dialog') in place of its single error toast.
Why: a toast is unsuitable for errors because people miss it (NN/g); WCAG 2.2.1; owner review 1 point 20.
EmptyState
Job. Nothing here, a failure, no network, a wall, or a missing record: one calm, centred explanation and, only when one exists, one way forward.
Anatomy. Centred in the content area · 64 from the top · a disc 80 gray100 with a 32 glyph in $textSecondary · 16 · title 17/24·600 ink · 8 · body 16/24 $textSecondary (two lines at most) · 24 · one md Button (tonal; primary when the empty state is the screen's one action). Compact placement inside a section: 24 from the top, disc 64. No card, no illustration, no mint.
Variants.
- First-run: the state in the title ("아직 일정이 없어요"), the next step in the body; the Button is the creating action.
- Filtered-empty: the filters hide everything; the Button (tonal) clears them and is named for where it goes: 전체 보기 when one value filters the list (알림's filter), 필터 초기화 when several filters are set.
- Permission: the page is real but this viewer may not see it. A bare 48 lock glyph in secondary → 16 → title ("로그인이 필요해요") → 8 → body (one line naming what signing in unlocks) → 24 → the Button, drawn only when an action resolves the wall (로그인). A role wall the viewer cannot resolve has no Button; the header back is the way out. Never on a hub.
- Error: an alert glyph; the title says what failed in 해요체 ("일정을 불러오지 못했어요": the object that failed, then 을 or 를 by its last syllable's 받침), the body gives the next step ("연결을 확인하고 다시 시도해 주세요"), one 다시 시도. Never a title like "오류". English: "Couldn’t load sessions", "Check your connection and try again", "Retry".
- Offline: a wifi-off glyph; "오프라인이에요" / "연결되면 자동으로 다시 불러와요" + 다시 시도 (English "You’re offline" / "We’ll load it once you’re back online" / "Retry"). Shown only when nothing is cached; with cached rows the Offline strip appears instead. Offline trumps error.
- Not found: a record that does not exist or may not be seen; the title says so and the body names the way out.
- Full screen: a blocking gate (the version gate) owns the whole screen with no header: the brand lockup slot, title, body and one primary Button when an action exists (업데이트).
States. None of its own beyond the Button's. 200% text: the block grows and the Button wraps.
Content. The title says what the state is, in 해요체, on one or two lines; the body is one or two lines; the copy gives the cause or the next step, never an apology first. The Error and Offline lines are the kit's, written once: a page passes the kind and, for an error, the object that failed (what: 일정, 전적, 참가비 내역), and never types them, so one wording serves every screen; one kit helper chooses the particle. A page may still pass its own title, body or action where its failure is a different one (the identity check's body "잠시 후 다시 시도해 주세요"; a search's title "검색하지 못했어요", since a search is not a read; 대진표's offline body, whose next step is paper: "종이에 점수를 적어 두면 연결될 때 입력할 수 있어요"), and no Button where its CTA band carries the retry. The Offline strip draws its title and action from the same lines. Without an object an error says only "불러오지 못했어요" ("Couldn’t load this").
Accessibility. One unit for a screen reader: the glyph hidden, the title (h2; h1 on a full-screen gate), the body, then the Button.
Motion. A 150 ms cross-fade from the skeleton; reduced motion: instant.
Replaces in code. packages/ui/src/empty-state.tsx, extended in place (K3a): given a kind it draws this part (gray100 disc, tonal action, the Error and Offline copy through the UI-strings registry); the file is now empty-state-kind.tsx (retired 2026-11-02, K3b part 2B: empty-state.tsx and the legacy mint disc with variant, icon, subtitle and actionLabel are deleted; every caller gives a kind). Then query-error-state.tsx, offline-empty-state.tsx, not-found-state.tsx and update-required-screen.tsx become kinds (error, offline, notfound, full).
Skeleton
Job. The loading shape of each surface at its exact pitch, so nothing jumps when data arrives.
Anatomy. Shapes, all gray100: media row (56 square r8 + a 17/24 bar at 60% + a 15/22 bar at 40%) · person row (40 circle + two bars) · Cell (label 40% + value 20%) · Block 144 r16 (bars 40% / 24% / 60% for eyebrow, figure, meta) · Strip 80 · Chart 160 · hero (a 24/32 bar at 70% + three 17/24 bars) · Field (70). Bars take the height of the type's cap line. The pulse is one shared value per screen: opacity .6 ↔ .9 over an 800 ms half-cycle.
Variants. One shape per surface.
States. The timing rule: a skeleton appears only after 300 ms of loading; once shown it stays at least 500 ms; cached data is shown instead of a skeleton; past 10 s it gives way to a determinate indicator with an estimate where the work reports progress (uploads), and otherwise to the slow-load Notice with 다시 시도. The pulse stops after 5 s at the static frame.
Content. None.
Accessibility. The container carries aria-busy and one label ("불러오는 중"); the shapes are hidden; nothing is announced.
Motion. Skeleton to content is a 150 ms cross-fade; reduced motion: static .75 and an instant swap.
Replaces in code. packages/ui/src/skeleton.tsx (Skeleton, SkeletonCard, SkeletonHero), skeleton-flat-page.tsx, shimmer.ts.
Offline strip
Job. Connectivity is lost while content is on screen: a quiet notice, not an error. It is the screen's only offline message.
Anatomy. A neutral one-line Notice, 350 × 48 (min; it grows with text), r12: a 20 wifi-off glyph in secondary + 12 + the title "오프라인이에요" 15/22·600 ink + 다시 시도 as a ghost sm Button at the right (a command is a Button, IA8).
Place (owner review 49). It is app chrome with one place, the frame's slot: under the status bar, above the whole screen (the Header, a MediaBand, a wizard), 8 above and below on the canvas. It pushes the screen down by its height. A page never places it; it only asks for it, and the frame draws it (the kit's FvkPhone slot), as the app mounts NetworkBanner once in its root layout above every screen (apps/mobile/app/_layout.tsx).
Variants. One. The plain-page, over-a-MediaBand and no-header variants retired, because they drew the same strip in three places: under the Tabs on 매치 찾기, above them on 테니스장, and on the header's edge on 클럽 홈 (the owner, 2026-10-29: "these alert bars are positioned in different places").
States. Absent · present. It appears the moment the device reports no route and goes the moment one returns; 다시 시도 forces a retry.
Content. Lists keep their cached rows; the empty variant of EmptyState appears only with nothing cached.
Accessibility. role="status", announced once ("오프라인이에요"); the action is its own stop.
Motion. It leaves as a Notice dismisses (opacity, then a 150 ms height collapse); reduced motion: instant.
Replaces in code. packages/app/src/presentation/components/network-banner.tsx (NetworkBanner).
CountdownNotice
Job. The clock on a held seat: how long the transfer has, and what happens at zero.
Anatomy. A Notice with trailing: a warning Notice (fill #F5B301, ink text, r12, pad 12 16): a 20 clock glyph + 12 · title 입금 대기 중 15/22·600 with the countdown 09:41 남음 15/22·600 tabular as the Notice's trailing node at the title's end · body 16/24 = the account line (70, text-driven). At zero it becomes danger ($error fill, white text, the alert glyph): "시간이 지나 자리가 풀렸어요", with the action 다시 신청 under the text (min 48).
Variants. Running · expired.
States. Ticks every second (the digits do not animate); expired.
Content. The account line is the next step; no second action while running.
Accessibility. The spoken name carries the time at the moment of focus ("입금 대기 중, 9분 41초 남음"); live announcements only at 5:00 and 1:00 (polite). The payment hold is an essential time limit, a server deadline.
Motion. None; the expiry swaps instantly.
Replaces in code. The payment-hold banner built on useLiveCountdown (packages/app/src/presentation/hooks/use-live-countdown.ts) in the session detail fee flow.
Spinner
Job. Work in progress inside a control: the only place a spinner appears.
Anatomy. The platform ActivityIndicator, 20, ink (white on a danger Button); inside a Button beside its progressive label, or in place of a Cell's chevron while its action runs (sibling Cells inert at .5, spoken "처리 중"). It never stands alone on a list or a screen (that is a Skeleton). Pull-to-refresh uses the native control with an ink tint, shown only for a person's own pull.
Variants. In a Button · in a busy Cell · pull-to-refresh.
States. Turning.
Content. None.
Accessibility. The control carries aria-busy; the spinner itself is hidden.
Motion. The platform's rotation; it continues under reduced motion.
Replaces in code. Raw ActivityIndicator uses (eight files).
9. Conversation and social
PostRow
Job. One post of the club feed: who wrote it, what they wrote, what they showed, and how the club reacted.
Anatomy. Top to bottom, on the rail:
- Author line, min 48: avatar 40 + 12 · name 15/22·600 ink (wraps, never cut) · time 13/18·500 secondary · ⋯ IconButton (20 glyph in a 48 target, its glyph's right edge on the rail). The author opens the profile.
- 12 → body 16/24·400 ink, a 4-line clamp with 더보기 15/22·600 secondary inline on the fourth line (it opens the post).
- 12 → photos: one = 350 × 262 r12 (4:3); two = two 171 squares r8, gap 8; three or more = two squares with "+N" 20/28·700 white on the on-photo fill (ink α0.6) over the second.
- 12 → action row, min 48: heart 20 + the total reaction count 13/18·600, comment 20 + count, gap 16.
- Posts sit 32 apart with no line: the author line opens each post.
The feed heart carries the total and there are no chips in the feed. The post page shows the per-emoji chips under the body (Reactions) and its heart carries no number.
Variants.
- Feed (above). Pinned: a neutral Tag "공지" trailing the author line. Deleted: "삭제된 게시물" 15/22 secondary.
- System (경기 결과, 사진 업로드): the author line is the club (logo 40 r12) with a 13/18 secondary line naming the event instead of a time; the body is the result (ResultRow) or the PhotoGrid preview (three tiles, +N on the last); no ⋯; the action row is unchanged.
States. Rest · pressed (the body .98 / .80, opens the post) · the viewer has reacted (the heart filled, in ink) · loading (a skeleton: avatar, two bars, a 350 × 262 block) · 200% text (the author line wraps; the action row stays on one line).
Content. Names are never cut. English: "Show more".
Accessibility. Stops in order: the author ("김민준, 프로필"), ⋯ ("김민준 게시글 메뉴"), the post (body and photos: "게시글, 김민준, 3시간 전, {the first words}, 사진 3장"), the heart ("공감 12개", aria-pressed), the comment control ("댓글 3개"). The heart's tap toggles ❤️; its long-press (on the post page, its tap) opens the MenuSheet with the Reactions quick row, and an accessibility action offers the same.
Motion. Press settle; the heart fills over 100 ms; reduced motion: dim only.
Built 2026-11-02 (K7, additive): PostRow is packages/ui/src/post-row.tsx + post-row-parts.tsx (numbers in post-row-visual.ts, held to FvkPostRow): kinds feed, pinned, deleted, system and loading; the 4-line clamp with 더보기 (onTextLayout, to be checked on Android in W5); photos 1 / 2 / 2 + "+N"; the ⋯ only with a handler; counts shown at zero as the kit does; no page mode, chips, share, category or session chip (the kit has none). The deleted line reads 게시글 (house style). Post has no deleted state, so that kind is wired nowhere. PostFeedRow converts in W4 (retired by no-legacy-surfaces); its categoryLabel, sessionLabel and onPressShare have no slot.
Replaces in code. packages/features/clubs/src/shared/post-row.tsx (PostFeedRow, FeedPhotoStrip) and the feed auto-rows.
Comment
Job. A reply under a post, with one level of replies beneath it.
Anatomy. Avatar 32 at the rail; the text column starts at x 64. Line 1: name 15/22·600 + the MetaBreak + time 13/18·500 secondary, with a ⋯ IconButton (20 glyph in a 48 target) at the right. Body 16/24·400 ink in the text column: full on the post page, 4 lines + 더보기 in previews. Under the body 답글 달기 as a ghost sm Button (a command is a Button, IA8; the 더 보기 and 답글 n개 더 보기 disclosures stay text, since they expand in place). Replies: one level; avatar 24 at x 64, text column at x 96; a deeper reply flattens with an @name; more than two replies collapse behind a lone ExpandToggle aligned to the text column ("답글 3개 더 보기"). 24 between comments, 12 between a comment and its replies.
Variants. Comment · reply · deleted ("삭제된 댓글입니다" in secondary; it keeps its replies).
States. Rest · pressed (the avatar and name open the profile) · sending (.6).
Content. The body indents to the name because short, threaded text belongs to its author; PostRow keeps its full-width body for long content and photos. Names never cut.
Accessibility. One stop for the comment ("김민준, 2시간 전, {body}"); ⋯ and 답글 달기 are their own stops; replies follow in document order.
Motion. The ExpandToggle reveals over 250 ms (reveal) and its chevron turns over 200 ms; reduced motion: instant.
Replaces in code. packages/features/clubs/src/post-detail/comment-row.tsx (CommentRow).
MessageBubble
Job. One message in a conversation. Bubbles stack into runs; the lines between them carry the date and system events.
Anatomy.
- Bubble: pad 8 12 · r18, the sender-side corner 6 on the last bubble of a run · text 16/24 (the reading role, the same size as the composer's input). Mine: ink
#15181Afill (dark#3A413C, 1.4:1 from the other side's gray100), white text, right-aligned. Theirs: gray100 fill, ink text, left-aligned. Max width 306 in a 1:1 thread, 266 in a group. - Group sender: the first bubble of another sender's run has the sender label (13/18·600
$textSecondary, 18 tall) 4 above the bubble and an avatar 32, its top aligned with the label's top, 8 before the bubble. A 1:1 thread shows neither. - Spacing: 2 within a run (same sender, same minute); 12 between runs; a new sender in a group is 12 above the label (34 from the previous bubble to this one); the first item is 12 below the header; 12 from the last bubble to the composer's top edge.
- Time: 12/18·500 secondary, 4 from the bubble's edge, bottom-aligned with the last line, on the last bubble of a run only.
- Date and system lines ("9월 18일 (금)", "OOO님이 입장했어요"): centred 13/18·500 secondary, 20 above and below, never a bubble.
Variants. Mine · theirs · theirs in a group (label + avatar) · system line.
States.
- Sending: a 12 clock glyph before the time; the bubble at .6.
- Queued (offline): "보내는 중" under the bubble; the composer stays enabled.
- Failed: "전송 실패" 13/18·600
$errorText, then 다시 보내기 and 삭제 as two ghost sm Buttons (IA8), right-aligned 4 under the bubble with the last label's edge on the rail; 삭제 removes the unsent message at once. - Delivered (the last sent bubble of a 1:1 thread): "읽음" or "전송됨" 12/18·500 secondary, right-aligned 4 under it; none in a group.
- Reactions: summary chips under the bubble.
- Away from the bottom: "새 메시지 3개" as a tonal sm pill (36) centred 12 above the composer, shown when the thread is more than a screen from the bottom; a tap scrolls down.
Content. Names never cut; links are linkified.
Accessibility. One stop per bubble: "김민준, 오후 2시 5분, {text}" (mine: "나, …"); sending and failed are in the name ("전송 실패"); the long-press has an accessibility action that opens the MenuSheet; the new-message pill is announced.
Motion. A new bubble fades in with an 8 rise over 150 ms (reduced motion: the fade only); on focus the thread scrolls to the newest message.
Replaces in code. packages/features/messaging/src/dm-thread/dm-message-row.tsx and the spacing literals in use-dm-chat-items.ts, dm-thread-screen.tsx (one constants file holds the thread values).
Composer
Job. The bar that writes the next message or comment.
Anatomy. The bar is min 56 (8 + the field 40 + 8) plus the home inset only while the keyboard is hidden; with the keyboard up it sits flush on it (gap 0, inset 0). On the rail, left to right: attach IconButton (24 glyph at the rail, 48 target bleeding 12 into the margin; it opens a MenuSheet with the labelled rows 사진 · 카메라) · 8 · field gray100, r20, min 40, 16/24, pad 8 16, growing to five lines (136) and then scrolling · 8 · send, a 36 circle with a 20 arrow in a 48 target, its right edge on the rail. An edge hairline draws on top while content scrolls under the bar. The thread's bottom inset is the composer height + 12.
Reply-target row: a 44 row inside the composer above the field, 8 above it: "OOO님에게 답글" 13/18·600 secondary, then a one-line excerpt 13/18·400 secondary that ellipsizes (quoted text, not a name, so it may crop), and a × IconButton (20 glyph in a 48 target) that clears the target. It appears with the field's focus over 250 ms.
Variants. Message composer (attach + field + send) · comment composer on the post page (field + send, with the reply-target row).
States. Empty (the send circle is gray100 with a $textSecondary arrow and inert) · has text (ball circle, ink arrow) · sending · offline (stays enabled; messages queue).
Content. The excerpt is quoted text.
Keyboard. One rule for both composers (the field is multi-line): on the web Enter sends and Shift+Enter adds a line (an Enter that confirms a Korean syllable in the IME is not a send); on a phone the keyboard's return adds a line and the send button sends.
Accessibility. The field is labelled ("메시지", "댓글"); attach is "첨부"; send is "보내기"; the reply-target row is read when it appears; × is "답글 취소".
Motion. The bar rides the keyboard (KeyboardStickyView); the reply-target row reveals over 250 ms; reduced motion: instant.
Replaces in code. packages/features/messaging/src/dm-thread/compose-bar.tsx, packages/features/clubs/src/post-detail/comment-composer.tsx.
Reactions
Job. React to a message, a post or a match result with one of six, and see who did. One component in two parts: a quick row to react, a chip summary to see and toggle.
Anatomy. The set, the same on every surface, in this order: 👍 ❤️ 👏 🔥 😂 🎾 (approval, affection, applause, intensity, humour, the sport). Emoji are content here, not fact glyphs.
- Quick row: at the top of the object's MenuSheet, between the title row and the first group. The six sit in 44 gray100 circles (emoji 24), gap 16, hit area 48 by slop. The viewer's current reaction has a 2px ink ring; a tap toggles it and closes the sheet. The sheet's title row shows the sender or author and a one-line excerpt of the message or post it acts on (13/18 secondary, may crop).
- Summary: under the bubble or the post body: chips 32 tall, r full, gray100, pad 0 10, emoji 16 + count 13/18·600 tabular, gap 4 inside, 8 between chips in a row and 12 between wrapped rows (so the 44 targets do not meet). The viewer's own has a 1.5px ink ring. A tap toggles that reaction; a long-press lists who reacted in a Sheet of PersonRows.
- Feed: the action row's heart carries the total and there are no chips; the heart's tap toggles ❤️.
Variants. Quick row · summary chips.
States. Not selected · selected (ring) · a count of 0 draws no chip · many chips wrap.
Content. Counts are numerals, 99+ capped. Spoken names: 좋아요, 하트, 박수, 불꽃, 웃음, 테니스공.
Accessibility. Each quick-row circle is a button with aria-pressed; each chip is a button ("좋아요 3명, 내가 반응함", aria-pressed); an accessibility action "누가 반응했나요" opens the list; the chips follow the message in document order.
Motion. Ring and fill change over 100 ms; counts change instantly; reduced motion: instant.
Replaces in code. Three vocabularies become one domain registry: REACTION_TYPES and REACTION_EMOJI (packages/app/src/domain/entities/reaction.entity.ts, the match reactions), the local map in post-row.tsx (like, clap, fire, heart, laugh) and QUICK_REACTIONS in packages/features/messaging/src/dm-thread/helpers.ts; the database enums migrate with the registry; the ReactionPickerModal in post-row.tsx.
10. Scoring
Scoring runs on the honor system (owner review 5, point 4; evidence in lane G). Any player of the match, the host or an official enters the score, and an entered score is settled and rated; nobody confirms it. A player may ask for a review until seven days after the session; the host or an official who is not playing decides. Nothing is counted live: no running score is drawn anywhere. Every surface draws a match with Team lines (§5). The first six entries define the machine every surface draws (states, transitions, races, who may do what, policy, pushes); the rest are the surfaces: two sheets, ScoreEntry in three modes and the Host review sheet.
Result states
Job. The states a match result passes through: one machine for every surface. The Tag of a state is the one map of Match row states.
Rules the machine holds.
- Entered = settled = rated. A score is an assertion by someone who played; nothing waits on a second person.
- One writer per match; the first commit wins. Every write takes the match's write lock and carries
expected_revision(thematches.score_revisionthe writer last saw; 0 for a new score) and an idempotency key. A stale revision returns the typed refusalscore_changedwith the stored score and writes nothing. No write overwrites a stored score except the host's or an official's explicit change, which carries the revision it replaces. A sheet shows a refusal as a Notice inside itself and refreshes; never a toast, never a silent overwrite. - An appeal asks; the host decides. An appeal never changes a score and never holds the gate.
- Nobody judges their own match. A host who plays is a player there; with no other official who is not playing, the score stands and the appeal stays on the record.
- Tell each person once, in their role, and only when it needs them.
- ELO moves at entry (after the 8 s undo) and is replayed by a host change, a left-out match or a correction of a stop outcome; it is reverted only by H5.
- In a recorded session, the last score ends the session (owner review 27). A player's session ends when their last match is settled; the session closes only when every match is (A14). Time never closes a recorded session and no timer voids a match: past its end time the session reads 점수 입력 대기 and names the owed matches. The ways out are explicit, never silence: a score, or 경기 중단 (기권, 부전승, 취소됨, 일시 중단 then 미완료) by a player, the host or an official. Any player of the match may enter a missing score at any time. Live mode (owner review 28): from check-in (or their first match starting, whichever is first) a player is held in the session's live page until their last match is settled: the tab bar hides, back does not leave, a cold start returns there, the session's chat is reachable inside and other pushes wait. Leaving early is itself a settlement (기권 on their remaining matches, or the host takes them out of the draw). So no score is ever owed outside the session: the review-27 backstop (참석 held while a score is owed) is retired, and the 할 일 Cell 점수 입력 retires when live mode ships (until then it is the safety net; its data is
scoresToEnterCount, recorded sessions only). A player who RSVPed but never checked in is not held. The host's 일정 종료 waits for every match to be settled: matches that never started can be cancelled in the same confirm (취소하고 종료, named and counted); a started match needs a score (complete_sessionrefuses withmatches_unsettled).
Recorded or casual (owner review 27). A session is recorded (점수 기록) or casual (기록 안 함), chosen by the host when creating it and shown in the session's facts before anyone joins; a player who does not want to be scored joins a casual session. A casual session asks for no score, rates nothing and has no gate: matches end by the round or the timer, and the scorecard shows the draw only. Score-driven draws (King of the court, Mexicano, Americano) need scores, so they are recorded only. It is the recording axis of GenerationPolicy (recorded | casual, today derived from isFriendly): a friendly session becomes casual. The choice is per session, not per player, because a player opting out inside a doubles match would leave the other three unrated.
| ID | State (ko · en) | matches.status and outcome | Rating | Tag | Entered by |
|---|---|---|---|---|---|
| H0 | 예정 · Scheduled | scheduled | none | 예정 | the draw |
| H1 | 경기 중 · Playing | in_progress | none | Live | 시작 (gated: each player's earlier matches are H3, H5 or a stop outcome) |
| H2 | 점수 입력 전 · Awaiting | awaiting_result | none | none | the host ends the round, the next round starts, or the rotation timer |
| H3 | 입력됨 · Scored | completed; outcome decisive, retired, walkover, defaulted | applied | none (stop outcomes keep their Tag) | any player enters it; a host or official enters or changes it |
| H4 | 검토 중 · Under review | as H3, plus one open review | stands | 검토 중 (warning weak) | a player appeals |
| H5 | 기록 제외 · Not counted | completed, outcome = non_competitive | none (reverted) | 기록 제외 | the host or an official; 30 days with no result |
| H6 | 일시 중단 · Suspended | in_progress, outcome = suspended | not yet | 일시 중단 (warning) | a stop with a score so far; it releases the gate for the others |
A host-set result is no longer a state: it is the attribution line the scorecard already draws. The plain H3 row shows no Tag; only an open review and a left-out match wear one (a stop outcome keeps its own).
Settled, and the gate (owner review 4, D4, read as H3). A match can start only when each of its players has every earlier match in the session settled: H3, H5, an accepted stop outcome, or H6 for the others. Not settled: H1 and H2. The gate is per player (not per court, not per round), derived and never stored (the same discipline as deriveRecruitmentStatus): a pure deriveMatchStartGate(match, sessionMatches) returns open, or closed with blockedBy and the players; the server enforces the same predicate in A0 (private.match_start_blockers(match_id), refusal previous_unsettled) and a parity test plus a canary fixture pin the two. An open review never holds the gate (lane G §4.2: one player must not be able to freeze a session; the trade-off is that a wrong score can feed one more pairing while the review is open, bounded by the host being told at once and a host change replaying ratings). The "an open review holds only the appellant's own next match" alternative is one guard in deriveMatchStartGate if the owner wants the harder rule. The host's ways out are 대신 입력, a recorded stop and 기록 제외; there is no "start anyway". With a score-driven style, the draw of round N+1 needs every court of round N: generate_session_round applies the same predicate to all players and refuses with previous_round_unsettled instead of reading a missing score as 0-0.
Stop outcomes (D5). One outcome model with two independent axes, counts in the record and is rated; the numbers are config (MATCH_OUTCOME_POLICY, Result policy below).
Outcome (matches.outcome) | Tag (ko · en) | Winner | Score as written | Record | Rated |
|---|---|---|---|---|---|
retired | 기권 · Retired | the side that did not retire | 6-4 3-1 기권 · 6-4 3-1 ret. | counts | at the house weight (half) if either side has retiredMinGames (4) or more games, on the score as played; else not |
walkover | 부전승 · Walkover | the side that came | 부전승 · W/O, no numerals | counts (configurable) | never |
defaulted | 실격 · Default | the other side | as retired if play started, else as walkover | counts | as retired if play started, else never |
suspended | 일시 중단 · Suspended | none yet | numerals so far, no winner | not yet | not yet |
abandoned | 미완료 · Unfinished | none | numerals so far | does not count | no |
cancelled (status) | 취소됨 · Cancelled | none | none | does not count | no |
non_competitive | 기록 제외 · Not counted | none | as ratified | does not count | no |
Retirement (owner's mid-review question). It counts as a win for the side that stayed, in the record. It is rated only once either side has won 4 games (retiredMinGames; UTR's published line), on the score as played, with nothing added for the games not played (no extrapolation). The half weight is our own house value, not UTR's (UTR documents none; the shipped incomplete branch of the rating engine uses actual 0.6 / 0.4 at weight 0.5): it limits the swing when a player retires while ahead, and it is one config value (set it to 1.0 for UTR's literal rule; no code changes). Any player of the match may record 기권 (the retirer usually says so himself); a false retirement is an appeal like any other; 실격 stays host or official only because it is a ruling, not a statement. The schema adds retired and defaulted to matches.outcome, a required winner_side for retired, walkover and defaulted (a retirement's winner is not the one who is ahead), a stopped_reason key and a resume_of link; the partial score stays in the set rows. Words in a person's history are viewer-relative: 기권승 / 기권패, 부전승 / 부전패, 실격승 / 실격패 (the loser forms are derived, not found in a rulebook). Winner emphasis applies to retired, walkover and defaulted, never to suspended, abandoned, cancelled or 기록 제외. Who records: retired, any player of the match, the host or an official; walkover, a player, the host or an official; defaulted, the host or an official only; suspended, abandoned and cancelled, a player, the host or an official. All are settled at once.
Transitions
Job. The transitions between the states, A0 to A13 (34 became 14). A2, A5 to A7, A10 and A13 also release the gate and tell the held players (P5).
| id | Event | Guard | Effect |
|---|---|---|---|
| A0 | H0 → H1: start (시작할 수 있는 경기 시작) | the gate is open for all four players (G1: deriveMatchStartGate); a host or official; no override | the ready matches start and the held ones are listed with the reason; no match is ever started past a closed gate |
| A1 | H1 → H2: the match ends | 라운드 종료, the next round, or the rotation timer | P1 to the four |
| A2 | H2 → H3: enter 점수 저장 | a player of the match, a host or an official; expected_revision 0; valid sets; idempotency key | revision 1; verified_by the entrant; ratings after the 8 s window; the gate opens; P2 to the other three; Toast 저장했어요 · 되돌리기 |
| A3 | H3 → H2: 되돌리기 | the entrant, within 8 s, no review open, no later match started | the score is withdrawn before rating; the log keeps it |
| A4 | H3 → H4: appeal 검토 요청 | a player of the match, until the session date + appealDays; expected_revision equal to the stored one; an optional proposed score; one open review per match | P3 to the host and the officials not playing; the row shows 검토 중; ratings and the gate are untouched |
| A5 | H4 → H3: 그대로 유지 | a host or official who does not play | review closed; P4 to the appellant |
| A6 | H3 or H4 → H3: change (이 점수로 바꾸기, 점수 고치기) | as A5, or an official who is not a player; expected_revision carried | revision + 1; ratings replayed; P4 to the four |
| A7 | H3 or H4 → H5: 기록 제외 | as A5 | ratings reverted by replay; P4 to the four; the gate stays open |
| A8 | H4 → H3: the appeal is withdrawn | the appellant | review closed; nobody is told |
| A9 | H4 → H3: stands | appealStandDays with no decision | review closed as kept; P4 to the appellant. With no official who is not playing, the score stands and the appeal stays on the record |
| A10 | H1 or H2 → H3 (stop outcome): 경기 중단 | a player, the host or an official records 기권, 부전승 or 취소됨; 실격 is host or official only; a winning side is required | as A2; walkover and cancelled are not rated; 기권 is rated per retiredMinGames |
| A11 | H1 → H6: suspend, 일시 중단 | the host, an official or a player | the score so far and a resume plan; the gate opens for the others |
| A12 | H6 → H1: resume, or H6 → 미완료 (7 days, or 여기서 끝낼게요) | the host or an official, at the next session or the planned day | continue from the stored score; 미완료 is not counted or rated |
| A13 | H2 → H5: exclude (not played) | a host or official; no timer (owner review 27: a recorded match never times out) | P4 to the four; no rating. A reopen (H5 → H2) is the same host action |
| A14 | the session closes (경기 종료) | every match settled (H3, H5 or a stop outcome), derived and never stored (deriveSessionCloseGate, like deriveRecruitmentStatus); the host's 경기 종료 waits for it | the session reads 종료. Until then the end-time job still ends play (status stays the truth about playing, canon), but the session reads 점수 입력 대기, not 종료, with the owed matches and their door |
Races
Job. Every conflict resolves by the one lock per match: the first commit wins, a later write gets the typed refusal and its sheet re-renders. 19 races became 5; the confirm, counter, contest and timer races (R1 to R13) are gone with those steps, and the gate's own guards (two hosts start at once, a start racing a write, a substitute, offline gate opening) stay in the gate's spec.
| # | Race | Resolution |
|---|---|---|
| Q1 | Two players enter at once | First commit wins. The second gets score_changed; its sheet turns into the appeal sheet, showing the stored score and keeping the digits it typed as the proposed score, so one tap on 검토 요청 sends it. The same sets twice are a no-op: the Toast 이미 입력됐어요 and nothing to appeal. |
| Q2 | Entering while someone else is editing | There is no edit state. Any write between the sheet opening and the send bumps score_revision, so the send is refused as in Q1. Nothing is overwritten, the host included. |
| Q3 | Entering after the host has entered | The host's entry is revision 1 like any other. A player who opens ScoreEntry after it lands sees the score, not an empty keypad (the door is gone), and may only appeal. |
| Q4 | An appeal racing a host change | The appeal carries the revision it was written against. If the host changed the score first, it gets score_changed and the sheet shows the new score under the Notice 방금 점수가 바뀌었어요; the appellant may ask again. If the appeal lands first, the host's change closes it. Two appeals on one revision merge into one open review. |
| Q5 | Offline entry, or an entry that arrives late | The client queues the write with its idempotency key and the revision it saw; on replay Q1 to Q4 apply. A refused entry is never dropped silently: the row reads 점수가 이미 있어요 and the unsent digits wait in the sheet as a proposed score, one tap away. |
The entrant's 8 s 되돌리기 withdraws the score only while nobody has appealed and no later match has started; a start reads the settled state under the same lock (lock_match_score_write), so a start never sees a half-written entry.
Who may do what
| Viewer | H2 | H3 | H4 | H5 |
|---|---|---|---|---|
| A player of the match | 점수 입력 | 검토 요청 (the entrant also 되돌리기 for 8 s) | read; withdraw their own | read |
| Host or official, not playing | 점수 입력, 기록 제외 | 점수 고치기, 기록 제외 | 그대로 유지, 바꾸기, 기록 제외 | 다시 기록에 포함 |
| Host who plays in the match | as a player | as a player | as a player (another official decides, else it stands) | as a player |
| Anyone else | 점수 입력 전 (text) | read | read | read |
An official is the shipped authority list: club owner, admin, match_director or the session creator, including one who is not at the court (the review reaches them by the same push).
Result policy
Job. Every number of the result flow in one object, editable per club; never a literal in an RPC or a screen. 14 keys became 5 (owner review 27 removed two).
| Key | Value | Meaning |
|---|---|---|
appealDays | 7 | players may appeal until the session date + 7 (was correctionDays) |
appealStandDays | 7 | an appeal not decided in 7 days stands as entered (was disputeVoidDays) |
gateNudgeMinutes | 3 | the one repeat of P1 while a successor is held |
suspendedResumeDays | 7 | a suspended match not resumed in 7 days becomes 미완료 (A12) |
retiredMinGames | 4 | a retirement is rated only if either side has won this many games |
Removed: autoConfirmHours, reminderHours, hostEscalationHours, entryReminderHours, contestHours, hostDisputeReminderHours, gateEscalationMinutes. The 8 s undo stays a constant of the Toast. Ratings are applied at entry after the undo window, so 되돌리기 is a plain withdrawal and never a rating replay; a host change after the session has ended replays only that match's players and writes one line in each rating history (점수가 수정되어 다시 계산됐어요), never a push per player beyond P4. Rating at entry is a taste question for the owner (peers wait 24 to 72 hours; we do not, because the host is in the room and the replay exists). Removed by owner review 27: entryWindowDays (any player of the match may enter a missing score at any time) and noResultVoidDays (a recorded match never times out; A13 has no timer).
Rating policy. One object, MATCH_OUTCOME_POLICY in packages/app/src/domain/rules/match-outcome.rules.ts, mirrored by a private table and pinned by a parity test (the trust-tier pattern), replaces the SQL literals of match_rating_outcome_actual. Per outcome: counts in the record, rating mode (full, partial, none), the actual score and weight (retired: 0.6 / 0.4 at the house weight 0.5, no extrapolation of the remainder), and retiredMinGames. Walkover in the record is a flag per match nature. Boards and this library state the policy; no app code in this round.
Pushes
Job. One push per change, to the people it concerns (21 became 5). Each has a title and a body that names the round and the court, so it reads alone ("3라운드, 2번 코트, …"); players land on the scorecard at that match, officials on the 대진표 (signal-routes.ts routes by role).
| P | When (to whom) | Title | Body |
|---|---|---|---|
| P1 | A1, to the four; repeated once after gateNudgeMinutes (3) only while a successor is held | 점수를 입력해 주세요 | 3라운드, 2번 코트, 경기가 끝났어요 |
| P2 | A2, to the other three players | 점수가 입력됐어요 | 3라운드, 2번 코트, 김민준 님이 6-4를 입력했어요 |
| P3 | A4, to the host and the officials who do not play (high) | 점수 검토 요청이 왔어요 | 3라운드, 2번 코트 |
| P4 | A5 to A7, A9, A13, to the appellant, and to the four where the score changed or the match left the record | 점수 검토가 끝났어요 / 점수가 바뀌었어요 / 기록에서 제외됐어요 | 3라운드, 2번 코트, 6-4로 유지됐어요 / 6-3으로 바뀌었어요 |
| P5 | A2, A5 to A7, A10, A13, when a held match is released, to the host and the players whose match was held | 다음 경기를 시작할 수 있어요 | 3라운드, 2번 코트 |
Never "6-4" alone in a push to the other players: P2's body names who entered it, because the order of the numbers is the entrant's. The entrant is told only that a review was asked, never by whom or with what (it avoids a quarrel in the court). The object particle after a score follows the digit as read (Score particles). Scores keep the hyphen in every surface and both languages (terminology guide; the board verifier's score check). The shipped signals bodies and clubs.scoreboard write "6 : 4" and common.tiebreakScoreline writes "7:5": all move to the hyphen when this ships. Every key has a title in send-push/notification-copy.ts, held by a reverse sync test. The players who are only waiting get an in-app line, not a push: they cannot unblock it.
ScoreEntry
Job. One way to record a finished match: type it, keypad first. One component, three modes (kind): enter (any player, the host or an official; CTA 점수 저장 for everyone, beside the tonal 경기 중단), appeal (title 점수 검토 요청, the cells prefilled with the stored score, CTA 검토 요청, no stop button) and host (title 점수 고치기, prefilled, CTA 점수 저장, slot order, no stop button). A match that did not finish is recorded from the same Sheet (경기 중단, Stop step below).
Owner approval (2026-10-14). The scoring sheet is approved: the honor-system entry, the 경기 중단 process, the tiebreak cell and the host review sheet.
Departs from lane D (owner review 4, D3). No chips at all, no pick of a winner and no preset list: UTR, DUPR, Match Tennis and Playtomic all type the score, and Toss's amount entry opens on the keypad with nothing to choose first. Taps are the same for a win and a loss.
Door. enter: a finished match with no score: a player's Match row carries 점수 입력 (an sm tonal Button), the host's carries 대신 입력, and the scorecard's Match state block reads 점수 입력 and opens the Sheet. appeal: the door 점수 검토 요청 › on the scorecard's Match state block once a score is in (the one appeal entry point; the canon bars CTAs in cards, so it is a text action; none while a review is open). host: 점수 고치기 on the host review sheet. A player who opens enter after a score landed finds the score, not an empty keypad: the door is gone (Q3).
Anatomy. A Sheet over the page docked on the numeric keypad from the first frame (216 + 34), top to bottom:
- Title row: the match name ("2라운드 1번 코트") with ×; in appeal and host modes the title is 점수 검토 요청 or 점수 고치기 with the match as its
sub. - The set table (Team lines, cells context, faces by default): the set labels 1세트 … (13/18·500 secondary) over the columns, then one team per line, 48 tall, each set a number Input 48 × 48, r12,
$borderControlborder, 24/32·700 centred numerals. A player's team is the top line with "(나)" (the stored order stays by slot) and its first cell is focused; a host or official not playing sees slot order. - The tiebreak cell (owner review 7), right of the losing team's games cell: only at 7-6 or 6-7 (or 6-6 in a format that plays one) one compact Input (36 × 36, two digits wide, in a 44 target, no placeholder; named three ways (owner review 8): a persistent TB column label in the header row over the cell (13/18·500, secondary, the same row and style as the set labels, so it is a column head and not a lone label), a live line in the header's free space while the cell is focused (1세트 타이브레이크, 진 쪽 점수, 13/18·600 ink, wraps with keep-all, the set labels sit on its last line; it leaves when the cell rests), and the spoken name "1세트 타이브레이크, 진 쪽 점수"; from 1.5x the set label and TB label merge into one centred label "1세트 TB") appears on the line of the team that lost the tiebreak (the 6 side; at 6-6 the second line), directly right of that team's games cell, 4 apart, as the display draws the loser's points beside their games. The set's column widens by 40 on both lines (the other line keeps an empty slot of the same width), so the next set's cells stay aligned; the set label stays centred over the games cells. It takes the loser's points (two digits) and is focused at once. Each set that needs one has its own cell. Measured at 390 (content 350): one tiebreak in a one- or two-set table leaves the names 126 or more with faces; three sets with two tiebreaks need 240 of columns, which would leave the names about 30, so the faces drop (names then get about 100 and wrap to two lines, never cut). It replaces the old row under the table.
- The footer, no home inset (the keypad is under it). In enter mode it is the kit's button pair (lane L §3, D3): a tonal 경기 중단 at one third and the primary 점수 저장 at two thirds, soft until the score is valid; the inline sentence 경기를 끝까지 못 했나요? is deleted (a command is a Button, §1.13 IA8; a sentence is never a control). The pair stacks with the commit on top from 1.5× text. Appeal and host modes have one primary lg button: 점수 저장 (host) or 검토 요청 (appeal); an appeal and a host change are live at once (sending the stored score unchanged asks for a check).
There is no description line and no helper line: the focused cell and the caret say what to do, and the gate fact lives only on the held row. The only line is the validation error, in danger. Controls on screen: at most two cells per set and the buttons.
Typing. A digit fills the focused cell and moves to the next (a game takes one digit; the match-format spec says what is valid): the viewer's line first, then the other line, then the next set. On 7-6 the TB cell appears right of the 6 and takes focus; tiebreak cells take two digits and focus never leaves one by itself. When a set is valid the next set's cells appear if the format has one (best of three or five, a pro set, a match tiebreak: the cells follow the format, never a list of presets). An invalid score (6-5, 8-2) shows the danger line (6-5는 끝난 세트가 아니에요), a danger border on the cells, and keeps the button soft. Taps: 6-4 is 3 (6, 4, the button), a loss is 3, 7-6(5) is 4, any other score is 3. Measured at 390 with the keypad up: the Sheet is about 340 tall in one set, within the 88% cap in every state.
Turned into an appeal (Q1). When another score landed first while the enter Sheet was open, the same Sheet turns into the appeal sheet in place: a warning Notice {이름} 님이 먼저 입력했어요 (the kit's Notice at the full content width, rail to rail, 12 below the title row and 24 before 입력된 점수), the stored score as Team lines (입력된 점수), and the typed digits kept as 내가 아는 점수 in the set table; one button 검토 요청 sends them as the proposed score. The same sets typed twice are a no-op (Toast 이미 입력됐어요).
Stop step (D5, review 6 D3). The tonal 경기 중단 opens a second step in the same Sheet, with a back arrow in the title row and a footer button on each screen (no helper line, no extra modal: the last screen is the confirmation):
- Reason (title 무슨 일이 있었나요?): radio Cells with a leading ring, the outcome word trailing: 한 팀이 도중에 그만뒀어요 기권 · 한 팀이 오지 않았어요 부전승 · 비나 시간 때문에 멈췄어요 일시 중단 · 경기를 하지 않았어요 취소됨. The host and officials also see 규칙 위반으로 실격 처리 실격. Below a chosen reason that needs a team, 어느 팀이 그만뒀어요? (오지 않았어요?, 실격이에요?) is revealed on the same screen (sequential reveal): the two teams as radio Cells, the player's own marked (나); nothing for 일시 중단 and 취소됨. 다음 is soft until the reason and what it needs are chosen.
- The score so far (그때까지 점수, the keypad up): optional for 부전승 and 취소됨. A 일시 중단 also asks 이어서 할까요?: 다음 모임에 이어서 or 여기서 끝낼게요 (becomes 미완료). This is the confirmation: the
substates the outcome in words ("박지훈, 최유나 팀 기권, 김민준, 이서연 팀 승") and the commit button is named for the outcome: 기권 저장, 부전승 저장, 일시 중단 저장, 취소 저장, 실격 저장. It settles at once and the 8 s 되돌리기 Toast follows.
기록에서 제외 is not a stop reason: it lives on the host review sheet behind ⋯. The rain outcome is named 일시 중단 so the word never collides with the 경기 중단 button.
A retirement costs a player 6 taps without digits (점수 입력, 경기 중단, a reason, a team, 다음, 기권 저장) plus the 4 digits of 6-4 3-1 = 10: Flow 7 counts it.
Variants. enter · appeal · host · one set · best of three or five (the next set's cells appear when the previous is valid, never past a decided match) · stop (three screens). The conflict variant and the correction role are gone (8 variants, were 9).
States. Rest (first cell focused, button soft) · typing · valid (button live) · tiebreak · invalid (2px danger border and the line above the button) · saving (저장 중…, 요청 중…) · saved · queued · the three stop screens.
Content. Errors name the rule that failed. A save ends in the Toast 저장했어요 with 되돌리기 (8 s), which withdraws the score while nobody has appealed and no later match has started; an appeal ends in 검토를 요청했어요. Dismissing with unsaved digits asks the guard Dialog. English: "Save score", "Ask for a review", "Change the score", "Stop match", "What happened?", "Which team stopped?", "The score so far", "Save as retired" (and the other outcomes), "The entered score", "The score as I know it".
Accessibility. Each Input is named "{team names}, {n}세트 점수"; 경기 중단 is a button; validation is announced; the stop reasons are a radio group read with their outcome words.
Motion. The Sheet rises over 300 ms with the keypad; the next set's cells appear over 200 ms; the stop step slides in from the right over 200 ms and back from the left; the turn into the appeal sheet cross-fades over 200 ms; reduced motion: a fade.
Replaces in code. packages/features/sessions/src/scorecard/score-edit-sheet.tsx (ScoreEditSheet, ±1 steppers: ten taps for 6-4), scorecard/score-call-sheet.tsx (ScoreCallSheet), scorecard/score-correction-sheet.tsx, the inline numeric fields of packages/features/sessions/src/match-card.tsx, multi-set-score-entry.tsx (MultiSetScoreEntry, whose progressive-row logic the table keeps); the stop step is new.
Why: review 4, point 3 (presets are clutter; keypad first), point 12 (matches that stop); review 5, point 4 (honor system: one sheet, three modes, no helper lines).
Host review sheet
Job. How the host or an official settles a review: keep the score, change it, or leave the match out of the record. It replaces the Decide sheet.
Door. The 할 일 Task Cell 검토 N경기 on the 대진표 (the old 분쟁 cell, renamed) opens the list of Match rows in review (a Sheet titled 검토 N경기); a row, or the P3 push, opens this Sheet. With one review the Cell opens the Sheet directly.
Anatomy. A Sheet with × in the title: the title 점수 검토 over the match as its sub; 입력된 점수 as Team lines (faces, slot order, the winner emphasised); when a player proposed a score that differs, {이름} 님이 말한 점수 under it as Team lines, both teams equal (a proposal never reads as a result). The footer is the pair 그대로 유지 (tonal) and 이 점수로 바꾸기, which with no proposal reads 점수 고치기 and opens ScoreEntry mode host prefilled. 기록에서 제외 is not in the footer: a ⋯ IconButton (label 더보기) in the title row, before ×, opens a MenuSheet titled with the match with one row, 기록에서 제외 (danger). Controls: ⋯, ×, two buttons. Helper lines: none.
Who. A host or official who is not playing the match. A host who plays acts as a player there; another official reviews it, and with none the score stands and the appeal stays on the record (the appellant's row reads 검토할 수 있는 운영자가 없어요).
Actions. 그대로 유지: the review closes, the score and ratings stand, the appellant is told (P4) · 이 점수로 바꾸기, 점수 고치기: a new revision, ratings replay, all four are told · 기록에서 제외 (⋯, then the row): asks a danger Dialog (기록에서 제외할까요?, "레이팅과 전적에서 빠져요. 나중에 다시 포함할 수 있어요.", 취소 · 제외), then the match becomes not counted, the rating reverts by replay, all four are told, the gate stays open. An appeal left appealStandDays stands as entered.
Races. The host's change carries the revision it replaces (Q4): if the entrant changed it in between, the host sees the new score under the Notice 방금 점수가 바뀌었어요 and decides again.
States. With a proposal · no proposal · the menu · the exclude Dialog · saving · the list (3 rows) · refused (a Notice inside the sheet).
Content. English: "Review the score", "The entered score", "The score Jihun Park gave", "Keep it", "Use this score", "Change the score", "Leave out of the record", "Leave it out of the record?", "Leave out". The host restores it from the match with the Button 다시 기록에 포함. Toasts: 그대로 유지했어요, 점수를 바꿨어요, 기록에서 제외했어요.
Accessibility. The title is the first stop; the entered score and the proposal read as two groups ("입력된 점수. 내 팀 … 6. 상대 팀 … 4."); the buttons read their text; ⋯ is read 더보기 and opens the menu.
Motion. The Sheet rises over 300 ms; the Dialog fades over 150 ms; reduced motion: a fade.
Replaces in code. Nothing today: a rejected call is deleted and no screen resolves it (reject_call, submit_match_score for a non-playing host). It replaces the Decide sheet of the dispute flow.
Match row states
Job. One match in a round of the 대진표 and of the scorecard, in every state a result passes through. The Tag says the state (only where there is something to say), the one trailing element says what happens next, and the header fact says how it got there.
Owner approval (2026-10-14). Approved with the match grammar.
Anatomy. The Match row is drawn header first. The header line holds the court first, then at most one fact (13/18·500 secondary Meta), and ends in the one trailing element: a Tag (24), an sm Button (36, its own stop outside the press area) or secondary text; the line is as tall as its tallest element and both are centred on it. Under it the Team lines (§5, faces by default): one team per line, 12 apart, the viewer's team first with "(나)", each team's games on its own line, no word between the teams. Heights at 390, measured in the DOM: 128 with a Tag, 140 with a Button, 122 with text or nothing at the end (an entered score); at 200% text 170 with a Tag and 203 for a held row whose names wrap. Taps: the row opens the match's page (the pair's record and head-to-head live there, a team stat; the backend needs a pair-keyed stats model, noted not built); each player's name is its own stop after the row and opens that player's profile; a Button is its own stop.
| State | Tag | Header fact after the court | Who acts |
|---|---|---|---|
| H0 upcoming | 예정 (neutral) | none | none |
| held (derived) | 대기 (neutral), no numerals | by viewer: the blocked player 내 이전 경기 점수가 필요해요, the others {이름} 님 경기 점수를 기다려요, the host N경기 점수를 기다려요 | the blocked player's one Button 점수 입력 in the Tag's place; nobody else acts here |
| H1 playing | Live tag | the minutes played, 18분째 (owner review 48: the live signal is time) | none |
| H2 점수 입력 전 | none; the end is a Button or text | player Button 점수 입력, host Button 대신 입력, spectator text 점수 입력 전; host: 선수가 입력하는 중이에요; past the session's end: 점수 입력 대기 (owner review 27: no entry window) | a player enters; the host steps in |
| H3 입력됨 | none, with the games, the winner emphasised | none (offline: 보내는 중; a queued entry that met a stored score: 점수가 이미 있어요) | a player: 점수 검토 요청 (in the Match state block, not on the row); the entrant 되돌리기 for 8 s |
| H4 검토 중 | 검토 중 (warning weak), with the games | none (an H4 with no official who is not playing: 검토할 수 있는 운영자가 없어요) | the host or an official: the 검토 N경기 Task Cell, then the review sheet |
| H5 기록 제외 | 기록 제외 (neutral), no numerals | 레이팅에 반영되지 않아요 | the host may reopen (다시 열기) |
| H6 일시 중단 suspended | 일시 중단 (warning), the numerals so far, no winner emphasis | "6-4 3-1에서 멈췄어요" | the host or a player resumes or ends it (A12) |
| 기권 retired | 기권 (neutral), the numerals so far, the winner emphasised | none | a player: appeal as for any score |
| 부전승 walkover | 부전승 (neutral), no numerals, the winner emphasised | 레이팅에 반영되지 않아요 | none |
| 실격 defaulted | 실격 (neutral), as retired if play started, else as walkover | none | none |
| 미완료 abandoned | 미완료 (neutral), the numerals so far, no emphasis | 기록에 반영되지 않아요 | none |
| 취소됨 cancelled | 취소됨 (neutral), no numerals | 기록에 반영되지 않아요 | the host may reopen (다시 열기) |
Block door. The scorecard's Match state block carries the appeal: on an entered score its door is the Door pill 점수 검토 요청 › at the top right of row 1 (§4 Block door rule), with no Tag; while a review is open the Tag 검토 중 takes the place and there is no door. A plain row has no door for a player: no action, no chevron.
Held and in sequence (D4). A match that cannot start yet is held, derived from the gate (Result states): Tag 대기, no numerals; the blocked player's row ends in the Button they owe instead (점수 입력), because the row is their way out. The earlier row carries the line 다음 경기가 기다려요 in the header fact's place while it holds a successor. The 대진표's round header reads 시작할 수 있는 경기 3 | 대기 2 (a Section head with the count facts), the host's one action 시작할 수 있는 경기 시작 starts only the ready ones, and 세션 종료 with unsettled matches asks the Dialog 점수가 정해지지 않은 경기가 N개 있어요 (경기 보기 · 그래도 종료: ending is never blocked, the rows keep their ways out). An open review does not hold.
Stopped (D5). A stopped match keeps the row's shape: the Tag is the outcome word (neutral weak; 일시 중단 warning; never a new colour), the numerals are the games so far (none for a walkover and a cancelled match), and the winner is emphasised only where one exists (기권, 부전승, 실격). In a person's history (ResultRow) the Tag is viewer-relative (기권승 / 기권패, 부전승 / 부전패, 실격승 / 실격패) and the meta line writes the score (6-4 3-1 기권, en 6-4 3-1 ret.); a suspended match is not in the history until it resolves.
Variants. By viewer: player (blocked) · host · spectator.
States. Rest · pressed (.98/.80) · loading (as Text row) · sent offline (the fact reads 보내는 중) · 200%: the facts wrap whole beside the trailing element, names wrap whole, the 32 columns hold.
Content. English Tags: "Under review", "Not counted", "Held", "Retired", "Walkover", "Default", "Suspended", "Unfinished", "Cancelled"; facts "Your earlier match needs its score", "Waiting for Jihun Park's earlier score", "Waiting for 2 scores", "The next match is waiting", "Stopped at 6-4 3-1", "This match isn't in the record", "Players are entering it", "Waiting for scores", "Sending", "A score is already in", "No official can review it", "This match won't affect ratings"; words "Win by ret.", "Loss by ret.", "Win by W/O", "Loss by W/O", "Win by def.", "Loss by def."; buttons "Enter score", "Enter for them"; "Not entered yet".
Accessibility. One stop per row: state first, then the court and the Team lines label from the viewer's side ("검토 중, 1번 코트, 내 팀 김민준, 이서연 6. 상대 팀 박지훈, 최유나 4."); each player's name is its own stop after the row; a Button is its own stop.
Motion. Press settle plus dim; a row that changes state fades its Tag over 100 ms (reduced motion: instant).
Replaces in code. scorecard/live-tab/live-match-row.tsx, scorecard/completed-row.tsx and the status badges of packages/features/sessions/src/match-card.tsx. One pure deriveMatchResultState(match, review) (extending packages/app/src/domain/rules/score-confirm-selectors.ts) supplies the Tag, the action and the copy, and matchStatusSchema with MatchStatusBadge and ScoreStatusBadge learn awaiting_result and the review state.
Live pages
Job. One structure for a club session and a tournament (owner review 48; research § 1). 스코어카드 is everyone's page; the organizer sees the same page with the operator's layer (스코어카드 운영, owner review 50), for both.
The structure. A tournament is stages of its session's own matches, a stage holds groups and a group rounds (Toornament's model): 예선 리그 (groups of four pairs, a round robin of three rounds) then 본선 (a knockout: 8강, 4강, 결승). A club session is one stage, one group and N rounds. 전적매칭 (swiss) and 전원부활전 (compass) are stages of rounds. There is no 3·4위전: Korean club tournaments share third place (공동 3위).
스코어카드 (owner review 50: "each tab needs to serve a specific function and help players get a clear and intuitive understanding of the matches and the session"; research A § 1).
- One tab set per session type, from the kit registry
FVK_SCORE_TABS(its labelsFVK_SCORE_TAB_LABELS), so 스코어카드 and its operator view can never differ: rotation and league LIVE · 일정 · 순위; tournament LIVE · 일정 · 대진표 · 순위; 코트승급전 코트 · 일정 · 순위 (the court ladder is its live view); a single match no tabs, one scoreboard. One tab answers one question (ATP: Live, Schedule, Draws), and four is the most (Toss's fixed tabs). - LIVE is the courts now, by court: each court's head, its match in play as the live Block, then its queue in order with the queue's words (앞 경기 후, 12:00 시작); the viewer's court first. LIVE stays when nothing is played, with its empty state, so the tabs never shift. No live numerals: a score is entered after its match, and its numerals roll in when it lands (up 8, 200 ms; Reduce Motion: none).
- 일정 is every match. By round in a rotation or a league (the round is the Choice in the first head); by group in a tournament, one flat card per group (its head A조 and its matches inside), then the 본선's rounds. Groups part by card, heading and space, never by a hue (UEFA). A finished match keeps its row with its score and its winner emphasised, so results fold in and the 결과 tab retires.
- 대진표 is the bracket (a tournament only): the round chips (8강 · 4강 · 결승), one round per screen, the viewer's path marked; before the groups end its slots are 미정.
- 순위 is the tables: a session's Podium and rows; one Group table per group, as a card each, its tie-break order stated.
- The default tab follows the phase: LIVE while anything is played, 순위 once the session ends.
- The recap (the research's appeal): over a finished session the viewer's day as a Block (오늘의 기록: 3승 1패; 게임 22-15 | ELO +18), its door 공유 to a result card Sheet (이미지 저장 · 공유하기).
스코어카드 운영: the scoreboard with the operator's layer (owner review 50: "So 대진표 is basically a scoreboard with an operator overlay, no? Design it upon the scoreboard and make the tasks of the operator clean and separate"; research A § 2: actions on the match itself, as Challonge; an exceptions queue, as Battlefy and Facebook's member requests; structure on its own screen, as Tournated; no tool uses an edit mode). The page is 스코어카드, the same header, tabs and cards, drawn from the same board code with a role, so the two cannot drift. The operator's layer adds four things and changes nothing else:
- On a card, its one state action: a live Block opens ScoreEntry when pressed (the whole Block, no button inside); a queued match on a free court ends its row in one sm 시작; a finished match waiting for review in one sm 검토. A finished round drops its controls.
- The exceptions inbox: the header's 할 일 IconButton with its count opens a Sheet of the operator's exceptions, each a row ending in its own action: 점수 입력 전 (대신 입력), 검토, a court running late (늦어짐; the queue's times move), 정산 대기 (참석자 관리).
- The band holds the phase's one action: 경기 시작 (soft-disabled with 대진을 먼저 확정해 주세요 until a draw exists) → N라운드 시작 (a round's matches start here, as the app's LIVE callout does) → N라운드 대진 짜기 (a round settled and none next; it opens 대진 짜기) → 본선 대진 만들기 (a tournament, behind its Dialog: 조 1·2위 8팀으로 8강을 만들어요) → 일정 끝내기.
- Structure in the header's ⋯ (five rows at most): 라운드 추가, 대진 다시 짜기 (before round 1 only), 늦게 온 선수 추가, 팀 바꾸기 (the live round), 경기 규칙. A scheduled match's row opens the 교체 Sheet (the app's
swap-sheet.tsx): the four slots, a pick, 팀 교체, the 교체 영향 card (문제 없음, 같은 라운드 중복, 미확정 참석자, 파트너 중복, 상대 중복) and 저장.
대진 짜기 (L3-47; owner review 50: "we are missing pages on how session generate matchups"; research B). The canon's pipeline, 대상 → 구성 → 생성 → 검토 → 확정 → 시작 → 기록, as the app builds it (AddRoundSheet):
- 구성: the readout of who plays (참석 N명, 코트 N면, who is late or left out for an unpaid fee, 경기 가능성's warnings) and the 방식 list by format: 소셜, 실력별, 고정 and 랜덤 복식 (or the three singles), KDK, 순위매칭전 and 코트승급전 (which needs 코트 2면 and 8명). One action, 대진 미리보기.
- 미리보기 (검토): each court's match, the bench (이번 판 휴식, or 전원 출전), 다시 생성; 저장 commits the draw (확정), and only then do players see it and get the push.
- Next round: the same page in next-round mode is 라운드 추가 (N라운드 대진 짜기); a rolling style (KDK, 순위매칭전, 코트승급전) waits for the last round's scores.
- A tournament: the method (조별리그, 토너먼트, 전적매칭 토너먼트, 전원부활전, with the recommendation for the entrant count) and the code's knobs behind a summary sentence (조별 인원, 진출 방식, 진출 인원, 조 편성).
- The door: the session's 관리 has 대진 짜기 from an open session (the app's 대진 방식 선택), and the operator's band has it between rounds.
Retired. The 토너먼트 page (L3-40) and its winner picks: a tournament's matches take scores and outcomes like any match.
Replaces in code. TournamentBoardScreen and /tournament-board; tournament matches move into matches with a stage and a group (the tournament record keeps its configuration: format, group size, advancers, seeding), so SpectatorScorecardScreen and MatchBoardScreen show them; get-default-tab.ts opens LIVE in the new order (King of Court's 코트승급전 tab, drawn in review 50, stands where LIVE does); the round labels of single-elimination.ts ("Final", "Semi-Final") become 결승, 4강 and N강 in i18n; the submit RPC's outcomes reach the 경기 중단 step; a court queue, the elapsed minutes per match and the result card are new.
Bracket
Job. A knockout's draw: the rounds as columns, the viewer's path marked (owner review 48: "tournaments need to support brackets and groups").
Anatomy. Columns 156 wide and 20 apart, so at 390 two rounds show and the third peeks 18; the row scrolls sideways (the Shelf's pattern) and the page never zooms: the round is the unit of paging (research § 1). The round's name 13/18·600 secondary above each column, 8 over the boxes. A match is a gray100 box r12 with 10 of side padding, two slot lines of 36 parted by a canvas hairline: the names 13/18 (the winner 600 ink with its games 700, the other 500 secondary), the games at the right, tabular. Boxes sit 16 apart in the first round; each later box is centred on the two that feed it, joined by connectors 1 edge. The viewer's path is the one loud thing: the viewer's boxes take a 2 ink ring and the connectors between them 2 ink. From 1.5× text a column widens (234 at 200%) so a name never breaks, and one round shows with the next peeking; in English and from 1.5× a slot holds two lines.
States. Decided · to be decided (미정, ink3) · a bye (부전승 in the empty slot, ink3; the team is through) · in play (the 6 danger dot where the games go; no live numerals) · the champion (a last column 우승, the winner's box in ink with white names).
Accessibility. A group named 본선 대진; each round a list named by its round; each box one stop that opens the match: "김민준, 이서연 6, 승. 박지훈, 최유나 3." and 진행 중 for a match in play.
Replaces in code. Nothing draws a bracket today: TournamentBoardScreen lists the current round only, and SingleEliminationBracket's feedsWinnerToMatchId and position are unused.
Group table
Job. One group's standings in a 예선 리그, the teams going through marked (owner review 48).
Anatomy. A head line of labels 13/18·500 ink3 (순위, 팀, 승, 패, 득실), then rows of at least 48: the rank 15/22·600 in a 24 slot, 12, the team as names (partners with the 1px break, the viewer's "(나)", each name its own stop to that player), then 승 600 ink, 패 and 득실 500 secondary in 32, 32 and 44 tabular columns ending on the right rail (they widen with the text to 1.5×). The rows going through take a 24 ink disc with canvas digits, the table's one mark; a caption 13/20·500 ink3 sits 8 under the last row: 1·2위가 본선에 올라요. 같은 승수면 승자승, 게임 득실 순이에요. No row presses as a whole and no row divides the next.
Ranking. The order Korean club tournaments use for a 예선 리그: 승수, then 승자승 between two teams, then 게임 득실 (a club rulebook, m.cafe.daum.net/charm./1uZR/17). The code differs: its pools rank by point difference alone and its round-robin groups by head-to-head, then point difference, then points for (group-knockout.ts, tournament.rules.ts); the code follows this order when built.
Accessibility. A table; each row "1위, 김민준, 이서연, 3승 0패, 득실 +9, 본선 진출".
Replaces in code. Nothing: TournamentBoardScreen flattens its pools into one list.
Group card
Job. One group of a tournament on 스코어카드's 일정, so groups part at a glance (owner review 50: "group-based matches need better visual separation"; research A § 1: UEFA separates groups by card, heading and space, never by a hue per group).
Anatomy. A card bordered by the cell hairline (#E5E5E5, 1px), r16, inset to the rail; inside it the rail is 16, since the card re-provides the kit's context at its own width, so every row placed in it pads itself to the card. The head: the group's name 17/24·700, its facts after it in secondary (3라운드), an optional trailing node (the Live tag while a match of the group is in play), min 56 (16 above, 8 below). Then the group's matches in play and to play, in order. When the group has finished matches, one disclosure row closes the card above a hairline: 완료한 경기 N, 15/22 secondary, with a 16 chevron that turns; it is closed by default, and open it shows the finished matches, with their scores, above the open ones. Cards sit 16 apart; the 본선's rounds follow the last card under their own heads, 34 under it (a box with no tail, as a Field).
Accessibility. The head is a heading (level 3) naming the group; the disclosure is a button with aria-expanded.
In the kit: FvkGroupCard (title, facts, trail, done {n, open}, doneRows, the rows as children).
Score particles
Job. The particle after a score in a push, a Toast or a line follows the digit as the score is read aloud (영 일 이 삼 사 오 육 칠 팔 구): "6-3을", "6-4를", "6-3으로", "6-4로", "6-1로".
| Last digit | Read | 을 / 를 (object) | (으)로 (direction) |
|---|---|---|---|
| 0 | 영 | 을 | 으로 |
| 1 | 일 | 을 | 로 |
| 2 | 이 | 를 | 로 |
| 3 | 삼 | 을 | 으로 |
| 4 | 사 | 를 | 로 |
| 5 | 오 | 를 | 로 |
| 6 | 육 | 을 | 으로 |
| 7 | 칠 | 을 | 로 |
| 8 | 팔 | 을 | 로 |
| 9 | 구 | 를 | 로 |
Replaces in code. The shipped josaEulReul (packages/app/src/utils/korean-josa.ts) reads Hangul only and falls back to 을 for a digit; it gains the digit table, and a josaEuroRo beside it takes the second column.
11. Retired
Every component or pattern the library retires, with its one-line replacement. Nothing in this table is built any more; a surviving use is drift, fixed on the next touch.
| Area | Retired | Replacement |
|---|---|---|
| Chrome and navigation | HeaderTextAction (bare ink text in a header: 관리, 초대 코드, 선택, 편집) | A registry icon, a labelled body entry, or a small mode button (취소 · 완료 · 저장 · 건너뛰기 · 전체 선택). |
| Text pill over a photo or map (white text on an ink pill) | Nothing on the photo; place actions are a labelled row under the title. | |
| A ⋯ menu of fewer than three rows | The action promoted to an icon, or placed in the sheet it belongs to. | |
AppHeader's centred title with a permanent hairline | Header: left-aligned title, edge hairline only while content scrolls under it. | |
| The hung "today" dot on the day header | The label 오늘 alone. | |
| The hung unread dot in the margin | An 8 px dot in a slot inside the rail (content starts at 36). | |
| Permanent hairlines under tab rows, the tab bar and the CTA band | An edge hairline only while content passes under. | |
SectionHeader's divider; Divider seam="section" | Section head (32 above, 12 below); the Band for a change of kind. | |
PillNav and SegmentedTabs on list surfaces | Tabs (underline). | |
| The inline tab bar with a brand tint | Tab bar (ink). | |
| The routed 관리 page used as a menu | The 관리 MenuSheet, built from one registry. | |
| Pager dots on the album overlay | The count Tag alone ("1 / 12"). | |
| A bookmark or save icon on rows and list thumbnails | Nothing on the photo; state is the row's one trailing element. | |
| Surfaces, rows, labels | Card on list surfaces (figure-only use stays for the detail hero image) | Flat rows and sections. |
SessionCardV2, SessionCompactRow, ClubCard, DirectoryVenueCard on list surfaces | MediaRow. | |
MemberRow, DuesRow, LeaderboardRow, RankRow | PersonRow. | |
FactRow, ListRow, SettingsRow, AdminNavRow | Cell. | |
HeroCard, SummaryCard, StatGrid hero, profile-hero-card | Block. | |
StatStrip, CellRow, ActionIconRow; the Strip's hairlines, its unequal text pitch and its hug-last column | Strip: n equal cells of content / n, each a centred stack, no dividers. | |
The Match state block's zone C (the state Tag alone on a row) and the create-session provenance Tag row (CsProv) | A Tag never owns a row: the Block's Tag trails zone A; the provenance is a fact in the header subtitle. | |
| A word between two teams in a row (대, vs) and the two-team single line | Team lines: one team per line, no word between the teams. | |
| Hand-set first-line offsets (a margin or padding that centres a Tag, count, glyph or dot beside text at one text size) | The line slot (§1.13 IA1). | |
TypeHero, VenueHero, the SessionFactsSection lead, GlyphFactRow chip density | Hero (detail) and its facts. | |
Banner, Callout, Well, NetworkBanner | Notice, and the Offline strip. | |
| A generic ⓘ or check-circle glyph on every Notice tone | A state-word title; a severity glyph on warning and danger only; a specific glyph elsewhere. | |
Badge, hand-drawn pills, ContextChip, MarkerPill, ReliabilityPill, TierRangeBadge in rows | Tag. | |
CornerCountBadge; SessionLiveBand and PulseDot on rows; EloTierBadge chrome; AvatarStack | Count tag; Live tag; Level tag; Avatar row. | |
ProgressBar | The Block's progress bar and the Slider track. | |
$primarySubtle and $surfaceTertiary as surfaces | gray100 and the weak tone fills. | |
| The two-row roster grid (216 tall) | One row of four faces with a door. | |
| Controls and fields | ActionButton / Button (green, 56), ActionPill, hand-drawn pressable pills | Button. |
| A disabled ball primary at opacity .5 | The soft-disabled state (gray, pressable, the press shows what is missing). | |
| A spinner that replaces a Button's label | A spinner beside a progressive label ("저장 중…"). | |
HeaderIconButton, ActionIconButton, RemoveIconButton, SaveButton | IconButton (48 target). | |
SelectionChip, DropdownChip, RangeChip, RemovableChip; the segmented control in forms | Choice and the Choice set (the fill variant gives the segmented look at 19:1). | |
| A Menu opened from a Choice ▾ | A bottom Sheet of radio Cells. | |
TogglePill (44 × 24) and the 48 × 28 switch | Switch 52 × 32 with a 3:1 off track. | |
A white Slider thumb; separate ValueSlider, TimeBandSlider, RangeBandSlider | Slider: ink thumb, tap-to-set, Stepper alternative. | |
The 32-circle Stepper | The pill Stepper (36). | |
SearchBar's bordered $card pill | Search field (40 r12 gray100 in a 44 target). | |
A 1px #D4D4D4 control border; LabeledField showing caption and error together | Field: $borderControl, one support line (error ?? caption). | |
| A placeholder used as an instruction, or carrying "(선택)" | An example value; "(선택)" on the label. | |
| The TextArea counter inside the box; a hard character limit on free text | The counter on the support line; the soft limit. | |
| A helper line under every field ("2–20자") | No hint (1.16). | |
AttachedPanel used as a tip box | The support line, a neutral Notice, or a nested Field. | |
| Validation as a Toast, a banner or a loose red hint | The field error: border, glyph and $errorText line. | |
| A required asterisk read aloud as "별표"; optional fields with no marker | An aria-hidden asterisk with "필수" in the name; "(선택)" after the label. | |
AddCard; the bordered PickerRow | AddRow; Picker Cell. | |
Raw react-native-modal-datetime-picker modals; a 오전 / 오후 face for displayed times | Date and time picker Sheet and Time range wheel; 24-hour HH:MM. | |
| The Kakao button at radius 6 | ProviderButton at radius 12. | |
SelectionCard lists | Checkbox and radio rows. | |
The separate PickerCell and CheckRow components | Cell variants (value, option, lead). | |
| Overlays and feedback | The name ActionSheet | MenuSheet. |
| The anchored Menu on phones and its five-row cap | MenuSheet on phones; the anchored Menu from 600 pt. | |
The shadowed HeaderActionsMenu; Tamagui Popover | The fade Menu with an edge hairline; Popover stays banned. | |
Alert.alert, ModalPanel centre as a confirm, bespoke confirm panels | Dialog (useConfirm). | |
| A bare 취소 or 확인 on a Dialog | Buttons named by action; 닫기 for a pure dismissal. | |
| The Edit Sheet's 취소 + 저장 pair and its missing × | × in the title and one 저장. | |
| The document Sheet for 약관 | Document page. | |
| Toasts for errors; the Toast status glyph; the 2800 ms Toast | Results and undo only, no glyph, 4 s and 8 s; failures at the button, in a Notice or a Dialog. | |
| "Never red text on the CTA band" | The danger helper line for an action failure. | |
| A translucent offline strip over media | The solid Offline strip. | |
The mint EmptyState disc; QueryErrorState, OfflineEmptyState, NotFoundState as separate screens | EmptyState and its kinds. | |
| Skeletons with no delay | The Skeleton timing rule (300 ms, 500 ms, 10 s). | |
Raw ActivityIndicators | Spinner, inside a Button or a Cell only. | |
| Conversation and scoring | The Reddit-anatomy PostRow / PostPinnedGroup and FeedPhotoStrip | PostRow. |
| Three reaction vocabularies and the reaction-picker modal | Reactions: 👍 ❤️ 👏 🔥 😂 🎾. | |
| The feature-local compose bar | Composer. | |
| The ±1 stepper score sheet and the three score-entry grammars | ScoreEntry. | |
| 팀 A / 팀 B 포인트 Buttons with an undo icon; the point-level (0 / 15 / 30 / 40 / AD) live model; the 6 px colour-only serve dot | Nothing live: a result is entered after the match (ScoreEntry) and is settled. | |
| ScoreRecorder (the full-screen live counter) and the live score feed | Deferred: no live counting; an official's live tool, if ever wanted, sets a host result. | |
| The LiveScore glance block (live numerals, serve glyph, tiebreak column) | Match state block. | |
| The LIVE band on the scorecard and the match board | The Live row. | |
The Result request row and the 확인할 점수 list; FvkConfirmSheet, FvkDisputeSheet, FvkDecideSheet; the AnswerPair in scoring; ScoreEntry's conflict variant and its correction role | ScoreEntry (enter, appeal, host) and the Host review sheet: two sheets, no request row. | |
| The score states 확인 대기, 분쟁 (countered and disputed), 호스트 확정, 이의 접수, the correction overlay and the Tag 확정 on a plain result | H3 입력됨 (no Tag), H4 검토 중, H5 기록 제외; 12 states became 7. | |
| The confirm flow: 확인 요청, 동의 · 거절, 바로 확정, 모두 확정, 이의 있어요, 수정 요청, the counter, the 12 h and 24 h timers, the 확인 대기 N경기 section; 19 pushes and 14 policy keys; ScoreEntry's helper lines | Entered is settled and rated; 점수 검토 요청 (the Match state block's door) and the host's 검토 N경기 Task Cell; 5 pushes (P1 to P5), 7 policy keys, no helper lines. | |
| Tokens | The shared-semantics.ts medal set | The one medal set (bronze, silver, gold, platinum). |
#B8332E as a fill or for small text; #D4D4D4 for control borders | $error and $errorText, one red (#A3261F); $borderControl for control boundaries. | |
| 15/22 for reading text and input text; 11/14 tab labels | The 16/24 reading role; 12/16 tab labels. |