Desi — Design Context
Cache-friendly preamble for the Desi designer agent. Load this at the top of every Desi run so generation is grounded in the canonical token system, the brand voice, and the consumer-product polish bar.
Source of truth for tokens: design_system/css/tokens.css. If a value below disagrees with that file, the file wins — regenerate this file. marketing_landing/css/landing-2026.css keeps its own duplicated :root block (static site, no bundler) — it must be kept in sync by hand, not by import. A vitest guard (ionic_frontend/tests/unit/tokenDrift.spec.ts) now CI-fails if the two drift (OBJ-1407).
Quality bar: the objectuve-hallmark skill is the design-judgement layer — the audit / redesign / study verbs, the slop-test gates, and the pre-emit P/H/E/S/R/V self-critique. Load it for every Desi run and run an audit before any handoff. This file is the tokens and the voice; objectuve-hallmark is the bar the output must clear.
1. Polish bar — calibration references
Desi outputs should feel like one of these products, never like an enterprise admin panel:
- Linear — quiet density, restrained color, generous keyboard affordances, motion only when it conveys state.
- Stripe — type rhythm and white space that lets a single accent color carry hierarchy.
- Things 3 — every state designed, including empty. Microcopy as part of the visual.
- Cron / Notion Calendar — gradient surfaces used sparingly, dark mode as a first-class citizen.
If a mockup wouldn't pass for a shipped screen in one of these apps, it isn't done.
2. Consumer-product polish checklist
Run every mockup against this list before handoff. Any "no" is a refinement target for the critique pass.
- Hierarchy — one primary action on screen; secondary actions visually subordinate.
- Spacing rhythm — every gap is on the 4 / 8 / 12 / 16 / 24 / 32 / 48 / 64 scale. No 7px.
- Type rhythm — at most three type sizes on a screen; weight carries hierarchy when size doesn't.
- Color discipline — primary blue, accent orange, and gold each appear at most once per surface unless intentionally chorded.
- Empty state designed — every list / feed / dashboard has a real empty state with a forward action, never "No items".
- Dark mode parity — every state rendered in both themes; contrast checked in both.
- Tap targets ≥ 44px on touch; visible focus ring on every interactive element. 44px is the sanctioned floor (
DESIGN.md:314touch-target-min,DESIGN.md:954; gate 67) — treat it as the exact value, not something to round up from for safety.min-h-[46px]/min-w-[46px]is not a cautious approximation of 44, it's an off-scale value with no basis; snap to the literalmin-h-[44px]/min-w-[44px]. - Motion has a reason — every animation maps to a state change. Respect
prefers-reduced-motion. - Microcopy is in voice — action verbs, brief, no generic motivational filler. No "AI" label in consumer copy — say "Coach".
- Reuse before invent — if Storybook already has a component for this, point at it. New patterns need a justification.
3. Color tokens (HSL, canonical)
From design_system/src/tokens/colors.ts. Always use the Tailwind class, never an inline hex.
| Group | Token | HSL | Tailwind | Use for |
|---|---|---|---|---|
| Primary | --primary | hsl(203 78% 41%) | bg-primary text-primary | CTAs, links, active states |
| Primary | --primary-interactive | hsl(203 78% 35%) | bg-primary-interactive | Pressed / hover-down |
| Primary | --primary-light | hsl(203 78% 61%) | bg-primary-light | Hover, secondary accents |
| Primary | --primary-glow | hsl(203 78% 71%) | shadow-glow | Focus halo, hero glow |
| Accent | --accent | hsl(27 89% 55%) | bg-accent text-accent | Streaks, XP, notifications |
| Gold | --gold | hsl(43 96% 56%) | bg-gold text-gold | Badges, achievements |
| Success | --success | hsl(160 84% 39%) | bg-success | Completed, positive feedback |
| Destructive | --destructive | hsl(0 84% 60%) | bg-destructive | Errors, broken streaks |
| Surface | --background | hsl(210 40% 98%) light / dark variant | bg-background | Page background |
| Surface | --card | hsl(0 0% 100%) | bg-card | Card surface (light) |
| Muted | --muted-foreground | hsl(215 16% 40%) | text-muted-foreground | Secondary copy |
| Border | --border | hsl(214 32% 91%) | border-border | All borders |
Hero gradient — primary brand mark: linear-gradient(135deg, hsl(203 78% 41%), hsl(203 78% 51%)) → Tailwind bg-gradient-primary. The marketing landing uses #177bbb → #05cbfc; both are valid expressions of the primary gradient.
4. Spacing scale (Tailwind)
Use these and only these for padding / margin / gap:
0 0.5 (2px) · 1 (4px) · 2 (8px) · 3 (12px) · 4 (16px) · 5 (20px) · 6 (24px) · 8 (32px) · 10 (40px) · 12 (48px) · 16 (64px) · 20 (80px) · 24 (96px) · 32 (128px)
Container max-widths: max-w-screen-sm (640) · max-w-md (28rem) · max-w-lg (32rem) · max-w-2xl (42rem) — pick one, don't invent.
Derived geometry — don't hand-solve an offset that depends on another element's size
The safest way to align two related elements (a rail under a dot, an offset badge, a centred overlay) is to make the relationship structural — same width, same flex container — so alignment holds automatically instead of depending on a number staying in sync by hand. Reference case: views/PublicGoal.vue's Journey timeline (:177-193). The spine's column and the dot are the same width (w-5), and the spine centres its 2px line inside that column with flex justify-center. There's no offset to solve for and nothing to keep in sync — resizing the dot means changing w-5 in the one place it's declared, and PublicGoal.render.spec.ts fails loudly if the rail and dot width ever diverge.
When structural centring isn't available and a hand-computed offset is unavoidable, derive it from the dimension it depends on and state the derivation in a comment — don't re-solve a new magic number by eye. This is what PublicGoal.vue's spine looked like before the flex-rail restructure: the arithmetic was correct, but nothing in the code said so, so it broke silently the moment w-5 changed without left-[9px] and pl-[30px] changing with it.
spine_left = (dot_width − spine_width) / 2
padding_left = dot_width + gapBoth are a function of the dot's own size — never a second, independently-chosen constant. A re-snapped value that isn't derived from what it depends on is the same bug at a new offset, not a fix.
5. Typography scale
From design_system/src/tokens/typography.ts. Headlines: Outfit. Body: Inter. Never mix in a third family.
| Role | Class | Family |
|---|---|---|
| Display | font-display font-extrabold text-6xl tracking-tight | Outfit 800 |
| H1 | font-display font-bold text-4xl tracking-tight | Outfit 700 |
| H2 | font-display font-bold text-3xl tracking-tight | Outfit 700 |
| H3 | font-display font-semibold text-2xl | Outfit 600 |
| H4 | font-display font-semibold text-xl | Outfit 600 |
| Body Large | font-sans text-lg | Inter 400 |
| Body | font-sans text-base | Inter 400 |
| Body Small | font-sans text-sm | Inter 400 |
| Micro-label | font-sans text-2xs font-medium tracking-wide uppercase | Inter 500 |
| Caption | font-sans text-xs font-medium tracking-wide uppercase | Inter 500 |
| Label | font-sans text-sm font-semibold | Inter 600 |
Type-ramp rule — no arbitrary text-[Npx]
An arbitrary Tailwind size (text-[15px], text-[13.5px], text-[11px]) is not permitted. Every text element snaps to a named ramp step. This is a mechanical mapping, not a judgment call:
| Class | Size | Role |
|---|---|---|
text-2xs | 11px | Eyebrow / micro-label — the legibility floor. Round up to this step if the nearest ramp size would fall below it. |
text-xs | 12px | Metadata, dense secondary |
text-sm | 14px | Body, control labels |
text-base | 16px | Emphasis / chrome identity |
text-lg | 18px | Card heading |
text-xl · text-2xl · text-3xl | 20 / 24 / 30px | Section, page, hero headings |
text-2xs is a design_system extension (design_system/tailwind-tokens.cjs:204-206, fontSize['2xs'] = ['0.6875rem', { lineHeight: '1rem' }]) — not a Tailwind default, but canonical here; do not reinvent it as text-[11px].
Wrong shape (ionic_frontend/src/views/teams/TeamHomeView.vue:42, current state of this milestone's own integration branch — the teams/** type-ramp sweep has not landed on it): class="inline-flex items-center gap-1 font-display text-[15px] font-bold text-foreground".
Right shape (the same team-name element, origin/master's TeamHomeView.vue:57 after OBJ-1969/PR #1970): class="font-display text-base font-bold text-foreground" — text-[15px] snaps up to the nearest step, text-base (16px).
The full 28-anchor mapping for views/teams/** (all four ramp steps, every occurrence) lives in .planning/phases/v4.27-teams-type-ramp-semantic-corrections/UI-SPEC.md on master — read it before mapping a new arbitrary size rather than re-deriving the rule from scratch.
6. Shadows
From design_system/src/tokens/shadows.ts. Pick one tier per surface — never stack.
| Token | Tailwind | Use for |
|---|---|---|
--shadow-sm | shadow-sm | Chips, tags |
--shadow-md | shadow-md | Default card |
--shadow-lg | shadow-lg | Modal, dropdown |
--shadow-xl | shadow-xl | Hovered card, elevated CTA |
--shadow-glow | shadow-glow | Hero element, primary focus |
--shadow-accent | shadow-accent | Gamification — XP gain, streak |
--shadow-gold | shadow-gold | Badge unlock, rank-up |
7. Card recipes
Three canonical card surfaces. Confirmed current 2026-09-02 against ionic_frontend/src/theme/tailwind.css's @layer components block (v4.51 Phase 2, OBJ-3227). Use one of these as-is — do not hand-roll a third.
7a. Glassmorphism card — elevated content
Modals, hero panels, standalone content surfaces. Write the .glass-card class, never the literal utilities — the four properties below (fill, blur, border, shadow) are the enforcement mechanism, not a snippet to retype. Light alpha is 0.70, dark is 0.40; this is the one true glass fill, ruled after auditing four drifted alpha values across the app (bg-card/60, /80, /50 were all off-recipe hand-rolls, not intentional variants).
<div class="glass-card relative overflow-hidden p-6">
<!-- subtle gradient overlay for depth -->
<div class="absolute inset-0 -z-10 bg-gradient-card opacity-60"></div>
<!-- content -->
</div>.glass-card itself, ionic_frontend/src/theme/tailwind.css:
.glass-card {
@apply bg-card/70 backdrop-blur-xl border border-border/60 shadow-xl;
@apply dark:bg-card/40 dark:border-white/10;
}Radius (rounded-2xl) and padding stay per-site — they vary legitimately by surface, and a utility class can't create a child element for the gradient overlay. Test both themes — glass that looks great on light often looks flat on dark.
7b. .glass-sheet — modal / bottom-sheet root surface
Denser than .glass-card (light 0.80, dark 0.50) because it floats over arbitrary app content and needs its own legibility floor rather than relying on a known page background. Deliberately carries no border, radius, or shadow — the modal chrome owns the sheet edge; stacking .glass-card's border/shadow onto a full-bleed sheet would be wrong, not just redundant.
<div class="glass-sheet relative flex max-h-full flex-col overflow-hidden">
<!-- sheet content -->
</div>.glass-sheet {
@apply bg-card/80 backdrop-blur-xl;
@apply dark:bg-card/50;
}Use this only for the root surface of an <ion-modal> bottom sheet — not as a "denser card" for any other purpose.
7c. .selectable-card — pickable option in a grid
The canonical surface for a pickable choice in a grid (onboarding slides, Coach setup) — not the glass recipe above. Defined once in ionic_frontend/src/theme/tailwind.css (@layer components, directly after .glass-card) and consumed by ionic_frontend/src/views/welcome/slides/SourceSlide.vue, ionic_frontend/src/views/welcome/slides/CoachFocusSlide.vue, and ionic_frontend/src/components/coach/CoachFrequencyCardGrid.vue — check those three call sites for the live usage pattern before adding a fourth.
.selectable-card {
--selectable-card-accent: hsl(var(--primary));
@apply relative rounded-2xl border border-solid border-border bg-card shadow-card;
@apply transition-all duration-150;
@apply hover:-translate-y-0.5 hover:shadow-card-hover;
@apply active:scale-[0.98];
}
.selectable-card--coach {
--selectable-card-accent: hsl(var(--coach));
}
.selectable-card--selected {
border-color: var(--selectable-card-accent);
box-shadow: 0 0 0 2px var(--selectable-card-accent), var(--shadow-card);
}Every value is a token; there is no .dark variant needed because --card, --border, and --shadow-card each carry their own dark value already. Use shadow-card/shadow-card-hover, never shadow-md/shadow-lg for this recipe specifically — --shadow-card and --shadow-card-hover are the only shadow tokens in design_system/css/tokens.css with a dark-mode override; --shadow-md keeps its light value in dark mode, where a 10%-opacity near-black shadow on a near-black surface is invisible.
Wrong shape (the pre-fix state all three call sites shared until OBJ-2002/PR #1957 → 6d7818f57): a hand-rolled <style scoped> block per component, border: 1px solid rgba(255, 255, 255, 0.1) over background: hsl(var(--card)). Since --card is 0 0% 100% in light mode, that border measured 1.00:1 — mathematically absent, not faint.
Right shape (current state, all three files): class="selectable-card text-left p-4 md:p-5 ..." with :class="[selected === opt.id ? 'selectable-card--selected' : '']" — the shared class, zero raw rgba(). The light-mode border now measures 1.24:1.
Selection accent via --selectable-card-accent, not a static ring-* utility — CoachFocusSlide.vue and CoachFrequencyCardGrid.vue re-colour the selected state at runtime from the chosen Coach persona; a static Tailwind ring can't follow that, so the ring and border would disagree whenever a persona colour is active. One custom property, set via a single inline style at the call site, drives both.
8. Dark mode parity rules
- Background: light
bg-background(hsl(210 40% 98%)) → darkbg-slate-950orbg-[hsl(222_47%_5%)]. - Card: light
bg-card→ darkbg-card/40over a darker background. - Border: light
border-border→ darkborder-white/10. - Text: light
text-foreground(hsl(222 47% 11%)) → darktext-slate-100. - Muted text: light
text-muted-foreground→ darktext-slate-400. - Primary glow shadow stays the same — it reads in both themes.
Every spec must render in both. If the dark version looks worse, the spec isn't done.
Marketing-surface exception. marketing_landing/ is a static, single-theme (light-only) site — it has no prefers-color-scheme handling and no dark-mode mechanism, so there is nothing for a dark variant to target. When a spec covers a surface that ships in marketing_landing/, do not write a dark-mode acceptance line — state "light-only — no dark variant (marketing surface)" instead. If you author the mockup in Tailwind for capture fidelity and render it in both themes, say so explicitly so no one mistakes the dark capture for a shipping requirement. This carve-out applies only to marketing_landing/; the app (ionic_frontend/) and admin dashboard stay dark-mode first-class and must render in both. (Precedent: Phase 7 Teams pricing panel, OBJ-1208.)
9. Motion
- Default duration: 200ms. Hover / press feedback: 150ms. State transitions: 300ms. Never longer than 400ms for UI motion.
- Easing:
ease-outfor entries (something appearing),ease-infor exits,ease-in-outfor moves. - Reserved meaningful motions: streak tick (bounce 1×), XP pop (scale + fade up), badge unlock (gold pulse), rank-up (full-screen confetti).
- Always honor
prefers-reduced-motion: reduce— replace loops and non-essential transitions with opacity fades under 200ms. Transforms triggered by an input state (:hover,:active,:focus, and their group/peer forms) are neutralized to identity globally inionic_frontend/src/theme/tailwind.css; write a press or hover scale exactly as you would normally and add nothing else — nomotion-safe:/motion-reduce:decoration. Canonical wording:DESIGN.md§ Accessibility (reduced-motion) — this file is derived and defers to it (OBJ-3226).
VitePress surfaces — guide_site / docs_site (OBJ-3627)
The rules above are the Ionic app. If you're speccing help.objectuve.com or docs.objectuve.com, three things change:
- Six timing tiers, not a default duration. Tick 150ms · State 200ms · Reveal 250–300ms · Move 350ms · Fill 400–500ms · Loop 2s. Easing comes from the three
--guide-ease*tokens (guide.csslines 85–88);docs_siteships none and writes bareease. Full table, with the file:line each value is shipped at:.claude/skills/objectuve-throughline/references/motion-vocabulary.md§ VitePress surfaces. - There is no blanket reduced-motion reset. The "write a hover scale exactly as you would normally and add nothing else" instruction above is Ionic-only. On VitePress, every animated rule carries its own fallback — either wrapped in
@media (prefers-reduced-motion: no-preference), or written plainly with a scopedreduceoverride. Colour and opacity transitions are exempt; transform, position, size, and anythinginfiniteare not. - Gates g7, g10, g29, g30 and most of the E-category are
n/a, notpass. There is no route-transition layer, nouseModalAnimations, no toast system, and no haptics on a web page. Writen/a — <why>in the gate table. g28 still applies, re-read as the inline answer panel. Ruling and rationale:motion-vocabulary.md§ VitePress surfaces.
10. Brand voice — every string
From docs/brand/brand.md (read it in full if drafting marketing copy). Inside-the-app rules:
- Action-forward. Lead with verbs. "Mark today done", not "Today's check-in".
- Brief. One sentence is the target. If it needs two, the spec needs an edit.
- Coach-who's-also-a-friend. Playful, never childish. Empowering, never generic-motivational ("You got this!").
- Celebrate without empty praise. "Streak +1. Day 12." beats "Amazing job!"
- No engagement bait. No FOMO, no "Don't break your streak!" guilt copy. The streak is information, not a threat.
- No "AI" in consumer copy. Always "Coach". Backend / admin naming can still say AI.
Microcopy templates by surface type:
| Surface | Pattern | Example |
|---|---|---|
| Empty state title | <verb> <noun> | "Set your first goal" |
| Empty state subtitle | <one-sentence next-step> | "Takes 30 seconds. We'll suggest a category." |
| Primary CTA | <verb> <noun> | "Add a goal" / "Mark today done" |
| Toast on success | <noun> <past-tense verb> | "Streak +1" / "Goal added" |
| Error | <what failed> · <recovery> | "Couldn't save. Tap to retry." |
11. Available primitives
Compose mockups from the canonical primitives at ionic_frontend/.mockups/primitives.html rather than reinventing per spec. Primitives include:
- Buttons — primary, secondary, ghost, destructive, in all sizes (sm / md / lg) and both themes.
- Cards — flat, elevated, glass.
- Inputs — text, password, search, with label + helper + error states.
- Dialogs / sheets — modal, bottom sheet, inline drawer.
- Lists — feed row, settings row, goal row.
- Chips & badges — counter, status, achievement.
- Empty states — three illustrated patterns to copy.
If you need a primitive that isn't there, propose adding it to the primitives file rather than inventing inline.
12. Anti-patterns (don't ship)
- Two CTAs on one card competing for attention.
- A
text-xlheadline directly above atext-lgsubhead — the size gap is too small. - Borders + shadows + gradient overlays on the same surface (pick one).
- Stacked gradient text (
bg-clip-text) on top of a gradient background. - Emoji as the only visual signal for a state.
- "No items to display" as an empty state.
- Spinners on actions that take < 200ms — use disabled state instead.
- New hex values inline. Use the token.
- Generic motivational microcopy ("Keep it up!", "You've got this!").
- Animation on a non-interactive element with no state behind it.
- An opacity modifier (
/60,/40) stacked ontext-muted-foregroundortext-foreground— see §13. - Arbitrary
text-[Npx]sizing instead of the type ramp — see §5. - A
computed()that falls back to?? {}/?? <default>on query data beforeloading/errorare bound — see §14. - Assuming a
--gradient-<hue>token's ink is--<hue>-foregroundwithout checking the real call site. v4.44's census found--gradient-coachpaired with literaltext-white, never--coach-foreground, at every ink-bearing site — seeDESIGN.md§ Accessibility hard rules — v4.44 Gradient Stop Ink Contract, rule 2. Verify the ink actually painted at the call site before asserting a gradient's contrast.
13. Contrast rule — no stacked opacity on muted text
Never stack an opacity modifier on an already-muted text token. text-muted-foreground and text-foreground are the tokens to reach for; adding /60, /40, or any other alpha suffix to fake a lighter shade drops the rendered contrast below the WCAG AA 4.5:1 floor for body text, silently — the class still looks intentional, so nothing catches it in review by eye.
Desi's measured table (design_system/css/tokens.css, both themes) is why this is a rule and not a style preference:
| Class | Light contrast | Dark contrast | Floor |
|---|---|---|---|
text-muted-foreground/60 | 2.59:1 | 3.31:1 | 4.5:1 |
placeholder:text-foreground/40 | 2.55:1 | 3.69:1 | 4.5:1 |
Both measure below the floor in both themes. The fix is never a smaller opacity number — it's dropping the modifier and using the un-aliased base token, which is already tuned to clear 4.5:1.
Wrong shape (ionic_frontend/src/views/SignIn.vue, current state of this milestone's own integration branch — the OBJ-1965 contrast sweep has not landed on it): placeholder:text-muted-foreground/60 (five input placeholders), placeholder:text-muted-foreground/40 (one).
Right shape (the same five+one anchors, origin/master's SignIn.vue after OBJ-1965/PR #1932): placeholder:text-muted-foreground — no alpha suffix.
Wrong shape, second instance (ionic_frontend/src/views/welcome/slides/GoalNameSlide.vue:139, current integration-branch state): placeholder:text-foreground/40. Right shape (same line, origin/master after OBJ-1965): placeholder:text-muted-foreground — both the alpha suffix and the wrong base token (text-foreground instead of text-muted-foreground) are corrected together.
Exemption — decorative aria-hidden icons (SC 1.4.11)
This rule governs text. Non-text decorative content — an icon with no accessible name — falls under WCAG SC 1.4.11 (non-text contrast), which does not apply to purely decorative graphics at all. AppIcon.vue (ionic_frontend/src/components/icons/AppIcon.vue:56-61) bakes this in structurally: any <AppIcon> with no aria-label/title prop renders aria-hidden="true" automatically, with no accessible name for a screen reader to announce. An opacity-muted icon in that state is not a contrast violation to flag — it's correctly decorative. Confirmed-exempt examples, all <AppIcon> instances with no aria-label/title, origin/master:
AnalyticsView.vue:15—<AppIcon name="sprout" class="h-8 w-8 text-muted-foreground/60" />AlliesPage.vue:87—<AppIcon name="users" class="w-12 h-12 text-muted-foreground/40 ..." />PendingAllyRequestsPage.vue:87—<AppIcon name="inbox" class="w-12 h-12 text-muted-foreground/40 ..." />MeetCoach.vue:429— a<span aria-hidden="true">bullet separator, explicitly marked rather than darkened
Don't "fix" these by dropping the alpha — they're correctly exempt, and darkening a decorative separator or empty-state icon for no reason is its own anti-pattern (§12).
14. Query-state honesty — don't render unresolved data as fact
A computed() that unconditionally falls back on query data (?? {}, ?? <literal>) — bound before loading/error are checked — is only safe if every consumer of that computed sits behind a gate distinguishing "no data yet" from "confirmed empty." Without that gate, a slow or failed GraphQL query renders as a confident, specific, wrong answer instead of a loading or error state. This shipped three times under the same shape (OBJ-1711 family): Dashboard.vue's streak pill (original), then Coach.vue (told a user who'd already finished the Enneagram assessment to start one) and EnneagramTypeDetail.vue (rendered Type 1 as the reader's own result whenever their type couldn't be resolved) — both fixed in this milestone (OBJ-1968, PR #1946 → dd476f245). Full incident detail, including the exact pre-fix code shapes, lives in CLAUDE.md's "Common Gotchas" under the OBJ-1711 entry — read that for the complete history; this section is the design-context summary.
Canonical honest shape: AnalyticsView.vue:127-136's cardState() helper — an explicit loading | error | ready | empty tri-state gating what renders, never a bare ?? fallback escaping to the template. Match this shape, not a bespoke one, for any new query-backed view.
Mechanical backstop: ESLint rule no-fabricated-query-state (ionic_frontend/eslint-rules/no-fabricated-query-state.js, internally QSTATE-GUARD-1), wired into ionic_frontend/eslint.config.js as local/no-fabricated-query-state: 'warn', running in CI via the existing npm run lint job. It flags a useQuery destructure binding result without loading/error, and a computed() ?? {} / ?? <literal> fallback on query data. ?? null and ?? [] are deliberately exempt (neither renders as a confident non-empty result on its own), and a ?? <literal> feeding straight into a comparison (AnalyticsView.vue's own cardState() isEmpty predicates) is exempt too, since the fallback never escapes as a displayed value. It's warn-level, not a hard fail — it already flags Dashboard.vue's own original, still-unfixed instance as expected, real pre-existing debt made visible, not a false positive.
This is a data-integrity rule as much as a visual one — it belongs here because it directly shapes how the polish checklist's "Empty state designed" (§2, item 5) item should actually be built: never as a fallback value quietly standing in for "we don't know yet."
15. Known gaps (recorded, not resolved by this doc)
Two follow-ups this milestone (v4.27 Hallmark Design-Debt Remediation) surfaced but does not close. Recorded here so they don't evaporate between milestones.
- The computed-dimensions tap-target rule has no mechanical enforcement. "Assert the rendered hit area is ≥44×44px, not the presence of a
w-11/h-11class" (§2, item 7) was broken twice in this milestone: Phase 1 caught it in review beforeCommunity.vueshipped, and Phase 2 reintroduced the exact same class-name-assertion anti-pattern independently — Roy's review on PR #1946 flaggedEnneagramTypeDetail.spec.ts's 44px back-button check as a MAJOR finding for asserting.classes()).toContain('w-11')instead of a real computed dimension, fixed in the same PR. Two catches in one milestone means the rule isn't reliably reaching authors from documentation alone.ionic_frontend/tests/unit/helpers/tapTarget.ts(added in Phase 2) derives real pixel dimensions from an element's Tailwind spacing classes for use in Vitest assertions — but nothing requires a new component to use it. Open need: a lint rule or CI assertion that makes this the enforced default rather than a pattern a reviewer has to remember to ask for. - Phase 2's live
loading/error/emptystates had never been visually verified.Coach.vueandEnneagramTypeDetail.vueshipped honest query-state gating (§14), but CI's "Capture UI Evidence" job only screenshots the default/ready state on six unrelated routes — nobody had visually confirmed the live-rendered loading/error/empty variants against Desi's mockups. This gate was cleared before v4.27 promoted tomaster— Vicki verified the live-rendered variants via Storybook + Playwright captures (10 screenshots, 5 states × light/dark) ahead of the promotion (PR #1987,df03a252, 2026-08-02), rather than trusting a green CI check alone — the same discipline as the CI-artifact-download method she'd used earlier to verify CARD-1's border fix.
16. Honest gating — the not-yet-live affordance contract
Two live call sites: views/teams/TeamHomeView.vue:121-130 (handler :736-740) and views/Achievements.vue:44-56 (handler :439-446). ComingSoonPill lives at components/ui/ComingSoonPill.vue — promoted out of components/teams/ in OBJ-3228, since it was never a teams-only concern.
The contract, in one sentence: a control for a feature that isn't live yet keeps aria-disabled="true" (never the disabled attribute), pairs with a visible <ComingSoonPill />, uses cursor-default, and keeps a live click handler that answers the tap with a real response — never a silent no-op — backed by a code comment naming the actual ticket that will ship it.
Five parts of that sentence are easy to lose by copying the markup alone:
aria-disabled="true", neverdisabled. Thedisabledattribute removes an element from the tab order entirely — a keyboard or screen-reader user never lands on it and never hears the accessible name someone carefully wrote.aria-disabledkeeps the control focusable and announced; the honesty of the label only reaches anyone if the control stays reachable.- The click handler has to answer, not no-op. Copying the five markup attributes without the
@clickhandler ships a control that silently swallows taps — worse than a plaindisabledbutton, which at least reports itself as unavailable. Both call sites firetoast.info(...)on tap:TeamHomeView.vue:740— "Team notifications land here soon.";Achievements.vue:444— "Sharing your achievements lands here soon." - The ticket number in the comment must be real. An invented number is worse than none — it lets an abandoned control pass as "planned" indefinitely.
TeamHomeView.vue:119names OBJ-1819;Achievements.vue:53names OBJ-3404, filed the same session it shipped, before the code that references it landed. - Icon ink is
/80, not/70.TeamHomeView.vue:128'stext-muted-foreground/70measures 3.07:1 in light mode — 0.07 above the 3:1 non-text floor, effectively no headroom.Achievements.vue:51'stext-muted-foreground/80measures 3.75:1 light / 5.09:1 dark. Use/80for a new gated icon; don't copy the older call site's marginal value. (Both are correctly exempt from §13's no-stacked-opacity rule — this is a decorative,aria-hidden-by-default icon behind a button that already carries the accessible name, the same basis as the §13 exemptions.) indigois not banned. The brand rule that the coach's own identity is purple, never indigo, governs the coach specifically — it does not ban the word or the token.indigoremains a legitimate category-colour value:constants/goalCategoryColors.ts:8maps'Career Development'to it, andbg-category-indigorenders live inGoalDetailsCard.vue,GoalHero.vue, and three Storybook stories. Don't "fix" a legitimatecategory-indigosite under the coach-color rule — a goal category being indigo is unrelated to what the coach renders as.
Known drift, not fixed here: TeamHomeView.vue:736-740's handler has no re-entrancy guard — a double-tap inside the toast's display window can stack a second toast. Achievements.vue's Share control closes this gap with a shareToastLive ref (:439-446, 3-second window) — an improvement over the model, not a deviation from it. Worth porting back to TeamHomeView.vue as its own ticket; not done as a drive-by here.
Canonical acknowledgment: DESIGN.md § Component voice — this file carries the full contract and rationale; DESIGN.md names the pattern and points back here.
Maintenance
This file is generated. To refresh after token changes:
# (when added) rails generate desi:design_contextFor now, regenerate by hand from design_system/src/tokens/*.ts, docs/brand/brand.md, and ionic_frontend/STYLE_GUIDE.md.
Last updated: 2026-09-06 (v4.51 Design Convention Enforcement, Phase 4: added §4's derived-geometry rule and the 44px-vs-46px tap-floor clarification in §2 item 7, OBJ-3229)