Skip to content

Shared foundation — an audit of the flat v2 page boards against the kit (2026-10-19) ​

Status: Reference · Date: 2026-10-19

Read-only audit after the owner's standing rule of 2026-10-19 ("I want our components across pages to have the same shared foundation. That eliminates the possibility of UI/UX divergence as well as hardcoding."; components canon, the paragraph under the intro). It asks where the seven page boards (hub, list-a, list-b, detail, forms, auth, flows) do not compose the kit (flat-v2-kit.jsx and its flat-v2-kit-*.jsx parts), and how to keep them composing it. Read at HEAD cb14f39f0 with the repo's own typescript API, and each board rendered in Node to count frames per board-local function: hub 95, list-a 173, list-b 120, detail 277, forms 285, auth 67, flows 78 (1,095 frames, matching the built HTML). Line references are HEAD's and shift as the lanes land.

Bottom line ​

  1. The boards are mostly kit already: 1,932 kit JSX sites against 203 raw host elements, with 6 hex literals, 2 'white' and 2 font: in all. The debt is scaffolding copied per board, object-to-row projections written per board, about 20 raw patterns, and kit constants and canned copy retyped (149 copy literals; 291, 250, 742, 743 and 800 in about 20 places).
  2. The kit's own spacing tokens are dead: FVK_SPACE, FVK_RADIUS and FVK_TARGET (flat-v2-kit.jsx:167-169) are read nowhere, which is why 309 <Gap h={N}> sites carry raw pixels.
  3. Nothing guards board source: ESLint ignores docs/twomore_design_system/** (eslint.config.mjs:50); the clones and file-size gates exclude it; the Chrome-based verify:flat-v2-boards and measure:board-heights are in neither yarn check, the husky hooks nor CI.
  4. A node-only check on the TypeScript AST runs in 0.6 s over the seven boards and finds 827 findings in 14 rules at HEAD: the baseline for a ratchet (§ 5).

1. Page-local components that draw patterns ​

548 local functions: 87 draw with raw elements, 35 alias a single kit component (26 of them in list-a), and the rest assemble pages, sections or overlays, which is legitimate. ✱ marks a pattern on more than one board.

#Function (HEAD)DrawsKit today → targetFramesBoards
1Gap hub:160, list-a:22, list-b:25, forms:27; AuGap auth:80; DtlGap detail:135a <div style={{height}}/> spacerno spacer or seam API → FvkGap{seam}732 frames, 1,332 renders6 ✱
2frame wrappers: Ph hub:120, list-b:159, forms:35; Fr list-a:28; AuPh auth:24; DtlP detail:17; dead DtlS :68 and DtlSlice :85FvkFrame + FvkPhone with fit, pins, cta, band, android and keyboard propsthe kit's header (kit:31-34) says "a board keeps no wrapper"; FvkFrame phone={{}} lacks fit, pins, band and android → extend it1,0176 ✱
3G, AuG, DtlG, Pg, Spec ×3, Dim ×3, Ov list-a:67, DtlOver detail:113one-line aliases of FvkGroup, FvkSection, FvkSpecTable, FvkScrimcovered; FvkSection needs ideal and specDim/Ov in 1856 ✱
4Cells hub:165, list-b:226, forms:129a margin: -12px 0 seam trim, identical in 3 filesFvkCell has last, not first → add first or FvkCellGroup76 (285 renders)3 ✱
5rail boxes: Fields forms:107 (gap 32), Stack forms:811, Bleed forms:116, AuRail auth:98, AuTitle auth:86, DtlRail detail:123, Controls list-b:237, ChoiceRow hub:176rail padding, column gap, bleed margin, an h1 blockFvkRail is padding only (kit-controls:1105) → top, bottom, gap; FvkFieldStack, FvkBleed, FvkPageTitleFields 125, Bleed 30, DtlRail 33, AuRail 21, Controls 24, AuTitle 125 ✱
6scrolled frames: Scroll forms:140 (19 hand-typed offsets), HubPgHomeScrolled hub:791, HubPgClubScrolled hub:811, DtlSd detail:824, AuPgGate auth:1215an overflow clip, a negative margin and a pinned-layer wrapper (hub:796, 816, 2420)none → FvkScrolled, FvkPinnedLayerforms 88, hub 2, detail 1, auth 14 ✱
7tier explainer: HubPgTierBody hub:1400, HubPgTrustBody hub:1708, LbTierBody list-b:1968a sheet body of tier rowsnone → FvkTierGuide{kind} (the first and third are identical, the five ELO/NTRP rows typed twice)1 each2 ✱
8HubFacesTile hub:513faces on a gray100 square as a Feed thumb→ FvkThumb kind="faces"301
9HubRecTrio hub:1103, HubRecPeople hub:1171three person tiles (avatar 56, name, rate, record), PersonRows at 1.5×→ FvkStrip face="people"91
10LbScopeHead list-b:1684a Section head whose note and action fold at 1.5×FvkSectionHead should own the fold121
11DtlDm detail:6564 (178 lines)the DM thread screenFvkThread and FvkComposer exist → FvkThreadScreen211
12DtlTnMatch detail:3341a winner pick of two tall fill Choices; 'white' on ink (:3343)→ FvkSidePick and an on-ink palette key41
13HubZone hub:456, HubClubZones hub:199, HubGrammarStack hub:245the spec layer's dashed outline and label (#C2255C, #FFFFFF, 700 10px/14px system-ui)the kit holds the same hex three times (FVK_FOCUS_INK kit:1616, the fold mark kit:615, :625) → FvkZoneMark31
14coachmark spotlight hub:855, hub:2729a scrim, the lifted control, a hand-placed pointerFvkCoachmark draws the bubble only → FvkSpotlight21
15stale strip over a band: hub:2542 (top: 100), detail:762 (top: 92)FvkOfflineStrip placed by hand→ FvkMediaBand stale22 ✱
16Bar forms:574; the private FvkCSk called at hub:1612a skeleton barFvkSkBar (kit:1144) is the same but for flexShrink: 0 → a pure swap72 ✱
17small ones: a centred ghost button ×3, a load-more spinner row ×2, ErrSummary forms:2986, PtBody list-a:3851, EpPage forms:4041, DuAttest list-a:2484flex rows and wrappersoptions on FvkButton (align), FvkEmptyState (secondary), FvkNotice (items), FvkPersonRow (expand); a new FvkListFooter1 to 18 eachmostly 1

Dead declarations: DtlS, DtlSlice (detail:68, :85), MAPT hub:161, CC_QUICK and CC_QUICK_NOREGION (forms:1519-1520).

Object-to-row projections. FvkPersonRow already takes context and data and resolves its second line itself ("boards pass data and a context, never L2 strings"), and the person-row factories follow it. FvkFeedItem (kit-content:1776) still takes raw over, facts, who, time, counts and label, so every board writes the club, session, venue and 대항전 mapping itself: list-a clubItem :229, clubArchived :258, sesItem :449, venueItem :503, vnRow :3321, chItem :3593; list-b vnRow :2595 with VN_X :2543; hub HUB_FEED :553 and HubClubFeed :1882; detail :5544; and the spoken labels three ways (list-a said :101, list-b inline :2614, hub hand-typed :553-660). The fix is a context + data resolver for club, session, venue, 대항전, post and result items with one label composer, the board's counterpart of the app's buildSessionInfoModel.

2. Hardcoded values ​

boardstyle objectsraw hosts (props / bare)colourtypespacing keyssize keysdecor keysposition keysGap h sites (renders)canned copy
hub4440 / 26 hex + 1 'white'22911164925 (141)15
list-a89 / 5 <br>003704110 (186)39
list-b87 / 200620032 (99)3
detail2423 / 591 'white'015411749 (356)82
forms1413 / 000834089 (545)3
auth2424 / 1900156104 (5)7
flows0000000000
total122116 / 876 + 2276332270309 (1,332)149
  • Gaps: 8 ×61, 12 ×48, 16 ×58, 20 ×11, 24 ×89, 32 ×19, 40 ×22, 120 ×1, 11 computed. The app has a named ladder (packages/ui/src/seams.ts: element 4, item 8, block and chrome 12, rail, hero and band 16, section 24, tail 48); the boards have none, and type a tail of 40 where the ladder says 48.
  • Colour and type: hub:211, 224, 225, 469, 482, 483 (1.5px dashed #C2255C, #FFFFFF); hub:264 color={opt.on ? 'white' : 'ink'} (not a palette key); detail:3343 'white' on ink; hub:226, 484 font: '700 10px/14px system-ui'.
  • Decor and seams: radius 6 and 4 (hub:212, 223, 470, 481); forms:584-586 (the Bar clone); detail:4793 opacity 0.55 (the app's TERMINAL_MUTE_OPACITY); auth:936; negative margins at hub:680, 707, 802, 825, detail:825, auth:1215 and the Cells −12 trim.
  • Kit constants retyped: the keyboard 291 and 250 (list-a:2006, 3403; list-b:3019; detail:3887, 6568; forms:30; auth:21; bottom={250} hub:2875, list-a:3376); height={800} (list-a:2006, 3404; forms:1028, 2134); sheet caps 742 and 743 ×6 and 560 ×2 (88% of 844); figureW 44 ×5 and 24 ×1, rankW 20 ×4, tagW 104 and 112 ×7.
  • Frame sizing is hand-typed three ways, 542 numbers: the tables HUB_BOTTOM (87), LB_BOTTOM (120) and FM_NEED (277), measured by scripts/measure-board-heights.mjs; explicit Fr h ×19, AuPh h ×2 and detail h: ×37, which nothing measures; 19 hand-typed scroll offsets in forms (KS_*, :6322-6328). detail:8073 claims "292 frames"; the board renders 277.
  • Legitimate layout glue (spacers between kit pieces, pinned-layer placement, centring wrappers, phone content-box arithmetic) stays in the boards but goes through a kit API so that no raw number remains.

3. Divergence: one thing drawn two ways ​

  1. The cast: four people tables (PPL list-a:77, list-b:261, forms:171; DTL_PEOPLE detail:147; hub inline :1045, :1093, :1559) give one person different portraits (김민준 2 and 0 on hub, 1 on list-a, list-b and forms, 0 on detail; 최유나, 한소희, 박지훈 and 이서연 likewise) and two English names ("Jihoon Park" list-b:264, "Jihun Park" detail:162); "강남 라켓클럽" is typed 74 times, "올림픽공원 테니스장" 65.
  2. Club, venue and session items: the same club projected three times (list-a CLUB_X.gangnam :133-142 and detail:5544 with the same values typed twice; HubClubFeed its own); venue facts typed in list-a and list-b; a session item built in hub (HUB_FEED, mine/due props) and list-a (sesItem, {text, tone} facts) with different structures.
  3. Spoken labels three ways (above). 4. The tier explainer three bodies — and the same fork in the app (tier-info-sheet.tsx:83, trust-tier-info-sheet.tsx:113). 5. The skeleton bar three implementations. 6. Frame sizing three mechanisms. 7. Scrolled frames four ways. 8. The stale strip at top: 100 and top: 92. 9. The locale picker five helpers where the kit has fvkL (kit:1066). 10. The field seam 32 drawn three ways. 11. Canned error and offline copy in 149 literals ("연결을 확인하고 다시 시도해 주세요", "오프라인이에요", "연결되면 자동으로 다시 불러와요" and their English twins), while FvkEmptyState kind="error" and kind="offline" supply none; Err and Off twice (list-b:285, 293; forms:147, 155). 12. Private kit API used from boards (FvkCSk hub:1612, fvkCParseScore detail:5403).

4. Consolidation plan ​

Every lane is a pixel-neutral extraction; anything that changes what a frame draws needs a wireframe pass with the owner. At most three lanes at once, at most seven files each, never one file in two lanes; Chrome jobs one at a time.

LaneFilesContentAfter
L0 guardscripts/lint-flat-v2-boards.mjs, scripts/lib/flat-v2-board-lint.mjs, its baseline JSON and test, package.json, AGENTS.md, docs/canon/components.md§ 5—
K1 coreflat-v2-kit.jsx, flat-v2-kit-chrome.jsx, flat-v2-lib-behaviour.jsx, flat-v2-lib-chrome.jsx, scripts/lib/board-heights.mjs, scripts/measure-board-heights.mjs, scripts/verify-flat-v2-boards.mjsFvkGap and FVK_SEAM mirroring seams.ts, with FVK_SPACE/FVK_RADIUS wired in; FVK_PHONE constants (844, 800, the keyboards, the sheet cap) and FvkCtx.height; FvkFrame phone fit, pins, cta, band, android, keyboard and tail; FvkSection ideal/spec; FVK_CAST (people, clubs, venues); FvkZoneMark, FvkScrolled, FvkPinnedLayer, FvkMediaBand stale, FvkSpotlight; an on-ink palette key; height tables for list-a, detail and auth—
K2 contentflat-v2-kit-content.jsx, flat-v2-lib-rows.jsx, flat-v2-lib-surfaces.jsx, scripts/lib/flat-v2-verify-feed.mjs, scripts/lib/flat-v2-verify-rows.mjsFvkFeedItem context + data and one label composer; FvkCell first; FvkTierGuide; FvkStrip face="people"; FvkThumb kind="faces"; the Section head fold; FvkSidePick; default figureW/rankW per contextK1
K3 controlsflat-v2-kit-controls.jsx, flat-v2-lib-controls.jsx, flat-v2-lib-overlays.jsx, flat-v2-lib-social.jsxFvkEmptyState default copy, secondary, centered; FvkRail{top, bottom, gap}, FvkFieldStack, FvkBleed, FvkPageTitle; sheet caps and keyboard offsets from FVK_PHONE; FvkThreadScreen; FvkListFooter; FvkButton align; an error summary on FvkNoticeK1
B1, B2, B3flat-v2-hub.jsx / flat-v2-list-a.jsx / flat-v2-list-b.jsxdelete the local scaffolding and projections, call the kit, swap Bar and FvkCSk for FvkSkBarK1, K2, K3
B4, B5, B6flat-v2-detail.jsx / flat-v2-forms.jsx / flat-v2-auth.jsx and flat-v2-flows.jsxthe same, and the stale "292 frames"B1 to B3
L5 tightenthe baseline and the lintrules at zero turn strict; optionally a post-layout fit replacing the three tablesB1 to B6

Every lane's acceptance: the board lint, the build, the Chrome verifier on its boards and on flat-v2-flows (whose 78 steps reuse frames by id), and measure-board-heights --check with no table diff — any diff is a bug or an owner-visible change. Lanes never edit the baseline; the lead records each shrink with --update-baseline.

5. Prevention: keep the page boards kit-only ​

  • Files: scripts/lint-flat-v2-boards.mjs (CLI) and scripts/lib/flat-v2-board-lint.mjs (pure rules) on the typescript AST (a declared dependency, as in scripts/lib/i18n-codemod.mjs), with a baseline JSON and a test; a guard test fails on any new flat-v2-*.jsx not classified as kit, library or page.
  • Rules and their HEAD findings (hub / list-a / list-b / detail / forms / auth): color-literal 6/0/0/0/0/0; palette-key 1/0/0/0/0/0; type-style 2/0/0/0/0/0; decor-style 16/0/0/1/4/1; space-style 76, size-style 33, pos-style 70; host-element 40/9/7/23/13/24; gap-literal 25/110/32/49/89/4; kit-private 1/0/0/1/0/0; frame-wrapper 2/2/3/4/3/2; cast-table 3/1/2/1/1/0; kit-copy 15/39/3/82/3/7; kit-constant 2/9/4/0/2/0 — 827 in all.
  • The ratchet: the baseline is keyed by file, top-level declaration, rule and count, so moving lines does not churn it; a new key or a higher count fails; a lower count passes and asks for --update-baseline, which refuses to grow; an intentional exception is a visible pragma (// kit-only: allow <rule> -- <reason>) that the report lists. kit-private and palette-key go strict at once; the rest go strict as their lanes land.
  • Wiring: docs:lint-boards in package.json, run inside docs:check (so in yarn check and pre-push); the test in test:scripts; one pointer line in the components canon; each rule with a positive and a negative fixture. Also a parity test that a board seam ladder matches packages/ui/src/seams.ts.

6. The same problem in app code (a census) ​

packages/features/* and packages/app/src/presentation, 530 .tsx files. The lint set is strong (an empty ratchet; waivers: 17 no-padding-top-in-features, 13 no-inline-style-with-vars, 5 no-raw-fontsize, 4 no-numeric-spacing, 2 no-raw-icon-size; no colour literal strings left). Classes with no rule yet (Badge and SelectionChip are covered by the W2 research):

ClassSites / filesExamplesBypasses
a literal fontWeight on Text186 / 116 (+16 state-based)club-attendance-screen.tsx:250, club-mission-card.tsx:71 (800), club-challenges-screen.tsx:453Text roles (the set lacks strong variants)
box chrome by hand (fill, radius, padding)25 / 24recap-summary-card.tsx:182, 203, club-session-card.tsx:136, dues-attest-modal.tsx:162Card, Well, SummaryCard, Callout
dots and swatches as childless circles17 / 13dm-thread-row.tsx:174, day-calendar-picker.tsx:222, 229, profile-screen.tsx:240Dot, PulseDot
icon and avatar bubblesabout 10entry-icon-bubble.tsx:18, settings-signal-preferences-screen.tsx:65, practice-log-screen.tsx:163, notification-row-shell.tsx:161none: @twomore/ui has no IconBubble
the tier row, forked2tier-info-sheet.tsx:83, trust-tier-info-sheet.tsx:113the same fork as the boards
meter bars by hand4 / 2club-card.tsx:419, 425, match-scoreboard.tsx:343, 368ProgressBar, MiniBarMeter
raw ActivityIndicator10 / 8decor-section.tsx:282, post-create-screen.tsx:327, settings/image-section.tsx:97, 155, edit-profile-screen.tsx:422none: @twomore/ui has no Spinner
a selectable tile by hand1style-option-row.tsx:99SelectionCard

Cheap follow-ups: no-hand-built-* rules in the existing family (no-hand-built-person-row, no-hand-hairline), starting as ratchets; an IconBubble and a Spinner in @twomore/ui; strong text roles so the 186 weight overrides can go. These belong with W3 (primitives) and W4 (pages).

The lead's order (2026-10-19) ​

The board batches already queued from the owner's check (the monthly activity grid and the copy row, then the 회비 Block, the 대항전 rename and the Strip sweep) land first, since they touch the same files. Then L0 (the guard, wired into docs:check), K1, K2 and K3, the B lanes board by board — each after the owner's marks on that board are in, so a consolidation never races a design change — and L5. The app-code classes join W3 and W4.

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