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
- The boards are mostly kit already: 1,932 kit JSX sites against 203 raw host elements, with 6 hex literals, 2
'white'and 2font: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). - The kit's own spacing tokens are dead:
FVK_SPACE,FVK_RADIUSandFVK_TARGET(flat-v2-kit.jsx:167-169) are read nowhere, which is why 309<Gap h={N}>sites carry raw pixels. - Nothing guards board source: ESLint ignores
docs/twomore_design_system/**(eslint.config.mjs:50); the clones and file-size gates exclude it; the Chrome-basedverify:flat-v2-boardsandmeasure:board-heightsare in neitheryarn check, the husky hooks nor CI. - 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) | Draws | Kit today → target | Frames | Boards |
|---|---|---|---|---|---|
| 1 | Gap hub:160, list-a:22, list-b:25, forms:27; AuGap auth:80; DtlGap detail:135 | a <div style={{height}}/> spacer | no spacer or seam API → FvkGap{seam} | 732 frames, 1,332 renders | 6 ✱ |
| 2 | frame wrappers: Ph hub:120, list-b:159, forms:35; Fr list-a:28; AuPh auth:24; DtlP detail:17; dead DtlS :68 and DtlSlice :85 | FvkFrame + FvkPhone with fit, pins, cta, band, android and keyboard props | the kit's header (kit:31-34) says "a board keeps no wrapper"; FvkFrame phone={{}} lacks fit, pins, band and android → extend it | 1,017 | 6 ✱ |
| 3 | G, AuG, DtlG, Pg, Spec ×3, Dim ×3, Ov list-a:67, DtlOver detail:113 | one-line aliases of FvkGroup, FvkSection, FvkSpecTable, FvkScrim | covered; FvkSection needs ideal and spec | Dim/Ov in 185 | 6 ✱ |
| 4 | Cells hub:165, list-b:226, forms:129 | a margin: -12px 0 seam trim, identical in 3 files | FvkCell has last, not first → add first or FvkCellGroup | 76 (285 renders) | 3 ✱ |
| 5 | rail 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:176 | rail padding, column gap, bleed margin, an h1 block | FvkRail is padding only (kit-controls:1105) → top, bottom, gap; FvkFieldStack, FvkBleed, FvkPageTitle | Fields 125, Bleed 30, DtlRail 33, AuRail 21, Controls 24, AuTitle 12 | 5 ✱ |
| 6 | scrolled frames: Scroll forms:140 (19 hand-typed offsets), HubPgHomeScrolled hub:791, HubPgClubScrolled hub:811, DtlSd detail:824, AuPgGate auth:1215 | an overflow clip, a negative margin and a pinned-layer wrapper (hub:796, 816, 2420) | none → FvkScrolled, FvkPinnedLayer | forms 88, hub 2, detail 1, auth 1 | 4 ✱ |
| 7 | tier explainer: HubPgTierBody hub:1400, HubPgTrustBody hub:1708, LbTierBody list-b:1968 | a sheet body of tier rows | none → FvkTierGuide{kind} (the first and third are identical, the five ELO/NTRP rows typed twice) | 1 each | 2 ✱ |
| 8 | HubFacesTile hub:513 | faces on a gray100 square as a Feed thumb | → FvkThumb kind="faces" | 30 | 1 |
| 9 | HubRecTrio hub:1103, HubRecPeople hub:1171 | three person tiles (avatar 56, name, rate, record), PersonRows at 1.5× | → FvkStrip face="people" | 9 | 1 |
| 10 | LbScopeHead list-b:1684 | a Section head whose note and action fold at 1.5× | FvkSectionHead should own the fold | 12 | 1 |
| 11 | DtlDm detail:6564 (178 lines) | the DM thread screen | FvkThread and FvkComposer exist → FvkThreadScreen | 21 | 1 |
| 12 | DtlTnMatch detail:3341 | a winner pick of two tall fill Choices; 'white' on ink (:3343) | → FvkSidePick and an on-ink palette key | 4 | 1 |
| 13 | HubZone hub:456, HubClubZones hub:199, HubGrammarStack hub:245 | the 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) → FvkZoneMark | 3 | 1 |
| 14 | coachmark spotlight hub:855, hub:2729 | a scrim, the lifted control, a hand-placed pointer | FvkCoachmark draws the bubble only → FvkSpotlight | 2 | 1 |
| 15 | stale strip over a band: hub:2542 (top: 100), detail:762 (top: 92) | FvkOfflineStrip placed by hand | → FvkMediaBand stale | 2 | 2 ✱ |
| 16 | Bar forms:574; the private FvkCSk called at hub:1612 | a skeleton bar | FvkSkBar (kit:1144) is the same but for flexShrink: 0 → a pure swap | 7 | 2 ✱ |
| 17 | small 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:2484 | flex rows and wrappers | options on FvkButton (align), FvkEmptyState (secondary), FvkNotice (items), FvkPersonRow (expand); a new FvkListFooter | 1 to 18 each | mostly 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
| board | style objects | raw hosts (props / bare) | colour | type | spacing keys | size keys | decor keys | position keys | Gap h sites (renders) | canned copy |
|---|---|---|---|---|---|---|---|---|---|---|
| hub | 44 | 40 / 2 | 6 hex + 1 'white' | 2 | 29 | 11 | 16 | 49 | 25 (141) | 15 |
| list-a | 8 | 9 / 5 <br> | 0 | 0 | 3 | 7 | 0 | 4 | 110 (186) | 39 |
| list-b | 8 | 7 / 2 | 0 | 0 | 6 | 2 | 0 | 0 | 32 (99) | 3 |
| detail | 24 | 23 / 59 | 1 'white' | 0 | 15 | 4 | 1 | 17 | 49 (356) | 82 |
| forms | 14 | 13 / 0 | 0 | 0 | 8 | 3 | 4 | 0 | 89 (545) | 3 |
| auth | 24 | 24 / 19 | 0 | 0 | 15 | 6 | 1 | 0 | 4 (5) | 7 |
| flows | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| total | 122 | 116 / 87 | 6 + 2 | 2 | 76 | 33 | 22 | 70 | 309 (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:264color={opt.on ? 'white' : 'ink'}(not a palette key); detail:3343'white'on ink; hub:226, 484font: '700 10px/14px system-ui'. - Decor and seams: radius 6 and 4 (hub:212, 223, 470, 481); forms:584-586 (the
Barclone); detail:4793 opacity 0.55 (the app'sTERMINAL_MUTE_OPACITY); auth:936; negative margins at hub:680, 707, 802, 825, detail:825, auth:1215 and theCells−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);figureW44 ×5 and 24 ×1,rankW20 ×4,tagW104 and 112 ×7. - Frame sizing is hand-typed three ways, 542 numbers: the tables
HUB_BOTTOM(87),LB_BOTTOM(120) andFM_NEED(277), measured byscripts/measure-board-heights.mjs; explicitFr h×19,AuPh h×2 and detailh:×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
- The cast: four people tables (
PPLlist-a:77, list-b:261, forms:171;DTL_PEOPLEdetail: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. - 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;HubClubFeedits own); venue facts typed in list-a and list-b; a session item built in hub (HUB_FEED,mine/dueprops) and list-a (sesItem,{text, tone}facts) with different structures. - 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 attop: 100andtop: 92. 9. The locale picker five helpers where the kit hasfvkL(kit:1066). 10. The field seam 32 drawn three ways. 11. Canned error and offline copy in 149 literals ("연결을 확인하고 다시 시도해 주세요", "오프라인이에요", "연결되면 자동으로 다시 불러와요" and their English twins), whileFvkEmptyState kind="error"andkind="offline"supply none;ErrandOfftwice (list-b:285, 293; forms:147, 155). 12. Private kit API used from boards (FvkCSkhub:1612,fvkCParseScoredetail: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.
| Lane | Files | Content | After |
|---|---|---|---|
| L0 guard | scripts/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 core | flat-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.mjs | FvkGap 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 content | flat-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.mjs | FvkFeedItem context + data and one label composer; FvkCell first; FvkTierGuide; FvkStrip face="people"; FvkThumb kind="faces"; the Section head fold; FvkSidePick; default figureW/rankW per context | K1 |
| K3 controls | flat-v2-kit-controls.jsx, flat-v2-lib-controls.jsx, flat-v2-lib-overlays.jsx, flat-v2-lib-social.jsx | FvkEmptyState 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 FvkNotice | K1 |
| B1, B2, B3 | flat-v2-hub.jsx / flat-v2-list-a.jsx / flat-v2-list-b.jsx | delete the local scaffolding and projections, call the kit, swap Bar and FvkCSk for FvkSkBar | K1, K2, K3 |
| B4, B5, B6 | flat-v2-detail.jsx / flat-v2-forms.jsx / flat-v2-auth.jsx and flat-v2-flows.jsx | the same, and the stale "292 frames" | B1 to B3 |
| L5 tighten | the baseline and the lint | rules at zero turn strict; optionally a post-layout fit replacing the three tables | B1 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) andscripts/lib/flat-v2-board-lint.mjs(pure rules) on thetypescriptAST (a declared dependency, as inscripts/lib/i18n-codemod.mjs), with a baseline JSON and a test; a guard test fails on any newflat-v2-*.jsxnot classified as kit, library or page. - Rules and their HEAD findings (hub / list-a / list-b / detail / forms / auth):
color-literal6/0/0/0/0/0;palette-key1/0/0/0/0/0;type-style2/0/0/0/0/0;decor-style16/0/0/1/4/1;space-style76,size-style33,pos-style70;host-element40/9/7/23/13/24;gap-literal25/110/32/49/89/4;kit-private1/0/0/1/0/0;frame-wrapper2/2/3/4/3/2;cast-table3/1/2/1/1/0;kit-copy15/39/3/82/3/7;kit-constant2/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-privateandpalette-keygo strict at once; the rest go strict as their lanes land. - Wiring:
docs:lint-boardsinpackage.json, run insidedocs:check(so inyarn checkand pre-push); the test intest: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 matchespackages/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):
| Class | Sites / files | Examples | Bypasses |
|---|---|---|---|
a literal fontWeight on Text | 186 / 116 (+16 state-based) | club-attendance-screen.tsx:250, club-mission-card.tsx:71 (800), club-challenges-screen.tsx:453 | Text roles (the set lacks strong variants) |
| box chrome by hand (fill, radius, padding) | 25 / 24 | recap-summary-card.tsx:182, 203, club-session-card.tsx:136, dues-attest-modal.tsx:162 | Card, Well, SummaryCard, Callout |
| dots and swatches as childless circles | 17 / 13 | dm-thread-row.tsx:174, day-calendar-picker.tsx:222, 229, profile-screen.tsx:240 | Dot, PulseDot |
| icon and avatar bubbles | about 10 | entry-icon-bubble.tsx:18, settings-signal-preferences-screen.tsx:65, practice-log-screen.tsx:163, notification-row-shell.tsx:161 | none: @twomore/ui has no IconBubble |
| the tier row, forked | 2 | tier-info-sheet.tsx:83, trust-tier-info-sheet.tsx:113 | the same fork as the boards |
| meter bars by hand | 4 / 2 | club-card.tsx:419, 425, match-scoreboard.tsx:343, 368 | ProgressBar, MiniBarMeter |
raw ActivityIndicator | 10 / 8 | decor-section.tsx:282, post-create-screen.tsx:327, settings/image-section.tsx:97, 155, edit-profile-screen.tsx:422 | none: @twomore/ui has no Spinner |
| a selectable tile by hand | 1 | style-option-row.tsx:99 | SelectionCard |
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.