Middle-dot joins, meta/sub text styling, graphical asset placement
Status: Accepted · Date: 2026-09-06 · Purpose: inventory behind three owner directives (no middle-dot joins; meta and sub texts properly configured; graphics left and top).
Scope covered: i18n string tables, template/.join joins, literal {' · '} JSX children in packages/features/** and packages/app/src/presentation/**, the flat-detail wireframe mock copy, the nine packages/ui primitives, and avatar/logo/thumbnail placement across hero/row/card call sites. No files were modified.
Headline finding for all three directives: the packages/ui primitives (type-hero.tsx, media-hero.tsx, fact-row.tsx, list-row.tsx, section-header.tsx) were already rewritten under the 2026‑09‑06 seam audit to comply with (1) and (3) — TypeHero.meta/MediaHero.meta accept string | readonly string[] specifically so "each entry is its own line (no middle-dot joins)," and both heroes only expose a leading/avatar slot (left side), never a right slot. The violations that remain are almost entirely in call sites that still pre-join facts with ' · ' into a single string before handing it to the compliant primitive, plus two components (club-photo-seam.tsx, and MediaHero's own seamRight slot as used by public-club-profile-screen.tsx) that anchor avatar clusters bottom-right.
Table A — Middle-dot joins
A1. i18n string tables (packages/app/src/config/i18n/{ko,en}/*.ts)
438 raw · matches across ko/en. After excluding source-comments/doc-prose (e.g. clubs.ts:947,979-1432 commentary, feed-auto.ts:23 doc example), the renderable-string count is ~410, concentrated in these files. Counts are ko+en combined; values shown are representative.
| File (ko/en) | Count | What it joins | Sample (file:line → value) | Alternative |
|---|---|---|---|---|
signals.ts | ko 33 / en 33 | push-notification body: club·title·date, round·court·score, dues amount·days | ko/signals.ts:91 `${clubName} · ${title} · ${date}` | Push bodies are single-line by OS constraint — keep the dot only here (notification tray text is an accepted exception), but stop reusing the same joiner for in-app meta rendering of the same fact set. |
sessions.ts | ko 33 / en 26 | round·match count·start time, court·round, format·tier·friendly, score rule strings | ko/sessions.ts:1354 `라운드 ${round} · ${matchCount}경기 · 예상 시작 ${estStart}`; en/sessions.ts:1331 same in EN | Split into a meta: string[] (round / count / start) so TypeHero/SectionHeader render 3 lines instead of one joined string. |
clubs.ts | ko ~55 / en ~20 | region·district, tier ladder prose ("KTA·KATO"), review labels ("지역 · 테니스장"), partsJoiner: ' · ' constant | ko/clubs.ts:802 partsJoiner: ' · '; ko/clubs.ts:1657 reviewLocationLabel: '지역 · 테니스장'; ko/clubs.ts:1666 reviewStyleLabel: '구성 · 형식 · 실력' | partsJoiner is a named join utility consumed by club-admin-attention.ts:67 — replace with an array-returning helper and let the caller render separate lines/chips. Field-group labels (구성 · 형식 · 실력) are fine as labels (they name a merged field), not violations. |
home.ts | ko 33 / en 33 | weather severity strings (temp·warning, wind·warning, PM2.5·level, UV·level), rank·count, recap "events·new members" | ko/home.ts:431 `${temp}°C · 폭염경보 수준`; en/home.ts:868 `${enCount(count,'log')} · ${totalMin} min total` | Weather chip is a single fact+severity pair — acceptable as one Badge label; the recap/rank ones (home.ts:214,223) are two distinct facts and should become a meta: string[]. |
venues.ts | ko 5 / en 5 | screen title·suffix, permission notice·hint, correction status·hint | ko/venues.ts:173 '위치 권한이 필요해요 · 기기 설정에서 허용해 주세요' | These are notice/hint sentences, not fact joins — leave as prose, but the fact-bearing ones (venues.ts:441 "메뉴 요금 · 설명과 단위 유지") should be a FactRow label, not a dotted string. |
settings.ts | ko 24 / en 24 | privacy legal date·version, debug scenario label/desc (venue·tier, day-count·label) | ko/settings.ts:893 '시행일: 2026-05-18 · 버전: KR-1.0.0'; ko/settings.ts:1136 desc: '잠실 · Silver-Gold' | Legal effective-date line: acceptable single caption. Debug-menu desc strings are dev-only (not user-facing) — lowest priority. |
ranking.ts | ko 2 / en 2 | rank·hash, band·"est." | ko/ranking.ts:328 `내 순위 · ${rank}위` | Two facts (label + value) — should be FactRow label="내 순위" value="{rank}위", not a joined string. |
matches.ts | ko 4 | format description prose, player·court·round count | ko/matches.ts:215 `${players}명 · ${courts}코트 · ${rounds}라운드` | 3 facts → StatStrip cells, not one string. |
tournaments.ts | ko 2 / en 2 | setup label, format·round | ko/tournaments.ts:109 `${formatLabel} · ${round}라운드 진행 중` | Split format (badge) from round-progress (meta line). |
activity.ts | ko 1 / en 1 | duration·count | ko/activity.ts:63 `총 ${hours}시간 ${mins}분 · ${count}회` | Two facts on one stat tile — StatTile subValue. |
common.ts | ko 1 / en 1 | empty-state text·pending count | ko/common.ts:214 `연결이 없어요 · ${n}개 전송 대기` | Fine as one sentence (not two facts). |
notifications.ts | ko 1 / en 1 | club·new-post count | ko/notifications.ts:36 `${clubName} · 새 글 ${count}개` | Notification-tray text — acceptable exception per above. |
onboarding.ts | ko 3 | headline word-pairs ("ELO·매너"), consent label | ko/onboarding.ts:9 'ELO·매너로 믿고 치는 상대' | Marketing copy, not a fact-join — out of scope for this law. |
share.ts | ko 1 / en 1 | share-card matches·win-rate | ko/share.ts:37 `🎾 ${matches}경기 · 승률 ${winRate}%` (double-spaced dot) | Share-card text renders as a flat image caption outside the app's own component system — lower priority, but the double-space around the dot is also a raw inconsistency worth normalizing. |
Bullet (•) joins | ko 3 / en 3 | settings.ts account-deletion warning bullets | ko/settings.ts:712-714, en/settings.ts:749-751 | These are a genuine bulleted list (not a fact-join) — acceptable use of •, unrelated to directive (1); flagged only because it's the sole • usage found. |
Count by package (i18n): packages/app/src/config/i18n — ko ≈ 210 renderable occurrences, en ≈ 200 renderable occurrences (438 total raw grep hits including ~28 comment/doc lines in clubs.ts).
A2. Template/.join(' · ') joins — packages/features/** and packages/app/src/presentation/**
All 32 call sites found (exhaustive):
| File:line | Facts joined | Alternative |
|---|---|---|
packages/features/clubs/src/public-club-profile-screen.tsx:333 | region · founded year · member count → fed into MediaHero meta (single string) | MediaHero.meta already accepts string[] — pass the 3-item array directly, drop .join(' · '). |
packages/features/clubs/src/join-review-detail-screen.tsx:198 | guest label · first-attended time · sessions-attended count → TypeHero meta | Same fix — array into TypeHero.meta. |
packages/features/clubs/src/shared/club-card-summary.ts:59 | region base + additional region bases | Region chip list — render as a Chip row or keep as one region string if truly one fact (it's a single "region" field with fallbacks, arguably acceptable). |
packages/features/clubs/src/shared/club-card-summary.ts:136 | next-session location · "+N more" | Two lines in the card's detail slot instead of one joined string. |
packages/features/clubs/src/shared/club-card-model.ts:430 | weekly-frequency label · daytime labels | Separate schedule sub-line + a chip for daytime buckets. |
packages/features/clubs/src/settings/regular-meets-section.tsx:219 | participation-group label · group name | FactRow label/value pair, not a joined caption. |
packages/features/clubs/src/club-members/join-review-row.tsx:56 | session count stat · manner-tag stat · co-attendee stat | 3 facts → StatTile row or 2nd meta line, not a single dotted string. |
packages/features/clubs/src/club-detail/club-mission-card.tsx:33 | remaining mission titles list | Legitimate ordered-list-as-prose ("다음: A, B, C") — lower priority; could use a comma list instead of ·. |
packages/features/clubs/src/settings/gallery-section.tsx:428 | up to 4 missing-profile-field names | Comma list is clearer than dots for an enumerable set. |
packages/features/clubs/src/settings/club-guests/proposed-row.tsx:95 | rating · visit count · trend label | 3 facts on a guest row → separate meta line + a trend badge. |
packages/features/clubs/src/post-detail/post-detail-header.tsx:107 | club name · relative time → TypeHero meta | Pass [clubName, formatRelativeTime(...)] as array instead of joining. |
packages/features/sessions/src/match-card.tsx:138,142 | team1/team2 player names | Fine as one "team name" composite (not competing facts), but consider AvatarStack+names instead of a text string. |
packages/features/sessions/src/scorecard/match-profiles-sheet.tsx:88 | court label · round label → sheet title | Sheet title is a single identity string — acceptable, but could be "{court} · {round}" → two-line header instead. |
packages/features/sessions/src/venue-detail-screen.tsx:247 | location label · address → MediaHero heroMeta | Pass array to MediaHero.meta. |
packages/features/sessions/src/directory-venue-detail/sections/hours.tsx:42 | "open now" label · Naver source label → SectionHeader meta | SectionHeader.meta is a single string prop by design (meta OR right) — this is a primitive constraint forcing the join (see code comment at that line); needs a SectionHeader API change (see below) or move the source label into the section body as its own line. |
packages/features/sessions/src/create-session-step6.tsx:105 | used-court names | Comma list clearer than dots for a name enumeration. |
packages/features/sessions/src/match-board-screen.tsx:184 | session date · venue → TypeHero eyebrow | eyebrow is a single string in TypeHeroProps — either accept one composite eyebrow fact (acceptable, it's literally one "when/where" identity line) or move venue into meta. |
packages/features/sessions/src/directory-venue-detail/use-directory-venue-detail.ts:296 | multiple payment-method labels → StatStripCell.value | Comma list, not dots, for an enumerable set. |
packages/features/sessions/src/session-detail/sections/participation-section.tsx:137 | tier label · host badge → ListRow sub | ListRow has only one sub: string — legitimate primitive constraint; two facts on one sub line is acceptable here (tier + host flag) since it's genuinely a compound identity fact, but if a 3rd fact were added this would need a chip instead. |
packages/features/sessions/src/session-detail/sections/session-hero-section.tsx:60 | formatted date · time range · place → MediaHero meta | Pass 3-item array instead of one joined string — this is the clearest violation since MediaHero.meta was built specifically to avoid this. |
packages/features/sessions/src/spectator-scorecard-screen.tsx:217 | session date · location | Screen subtitle — two facts, should be array/2 lines if the shell supports it. |
packages/features/sessions/src/pickup-host-card.tsx:102 | matches-played stat · attendance-% stat | StatTile row (2 cells) instead of 1 joined caption. |
packages/features/sessions/src/edit-session/config-form.tsx:259 | used-court names | Comma list. |
packages/features/sessions/src/scorecard/king-of-court/court-card.tsx:107 | player 1 name · player 2 name (team) | Acceptable composite (team identity). |
packages/features/sessions/src/directory-venue-detail/parts/correction-row.tsx:103,114 | weekday/weekend/holiday hours; fee-scope entries | Each is a distinct field — should render as its own FactRow, not one joined value string. |
packages/features/profile/src/profile-screen.tsx:197 | club name · "tennis career: N years" → TypeHero heroMeta | Pass array. |
packages/app/src/presentation/components/sessions/session-compact-row.tsx:157 | date text · time part | ListRow.sub — 2 facts on one sub line, borderline acceptable (date+time is one "when" fact), but could be [date, time] if the row primitive ever accepts array subs. |
packages/app/src/presentation/components/venue-row.tsx:188,212 | distance · district · court count; name · secondary line | Comment at line 181 literally says "Meta line (right column): distance · district · courts" — 3 independent facts joined into one string by design; should be a stacked meta (or drop the least useful fact, e.g. court count, on narrow rows). |
packages/app/src/presentation/utils/share-cards.ts:142,288,292 | location·district; team member names | Share-card export text (rendered outside app UI as shareable image/caption) — lower priority exception, similar to i18n share.ts. |
packages/features/clubs/src/settings/club-groups-section.tsx:243 | target-size label · member-count label | FactRow/two-cell stat instead of one string. |
packages/ui/src/reveal-stack.tsx:136 | per-item a11y announcements | Accessibility-only string (never visually rendered) — not a UI violation, exclude from remediation. |
packages/app/src/presentation/utils/club-admin-attention.ts:67 | uses i18n strings.partsJoiner (' · ') | Same fix as clubs.ts:802/787 — retire partsJoiner. |
A3. Literal {' · '} JSX text children
| File:line | Facts joined | Alternative |
|---|---|---|
packages/features/clubs/src/club-attendance-screen.tsx:83 | strike date · strike-count label (inside one Text role="cardMeta") | Two Text lines (date, then strike count) or a FactRow. |
packages/features/clubs/src/settings/gallery-section.tsx:201,203 | media status · kind · visibility (3-way join inside one meta Text) | Split into a 2-line meta or a small chip for visibility. |
packages/features/clubs/src/settings/gallery-section.tsx:433 | gallery-limit label · featured-limit label | Two separate meta lines. |
A4. Wireframe mock copy — docs/twomore_design_system/wireframes/detail-flat-language.jsx (in-phone content only; documentation prose with · at lines 14, 70, 80-87, 252-269, 1553-1638, 2568-2951, 3111-3182 excluded as spec prose, not mock copy)
~65 in-phone occurrences, all following the same handful of patterns, repeated across the session/venue/record/club/post mock screens:
| Pattern | Representative file:line | Value |
|---|---|---|
Hero meta= date·time·venue | :989, :2302, :2425, :2677 | meta="8월 30일 (토) 14:00–17:00 · 올림픽공원 테니스장" |
FlSection meta= count·waitlist | :1011, :2241, :2326, :2448, :2690 | meta="6명 확정 · 예비 1" |
FlFactRow label= compound field name | :953, :1040, :2210, :2262 | label="형식 · 실력" value="복식 · 3.0–4.0" |
FlFactRow fee value | :954, :1041, :2219, :2263 | value="₩8,000 · 계좌 이체" |
ListRow sub= (mock) venue·attendance | :1015, :1226, :1845, :2142, :2246 | sub="3.5 · 호스트", sub="올림픽공원 · 6/8" |
| Match title/sub | :1707-1714, :1792, :1801, :2383, :2392 | title="6–3 · 김민준/이서연 vs 박도윤/최지우", sub="토요 정기 모임 · 복식" |
| Club/profile hero meta | :1826, :1338, :1883, :1948 | meta="강남구 · 2023년 설립 · 회원 42명" (3-way join) |
| Notice line | :2335, :2457 | 📣 공지 3개 · 최신: 라켓 여분 챙겨오기 |
eyebrow= | :1769 | eyebrow="8월 30일 (토) · 올림픽공원 테니스장" |
| Chip label | :1091 | <WFChip label="Naver · 정부 · Kakao" /> |
Since this file is itself the design-system reference board that documents the "no middle-dot joins" rule in its own prose (line ~13, "joins ... rejected"), its in-phone mocks are internally inconsistent with its own written rule — worth a pass to update the mock copy to array-style meta lines so the board doesn't visually contradict the text beside it.
Count by package:
packages/app/src/config/i18n: ~410 renderable occurrences (ko+en)packages/features/**: ~27.join(' · ')/template sites + 3 literal{' · '}children = 30packages/app/src/presentation/**: 6.join/template sitespackages/ui: 1 (reveal-stack.tsx, a11y-only, non-visual)docs/.../detail-flat-language.jsx: ~65 in-phone mock-copy occurrences
Table B — Meta / sub-text styling
Primitive baseline (source of truth today)
| Primitive | Slot | Role / color / weight | File:line |
|---|---|---|---|
TypeHero | eyebrow | role="cardMeta" color="$textTertiary" fontWeight="700" letterSpacing={0.4} | packages/ui/src/type-hero.tsx:66-69 |
TypeHero | meta (each line) | role="cardBody" color="$textSecondary" | packages/ui/src/type-hero.tsx:78-81 |
MediaHero | meta (each line) | role="cardBody" color="$textSecondary" | packages/ui/src/media-hero.tsx:296-303 |
ListRow | sub | role="cardMeta" color="$textTertiary" | packages/ui/src/list-row.tsx:432-434 |
FactRow | label | role="cardMeta" color="$textTertiary" (maxWidth 45%) | packages/ui/src/fact-row.tsx:595-597 |
FactRow | value (string) | role="cardBody" fontWeight="600" color=TONE_COLOR[tone] (default $text) | packages/ui/src/fact-row.tsx:574-582 |
SectionHeader | meta | role="cardMeta" color="$textTertiary" | packages/ui/src/section-header.tsx:764-766,784-786 |
SectionHeader | link label | role="cardMeta" fontWeight="700" color="$primary" | packages/ui/src/section-header.tsx:776-778 |
StatTile | label | role="cardMeta" color=labelColor (default unset → cardMeta's baked-in $textSecondary per its own doc comment) | packages/ui/src/stat-tile.tsx:913-917 |
StatTile | subValue | role="cardMeta" color="$textSecondary" | packages/ui/src/stat-tile.tsx:923-926 |
Callout | body (neutral) | role="cardBody" color="$text" | packages/ui/src/callout.tsx:1094-1096,1131-1134 |
Banner | title / body | role="cardTitle" / role="cardBody", both color=toneStyle.text | packages/ui/src/banner.tsx:1258-1266 |
EmptyState | subtitle | hand-styled SubtitleText (color: '$textSecondary', NOT the role="cardMeta" system) | packages/ui/src/empty-state.tsx:1392-1408 |
Well | — | no owned text style (caller supplies) | packages/ui/src/well.tsx |
Immediate primitive-level inconsistency: StatTile.label is documented ("Always renders in the muted cardMeta role") to fall back to cardMeta's own baked-in $textSecondary, while FactRow.label and ListRow.sub are explicitly overridden to $textTertiary — i.e. three different "label"-class slots resolve to two different colors with no functional distinction (a fact label is more muted than a stat label, for no evident reason).
Page-level usage (sessions/session-detail, directory-venue-detail, venue-detail, match-board, records/record-detail, profile/*, join-review-detail, public-club-profile, post-detail)
| Slot | Where | Role/color used | File:line |
|---|---|---|---|
| Hero meta (via primitive) | session-detail hero | TypeHero/MediaHero → cardBody/$textSecondary (compliant, array-based) | packages/features/sessions/src/session-detail/sections/session-hero-section.tsx:94-109 |
| Hero meta (hand-rolled) | join-review, post-detail, profile, public-profile, public-club-profile | computed via .join(' · ') string, still fed through the compliant primitive prop | see Table A2 |
| Row sub (fact label styled as body) | join-review detail's evidence rows | role="cardBody" color="$textTertiary" — not the FactRow/ListRow convention (cardMeta/tertiary); mixes a body-weight role with a muted tint | packages/features/clubs/src/join-review-detail-screen.tsx:218,241,262 |
| Fact label, no color override | terms/legal field rows | role="cardMeta" with no color prop at all (falls to Text's own default ink, not $textSecondary/$textTertiary) | packages/features/profile/src/terms-screen.tsx:195,198; packages/features/profile/src/privacy-screen.tsx:397,400 |
| Stat/fact label | profile ELO hero fact | role="cardMeta" color="$textTertiary" | packages/features/profile/src/profile-screen.tsx:307,316 |
| Section/eyebrow-style caption | settings-privacy-screen | cardMeta/$textSecondary at one call (:108) vs cardMeta/$textTertiary at another (:123) in the same file, for what read as parallel "helper text under a control" slots | packages/features/profile/src/settings-privacy-screen.tsx:108 vs :123 |
| Record-detail row sub | match-timeline-row | cardBody/$text (title) + cardMeta/$textSecondary (sub) — secondary, not tertiary, unlike ListRow's own baked-in tertiary | packages/features/records/src/record-detail/match-timeline-row.tsx:55,59,63 |
| Post-detail comment meta | comment-row | author name cardMeta/$textTertiary fontWeight="600", timestamp cardMeta/$textSecondary — two different colors for two meta fields on the same row | packages/features/clubs/src/post-detail/comment-row.tsx:105,108 |
| Achievements list meta | achievements-gallery-screen | cardMeta/$textSecondary in one place (:166,220) vs bare no-color cardMeta isn't present here, but settings-consent-management-screen.tsx mixes $textSecondary (:109) and $textTertiary (:117) for what both read as "helper caption under a title" | packages/features/profile/src/settings-consent-management-screen.tsx:109 vs :117 |
| Section link / meta (SectionHeader) | friends-summary-card, most SectionBlock variant="flat" callers | primitive-forced cardMeta/$textTertiary — consistent | packages/features/profile/src/friends-summary-card.tsx:92-97 |
Drift pattern
Across the sampled pages, the same conceptual slot ("secondary descriptive text under a title/value") is rendered with at least four different color tokens depending on file: $textSecondary (most hero metas, most row subs written by hand), $textTertiary (all primitive-baked labels: FactRow.label, ListRow.sub, SectionHeader.meta), no color at all (terms-screen.tsx, privacy-screen.tsx field rows — inherits default ink, effectively $text), and $primary/$warning/$error for state-tinted meta (join-review candidate note, primary-action countdown warnings) which is a legitimate third category (status meta), not drift.
Proposed single meta grammar
| Tier | Role | Color | Weight | Use |
|---|---|---|---|---|
| Primary meta (hero identity line, directly under a title) | cardBody | $textSecondary | 400 | TypeHero.meta / MediaHero.meta — already correct; keep as the one canonical hero-meta style. |
| Secondary/sub (row sub-line, fact label, stat label — anything one level more muted than primary meta) | cardMeta | $textTertiary | 400 | Make this the ONE rule for ListRow.sub, FactRow.label, StatTile.label, SectionHeader.meta — already true for the primitives; fix the hand-rolled call sites (join-review-detail-screen.tsx, record-detail/match-timeline-row.tsx, settings-consent-management-screen.tsx, terms-screen.tsx, privacy-screen.tsx) to match instead of independently choosing $textSecondary/no-color/$textTertiary. |
| Eyebrow (all-caps-adjacent overline above a title) | cardMeta | $textTertiary | 700, letterSpacing 0.4 | Matches TypeHero's existing eyebrow — the only eyebrow implementation found; no drift here. |
| Caption (legal/fine-print, empty-state subtitle) | cardBody (small) or a dedicated caption role | $textSecondary | 400 | Retire EmptyState's bespoke SubtitleText styled-component in favor of the shared Text role system so it can't drift from the rest independently. |
Table C — Graphical asset placement
| Asset | Component / call site | Side | Vertical align | Compliant? | Fix |
|---|---|---|---|---|---|
Avatar/ring/logo (leading) | TypeHero primitive | LEFT (leading slot, first child of XStack) | alignItems="flex-start" → top | Yes | Already matches directive 3 exactly; doc comment at type-hero.tsx:9-11 states the rule verbatim. |
Avatar/logo (avatar) | MediaHero primitive | LEFT (avatar rendered first in identity XStack) | alignItems="center" → center, not top | Partial | Inconsistent with TypeHero's top-alignment for the same conceptual slot — change media-hero.tsx:284 identity XStack to alignItems="flex-start" (or explicitly top-align just the avatar) to match TypeHero. |
Member-faces AvatarStack + capacity pill | packages/features/clubs/src/shared/club-photo-seam.tsx:190-212 | RIGHT (position="absolute" right="$3" bottom={-CLUSTER_OVERHANG}) | bottom overlay (not top) | No — violation | This is the origin of the "faces at the seam" pattern (owner note 2026-08-03, predates the 2026-09-06 directive). Move the avatar cluster to the card's left/top identity zone (beside the club logo, which is already correctly anchored bottom-left at :161) or drop it to a FactRow/meta line ("42 members") without faces. |
Member-faces AvatarStack in MediaHero seamRight | packages/features/clubs/src/public-club-profile-screen.tsx:406-423 (renders into MediaHero's seamRight prop, media-hero.tsx:163-171 bottom-right overlay) | RIGHT | bottom overlay | No — violation (this is the exact MediaHero seamRight case named in the brief) | MediaHero.seamRight is documented as a "faces/capacity pill" slot at media-hero.tsx:74-76 — this is a primitive API problem, not just a misuse: rename/repurpose seamRight to a text-only capacity pill and move the avatar cluster to a new top-left overlay slot, or add a seamLeft/identityAvatars slot. |
Opponent AvatarStack (trailing) | packages/features/records/src/records-history-screen.tsx:154-159 | RIGHT (second AvatarStack, after the YStack content, before the chevron, inside one XStack) | alignItems="center" | No — violation | This is a "my team vs. opponent team" row where showing avatars on both sides is arguably meaningful (a matchup), but per directive 3 it should still not read as a right-anchored image — restructure as: my avatars (left) + names/score (center) + a small text "vs {opponent names}" (no opponent avatars), or stack opponent avatars into a second line under the score instead of the trailing column. |
AvatarStack in ListRow/other rows (dm-thread-row, session-status-participation-card, friends-summary-card, club-card.tsx) | various | LEFT (leading position in row) | center (row-height driven, acceptable for a single-line row) | Yes | No change needed — these are single-line rows, not hero blocks, so center-in-row is fine; only hero-scale blocks need the top-alignment rule. |
Flag/logo (FlagMedallion/ClubLogoBubble) | club-photo-seam.tsx:161-163 | LEFT (left="$3" bottom={-FLAG_OVERHANG}) | bottom overlay, left-anchored | Yes (side); overlay-bottom is an established "flag on the seam" convention, not a top/left violation | No change needed for side; if the "top" clause is read strictly, this is a bottom overlay by design (seam convention) — flag this only if the owner wants overlays banned outright, not just right-anchoring. |
| Tier-less "open" badge | club-photo-seam.tsx:139-146 | top-left | top | Yes | Compliant, not an avatar/logo but included for completeness since it shares the corner-overlay technique. |
ClubAlarmBadge count circle | club-photo-seam.tsx:157-159 | top-right | top | N/A (badge, not avatar/logo/flag/thumbnail) | Out of scope for directive 3 (a notification-count badge, not a graphical identity asset), but noted since it's the one top-right corner overlay in the same file that otherwise obeys the rule. |
Primitives needing an API change:
packages/ui/src/media-hero.tsx—seamRightslot (bottom-right by construction) is being used for avatar clusters atpublic-club-profile-screen.tsx:406-423; either repurposeseamRightto text-only content and add a compliant slot for member-face clusters, or rename it to make the right-anchoring an explicit, reviewable exception rather than the default place hosts reach for "put people faces near the media."packages/ui/src/media-hero.tsx— identity-rowalignItems="center"(:284) should becomeflex-startto matchTypeHero's top-alignedleadingslot, so the two hero primitives agree on vertical alignment for the same conceptual avatar slot.packages/features/clubs/src/shared/club-photo-seam.tsx— no shared primitive at all; it's a hand-rolled corner-overlay component thatmedia-hero.tsx's own doc comment (:11-18) cites as its model — fixing this file's right-anchored avatar cluster should happen before/alongside theMediaHero.seamRightfix since they're the same pattern in two places.