Skip to content

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 ​

  1. 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 outside packages/ui; the other two are dropdown-chip.tsx:162 and range-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.
  2. 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.
  3. 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.
  4. Hard stops in yarn check: a wireframe's groundedIn fails 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-blueprints fails when a **Screen:** file changes after its **Verified:** date (17 consumer files are Screen files); AGENTS.md is 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:13 is empty (the repo's norm is rules born clean plus inline eslint-disable -- reason waivers; 9 exist today for Badge variants); the test-kit PRESSABLES set (packages/test-kit/src/ui-mock.tsx:336) must gain Choice or a mocked Choice ignores onPress.
  5. Infrastructure the spec assumes and the code lacks: text-scale.ts / useTextScale() / maxFontSizeMultiplier; LineSlot (library §1.13); the named animation fadeFast (in neither Tamagui config); radius 6; a brand-weak text token; an ink focus ring (FOCUS_RING is $primary, pressable-visual.ts:84); a 5-second cap on PulseDot (it loops forever at 8 px); no consumer declares role="radiogroup", so web arrow keys never work for the 30 role="radio" chips.
  6. 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 Badge and SelectionChip stay 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); tone neutral (default), brand, success, warning, danger, info; tier weak (default) or solid (brand has no solid in the spec; the kit defines ball/ink; follow the spec); size sm (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); dot true 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):
toneweak fill / textsolid fill / textcontrast
neutral$gray100 / $textSecondary$textSecondary / $textInverseweak 6.59 / 6.94; solid 7.38 / 8.86
brand$brandWeak / light $text, dark $ballnone17.22 / 10.13
success$successWeak / $successText$success / $textInverseweak 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 / $textInversethe same (weak = solid)7.36 / 9.28
info$infoWeak / $info$info / $textInverseweak 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-styled pattern of badge.tsx:52 (BadgeContainer) and :105 (BadgeText): the container takes one static variant axis with nine combined keys (neutral, neutralSolid, brand, success, successSolid, warning, danger, info, infoSolid); the public tone and tier resolve to that key inside the wrapper, so the variants stay static for the compiler. Pure maps and metrics go in a framework-free tag-visual.ts, tested in the node-only *-visual.test.ts pattern (packages/ui/jest.config.js). The text is a styled(Text) with the font-size tokens $3 (13/18) and $5 (15/22), not a textRole (bounded-variant-enums bounds textRole to 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개 이상" (into ui-strings.ts). It replaces CornerCountBadge and formatBadgeCount.
  • Live tag (:1938): PulseDot today 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, TrustTierBadge and 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_HAIRLINE in box-sizes.ts; 44 is TOUCH_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); removable with onRemove (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 $hairlineEdge at rest; selected: an ink fill ($text), the label $textInverse at 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 pure choiceRows(widths, room) on the kit's character-class width estimator (Hangul 0.864 em, digits 0.64, …) and useContentWidth(). 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 = radiogroup with a label, radio, aria-checked; several = a group of buttons with aria-pressed (Pressable already folds it into aria-selected on native); Choice ▾ = a button with aria-expanded; the accessible name starts with the visible text.
  • In Tamagui: Choice is Pressable variant="pill" around an XStack, like selection-chip.tsx:92-135; ChoiceSet renders the radiogroup or group and the roving tabIndex (the existing handleRadioKeyDown already needs a [role=radiogroup] ancestor). Pure logic stays in choice-visual.ts, reusing selection-accessibility.ts, which avoids the import cycles check:cycles guards.

Token and infrastructure gaps (to build in W2a) ​

needstatus
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 6not $size / $space tokens: named constants (10 is off the 4-pt grid)
type 13/18, 15/22, 11/16exist 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
LineSlotabsent: today Badge bakes alignSelf: 'flex-start' and 7 call sites override it
the fadeFast animationabsent 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 ringFOCUS_RING is shared by Button, PillNav and StyledInput: for W2, Choice gets its own focusStyle; the shared one changes in W3
a name collisiongray100 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) ​

Badgeusesv2
label130label; 29 are fact-like, 8 sentences or name-bearing, 6 emoji or icons, 1 a count (§ 3)
variant="neutral"53, plus 5 unsetneutral (default)
variant="surface"6neutral (v2 has no tinted grounds)
variant="warning"21warning
variant="live"3danger, 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}4223 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 1accent → brand, info → info, live → danger; muted and primarySolid need a ruling (D1)
premium, primarySubtle, outline0deleted
size="sm"55, plus 7 unsetsm (26 tall at 12 px becomes 24 tall at 13 px)
size="xs"45sm (18 tall at 10 px becomes 24 tall at 13 px: taller, wider, larger text on 45 call sites)
size={size}23the wrappers' own prop; their callers pass xs 21 times, sm 9, md 0
alignSelf7 (center 6, flex-start 1)removed: the host aligns (LineSlot)
icon4none; dot only for live (SessionStripChipBadge's PulseDot, post-row 📌, venue-detail-screen's facility, glyph-fact-row)
BadgeVariant type10 filesTagTone

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) ​

SelectionChipusesv2
label, onPress106, 106the same
selected101the same; the 5 unset are action chips, not selections (venue-hours 3, batch-confirm-row, report-sheet)
sizesm 38, unset 66, expressions 2 (internal)sm 32 and md 40 become the 36 default; tall (44) inside forms
roleradio 30, checkbox 3, button 2, tab 2, unset 69the role moves to the set: radio → radiogroup; checkbox → a button with aria-pressed; tab → Tabs, not Choice (W3)
aria-label34kept only when it starts with the visible text
disabled16the same
flex={1}16the set's fill (the segmented look; the selected state at 19:1)
trailing7no equivalent: 1 chevron, 1 expand chevron, 1 count (notification-center's FilterChip), 4 source or meta texts (J)
justifyContent6 (flex-start 5, space-between 1)list rows and the expand-in-place trigger (J)
testID5kept (Maestro uses venue.nav.*; venue.filter.* and contribution.confirm.* are IDs too)
labelFlex, labelMaxWidth, aria-expanded, aria-selected, leading, a spread1, 2, 1, 1, 1, 1internal 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 ​

componentuses / filesv2 target
DropdownChip23 / 11Choice ▾ 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)
RangeChip3 / 2a range Choice (pickup-filters, discover-filters)
RemovableChip2 / 2a removable Choice (venue-finder-header:98, amenities-field:97)
MarkerPill4 / 4Tag (period-nav-panel, recap-summary-card, week-month-picker, ui/media-hero)
ActionPill2 / 2a ghost sm Button (IA8)
CornerCountBadge2 / 2the Count tag or W3's Badged host (one is apps/web/components/app-nav.tsx:130)
ChipLabel7 / 6 (3 inside ui)the Count tag or Choice internals
hand-built Tag-shaped pills outside uiabout 16Tag

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.

packagefilesMM+JWL
packages/app21521220
features/clubs371981000
features/home412100
features/messaging110000
features/profile741200
features/records200200
features/sessions531692800
packages/ui300003
total12846225523

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, SectionBlock or FieldCard ancestor 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.

wrappertodaycall sitesproposed v2 tone (board evidence)
SessionStatusBadgedraft muted, open accent, locked neutral, in_progress live, completed success, cancelled muted, awaitingScores warning4open → brand (모집 중); in_progress → danger with the pulse dot; cancelled: D1
MatchStatusBadgescheduled neutral, in_progress live, completed success, cancelled muted5the match-state Tags (spec :1892) come with W4's match row
RsvpStatusBadgeconfirmed primarySolid, attended success, waitlisted info, cancelled muted, no_show warning6confirmed → success (D1); the boards draw 예비 neutral where the old map says info
DuesStatusBadgepaid success, unpaid and partial warning, waived muted2완납 success, 미납 warning
ScoreStatusBadgeunverified warning, verified success, disputed live4disputed → danger
SessionPaymentStatusBadgepending warning, paid success, waived muted2입금 필요 warning, 입금 완료 success
ConsentStateBadge / ConsentRequirementBadgegranted success; withdrawn and refused muted; expired warning; superseded neutral / required neutral, optional accent2 / 1refused → neutral; optional → brand
RecruitmentStatusBadgerecruiting and needsQuorum accent, almostFull warning, waitlist info, closed neutral1모집 중 brand, 마감 임박 warning, 마감 neutral
SessionStripChipBadgelive, standing surface, error and warning → warning, success, neutral; a PulseDot icon2 (1 file)the icon becomes dot
WinResultBadgewin success, draw neutral, loss muted; mode="dot" reads $badge* through WIN_RESULT_DOT_TOKENS4the dot mode stays a hand-built circle
EloDeltaBadgesuccess / warning / neutral, alignSelf=center3the same
AchievementStateBadge, ConfidenceTierBadge, VenueTrustBadge, AttendanceStatusBadge, ChallengeStatusBadgeper status-variant-maps.ts1, 1, 3, 1, 1by the same rule
ClubJoinabilityBadge, ClubRecruitBadgeopen accent, approval info, invite_only neutral / accent1, 1the composed label "모집 중 · N자리 남음" breaks the one-to-two-word rule (a content item)
ApprovalModeBadge, ClubRoleBadgeaccent, neutral0, 0dead wrappers
ClubAlarmBadge, TierRangeBadge, ReliabilityPill, EloTierBadge, TrustTierBadgehand-built pills or level chrome1, 2, 1, 7, 8Tag 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, ChipLabel and MarkerPill from @twomore/ui in packages/features, packages/app/src/presentation and apps/* (the ratchet's entry point); (2) a loose Choice outside a ChoiceSet when it sits in a .map callback or beside a sibling Choice (a chevron or removable Choice is exempt); (3) the retargeted rules: bounded-variant-enums swaps the Badge variant spec (neutral|warning|live|surface, strictNonLiteral) for Tag tone in neutral|warning|danger and restricts tier to weak and size to sm outside the wrappers and hero files; no-adhoc-status-badge swaps 'Badge' for 'Tag' and variant for tone (it still checks label; the presentation/components/status/ exemption stays); no-badge-weight-override stays while textRole="badge" exists. Optional: no-hand-built-tag (a stack with a background, a radius and one badge or micro text; 16 such stacks outside packages/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 is no-deprecated-import (eslint.config.mjs:789, a {name, sources, replacement} list with no ratchet); born-clean precedents are no-badge-weight-override (eslint.config.mjs:455, "41 sites swept, zero waivers") and no-hand-cta-band. The ratchet (eslint.ratchet.config.mjs:13, empty; generator scripts/generate-eslint-ratchet.mjs, which writes per-rule file blocks for @twomore/* rules only, spread at eslint.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, tsc catches 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 by docs:lint-rule-docs), the index.js registration, a README entry, and the registration in eslint.config.mjs § 8; plus the COMP-2 row in AGENTS.md, and the regex in packages/app/src/config/__tests__/ui-contracts.test.ts:17 retires.

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 variant to tone (neutral and surface omitted, warning kept, live → danger, accent → brand, success and info kept); ternaries whose branches are both literals; size xs and sm dropped (md kept); alignSelf dropped with a REVIEW line; BadgeVariant → TagTone; the values of typed Record<*, BadgeVariant> literals. It leaves icon, 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 or tall inside a LabeledField or a field row); drops role, aria-selected and a redundant aria-label; hoists flex={1} to the set's fill; turns the parent XStack of a clean set into <ChoiceSet label={…}>, inheriting the label from a LabeledField ancestor or emitting a TODO onto the REVIEW list. It leaves trailing, 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).
  • 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 and fadeFast, ui-strings.ts, the test-kit change, the lint rules and their docs, the codemods, the wrapper files and maps, build-wireframes.mjs's groundedIn and .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 check and 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 ​

  1. 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/ui and would show the mix if Vercel deploys from main (apps/web/vercel.json; the repo does not show the trigger).
  2. 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 $primary border to an ink fill with a check; a Tag never shrinks, so narrow rows need the no-overflow check again.
  3. Dark-mode contrast. $gray100 dark (#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. The check:color-contrast fill pairs walk colors.badge.*, so Tag pairs are invisible to it; add them to s2-contrast.test.ts or ColorToken.
  4. Tests. No snapshots exist. participation-section.test.tsx:203,232,263 assert queryAllByTestId('ui:Badge'); local SelectionChip stand-ins live in ranking-tab.test.tsx:49, settings-screen.test.tsx, venue-correction-modal.test.tsx:214, contribution-editors.test.tsx, contribution-screens.test.tsx and venue-hours-composer.test.tsx; venue-finder.test.tsx mocks RemovableChip and edit-profile-screen.test.tsx DropdownChip; status-badges.test.ts has 14 "only emits valid Badge variants" tests keyed to the variant union; ui-contracts.test.ts:17 greps <Badge variant=…>; badge-visual.test.ts and chip-label-visual.test.ts are replaced; the test-kit PRESSABLES needs Choice; the ESLint plugin tests of the retargeted rules change.
  5. 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 not aria-hidden (the composed one-stop row name is W3 and will break those matches), and testID passes through.
  6. Accessibility. 69 role-less chips move from aria-pressed to radiogroup or group (verify with VoiceOver and TalkBack; native folds aria-pressed into aria-selected); the 3 role="checkbox" chips become pressed buttons; the group label is a new required input, and 40 of the 83 clean sets have no labelling ancestor.
  7. 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 needs fadeFast in both Tamagui configs; the pulse is Animated-based; Android numerals need includeFontPadding: false (see club-alarm.tsx); Korean wrapping relies on lineBreakStrategyIOS and .is_Text { word-break: keep-all }, so the text is built on the shared Text.
  8. apps/web uses only CornerCountBadge and formatBadgeCount (app-nav.tsx:130); both stay in W2, and the Count tag and the Badged host follow in W3. apps/web/public/design-system/ui_kits/mobile/*.jsx are static kit prototypes with their own Badge.
  9. 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=1 is 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, and AGENTS.md COMP-2.
  10. 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).
  11. Hazards while adding the infrastructure: import cycles (check:cycles: keep pure logic in -visual.ts files); no-korean-string-literal (strings through uiStrings()); require-reduced-motion-gate for any animation= in packages/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): muted becomes 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 xs kept 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 no alignSelf passthrough.
  • 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 Badged host 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-tag waits 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_RING changes 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)FilesWhat replaces the old piece
기록 (hub)A/match-history-row, A/match-history-scope-selector, R/cards/top-partners-card, R/record-detail-screenfacts 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-rowthe 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-sectionthe tier Tag and the 신뢰 facts (library § 4 Trust); 받은 매너 태그 leaves the public profile (M6)
알림 (list-a)H/notification-center/componentstime 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-screenthe Feed item's facts; the source as a Caption row; the FilterSheet behind one Choice ▾; Tabs; facts, not chips
연습 기록 (list-b)P/practice-log-screenthe 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-rowthe 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-editorcheck 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-editorcheck 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-summarycheck 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-modalradio Cells, one reason per row (the owner's "one per row")
Kept as it isS/guest-rating-blocka 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.

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