W2 research — the v2 Tag and Choice, the lint rule, the Badge and SelectionChip migration (2026-10-19)
Status: Reference · Date: 2026-10-19
Read-only research for build wave W2 (migration plan § Build waves, row W2: "the v2 Tag and Choice, a lint rule, and the mechanical migration of Badge and SelectionChip consumers"). Nothing is built; W2 starts when the owner says the library pass allows it (open issues, W2 gate). Counts come from a TypeScript-AST scan of packages/ and apps/ at HEAD 784f46d13 (2,758 files, JSX resolved through imports), not grep; the scan scripts were kept outside the repo. The lead's calls on the open points are at the end.
0. What matters most
- The plan's counts reproduce. Badge: 73 files, 130 JSX uses (23 of them inside the two status-wrapper files; one file is
packages/ui/src/glyph-fact-row.tsx). SelectionChip: 58 files, 106 uses (56 files outsidepackages/ui; the other two aredropdown-chip.tsx:162andrange-chip.tsx:192). The union is 128 files (3 use both). No test file uses either for real. All 236 uses are self-closing JSX with no aliased imports and no non-JSX references, so a codemod is structurally safe. - 55 of the 128 files (43%) are not mechanical. Many Badges are facts, sentences, emoji or counts rather than Tags (the library draws facts as inline text); many Choice sets have more than five options, are vertical lists, are navigation tabs, or sit in a
ListRow's trailing slot. Many of these need W3 primitives (check-row Cells, Tabs, Switch, Picker Cell) that do not exist yet. - Review 46 may still move Tag and Choice numbers, and the behaviour addendum and the library already disagree in two places (label cap 1.5 against 2.0; Choice padding 14 against 16). This research follows the library and the kit.
- Hard stops in
yarn check: a wireframe'sgroundedInfails the build when a cited file is deleted (scripts/build-wireframes.mjs:2486-2492; 26 entries across 13 boards cite files a full deletion removes, and 195 baseline keys across 54 boards cite consumer files, which only warn);check:screen-blueprintsfails when a**Screen:**file changes after its**Verified:**date (17 consumer files are Screen files);AGENTS.mdis 28,364 of 28,672 bytes, so the COMP-2 row is rewritten in place (about +120 B) rather than added to;eslint.ratchet.config.mjs:13is empty (the repo's norm is rules born clean plus inlineeslint-disable -- reasonwaivers; 9 exist today for Badge variants); the test-kitPRESSABLESset (packages/test-kit/src/ui-mock.tsx:336) must gainChoiceor a mockedChoiceignoresonPress. - Infrastructure the spec assumes and the code lacks:
text-scale.ts/useTextScale()/maxFontSizeMultiplier;LineSlot(library §1.13); the named animationfadeFast(in neither Tamagui config); radius 6; a brand-weak text token; an ink focus ring (FOCUS_RINGis$primary,pressable-visual.ts:84); a 5-second cap onPulseDot(it loops forever at 8 px); no consumer declaresrole="radiogroup", so web arrow keys never work for the 30role="radio"chips. - Recommendation: split W2. W2a: the components, the infrastructure, the lint rule, the codemods, the wrappers and the 68 mechanical files (46 M + 22 M+). W2b: the 55 judgment files on a shrink-only ratchet, converted with their pages or after W3. The old
BadgeandSelectionChipstay exported until the ratchet block is empty.
1. The v2 APIs as specified
Sources: the library (Tag :1877, Level tag :1908, Count tag :1922, Live tag :1938, Choice :2042, Choice set :2307; foundations: colour §1.7 :143, radii :195, targets :209, motion :222, line slot :247, press :339, text scaling :371, accessibility :465); the kit (FvkTag flat-v2-kit-content.jsx:511, FvkLevelTag :583, FvkCountTag :623, FvkLiveTag :664; FvkChoice flat-v2-kit-controls.jsx:519, fvkChoiceRows :598, FvkChoiceSet :1491; fvkTagTones flat-v2-kit.jsx:116, fvkTextW :1074).
Tag
- Props:
label(one or two Korean words; English at most 12 characters and 80 px; one line, never truncated);toneneutral (default), brand, success, warning, danger, info;tierweak (default) or solid (brand has no solid in the spec; the kit defines ball/ink; follow the spec);sizesm (24 tall, r6, 13/18·600, padding 0 8) or md (28 tall, r8, 15/22·600, padding 0 10; hero and identity Blocks only);dottrue or'pulse'(a 6 px white dot; the Live tag is danger solid plus the pulse, 1600 ms for 5 s per mount, then static; static under reduced motion);testID.flexShrink: 0; at most one per row and two per object, placed through the line slot. - Behaviour: passive (no press, no role, no
aria-pressed); in a row it has no stop of its own (the row composes its name), standalone it is plain text; changes are instant; text class "label", cap 2.0; the Tag keeps its floor and grows with its label. - Tone to token (every token exists since W1,
config/themes/tamagui.ts:132-164; contrast from the live theme values, light / dark):
| tone | weak fill / text | solid fill / text | contrast |
|---|---|---|---|
| neutral | $gray100 / $textSecondary | $textSecondary / $textInverse | weak 6.59 / 6.94; solid 7.38 / 8.86 |
| brand | $brandWeak / light $text, dark $ball | none | 17.22 / 10.13 |
| success | $successWeak / $successText | $success / $textInverse | weak 7.95 / 7.38; solid 6.61 / 10.65 |
| warning | $warningFill / ink ($onBall, #0D100E in both modes) | the same (weak = solid) | 10.32 / 9.65 |
| danger | $error / $textInverse | the same (weak = solid) | 7.36 / 9.28 |
| info | $infoWeak / $info | $info / $textInverse | weak 9.91 / 7.79; solid 12.95 / 10.14 |
Every pair clears the spec's 6.5:1 for text of 14 px and below.
- In Tamagui: follow the two-
styledpattern ofbadge.tsx:52(BadgeContainer) and:105(BadgeText): the container takes one staticvariantaxis with nine combined keys (neutral, neutralSolid, brand, success, successSolid, warning, danger, info, infoSolid); the publictoneandtierresolve to that key inside the wrapper, so the variants stay static for the compiler. Pure maps and metrics go in a framework-freetag-visual.ts, tested in the node-only*-visual.test.tspattern (packages/ui/jest.config.js). The text is astyled(Text)with the font-size tokens$3(13/18) and$5(15/22), not atextRole(bounded-variant-enumsboundstextRoleto 11 values).
Count tag and Live tag
- Count tag (:1922): 20 tall (
$5), fully round ($7), numeral 11/16·700 ($1); the box grows as ceil(20 × min(scale, 1.3) / 4) × 4; above 99 it reads "99+"; tones danger solid ($error/$textInverse), neutral ($gray100/$textSecondary), ink ($text/$background); the 16-tall form on the tab bar and header is fixed and carries a 1.5 canvas ring; spoken "읽지 않은 3개", "99개 이상" (intoui-strings.ts). It replacesCornerCountBadgeandformatBadgeCount. - Live tag (:1938):
PulseDottoday is 8 px and loops forever; it needs 6 px, white, and a 5-second stop. - Level tag and Unread marker (:1908, :1952) replace
EloTierBadge,TrustTierBadgeand the 7 px dot; both belong to W3 and sit outside the Badge and SelectionChip consumer set.
Choice
- Props:
label,selected,onPress,disabled,testID;tall(44 inside forms; the default is 36,ROW_HEIGHT_HAIRLINEinbox-sizes.ts; 44 isTOUCH_TARGET.MIN);fill(a one-line set of 2 to 4 shares the line);chevron("Choice ▾": shows the value or the active count; with an active value it takes the selected look with no check, and opens a bottom Sheet of radio Cells);removablewithonRemove(a 16 × in its own 44 target, named "{label} 삭제");check={false}for sheet picks, which show the fill alone. - ChoiceSet:
label(the group's name, required), single or multiple value,fill,columns,required,error(the line only, no border). At most 5 options (7 for a fixed series such as 요일); a longer list is a Picker Cell, and sentences are check-row Cells. - Anatomy: fully round (
$7), 15/22·500 (700 when selected), padding 0 16 ($4) or 0 20 ($5) at 44, a 1 px$hairlineEdgeat rest; selected: an ink fill ($text), the label$textInverseat 700, a leading 16 check (Icon size="md"). The hit slop is (44 − 36) / 2 = 4 a side, which tiles with the 8 gap. - Wrap rule (
fvkChoiceRows, kit :598): a set never leaves one Choice alone on its last row (4 + 1 becomes 3 + 2, 3 + 1 becomes 2 + 2). Port it as a purechoiceRows(widths, room)on the kit's character-class width estimator (Hangul 0.864 em, digits 0.64, …) anduseContentWidth(). The check and the 700 label add width when selected, so a wrapped multi-select set re-wraps on toggle. - States: pressed is
Pressable variant="pill"(0.96 / 0.85,pressable-visual.ts:43); hover 0.85 (HOVER_OPACITY); disabled 0.5 (DISABLED_OPACITY); focus an ink ring, 2 px, offset 2; a long label wraps to two lines and the pill grows; at 200% the label caps at 2.0 and the set wraps; the fill changes over 100 ms (MOTION_DURATION.exitFast) with the check, instantly under reduced motion. - Accessibility: one choice =
radiogroupwith a label,radio,aria-checked; several = a group of buttons witharia-pressed(Pressablealready folds it intoaria-selectedon native); Choice ▾ = abuttonwitharia-expanded; the accessible name starts with the visible text. - In Tamagui:
ChoiceisPressable variant="pill"around anXStack, likeselection-chip.tsx:92-135;ChoiceSetrenders theradiogrouporgroupand the rovingtabIndex(the existinghandleRadioKeyDownalready needs a[role=radiogroup]ancestor). Pure logic stays inchoice-visual.ts, reusingselection-accessibility.ts, which avoids the import cyclescheck:cyclesguards.
Token and infrastructure gaps (to build in W2a)
| need | status |
|---|---|
| brand-weak text (light ink, dark ball) | missing: add onBrandWeak to s2-palette.ts, colors.ts, tamagui.ts and the contrast test, then regenerate colors_and_type.css (docs:check-color-tokens-css) |
| ink on the warning fill | $onBall has the value; alias it or add onWarning |
| radius 6 (Tag sm) | no token (the ladder is 4/8/12/16/20/24/full): a named constant in tag-visual.ts (precedent: SQUARE_RADIUS = 6 in selection-check.tsx) |
| heights 28/36/44, paddings 10 and 6 | not $size / $space tokens: named constants (10 is off the 4-pt grid) |
| type 13/18, 15/22, 11/16 | exist as bodyFont $3, $5, $1 (fonts.ts), not as Text roles |
text-scale.ts, useTextScale() | absent: add the class table (reading 2.0, label 2.0, hero 1.7, numeral 1.5, chrome 1.3) with a parity test |
LineSlot | absent: today Badge bakes alignSelf: 'flex-start' and 7 call sites override it |
the fadeFast animation | absent from apps/mobile/tamagui.config.ts (quick is a spring there) and apps/web/tamagui.web.config.ts; needs a require-reduced-motion-gate gate in packages/ui |
| an ink focus ring | FOCUS_RING is shared by Button, PillNav and StyledInput: for W2, Choice gets its own focusStyle; the shared one changes in W3 |
| a name collision | gray100 is a primitive (tokens.color.gray100 = #F0F5F1, tokens.ts:51) and a theme role (#F2F2F2); $gray100 resolves to the role inside components, and other readers of the primitive keep the old value |
Outside W2 but visible: the press springs in motion.ts:27 are {20, 400, 0.7} / {15, 300, 0.7}; library §1.12 says {30, 400, 0.7} / {26, 300, 0.7}.
2. Mapping tables
Badge to Tag (130 uses)
| Badge | uses | v2 |
|---|---|---|
label | 130 | label; 29 are fact-like, 8 sentences or name-bearing, 6 emoji or icons, 1 a count (§ 3) |
variant="neutral" | 53, plus 5 unset | neutral (default) |
variant="surface" | 6 | neutral (v2 has no tinted grounds) |
variant="warning" | 21 | warning |
variant="live" | 3 | danger, not the Live tag: the disputed alert (home-disputed-alert-banner.tsx:100) and two "✓ agreed" chips (score-call-sheet.tsx:169, score-correction-sheet.tsx:249) |
variant={expr} | 42 | 23 inside the wrappers (the status maps); of the other 19: 10 warning/neutral ternaries, 3 other literal ternaries, 6 typed passthroughs |
wrapper-only values (62 entries, status-variant-maps.ts) | muted 13, success 12, neutral 12, warning 11, accent 6, live 4, info 3, primarySolid 1 | accent → brand, info → info, live → danger; muted and primarySolid need a ruling (D1) |
premium, primarySubtle, outline | 0 | deleted |
size="sm" | 55, plus 7 unset | sm (26 tall at 12 px becomes 24 tall at 13 px) |
size="xs" | 45 | sm (18 tall at 10 px becomes 24 tall at 13 px: taller, wider, larger text on 45 call sites) |
size={size} | 23 | the wrappers' own prop; their callers pass xs 21 times, sm 9, md 0 |
alignSelf | 7 (center 6, flex-start 1) | removed: the host aligns (LineSlot) |
icon | 4 | none; dot only for live (SessionStripChipBadge's PulseDot, post-row 📌, venue-detail-screen's facility, glyph-fact-row) |
BadgeVariant type | 10 files | TagTone |
The six typed passthroughs are style-option-row.tsx (2), notification-row-shell.tsx, recap-note-badge.tsx, capabilities.tsx and round-header-row.tsx; three non-JSX files also hold BadgeVariant (format-helpers.ts, use-directory-venue-detail.ts, format-chooser-sheet.tsx).
SelectionChip to Choice (106 uses)
| SelectionChip | uses | v2 |
|---|---|---|
label, onPress | 106, 106 | the same |
selected | 101 | the same; the 5 unset are action chips, not selections (venue-hours 3, batch-confirm-row, report-sheet) |
size | sm 38, unset 66, expressions 2 (internal) | sm 32 and md 40 become the 36 default; tall (44) inside forms |
role | radio 30, checkbox 3, button 2, tab 2, unset 69 | the role moves to the set: radio → radiogroup; checkbox → a button with aria-pressed; tab → Tabs, not Choice (W3) |
aria-label | 34 | kept only when it starts with the visible text |
disabled | 16 | the same |
flex={1} | 16 | the set's fill (the segmented look; the selected state at 19:1) |
trailing | 7 | no equivalent: 1 chevron, 1 expand chevron, 1 count (notification-center's FilterChip), 4 source or meta texts (J) |
justifyContent | 6 (flex-start 5, space-between 1) | list rows and the expand-in-place trigger (J) |
testID | 5 | kept (Maestro uses venue.nav.*; venue.filter.* and contribution.confirm.* are IDs too) |
labelFlex, labelMaxWidth, aria-expanded, aria-selected, leading, a spread | 1, 2, 1, 1, 1, 1 | internal to DropdownChip and RangeChip, or one-offs of the scope selector |
Parents of the 106 uses: a wrapping XStack 48 (38 through .map, 10 literal), a non-wrapping XStack 42, YStack lists 8, a component root 6, a fragment 2.
The family outside the two counts
| component | uses / files | v2 target |
|---|---|---|
| DropdownChip | 23 / 11 | Choice ▾ plus a Sheet (region-district-filter, pickup-filters, recurrence-section, create-club-step-guests, create-club-step7, discover-filters, edit-profile-screen, match-rules-editor, create-session-step3, create-session-step4, venue-courts-composer) |
| RangeChip | 3 / 2 | a range Choice (pickup-filters, discover-filters) |
| RemovableChip | 2 / 2 | a removable Choice (venue-finder-header:98, amenities-field:97) |
| MarkerPill | 4 / 4 | Tag (period-nav-panel, recap-summary-card, week-month-picker, ui/media-hero) |
| ActionPill | 2 / 2 | a ghost sm Button (IA8) |
| CornerCountBadge | 2 / 2 | the Count tag or W3's Badged host (one is apps/web/components/app-nav.tsx:130) |
| ChipLabel | 7 / 6 (3 inside ui) | the Count tag or Choice internals |
| hand-built Tag-shaped pills outside ui | about 16 | Tag |
Out of scope, W3: PillNav 15/8, SegmentedTabs 8/8, TogglePill 16/9, SelectionCard 19/9.
3. Consumer files by package and difficulty
M = mechanical (the codemod finishes the file); M+ = the codemod plus a small hand step (a group label, an irregular container, a typed passthrough); J = needs a design judgment; W = the status-wrapper files; L = packages/ui internals.
| package | files | M | M+ | J | W | L |
|---|---|---|---|---|---|---|
| packages/app | 21 | 5 | 2 | 12 | 2 | 0 |
| features/clubs | 37 | 19 | 8 | 10 | 0 | 0 |
| features/home | 4 | 1 | 2 | 1 | 0 | 0 |
| features/messaging | 1 | 1 | 0 | 0 | 0 | 0 |
| features/profile | 7 | 4 | 1 | 2 | 0 | 0 |
| features/records | 2 | 0 | 0 | 2 | 0 | 0 |
| features/sessions | 53 | 16 | 9 | 28 | 0 | 0 |
| packages/ui | 3 | 0 | 0 | 0 | 0 | 3 |
| total | 128 | 46 | 22 | 55 | 2 | 3 |
Prefixes below: A = packages/app/src/presentation/components, C = packages/features/clubs/src, H = home/src, M = messaging/src, P = profile/src, R = records/src, S = sessions/src, U = packages/ui/src.
- W: A/status/status-badges.tsx (19 Badge uses), A/status/badges/club.tsx (4).
- L: U/dropdown-chip.tsx and U/range-chip.tsx (rebuilt as Choice ▾), U/glyph-fact-row.tsx (one Badge per fact with an icon; facts become inline text in v2).
- M (46): app (5): the dev-panel diagnostics, logs and telemetry panes, leaderboard-row, venue-card · clubs (19): challenge-accept-modal, club-attendance-screen, club-challenges-screen, club-detail/admin-tab, club-members/join-review-row, club-members/member-row, club-reports-screen, create-club-step4, create-club-step6, create-club-step8, create-club/step1/decor-section, create-club/step3/days-chips-section, dues/dues-row, post-detail/post-detail-header, settings/club-guests/priority-row, settings/club-guests/proposed-row, settings/image-section, settings/info-section, settings/regroup-preview · home (1): cards/home-disputed-alert-banner · messaging (1): dm-thread/participants-modal · profile (4): edit-profile-screen, friends-screen, settings-signal-preferences-screen, settings/app-section · sessions (16): add-round/availability-roster, create-session-step3, directory-venue-detail/parts/correction-row, scorecard/draws-row, scorecard/live-row, session-detail/sections/participation-section, session-detail/sections/session-fact-rows, session-payments/payment-row, venue-contributions/contribution-row, venue-contributions/court-operation-fields, venue-contributions/detail-screen, venue-contributions/new-venue-editor, venue-correction/fields/capabilities-field, venue-correction/fields/facility-kind-field, venue-correction/fields/has-indoor-field, venue-correction/fields/has-lighting-field.
- M+ (22): app (2): activity-metric-calendar, sessions/style-option-row · clubs (8): club-detail/ranking-tab, club-detail/sessions-pane, create-club/home-court-toggle, dues/dues-attest-modal, post-create-screen, settings/gallery-section, settings/group-editor, settings/tier-range-editor-modal · home (2): shared/notification-row-shell, views/recap-note-badge · profile (1): settings-privacy-screen · sessions (9): directory-venue-detail/sections/capabilities, scorecard/round-header-row, session-detail/editing/composition-edit-modal, venue-contributions/list-screen, venue-contributions/receipt-actions, venue-correction/field-shared, venue-courts-composer, venue-owner-claims/list-screen, venue-owner-claims/review-editor. Of the 83 SelectionChip uses whose parent holds only chips (clean sets), 43 have a
LabeledField,SectionBlockorFieldCardancestor that can name the group; the other 40 (24 files) need a written label. - J (55), with the reason (Badge side: F a fact or measure, S a sentence or name-bearing, I an icon or emoji, C a count, X other; Choice side: LONG more than 5 options, SENT sentence rows, ACT action chips, TRIG a trigger or picker, TRAIL in a ListRow's trailing slot, SCROLL horizontal scroll, TAB view switching, VERT a vertical list, RATE a rating):
- app (12): A/court-usage-panel (LONG court numbers; ACT rename chips), A/match-history-row (F, mixed with T), A/match-history-scope-selector (SCROLL; TRIG expand-in-place), A/reliability-pill (X: a component to replace), A/report-modal (VERT: the owner's "one per row", 5 options), A/sessions/club-session-card (F: D-day, attending count), A/sessions/directory-venue-card (F: distance, price), A/sessions/match-row-header (F, mixed with T), A/sessions/match-scoreboard (F: club name), A/sessions/venue-confidence (F: source chips), A/sessions/venue-filter-bar (LONG: up to 8 facets; the nearby-sort toggle is M), A/sessions/venue-finder/venue-finder-header (TAB, with Maestro IDs).
- clubs (10): C/club-analytics-screen (S: count phrases), C/club-detail/club-feed-chips (SCROLL, 6 chips), C/club-detail/club-schedule-rows (F: D-day), C/create-club/step3/used-courts-subset-section (LONG), C/join-review-detail-screen (F: emoji and count), C/settings/club-guests-section (TRAIL), C/settings/club-planning-section (TRAIL), C/settings/recurrence-editor (LONG on the groups; its other 5 sets are M), C/shared/club-photo-seam (F: the "무관" level), C/shared/post-row (I: 📌).
- home (1): H/notification-center/components (TAB, with a count). profile (2): P/manner-tags-section (F: emoji, label and count), P/practice-log-screen (F: duration). records (2): R/cards/top-partners-card (F: medal or rank), R/record-detail-screen (C: a count, becomes a Count tag).
- sessions (28): S/components/match-rules-editor (X: a validation error drawn as a Badge), S/components/participant-row (SENT: manner tags, drawn as check rows), S/create-session-step-players (F ×2; LONG, 6 chips), S/create-session/step1/past-session-prefill-section and regular-meet-prefill-section (SENT), S/directory-venue-detail/sections/amenities, courts, payment and review-keywords (F), S/guest-rating-block (RATE: a star rating, kept apart from Choice), S/live-round-editor-sheet (F: a player chip), S/scorecard/king-of-court-ladder (I: 👑), S/scorecard/king-of-court/court-card (I), S/scorecard/round-rules-strip (F ×6: a rules summary), S/scorecard/score-call-sheet and score-correction-sheet (I: "✓" drawn in danger), S/swap-sheet (S: 2 of 4 chips carry a person's name), S/venue-contributions/fact-confirmation-picker (SENT), S/venue-contributions/lifecycle-editor (LONG: a venue list in a FlashList), S/venue-contributions/report-editor (LONG: 6 reasons), S/venue-correction/batch-confirm-row (ACT), S/venue-correction/fields/court-count-field and telephone-field (SENT: a value plus its source), S/venue-correction/report-sheet (ACT), S/venue-detail-screen (F plus an icon), S/venue-fee-composer (LONG: 10 scopes), S/venue-hours-composer (ACT and TRIG: a time picker), S/venue-owner-claims/claim-summary (S: "신청 상태: 승인").
- On the SelectionChip side (34 uses): LONG 10, SENT 8, ACT 4, TRIG 3, TRAIL 3, SCROLL 2, TAB 2, VERT 1, RATE 1; 28 of the 34 (all but SCROLL, TRIG and RATE) need Cells, Tabs, Switch or Notice from W3.
- Also touched (type holders, not JSX users): S/format-chooser-sheet.tsx, S/directory-venue-detail/use-directory-venue-detail.ts, S/directory-venue-detail/format-helpers.ts, and A/status/status-variant-maps.ts on the wrapper side.
The canonical status wrappers (they keep their names)
The names stay, so tests that mock *Badge as app:<Name> (packages/test-kit/src/app-mock.tsx:565) and the Maestro text assertions keep working.
| wrapper | today | call sites | proposed v2 tone (board evidence) |
|---|---|---|---|
| SessionStatusBadge | draft muted, open accent, locked neutral, in_progress live, completed success, cancelled muted, awaitingScores warning | 4 | open → brand (모집 중); in_progress → danger with the pulse dot; cancelled: D1 |
| MatchStatusBadge | scheduled neutral, in_progress live, completed success, cancelled muted | 5 | the match-state Tags (spec :1892) come with W4's match row |
| RsvpStatusBadge | confirmed primarySolid, attended success, waitlisted info, cancelled muted, no_show warning | 6 | confirmed → success (D1); the boards draw 예비 neutral where the old map says info |
| DuesStatusBadge | paid success, unpaid and partial warning, waived muted | 2 | 완납 success, 미납 warning |
| ScoreStatusBadge | unverified warning, verified success, disputed live | 4 | disputed → danger |
| SessionPaymentStatusBadge | pending warning, paid success, waived muted | 2 | 입금 필요 warning, 입금 완료 success |
| ConsentStateBadge / ConsentRequirementBadge | granted success; withdrawn and refused muted; expired warning; superseded neutral / required neutral, optional accent | 2 / 1 | refused → neutral; optional → brand |
| RecruitmentStatusBadge | recruiting and needsQuorum accent, almostFull warning, waitlist info, closed neutral | 1 | 모집 중 brand, 마감 임박 warning, 마감 neutral |
| SessionStripChipBadge | live, standing surface, error and warning → warning, success, neutral; a PulseDot icon | 2 (1 file) | the icon becomes dot |
| WinResultBadge | win success, draw neutral, loss muted; mode="dot" reads $badge* through WIN_RESULT_DOT_TOKENS | 4 | the dot mode stays a hand-built circle |
| EloDeltaBadge | success / warning / neutral, alignSelf=center | 3 | the same |
| AchievementStateBadge, ConfidenceTierBadge, VenueTrustBadge, AttendanceStatusBadge, ChallengeStatusBadge | per status-variant-maps.ts | 1, 1, 3, 1, 1 | by the same rule |
| ClubJoinabilityBadge, ClubRecruitBadge | open accent, approval info, invite_only neutral / accent | 1, 1 | the composed label "모집 중 · N자리 남음" breaks the one-to-two-word rule (a content item) |
| ApprovalModeBadge, ClubRoleBadge | accent, neutral | 0, 0 | dead wrappers |
| ClubAlarmBadge, TierRangeBadge, ReliabilityPill, EloTierBadge, TrustTierBadge | hand-built pills or level chrome | 1, 2, 1, 7, 8 | Tag or the Level tag (the level chrome is W3) |
Wrapper call sites that pass size: 30 (21 xs, 9 sm, none md, across 21 files); keeping xs as an accepted alias of sm in W2 avoids editing them, and the alias goes in W3 or W4. About 28 files read the $badge* theme keys outside Badge (Banner, Callout, DayDotsRow, Well, CornerCountBadge, pill-nav's BadgeDot, network-banner, status/*, the themeHex list), so the badge palette stays in the theme through W2.
4. The lint rule
- After the wave it forbids: (1) importing
Badge,BadgeVariant,SelectionChip,DropdownChip,RangeChip,RemovableChip,ChipLabelandMarkerPillfrom@twomore/uiinpackages/features,packages/app/src/presentationandapps/*(the ratchet's entry point); (2) a looseChoiceoutside aChoiceSetwhen it sits in a.mapcallback or beside a siblingChoice(achevronorremovableChoice is exempt); (3) the retargeted rules:bounded-variant-enumsswaps the Badgevariantspec (neutral|warning|live|surface,strictNonLiteral) for Tagtoneinneutral|warning|dangerand restrictstierto weak andsizeto sm outside the wrappers and hero files;no-adhoc-status-badgeswaps'Badge'for'Tag'andvariantfortone(it still checkslabel; thepresentation/components/status/exemption stays);no-badge-weight-overridestays whiletextRole="badge"exists. Optional:no-hand-built-tag(a stack with a background, a radius and one badge or micro text; 16 such stacks outsidepackages/ui, e.g.member-stats-rows.tsx:120,dm-message-row.tsx:159,pickup-host-card.tsx:196,venue-card.tsx:188,club-photo-seam.tsx:230,public-club-profile-screen.tsx:570; some are not Tags, so it needs inline waivers). - How the repo carries it: the structural precedent is
packages/eslint-plugin/rules/no-hand-built-person-row.js(JSX parent, sibling and expression-wrapper helpers; a "born clean" header); the import-ban precedent isno-deprecated-import(eslint.config.mjs:789, a{name, sources, replacement}list with no ratchet); born-clean precedents areno-badge-weight-override(eslint.config.mjs:455, "41 sites swept, zero waivers") andno-hand-cta-band. The ratchet (eslint.ratchet.config.mjs:13, empty; generatorscripts/generate-eslint-ratchet.mjs, which writes per-rule file blocks for@twomore/*rules only, spread ateslint.config.mjs:926) baselines the 55 J files and the 3 ui internals once, and the block shrinks as pages convert. If instead everything moves in W2 and the old exports are deleted,tsccatches leftovers and the rule only forbids loose Choices and hand-built shapes. - Plumbing per rule:
packages/eslint-plugin/rules/<name>.js, its test,docs/rules/<name>.md(checked bydocs:lint-rule-docs), theindex.jsregistration, a README entry, and the registration ineslint.config.mjs§ 8; plus the COMP-2 row inAGENTS.md, and the regex inpackages/app/src/config/__tests__/ui-contracts.test.ts:17retires.
5. Codemods and lanes
- Codemods in the repo's house style (
scripts/codemods/text-role-rename.mjs,a11y-web-vocabulary.mjs: TypeScript-AST text edits with--write, a REVIEW list for anything ambiguous, then prettier).- Badge → Tag rewrites the imports and tags; a literal
varianttotone(neutral and surface omitted, warning kept, live → danger, accent → brand, success and info kept); ternaries whose branches are both literals;sizexs and sm dropped (md kept);alignSelfdropped with a REVIEW line;BadgeVariant→TagTone; the values of typedRecord<*, BadgeVariant>literals. It leavesicon, non-literal variants, muted and primarySolid (D1) and the F/S/I/C/X classes to hand edits, as compile-clean Tags marked// TODO(w2-judgment)from an allowlist, with a grep gate. - SelectionChip → Choice rewrites the imports, the tag and
selected;size(sm drops, unset becomes the default ortallinside aLabeledFieldor a field row); dropsrole,aria-selectedand a redundantaria-label; hoistsflex={1}to the set'sfill; turns the parentXStackof a clean set into<ChoiceSet label={…}>, inheriting the label from aLabeledFieldancestor or emitting a TODO onto the REVIEW list. It leavestrailing,leading,justifyContent,labelFlex, the 6 irregular containers and everything in J. - 83 of the 106 SelectionChip uses are clean sets (46 files); 33 of the 70 M uses are fully automatic (14 files).
- Badge → Tag rewrites the imports and tags; a literal
- W2a lanes (sonnet, at most 3 at a time, at most 7 files each). The lead's thread owns the new components,
tag-visual.ts,choice-visual.ts,text-scale.ts,LineSlot, the theme token andfadeFast,ui-strings.ts, the test-kit change, the lint rules and their docs, the codemods, the wrapper files and maps,build-wireframes.mjs'sgroundedInand.grounding.json, the 12 blueprint re-dates for the M and M+ Screen files, and the canon edits. The hand-edit lanes cover the 22 M+ files and 3 type holders:- A (7): A/activity-metric-calendar, A/sessions/style-option-row, H/shared/notification-row-shell, H/views/recap-note-badge, P/settings-privacy-screen, C/club-detail/ranking-tab, C/club-detail/sessions-pane.
- B (7): C/create-club/home-court-toggle, C/dues/dues-attest-modal, C/post-create-screen, C/settings/gallery-section, C/settings/group-editor, C/settings/tier-range-editor-modal, S/session-detail/editing/composition-edit-modal.
- C (7): S/venue-contributions/list-screen, S/venue-contributions/receipt-actions, S/venue-correction/field-shared, S/venue-courts-composer, S/venue-owner-claims/list-screen, S/venue-owner-claims/review-editor, S/directory-venue-detail/sections/capabilities.
- D (4): S/scorecard/round-header-row, S/format-chooser-sheet, S/directory-venue-detail/use-directory-venue-detail, S/directory-venue-detail/format-helpers.
- A, B and C run together, then D; the 46 M files are verified by
yarn checkand the lead's diff skim.
- W2b (the J files) starts after the rulings below: 55 files plus 3 ui internals, about 8 or 9 lanes by area (app 12; clubs 10, home 1, profile 2, records 2; sessions 28).
- The alternative, all in W2: 81 hand-edit files in 12 lanes, four rounds, with interim rulings for the 55 J files and their work redone in W4.
6. Risks
- Mixed grammar on a screen. The migration's rule is "never mixed on one screen", and W2 puts v2 Tags and Choices into v1 rows and cards. Preview OTA is paused until W5, so no device sees it; the web build shares
@twomore/uiand would show the mix if Vercel deploys from main (apps/web/vercel.json; the repo does not show the trigger). - Look deltas. Tag sm goes from 26 to 24 tall (12 to 13 px); xs goes from 18 to 24 tall (10 to 13 px) on 45 call sites; Choice goes from 32/40 to 36/44, and the selected look from a green tint with a
$primaryborder to an ink fill with a check; a Tag never shrinks, so narrow rows need the no-overflow check again. - Dark-mode contrast.
$gray100dark (#232824) as the neutral weak fill is 1.26:1 against the canvas (#0F1211) and about 1.13:1 against$card, while the owner's rule of 2026-09-28 (docs/canon/components.md:148) asks for dark badge fills of at least 1.5:1. Thecheck:color-contrastfill pairs walkcolors.badge.*, so Tag pairs are invisible to it; add them tos2-contrast.test.tsorColorToken. - Tests. No snapshots exist.
participation-section.test.tsx:203,232,263assertqueryAllByTestId('ui:Badge'); localSelectionChipstand-ins live inranking-tab.test.tsx:49,settings-screen.test.tsx,venue-correction-modal.test.tsx:214,contribution-editors.test.tsx,contribution-screens.test.tsxandvenue-hours-composer.test.tsx;venue-finder.test.tsxmocks RemovableChip andedit-profile-screen.test.tsxDropdownChip;status-badges.test.tshas 14 "only emits valid Badge variants" tests keyed to the variant union;ui-contracts.test.ts:17greps<Badge variant=…>;badge-visual.test.tsandchip-label-visual.test.tsare replaced; the test-kitPRESSABLESneedsChoice; the ESLint plugin tests of the retargeted rules change. - Maestro. Flows 03, 04 and 05 assert Tag text as standalone elements ('참가 확정', '미확정', '분쟁 발생'); flow 11 uses
venue.nav.*testIDs on SelectionChip; flow 08 notes that Maestro's text matchers are full-string regexes. In W2 the Tag is notaria-hidden(the composed one-stop row name is W3 and will break those matches), andtestIDpasses through. - Accessibility. 69 role-less chips move from
aria-pressedtoradiogrouporgroup(verify with VoiceOver and TalkBack; native foldsaria-pressedintoaria-selected); the 3role="checkbox"chips become pressed buttons; the group label is a new required input, and 40 of the 83 clean sets have no labelling ancestor. - Web and native. The web gets roving tabindex, arrow keys (
handleRadioKeyDown), hover and an ink focus ring; native has no focus ring; the 100 ms fill needsfadeFastin both Tamagui configs; the pulse isAnimated-based; Android numerals needincludeFontPadding: false(seeclub-alarm.tsx); Korean wrapping relies onlineBreakStrategyIOSand.is_Text { word-break: keep-all }, so the text is built on the sharedText. - apps/web uses only
CornerCountBadgeandformatBadgeCount(app-nav.tsx:130); both stay in W2, and the Count tag and theBadgedhost follow in W3.apps/web/public/design-system/ui_kits/mobile/*.jsxare static kit prototypes with their ownBadge. - Docs and gates. 13 boards cite files a full deletion removes; after the consumer edits, 54 boards warn, and each is re-accepted only after it is verified (
REGROUND=<keys>;REGROUND=1is for a first baseline only). Canon to update:docs/canon/components.md(lines 56, 82-84, 148-150, 222, 241),docs/canon/styling.md(64, 93-95),docs/canon/status-and-recruitment.md,docs/architecture/screen-blueprint.md, the rebuild log, the migration plan, andAGENTS.mdCOMP-2. - Layout shift on toggle. A Choice's check and its 700 label add about 24 px when selected, so wrapped multi-select sets re-wrap on tap (days, capabilities, facets).
- Hazards while adding the infrastructure: import cycles (
check:cycles: keep pure logic in-visual.tsfiles);no-korean-string-literal(strings throughuiStrings());require-reduced-motion-gatefor anyanimation=inpackages/ui.
The lead's calls (2026-10-19)
The lead decides what the spec and the owner's standing rules already settle; the two visual calls are logged in the decision log for veto. W2 itself waits for the owner's go (D9).
- D1, the tone map (for veto):
mutedbecomes neutral everywhere (cancelled, withdrawn, waived, draft): a closed state reads quiet, and danger stays for what needs action now (disputed, severely overdue); the spec's Tag table, which lists "danger: cancelled", is corrected to match the match row's neutral 취소됨. RSVP confirmed (primarySolid) becomes success weak (solid only over photos and heroes); 예비 is neutral, as the boards draw it. - D2, size and tier: weak by default, solid only over photos and heroes, md for hero and identity Blocks only; xs collapses into sm, with
xskept as an accepted alias in the wrappers during W2 and removed in W3 or W4. - D3, LineSlot and text scaling: built in W2a (
LineSlot,text-scale.ts); the Tag gets noalignSelfpassthrough. - D4, scope: W2a now (components, infrastructure, lint, codemods, wrappers, the 68 mechanical files), W2b with the pages, on a shrink-only ratchet. Proposed to the owner with the W2 go.
- D5, the family: DropdownChip, RangeChip and RemovableChip become Choice ▾, the range Choice and the removable Choice in W2a; MarkerPill becomes a Tag in W2a; the Live tag's dot (6 px, a 5-second pulse) is in W2a; ActionPill (a Button) and the Count tag with the
Badgedhost wait for W3. - D6, the rules: feature code may write the tones neutral, warning and danger; every other tone goes through a status wrapper;
no-hand-built-tagwaits for W3. - D7, the dark neutral fill (for veto): the owner's rule of 2026-09-28 stands (dark badge fills at least 1.5:1): in dark mode the neutral weak Tag gets its own fill token at 1.5:1 or more against the canvas, with a label colour that keeps 6.5:1; light mode is unchanged; both pairs join the contrast test.
- D8, the focus ring: Choice gets its own ink focus ring in W2; the shared
FOCUS_RINGchanges in W3. - D9, the order: W2 starts on the owner's go.
W2b by page (lead, 2026-10-19)
W2a landed (the rebuild log, 2026-10-19). W2b is the 55 J files of § 3, and they move with their pages (W4) once the W3 primitive each needs exists; a file is never converted to a stopgap. This table puts each one with the page that carries it on the boards and the primitive or rule that replaces its Badge or SelectionChip. Prefixes as in § 3.
| Page (board) | Files | What replaces the old piece |
|---|---|---|
| 기록 (hub) | A/match-history-row, A/match-history-scope-selector, R/cards/top-partners-card, R/record-detail-screen | facts as MetaSegments with one Tag; the scope as a Choice ▾ in the section head; the Podium's rank column; a Count tag |
| 클럽 홈 (hub) | A/sessions/club-session-card, C/club-detail/club-feed-chips, C/club-detail/club-schedule-rows, C/shared/club-photo-seam, C/shared/post-row | the Feed item's facts (D-day, the count); the Feed head's filter Choice ▾; the Club hero's facts; the leading marker slot for a pinned post |
| 프로필, 공개 프로필 (hub, detail) | A/reliability-pill, P/manner-tags-section | the tier Tag and the 신뢰 facts (library § 4 Trust); 받은 매너 태그 leaves the public profile (M6) |
| 알림 (list-a) | H/notification-center/components | time sections and the filter Choice ▾ (M14); no tab row |
| 테니스장, 장소 (list-b, detail) | A/sessions/directory-venue-card, A/sessions/venue-confidence, A/sessions/venue-filter-bar, A/sessions/venue-finder/venue-finder-header, S/directory-venue-detail/sections (amenities, courts, payment, review-keywords), S/venue-detail-screen | the Feed item's facts; the source as a Caption row; the FilterSheet behind one Choice ▾; Tabs; facts, not chips |
| 연습 기록 (list-b) | P/practice-log-screen | the Strip's tiles face and row facts |
| 일정 상세, 스코어카드, 대진표 (detail) | A/sessions/match-row-header, A/sessions/match-scoreboard, S/live-round-editor-sheet, S/scorecard/king-of-court-ladder, S/scorecard/king-of-court/court-card, S/scorecard/round-rules-strip, S/scorecard/score-call-sheet, S/scorecard/score-correction-sheet, S/swap-sheet, S/components/participant-row | the Match row and the Match state block; a PersonRow where a chip held a person; the medal or the leading marker for the 👑; the rules as a Caption row of facts; a Notice where a "✓" chip warned; the 매너 태그 flow's options as check rows |
| 가입 심사, 성장 분석 (detail) | C/join-review-detail-screen, C/club-analytics-screen | 칭찬한 사람 N명 (one fact, M6); the Strip's tiles face |
| 일정 만들기 (forms) | A/court-usage-panel, S/create-session-step-players, S/create-session/step1/past-session-prefill-section, S/create-session/step1/regular-meet-prefill-section, S/components/match-rules-editor | check rows or the Picker Cell for long and sentence options; row controls for the rename chips; radio Cells for the prefills; the Field error for the validation Badge |
| 클럽 만들기, 클럽 설정 (forms) | C/create-club/step3/used-courts-subset-section, C/settings/club-guests-section, C/settings/club-planning-section, C/settings/recurrence-editor | check rows for the court subset; a value Cell where a chip sat in a row's trailing slot; the Picker Cell for the recurrence groups (its other five sets are mechanical) |
| 장소 정보 제안, 소유권 신청 (forms) | S/venue-contributions/fact-confirmation-picker, S/venue-contributions/lifecycle-editor, S/venue-contributions/report-editor, S/venue-correction/batch-confirm-row, S/venue-correction/fields/court-count-field, S/venue-correction/fields/telephone-field, S/venue-correction/report-sheet, S/venue-fee-composer, S/venue-hours-composer, S/venue-owner-claims/claim-summary | check rows (sentences, the six reasons); the search takeover for a venue list; row controls and a MenuSheet for actions; the value with its source as a Field note; the Picker Cell for ten fee scopes; the time range wheel; the claim's state as a Tag with its facts |
| 신고 (overlays) | A/report-modal | radio Cells, one reason per row (the owner's "one per row") |
| Kept as it is | S/guest-rating-block | a star rating is not a Choice (RATE) |
The order follows W4's: each page's files move in the change that builds the page, with the page's blueprint entry and its tests; the ratchet (eslint.ratchet.config.mjs) only shrinks.