Skip to content

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)CountWhat it joinsSample (file:line → value)Alternative
signals.tsko 33 / en 33push-notification body: club·title·date, round·court·score, dues amount·daysko/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.tsko 33 / en 26round·match count·start time, court·round, format·tier·friendly, score rule stringsko/sessions.ts:1354 `라운드 ${round} · ${matchCount}경기 · 예상 시작 ${estStart}`; en/sessions.ts:1331 same in ENSplit into a meta: string[] (round / count / start) so TypeHero/SectionHeader render 3 lines instead of one joined string.
clubs.tsko ~55 / en ~20region·district, tier ladder prose ("KTA·KATO"), review labels ("지역 · 테니스장"), partsJoiner: ' · ' constantko/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.tsko 33 / en 33weather 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.tsko 5 / en 5screen title·suffix, permission notice·hint, correction status·hintko/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.tsko 24 / en 24privacy 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.tsko 2 / en 2rank·hash, band·"est."ko/ranking.ts:328 `내 순위 · ${rank}위`Two facts (label + value) — should be FactRow label="내 순위" value="{rank}위", not a joined string.
matches.tsko 4format description prose, player·court·round countko/matches.ts:215 `${players}명 · ${courts}코트 · ${rounds}라운드`3 facts → StatStrip cells, not one string.
tournaments.tsko 2 / en 2setup label, format·roundko/tournaments.ts:109 `${formatLabel} · ${round}라운드 진행 중`Split format (badge) from round-progress (meta line).
activity.tsko 1 / en 1duration·countko/activity.ts:63 `총 ${hours}시간 ${mins}분 · ${count}회`Two facts on one stat tile — StatTile subValue.
common.tsko 1 / en 1empty-state text·pending countko/common.ts:214 `연결이 없어요 · ${n}개 전송 대기`Fine as one sentence (not two facts).
notifications.tsko 1 / en 1club·new-post countko/notifications.ts:36 `${clubName} · 새 글 ${count}개`Notification-tray text — acceptable exception per above.
onboarding.tsko 3headline word-pairs ("ELO·매너"), consent labelko/onboarding.ts:9 'ELO·매너로 믿고 치는 상대'Marketing copy, not a fact-join — out of scope for this law.
share.tsko 1 / en 1share-card matches·win-rateko/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 () joinsko 3 / en 3settings.ts account-deletion warning bulletsko/settings.ts:712-714, en/settings.ts:749-751These 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:lineFacts joinedAlternative
packages/features/clubs/src/public-club-profile-screen.tsx:333region · 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:198guest label · first-attended time · sessions-attended count → TypeHero metaSame fix — array into TypeHero.meta.
packages/features/clubs/src/shared/club-card-summary.ts:59region base + additional region basesRegion 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:136next-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:430weekly-frequency label · daytime labelsSeparate schedule sub-line + a chip for daytime buckets.
packages/features/clubs/src/settings/regular-meets-section.tsx:219participation-group label · group nameFactRow label/value pair, not a joined caption.
packages/features/clubs/src/club-members/join-review-row.tsx:56session count stat · manner-tag stat · co-attendee stat3 facts → StatTile row or 2nd meta line, not a single dotted string.
packages/features/clubs/src/club-detail/club-mission-card.tsx:33remaining mission titles listLegitimate ordered-list-as-prose ("다음: A, B, C") — lower priority; could use a comma list instead of ·.
packages/features/clubs/src/settings/gallery-section.tsx:428up to 4 missing-profile-field namesComma list is clearer than dots for an enumerable set.
packages/features/clubs/src/settings/club-guests/proposed-row.tsx:95rating · visit count · trend label3 facts on a guest row → separate meta line + a trend badge.
packages/features/clubs/src/post-detail/post-detail-header.tsx:107club name · relative time → TypeHero metaPass [clubName, formatRelativeTime(...)] as array instead of joining.
packages/features/sessions/src/match-card.tsx:138,142team1/team2 player namesFine 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:88court label · round label → sheet titleSheet title is a single identity string — acceptable, but could be "{court} · {round}" → two-line header instead.
packages/features/sessions/src/venue-detail-screen.tsx:247location label · address → MediaHero heroMetaPass array to MediaHero.meta.
packages/features/sessions/src/directory-venue-detail/sections/hours.tsx:42"open now" label · Naver source label → SectionHeader metaSectionHeader.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:105used-court namesComma list clearer than dots for a name enumeration.
packages/features/sessions/src/match-board-screen.tsx:184session date · venue → TypeHero eyebroweyebrow 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:296multiple payment-method labels → StatStripCell.valueComma list, not dots, for an enumerable set.
packages/features/sessions/src/session-detail/sections/participation-section.tsx:137tier label · host badge → ListRow subListRow 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:60formatted date · time range · place → MediaHero metaPass 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:217session date · locationScreen subtitle — two facts, should be array/2 lines if the shell supports it.
packages/features/sessions/src/pickup-host-card.tsx:102matches-played stat · attendance-% statStatTile row (2 cells) instead of 1 joined caption.
packages/features/sessions/src/edit-session/config-form.tsx:259used-court namesComma list.
packages/features/sessions/src/scorecard/king-of-court/court-card.tsx:107player 1 name · player 2 name (team)Acceptable composite (team identity).
packages/features/sessions/src/directory-venue-detail/parts/correction-row.tsx:103,114weekday/weekend/holiday hours; fee-scope entriesEach is a distinct field — should render as its own FactRow, not one joined value string.
packages/features/profile/src/profile-screen.tsx:197club name · "tennis career: N years" → TypeHero heroMetaPass array.
packages/app/src/presentation/components/sessions/session-compact-row.tsx:157date text · time partListRow.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,212distance · district · court count; name · secondary lineComment 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,292location·district; team member namesShare-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:243target-size label · member-count labelFactRow/two-cell stat instead of one string.
packages/ui/src/reveal-stack.tsx:136per-item a11y announcementsAccessibility-only string (never visually rendered) — not a UI violation, exclude from remediation.
packages/app/src/presentation/utils/club-admin-attention.ts:67uses i18n strings.partsJoiner (' · ')Same fix as clubs.ts:802/787 — retire partsJoiner.

A3. Literal {' · '} JSX text children

File:lineFacts joinedAlternative
packages/features/clubs/src/club-attendance-screen.tsx:83strike 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,203media 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:433gallery-limit label · featured-limit labelTwo 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:

PatternRepresentative file:lineValue
Hero meta= date·time·venue:989, :2302, :2425, :2677meta="8월 30일 (토) 14:00–17:00 · 올림픽공원 테니스장"
FlSection meta= count·waitlist:1011, :2241, :2326, :2448, :2690meta="6명 확정 · 예비 1"
FlFactRow label= compound field name:953, :1040, :2210, :2262label="형식 · 실력" value="복식 · 3.0–4.0"
FlFactRow fee value:954, :1041, :2219, :2263value="₩8,000 · 계좌 이체"
ListRow sub= (mock) venue·attendance:1015, :1226, :1845, :2142, :2246sub="3.5 · 호스트", sub="올림픽공원 · 6/8"
Match title/sub:1707-1714, :1792, :1801, :2383, :2392title="6–3 · 김민준/이서연 vs 박도윤/최지우", sub="토요 정기 모임 · 복식"
Club/profile hero meta:1826, :1338, :1883, :1948meta="강남구 · 2023년 설립 · 회원 42명" (3-way join)
Notice line:2335, :2457📣 공지 3개 · 최신: 라켓 여분 챙겨오기
eyebrow=:1769eyebrow="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 = 30
  • packages/app/src/presentation/**: 6 .join/template sites
  • packages/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)

PrimitiveSlotRole / color / weightFile:line
TypeHeroeyebrowrole="cardMeta" color="$textTertiary" fontWeight="700" letterSpacing={0.4}packages/ui/src/type-hero.tsx:66-69
TypeHerometa (each line)role="cardBody" color="$textSecondary"packages/ui/src/type-hero.tsx:78-81
MediaHerometa (each line)role="cardBody" color="$textSecondary"packages/ui/src/media-hero.tsx:296-303
ListRowsubrole="cardMeta" color="$textTertiary"packages/ui/src/list-row.tsx:432-434
FactRowlabelrole="cardMeta" color="$textTertiary" (maxWidth 45%)packages/ui/src/fact-row.tsx:595-597
FactRowvalue (string)role="cardBody" fontWeight="600" color=TONE_COLOR[tone] (default $text)packages/ui/src/fact-row.tsx:574-582
SectionHeadermetarole="cardMeta" color="$textTertiary"packages/ui/src/section-header.tsx:764-766,784-786
SectionHeaderlink labelrole="cardMeta" fontWeight="700" color="$primary"packages/ui/src/section-header.tsx:776-778
StatTilelabelrole="cardMeta" color=labelColor (default unset → cardMeta's baked-in $textSecondary per its own doc comment)packages/ui/src/stat-tile.tsx:913-917
StatTilesubValuerole="cardMeta" color="$textSecondary"packages/ui/src/stat-tile.tsx:923-926
Calloutbody (neutral)role="cardBody" color="$text"packages/ui/src/callout.tsx:1094-1096,1131-1134
Bannertitle / bodyrole="cardTitle" / role="cardBody", both color=toneStyle.textpackages/ui/src/banner.tsx:1258-1266
EmptyStatesubtitlehand-styled SubtitleText (color: '$textSecondary', NOT the role="cardMeta" system)packages/ui/src/empty-state.tsx:1392-1408
Wellno 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)

SlotWhereRole/color usedFile:line
Hero meta (via primitive)session-detail heroTypeHero/MediaHerocardBody/$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-profilecomputed via .join(' · ') string, still fed through the compliant primitive propsee Table A2
Row sub (fact label styled as body)join-review detail's evidence rowsrole="cardBody" color="$textTertiary"not the FactRow/ListRow convention (cardMeta/tertiary); mixes a body-weight role with a muted tintpackages/features/clubs/src/join-review-detail-screen.tsx:218,241,262
Fact label, no color overrideterms/legal field rowsrole="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 labelprofile ELO hero factrole="cardMeta" color="$textTertiary"packages/features/profile/src/profile-screen.tsx:307,316
Section/eyebrow-style captionsettings-privacy-screencardMeta/$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" slotspackages/features/profile/src/settings-privacy-screen.tsx:108 vs :123
Record-detail row submatch-timeline-rowcardBody/$text (title) + cardMeta/$textSecondary (sub) — secondary, not tertiary, unlike ListRow's own baked-in tertiarypackages/features/records/src/record-detail/match-timeline-row.tsx:55,59,63
Post-detail comment metacomment-rowauthor name cardMeta/$textTertiary fontWeight="600", timestamp cardMeta/$textSecondary — two different colors for two meta fields on the same rowpackages/features/clubs/src/post-detail/comment-row.tsx:105,108
Achievements list metaachievements-gallery-screencardMeta/$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" callersprimitive-forced cardMeta/$textTertiary — consistentpackages/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

TierRoleColorWeightUse
Primary meta (hero identity line, directly under a title)cardBody$textSecondary400TypeHero.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$textTertiary400Make 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$textTertiary700, letterSpacing 0.4Matches 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$textSecondary400Retire 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

AssetComponent / call siteSideVertical alignCompliant?Fix
Avatar/ring/logo (leading)TypeHero primitiveLEFT (leading slot, first child of XStack)alignItems="flex-start"topYesAlready matches directive 3 exactly; doc comment at type-hero.tsx:9-11 states the rule verbatim.
Avatar/logo (avatar)MediaHero primitiveLEFT (avatar rendered first in identity XStack)alignItems="center"center, not topPartialInconsistent 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 pillpackages/features/clubs/src/shared/club-photo-seam.tsx:190-212RIGHT (position="absolute" right="$3" bottom={-CLUSTER_OVERHANG})bottom overlay (not top)No — violationThis 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 seamRightpackages/features/clubs/src/public-club-profile-screen.tsx:406-423 (renders into MediaHero's seamRight prop, media-hero.tsx:163-171 bottom-right overlay)RIGHTbottom overlayNo — 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-159RIGHT (second AvatarStack, after the YStack content, before the chevron, inside one XStack)alignItems="center"No — violationThis 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)variousLEFT (leading position in row)center (row-height driven, acceptable for a single-line row)YesNo 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-163LEFT (left="$3" bottom={-FLAG_OVERHANG})bottom overlay, left-anchoredYes (side); overlay-bottom is an established "flag on the seam" convention, not a top/left violationNo 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" badgeclub-photo-seam.tsx:139-146top-lefttopYesCompliant, not an avatar/logo but included for completeness since it shares the corner-overlay technique.
ClubAlarmBadge count circleclub-photo-seam.tsx:157-159top-righttopN/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:

  1. packages/ui/src/media-hero.tsxseamRight slot (bottom-right by construction) is being used for avatar clusters at public-club-profile-screen.tsx:406-423; either repurpose seamRight to 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."
  2. packages/ui/src/media-hero.tsx — identity-row alignItems="center" (:284) should become flex-start to match TypeHero's top-aligned leading slot, so the two hero primitives agree on vertical alignment for the same conceptual avatar slot.
  3. packages/features/clubs/src/shared/club-photo-seam.tsx — no shared primitive at all; it's a hand-rolled corner-overlay component that media-hero.tsx's own doc comment (:11-18) cites as its model — fixing this file's right-anchored avatar cluster should happen before/alongside the MediaHero.seamRight fix since they're the same pattern in two places.

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