Skip to content

UI-SPEC — v4.32 Shared header, nav & motion system

This file is cumulative across the v4.32 milestone. Part 1 (below) is Phase 1's header, nav and motion contract. Part 2 is Phase 2's landing section deltas. Part 3 is Phase 3's Meet Coach beam choreography, Moments chip and closing mark. Part 4 is Phase 4's ruling on heading <em> emphasis — colour fill, not synthetic-italic slant. The rename ledger and the reduced-motion still-state table are extended in place across parts, never restarted — splitting them is how they drift.

Milestone: v4.32 — Marketing Landing Rebuild · Phase 1 · Task 2 (OBJ-2273) Implements for: Task 3 (Codi) — marketing_landing/css/landing-2026.css, marketing_landing/js/main.js, header markup in index.html + meet-coach.htmlMockups + evidence: .planning/phases/phase-1-shared-header-motion/mockups/ — 3 mockups, 78 PNGs (light band + dark band), 2 motion GIFs, axe-core clean CSS deliverable: mockups/contract.css — every rule below, ready to merge into landing-2026.cssReference JS: mockups/motion/motion-demo.html — working implementation of the motion module


Scope

One header markup block, one nav, one CSS block and one JS motion module serving index.html and meet-coach.html — plus the first prefers-reduced-motion contract this site has ever had, covering the effects that already ship as well as the ones Phases 2 and 3 add.

landing-2026.css and main.js are loaded by all ten pages (index, meet-coach, contact, 404, six policies/*). Everything in this spec that touches the shared stylesheet or the shared script therefore lands on ten pages, not two. Where that produces a deliberate visual change on the other eight, it is named in the edit ledger. Where it does not, the ledger says so explicitly. The other eight pages' nav content is out of scope and does not change.

Light-only site, two bands. marketing_landing/ ships no prefers-color-scheme handling and no theme toggle — verified, 0 occurrences across css/*.css. Per docs/reference/desi-design-context.md § 8 "Marketing-surface exception", there is no dark-mode acceptance line in this spec: "dark" below always means a .site-section.dark band, never an OS theme.

That distinction is not a technicality here. The band is the second surface every header state and every motion effect composites against, and auditing it is what surfaced this phase's second live WCAG failure — see § The header over a dark band. Every subject in this spec is rendered and contrast-checked on both.


The two nav rulings

1. Teams ships on both pages. Orion's recommendation confirmed. master's landing already serves #teams-showcase and #teams, Meet Coach's nav already deep-links back with index.html#…, and a nav that changes shape between two pages of the same site is a wayfinding defect, not a curation choice. Five items on both: Features · Meet Coach · Rewards · Teams · Pricing.

2. "Meet Coach" always points at meet-coach.html, from every page — overruling the canvas. The landing canvas repoints it at #coach, an on-page teaser section. Left as drawn, Phase 2's coach-teaser section would silently delete the only nav path to the page Phase 3 exists to rebuild. The teaser gets its own in-section CTA ("Meet your Coach" → meet-coach.html); the nav link does not move. Consequence for scroll spy: see § Header states.


Class-preservation policy

Zero renames in Phase 1. Every selector in contract.css is either an existing class or a new additive one. The rename ledger below is complete and it is empty of true renames — there is one retirement of a class that has no CSS and no allowlist entry, and it is ledgered anyway.

The policy exists because of a specific mechanic, not a style preference. scripts/check-marketing-contrast.mjs allowlists 29 exact page::selector pairs, and those selector strings are generated by axe-core, not hand-written. Axe emits the shortest selector that is unique on the page as scanned — so an entry can be orphaned by things that are not renames at all:

  • Adding a class to an allowlisted node changes the string axe emits for it.
  • Adding a second element that matches an existing class-only entry forces axe to disambiguate, changing the string. meet-coach.html::.mc-btn-sm and index.html::.mc-btn-coach are both class-only entries — each is currently unique on its page, and must stay unique.
  • Inserting a sibling shifts every :nth-child() index after it. Three meet-coach entries are positional (.site-section:nth-child(2|4|5), counted inside <main>), as are four on index (.hero-trust-item:nth-child(1|3|5), .tint-brand.feat-card:nth-child(1|6)).
  • Changing runtime state that axe sees. Three entries embed .section-intro.reveal.in — the .in class the reveal observer adds. If reduced-motion handling ever short-circuits that class, all three orphan. This is the single most load-bearing constraint in this spec; see § Effect 2.

Scan conditions the ledger assumes, all verified in check-marketing-contrast.mjs: viewport 1280×1000, deviceScaleFactor: 2, scrollY 0 (so the header is in its rest state and no spy pill is active), motion no-preference (Playwright's newContext default — so .in is present), and the mobile menu hidden (so its four nodes are not scanned).

Rename / edit ledger

#ChangeKindContrast-allowlist consequenceBlast radius
1.site-nav.scrolled.site-nav.is-condensedretirement + replacementNone. .scrolled appears in 0 allowlist entries and 0 CSS rules — main.js:44 toggles a class nothing styles. It is also never applied at scan time (scrollY 0).All 10 pages get a working condense; today none do.
2.nav-link.is-active gains a CSS ruleadditive (class already in markup)None. No entry references .nav-link. is-active already sits on meet-coach.html:104 with no rule behind it.index + meet-coach only.
3.site-nav.nav-coach modifier on <header>new class, meet-coach onlyNone. No entry's path traverses <header>; the three meet-coach entries are rooted in <main>.meet-coach only.
4.nav-progress element added to <header>new node, index + meet-coach onlyNone — it is a 2px decorative bar with no text, so axe's contrast rule does not assess it. It is the last child of <header>, after .site-nav-inner, so no :nth-child() index inside <main> moves.index + meet-coach only; main.js is null-safe for the other 8.
5.nav-signin gains markup (rule already exists at :416-420)new scanned nodeWatch. A genuinely new node enters the 1280px scan. It ships #334155 — byte-identical to .site-nav .nav-link, on the same background, which the guard already passes. Expected green; if it isn't, it routes to Desi, never into the allowlist.index + meet-coach only.
6.nav-link gains padding: 8px 14px + border-radius; .site-nav-links gap 28px → 4pxproperty change, no class changeNone. Foreground and background are unchanged at rest; only box metrics move.index + meet-coach visually; the other 8 have 2 nav links and reflow harmlessly.
7.nav-toggle 40×40 → 44×44property changeNone — icon-only button, no text to assess.All 10 pages. Deliberate: 40px violates the ≥44px touch floor on every page today.
8Nav collapse breakpoint 900px → 1023pxmedia-query changeNone — the guard scans at 1280px, above both values.All 10 pages, in the 900–1023px band only. Deliberate: at 901px the five-item nav plus brand plus actions overflows the 741px of available inner width.
9.mobile-menu a gap 14px → 8px, padding 8px 0 → 10px 0, min-height: 44pxproperty changeNone — menu is hidden at scan.All 10 pages. Deliberate: 34px rows violate the touch floor.
10.mobile-menu a.mc-btn-brand/.mc-btn-coachcolor: #fffbug fixNone — menu is hidden at scan, which is exactly why the guard never caught this.All 10 pages. See below.
11.reveal / .reveal.in reduced-motion rule extendedproperty changeDo not touch the class names. Three entries match on .section-intro.reveal.in.index + meet-coach.
12[data-animate] / .is-visible path deleted from main.jsdead-code removalCorrected — was false, see below. None. 0 matching elements on any page; .is-visible has 0 CSS rules.All 10 pages — false: about.html/contact.html shipped invisible content. See below.
13Inline reveal <script> folded into main.js (index.html:910-935, meet-coach.html:468-486)consolidationNone, provided .in is still applied — see § Effect 2.The other 8 pages have 0 .reveal elements, so this is a no-op there.

Ledger item 10 is a live WCAG AA failure on master, found while rendering this spec's mockups..mobile-menu a (specificity 0,1,1) outranks .mc-btn-brand (0,1,0), so the mobile menu's own "Sign up free" button renders #0F172A on the brand fill — 2.88:1, against a 4.5:1 requirement. It ships on all ten pages today and is invisible to marketing-contrast-guard because the menu is hidden at the 1280px scan viewport. It is a one-line fix and it belongs in this phase, since this phase is already rewriting that rule block. Evidence: screenshots/header-menu-open__mobile__light.png in the first render pass (pre-fix), clean in the shipped set.

Ledger item 12's "0 matching elements on any page" claim was false and caused a real regression (OBJ-2377). about.html (9 elements) and contact.html (2 elements) both still carried live [data-animate] markup at the time of PR #2178 / commit b34d45bfa — the claim was never actually verified against those two pages before this spec asserted it as ground truth. main.js loads on all ten pages (see § Scope), so deleting its [data-animate]/.is-visible observer per item 12 removed the only code path that ever added .is-visible on any page still using the attribute — about.html's 9 and contact.html's 2 elements permanently disappeared for any visitor without prefers-reduced-motion, not the no-op this ledger claimed. The hotfix (OBJ-2377, PR #2243, commit 3c0d81737) migrated both pages' elements to the .reveal/.reveal.in convention item 13 already covers. Lesson for any future "0 matching elements" claim in this ledger: verify it against the actual markup of every page the deleted selector could appear on — a claim scoped to this stylesheet's own audit surface (landing-2026.css + the pages that load it) can still be wrong about a page like about.html, which loads a different stylesheet (css/styles.css) but the same shared main.js.

Two things Codi must not do, both of which orphan an allowlist entry with no error:

  • Do not add mc-btn-sm to the mobile-menu CTA on meet-coach.html. That class is the allowlist's only handle on the header CTA (meet-coach.html::.mc-btn-sm); a second match forces axe to disambiguate and the entry stops matching anything.
  • Do not move the .mobile-menu div inside <main>, or wrap <main>'s children. The three meet-coach entries count :nth-child() inside <main>.

Header states

One markup block. The only per-page differences are the nav-coach class on <header>, the href set, and which link carries aria-current.

<header class="site-nav[ nav-coach]" role="banner">
  <div class="site-container site-nav-inner">
    <a class="site-nav-brand">        <img lockup, height 42>
    <nav class="site-nav-links">      5 × a.nav-link[ data-spy]
    <div class="nav-actions">         a.nav-signin · a.mc-btn.mc-btn-{brand|coach}.mc-btn-sm · button.nav-toggle
  <div class="nav-progress">          ← new, last child, index + meet-coach only
</header>
<div class="mobile-menu" id="mobile-menu" hidden>  ← unchanged position, sibling after </header>
StateTriggerBar heightSheetHairlineShadowBrandProgress barSpy pill
RestscrollY ≤ 12var(--nav-h) = 68pxrgba(255,255,255,0.6)hsl(var(--border) / 0.4)nonescale(1)width: 0%none active
CondensedscrollY > 12var(--nav-h-condensed) = 58pxrgba(255,255,255,0.88)hsl(var(--border) / 0.9)0 10px 30px -20px rgba(15,23,42,0.35)scale(0.92), origin left centertracks scrollas below
Spy activea spied section spans the line navHeight + 72.is-active + aria-current="location": hsl(var(--nav-accent-active)) on hsl(var(--nav-accent) / 0.10), weight 600
Current pagestatic, authoredsame treatment, aria-current="page"
Menu open.nav-toggle click, < 1024pxunchangedunchangedunchangedunchangedunchangedunchanged

All five transitions run on .3s var(--nav-ease) (cubic-bezier(.4,0,.2,1)); the progress bar runs width .1s linear.

One canvas value overruled. The canvas puts the rest sheet at rgba(255,255,255,0.55). This spec holds it at the existing 0.6. A 0.05 delta buys nothing perceptually and moves the exact background the contrast guard measures .nav-link (#334155) against, on a page where five nav nodes are already allowlisted. Condensed at 0.88 is adopted as drawn — it only ever increases opacity, which can only improve contrast.

Scroll spy tracks same-page anchors only. On index.html that is four links — Features, Rewards, Teams, Pricing — since "Meet Coach" is cross-page by ruling 2. On meet-coach.html nothing in the nav is a same-page anchor, so there is no spy there at all; the "Meet Coach" link carries a static aria-current="page". The canvas's Meet Coach page has no data-spy either, so this matches it.

The spy line is derived (.site-nav-inner's measured height + 72), not the canvas's hard-coded 140 — otherwise it desynchronises the moment the header condenses.

The header over a dark band — a second live AA failure, and why the threshold is load-bearing

The header is position: sticky and translucent, so whatever scrolls beneath it composites through. Three sections on the two pages in scope are .site-section.dark (#060B18 → #0B1530 → #07101F): index's coach-quote band and #teams-showcase, and meet-coach's .mc-anatomy. All three pass under the header.

At the rest sheet — rgba(255,255,255,0.6), which is what master ships permanently, because it has no condense — the composite is #9DA1AC, and #334155 nav links land at 4.01:1 (3.82:1 at the band's darkest stop) against a 4.5:1 requirement. That is a live WCAG AA failure on master today. marketing-contrast-guard cannot see it: the guard scans at scrollY 0, where the light hero is behind the header and the same text measures 10.02:1.

The condense fixes it structurally. Rest exists only at scrollY ≤ 12, and no dark band on either page is within 12px of the top — so under this spec the header is always at the 0.88 sheet (#E2E3E6, 8.07:1) by the time a dark band reaches it. That makes the 12px threshold an accessibility constraint, not a taste one:

Do not raise the condense threshold, and do not disable condense at any breakpoint. Either change re-opens a WCAG AA failure that no CI gate on this repo can currently detect.

The same compositing is why hover and active states use --nav-accent-active rather than the shared --brand-ink / --coach-ink: on a 10% tint over the condensed sheet over a dark band, #136699 measures 4.32:1 and fails. The two deeper values clear it at 5.15:1 and 6.09:1.

Rendered proof, both bands, all three viewports: screenshots/header-{rest,condensed}-brand__*__{light,dark}.png and screenshots/header-rest-coach__*__dark.png.


Responsive

WidthNav
≥ 1101px5 pills, gap: 4px, padding: 8px 14px
1024–1100px5 pills, gap: 0, padding: 8px 10px — tightens instead of overflowing
≤ 1023pxlinks + Sign in + CTA hidden; 44×44 toggle; .mobile-menu at top: var(--nav-h), following the header down to --nav-h-condensed when condensed

.site-container's own padding: 0 80px → 0 28px breakpoint stays at 900px. Do not move it to 1023px to match the nav — it governs every section on all ten pages, and shifting it is a real regression across eight pages nobody is looking at.


Reduced motion — a named still state per effect

The rule this table is built on: a still state is the animation's last frame, never its first. Nothing on this site may render invisible, half-drawn, dimmed, or stuck at zero because motion is off.

The one rule that is not negotiable: JS never withholds a class or a value to produce stillness. .reveal still gets .in; [data-count] still gets its final number. Stillness lives in the media query. Three contrast-allowlist entries match on .section-intro.reveal.in, and the guard runs with motion at no-preference today — but the moment anyone runs it under reduce, or a runner defaults that way, a JS-gated .in orphans all three silently and trips their nodes as new violations.

Visual proof of every row: screenshots/still-{1..7}-*__{mobile,tablet,desktop}__light.png.

The seven required effects

#EffectWhere it livesFull motionStill stateHow
1Entrance stagger[data-enter], hero, on loadfade up 18px, 560ms, 0→310ms delay ladderArrived — every element at final opacity and transform, painted on frame oneNo CSS rule, deliberately. Markup ships arrived; main.js returns early and never animates. This is also why the no-JS render is correct.
2Reveal on scroll.reveal / .reveal.in, 58 nodes across the two pagesopacity 0→1 + 14px up, 700ms, on intersectAlready revealed — final state, no transition.reveal, .reveal.in { opacity: 1; transform: none; transition: none }. main.js still adds .in. Extends the rule already at landing-2026.css:1477.
3Drifting halos.halo, obj-drift / obj-drift2, 14 nodestranslate ~24px + scale 1.06, 18–24s, infiniteParked — keyframe 0, still visible.halo { animation: none; transform: none }. The blur wash is the design; only the drift is decoration.
4Count-up stats[data-count] (Phase 2 markup; animateCount machinery already in main.js)0 → target, 900ms ease-out cubic, once, never restartsFinal number, printedel.textContent = target + suffix synchronously, before any observer. Markup ships the final value too, so a no-JS reader sees it. A stuck 0 is a factual error, not a missing animation.
5Animated FAQ<details class="faq-item"> × 6answer fades up 6px 260ms; + rotates 45°; border tints; shadow liftsInstant open — accordion works, ramps removed, mark still flips.faq-item, .faq-item summary, .faq-mark { transition: none }. <details> toggling is native and untouched. The mark still flips, so the control still reads as a control.
6Header condense.site-nav.is-condensed68→58px, sheet 0.6→0.88, hairline, shadow, brand scale(0.92), all 300msCondensed, untransitioned — condense still happens, snapped; brand scale dropped entirelytransition: none on the four header elements, plus .site-nav.is-condensed .site-nav-brand { transform: none }. Condensing is information ("you have scrolled"); the transform is the part a vestibular reader reacts to.
7Beam draw.mc-beam / .mc-beam-input (Phase 3 markup; contract set here)4 paths draw at 160ms offsets, 1100ms each; each input lights 0.45→1 as its beam startsCompleted diagram — all four drawn, opacity 1, all four inputs lit.mc-beam { stroke-dasharray: none; stroke-dashoffset: 0; opacity: 1 }, .mc-beam-input { opacity: 1 }. stroke-dasharray is primed only in the full-motion JS branch, so there is no path by which this renders blank.

Effect 7 overrules the canvas. The canvas's reduced branch sets beams to opacity: 0.55 while its full-motion branch ends at 1. A dimmed diagram reads as disabled, and it breaks the rule this whole table rests on. The still state matches the last frame: opacity 1.

The rest of the site's motion

The phase goal is "every effect on the site," and Orion's seven are not all of them. These are covered by the same block so nothing ships unguarded.

EffectFull motionStill stateHow
Scroll progress barwidth tracks scroll, .1s linearAbsent.nav-progress { display: none }, and main.js stops writing width. Scroll-linked motion is a vestibular trigger and the scrollbar already carries this information.
Scroll-spy pillcolour/background over 200msInstant — state kept.site-nav .nav-link { transition: none }. A discrete state change is information, not motion.
Smooth scrollwindow.scrollTo({ behavior: 'smooth' }), main.js:87Instant jumphtml { scroll-behavior: auto } and main.js passes behavior: reduce ? 'auto' : 'smooth'. The CSS alone does not override an explicit JS behavior.
Beta-dot pulsebox-shadow ring, 2.2s infiniteStatic dot.hero-beta-dot { animation: none }
Coach-orb ring pulseobj-ring scale+fade, 3.2s infiniteStatic ring.spark::after { animation: none }
Hero phone floatobj-float ±14px, 7s infiniteLevel.hero-phone-float { animation: none; transform: none }
Hover lifttranslateY(-2px/-4px) on buttons, feature cards, tier cardsColour onlytransform: none on the four :hover rules. Hover feedback survives; the lift does not.
Billing-toggle amountfades up 6px, 240ms, on switchInstant swap.teams-price-amount { animation: none }. The number still changes — that is the whole point of the control.
Mobile menu opendisplay swap, no animation todayUnchangedNothing to do. Recorded so the audit is complete, not because there is work.

Live re-evaluation. main.js reads the query once and subscribes: mq.addEventListener('change', …). Toggling the OS setting mid-session must not leave the page in a half-guarded state — that is what "coherent" means in the phase goal.

One motion rule is deliberately exempted from desi-design-context.md § 9's ≤400ms ceiling. That ceiling governs interactive feedback — hover, press, toggle, condense — and every such transition here is ≤300ms. Entrance (560ms), reveal (700ms) and beam-draw (1100ms) are arrival choreography: one-shot, unprompted, never blocking input. Holding them to 400ms would make them read as a flicker. Recorded as an exemption rather than left as a silent inconsistency.


Ring-glyph geometry

One circle, five renderings. viewBox="0 0 100 100", cx=cy=50, r=35, stroke-width=14 (so inner edge 28, outer edge 42 — the brand mark's own radii), circumference 219.91, transform: rotate(-90deg) so every arc starts at twelve o'clock, stroke-linecap: butt. Rendered at 52×52 in the tier cards. Track: #EEF2F7 on a light band, rgba(255,255,255,0.10) on a .site-section.dark band.

TierSharestroke-dasharrayArc — lightArc — dark bandLabel — lightLabel — dark
Common25%54.98 164.93#64748B#94A3B8#5C6B80#94A3B8
Rare50%109.96 109.96#177BBB#05CBFC#136699#05CBFC
Epic75%164.93 54.98#A855F7#C084FC#9333EA#C084FC
Legendary100%none#FCC419#FCC419#7E5F02#FCC419

Legendary additionally carries filter: drop-shadow(0 6px 16px rgba(252,196,25,0.45)) and no track (the stroke covers it).

Legendary overrules the canvas — its glyph does not actually close. The canvas draws Legendary as the mark path plus a second wedge spanning 12→3 o'clock. The mark path already fills that quadrant, so the wedge double-paints it and the mark's real gap (8→12 o'clock) stays wide open. It renders as a C, under a caption reading "The ring closes." Rendered proof of the corrected form: screenshots/rings-tiers__desktop__{light,dark}.png. A plain r=35 / stroke-width=14 circle with no dash pattern closes it, lands on exactly the mark's radii, and keeps all four tiers on one geometry — which is the idea the tier ladder is expressing in the first place.

Closing CTA — the brand mark, not a tier glyph. 76×76, the mark path filled with the #177bbb → #05cbfc gradient (url(#og)), gold pip polygon 9,57 15,50 22,55 and gold circle cx=23 cy=23 r=5.5 intact, filter: drop-shadow(0 20px 40px hsla(203,78%,41%,0.35)), and the gap open. Legendary is the only rendering in the system where the gap closes; nothing else is allowed to close it, or the tier ladder's payoff stops meaning anything.


Design tokens

New tokens, added to the existing :root in landing-2026.css. All derived from tokens already in the file — no new colour is introduced by this phase.

PurposeTokenValueNotes
Bar height, rest--nav-h68pxReplaces three hard-coded 68s (.site-nav-inner, .mobile-menu top, main.js scroll offset)
Bar height, condensed--nav-h-condensed58px
Header easing--nav-easecubic-bezier(.4, 0, .2, 1)Matches the canvas
Nav accent--nav-accentvar(--brand-1)var(--coach) under .nav-coach
Nav accent, hover + active--nav-accent-active203 78% 30%272 72% 40% under .nav-coachDeliberately deeper than --brand-ink / --coach-ink. Those are audited against light sections only; on a tinted sheet over a dark band the brand one measures 4.32:1 and fails. Do not substitute them, and do not move the shared tokens — allowlisted nodes depend on their current values.
Progress fill--nav-progress-fillvar(--gradient-primary)var(--gradient-coach)

Everything else reuses existing tokens: --border, --radius-full, --font-display, --font-sans, --font-mono, --gold, --coach, --brand-1, --brand-2.

Typography

Nothing new. The header uses the existing ramp: nav links and Sign in at 14px / 500 Inter, the CTA at 13px / 600 (.mc-btn-sm), the lockup as SVG at height: 42px. Tier labels are 11px mono uppercase with .04em tracking; tier titles 18px / 700 Outfit at -0.015em; tier descriptions 13.5px / 1.55 Inter — all matching .tier-card as it ships today.


Microcopy

Five strings are new to these two pages. Everything else in the header already ships and is unchanged.

SurfaceStringTone check
Nav item (new on Meet Coach)TeamsNoun label, matches its section heading ✅
Nav action (new, both pages)Sign inTwo words, action verb, no "Log in"/"Sign in →" variance ✅
Toggle, closedOpen menu (aria-label)Verb-first ✅
Toggle, openClose menu (aria-label)Verb-first; must swap with state, it is static today ✅
Progress bar(none — aria-hidden="true")Decorative. A percentage read aloud on every scroll tick is noise, not information ✅

Existing header strings held verbatim: Features, Meet Coach, Rewards, Pricing, Sign up free, Objectuve home (brand aria-label).


Accessibility

  • Focus order (header): skip link → brand → 5 nav links in visual order → Sign in → Sign up free → toggle. DOM order already matches visual order; keep it that way.

  • Focus ring: outline: 2px solid hsl(var(--nav-accent)); outline-offset: 2px on every nav link, Sign in, the toggle, and every mobile-menu row — via :focus-visible, so it does not fire on mouse clicks. The site has no header focus ring today.

  • Current location: aria-current="page" on the current-page link (static). Scroll spy uses aria-current="location" — not "page", which would be a lie about which document you are on — set and removed as the active section changes.

  • The progress bar is aria-hidden="true" and carries no ARIA role. It is a decorative mirror of the scrollbar; a progressbar role would announce a value change on every scroll frame.

  • Tap targets: toggle 44×44 (up from 40×40); mobile-menu rows min-height: 44px (up from ~34px); mobile-menu CTA min-height: 44px. Desktop nav pills are ~34px, which is correct — they only exist above 1024px, where the input is a pointer.

  • Contrast: every value below is measured, not asserted — see § The header over a dark band.

    NodeOver the light heroOver a .site-section.dark band
    .nav-link / .nav-signin #334155, rest sheet10.02:14.01:1 — fails. Unreachable under this spec; see the ruling.
    .nav-link #334155, condensed sheet12.4:18.07:1 (7.98:1 at the band's darkest stop)
    Active pill, brand hsl(203 78% 30%) on a 10% tint6.30:15.15:1 (5.10:1 darkest stop)
    Active pill, coach hsl(272 72% 40%) on a 10% tint7.43:16.09:1 (6.02:1 darkest stop)
    Toggle icon #0F172A, condensed sheet15.9:113.9:1
    Mobile-menu row #0F172A on the opaque #fff panel17.85:117.85:1 — the panel is opaque, so the band never reaches it

    Every mockup renders axe-core clean at WCAG 2.1 AA, both bands.

  • Motion: the full contract above. Every effect on the site, individually.

  • aria-expanded on the toggle must track the menu, and its aria-label must swap Open menuClose menu. It ships Open menu permanently today.


What Codi builds

marketing_landing/css/landing-2026.css — merge mockups/contract.css in. It is annotated with the rule it adds to or replaces, keyed to current line numbers (.site-nav :389, .site-nav-inner :398, .site-nav-links :404, .nav-link :408, .nav-signin :416, .nav-toggle :424, the 900px collapse block :434, .mobile-menu :441, .reveal :1468).

marketing_landing/js/main.js — extend, do not replace. mockups/motion/motion-demo.html carries a working implementation of the four functions below; port it, keep the file's existing IIFE + var style, and keep every section not named here untouched (contact form, copyright year, teams billing toggle, PostHog capture).

  1. One motion gate at the topmatchMedia('(prefers-reduced-motion: reduce)'), read once, subscribed via addEventListener('change', …).
  2. Fold in the two inline reveal scripts (index.html:910-935, meet-coach.html:468-486) and delete them from both pages. .in is applied unconditionally.
  3. Delete the dead paths: the [data-animate].is-visible observer (0 matching elements on any page, and .is-visible has no CSS), and the .site-nav.scrolled toggle (no CSS rule). Keep animateCount — Phase 2's counters use it — but move its gate to the shared flag.
  4. One rAF-throttled scroll handler driving condense, progress and spy together. There are two scroll listeners today; there should be one.
  5. Null-safety is load-bearing. .nav-progress and [data-spy] exist only on index and meet-coach. Guard every lookup, or the other eight pages throw on every scroll.
  6. Smooth scroll takes the flag: behavior: reduce ? 'auto' : 'smooth' at main.js:87. The CSS scroll-behavior rule does not override an explicit JS behavior.

Header markup in index.html and meet-coach.html — identical blocks per § Header states. Do not touch the header on the other eight pages.


Acceptance criteria for Codi

  • [ ] One header markup block, one nav (5 items, same order), one CSS block, one JS module serve both pages
  • [ ] meet-coach.html carries nav-coach on <header>; index.html carries no modifier
  • [ ] "Meet Coach" points at meet-coach.html from both pages
  • [ ] Header condenses at scrollY > 12: 68→58px, sheet 0.6→0.88, hairline, shadow, brand scale(0.92)
  • [ ] Progress bar tracks scroll on index + meet-coach; the other 8 pages throw no console error
  • [ ] Scroll spy activates on index for Features / Rewards / Teams / Pricing, using the derived line, and sets aria-current="location"
  • [ ] prefers-reduced-motion: reduce matches the still-state table effect by effect, including pre-existing reveal, halos and smooth scroll
  • [ ] .reveal still receives .in under reduce; [data-count] still receives its final value
  • [ ] Toggling the OS motion setting mid-session re-applies without reload
  • [ ] Toggle is 44×44; mobile-menu rows ≥44px; mobile-menu CTA renders #fff on its fill
  • [ ] Nav does not overflow or wrap at 1024px, 1100px, or 1280px; collapses cleanly below 1024px
  • [ ] Zero class renames beyond ledger row 1; no new class on any allowlisted node; meet-coach.html::.mc-btn-sm and index.html::.mc-btn-coach each still match exactly one element at 1280px
  • [ ] The 12px condense threshold is not raised and condense is not disabled at any breakpoint — it is what keeps the rest sheet off every dark band (see § The header over a dark band)
  • [ ] Hover and active nav states use --nav-accent-active, not --brand-ink / --coach-ink
  • [ ] marketing-contrast-guard green with the allowlist unchanged — no entry added, none removed
  • [ ] png-legibility-guard green
  • [ ] All 10 landing-2026.css pages visually unregressed, apart from ledger rows 7–10, which are expected on all ten
  • [ ] UI evidence at 390 / 768 / 1280, light band + dark band, plus a reduced-motion capture per effect

Mockups & evidence

MockupWhat it pinsCaptures
header-states.htmlRest, condensed + spy, coach accent, menu open — each over the light hero and a .site-section.dark band4 subjects × 2 bands × 3 viewports = 24 PNGs
still-states.htmlAll 7 required effects, full motion vs still, on both bands7 subjects × 2 bands × 3 viewports = 42 PNGs
ring-glyphs.html4 tiers per band, closing CTA per band2 subjects × 2 bands × 3 viewports = 12 PNGs
motion/motion-demo.htmlThe motion module running — entrance, reveal, condense crossing onto a dark band, progress, spy, count-up2 GIFs: full motion and reduce, 390px

Every subject is paired across both bands. Four of the seven effects genuinely occur on a dark band today — reveal (9 nodes), halos (12 of 14), header condense (all three bands), and beam-draw (dark only.mc-anatomy is its sole home, so the light rendering is the diagnostic and the dark one is what ships). The other three — entrance, count-up, FAQ — have no dark-band occurrence on master; their dark captures are labelled as the forward contract for Phase 2, not as shipping states, rather than being omitted or fabricated.

All three mockups render against the real marketing_landing/css/landing-2026.css plus contract.css, served from the marketing web root by scripts/capture-marketing-mockups.mjs — so what renders is what Codi ships. axe-core (WCAG 2.1 A/AA): 0 violations, all three mockups.

The two GIFs are a pair on purpose: a motion GIF alone cannot show that the still path lands anywhere sane, and the still path is what this phase is actually for.

Tooling gap — resolved in Phase 2 (OBJ-2282)

The repo has no marketing motion capture. Promoted to scripts/capture-marketing-motion.mjs (Tess, Task 4a). It serves the same two roots capture-marketing-mockups.mjs does — marketing_landing/ at /, a phase's mockups/ at /mockups/ — without that script's animation-killing step, over real HTTP (never file://), so the marketing CSS and font paths resolve exactly as they do live. Both Phase 1 throwaway bridges (this phase's mockups/motion/capture-motion.mjs and Task 3's codi-capture-evidence.mjs) now call it instead of recording motion inline.


Out of scope — one finding to route

Eight pages ship a broken nav link. contact.html, 404.html and all six policies/* link /#how-it-works, and index.html has had no #how-it-works anchor for some time (0 occurrences). Their nav also carries only two items, against the five on the pages in scope.

Phase 1 does not fix this. Changing eight pages' nav content would blow past the phase's scope, put eight unwatched pages into visual-change territory in a phase whose acceptance criterion is that they stay unregressed, and add eight new scanned nodes to the contrast guard in the same PR that rewrites the header. It is a real defect and it wants its own ticket — routing to Maggie, not folding it in here.

Open questions

None blocking. Both nav rulings and all three canvas overrules (rest sheet opacity, beam still opacity, Legendary geometry) are made in this document and are Codi-ready.



UI-SPEC — v4.32 Phase 2 · Landing section deltas

Milestone: v4.32 — Marketing Landing Rebuild · Phase 2 · Task 4 (OBJ-2274) Implements for: Task 5 (Codi) — marketing_landing/index.html, marketing_landing/css/landing-2026.css, marketing_landing/js/main.jsMockups + evidence: .planning/phases/phase-2-landing-rebuild/mockups/ — 1 mockup, 5 subjects, 30 PNGs (light band + dark band × 390 / 768 / 1280), 1 trap capture, 4 motion GIFs (2 full-motion / reduce pairs), axe-core clean CSS deliverable: mockups/contract.css — every rule below, annotated with the line it adds to, replaces or deletes Depends on: Phase 1 (OBJ-2273), merged b34d45bfa on gsd/v4.32-marketing-landing-rebuild. Every line number in this part is keyed to that commit.


Scope

index.html brought to its canvas. The canvas is copy-identical to master in seven of its nine sectionsstory, features (all six cards), teams-showcase (including the privacy table), pricing, teams, faq (all six questions) and connect carry the same strings, the same order and the same structure that ship today. Verified by extracting every text node from Objectuve Landing.dc.html and diffing against index.html.

So this phase is not a rebuild. It is five visual subjects and one attribute:

#SubjectWhat changes
ARarity-tier glyphsfour identical #i-award sprites → the Objectuve ring at each tier's share of the arc, plus a new third line of copy
BStat countersfour static numbers → three animated, one deliberately not
CFAQ marka ::after content swap → a real element that rotates
DClosing-CTA glypha gradient rounded-square → the brand mark, gap open
E#coach-teaserthe id-less dark band gains an id (and nothing else)

Plus one thing the canvas asks for that nothing on the site has: an anchor offset, so a direct /#pricing load does not land under the sticky header.

Everything in Part 1's scope note still applies — landing-2026.css and main.js are loaded by all ten pages, and "dark" always means a .site-section.dark band, never an OS theme. Rule 6 below is the only change in this part that reaches beyond index.html, and it reaches only #-id selectors that exist on one page.


The four rulings

Ruling 1 — the "pricing toggle" criterion describes the Teams toggle that already ships. Verify, do not build.

Orion's recommendation (a), confirmed, with the canvas as evidence rather than inference:

  • The canvas's #pricing contains zero toggle markup. Free is $0 / forever, Supporter is $4 / month, exactly as index.html:648-698 ships them.
  • The only billing toggle anywhere in the canvas is .teams-billing-toggle inside #teams, drawn precisely as it ships: Annual / Monthly tabs, $70$7, Save 17%, and the same subline pair. It is already fully wired (data-annual-amount / data-monthly-amount / data-annual-interval / data-monthly-subline, main.js:233-259).

This is a non-regression item, not a build item. Do not add an annual Supporter tier. Supporter is $4/month in the product, and a marketing page that quotes an annual price checkout cannot honour is a promise the app does not keep — and inventing the tier is a product decision, not a design one.

Ruling 2 — coach-teaser is the existing dark band gaining an id. No data-spy. No #coach.

Orion's recommendation, confirmed. The <section class="site-section dark padded"> at index.html:408 already is the canvas's #coach-teaser, string for string. It gains id="coach-teaser" and joins rule 6's scroll-margin-top set. Nothing else about it changes — see screenshots/coach-teaser__*__dark.png.

Three consequences, all named because each is a way to get this wrong:

  1. The nav does not move. "Meet Coach" points at meet-coach.html from every page, per Part 1's ruling 2. The teaser keeps its own in-section CTA ("Meet your Coach" → meet-coach.html).
  2. It does not join the scroll-spy set. The spy is driven by .site-nav .nav-link[data-spy] (main.js:86) — it needs a nav item to light, and there is no nav item for this section. Adding one would break Part 1's five-item contract; adding data-spy without one would register a section that can never light a pill. So: anchor target only.
  3. id="coach" is not adopted. The canvas puts id="coach" + data-spy="coach" on the Coach feature card (<article> inside #features) because its nav repoints "Meet Coach" at it — the exact repoint Part 1 overruled. With the nav staying cross-page, that id would be an unreferenced orphan competing with #coach-teaser for the same meaning. One coach anchor on the page.

Why give it an id at all, given nothing links to it yet: every other section on the page has one, an id-less section is the anomaly, the ROADMAP names coach-teaser in the section inventory, and it makes the band addressable by a campaign or shared link without inventing a page. The cost of the id is exactly the three lines above, all closed here.

Ruling 3 — three of four stats animate. 0 / Ads, ever does not.

Confirmed, and the canvas agrees independently: its stat row carries data-count on three spans and leaves the fourth as plain text. Counting up to zero is a non-event — the flat 0 is the brand line, and animating it would turn a promise into a slot machine. Full per-stat table below.

Ruling 4 — inline SVG per card, not a sprite symbol.

Four cards, four different geometries. A shared <symbol> cannot carry them: stroke-dasharray set on a <use> does not reach the referenced <circle> (it is not an inherited property), so each tier would need its own CSS rule to re-specify what is already an attribute — and Legendary is a different shape entirely (no dash pattern, no track). A sprite would buy indirection and cost four rules. Two <circle> elements per card, three for Legendary's single circle, is the whole cost.

The geometry itself is Part 1's and is not re-derived here — including Part 1's Legendary correction (a plain r=35 / stroke-width=14 circle, not the canvas's mark-path-plus-wedge, which double-paints one quadrant and renders as a C under a caption reading "The ring closes").


Canvas → class mapping

Every canvas node, mapped to the class that already exists or to the new one that does not. "reused" means byte-identical structure and copy — no change to that node at all.

#story — reused entirely

Canvas nodeClassStatus
<section id="story">.site-section.padded#storyreused
intro block.section-intro.eyebrow-brand, .site-title, .section-ledereused
four numbered principles.manifesto-row.n, .h, .breused

#features — reused entirely, one canvas attribute rejected

Canvas nodeClassStatus
<section id="features">.site-section.padded#featuresreused
split intro.section-intro-split.lead, .asidereused
6 cards.feat-card + .tint-brand / .tint-coach / .tint-gold / .tint-success / .tint-accent / .tint-brandreused, same order
card internals.feat-card-visual.feat-card-tag, .feat-card-phone; .feat-card-body.feat-card-eyebrow, .feat-card-title, p, .feat-card-linkreused
id="coach" on card 2not adopted — ruling 2.3

Card order is load-bearing. Four contrast-allowlist entries are positional (.tint-brand.feat-card:nth-child(1), :nth-child(6), two each). Do not reorder, insert or remove a card.

#coach-teaser — reused, gains one attribute

Canvas nodeClassStatus
<section id="coach-teaser">.site-section.dark.padded (index.html:408)gains id="coach-teaser" — ledger 14
grid.coach-teaser-gridreused
copy column.eyebrow.on-dark, .d-title.on-darkem.coach, .section-lede.on-dark, .hero-actions.mc-btn.mc-btn-coachreused
quote card.coach-quote-card.coach-quote-head.spark, .coach-quote-name, .mc-mono; .coach-quote-text; .coach-personas-chipreused
halo.halo.halo-coachreused

#gamification — the phase's real work

Canvas nodeClassStatus
<section id="gamification">.site-section.padded#gamificationreused
intro.section-intro.eyebrow-brand, .site-title, .section-ledereused
tier grid.tier-gridreused
tier card.tier-card + .tier-common / .tier-rare / .tier-epic / .tier-legendaryreused
52×52 ring SVG.tier-ringnew — replaces .tier-medal, ledger 15
tier label.mc-monoreused
18px tier title.descreused — already 18px/700 Outfit (:1010), the canvas's value
13.5px third line.tier-notenew, ledger 16
card hover.tier-card:hover (+ 4 per-tier tints)new, ledger 17
stat row.bigstats.stat.v, .v small, .lreused
counted figurenew inner <span data-count> inside .vnew node + attributes, ledger 22

#teams-showcase — reused entirely

Canvas nodeClassStatus
<section id="teams-showcase">.site-section.dark.padded#teams-showcasereused
intro.section-intro.teams-intro.eyebrow-brand, .site-title, .section-lede.on-dark, .teams-actionsreused
3 alternating rows.teams-row / .teams-row.media-left.teams-row-copy.teams-row-eyebrow, .teams-row-title, .teams-row-body, .teams-chips.teams-chip; .teams-row-media.device-framereused
privacy table.teams-privacy.teams-privacy-head.teams-privacy-lock, .teams-privacy-title; .teams-privacy-sub; .teams-privacy-cols.teams-privacy-col.can ("Admins can see", 4 items) + .teams-privacy-col.never ("Admins never see", 4 items)reused, all eight rows verbatim

#pricing — reused entirely, no toggle

Canvas nodeClassStatus
<section id="pricing">.site-section.pricing-section.padded#pricingreused
intro.section-intro.pricing-introreused
Free card.price-cardheaderh3, .price-amount.price-num $0 / .price-unit / forever; .price-blurb; .price-list (6); .mc-btn-ghost.price-ctareused
Supporter card.price-card.featured.price-flag Optional; .price-num $4 / .price-unit / month; .price-list (6); .mc-btn-brand.price-ctareused
billing toggleabsent in the canvas — ruling 1

#teams — reused entirely, toggle is a non-regression item

Canvas nodeClassStatus
<section id="teams">.site-section.teams-panel-section.padded#teamsreused
value column.teams-panel-value.teams-feature-grid (6), .teams-trust-stripreused
price card.price-card.teams-price-card.teams-price-head, .teams-seat-chipreused
billing toggle.teams-billing-toggle.teams-toggle-tab[data-period] ×2reused — must still switch, and the amounts must still change
price figure.teams-price-amount[data-annual-amount][data-monthly-amount], .teams-price-interval, .teams-save-badge, .teams-price-sublinereused, all data-attributes byte-identical
honest terms.teams-honest-terms (3)reused

#faq — reused, one mark becomes an element

Canvas nodeClassStatus
<section id="faq">.site-section.faq-section.padded#faqreused
intro.section-intro.eyebrow-brand, .site-titlereused — allowlisted path, see below
6 items.faq-item (<details>) → summary, preused, same six questions in the same order
+ mark.faq-mark (<span> in summary)new, replaces summary::after — ledger 19

index.html::#faq > .site-container > .section-intro.reveal.in > .eyebrow-brand is an allowlisted path. The intro must stay the direct child of .site-container under #faq, and it must still receive .in at scan time. Do not wrap it.

#connect — reused, glyph becomes the mark

Canvas nodeClassStatus
<section id="connect">.cta-section.padded#connectreused
76×76 mark SVG.cta-marknew, replaces .cta-orb on this page — ledger 20
heading / lede / CTA.cta-titleem, .cta-lede, .cta-actions.mc-btn.mc-btn-brand.mc-btn-lgreused
store badges.store-badges.store-badge-item.store-badgereused — the canvas keeps the Google Play badge; keep the held iOS block commented exactly as it is
fine foot.cta-finefootreused — allowlisted, class-only, must stay unique

Rename / edit ledger — extended

Continues Part 1's numbering. Nine new rows. Zero renames. One retirement + replacement (row 15), by the same test Part 1 applied to its own row 1: the class carries zero allowlist entries and its entire rule body is being deleted because the element it names no longer exists in that form. Eight additive.

#ChangeKindContrast-allowlist consequenceBlast radius
14id="coach-teaser" added to <section class="site-section dark padded"> (index.html:408)new attribute on an existing nodeNone. No index entry's path traverses this section by id. index.html::.coach-quote-head > div > .mc-mono and index.html::.mc-btn-coach both sit inside it and both resolve without an ancestor id — axe emits the shortest unique selector and stops as soon as it has one, so an id added above an already-unique path does not lengthen it. No index entry is positional at section level (the three :nth-child() section entries are meet-coach's).index only.
15.tier-medal.tier-ringretirement + replacementNone. .tier-medal appears in 0 allowlist entries, and it is an icon container with no text, so axe's contrast rule does not assess it. Deletes .tier-card .tier-medal (:995) and the four per-tier gradient rules (:1002-1005).index only — .tier-medal is used on no other page.
16.tier-note added, 4 nodesnew class + new text nodesWatch — and measured. Four genuinely new text nodes enter the 1280px scan. At hsl(var(--muted-foreground)) = #5C6B80 they measure 5.21:1 on the card's #FAFAFC foot and 5.33:1 on Legendary's #FFFDF5. Expected green; if not, it routes to Desi, never into the allowlist.index only.
17.tier-card gains transition; .tier-card:hover + 4 per-tier hover tints addedproperty change, no class changeNone. The guard scans at rest — no hover state is applied.index only.
18.tier-card padding: 28px 24px → 22px 18px and .desc 18px → 16px + hyphens: auto, below 600px onlyproperty change in a new media queryNone. The guard scans at 1280px, above the query.index only, ≤600px. Fixes a live clip on master — see below.
19.faq-mark <span> added inside all six <summary>; .faq-item summary::after (:2169) and .faq-item[open] summary::after (:2175) deletednew node, pseudo-element removedWatch. Six new text nodes enter the scan. At hsl(var(--brand-ink)) = #136699 on the card's #fff they measure 6.20:1. They also carry aria-hidden="true", so axe skips them entirely — the measurement is for the sighted reader, not the gate.index only — .faq-item exists on no other page.
20.cta-mark added; .cta-orb retained and unchangednew class, sibling not modifierNone — decorative aria-hidden SVG, no text to assess. Does not touch .cta-finefoot, which is the only allowlisted node in this section and is class-only.index only. .cta-orb stays live on meet-coach.html; adopting .cta-mark there is Phase 3's call, since that phase rebuilds the page.
21scroll-margin-top on the nine #-id sectionsproperty change on id selectorsNone — layout only, no colour, no new node.index only (all nine ids exist on index alone).
22[data-count] / [data-suffix] on three new inner <span> wrappers inside .bigstats .stat .vnew nodes, text moved into themNone. No allowlist entry references .bigstats, .stat or .v, and the wrapper carries the same ink on the same background as the .v it sits inside.index only.

Three things Codi must not do, each of which breaks something with no error:

  • Do not put data-count on .v. animateCount writes el.textContent (main.js:217), which erases every child node. Two of the four stats carry a <small> sibling — min/day and ranks — and both would vanish on frame one and never come back. data-count goes on an inner <span>, with the <small> as its sibling, still inside .v so .bigstats .stat .v small (:1040) keeps matching. Verified in both motion states: screenshots/deltas-mobile-{full,reduced}.gif.
  • Do not leave summary::after in place when adding .faq-mark. The first render pass of this phase's mockup did exactly that and produced two marks per row — the new span rotating and the pseudo-element still swapping +/. Evidence: screenshots/trap-faq-double-mark__desktop__light.png. The delete is half the change.
  • Do not keep the + content swap alongside the rotation. A rotated is a vertical bar. The glyph stays + in both states and reads as × when rotated.

Row 18 fixes a live defect on master. At two columns on a 390px viewport each .tier-card is ~159px wide, ~103px of it content — and "breakthroughs" at 18px/700 Outfit is ~117px. .tier-card carries overflow: hidden (:993), so it clips the word rather than pushing the layout, which is why it has never been reported. Found by rendering, not by inspection; pre-fix and post-fix at screenshots/tier-cards__mobile__light.png. Two columns is kept deliberately — the four tiers are a ladder and a 2×2 still reads Common → Rare → Epic → Legendary in order, while a four-row stack costs extra scroll on the surface whose whole argument is that it respects your time.


Counter spec

[data-count] appears zero times in index.html today while counters() / animateCount() (main.js:195-223) have been sitting there with nothing to animate. All four values are static text (index.html:483-495).

#LabelTargetSuffixMarkupAnimates
1Average use10min/day, as a sibling <small><div class="v"><span data-count="10">10</span><small>min/day</small></div>yes
2Badges to earn20+, via data-suffix<div class="v"><span data-count="20" data-suffix="+">20+</span></div>yes
3First Stone → Legacy10ranks, as a sibling <small><div class="v"><span data-count="10">10</span><small>ranks</small></div>yes
4Ads, ever0none<div class="v">0</div>no data-count, no wrapperno

Three notes, all of which are things that go wrong quietly:

  • Two suffixes are markup, one is an attribute, and that split is deliberate. min/day and ranks are styled by .bigstats .stat .v small (:10400.36em, #5C6B80, weight 600), so they must stay real <small> elements outside the counted span. + has no styling of its own and is part of the number, so it rides data-suffix and is written on every frame by animateCount — the count reads 0+ … 20+, which is correct: 20+ is the value, not 20 with an ornament.
  • counters() already never restarts. io.unobserve(entry.target) fires before animateCount (main.js:203-204), so re-scrolling cannot re-trigger it. No change needed; do not add a guard.
  • Markup ships the final value. 10, 20+, 10, 0 are the authored text, so a no-JS reader and a reduce reader see the same thing a full-motion reader ends on. A stuck 0 would be a factual error, not a missing animation.

Empirically checked at 390px in both motion states — final text and surviving <small> for all four:

reducedMotion=no-preference          reducedMotion=reduce
  "10min/day"  <span data-count="10">10</span><small>min/day</small>     ← identical
  "20+"        <span data-count="20" data-suffix="+">20+</span>          ← identical
  "10ranks"    <span data-count="10">10</span><small>ranks</small>       ← identical
  "0"          0                                                        ← identical

Ring-glyph markup contract

Geometry is Part 1's — § Ring-glyph geometry — and is not restated. This is the markup that carries it. Inline SVG per card, per ruling 4.

html
<div class="tier-card tier-common reveal">
  <svg class="tier-ring" viewBox="0 0 100 100" aria-hidden="true">
    <circle cx="50" cy="50" r="35" fill="none" stroke="#EEF2F7" stroke-width="14"/>
    <circle cx="50" cy="50" r="35" fill="none" stroke="#64748B" stroke-width="14"
            stroke-linecap="butt" stroke-dasharray="54.98 164.93"/>
  </svg>
  <div class="mc-mono">Common</div>
  <div class="desc">Everyday wins</div>
  <p class="tier-note">First check-in, first goal, first week.</p>
</div>
TierTrackArc strokestroke-dasharray
Common#EEF2F7#64748B54.98 164.93
Rare#EEF2F7#177BBB109.96 109.96
Epic#EEF2F7#A855F7164.93 54.98
Legendarynone — the stroke covers it#FCC419none — one <circle>, no dash
  • width / height live in CSS (.tier-ring, 52×52), not on the element — one place to change.
  • rotate(-90deg) is on .tier-ring and applies to all four, including Legendary, where it is a no-op on a full circle. One rule, no exception to remember.
  • aria-hidden="true" on every ring. The tier is already named in text twice, by .mc-mono and .desc; a screen reader announcing a graphic here adds nothing.
  • Legendary's filter: drop-shadow(0 6px 16px rgba(252,196,25,0.45)) is a CSS rule (.tier-card.tier-legendary .tier-ring), not an inline style.

Closing CTA — the brand mark, gap open. 76×76, url(#og) gradient fill, gold pip polygon 9,57 15,50 22,55, gold circle cx=23 cy=23 r=5.5, drop-shadow per .cta-mark. The #og gradient must be defined in the page's sprite <defs>. Legendary is the only rendering in the whole system where the gap closes — nothing else may close it, or the tier ladder's payoff stops meaning anything.

On-dark values are pinned but must NOT be shipped. #gamification, #faq and #connect are light sections and this phase does not move them. screenshots/{tier-cards,bigstats,faq-mark,cta-mark}__*__dark.png render the forward contract — track rgba(255,255,255,.10), arcs #94A3B8 / #05CBFC / #C084FC / #FCC419 (7.04:1, 9.41:1, 6.83:1, 11.21:1), note and body #CBD5E1 (12.15:1 on #0B1530), FAQ mark #05CBFC — so a later phase does not re-derive them. Adding those rules now would be dead CSS.


Reduced motion — reconciled against Part 1's still-state table

Phase 2 introduces three moving things. All three already have a row in Part 1's table, and Part 2 adds none. Part 1 wrote that table forward — two of its rules currently match nothing on the site — and this phase is what makes them live. That is the accounting, not an omission:

Effect this phase addsPart 1 rowFull motionStill stateRule that produces it
Count-up, [data-count] × 3required effect 40 → target, 900ms ease-out cubic, onceFinal number, printed, suffix and <small> intactmain.js:199 writes target + suffix synchronously before any observer. No CSS. Row 4 said "Phase 2 markup" — this is that markup.
FAQ mark rotaterequired effect 5+ rotates 45° over 250msRotated, untransitioned — the control still reads as a control.faq-mark { transition: none } (:1610) — already shipped, matched nothing until now
Tier card hover lift"rest of the site's motion" → hover lifttranslateY(-4px) + per-tier shadow + border tint, 250msColour only — tint survives, lift does not.tier-card:hover { transform: none } (:1636) — already shipped against a :hover rule that did not exist

The two non-negotiables from Part 1 are restated because this phase is where they bite:

JS never withholds a class or a value to produce stillness. .reveal still gets .in; [data-count] still gets its final number. Stillness lives in the media query.

Three contrast-allowlist entries match on .section-intro.reveal.in, and one of them (index.html::#faq > … > .section-intro.reveal.in > .eyebrow-brand) sits in a section this phase edits. A JS-gated .in orphans all three silently and trips their nodes as new violations. Both halves verified: screenshots/deltas-mobile-full.gif and deltas-mobile-reduced.gif are the same journey, and the still one ends on the same numbers.

scroll-margin-top is not motion and needs no row — it is a layout offset that applies identically in both states. It is also the correct fix precisely because it is not motion: it repairs the direct-hash-load case, which never goes through main.js's smooth-scroll handler at all.


Design tokens

None new. Every value this phase introduces is an existing token or an existing literal in this file:

PurposeValueSource
.tier-note inkhsl(var(--muted-foreground)) = #5C6B80already :root, already used by .bigstats .stat .v small and .tier-card.tier-common .mc-mono
.faq-mark inkhsl(var(--brand-ink)) = #136699already :root, published for exactly this job
Hover / rotate easingvar(--nav-ease)added by Part 1
Anchor offsetcalc(var(--nav-h-condensed) + 16px)--nav-h-condensed added by Part 1
Ring arcs, track, gold shadow#64748B / #177BBB / #A855F7 / #FCC419 / #EEF2F7Part 1's ring table; all four are the tier colours already in :1002-1009

Two canvas values overruled, both on contrast headroom:

NodeCanvasThis specWhy
.tier-note#64748B--muted-foreground #5C6B80#64748B is 4.57:1 on the card's #FAFAFC foot — 0.07 above the AA floor, on a gradient background, in a card that also gains a hover state. #5C6B80 is 5.21:1 / 5.33:1.
.faq-mark#177BBB--brand-ink #136699#177BBB is 4.58:1 on white at 22px, which is not WCAG large text (that starts at 24px). It was invisible to the guard as a pseudo-element; promoted to a real node it is a measured 0.08 of headroom. #136699 is 6.20:1.

Neither is a new colour and neither moves a shared token — allowlisted nodes depend on the current values of --brand-ink and --muted-foreground, so do not adjust the tokens themselves.


Typography

Nothing new. Two changes inside the existing ramp:

ElementFontClass / sizeNote
Tier labelmono.mc-mono 11px, .04em, uppercaseunchanged
Tier titleOutfit.desc 18px / 700, -0.015emunchanged — already the canvas's value
Tier title, ≤600pxOutfit16px / 700, hyphens: autonew — ledger 18
Tier noteInter.tier-note 13.5px / 1.55new, matches the canvas
FAQ markinherited.faq-mark 22px / 400same size the pseudo-element used
Stat figureOutfit.v clamp(36px, 5vw, 56px) / 800unchanged
Stat suffixOutfit.v small 0.36em / 600unchanged — preserved by keeping <small> a sibling

Microcopy

Four new strings. Every other string in the nine sections is already shipping and is held verbatim — including all six FAQ questions and answers, all eight privacy-table rows, both price cards, and the closing CTA.

SurfaceStringTone check
Tier note — CommonFirst check-in, first goal, first week.Concrete, not abstract ✅ · three real first-times, no praise ✅
Tier note — Rare30 days unbroken. 100 check-ins logged.Names the actual bar ✅ · earned, not handed out ✅
Tier note — EpicA goal finished. A season held together.Brief ✅ · "held together" is honest about what it costs ✅
Tier note — LegendaryThe ring closes. Rank ten: Legacy.Pays off the glyph in four words ✅ · the one string the geometry has to earn ✅

These four are the canvas's own copy, adopted verbatim. They are worth calling out as the phase's only new copy because of what they do: the tier ladder previously said what a tier is called and nothing about how you get one. "20+ badges across four rarity tiers" was a claim; 30 days unbroken is a price. That is the difference between gamification and manipulation, stated on the page that promises we do not manipulate.

No string uses "AI" — Coach is Coach. No string is generic-motivational.


Accessibility

  • Focus order is unchanged in all nine sections. The only new interactive-adjacent node is .faq-mark, which sits inside <summary> and is aria-hidden="true" — it does not become a tab stop and does not split the summary's accessible name.

  • .faq-mark is aria-hidden="true". <details> / <summary> exposes expanded state natively; a screen reader announcing "plus" before every one of six questions is noise, not information. Do not add aria-expanded to the summary either — the native element owns that.

  • Every .tier-ring and the .cta-mark are aria-hidden="true". Both are decorative: the tier is named twice in text beside its ring, and the closing mark sits above an <h2> that carries the message.

  • Tap targets. <summary> rows are 20px 24px padding on an 18px line ≈ 62px tall — over the 44px floor. .tier-card is not interactive on touch (hover is pointer-only), so it needs no target size. No new control is added anywhere.

  • Contrast — measured, not asserted. Every value below was computed, and the shipping set is axe-verified at 1280px:

    NodeValueBackgroundRatio
    .tier-note#5C6B80#FAFAFC (card foot)5.21:1
    .tier-note, Legendary#5C6B80#FFFDF55.33:1
    .tier-card .desc#0F172A#FAFAFC17.13:1
    .tier-card .mc-mono, Common → Legendary#5C6B80 / #136699 / #9333EA / #7E5F02#FAFAFC / #FFFDF55.21 / 5.95 / 5.16 / 5.85:1
    .faq-mark#136699#fff6.20:1
    .bigstats .stat .v#0F172A#fff18.4:1
    .bigstats .stat .v small, .l#5C6B80 / #64748B#fff5.43 / 4.76:1 (both unchanged)
  • Motion: the reconciliation table above, effect by effect.

  • hyphens: auto needs lang. index.html already carries <html lang="en">, so the ≤600px hyphenation resolves against English patterns. Do not remove it.

  • axe-core (WCAG 2.1 A/AA): 0 violations, all five subjects, both bands.


What Codi builds

marketing_landing/css/landing-2026.css — merge mockups/contract.css. Six numbered rules plus one media query, each annotated with the line it adds to, replaces or deletes. Three deletions are part of the merge and none of them is optional:

  1. .tier-card .tier-medal (:995) and the four per-tier .tier-medal rules (:1002-1005)
  2. .faq-item summary::after (:2169) and .faq-item[open] summary::after (:2175)
  3. nothing else — .cta-orb (:1401) stays, it is still meet-coach's

marketing_landing/index.html — five subjects:

  1. id="coach-teaser" on the section at :408. No data-spy. No nav change.
  2. Four .tier-card bodies: .tier-medal div → .tier-ring SVG, plus a .tier-note <p>.
  3. Four .bigstats .stat .v bodies: inner <span data-count> for three, <small> kept as sibling, fourth left as plain 0.
  4. Six <summary> bodies: append <span class="faq-mark" aria-hidden="true">+</span>.
  5. .cta-orb div → .cta-mark SVG in #connect. Keep the store badges and the commented iOS block exactly as they are.

marketing_landing/js/main.jsno change is required. counters() and animateCount() already do everything the counter spec needs, including the data-suffix path, the reduce branch and the once-only unobserve. If this file ends up in the diff, that is a signal something was misread — check that against the counter spec before shipping it.

#i-award stays in the sprite. #gamification stops using it, but #teams's feature grid still does (index.html:720), and meet-coach.html uses it too. Do not remove the symbol.


Acceptance criteria for Codi

  • [ ] Every section in the mapping is built; the seven "reused entirely" sections are untouched — same strings, same order, same structure
  • [ ] #coach-teaser present on the existing dark band, with no data-spy, no nav change and no id="coach" anywhere
  • [ ] Four rarity-tier glyphs render the ring at its share of the arc per tier; Legendary is gold, closed, and has no track; the closing CTA uses the mark with its gap open
  • [ ] .tier-note present on all four cards with the four strings verbatim
  • [ ] Counters count to 10 / 20+ / 10 with the spec's suffixes; 0 / Ads, ever does not animate; min/day and ranks survive the count (they are siblings of [data-count], not children); nothing restarts on re-scroll
  • [ ] Under prefers-reduced-motion: reduce, every [data-count] shows its final number with its suffix, and every .reveal still gets .in — stillness from the media query only
  • [ ] Exactly one mark per FAQ row: summary::after is deleted, the + rotates and does not also swap to
  • [ ] #pricing is unchanged — no consumer annual tier, no toggle. .teams-billing-toggle still switches and $70$7, the interval and the subline all still change
  • [ ] No horizontal scroll and no clipped word at 390px; "Milestone breakthroughs" fits its card
  • [ ] A direct /#pricing / /#faq / /#gamification load lands with the heading clear of the sticky header
  • [ ] Phase 1 contract not regressed: zero renames against the ledger; the 12px condense threshold unchanged and condense not dropped at any breakpoint; .site-nav.nav-coach ~ .mobile-menu sibling rule intact. (RAMP_GAP_TARGETS allowlist retired entirely by OBJ-2292 — the mechanism no longer exists in scripts/check-marketing-contrast.mjs, so there is nothing left to check byte-identical.)
  • [ ] No new class on any allowlisted node; the features grid is not reordered, and index.html::.mc-btn-coach, .pricing-intro > .eyebrow-brand, .teams-panel-intro > .eyebrow-brand and .cta-finefoot each still match exactly one element at 1280px
  • [ ] marketing-contrast-guard green with the allowlist unchanged — no entry added, none removed
  • [ ] png-legibility-guard green
  • [ ] The nine other landing-2026.css consumers unregressed — meet-coach, contact, 404, six policies/*; headless pass with zero attributable JS errors. .cta-orb still renders as a chip on meet-coach.html
  • [ ] UI evidence at 390 / 768 / 1280, light band + dark band, plus a full-motion / reduce GIF pair

Mockups & evidence

Subject (data-capture)What it pinsCaptures
tier-cardsFour tiers in real .tier-card classes: ring, label, title, new note2 bands × 3 viewports = 6
bigstatsCounter row at final value, suffix markup labelled per stat6
faq-markClosed + and open ×, single mark per row6
cta-markBrand mark at 76×76, gap open, in real .cta-inner context6
coach-teaserThe band as it ships, with the id ruling behind it6
trap-faq-double-markThe pre-fix state: two marks per row when summary::after is left in place1
motion/motion-demo.htmlCount-up through the real main.js, FAQ rotate, tier hover4 GIFs — mobile + desktop, each full-motion / reduce

Rendered by scripts/capture-marketing-mockups.mjs --phase phase-2-landing-rebuild, which serves marketing_landing/ as the web root — so the mockup loads the real landing-2026.css, the real fonts and the real main.js. What renders is what Codi ships. axe-core (WCAG 2.1 A/AA): 0 violations.

The GIFs are a pair on purpose: a motion GIF alone cannot show that the still path lands anywhere sane, and for this phase the still path is where the money is — a counter stuck at 0, or one that ate its own min/day, is a factual error on the page, not a missing animation.

Two findings from rendering, not from inspection

Both were invisible on the page and both were caught by capturing a condition nobody captures:

  1. A clipped word at 390px — ledger 18. Fixed in this phase.
  2. #coach-teaser has a 2.12:1 node if it ever goes light..coach-quote-card .coach-quote-head .mc-mono (:927) — the "Read the room · 9:42 PM" line — ships hsl(271 91% 80%) = #ce9efa. On the dark band it is allowlisted and fine. Rendered on a light band it is 2.12:1, less than half the AA floor; --coach-ink is 6.98:1. This is not a Phase 2 change — the band stays dark, so nothing fails today. It is recorded because Phase 3 is the phase most likely to move a coach surface onto a light background, and this is the value it would need. Evidence: screenshots/coach-teaser__*__light.png.

Tooling — closed by Task 4a (OBJ-2282)

This phase has no motion bridge of its own. Task 4a landed scripts/capture-marketing-motion.mjs while Task 4 was in flight, so the phase-local bridge this spec originally carried was deleted rather than left as a third copy. Both GIF pairs above are re-recorded through the shared script, and these are the two commands that reproduce them:

bash
node scripts/capture-marketing-motion.mjs --phase phase-2-landing-rebuild \
  --page mockups/motion/motion-demo.html --viewport mobile --name deltas \
  --motion-steps 'wait:500|scroll:560|wait:1500|click:#faq-a summary|wait:900|scroll:180|wait:700'

node scripts/capture-marketing-motion.mjs --phase phase-2-landing-rebuild \
  --page mockups/motion/motion-demo.html --viewport desktop --name deltas-hover \
  --motion-steps 'wait:400|scroll:900|wait:600|hover:.tier-card.tier-rare|wait:700|hover:.tier-card.tier-legendary|wait:900'

The dependency finding this spec originally reported was half right, and the correction matters.playwright-core is declared — it ships transitively via ionic_frontend's @playwright/test and playwright devDependencies — but ionic_frontend/node_modules does not exist after a bare multica repo checkout, so the resolution fails until someone runs npm install there. It is a missing install step, not a missing dependency. Task 4a resolved it the right way: the shared script carries a two-entry ladder (ionic_frontend/package.json, then the repo root) and no developer-working-copy fallback, and it exits telling you to run npm install in ionic_frontend/ instead of silently reaching outside the checkout. scripts/capture-marketing-mockups.mjs — which produced the 30 PNGs — still carries the three-entry ladder with that fallback as its last entry; aligning it with the shared script's behaviour is a small follow-up on that script, not this phase's change.

ffmpeg is present, and chromium is not — both scripts fall back to system Chrome, which is what recorded these GIFs.


Open questions

None blocking. All four rulings are made in this document. Both of Orion's open questions are answered with the canvas as evidence rather than inference, and all four canvas overrules — the two contrast values, id="coach", and Legendary's geometry (Part 1's, restated) — are recorded here and are Codi-ready.

Out of scope — findings to route

  1. .coach-quote-head .mc-mono at 2.12:1 on a light band (above). Not a defect today. Belongs to whichever phase moves a coach surface off the dark band — most likely Phase 3.
  2. .cta-orb on meet-coach.html. Once index uses .cta-mark, the two closing CTAs render different glyphs. Phase 3 rebuilds that page and should adopt .cta-mark there, retiring .cta-orb in the same change. Doing it here would put a second file's closing CTA into a phase whose acceptance criterion is that the other nine pages stay unregressed.
  3. playwright-core absent from the checkout (above) → Task 4a / OBJ-2282.


UI-SPEC — v4.32 Phase 3 · Meet Coach: beam choreography, Moments chip, closing mark

Milestone: v4.32 — Marketing Landing Rebuild · Phase 3 · Task 6 (OBJ-2275) Implements for: Task 7 (Codi) — marketing_landing/meet-coach.html, marketing_landing/css/landing-2026.css, marketing_landing/js/main.jsMockups + evidence: .planning/phases/phase-3-meet-coach/mockups/ — 1 mockup, 4 subjects + 1 trap, 21 PNGs, 4 motion GIFs (2 full-motion / reduce pairs), axe-core clean, png-legibility-guard green CSS deliverable: mockups/contract.css — five rules, each annotated with the line it adds to, replaces or deletes Reference JS: mockups/motion/motion-demo.html — working beams() module, ready to port Depends on: Phase 1 (OBJ-2273) b34d45bfa and Phase 2 (OBJ-2274) 4e5234e6b on gsd/v4.32-marketing-landing-rebuild. Every line number in this part is keyed to 4e5234e6b.


Scope

Orion's re-scope is confirmed and the diff below is the evidence. Every section this phase's issue describes as "new" already ships and is fully populated — anatomy, Moments, Voice and the closing CTA are all on the branch. So this is not a rebuild either. It is one broken diagram, one copy delta, and one glyph:

#SubjectWhat changes
ABeam drawthe four anatomy beams are re-boxed onto the real gutter — they currently connect neither end — and then animated
BMoments timestampfour mono lines the canvas carries and the page does not
CClosing CTA.cta-orb cta-orb-coach.cta-mark, the brand mark with its gap open

Everything in Part 1's scope note still applies — landing-2026.css and main.js are loaded by all ten pages, and "dark" always means a .site-section.dark band, never an OS theme. Nothing in this part reaches beyond meet-coach.html: subject A's selectors exist on no other page, subject B's .moment-card exists on no other page, and subject C deletes a rule whose last consumer this phase converts.


Canvas → page text diff — run first, before anything was specified

Every text node extracted from Objectuve Meet Coach.dc.html (the canvas attached to OBJ-2226) and diffed against meet-coach.html. 126 canvas nodes, 119 page nodes, and the two are copy-identical in every section except one.

SectionVerdict
Hero (A_Hero)copy-identical — eyebrow, headline, lede, both CTAs, the Coach sheet, both chips and the reply bubble all match verbatim
Positioning (A_Positioning)copy-identical — intro plus all three .pos-cards
Anatomy (A_Anatomy)copy-identical — eyebrow, headline, lede, all four numbered layers, the output card's mono line, bubble and all three meta chips
Moments (A_Moments)delta — 4 strings. The canvas carries a mono timestamp above each title: 10:40 PM, 7:05 AM, Day 30, Any day. The page has none.
Voice (A_Voice)copy-identical — all three Most apps / Coach pairs, verbatim
Closing CTA (A_CTA)copy-identical — headline, lede, both buttons, fine foot
Navpage has five items, canvas has four (no Teams). Not a delta — Part 1 ruling 1 already decided Teams ships on both pages, deliberately overruling the canvas.
Footerpage has social labels, a consent-preferences row and a JS-filled year; the canvas has a data-year placeholder and a shorter blurb. Canvas-side scaffolding, not a design intent. Out of scope.

A near-empty diff is the accepted outcome and this is one. The section inventory in this issue's description is superseded by Orion's task package; nothing in the copy needs building.


The four rulings

Ruling 1 — beam-draw is desktop-only. ≤900px keeps its deliberate, documented still.

Orion's recommendation (a), confirmed. landing-2026.css:1993 sets .mc-anatomy-beam { display: none } under @media (max-width: 900px), and it stays.

That rule is not an oversight — it is half of a matched pair. .mc-anatomy-output drops its margin-left: 80px at the same breakpoint (:1992) because the two-column board collapses to one. Below 900px the input column sits above the output card, not beside it, so there is no gutter for a beam to span and no horizontal relationship for it to express. Overruling this would not be a choreography change; it would be a responsive redesign, with a new mobile layout, a new motion story and a new still state, in a phase whose whole subject is one effect.

Consequence for the evidence set, stated once so it is not read as a gap:

  • Beam evidence is desktop. anatomy-still__desktop__dark.png and the beam-draw-desktop-* GIF pair are the beam's shipping proof.
  • ≤900px evidence shows the stacked, beamless layoutanatomy-still__{mobile,tablet}__dark.png plus the beam-stacked-mobile-* GIF pair. Those captures are not missing the beam; they are the ruling, rendered.
  • The stacked state loses no information. 01 / 02 / 03 / 04 already carry the sequence in text, and the output card follows them in reading order — which is what the beams say in the first place.

Ruling 2 — the closing CTA takes .cta-mark. The canvas is overruled.

The canvas draws Meet Coach's closing glyph as an 80px filled purple circle holding #i-trail, wrapped in two obj-ring pulses. This spec ships Part 2's .cta-mark — the brand mark, gap open — instead. Four reasons, in order of weight:

  1. A filled closed circle at the closing CTA competes with the one place the system lets a ring close. Part 1: "Legendary is the only rendering in the whole system where the gap closes; nothing else may close it, or the tier ladder's payoff stops meaning anything." The tier ladder is on the other page in this milestone. Two pages, one system.
  2. It is not a colour change. .cta-orb-coach has no CSS rule anywhere in this file — verified, zero occurrences outside the markup. So this glyph has been inheriting .cta-orb's var(--gradient-primary) and rendering the brand blue on a purple page for as long as it has shipped. .cta-mark is that same gradient. Same ink, right shape. It also rhymes with the two brand lockups that already bookend this page in the header and the footer.
  3. The canvas's version costs a new infinite animation — two staggered obj-ring pulses — on the one page in this milestone whose motion budget is already spent on the beam, and it would need its own row in Part 1's still-state table.
  4. Part 2 routed this here explicitly, having deliberately left .cta-orb alive for exactly one more phase.

Geometry is not re-derived: Part 1's ring-glyph section and Part 2's markup contract own it, and the mark below is byte-identical to index.html:834-838.

Ruling 3 — take the Moments copy. Do not take the Moments card redesign.

The diff's one delta is four strings, and they earn their place: the section argues Coach shows up "at the moments where one sentence actually changes the next move," and a timestamp is the cheapest possible proof of that claim. They ship, verbatim, on the already-existing .mc-mono.

The canvas's card treatment does not ship. Its Moments cards are a different component — icon stacked above the copy in one column, and four individually tinted card backgrounds including a dark navy card sitting inside a light section. Adopting that is a four-card redesign that introduces a dark surface with its own contrast set into a phase whose acceptance criterion is that everything else stays unregressed. It is a real delta and it is routed, not folded in — see § Out of scope.

Ruling 4 — Part 2's routed light-band finding does not land in this phase. Closing it.

Part 2 recorded .coach-quote-card .coach-quote-head .mc-mono at 2.12:1 on a light band and routed it to "whichever phase moves a coach surface off the dark band — most likely Phase 3."Phase 3 moves no coach surface off a dark band. .mc-anatomy stays dark; .mc-cta stays light and gains no coach-quote node. Rendered rather than asserted: anatomy-still__*__light.png is the diagnostic, and it is labelled in the mockup as not-shipping. The finding stays open and stays routed; it is not this phase's.


Subject A — the defect, found by rendering

The beams currently connect neither the inputs nor the card. This was invisible on the page and invisible in the CSS; it took measuring the rendered boxes.

.mc-anatomy-beam ships viewBox="0 0 400 380" with the default preserveAspectRatio (xMidYMid meet) inside a 463.6 × 350.2 box. meet scales uniformly to 0.9215 and centres the result, so at 1280px:

Landmarkx
Input column, right edge632.4
Beam paths, start743.9
Output card, left edge776.4
Beam paths, end928.2

The four paths begin 111px clear of the input column, floating in empty gutter, and terminate 152px inside the output card, drawing four lines across its glass face and converging at a point in its interior. A diagram whose entire argument is four inputs, one output attaches to neither. Evidence: screenshots/trap-beam-letterboxed__desktop__dark.png.

Animating this would animate a broken diagram, and the phase's acceptance criterion — "beams draw into the output card as the inputs light" — cannot be met without fixing it first.

The fix — one honest box, one non-uniform mapping

PieceValueWhy
SVG boxleft: calc(-1 * var(--mc-board-gap)), width: calc(var(--mc-board-gap) + var(--mc-output-inset))the box becomes the actual gutter — the board's 64px column gap plus the card's 80px inset — reaching back to the input column's edge and forward to the card's
preserveAspectRationonethe viewBox maps onto that box exactly; there is no letterbox left to compute
vector-effectnon-scaling-strokenon-uniform scaling would otherwise thicken the diagonals
viewBox0 0 144 500x is 1:1 with the 144px gutter; y ≈ 1:1 with the board at desktop
Board alignmentalign-items: stretch (was center)the wrap now spans the board's height, not the card's — inputs are 497.5px of column against a 350.2px card, so a beam boxed to the card can never reach rows 01 and 04. The card stays optically centred via justify-content: center on the wrap, which is all align-items: center was buying.

Both numbers become custom properties on .mc-anatomy-board so the gutter and the layout cannot drift apart, and they change as variables below 900px rather than as competing declarations — which is what makes the result cascade-order-independent. (Phase 2 shipped a cascade-order bug and had to chase it; this is the same trap one layer down.)

Measured stable at 901 / 1024 / 1280 / 1440px, which is why hard-coded path coordinates are safe here:

Landmark1024–1440px901px
Gutter width144px144px
Row centres, % of board height11.29 / 36.29 / 61.29 / 86.2911.47 / 36.47 / 61.47 / 86.47
Output-card centre, % of board height50.0050.00

Beam markup contract

html
<svg class="mc-anatomy-beam" viewBox="0 0 144 500" preserveAspectRatio="none" aria-hidden="true">
  <path class="mc-beam" d="M0 56 C 86 56 86 250 144 250"  stroke="url(#mc-anatomy-beam)" stroke-width="1.25" fill="none" vector-effect="non-scaling-stroke"/>
  <path class="mc-beam" d="M0 181 C 86 181 86 250 144 250" stroke="url(#mc-anatomy-beam)" stroke-width="1.25" fill="none" vector-effect="non-scaling-stroke"/>
  <path class="mc-beam" d="M0 306 C 86 306 86 250 144 250" stroke="url(#mc-anatomy-beam)" stroke-width="1.25" fill="none" vector-effect="non-scaling-stroke"/>
  <path class="mc-beam" d="M0 431 C 86 431 86 250 144 250" stroke="url(#mc-anatomy-beam)" stroke-width="1.25" fill="none" vector-effect="non-scaling-stroke"/>
</svg>
  • y = 56 / 181 / 306 / 431 are the four row centres in the 500-unit space; 250 is the card's.
  • Control points sit at x = 86 (60% of the gutter), the canvas's own proportion.
  • The <defs> gradient keeps its existing id="mc-anatomy-beam"; only its end stop moves, hsla(271,91%,75%,0.5)hsla(271,91%,75%,0.65) — the canvas's figure. The still state is the state this effect spends most of its life in, so the drawn beam wants presence.
  • The gradient runs transparent → opaque left to right, so the beam is faintest at the input and brightest where it meets the card. That direction is the diagram's argument; do not flip it.
  • class="mc-beam" is not optional and not renameable. landing-2026.css:1675 carries Phase 1's reduced-motion contract for this exact selector, written before the markup existed. Any other name and the still state silently applies to nothing, and no gate in this repo can see it.

Beam choreography — ordered steps

These numbers are Part 1's, restated, not re-derived. Part 1 published them in its still-state table row 7 before the effect existed; changing them now would put the two documents out of sync for no gain.

t (ms)StepTargetFrom → toDurationEasing
trigger.mc-anatomy-board reaches threshold: 0.35observer unobserves immediately; fires once, never restarts
01ainput 01opacity 0.45 → 1500ease (CSS transition)
01bbeam 01stroke-dashoffset len → 01100cubic-bezier(.4, 0, .2, 1)
1602a / 2binput 02 / beam 02as above500 / 1100as above
3203a / 3binput 03 / beam 03as above500 / 1100as above
4804a / 4binput 04 / beam 04as above500 / 1100as above
1580settledfour drawn, four litbyte-identical to the authored still state
  • Each input lights as its own beam departs, not before and not after — the row brightening and the line leaving it are one event, read top to bottom.
  • The 1100ms draw is covered by Part 1's named exemption from the ≤400ms ceiling: that ceiling governs interactive feedback, and this is arrival choreography — one-shot, unprompted, never blocking input.
  • threshold: 0.35 on a board that is 497.5–584.3px tall is reachable at every viewport height this effect can occur at (it only exists above 900px wide). If the board ever grew past ~2.8× the viewport height the ratio would become unreachable — noted, not a live risk.

JS contract — main.js module 11, plus one line in module 0

Orion asked for any main.js diff to be justified. It is: stroke-dasharray has to be primed from each path's own getTotalLength(), which CSS cannot compute, and Part 1's contract requires that priming to be JS-only so there is no state the markup can fall back from. A pure-CSS pathLength variant exists and was rejected — under it, a no-JS reader gets four invisible beams forever, which is the exact failure this contract was written to prevent.

The full working module is in mockups/motion/motion-demo.html. Port it in the file's existing IIFE + var style, call it alongside entrance() / reveal() / counters() / header(), and change nothing else.

Five things in it that are load-bearing:

  1. The still state is the authored state. Markup ships undashed, opaque and lit. beams()departs from it under full motion; it never has to build it.
  2. settle() is what a media query cannot do. Priming is an inline style, and an inline style outranks @media (prefers-reduced-motion: reduce). Without settle(), a reader who turns motion on mid-draw freezes the diagram half-drawn permanently — Part 1's "toggling the OS setting mid-session must not leave the page in a half-guarded state" fails silently. Module 0's change handler gains exactly one line: if (reduce && beamSettle) beamSettle();
  3. The observer re-checks reduce before animating, for the reader who turned it on while the section was still offscreen.
  4. prime() is idempotent and is called again inside the observer, so an off → on → off flip still starts from a clean undrawn state instead of redrawing over a visible line.
  5. Null-safe. beams() returns null when there is no .mc-beam on the page — which is nine of the ten pages that load this file.

Canvas → class mapping

"reused" means byte-identical structure and copy — no change to that node at all.

mc-hero · A_Positioning · A_Voice — reused entirely

Canvas nodeClassStatus
hero.mc-hero.padded.mc-hero-grid, .mc-hero-copy, .mc-hero-visual, .mc-hero-orb, .mc-hero-sheet, .mc-hero-chips, .mc-hero-bubblereused
positioning.site-section.padded.section-intro, .mc-positioning-grid.pos-card ×3 (.is-coach on card 2)reused
voice.site-section.padded.voice-list.voice-row ×3 → .voice-bad / .voice-good.voice-tag, .voice-bad-text, .voice-good-textreused, all three pairs verbatim

Section order is load-bearing. Three contrast-allowlist entries are positional — meet-coach.html::.site-section:nth-child(2|4|5), counted inside <main>. Do not insert, remove or reorder a <section>, and do not wrap <main>'s children.

A_Anatomy — the phase's real work

Canvas nodeClassStatus
<section>.site-section.dark.mc-anatomy.paddedreused
intro.section-intro.eyebrow.on-dark, .d-title.on-dark, .section-lede.on-darkreused
board.mc-anatomy-boardgains 2 custom properties + align-items: stretch — ledger 26
four input rows.mc-input-row.mc-input-num, .mc-input-head, peach gains .mc-beam-input — ledger 24
beam gutter.mc-anatomy-beamre-boxed + re-pathed — ledger 25
four beam paths(no class today)each gains .mc-beam — ledger 23
beam gradient#mc-anatomy-beam <linearGradient>end stop 0.5 → 0.65 — ledger 29
output wrap.mc-anatomy-output-wrapgains flex centring — ledger 26
output card.mc-anatomy-output.mc-anatomy-output-head.spark, .mc-mono; .mc-anatomy-bubble; .mc-anatomy-metareused; margin-left becomes a variable

A_Moments — one new line per card

Canvas nodeClassStatus
<section>.site-section.paddedreused
intro.section-intro.eyebrow, .d-title, .section-ledereused
grid.moments-gridreused
4 cards.moment-card + .mc-moon / .mc-sun / .mc-gold / .mc-bargrid rows 2 → 3 — ledger 27, same order
icon.moment-iconreused; row span 1/3 → 1/4
timestamp.mc-mononew node ×4 — ledger 27. No new class: .mc-mono already ships mono / 11px / .04em / uppercase / --muted-foreground, which is the canvas's treatment exactly, and Phase 2 already fixed its dark-band value.
title / bodyh3, preused; h3 loses its 6px top margin to the chip

A_CTA — the glyph

Canvas nodeClassStatus
<section id="connect">.mc-cta.padded#connectreused
glyph.cta-orb.cta-orb-coach.cta-markretirement + replacement — ledger 28
#og gradientsprite <defs>new — the mark's fill; absent from this page today
heading / lede / actions / fine foot.cta-titleem.coach, .cta-lede, .hero-actions, .cta-finefootreused
html
<svg class="cta-mark" viewBox="0 0 100 100" aria-hidden="true">
  <path d="M 50 8 A 42 42 0 1 1 9 57 L 15 50 L 22 55 A 28 28 0 1 0 50 22 Z" fill="url(#og)"/>
  <polygon points="9,57 15,50 22,55" fill="#fcc419"/>
  <circle cx="23" cy="23" r="5.5" fill="#fcc419"/>
</svg>

Byte-identical to index.html:834-838. The #og <linearGradient> must be copied into meet-coach.html's existing sprite <defs> — byte-identical to index.html:143-146 — or the mark renders unfilled. The gap stays open.


Rename / edit ledger — extended

Continues Part 2's numbering. Seven new rows. Zero renames. Two retirements (row 28), by the same test Part 1 row 1 and Part 2 row 15 applied: zero allowlist entries, zero remaining consumers, whole rule body dead. Five additive.

#ChangeKindContrast-allowlist consequenceBlast radius
23.mc-beam added to the four anatomy <path> nodesnew class, existing nodesNone. Decorative aria-hidden SVG paths with no text — axe's contrast rule does not assess them. No entry's path traverses .mc-anatomy.meet-coach only.
24.mc-beam-input added to the four .mc-input-row <li>; one base transition rulenew class on existing nodesNone. No entry references .mc-input-row, and no meet-coach entry sits inside .mc-anatomy (.site-section:nth-child(3) is not allowlisted — the anatomy intro passes on its own). Adding a second class to a node that is not allowlisted cannot change any other node's emitted selector.meet-coach only.
25.mc-anatomy-beam viewBox / preserveAspectRatio / path data replaced; positioned onto the real gutterproperty + markup change, no class changeNone — geometry only, no text, no new node.meet-coach only.
26.mc-anatomy-board gains --mc-board-gap, --mc-output-inset, align-items: stretch; .mc-anatomy-output-wrap gains display: flex + justify-content: center; .mc-anatomy-output's margin-left becomes a variableproperty change, no class changeNone. Foreground and background are unchanged everywhere; only box metrics move.meet-coach only — .mc-anatomy-board exists on no other page.
27.mc-mono added to all four .moment-cards (4 new text nodes); .moment-card grid-template-rows 2 → 3 and the icon's grid-row span 1/3 → 1/4; h3 margin: 6px 0 8px → 0 0 8pxnew nodes + property changeWatch — and measured. Four genuinely new text nodes enter the 1280px scan. At hsl(var(--muted-foreground)) = #5C6B80 on the card's #fff head they measure 5.43:1. Expected green; if not, it routes to Desi, never into the allowlist. They cannot lengthen meet-coach.html::.mc-hero-sheet-head > div > .mc-mono — that entry is already qualified past a bare .mc-mono (the page has carried two since before this phase) and .mc-hero-sheet-head stays unique.meet-coach only — .moment-card exists on no other page.
28.cta-orb rule deleted (:1430-1438); .cta-orb-coach retired; .cta-mark adopted; #og <linearGradient> added to the spriteretirement ×2 + reuse + new defs nodeNone. .cta-orb appears in 0 allowlist entries; it is an icon container with no text. .cta-orb-coach has no CSS rule at all. .cta-mark is a decorative aria-hidden SVG. meet-coach.html::.cta-inner > .hero-actions > .mc-btn-coach.mc-btn is the only allowlisted node in this section and it is untouched — the mark is a sibling of the actions row, not an ancestor.meet-coach only. .cta-orb's last consumer is this page, which is why the rule can go.
29#mc-anatomy-beam gradient end stop hsla(271,91%,75%,0.5) → 0.65value change in markupNone — no text, aria-hidden.meet-coach only.

Four things Codi must not do, each of which breaks something with no error:

  • Do not rename .mc-beam or .mc-beam-input. landing-2026.css:1675-1676 is Phase 1's reduced-motion contract, written against those exact strings before the markup existed. Any other name and the still state applies to nothing — marketing-contrast-guard cannot see it, png-legibility-guard cannot see it, and nothing in CI will catch it. grep mc-beam marketing_landing/*.html returns zero today; after this phase it must return eight hits.
  • Do not place the .mc-beam-input base rule after landing-2026.css:1638. At equal specificity the later rule wins regardless of which media query matches, so a transition declared below the block would survive under reduce and quietly break the still contract. This is the bug Phase 2 shipped; it belongs before the block, and contract.css says so at the top.
  • Do not turn the ≤900px display: none into a left/width override. The variables change at the breakpoint, not the declarations, precisely so this rule stays the single place the ruling lives.
  • Do not re-fix .mc-mono. Phase 2 fixed its dark-band contrast failure at the base rule and that fix is already live on this page (:175, :292). The four new Moments nodes inherit it.

Reduced motion — reconciled against Part 1's still-state table

Phase 3 introduces one moving thing, it already has a row in Part 1's table, and Part 3 adds none. Part 1 wrote row 7 forward against markup that did not exist; this phase is what makes it live. That is the accounting, not an omission.

Effect this phase addsPart 1 rowFull motionStill stateRule that produces it
Beam draw, .mc-beam × 4 + .mc-beam-input × 4required effect 74 paths draw at 160ms offsets, 1100ms each; each input lights 0.45 → 1 as its beam startsCompleted diagram — all four drawn, opacity 1, all four inputs lit, output card visible.mc-beam { stroke-dasharray: none; stroke-dashoffset: 0; opacity: 1 } and .mc-beam-input { opacity: 1; transition: none } (:1675-1676) — already shipped, matched nothing until now

Part 1's overrule of the canvas stands and this phase is where it bites. The canvas's reduce branch sets beams.forEach(p => p.style.opacity = '0.55') while its full-motion branch ends at 1. A dimmed diagram reads as disabled and breaks the rule the whole table rests on — a still state is the animation's last frame, never its first, and never a lesser version of it. The still state is opacity 1.

The still state cannot render blank, structurally, not by convention. Three independent reasons, all of which have to fail together:

  1. .mc-beam has no authored stroke-dasharray — an undashed path is a drawn path, and the SVG default is the completed state.
  2. .mc-beam-input ships at opacity 1; the 0.45 dim is written only by the full-motion branch.
  3. beams() returns before priming anything when reduce is true, and settle() un-primes if the query flips later.

So the blank state is not something the code avoids — it is a state the code has no way to author. Both halves are rendered, not asserted: beam-draw-desktop-full.gif and beam-draw-desktop-reduced.gif are the same journey, and the still one starts and ends on the completed diagram.

Subjects B and C add no motion and need no row. The Moments chip is static text; .cta-mark is a static SVG with a CSS filter, and it inherits nothing animated (which is one more small reason to prefer it over the canvas's twin-pulse orb).


Design tokens

None new. Every value this phase introduces is an existing token, an existing literal in this file, or a value Part 1 / Part 2 already published:

PurposeValueSource
Beam gutter--mc-board-gap: 64px / --mc-output-inset: 80pxnew custom properties, not new values — both mirror literals already at :1933 and :1988, promoted so the gutter and the layout share one number
Beam strokeurl(#mc-anatomy-beam), hsla(271,91%,75%, 0 → .65)the page's existing gradient; end stop is the canvas's own figure
Beam easingcubic-bezier(.4, 0, .2, 1)var(--nav-ease), added by Part 1
Moments timestamp inkhsl(var(--muted-foreground)) = #5C6B80already :root; already used by .mc-mono itself
Moments timestamp, dark band#94A3B8.site-section.dark .mc-mono (:262) — Phase 2's fix, inherited
Closing mark fillurl(#og) #177bbb → #05cbfc; pip + dot #fcc419Part 1's ring-glyph section; Part 2's markup contract
Closing mark shadowdrop-shadow(0 20px 40px hsla(203,78%,41%,0.35)).cta-mark (:1439-1445), unchanged

No shared token moves. Allowlisted nodes depend on the current values of --muted-foreground and --brand-ink; do not adjust either.

Typography

Nothing new. One element joins the existing ramp:

ElementFontClass / sizeNote
Moments timestampmono.mc-mono 11px, .04em, uppercasenew node, existing rule — no size, weight or family is invented
Moments titleOutfith3 22px / 700, -0.015emunchanged; only its top margin moves to the chip
Moments bodyInterp 14.5px / 1.6unchanged
Anatomy input headOutfit.mc-input-head 18px / 700unchanged
Anatomy bubbleOutfit.mc-anatomy-bubble 22px / 1.32 / 600unchanged
CTA titleOutfit.cta-title clamp(40px, 6vw, 64px)unchanged (.mc-cta .d-title, :2176)

Microcopy

Four new strings, all four the canvas's own copy adopted verbatim. Every other string on the page is already shipping and is held byte-identical — including all four anatomy layers, all three Voice pairs and the entire closing CTA.

SurfaceStringTone check
Moments — Before bed10:40 PMNames the moment, not the feeling ✅ · the hour a streak actually dies ✅
Moments — Monday morning7:05 AMSpecific to the minute — a real time, not "morning" ✅
Moments — Streak day 30Day 30Counts what was earned, no adjective ✅
Moments — When numbers lieAny dayHonest: this one has no schedule, and saying so is the point ✅

These four do a job the section could not do without them. "When Coach shows up" previously promised restraint — "Not constantly. Not on a notification schedule." — and then listed four situations with no times attached. A timestamp is the cheapest possible proof that the restraint is real, and Any day is the one that earns the set: it admits the pattern has an exception rather than pretending the schedule is tidy.

No string uses "AI" — Coach is Coach. No string is generic-motivational. Nothing was invented; a placeholder would have been the failure here, and there are none.


Accessibility

  • Focus order is unchanged. This phase adds zero interactive nodes. The beams, the beam gutter, the four timestamps and the closing mark are all non-interactive; every tab stop on the page is exactly where it was.

  • Everything this phase adds to the SVG layer is aria-hidden="true". .mc-anatomy-beam already carries it and keeps it; .cta-mark carries it. Both are decorative by construction — the beam restates a relationship the numbered list already makes explicit in text, and the closing mark sits above an <h2> that carries the whole message. A screen reader gains nothing from either.

  • The timestamps are real, readable text — deliberately not aria-hidden. They carry information (when Coach shows up), which is the section's actual subject. They read in DOM order before their card's <h3>, which is also their visual order.

  • Tap targets: no new control, so nothing to measure. .moment-card is not interactive on touch.

  • Motion: the reconciliation table above, plus Part 1's live re-evaluation requirement, which for this effect means settle() — see the JS contract. A vestibular reader who turns the OS setting on mid-draw lands on the completed diagram, not a frozen half-drawn one.

  • Contrast — measured, not asserted. The shipping set is axe-verified at 1280px:

    NodeValueBackgroundRatio
    Moments timestamp .mc-mono#5C6B80#fff (card head)5.43:1
    Moments timestamp, dark band (forward contract, not shipped)#94A3B8#0B15307.04:1
    .moment-card h3#0F172A#fff18.4:1
    .moment-card p#475569#FAFAFC8.32:1
    .mc-input-head#fffrgba(255,255,255,.04) on #1A0D2E15.9:1
    .mc-input-numhsl(271 91% 75%)same7.53:1
    .mc-anatomy-output-head .mc-monohsl(271 91% 80%)rgba(255,255,255,.08) on #1A0D2E8.71:1
    Beam strokehsla(271,91%,75%,.65)dark bandnot assessed — decorative, aria-hidden, no text
  • axe-core (WCAG 2.1 A/AA): 0 violations, all four subjects plus the trap, both bands.

  • png-legibility-guard: 25 files checked, 0 skipped, PASS — run against the evidence set itself, so the evidence is proven legible rather than asserted to be.


What Codi builds

marketing_landing/css/landing-2026.css — merge mockups/contract.css. Five rules. Rule 1 goes before :1638; the rest sit with the Meet Coach block. Two deletions are part of the merge and neither is optional:

  1. .cta-orb (:1430-1438) — its last consumer is the page this phase converts
  2. nothing else — .cta-mark (:1439) stays, it is now both pages'

marketing_landing/meet-coach.html — five edits:

  1. #og <linearGradient> into the sprite <defs> (byte-identical to index.html:143-146)
  2. Beam <svg>: new viewBox, preserveAspectRatio="none", four re-pathed <path class="mc-beam">, gradient end stop 0.5 → 0.65
  3. class="mc-beam-input" onto the four .mc-input-row <li>
  4. Four <div class="mc-mono"> in the Moments cards, above each <h3>
  5. .cta-orb.cta-orb-coach <div> → the .cta-mark <svg>

marketing_landing/js/main.jsextend, do not replace. Two additions and nothing else:

  1. beams() as module 11, ported from mockups/motion/motion-demo.html, called alongside entrance() / reveal() / counters() / header()
  2. one line inside module 0's existing mq change handler: if (reduce && beamSettle) beamSettle();

Every other module — mobile menu, entrance, reveal, header, smooth scroll, contact form, copyright, counters, PostHog, teams toggle — is untouched. If any of them appears in the diff, that is a signal something was misread.

Do not touch the 12px header condense threshold. It is Part 1's accessibility constraint (deviation 2b), not a taste knob.


Acceptance criteria for Codi

  • [ ] The four beams start at the input column's edge and terminate at the output card's edge — verified by rendering at 1280px, not by reading the CSS. The trap-beam-letterboxed capture is the before; do not ship anything that still looks like it
  • [ ] Beams draw into the output card as the inputs light, in the spec's order and durations — 160ms offsets, 1100ms each, cubic-bezier(.4,0,.2,1), once, never restarting
  • [ ] prefers-reduced-motion: reduce shows the completed diagram — all four drawn at opacity 1, all four inputs lit — verified by rendering it, not by reading the CSS
  • [ ] With JS disabled entirely, the diagram still renders complete
  • [ ] Toggling the OS motion setting mid-draw lands on the completed diagram, not a frozen half-drawn one
  • [ ] grep mc-beam marketing_landing/*.html returns 8 hits; zero class renames anywhere
  • [ ] The .mc-beam-input base rule sits before landing-2026.css:1638
  • [ ] .mc-anatomy-beam { display: none } at ≤900px is unchanged, and the stacked layout below 900px shows the full input list and the output card with nothing missing and nothing dimmed
  • [ ] Four Moments timestamps present with the four strings verbatim, above each title, and the icon still spans the full card height
  • [ ] Closing CTA renders .cta-mark with its gap open, matching index.html byte for byte; #og is defined on this page; .cta-orb's rule is deleted
  • [ ] Phase 1 + 2 contracts not regressed: the 12px condense threshold unchanged and condense not dropped at any breakpoint; .mc-mono not re-fixed; no <section> inserted, removed or reordered inside <main>. (RAMP_GAP_TARGETS allowlist retired entirely by OBJ-2292 — the mechanism no longer exists in scripts/check-marketing-contrast.mjs, so there is nothing left to check byte-identical.)
  • [ ] No new class on any allowlisted node; meet-coach.html::.mc-btn-sm and meet-coach.html::.mc-hero-sheet-head > div > .mc-mono each still match exactly one element at 1280px
  • [ ] marketing-contrast-guard green with the allowlist unchanged — no entry added, none removed
  • [ ] png-legibility-guard green
  • [ ] The nine other landing-2026.css consumers unregressed — index, contact, 404, six policies/*; headless pass with zero attributable JS errors. index.html's #connect still renders .cta-mark
  • [ ] UI evidence at 390 / 768 / 1280, light band + dark band, plus a full-motion / reduce GIF pair at desktop (per ruling 1) recorded through scripts/capture-marketing-motion.mjs — do not build a third bridge

Mockups & evidence

Subject (data-capture)What it pinsCaptures
anatomy-stillThe completed diagram — four beams landing on the card, four inputs lit — in real .mc-anatomy classes2 bands × 3 viewports = 6
trap-beam-letterboxedThe shipped state: beams beginning in empty gutter and terminating inside the card3 (dark only — it is a dark-band defect)
moment-cardsFour cards with the timestamp chip, icon spanning all three rows6
cta-mark-coachThe mark at 76×76 on the .mc-cta wash, gap open, in real .cta-inner context6
motion/motion-demo.htmlThe beam module running through the real observer4 GIFs — desktop + mobile, each full-motion / reduce

Rendered by scripts/capture-marketing-mockups.mjs --phase phase-3-meet-coach, which serves marketing_landing/ as the web root — so the mockup loads the real landing-2026.css, the real fonts and the real sprite. What renders here is what Codi ships. axe-core (WCAG 2.1 A/AA): 0 violations. png-legibility-guard: PASS, 25 files.

The GIFs are a pair on purpose, and for this phase the pair is the whole point: a motion GIF alone cannot show that the still path lands anywhere sane, and this effect's still path is the one Part 1 singled out as most likely to fail in the direction of showing nothing.

bash
node scripts/capture-marketing-motion.mjs --phase phase-3-meet-coach \
  --page mockups/motion/motion-demo.html --viewport desktop --name beam-draw \
  --motion-steps 'wait:400|scroll:560|wait:2400|scroll:160|wait:700'

node scripts/capture-marketing-motion.mjs --phase phase-3-meet-coach \
  --page mockups/motion/motion-demo.html --viewport mobile --name beam-stacked \
  --motion-steps 'wait:400|scroll:520|wait:1200|scroll:700|wait:1200|scroll:700|wait:1000'

The desktop viewport for the beam GIF is ruling 1, not a preference. The default motion viewport is mobile, where this effect does not exist. The mobile pair is recorded anyway, and it is evidence in its own right: it shows the ≤900px state has nothing missing and nothing half-drawn in either motion mode.

Tooling note

Part 2's finding still holds and is worth restating because it will bite the next person: playwright-core is declared (transitively, via ionic_frontend's @playwright/test) but ionic_frontend/node_modules does not exist after a bare multica repo checkout, so both capture scripts fail to resolve it. capture-marketing-motion.mjs has no developer-working-copy fallback and correctly tells you to run npm install in ionic_frontend/. This phase's captures were produced by installing playwright-core and axe-core into the checkout's parent directory, which Node's resolution walk finds from either candidate without writing anything into the repo — a lighter equivalent of the sanctioned fix, not a replacement for it. ffmpeg is present; playwright's chromium is not, so both scripts fell back to system Chrome.


Open questions

None blocking. All four rulings are made in this document. The canvas → page diff was run before anything was specified and is reported above whether or not it supported the phase's original framing. Two canvas overrules — the closing glyph (ruling 2) and the Moments card treatment (ruling 3) — are recorded with their reasons, joining Part 1's three and Part 2's two.

Out of scope — findings to route

  1. The Moments card treatment. The canvas draws four individually tinted cards with the icon stacked above the copy, including a dark navy card inside a light section. That is a four-card component redesign with its own contrast set, and ruling 3 declines it. Real delta, wants its own ticket.
  2. .mc-anatomy-grid is dead CSS. :1929 and :1937 style a class with zero occurrences in any HTML file in marketing_landing/. Not deleted here — it is pre-existing dead code and not this phase's to remove — but it should not survive the next sweep of this file.
  3. .coach-quote-head .mc-mono at 2.12:1 on a light band — Part 2's routed finding, re-routed unchanged. Ruling 4 confirms this phase does not trigger it.
  4. Eight pages ship a broken /#how-it-works nav link — Part 1's routed finding, still open, still not this phase's.

UI-SPEC — v4.32 Phase 4 · Heading emphasis: the last synthetic italics

Task: OBJ-2291 (Task 8a of Phase 4, OBJ-2276) · Milestone: v4.32 Marketing Landing Rebuild Branch: gsd/v4.32-marketing-landing-rebuild @ 579dd5be7 (Phase 3 merged) CSS contract: .planning/phases/phase-4-emphasis-gates-docs/mockups/contract.cssMockup: .planning/phases/phase-4-emphasis-gates-docs/mockups/emphasis.html (+ screenshots/) Tier: 3 — spec + HTML mockup + rendered captures. Part 4 of a cumulative document; Parts 1–3 above are unchanged.

Scope

One ruling, on three CSS rules: the font-style: italic declarations on <em> emphasis inside headings in marketing_landing/css/landing-2026.css.site-title em (:166-177), .cta-title em, .mc-hero-title em (:193-196), .d-title em (:205-215). The wordmark is not in scope: OBJ-2225 shipped it as outlined SVG paths and index.html:169 already renders from that lockup. What is left is emphasis text, and whether a drawn oblique is right for emphasis text is a different question from whether it was right for a brand mark. It is not, and the reason is mechanical, not aesthetic — §"Why the wordmark's answer does not transfer" below.

The ruling covers all ten pages landing-2026.css serves, not just the two rebuilt ones. Enumerated, because Part 1's routed finding says the docs get this count wrong: index.html, meet-coach.html, 404.html, contact.html, and policies/{privacy,terms,acceptable-use,community-guidelines,cookie-policy,user-content-guidelines}.html. about.html has <em> but loads css/styles.css, not this file — routed below, not fixed here.

The ruling

Emphasis inside a heading is carried by colour, never by slant. All three font-style: italic declarations become font-style: normal; the gradient text fill already on two of the three rules is the emphasis, and the third — which had no fill — gets one. A global em { font-style: normal } covers the eight pages that have no heading rule of their own.

Why the slant has to go regardless of taste

Not an opinion — read off the shipped binaries:

Facefvar axespost.italicAngle@font-face files
fonts/outfit-variable.woff2wght 100–900 only0.01, font-style: normal (:17-24)
fonts/inter-variable.woff2wght 100–900 only0.01, font-style: normal (:9-16)

No italic cut, no slnt axis, no ital axis, on either face. Every font-style: italic in this stylesheet is therefore synthesised by the renderer — a shear each engine computes for itself, with no glyph design behind it. Same root cause OBJ-2225 fixed for the wordmark.

Why the wordmark's answer does not transfer

logo-v12-oblique-wordmark.md replaced the wordmark's synthetic italic with an explicit transform: skewX(-8deg) about a defined pivot. That answer cannot be reused here, and the blocker is CSS, not judgement: transform does not apply to a non-replaced inline box. Making it apply requires display: inline-block, which forbids a line break inside the em — and mid-em wraps are a live, shipping state, not a hypothesis. The stylesheet says so in its own voice at :188-189: "Multi-line italic ems also clip on the line-break edge; box-decoration-break:clone gives each fragment its own padded box so mid-em wraps don't slice the trailing letter." Eight of the sixteen heading ems are multi-word — one private space, pays attention, shows up, chip in, One reason, 10 minutes, 10 minutes., A coach. — and at 375px they break. inline-block would turn each of those wraps into horizontal overflow — failing this milestone's own first acceptance line. Board D renders the wrap under both states.

The alternative — wrapping each word in its own span so each can skew — is DOM surgery across all sixteen headings to reproduce an effect the face was never drawn for. Declined.

Why colour, and not a weight step

Weight was the other candidate and it loses on all three counts. .site-title/.d-title are already font-weight: 800; 800 → 900 in Outfit at 64–90px is a delta you have to be told to look for. Two of the three rules already carry a gradient text fill, so a weight step would be a second signal stacked on a working first one. And colour is what the rest of the design language already uses for emphasis — the .coach modifier, the tier rings, the ring glyph. Reuse before invent.

Weight does win for body prose, where there is no fill and the surrounding text is Inter 400. That is E2.

Design tokens

Every value is a token already defined in landing-2026.css. No new tokens, no inline hex.

SurfaceTokenValue (reference only)Status
.site-title em, .cta-title em--gradient-primary-inklinear-gradient(135deg, hsl(203 78% 41%), hsl(193 82% 31%))reused; newly applied to .cta-title em
.d-title em, .mc-hero-title em--gradient-coachlinear-gradient(135deg, hsl(271 91% 65%), hsl(258 90% 66%))unchanged
em.coach (all four)--gradient-coachas aboveunchanged
.site-section.dark .site-title em--gradient-primarylinear-gradient(135deg, hsl(203 78% 41%), hsl(194 96% 51%))reused; newly applied (E6)

Typography

Nothing new. The ramp is untouched; only font-style and two spacing values move.

SelectorFontComputed at 1280pxletter-spacingChange
.site-title.hero-titleOutfit 80089.6px-0.03emfont-style only
.site-title (sections)Outfit 80064px-0.03emfont-style only
.cta-titleOutfit 700 (from h1…h6, :115)76.8px-0.02em (from :116)font-style + gains a fill
.d-titleOutfit 80024px-0.03emfont-style only
.d-title.mc-hero-titleOutfit 80088px-0.03emfont-style only
prose <em>Inter 400 → 60016pxinheritednew (E2)

What replaces the 0.28em / -0.22em overhang pair

padding-right: 0.04em; margin-right: -0.04em. Not zero, and not kept as-is — both would be wrong, and the number is measured, not guessed.

Those two declarations exist only to stop a synthesised slant's trailing glyph being sliced by the background-clip: text box (:172). Upright glyphs do not overhang that far — but .site-title and .d-title track at letter-spacing: -0.03em, and CSS applies that negative track after the final glyph, pulling the inline box in behind the ink. So the pad cannot go to zero either.

Measured with TextMetrics on the real Outfit binary at 800 weight, every trailing glyph in the sixteen shipping ems, ink extent minus advance:

TrackingWorst caseValue
-0.03em (.site-title, .d-title)together+0.0297em (actually +0.0295em, Hours/talks/10 minutes +0.017em)
-0.02em (.cta-title, base h1…h6)together+0.0197em

0.04em clears the worst real case with ~0.01em spare; the matching negative margin keeps the em's advance identical, so the 0.06em of phantom space the old pair added after every em disappears. Board C renders all three candidates with a hairline on the em's box edge; pixel-measured, the ruled variant leaves the trailing ink 2 device px inside the box and the shipped variant leaves a visible 28 device px gap before the following period.

box-decoration-break: clone stays. It is not italic compensation — it gives each fragment of a wrapped em its own padded background box, which is what stops the fill slicing at a break edge. Board D pins it.

The rule-by-rule contract

Full annotated source: .planning/phases/phase-4-emphasis-gates-docs/mockups/contract.css. Codi merges it into marketing_landing/css/landing-2026.css; the summary is:

#SelectorActionWhere
E1emadd font-style: normalafter h1…h6 (:113-120)
E2p em, li em, dd em, blockquote em, figcaption emadd font-weight: 600after E1
E3.site-title emitalic → normal; pads 0.28/-0.220.04/-0.04replaces :166-177
E4.cta-title emitalic → normal; gains --gradient-primary-ink + clipreplaces :193-196
E4b.d-title em, .mc-hero-title emitalic → normal; pads shrink; coach fill keptreplaces :205-215
E5.site-title/.d-title/.cta-title/.mc-hero-title em.coachpads shrink onlyreplaces :178-192
E6.site-section.dark .site-title emadd --gradient-primaryafter E4b, before E5

Cascade order is load-bearing in one place. E6 (0,2,1) ties .site-title em.coach in E5 (0,2,1), so source order decides. E6 must sit before E5, or a .coach em placed on a dark band silently takes the brand ramp instead of the coach ramp. Part 3 hit the mirror of this trap with the reduced-motion block; it is called out here for the same reason.

font-style: normal is written out at each heading rule rather than left to E1 alone. E1 is (0,0,1) and loses to every heading rule on specificity, so if a future edit re-adds italic at a heading rule, E1 cannot save it — the declaration has to live where the defect lives. E1's job is the UA default (em { font-style: italic } in every browser's default sheet, with no em reset anywhere in this file today) and the eight pages that have no heading rule of their own.

Deleting the declarations is not sufficient and will look like it worked. Remove font-style: italic from all three rules and ship, and every heading <em> on all ten pages still renders synthetically slanted, because the UA default reinstates it. This is the single most likely way this task gets marked done while shipping the exact defect it was opened for.

E6 — the dark band, found by rendering

The one place this ruling costs something. --gradient-primary-ink is, by this stylesheet's own token comment (:55-57), the light-surface text ramp: "darkened accents for small text on light surfaces… an accent that is legible as a fill is not automatically legible as text." It is currently painted on .site-section.dark — the darkened-for-white ink on a near-black band, the inversion of what that comment prescribes.

That was survivable while a slant carried half the signal. Once colour is the only signal, it is the weakest surface on the site, so it is fixed here rather than routed — the ruling is what made it load-bearing. --gradient-primary is the bright sibling of the same token pair and starts at the identical stop, so the floor does not move; the ramp only stops falling:

FillAgainst #0B1530 (band's lightest stop)
--gradient-primary-ink (ships today)3.98:1 → 3.37:1
--gradient-primary (E6)3.98:1 → 8.68:1

.site-title on a dark band is 64px / 800 — large text, AA floor 3:1 — so both pass; one with 0.37 of headroom and one with 5.68. This is the same relationship .d-title em.coach already has, where the bright --gradient-coach is used on the dark band and the darkened --gradient-coach-ink is reserved for text-bearing light surfaces (:68-70).

Scope: exactly one heading in the treeindex.html:539, #teams-showcase. index.html:419 is .d-title.on-dark em.coach and is untouched; board E renders it as the control.

State catalog

<em> is inline text, not a component — it has no loading, empty, error, success, disabled, focus, hover or pressed state, and no motion. The states that exist are the ones that change how it renders:

  • Default (light band): --gradient-primary-ink clipped to text, upright, weight inherited.
  • Default (.d-title / Meet Coach): --gradient-coach, upright.
  • .coach modifier: --gradient-coach, on any of the four selectors, overrides either default.
  • On .site-section.dark: --gradient-primary (E6). .coach still wins over it.
  • Wrapped mid-em: each fragment gets its own padded box via box-decoration-break: clone; the fill runs continuously and neither fragment's trailing glyph is sliced. Board D.
  • Prose <em> (all ten pages): Inter 600, upright. No instances today; E2 is preventive.
  • Dark mode: light-only — no dark variant (marketing surface). marketing_landing/ ships no prefers-color-scheme handling and no theme toggle; the dark board is the .site-section.dark band treatment, not an OS theme.
  • Motion-reduced: no delta. Nothing in this ruling animates — no transition, no transform, no keyframe. This is a static spec, so no motion GIF is attached and none is owed.
  • Fallback face (Outfit fails to load): the --font-display stack falls to Inter, then system-ui. system-ui does have an italic cut on most platforms — which is precisely why the current behaviour is inconsistent, and why E1's normal is the right floor: after this ruling the fallback renders upright too, and the fill still distinguishes the em.

Microcopy

No strings change. Every <em> on both rebuilt pages keeps its exact current text. This is a type-treatment ruling, and rewriting copy underneath it would make the before/after captures unreadable as evidence. Recorded for the tone check only, unchanged and verbatim:

SurfaceStringTone check
Hero + closing CTA, index.htmlShow up for **10 minutes**. Then go live your life.Action-forward ✅ · the emphasis lands on the promise, which is why E4 matters ✅
#teams-showcase, index.htmlChase the goal **together**.Brief ✅ · the emphasised word is the one E6 makes legible ✅
#coach-teaser, index.htmlA coach who **pays attention**."Coach", never "AI" ✅
Meet Coach heroA coach who **actually** pays attention.Emphasis carries the whole claim ✅
Meet Coach closing CTAA coach, in your pocket. **Yours.**One word, celebratory without praise ✅

Accessibility

  • Contrast. Every fill × band pair, against WCAG AA. All heading ems are large text (≥24px at 700+ weight), floor 3:1:

    FillBandRatioVerdict
    --gradient-primary-inkwhite / .site-section light4.54:1 → 5.36:1AA large ✅, AA normal ✅
    --gradient-primary-ink.cta-section wash (#FFF → #ECF7FE)4.17:1 → 4.92:1AA large ✅ (E4's new application)
    --gradient-primary.site-section.dark (#0B1530, band's lightest stop)3.98:1 → 8.68:1AA large ✅ (E6)
    --gradient-coachwhite3.96:1 → 4.32:1AA large ✅
    --gradient-coach.site-section.dark (#0B1530)4.56:1 → 4.17:1AA large ✅
    Inter 600 body inkwhiteunchanged from body

    Ratios computed against each band's lightest point, which is the worst case for every fill here. .site-section.dark's gradient runs #060B18 → #0B1530 → #07101F; the #0B1530 midpoint is the one quoted.

  • E6 is scoped to the dark band on purpose, and must stay that way. --gradient-primary's bright stop measures 2.08:1 on white — a hard AA failure at any size. That is exactly why the -ink sibling exists, and why E6's selector is .site-section.dark .site-title em rather than a swap of the base rule. Widening it to light bands reintroduces the failure the token pair was built to prevent.

  • Colour is not the only means of conveying information (WCAG 1.4.1). The emphasis is <em>, so the semantics travel in the markup: assistive technology announces it independently of the fill, and every heading reads as a complete sentence with the emphasis ignored. Nothing is encoded in the colour — it renders emphasis that is already in the DOM. Same standing the synthetic italic had, minus the per-engine variance.

  • Focus / keyboard / tap targets: no delta. <em> is not interactive, is not in the tab order, and contains no controls. No ARIA is added or needed.

  • Reading order: unchanged. No DOM changes anywhere in this spec — CSS only.

  • axe-core (WCAG 2.1 A/AA) on the rendered mockup: 0 violations.

Storybook story brief

None. marketing_landing/ is a static site outside the Ionic app; it has no Storybook, and Parts 1–3 of this spec set no story either. The mockup plus its captures is the visual ground truth for this surface, per docs/reference/desi-design-context.md §8.

Mockups & evidence

Board (data-capture)What it pinsCaptures
emphasis-selectorsAll four selectors, shipped vs ruled, in real classes3 (light)
emphasis-cta-bookendThe bare .cta-title em: italic / italic-struck-nothing-added / E4, plus the hero↔CTA pair3 (light)
emphasis-clip-testThree padding candidates with the em's box edge drawn — settles 0.04em3 (light)
emphasis-wrapMid-em wrap at a 340px measure — the state that rules out skewX3 (light)
emphasis-dark-bandDark band: shipped / ruled-with-ink-ramp / E6, with the .coach control3 (dark)
emphasis-proseProse <em> — the rule for the other eight pages3 (light)

18 PNGs at .planning/phases/phase-4-emphasis-gates-docs/mockups/screenshots/ (<board>__<mobile|tablet|desktop>__<band>.png), rendered by node scripts/capture-marketing-mockups.mjs --phase phase-4-emphasis-gates-docs, which serves marketing_landing/ as the web root — so the mockup loads the real landing-2026.css, the real outfit-variable.woff2 and the real inter-variable.woff2. What renders there is what Codi ships. axe-core (WCAG 2.1 A/AA): 0 violations.

The mockup restores the shipped state under a .shipped class rather than screenshotting master separately, so both halves of every comparison render in one pass, at one moment, in one engine — which is the only way a per-engine shear can be shown as a defect rather than asserted as one.

No motion GIFs, and none are owed. Nothing in this ruling animates.

Tooling note

Parts 2 and 3's finding still holds: playwright-core/axe-core are declared transitively via ionic_frontend's @playwright/test but ionic_frontend/node_modules does not exist after a bare multica repo checkout. This phase's captures were produced by installing both into the checkout's parent directory, which Node's resolution walk finds from ionic_frontend/package.json without writing into the repo. Playwright's chromium is not installed; the script fell back to system Chrome, as designed.

Acceptance criteria for Codi

  • [ ] grep -c 'font-style: italic' marketing_landing/css/landing-2026.css returns 0
  • [ ] grep -c 'skewX' marketing_landing/css/landing-2026.css returns 0 (it already does — do not add one)
  • [ ] em { font-style: normal } exists and sits before the four heading rules
  • [ ] E6 sits before E5; a .coach em on a dark band still renders --gradient-coach
  • [ ] index.html:839's <em>10 minutes</em> renders with --gradient-primary-ink, matching the hero at index.html:218
  • [ ] index.html:539's <em>together</em> renders with --gradient-primary on the dark band
  • [ ] No padding-right: 0.28em / margin-right: -0.22em survives on any em rule
  • [ ] box-decoration-break: clone survives on every em rule that has a fill
  • [ ] Renders at 375px with no horizontal scroll, both pages — check Bring your whole group into <em>one private space</em>. (index.html:728) and A coach who <em class="coach">pays attention</em>. (index.html:419), both of which wrap mid-em at that width
  • [ ] Zero DOM changes — this is a CSS-only ruling; no <span> wrappers, no inline-block
  • [ ] The eight non-rebuilt pages are visually unchanged (they carry no <em>; this is a no-regression check, not a no-op claim)
  • [ ] scripts/check-marketing-contrast.mjs passes clean. (Written against the guard's now-retired RAMP_GAP_TARGETS allowlist — OBJ-2292 removed that mechanism entirely, so there is no allowlist left for this ruling to leave unchanged; the intent, no new violation introduced, still holds.)
  • [ ] Light-only — no dark-mode acceptance line (marketing surface)

Open questions

None blocking. The ruling is made, the replacement is specified to the declaration, and the number that replaces the overhang pair was measured rather than chosen. One canvas-independent overrule is recorded — E6 changes a fill this phase was not asked to touch — with its reason and its one-heading blast radius stated above.

Out of scope — findings to route

  1. about.html has the same defect in a different stylesheet. It carries three <em> (:126, :129, :241 — the last is .about-mark-tagline, "Progress is the point.", the page's closing line) and loads css/styles.css, which has its own font-style: italic at :2038 and self-hosts the same two italic-less faces. Identical root cause, different file, outside this stylesheet's ten pages. Wants its own ticket.
  2. .d-title has no font-size of its own. It sets family, weight, tracking, line-height and colour (:198-204) but no size, so any .d-title in a section that does not size it falls to the UA h2 default — measured 24px at 1280px on index.html:419 and four headings on meet-coach.html, against 64–90px for .site-title. Not this ruling's to fix (it changes no sizes), but it is a real type-ramp hole and it is why those ems sit at the bottom of the large-text contrast band rather than comfortably inside it.
  3. .coach-quote-head .mc-mono at 2.12:1 on a light band — Part 2's routed finding, re-routed unchanged. This ruling does not touch it.
  4. Eight pages ship a broken /#how-it-works nav link — Part 1's routed finding, still open.
  5. .mc-anatomy-grid is dead CSS — Part 3's routed finding, still open.

Loading…