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-smandindex.html::.mc-btn-coachare 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.inclass 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
| # | Change | Kind | Contrast-allowlist consequence | Blast radius |
|---|---|---|---|---|
| 1 | .site-nav.scrolled → .site-nav.is-condensed | retirement + replacement | None. .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 rule | additive (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 only | None. 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 only | None — 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 node | Watch. 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 → 4px | property change, no class change | None. 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×44 | property change | None — icon-only button, no text to assess. | All 10 pages. Deliberate: 40px violates the ≥44px touch floor on every page today. |
| 8 | Nav collapse breakpoint 900px → 1023px | media-query change | None — 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: 44px | property change | None — menu is hidden at scan. | All 10 pages. Deliberate: 34px rows violate the touch floor. |
| 10 | .mobile-menu a.mc-btn-brand/.mc-btn-coach → color: #fff | bug fix | None — 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 extended | property change | Do 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.js | dead-code removal | Corrected — was false, see below. .is-visible has 0 CSS rules. | All 10 pages — false: about.html/contact.html shipped invisible content. See below. |
| 13 | Inline reveal <script> folded into main.js (index.html:910-935, meet-coach.html:468-486) | consolidation | None, 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-smto the mobile-menu CTA onmeet-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-menudiv 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>| State | Trigger | Bar height | Sheet | Hairline | Shadow | Brand | Progress bar | Spy pill |
|---|---|---|---|---|---|---|---|---|
| Rest | scrollY ≤ 12 | var(--nav-h) = 68px | rgba(255,255,255,0.6) | hsl(var(--border) / 0.4) | none | scale(1) | width: 0% | none active |
| Condensed | scrollY > 12 | var(--nav-h-condensed) = 58px | rgba(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 center | tracks scroll | as below |
| Spy active | a 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 page | static, authored | — | — | — | — | — | — | same treatment, aria-current="page" |
| Menu open | .nav-toggle click, < 1024px | unchanged | unchanged | unchanged | unchanged | unchanged | unchanged | — |
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
| Width | Nav |
|---|---|
| ≥ 1101px | 5 pills, gap: 4px, padding: 8px 14px |
| 1024–1100px | 5 pills, gap: 0, padding: 8px 10px — tightens instead of overflowing |
| ≤ 1023px | links + 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
| # | Effect | Where it lives | Full motion | Still state | How |
|---|---|---|---|---|---|
| 1 | Entrance stagger | [data-enter], hero, on load | fade up 18px, 560ms, 0→310ms delay ladder | Arrived — every element at final opacity and transform, painted on frame one | No CSS rule, deliberately. Markup ships arrived; main.js returns early and never animates. This is also why the no-JS render is correct. |
| 2 | Reveal on scroll | .reveal / .reveal.in, 58 nodes across the two pages | opacity 0→1 + 14px up, 700ms, on intersect | Already 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. |
| 3 | Drifting halos | .halo, obj-drift / obj-drift2, 14 nodes | translate ~24px + scale 1.06, 18–24s, infinite | Parked — keyframe 0, still visible | .halo { animation: none; transform: none }. The blur wash is the design; only the drift is decoration. |
| 4 | Count-up stats | [data-count] (Phase 2 markup; animateCount machinery already in main.js) | 0 → target, 900ms ease-out cubic, once, never restarts | Final number, printed | el.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. |
| 5 | Animated FAQ | <details class="faq-item"> × 6 | answer fades up 6px 260ms; + rotates 45°; border tints; shadow lifts | Instant 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. |
| 6 | Header condense | .site-nav.is-condensed | 68→58px, sheet 0.6→0.88, hairline, shadow, brand scale(0.92), all 300ms | Condensed, untransitioned — condense still happens, snapped; brand scale dropped entirely | transition: 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. |
| 7 | Beam 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 starts | Completed 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.
| Effect | Full motion | Still state | How |
|---|---|---|---|
| Scroll progress bar | width tracks scroll, .1s linear | Absent | .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 pill | colour/background over 200ms | Instant — state kept | .site-nav .nav-link { transition: none }. A discrete state change is information, not motion. |
| Smooth scroll | window.scrollTo({ behavior: 'smooth' }), main.js:87 | Instant jump | html { scroll-behavior: auto } and main.js passes behavior: reduce ? 'auto' : 'smooth'. The CSS alone does not override an explicit JS behavior. |
| Beta-dot pulse | box-shadow ring, 2.2s infinite | Static dot | .hero-beta-dot { animation: none } |
| Coach-orb ring pulse | obj-ring scale+fade, 3.2s infinite | Static ring | .spark::after { animation: none } |
| Hero phone float | obj-float ±14px, 7s infinite | Level | .hero-phone-float { animation: none; transform: none } |
| Hover lift | translateY(-2px/-4px) on buttons, feature cards, tier cards | Colour only | transform: none on the four :hover rules. Hover feedback survives; the lift does not. |
| Billing-toggle amount | fades up 6px, 240ms, on switch | Instant swap | .teams-price-amount { animation: none }. The number still changes — that is the whole point of the control. |
| Mobile menu open | display swap, no animation today | Unchanged | Nothing 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.
| Tier | Share | stroke-dasharray | Arc — light | Arc — dark band | Label — light | Label — dark |
|---|---|---|---|---|---|---|
| Common | 25% | 54.98 164.93 | #64748B | #94A3B8 | #5C6B80 | #94A3B8 |
| Rare | 50% | 109.96 109.96 | #177BBB | #05CBFC | #136699 | #05CBFC |
| Epic | 75% | 164.93 54.98 | #A855F7 | #C084FC | #9333EA | #C084FC |
| Legendary | 100% | 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.
| Purpose | Token | Value | Notes |
|---|---|---|---|
| Bar height, rest | --nav-h | 68px | Replaces three hard-coded 68s (.site-nav-inner, .mobile-menu top, main.js scroll offset) |
| Bar height, condensed | --nav-h-condensed | 58px | |
| Header easing | --nav-ease | cubic-bezier(.4, 0, .2, 1) | Matches the canvas |
| Nav accent | --nav-accent | var(--brand-1) → var(--coach) under .nav-coach | |
| Nav accent, hover + active | --nav-accent-active | 203 78% 30% → 272 72% 40% under .nav-coach | Deliberately 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-fill | var(--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.
| Surface | String | Tone check |
|---|---|---|
| Nav item (new on Meet Coach) | Teams | Noun label, matches its section heading ✅ |
| Nav action (new, both pages) | Sign in | Two words, action verb, no "Log in"/"Sign in →" variance ✅ |
| Toggle, closed | Open menu (aria-label) | Verb-first ✅ |
| Toggle, open | Close 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: 2pxon 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 usesaria-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; aprogressbarrole 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 CTAmin-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.
Node Over the light hero Over a .site-section.darkband.nav-link/.nav-signin#334155, rest sheet10.02:1 4.01:1 — fails. Unreachable under this spec; see the ruling. .nav-link#334155, condensed sheet12.4:1 8.07:1 (7.98:1 at the band's darkest stop) Active pill, brand hsl(203 78% 30%)on a 10% tint6.30:1 5.15:1 (5.10:1 darkest stop) Active pill, coach hsl(272 72% 40%)on a 10% tint7.43:1 6.09:1 (6.02:1 darkest stop) Toggle icon #0F172A, condensed sheet15.9:1 13.9:1 Mobile-menu row #0F172Aon the opaque#fffpanel17.85:1 17.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-expandedon the toggle must track the menu, and itsaria-labelmust swapOpen menu↔Close menu. It shipsOpen menupermanently 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).
- One motion gate at the top —
matchMedia('(prefers-reduced-motion: reduce)'), read once, subscribed viaaddEventListener('change', …). - Fold in the two inline reveal scripts (
index.html:910-935,meet-coach.html:468-486) and delete them from both pages..inis applied unconditionally. - Delete the dead paths: the
[data-animate]→.is-visibleobserver (0 matching elements on any page, and.is-visiblehas no CSS), and the.site-nav.scrolledtoggle (no CSS rule). KeepanimateCount— Phase 2's counters use it — but move its gate to the shared flag. - One rAF-throttled scroll handler driving condense, progress and spy together. There are two scroll listeners today; there should be one.
- Null-safety is load-bearing.
.nav-progressand[data-spy]exist only on index and meet-coach. Guard every lookup, or the other eight pages throw on every scroll. - Smooth scroll takes the flag:
behavior: reduce ? 'auto' : 'smooth'atmain.js:87. The CSSscroll-behaviorrule does not override an explicit JSbehavior.
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.htmlcarriesnav-coachon<header>;index.htmlcarries no modifier - [ ] "Meet Coach" points at
meet-coach.htmlfrom both pages - [ ] Header condenses at
scrollY > 12: 68→58px, sheet 0.6→0.88, hairline, shadow, brandscale(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: reducematches the still-state table effect by effect, including pre-existing reveal, halos and smooth scroll - [ ]
.revealstill receives.inunderreduce;[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
#fffon 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-smandindex.html::.mc-btn-coacheach 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-guardgreen with the allowlist unchanged — no entry added, none removed - [ ]
png-legibility-guardgreen - [ ] All 10
landing-2026.csspages 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
| Mockup | What it pins | Captures |
|---|---|---|
header-states.html | Rest, condensed + spy, coach accent, menu open — each over the light hero and a .site-section.dark band | 4 subjects × 2 bands × 3 viewports = 24 PNGs |
still-states.html | All 7 required effects, full motion vs still, on both bands | 7 subjects × 2 bands × 3 viewports = 42 PNGs |
ring-glyphs.html | 4 tiers per band, closing CTA per band | 2 subjects × 2 bands × 3 viewports = 12 PNGs |
motion/motion-demo.html | The motion module running — entrance, reveal, condense crossing onto a dark band, progress, spy, count-up | 2 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 sections — story, 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:
| # | Subject | What changes |
|---|---|---|
| A | Rarity-tier glyphs | four identical #i-award sprites → the Objectuve ring at each tier's share of the arc, plus a new third line of copy |
| B | Stat counters | four static numbers → three animated, one deliberately not |
| C | FAQ mark | a ::after content swap → a real element that rotates |
| D | Closing-CTA glyph | a gradient rounded-square → the brand mark, gap open |
| E | #coach-teaser | the 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
#pricingcontains zero toggle markup. Free is$0 / forever, Supporter is$4 / month, exactly asindex.html:648-698ships them. - The only billing toggle anywhere in the canvas is
.teams-billing-toggleinside#teams, drawn precisely as it ships:Annual/Monthlytabs,$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:
- The nav does not move. "Meet Coach" points at
meet-coach.htmlfrom every page, per Part 1's ruling 2. The teaser keeps its own in-section CTA ("Meet your Coach" →meet-coach.html). - 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; addingdata-spywithout one would register a section that can never light a pill. So: anchor target only. id="coach"is not adopted. The canvas putsid="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-teaserfor 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 node | Class | Status |
|---|---|---|
<section id="story"> | .site-section.padded#story | reused |
| intro block | .section-intro → .eyebrow-brand, .site-title, .section-lede | reused |
| four numbered principles | .manifesto-row → .n, .h, .b | reused |
#features — reused entirely, one canvas attribute rejected
| Canvas node | Class | Status |
|---|---|---|
<section id="features"> | .site-section.padded#features | reused |
| split intro | .section-intro-split → .lead, .aside | reused |
| 6 cards | .feat-card + .tint-brand / .tint-coach / .tint-gold / .tint-success / .tint-accent / .tint-brand | reused, 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-link | reused |
id="coach" on card 2 | — | not 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 node | Class | Status |
|---|---|---|
<section id="coach-teaser"> | .site-section.dark.padded (index.html:408) | gains id="coach-teaser" — ledger 14 |
| grid | .coach-teaser-grid | reused |
| copy column | .eyebrow.on-dark, .d-title.on-dark → em.coach, .section-lede.on-dark, .hero-actions → .mc-btn.mc-btn-coach | reused |
| quote card | .coach-quote-card → .coach-quote-head → .spark, .coach-quote-name, .mc-mono; .coach-quote-text; .coach-personas-chip | reused |
| halo | .halo.halo-coach | reused |
#gamification — the phase's real work
| Canvas node | Class | Status |
|---|---|---|
<section id="gamification"> | .site-section.padded#gamification | reused |
| intro | .section-intro → .eyebrow-brand, .site-title, .section-lede | reused |
| tier grid | .tier-grid | reused |
| tier card | .tier-card + .tier-common / .tier-rare / .tier-epic / .tier-legendary | reused |
| 52×52 ring SVG | .tier-ring | new — replaces .tier-medal, ledger 15 |
| tier label | .mc-mono | reused |
| 18px tier title | .desc | reused — already 18px/700 Outfit (:1010), the canvas's value |
| 13.5px third line | .tier-note | new, ledger 16 |
| card hover | .tier-card:hover (+ 4 per-tier tints) | new, ledger 17 |
| stat row | .bigstats → .stat → .v, .v small, .l | reused |
| counted figure | new inner <span data-count> inside .v | new node + attributes, ledger 22 |
#teams-showcase — reused entirely
| Canvas node | Class | Status |
|---|---|---|
<section id="teams-showcase"> | .site-section.dark.padded#teams-showcase | reused |
| intro | .section-intro.teams-intro → .eyebrow-brand, .site-title, .section-lede.on-dark, .teams-actions | reused |
| 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-frame | reused |
| 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 node | Class | Status |
|---|---|---|
<section id="pricing"> | .site-section.pricing-section.padded#pricing | reused |
| intro | .section-intro.pricing-intro | reused |
| Free card | .price-card → header → h3, .price-amount → .price-num $0 / .price-unit / forever; .price-blurb; .price-list (6); .mc-btn-ghost.price-cta | reused |
| Supporter card | .price-card.featured → .price-flag Optional; .price-num $4 / .price-unit / month; .price-list (6); .mc-btn-brand.price-cta | reused |
| billing toggle | — | absent in the canvas — ruling 1 |
#teams — reused entirely, toggle is a non-regression item
| Canvas node | Class | Status |
|---|---|---|
<section id="teams"> | .site-section.teams-panel-section.padded#teams | reused |
| value column | .teams-panel-value → .teams-feature-grid (6), .teams-trust-strip | reused |
| price card | .price-card.teams-price-card → .teams-price-head, .teams-seat-chip | reused |
| billing toggle | .teams-billing-toggle → .teams-toggle-tab[data-period] ×2 | reused — 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-subline | reused, all data-attributes byte-identical |
| honest terms | .teams-honest-terms (3) | reused |
#faq — reused, one mark becomes an element
| Canvas node | Class | Status |
|---|---|---|
<section id="faq"> | .site-section.faq-section.padded#faq | reused |
| intro | .section-intro → .eyebrow-brand, .site-title | reused — allowlisted path, see below |
| 6 items | .faq-item (<details>) → summary, p | reused, 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-brandis an allowlisted path. The intro must stay the direct child of.site-containerunder#faq, and it must still receive.inat scan time. Do not wrap it.
#connect — reused, glyph becomes the mark
| Canvas node | Class | Status |
|---|---|---|
<section id="connect"> | .cta-section.padded#connect | reused |
| 76×76 mark SVG | .cta-mark | new, replaces .cta-orb on this page — ledger 20 |
| heading / lede / CTA | .cta-title → em, .cta-lede, .cta-actions → .mc-btn.mc-btn-brand.mc-btn-lg | reused |
| store badges | .store-badges → .store-badge-item → .store-badge | reused — the canvas keeps the Google Play badge; keep the held iOS block commented exactly as it is |
| fine foot | .cta-finefoot | reused — 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.
| # | Change | Kind | Contrast-allowlist consequence | Blast radius |
|---|---|---|---|---|
| 14 | id="coach-teaser" added to <section class="site-section dark padded"> (index.html:408) | new attribute on an existing node | None. 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-ring | retirement + replacement | None. .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 nodes | new class + new text nodes | Watch — 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 added | property change, no class change | None. 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 only | property change in a new media query | None. 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) deleted | new node, pseudo-element removed | Watch. 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 unchanged | new class, sibling not modifier | None — 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. |
| 21 | scroll-margin-top on the nine #-id sections | property change on id selectors | None — 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 .v | new nodes, text moved into them | None. 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-counton.v.animateCountwritesel.textContent(main.js:217), which erases every child node. Two of the four stats carry a<small>sibling —min/dayandranks— and both would vanish on frame one and never come back.data-countgoes on an inner<span>, with the<small>as its sibling, still inside.vso.bigstats .stat .v small(:1040) keeps matching. Verified in both motion states:screenshots/deltas-mobile-{full,reduced}.gif. - Do not leave
summary::afterin 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).
| # | Label | Target | Suffix | Markup | Animates |
|---|---|---|---|---|---|
| 1 | Average use | 10 | min/day, as a sibling <small> | <div class="v"><span data-count="10">10</span><small>min/day</small></div> | yes |
| 2 | Badges to earn | 20 | +, via data-suffix | <div class="v"><span data-count="20" data-suffix="+">20+</span></div> | yes |
| 3 | First Stone → Legacy | 10 | ranks, as a sibling <small> | <div class="v"><span data-count="10">10</span><small>ranks</small></div> | yes |
| 4 | Ads, ever | 0 | none | <div class="v">0</div> — no data-count, no wrapper | no |
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/dayandranksare styled by.bigstats .stat .v small(:1040—0.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 ridesdata-suffixand is written on every frame byanimateCount— the count reads0+ … 20+, which is correct:20+is the value, not20with an ornament. counters()already never restarts.io.unobserve(entry.target)fires beforeanimateCount(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,0are the authored text, so a no-JS reader and areducereader see the same thing a full-motion reader ends on. A stuck0would 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 ← identicalRing-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.
<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>| Tier | Track | Arc stroke | stroke-dasharray |
|---|---|---|---|
| Common | #EEF2F7 | #64748B | 54.98 164.93 |
| Rare | #EEF2F7 | #177BBB | 109.96 109.96 |
| Epic | #EEF2F7 | #A855F7 | 164.93 54.98 |
| Legendary | none — the stroke covers it | #FCC419 | none — one <circle>, no dash |
width/heightlive in CSS (.tier-ring, 52×52), not on the element — one place to change.rotate(-90deg)is on.tier-ringand 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-monoand.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 adds | Part 1 row | Full motion | Still state | Rule that produces it |
|---|---|---|---|---|
Count-up, [data-count] × 3 | required effect 4 | 0 → target, 900ms ease-out cubic, once | Final number, printed, suffix and <small> intact | main.js:199 writes target + suffix synchronously before any observer. No CSS. Row 4 said "Phase 2 markup" — this is that markup. |
| FAQ mark rotate | required effect 5 | + rotates 45° over 250ms | Rotated, 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 lift | translateY(-4px) + per-tier shadow + border tint, 250ms | Colour 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.
.revealstill 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:
| Purpose | Value | Source |
|---|---|---|
.tier-note ink | hsl(var(--muted-foreground)) = #5C6B80 | already :root, already used by .bigstats .stat .v small and .tier-card.tier-common .mc-mono |
.faq-mark ink | hsl(var(--brand-ink)) = #136699 | already :root, published for exactly this job |
| Hover / rotate easing | var(--nav-ease) | added by Part 1 |
| Anchor offset | calc(var(--nav-h-condensed) + 16px) | --nav-h-condensed added by Part 1 |
| Ring arcs, track, gold shadow | #64748B / #177BBB / #A855F7 / #FCC419 / #EEF2F7 | Part 1's ring table; all four are the tier colours already in :1002-1009 |
Two canvas values overruled, both on contrast headroom:
| Node | Canvas | This spec | Why |
|---|---|---|---|
.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:
| Element | Font | Class / size | Note |
|---|---|---|---|
| Tier label | mono | .mc-mono 11px, .04em, uppercase | unchanged |
| Tier title | Outfit | .desc 18px / 700, -0.015em | unchanged — already the canvas's value |
| Tier title, ≤600px | Outfit | 16px / 700, hyphens: auto | new — ledger 18 |
| Tier note | Inter | .tier-note 13.5px / 1.55 | new, matches the canvas |
| FAQ mark | inherited | .faq-mark 22px / 400 | same size the pseudo-element used |
| Stat figure | Outfit | .v clamp(36px, 5vw, 56px) / 800 | unchanged |
| Stat suffix | Outfit | .v small 0.36em / 600 | unchanged — 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.
| Surface | String | Tone check |
|---|---|---|
| Tier note — Common | First check-in, first goal, first week. | Concrete, not abstract ✅ · three real first-times, no praise ✅ |
| Tier note — Rare | 30 days unbroken. 100 check-ins logged. | Names the actual bar ✅ · earned, not handed out ✅ |
| Tier note — Epic | A goal finished. A season held together. | Brief ✅ · "held together" is honest about what it costs ✅ |
| Tier note — Legendary | The 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 isaria-hidden="true"— it does not become a tab stop and does not split the summary's accessible name..faq-markisaria-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 addaria-expandedto the summary either — the native element owns that.Every
.tier-ringand the.cta-markarearia-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 are20px 24pxpadding on an 18px line ≈ 62px tall — over the 44px floor..tier-cardis 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:
Node Value Background Ratio .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: autoneedslang.index.htmlalready 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:
.tier-card .tier-medal(:995) and the four per-tier.tier-medalrules (:1002-1005).faq-item summary::after(:2169) and.faq-item[open] summary::after(:2175)- nothing else —
.cta-orb(:1401) stays, it is still meet-coach's
marketing_landing/index.html — five subjects:
id="coach-teaser"on the section at:408. Nodata-spy. No nav change.- Four
.tier-cardbodies:.tier-medaldiv →.tier-ringSVG, plus a.tier-note<p>. - Four
.bigstats .stat .vbodies: inner<span data-count>for three,<small>kept as sibling, fourth left as plain0. - Six
<summary>bodies: append<span class="faq-mark" aria-hidden="true">+</span>. .cta-orbdiv →.cta-markSVG in#connect. Keep the store badges and the commented iOS block exactly as they are.
marketing_landing/js/main.js — no 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-teaserpresent on the existing dark band, with nodata-spy, no nav change and noid="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-notepresent on all four cards with the four strings verbatim - [ ] Counters count to
10/20+/10with the spec's suffixes;0 / Ads, everdoes not animate;min/dayandrankssurvive 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.revealstill gets.in— stillness from the media query only - [ ] Exactly one mark per FAQ row:
summary::afteris deleted, the+rotates and does not also swap to− - [ ]
#pricingis unchanged — no consumer annual tier, no toggle..teams-billing-togglestill 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//#gamificationload 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-menusibling rule intact. (RAMP_GAP_TARGETSallowlist retired entirely by OBJ-2292 — the mechanism no longer exists inscripts/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-brandand.cta-finefooteach still match exactly one element at 1280px - [ ]
marketing-contrast-guardgreen with the allowlist unchanged — no entry added, none removed - [ ]
png-legibility-guardgreen - [ ] The nine other
landing-2026.cssconsumers unregressed —meet-coach,contact,404, sixpolicies/*; headless pass with zero attributable JS errors..cta-orbstill renders as a chip onmeet-coach.html - [ ] UI evidence at 390 / 768 / 1280, light band + dark band, plus a full-motion /
reduceGIF pair
Mockups & evidence
Subject (data-capture) | What it pins | Captures |
|---|---|---|
tier-cards | Four tiers in real .tier-card classes: ring, label, title, new note | 2 bands × 3 viewports = 6 |
bigstats | Counter row at final value, suffix markup labelled per stat | 6 |
faq-mark | Closed + and open ×, single mark per row | 6 |
cta-mark | Brand mark at 76×76, gap open, in real .cta-inner context | 6 |
coach-teaser | The band as it ships, with the id ruling behind it | 6 |
trap-faq-double-mark | The pre-fix state: two marks per row when summary::after is left in place | 1 |
motion/motion-demo.html | Count-up through the real main.js, FAQ rotate, tier hover | 4 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:
- A clipped word at 390px — ledger 18. Fixed in this phase.
#coach-teaserhas 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 — shipshsl(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-inkis 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:
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
.coach-quote-head .mc-monoat 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..cta-orbonmeet-coach.html. Once index uses.cta-mark, the two closing CTAs render different glyphs. Phase 3 rebuilds that page and should adopt.cta-markthere, retiring.cta-orbin 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.playwright-coreabsent 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:
| # | Subject | What changes |
|---|---|---|
| A | Beam draw | the four anatomy beams are re-boxed onto the real gutter — they currently connect neither end — and then animated |
| B | Moments timestamp | four mono lines the canvas carries and the page does not |
| C | Closing 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.
| Section | Verdict |
|---|---|
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 |
| Nav | page 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. |
| Footer | page 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.pngand thebeam-draw-desktop-*GIF pair are the beam's shipping proof. - ≤900px evidence shows the stacked, beamless layout —
anatomy-still__{mobile,tablet}__dark.pngplus thebeam-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/04already 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:
- 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.
- It is not a colour change.
.cta-orb-coachhas no CSS rule anywhere in this file — verified, zero occurrences outside the markup. So this glyph has been inheriting.cta-orb'svar(--gradient-primary)and rendering the brand blue on a purple page for as long as it has shipped..cta-markis 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. - The canvas's version costs a new infinite animation — two staggered
obj-ringpulses — 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. - Part 2 routed this here explicitly, having deliberately left
.cta-orbalive 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:
| Landmark | x |
|---|---|
| Input column, right edge | 632.4 |
| Beam paths, start | 743.9 |
| Output card, left edge | 776.4 |
| Beam paths, end | 928.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
| Piece | Value | Why |
|---|---|---|
| SVG box | left: 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 |
preserveAspectRatio | none | the viewBox maps onto that box exactly; there is no letterbox left to compute |
vector-effect | non-scaling-stroke | non-uniform scaling would otherwise thicken the diagonals |
viewBox | 0 0 144 500 | x is 1:1 with the 144px gutter; y ≈ 1:1 with the board at desktop |
| Board alignment | align-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:
| Landmark | 1024–1440px | 901px |
|---|---|---|
| Gutter width | 144px | 144px |
| Row centres, % of board height | 11.29 / 36.29 / 61.29 / 86.29 | 11.47 / 36.47 / 61.47 / 86.47 |
| Output-card centre, % of board height | 50.00 | 50.00 |
Beam markup contract
<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 / 431are the four row centres in the 500-unit space;250is the card's.- Control points sit at
x = 86(60% of the gutter), the canvas's own proportion. - The
<defs>gradient keeps its existingid="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:1675carries 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) | Step | Target | From → to | Duration | Easing |
|---|---|---|---|---|---|
| — | trigger | .mc-anatomy-board reaches threshold: 0.35 | — | — | observer unobserves immediately; fires once, never restarts |
| 0 | 1a | input 01 | opacity 0.45 → 1 | 500 | ease (CSS transition) |
| 0 | 1b | beam 01 | stroke-dashoffset len → 0 | 1100 | cubic-bezier(.4, 0, .2, 1) |
| 160 | 2a / 2b | input 02 / beam 02 | as above | 500 / 1100 | as above |
| 320 | 3a / 3b | input 03 / beam 03 | as above | 500 / 1100 | as above |
| 480 | 4a / 4b | input 04 / beam 04 | as above | 500 / 1100 | as above |
| 1580 | settled | — | four drawn, four lit | — | byte-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.35on 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:
- 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. settle()is what a media query cannot do. Priming is an inline style, and an inline style outranks@media (prefers-reduced-motion: reduce). Withoutsettle(), 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();- The observer re-checks
reducebefore animating, for the reader who turned it on while the section was still offscreen. 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.- Null-safe.
beams()returnsnullwhen there is no.mc-beamon 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 node | Class | Status |
|---|---|---|
| hero | .mc-hero.padded → .mc-hero-grid, .mc-hero-copy, .mc-hero-visual, .mc-hero-orb, .mc-hero-sheet, .mc-hero-chips, .mc-hero-bubble | reused |
| 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-text | reused, 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 node | Class | Status |
|---|---|---|
<section> | .site-section.dark.mc-anatomy.padded | reused |
| intro | .section-intro → .eyebrow.on-dark, .d-title.on-dark, .section-lede.on-dark | reused |
| board | .mc-anatomy-board | gains 2 custom properties + align-items: stretch — ledger 26 |
| four input rows | .mc-input-row → .mc-input-num, .mc-input-head, p | each gains .mc-beam-input — ledger 24 |
| beam gutter | .mc-anatomy-beam | re-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-wrap | gains flex centring — ledger 26 |
| output card | .mc-anatomy-output → .mc-anatomy-output-head → .spark, .mc-mono; .mc-anatomy-bubble; .mc-anatomy-meta | reused; margin-left becomes a variable |
A_Moments — one new line per card
| Canvas node | Class | Status |
|---|---|---|
<section> | .site-section.padded | reused |
| intro | .section-intro → .eyebrow, .d-title, .section-lede | reused |
| grid | .moments-grid | reused |
| 4 cards | .moment-card + .mc-moon / .mc-sun / .mc-gold / .mc-bar | grid rows 2 → 3 — ledger 27, same order |
| icon | .moment-icon | reused; row span 1/3 → 1/4 |
| timestamp | .mc-mono | new 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 / body | h3, p | reused; h3 loses its 6px top margin to the chip |
A_CTA — the glyph
| Canvas node | Class | Status |
|---|---|---|
<section id="connect"> | .mc-cta.padded#connect | reused |
| glyph | .cta-orb.cta-orb-coach → .cta-mark | retirement + replacement — ledger 28 |
#og gradient | sprite <defs> | new — the mark's fill; absent from this page today |
| heading / lede / actions / fine foot | .cta-title → em.coach, .cta-lede, .hero-actions, .cta-finefoot | reused |
<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.
| # | Change | Kind | Contrast-allowlist consequence | Blast radius |
|---|---|---|---|---|
| 23 | .mc-beam added to the four anatomy <path> nodes | new class, existing nodes | None. 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 rule | new class on existing nodes | None. 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 gutter | property + markup change, no class change | None — 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 variable | property change, no class change | None. 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 8px | new nodes + property change | Watch — 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 sprite | retirement ×2 + reuse + new defs node | None. .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.65 | value change in markup | None — 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-beamor.mc-beam-input.landing-2026.css:1675-1676is 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-guardcannot see it,png-legibility-guardcannot see it, and nothing in CI will catch it.grep mc-beam marketing_landing/*.htmlreturns zero today; after this phase it must return eight hits. - Do not place the
.mc-beam-inputbase rule afterlanding-2026.css:1638. At equal specificity the later rule wins regardless of which media query matches, so atransitiondeclared below the block would survive underreduceand quietly break the still contract. This is the bug Phase 2 shipped; it belongs before the block, andcontract.csssays so at the top. - Do not turn the ≤900px
display: noneinto aleft/widthoverride. 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 adds | Part 1 row | Full motion | Still state | Rule that produces it |
|---|---|---|---|---|
Beam draw, .mc-beam × 4 + .mc-beam-input × 4 | required effect 7 | 4 paths draw at 160ms offsets, 1100ms each; each input lights 0.45 → 1 as its beam starts | Completed 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:
.mc-beamhas no authoredstroke-dasharray— an undashed path is a drawn path, and the SVG default is the completed state..mc-beam-inputships at opacity 1; the 0.45 dim is written only by the full-motion branch.beams()returns before priming anything whenreduceis true, andsettle()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:
| Purpose | Value | Source |
|---|---|---|
| Beam gutter | --mc-board-gap: 64px / --mc-output-inset: 80px | new custom properties, not new values — both mirror literals already at :1933 and :1988, promoted so the gutter and the layout share one number |
| Beam stroke | url(#mc-anatomy-beam), hsla(271,91%,75%, 0 → .65) | the page's existing gradient; end stop is the canvas's own figure |
| Beam easing | cubic-bezier(.4, 0, .2, 1) | var(--nav-ease), added by Part 1 |
| Moments timestamp ink | hsl(var(--muted-foreground)) = #5C6B80 | already :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 fill | url(#og) #177bbb → #05cbfc; pip + dot #fcc419 | Part 1's ring-glyph section; Part 2's markup contract |
| Closing mark shadow | drop-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:
| Element | Font | Class / size | Note |
|---|---|---|---|
| Moments timestamp | mono | .mc-mono 11px, .04em, uppercase | new node, existing rule — no size, weight or family is invented |
| Moments title | Outfit | h3 22px / 700, -0.015em | unchanged; only its top margin moves to the chip |
| Moments body | Inter | p 14.5px / 1.6 | unchanged |
| Anatomy input head | Outfit | .mc-input-head 18px / 700 | unchanged |
| Anatomy bubble | Outfit | .mc-anatomy-bubble 22px / 1.32 / 600 | unchanged |
| CTA title | Outfit | .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.
| Surface | String | Tone check |
|---|---|---|
| Moments — Before bed | 10:40 PM | Names the moment, not the feeling ✅ · the hour a streak actually dies ✅ |
| Moments — Monday morning | 7:05 AM | Specific to the minute — a real time, not "morning" ✅ |
| Moments — Streak day 30 | Day 30 | Counts what was earned, no adjective ✅ |
| Moments — When numbers lie | Any day | Honest: 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-beamalready carries it and keeps it;.cta-markcarries 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-cardis 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:
Node Value Background Ratio 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%)same 7.53:1 .mc-anatomy-output-head .mc-monohsl(271 91% 80%)rgba(255,255,255,.08)on#1A0D2E8.71:1 Beam stroke hsla(271,91%,75%,.65)dark band not assessed — decorative, aria-hidden, no textaxe-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:
.cta-orb(:1430-1438) — its last consumer is the page this phase converts- nothing else —
.cta-mark(:1439) stays, it is now both pages'
marketing_landing/meet-coach.html — five edits:
#og<linearGradient>into the sprite<defs>(byte-identical toindex.html:143-146)- Beam
<svg>: newviewBox,preserveAspectRatio="none", four re-pathed<path class="mc-beam">, gradient end stop0.5 → 0.65 class="mc-beam-input"onto the four.mc-input-row<li>- Four
<div class="mc-mono">in the Moments cards, above each<h3> .cta-orb.cta-orb-coach<div>→ the.cta-mark<svg>
marketing_landing/js/main.js — extend, do not replace. Two additions and nothing else:
beams()as module 11, ported frommockups/motion/motion-demo.html, called alongsideentrance()/reveal()/counters()/header()- one line inside module 0's existing
mqchange 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-letterboxedcapture 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: reduceshows 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/*.htmlreturns 8 hits; zero class renames anywhere - [ ] The
.mc-beam-inputbase rule sits beforelanding-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-markwith its gap open, matchingindex.htmlbyte for byte;#ogis 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-mononot re-fixed; no<section>inserted, removed or reordered inside<main>. (RAMP_GAP_TARGETSallowlist retired entirely by OBJ-2292 — the mechanism no longer exists inscripts/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-smandmeet-coach.html::.mc-hero-sheet-head > div > .mc-monoeach still match exactly one element at 1280px - [ ]
marketing-contrast-guardgreen with the allowlist unchanged — no entry added, none removed - [ ]
png-legibility-guardgreen - [ ] The nine other
landing-2026.cssconsumers unregressed —index,contact,404, sixpolicies/*; headless pass with zero attributable JS errors.index.html's#connectstill renders.cta-mark - [ ] UI evidence at 390 / 768 / 1280, light band + dark band, plus a full-motion /
reduceGIF pair at desktop (per ruling 1) recorded throughscripts/capture-marketing-motion.mjs— do not build a third bridge
Mockups & evidence
Subject (data-capture) | What it pins | Captures |
|---|---|---|
anatomy-still | The completed diagram — four beams landing on the card, four inputs lit — in real .mc-anatomy classes | 2 bands × 3 viewports = 6 |
trap-beam-letterboxed | The shipped state: beams beginning in empty gutter and terminating inside the card | 3 (dark only — it is a dark-band defect) |
moment-cards | Four cards with the timestamp chip, icon spanning all three rows | 6 |
cta-mark-coach | The mark at 76×76 on the .mc-cta wash, gap open, in real .cta-inner context | 6 |
motion/motion-demo.html | The beam module running through the real observer | 4 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.
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
- 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.
.mc-anatomy-gridis dead CSS.:1929and:1937style a class with zero occurrences in any HTML file inmarketing_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..coach-quote-head .mc-monoat 2.12:1 on a light band — Part 2's routed finding, re-routed unchanged. Ruling 4 confirms this phase does not trigger it.- Eight pages ship a broken
/#how-it-worksnav 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: italicdeclarations becomefont-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 globalem { 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:
| Face | fvar axes | post.italicAngle | @font-face files |
|---|---|---|---|
fonts/outfit-variable.woff2 | wght 100–900 only | 0.0 | 1, font-style: normal (:17-24) |
fonts/inter-variable.woff2 | wght 100–900 only | 0.0 | 1, 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.
| Surface | Token | Value (reference only) | Status |
|---|---|---|---|
.site-title em, .cta-title em | --gradient-primary-ink | linear-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-coach | linear-gradient(135deg, hsl(271 91% 65%), hsl(258 90% 66%)) | unchanged |
em.coach (all four) | --gradient-coach | as above | unchanged |
.site-section.dark .site-title em | --gradient-primary | linear-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.
| Selector | Font | Computed at 1280px | letter-spacing | Change |
|---|---|---|---|---|
.site-title.hero-title | Outfit 800 | 89.6px | -0.03em | font-style only |
.site-title (sections) | Outfit 800 | 64px | -0.03em | font-style only |
.cta-title | Outfit 700 (from h1…h6, :115) | 76.8px | -0.02em (from :116) | font-style + gains a fill |
.d-title | Outfit 800 | 24px | -0.03em | font-style only |
.d-title.mc-hero-title | Outfit 800 | 88px | -0.03em | font-style only |
prose <em> | Inter 400 → 600 | 16px | inherited | new (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:
| Tracking | Worst case | Value |
|---|---|---|
-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:
| # | Selector | Action | Where |
|---|---|---|---|
| E1 | em | add font-style: normal | after h1…h6 (:113-120) |
| E2 | p em, li em, dd em, blockquote em, figcaption em | add font-weight: 600 | after E1 |
| E3 | .site-title em | italic → normal; pads 0.28/-0.22 → 0.04/-0.04 | replaces :166-177 |
| E4 | .cta-title em | italic → normal; gains --gradient-primary-ink + clip | replaces :193-196 |
| E4b | .d-title em, .mc-hero-title em | italic → normal; pads shrink; coach fill kept | replaces :205-215 |
| E5 | .site-title/.d-title/.cta-title/.mc-hero-title em.coach | pads shrink only | replaces :178-192 |
| E6 | .site-section.dark .site-title em | add --gradient-primary | after 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:
| Fill | Against #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 tree — index.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-inkclipped to text, upright, weight inherited. - Default (
.d-title/ Meet Coach):--gradient-coach, upright. .coachmodifier:--gradient-coach, on any of the four selectors, overrides either default.- On
.site-section.dark:--gradient-primary(E6)..coachstill 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 noprefers-color-schemehandling and no theme toggle; the dark board is the.site-section.darkband 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-displaystack falls to Inter, thensystem-ui.system-uidoes have an italic cut on most platforms — which is precisely why the current behaviour is inconsistent, and why E1'snormalis 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:
| Surface | String | Tone check |
|---|---|---|
Hero + closing CTA, index.html | Show 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.html | Chase the goal **together**. | Brief ✅ · the emphasised word is the one E6 makes legible ✅ |
#coach-teaser, index.html | A coach who **pays attention**. | "Coach", never "AI" ✅ |
| Meet Coach hero | A coach who **actually** pays attention. | Emphasis carries the whole claim ✅ |
| Meet Coach closing CTA | A 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:
Fill Band Ratio Verdict --gradient-primary-inkwhite / .site-sectionlight4.54:1 → 5.36:1 AA large ✅, AA normal ✅ --gradient-primary-ink.cta-sectionwash (#FFF → #ECF7FE)4.17:1 → 4.92:1 AA large ✅ (E4's new application) --gradient-primary.site-section.dark(#0B1530, band's lightest stop)3.98:1 → 8.68:1 AA large ✅ (E6) --gradient-coachwhite 3.96:1 → 4.32:1 AA large ✅ --gradient-coach.site-section.dark(#0B1530)4.56:1 → 4.17:1 AA large ✅ Inter 600 body ink white unchanged 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#0B1530midpoint 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-inksibling exists, and why E6's selector is.site-section.dark .site-title emrather 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 pins | Captures |
|---|---|---|
emphasis-selectors | All four selectors, shipped vs ruled, in real classes | 3 (light) |
emphasis-cta-bookend | The bare .cta-title em: italic / italic-struck-nothing-added / E4, plus the hero↔CTA pair | 3 (light) |
emphasis-clip-test | Three padding candidates with the em's box edge drawn — settles 0.04em | 3 (light) |
emphasis-wrap | Mid-em wrap at a 340px measure — the state that rules out skewX | 3 (light) |
emphasis-dark-band | Dark band: shipped / ruled-with-ink-ramp / E6, with the .coach control | 3 (dark) |
emphasis-prose | Prose <em> — the rule for the other eight pages | 3 (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.cssreturns 0 - [ ]
grep -c 'skewX' marketing_landing/css/landing-2026.cssreturns 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
.coachem on a dark band still renders--gradient-coach - [ ]
index.html:839's<em>10 minutes</em>renders with--gradient-primary-ink, matching the hero atindex.html:218 - [ ]
index.html:539's<em>together</em>renders with--gradient-primaryon the dark band - [ ] No
padding-right: 0.28em/margin-right: -0.22emsurvives on anyemrule - [ ]
box-decoration-break: clonesurvives on everyemrule 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) andA 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, noinline-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.mjspasses clean. (Written against the guard's now-retiredRAMP_GAP_TARGETSallowlist — 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
about.htmlhas 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 loadscss/styles.css, which has its ownfont-style: italicat:2038and self-hosts the same two italic-less faces. Identical root cause, different file, outside this stylesheet's ten pages. Wants its own ticket..d-titlehas nofont-sizeof its own. It sets family, weight, tracking, line-height and colour (:198-204) but no size, so any.d-titlein a section that does not size it falls to the UAh2default — measured 24px at 1280px onindex.html:419and four headings onmeet-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..coach-quote-head .mc-monoat 2.12:1 on a light band — Part 2's routed finding, re-routed unchanged. This ruling does not touch it.- Eight pages ship a broken
/#how-it-worksnav link — Part 1's routed finding, still open. .mc-anatomy-gridis dead CSS — Part 3's routed finding, still open.