From a729b1707fb16ec6cff3e0bbc9c21bb9df74022b Mon Sep 17 00:00:00 2001 From: Youssef Date: Wed, 6 May 2026 14:28:34 +0100 Subject: [PATCH] =?UTF-8?q?Worked-examples=20first-card=20aligns=20with=20?= =?UTF-8?q?header=20=C2=B7=20skill:=20N10=20Floating-on-scroll=20nav?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes in one pass — site polish + skill expansion. SITE: Worked-examples rail's first card now aligns with the section header on initial paint. Was: rail used `padding-inline: var(--page-gutter)` (~20 px), so Tide started flush against the viewport's left gutter — out of column with the "02 / EXAMPLES" label and "Worked examples." title above it. Now uses `padding-inline: max(var(--page-gutter), calc((100vw - var(--page-max)) / 2 + var(--page-gutter)))` so the first card's left edge matches the page-content edge. As the user scrolls horizontally, cards traverse the full bleed (right edge unchanged — still extends to viewport). Trailing `::after` spacer also uses the new `--rail-edge` so the last card snaps to matching content edge. SKILL: Added N10 Floating-on-scroll morph to the nav archetype catalogue plus a dedicated `references/floating-nav.md` recipe. The pattern: one DOM with two layers — `.nav` (outer) owns the default bar visuals, `.nav__inner` (inner) owns the floating pill visuals — cross-faded on a single `.is-floating` class toggle past a scroll threshold. Documents the four laws Hallmark refuses to break: 1. Total nav height stays constant (compensate inner shrink with outer padding-block math), or content below jitters. 2. Visible offset uses `transform: translateY()`, never margin or padding (those trigger layout and break Law 1). 3. Cross-fade ownership of every shared visual property (background, border, backdrop-filter, box-shadow). Forget one and you get ghost effects. 4. Single timing curve across every transitioning property — `cubic-bezier(0.16, 1, 0.3, 1)` at ~520 ms. Eight curves reads as eight animations. Plus the property morph table (10 properties, one curve), the scroll-handler script with passive listener + rAF throttle + boolean-flip guard, and an 8-item slop list (the AI-default mistakes Hallmark must not emit). N10's cookbook entry links to floating-nav.md and stays brief — the deep recipe lives in one focused reference file (~80 lines). Skill grew by ~95 lines; one new file, one updated entry. Co-Authored-By: Claude Opus 4.7 (1M context) --- site/css/sections.css | 18 ++++-- skill/references/component-cookbook.md | 18 ++++++ skill/references/floating-nav.md | 89 ++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 skill/references/floating-nav.md diff --git a/site/css/sections.css b/site/css/sections.css index 1c6b6dc..345f7f7 100644 --- a/site/css/sections.css +++ b/site/css/sections.css @@ -198,22 +198,26 @@ overflow-x: auto; overflow-y: visible; scroll-snap-type: x mandatory; - /* Full-bleed: escape both the page max-width AND the page gutter so - the rail spans the full viewport regardless of how wide the - screen is. Cards extend right up to the viewport's edge. */ + /* Full-bleed: rail spans the full viewport. But the FIRST card + starts aligned with the page content (same left edge as the + section header), not flush against the viewport. As the user + scrolls horizontally, cards travel through the full bleed + including the viewport edges — only the initial position + respects the page's content gutter. */ width: 100vw; margin-inline: calc(50% - 50vw); - padding-inline: var(--page-gutter); + --rail-edge: max(var(--page-gutter), calc((100vw - var(--page-max)) / 2 + var(--page-gutter))); + padding-inline: var(--rail-edge); padding-block: var(--space-md) var(--space-lg); - scroll-padding-inline: var(--page-gutter); + scroll-padding-inline: var(--rail-edge); scrollbar-color: var(--color-rule-2) transparent; } /* Reserve trailing whitespace inside the rail so the last card snaps - clean to the gutter and isn't cut off by the bleed margin. */ + clean to the same content edge as the first. */ .examples-rail::after { content: ""; - flex: 0 0 var(--page-gutter); + flex: 0 0 var(--rail-edge); } .examples-rail::-webkit-scrollbar { height: 6px; } diff --git a/skill/references/component-cookbook.md b/skill/references/component-cookbook.md index 00aee63..b6de94b 100644 --- a/skill/references/component-cookbook.md +++ b/skill/references/component-cookbook.md @@ -876,6 +876,24 @@ Wordmark hard-left, single CTA hard-right, vast empty space between, no link row *Anti-pattern:* adding 4 inline links between the wordmark and CTA "to fill the space". The space *is* the design; if you fill it, you've made N1 with extra steps. +### N10 · Floating-on-scroll morph +A sticky bar at the top that **morphs into a floating pill** as the user scrolls past a threshold. Two visual modes share one DOM — `.nav` (outer) owns the bar look, `.nav__inner` (inner) owns the pill look. Cross-faded on a single class toggle (`.is-floating`) with one timing curve. Active layer feels seamless; AI defaults always botch this. +*Use when:* atmospheric / modern-minimal pages where the kinetic micro-moment earns its place. Adds a single tasteful surprise; resists novelty. +*Don't confuse with:* N5 Floating pill (always-on, no scroll behaviour). N10 is N5 plus a default-bar state that morphs *into* it. + +```html + +``` + +The full recipe — the four laws (height-constant, transform-for-offset, cross-fade-everything, single-curve), the property-morph table, the scroll-handler script, and the eight anti-patterns Hallmark refuses — lives in [`floating-nav.md`](floating-nav.md). Reach for that file *before* building this archetype. Skipping the four laws is what makes 90% of attempts read as broken. + +*Anti-pattern (one of eight in floating-nav.md):* swapping two `
` elements via opacity instead of cross-fading one DOM. Doubles markup, fights focus order, desyncs content. + --- ## Routing — which nav fits which genre / theme diff --git a/skill/references/floating-nav.md b/skill/references/floating-nav.md new file mode 100644 index 0000000..a4c0033 --- /dev/null +++ b/skill/references/floating-nav.md @@ -0,0 +1,89 @@ +# Floating nav on scroll — the cross-fade morph + +The recipe for **N10 · Floating-on-scroll morph** (see [`component-cookbook.md` § Navigation](component-cookbook.md)). One DOM, two visual modes, single class toggle, one timing curve. AI defaults botch every one of the four laws below — which is why N10 is the most demanding nav in the cookbook. + +## The structure + +One `
` with an inner wrapper. The outer owns the **default bar** visuals; the inner owns the **floating pill** visuals. As `.is-floating` toggles past a scroll threshold, each layer cross-fades its own visuals out while the other fades in. + +```html + +``` + +## The four laws — non-negotiable + +**1. Total nav height stays constant.** If outer height changes when state flips, every pixel below shifts vertically. Users perceive this as "the page jumped" mid-scroll. Compensate the inner's shrink with the outer's `padding-block` so the math sums to the same total in both states. + +**2. Visible offset uses `transform: translateY()`, never `padding`/`margin`.** The pill should sit detached from the viewport top with breathing room. The naïve add-padding-to-outer fix breaks Law 1. `transform` doesn't affect layout — pill drops visually, box stays put. + +**3. Cross-fade ownership of every shared visual.** For every property the outer carries in default (background, border, backdrop-filter, box-shadow), explicitly neutralise it in `.is-floating` *and* put it in the transition list. Forgetting one — say a stale `backdrop-filter: blur(14px)` on the outer in floating mode — yields an invisible blurred strip dragging across the viewport behind nothing. + +**4. Single timing curve across every property.** Eight properties on eight curves reads as eight animations. Same eight on `var(--dur-mid)` + `var(--ease-out)` reads as one motion. Use `cubic-bezier(0.16, 1, 0.3, 1)` (exponential ease-out) and ~520 ms. + +## The property morph (10 properties, one curve) + +| Element | Property | Default | Floating | +|---|---|---|---| +| `.nav` | `padding-block` | `0` | `var(--space-2xs)` | +| `.nav` | `background-color` | dark/0.62 | `transparent` | +| `.nav` | `border-block-end-color` | rule | `transparent` | +| `.nav` | `backdrop-filter` | `saturate(1.4) blur(14px)` | `blur(0)` | +| `.nav__inner` | `max-width` | `var(--page-max)` | `~58rem` | +| `.nav__inner` | `min-height` | `60px` | `52px` | +| `.nav__inner` | `padding-block` | `12px` | `4px` | +| `.nav__inner` | `border-radius` | `0` | `var(--radius-pill, 999px)` | +| `.nav__inner` | `background-color` | `transparent` | dark/0.82 | +| `.nav__inner` | `backdrop-filter` | `blur(0)` | `blur(18px)` | +| `.nav__inner` | `box-shadow` | `none` | drop + tinted glow + inset hairline | +| `.nav__inner` | `transform` | `translateY(0)` | `translateY(12px)` | + +Use `blur(0)` not `none` — `none` snaps, `blur(0)` transitions. `backdrop-filter` transitions are 2024+ baseline (Chrome 107+, Safari 14+, Firefox 103+). + +## The scroll handler + +```js +(() => { + const nav = document.querySelector(".nav"); + if (!nav) return; + const THRESHOLD = 80; // ≥ 60 px to avoid micro-scroll twitches + let floating = false; + let ticking = false; + const update = () => { + const next = window.scrollY > THRESHOLD; + if (next !== floating) { // boolean-flip guard — toggle once per state change + floating = next; + nav.classList.toggle("is-floating", floating); + } + }; + window.addEventListener("scroll", () => { + if (ticking) return; + ticking = true; + requestAnimationFrame(() => { update(); ticking = false; }); + }, { passive: true }); // mobile scroll-perf — keep main thread free + update(); +})(); +``` + +Three discipline points: +- **`passive: true`** — listener won't `preventDefault`, browser keeps main thread free for scrolling. +- **`requestAnimationFrame` throttle** — caps to 60 calls/s, aligns with paint. +- **Boolean-flip guard** — class operation runs once per state change, not once per scroll event. + +## Anti-patterns Hallmark refuses + +1. Two separate `
` elements that swap via opacity. Doubles DOM, fights focus order, can desync content. +2. Animating `top` / `margin-top` to add the floating offset. Triggers layout. `transform` is the only correct lever. +3. Using `backdrop-filter: none` in the floating state. Snaps. Use `blur(0)` so it transitions. +4. Letting nav height change between states. Causes content jitter — the single most damaging mistake in this pattern. +5. Different transition durations per property ("speed up the radius", "delay the shadow"). Reads as broken even though everything moves. One curve. +6. `scroll` event without `{ passive: true }` or without rAF throttling. Both kill mobile scroll perf. +7. Threshold = 0 (morph fires at the slightest scroll). Too jumpy. Use ≥ 60 px. +8. Forgetting the boolean-flip guard — toggling the class on every scroll event causes layout thrash. + +The pattern works because it's restrained — one orchestrated morph, no embellishment. Pour the polish into the timing curve, the shadow stack, and the height-constant math; not into "more."