Skip to content

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.

  1. Hierarchy — one primary action on screen; secondary actions visually subordinate.
  2. Spacing rhythm — every gap is on the 4 / 8 / 12 / 16 / 24 / 32 / 48 / 64 scale. No 7px.
  3. Type rhythm — at most three type sizes on a screen; weight carries hierarchy when size doesn't.
  4. Color discipline — primary blue, accent orange, and gold each appear at most once per surface unless intentionally chorded.
  5. Empty state designed — every list / feed / dashboard has a real empty state with a forward action, never "No items".
  6. Dark mode parity — every state rendered in both themes; contrast checked in both.
  7. Tap targets ≥ 44px on touch; visible focus ring on every interactive element. 44px is the sanctioned floor (DESIGN.md:314 touch-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 literal min-h-[44px] / min-w-[44px].
  8. Motion has a reason — every animation maps to a state change. Respect prefers-reduced-motion.
  9. Microcopy is in voice — action verbs, brief, no generic motivational filler. No "AI" label in consumer copy — say "Coach".
  10. 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.

GroupTokenHSLTailwindUse for
Primary--primaryhsl(203 78% 41%)bg-primary text-primaryCTAs, links, active states
Primary--primary-interactivehsl(203 78% 35%)bg-primary-interactivePressed / hover-down
Primary--primary-lighthsl(203 78% 61%)bg-primary-lightHover, secondary accents
Primary--primary-glowhsl(203 78% 71%)shadow-glowFocus halo, hero glow
Accent--accenthsl(27 89% 55%)bg-accent text-accentStreaks, XP, notifications
Gold--goldhsl(43 96% 56%)bg-gold text-goldBadges, achievements
Success--successhsl(160 84% 39%)bg-successCompleted, positive feedback
Destructive--destructivehsl(0 84% 60%)bg-destructiveErrors, broken streaks
Surface--backgroundhsl(210 40% 98%) light / dark variantbg-backgroundPage background
Surface--cardhsl(0 0% 100%)bg-cardCard surface (light)
Muted--muted-foregroundhsl(215 16% 40%)text-muted-foregroundSecondary copy
Border--borderhsl(214 32% 91%)border-borderAll 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 + gap

Both 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.

RoleClassFamily
Displayfont-display font-extrabold text-6xl tracking-tightOutfit 800
H1font-display font-bold text-4xl tracking-tightOutfit 700
H2font-display font-bold text-3xl tracking-tightOutfit 700
H3font-display font-semibold text-2xlOutfit 600
H4font-display font-semibold text-xlOutfit 600
Body Largefont-sans text-lgInter 400
Bodyfont-sans text-baseInter 400
Body Smallfont-sans text-smInter 400
Micro-labelfont-sans text-2xs font-medium tracking-wide uppercaseInter 500
Captionfont-sans text-xs font-medium tracking-wide uppercaseInter 500
Labelfont-sans text-sm font-semiboldInter 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:

ClassSizeRole
text-2xs11pxEyebrow / micro-label — the legibility floor. Round up to this step if the nearest ramp size would fall below it.
text-xs12pxMetadata, dense secondary
text-sm14pxBody, control labels
text-base16pxEmphasis / chrome identity
text-lg18pxCard heading
text-xl · text-2xl · text-3xl20 / 24 / 30pxSection, 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.

TokenTailwindUse for
--shadow-smshadow-smChips, tags
--shadow-mdshadow-mdDefault card
--shadow-lgshadow-lgModal, dropdown
--shadow-xlshadow-xlHovered card, elevated CTA
--shadow-glowshadow-glowHero element, primary focus
--shadow-accentshadow-accentGamification — XP gain, streak
--shadow-goldshadow-goldBadge 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).

html
<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:

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.

html
<div class="glass-sheet relative flex max-h-full flex-col overflow-hidden">
  <!-- sheet content -->
</div>
css
.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.

css
.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-* utilityCoachFocusSlide.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%)) → dark bg-slate-950 or bg-[hsl(222_47%_5%)].
  • Card: light bg-card → dark bg-card/40 over a darker background.
  • Border: light border-border → dark border-white/10.
  • Text: light text-foreground (hsl(222 47% 11%)) → dark text-slate-100.
  • Muted text: light text-muted-foreground → dark text-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-out for entries (something appearing), ease-in for exits, ease-in-out for 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 in ionic_frontend/src/theme/tailwind.css; write a press or hover scale exactly as you would normally and add nothing else — no motion-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.css lines 85–88); docs_site ships none and writes bare ease. 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 scoped reduce override. Colour and opacity transitions are exempt; transform, position, size, and anything infinite are not.
  • Gates g7, g10, g29, g30 and most of the E-category are n/a, not pass. There is no route-transition layer, no useModalAnimations, no toast system, and no haptics on a web page. Write n/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:

SurfacePatternExample
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-xl headline directly above a text-lg subhead — 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 on text-muted-foreground or text-foreground — see §13.
  • Arbitrary text-[Npx] sizing instead of the type ramp — see §5.
  • A computed() that falls back to ?? {} / ?? <default> on query data before loading/error are bound — see §14.
  • Assuming a --gradient-<hue> token's ink is --<hue>-foreground without checking the real call site. v4.44's census found --gradient-coach paired with literal text-white, never --coach-foreground, at every ink-bearing site — see DESIGN.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:

ClassLight contrastDark contrastFloor
text-muted-foreground/602.59:13.31:14.5:1
placeholder:text-foreground/402.55:13.69:14.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-11 class" (§2, item 7) was broken twice in this milestone: Phase 1 caught it in review before Community.vue shipped, and Phase 2 reintroduced the exact same class-name-assertion anti-pattern independently — Roy's review on PR #1946 flagged EnneagramTypeDetail.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/empty states had never been visually verified. Coach.vue and EnneagramTypeDetail.vue shipped 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 to master — 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:

  1. aria-disabled="true", never disabled. The disabled attribute 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-disabled keeps the control focusable and announced; the honesty of the label only reaches anyone if the control stays reachable.
  2. The click handler has to answer, not no-op. Copying the five markup attributes without the @click handler ships a control that silently swallows taps — worse than a plain disabled button, which at least reports itself as unavailable. Both call sites fire toast.info(...) on tap: TeamHomeView.vue:740 — "Team notifications land here soon."; Achievements.vue:444 — "Sharing your achievements lands here soon."
  3. 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:119 names OBJ-1819; Achievements.vue:53 names OBJ-3404, filed the same session it shipped, before the code that references it landed.
  4. Icon ink is /80, not /70. TeamHomeView.vue:128's text-muted-foreground/70 measures 3.07:1 in light mode — 0.07 above the 3:1 non-text floor, effectively no headroom. Achievements.vue:51's text-muted-foreground/80 measures 3.75:1 light / 5.09:1 dark. Use /80 for 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.)
  5. indigo is 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. indigo remains a legitimate category-colour value: constants/goalCategoryColors.ts:8 maps 'Career Development' to it, and bg-category-indigo renders live in GoalDetailsCard.vue, GoalHero.vue, and three Storybook stories. Don't "fix" a legitimate category-indigo site 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:

bash
# (when added) rails generate desi:design_context

For 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)

Loading…