Skip to content

Commit 8e83f4e

Browse files
committed
Apply the site-wide apple-design audit
The home header no longer hides on landing: the scroll entrance is scoped to the brand, and the emerging veil moved to a pseudo-element that animates opacity only, so the nav reads from first paint and scrolling stops repainting background and shadow every frame. Body type is 100%/1.6 so browser text-size settings apply, and the /about specimen now describes the stack as it actually renders — no font-face ships, so the system face is the real product, recorded in lessons-learned. prefers-reduced-transparency and prefers-contrast gain solid-chrome and defined-border fallbacks. Cards press down on tap, header links carry ~43px tap targets via padding with cancelling negative margins, search exposes combobox and listbox semantics to match its keyboard behavior, and the runner chip's navy is tokenized as --terminal-bg/--terminal-ink with swatches on /about. The silent arrow-key dead-end at catalog edges stays by design: feedback only where it earns its place. Verified in headless Chromium at 1160, 390x844, and 844x390: the nav renders at first paint on home at all three, and About's tap target measures 43px tall ending inside the 390px viewport. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PJDjKbt3EyWPEThak9WQXp
1 parent 6503005 commit 8e83f4e

10 files changed

Lines changed: 131 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/en/1.1.0
2626

2727
### Changed
2828

29+
- The home header stays visible on landing: the scroll-driven entrance now animates the brand and a compositor-friendly veil layer instead of hiding the whole header (which left the page with no visible nav at first paint).
30+
- Body type is `100%/1.6` instead of a fixed 16px, so browser text-size settings are respected; the `/about` type specimen now describes the stack as it actually renders (system face; FT Kunst Grotesk only where installed).
31+
- `prefers-reduced-transparency` and `prefers-contrast: more` get solid-chrome and defined-border fallbacks; cards press down on tap; header links carry full-height (~43px) tap targets via padding with cancelling negative margins.
32+
- The home search exposes combobox/listbox semantics (`aria-expanded`, `role="option"`) to match its existing keyboard behavior.
33+
- The runner output chip's navy is now tokenized (`--terminal-bg`/`--terminal-ink`) and appears in the `/about` design language.
2934
- Journeys now reference every example: `iterator-vs-iterable`, `classmethods-and-staticmethods`, `bound-and-unbound-methods`, `abstract-base-classes`, and `structured-data-shapes` joined their natural sections, with journey-outcome support lists updated.
3035
- Journey meta descriptions no longer claim gap placeholders; all previously declared gaps are filled.
3136

docs/lessons-learned.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,5 +140,6 @@ git diff --check
140140
- **Copy that describes data state goes stale silently.** Journey meta descriptions still claimed "explicit placeholders for missing examples" long after the last gap placeholder was filled, and the gap-rendering UI suggested holes that no longer existed. Prose that asserts a property of the data ("all examples run," "placeholders mark gaps") should either be derived from the data or covered by a check; otherwise fixing the data falsifies the copy.
141141
- **Make targets that import the example loader must go through uv.** The loader executes example code that uses 3.12+ syntax (`type UserId = int`), so any script importing `src.app` breaks under an older system `python3` even though its shebang says `python3`. CI masks this by installing 3.13 as the default interpreter. Build steps that import the catalog (`build_search_index`, `build_social_cards`) run as `uv run --python 3.13 scripts/...` in the Makefile so local machines with an older `python3` behave like CI.
142142
- **Index notes text, not just titles, and normalize at build time.** The search index concatenates the slug words and every note line, lowercased at build time, so concept queries ("walrus", "GIL"-style vocabulary that titles avoid) hit the right example and the client never re-normalizes entry content. Exporting the ranking function from `search.js` lets a plain Node script (`make search-ranking-test`) assert ranking behaviour against the real generated index without a JS test framework.
143+
- **The brand faces are progressive enhancement; the system stack is the product.** FT Kunst Grotesk and Apercu Mono Pro lead the font stacks, but no `@font-face` ships (no web license), so nearly every visitor reads the system fallbacks. Body type is `100%/1.6` so browser text-size settings are respected. `/about` documents the stack as it actually renders — never describe the leading face as if everyone sees it.
143144
- **UI icons are a deliberate, minimal vocabulary.** The site's chrome is text-first; the only UI icons are the Lucide copy/check/x glyphs on the code-cell copy button, drawn as `currentColor` CSS masks so every token state (muted, hover, accent-on-copied, dark mode) flows through with no icon-specific color rules. Toolbar actions (Run, Reset, Copy link) stay text because no glyph says "copy a URL containing your edit" unambiguously. Any further icon must update this entry and justify itself the same way.
144145
- **The wide events already knew what learners do; nobody was asking.** `example.slug`, `code_edited`, `execution_ms`, turnstile outcome, and 404 paths were all in the observability payload before any analytics existed. `scripts/learner_report.py` is a pure consumer: it auto-detects the raw payload, `wrangler tail` envelope, and Workers Logs envelope per line, so exports from any source work unmodified. The most valuable section costs nothing to collect: recurring 404s under `/examples/` are direct demand for pages that do not exist.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,16 @@ function wireSearch() {
5151
function hideResults() {
5252
results.hidden = true;
5353
results.replaceChildren();
54+
input.setAttribute('aria-expanded', 'false');
5455
}
5556

5657
// Result nodes are built with textContent so catalog fields can never
5758
// be parsed as markup.
5859
function resultNode(entry) {
5960
const item = document.createElement('li');
61+
item.setAttribute('role', 'presentation');
6062
const link = document.createElement('a');
63+
link.setAttribute('role', 'option');
6164
link.href = `/examples/${encodeURIComponent(entry.slug)}`;
6265
const title = document.createElement('strong');
6366
title.textContent = entry.title;
@@ -79,13 +82,15 @@ function wireSearch() {
7982
empty.textContent = 'No matching examples.';
8083
results.replaceChildren(empty);
8184
results.hidden = false;
85+
input.setAttribute('aria-expanded', 'true');
8286
} else {
8387
hideResults();
8488
}
8589
return;
8690
}
8791
results.replaceChildren(...matches.map(resultNode));
8892
results.hidden = false;
93+
input.setAttribute('aria-expanded', 'true');
8994
}
9095

9196
input.addEventListener('focus', loadIndex, { once: true });

public/search.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,16 @@ function wireSearch() {
5151
function hideResults() {
5252
results.hidden = true;
5353
results.replaceChildren();
54+
input.setAttribute('aria-expanded', 'false');
5455
}
5556

5657
// Result nodes are built with textContent so catalog fields can never
5758
// be parsed as markup.
5859
function resultNode(entry) {
5960
const item = document.createElement('li');
61+
item.setAttribute('role', 'presentation');
6062
const link = document.createElement('a');
63+
link.setAttribute('role', 'option');
6164
link.href = `/examples/${encodeURIComponent(entry.slug)}`;
6265
const title = document.createElement('strong');
6366
title.textContent = entry.title;
@@ -79,13 +82,15 @@ function wireSearch() {
7982
empty.textContent = 'No matching examples.';
8083
results.replaceChildren(empty);
8184
results.hidden = false;
85+
input.setAttribute('aria-expanded', 'true');
8286
} else {
8387
hideResults();
8488
}
8589
return;
8690
}
8791
results.replaceChildren(...matches.map(resultNode));
8892
results.hidden = false;
93+
input.setAttribute('aria-expanded', 'true');
8994
}
9095

9196
input.addEventListener('focus', loadIndex, { once: true });
Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
:root { color-scheme: light; --accent: #FF4801; --accent-hover: #FF7038; --accent-soft: rgba(255, 72, 1, 0.08); --text: #521000; --muted: rgba(82, 16, 0, 0.7); --page: #F5F1EB; --surface: #FFFBF5; --surface-2: #FFFDFB; --surface-3: #FEF7ED; --hairline: #EBD5C1; --hairline-soft: rgba(235, 213, 193, 0.5); --header-veil: rgba(245, 241, 235, 0.82); --header-veil-0: rgba(245, 241, 235, 0); --header-veil-solid: rgba(245, 241, 235, 0.95); --figure-paper: #F5F1EB; --space-1: .5rem; --space-2: .75rem; --space-3: 1rem; --space-4: 1.5rem; --space-5: 2rem; --space-6: 3rem; }
1+
:root { color-scheme: light; --accent: #FF4801; --accent-hover: #FF7038; --accent-soft: rgba(255, 72, 1, 0.08); --text: #521000; --muted: rgba(82, 16, 0, 0.7); --page: #F5F1EB; --surface: #FFFBF5; --surface-2: #FFFDFB; --surface-3: #FEF7ED; --hairline: #EBD5C1; --hairline-soft: rgba(235, 213, 193, 0.5); --header-veil: rgba(245, 241, 235, 0.82); --header-veil-0: rgba(245, 241, 235, 0); --header-veil-solid: rgba(245, 241, 235, 0.95); --figure-paper: #F5F1EB; --terminal-bg: #0b1020; --terminal-ink: #f9fafb; --space-1: .5rem; --space-2: .75rem; --space-3: 1rem; --space-4: 1.5rem; --space-5: 2rem; --space-6: 3rem; }
22
* { box-sizing: border-box; }
33
html { -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
4-
body { max-width: 1040px; margin: 0 auto; padding: var(--space-4); color: var(--text); font: 16px/1.6 FT Kunst Grotesk, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif; background: radial-gradient(circle at top left, rgba(255, 72, 1, 0.10), transparent 34rem), var(--page); }
4+
body { max-width: 1040px; margin: 0 auto; padding: var(--space-4); color: var(--text); font: 100%/1.6 FT Kunst Grotesk, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif; background: radial-gradient(circle at top left, rgba(255, 72, 1, 0.10), transparent 34rem), var(--page); }
55
header { position: sticky; top: 0; z-index: 2; margin: 0 calc(-1 * var(--space-4)) var(--space-5); padding: var(--space-2) var(--space-4); backdrop-filter: blur(16px); background: var(--header-veil); }
66
.skip-link { position: absolute; left: -9999px; }
77
.skip-link:focus { position: fixed; left: var(--space-3); top: var(--space-3); z-index: 10; padding: .6rem 1rem; border: 1px solid var(--accent); border-radius: .5rem; background: var(--surface); color: var(--text); }
@@ -13,7 +13,7 @@
1313
h1 { font-size: clamp(2.4rem, 4.5vw, 3.75rem); margin: 0 0 1rem; }
1414
h2 { margin-top: 0; }
1515
p, li { text-wrap: pretty; }
16-
pre { overflow: auto; padding: 1rem; border-radius: 1rem; background: #0b1020; color: #f9fafb; box-shadow: 0 1px 1px rgba(0,0,0,.12), 0 12px 42px rgba(0,0,0,.18); }
16+
pre { overflow: auto; padding: 1rem; border-radius: 1rem; background: var(--terminal-bg); color: var(--terminal-ink); box-shadow: 0 1px 1px rgba(0,0,0,.12), 0 12px 42px rgba(0,0,0,.18); }
1717
textarea { box-sizing: border-box; width: 100%; height: auto; min-height: 18rem; padding: 0; overflow: hidden; resize: vertical; border: 0; outline: 0; background: transparent; color: var(--text); font: 14px/1.5 Apercu Mono Pro, SF Mono, Fira Code, Consolas, monospace; tab-size: 4; transition-property: box-shadow; transition-duration: 150ms; transition-timing-function: cubic-bezier(0, 0, 0.2, 1); }
1818
textarea:focus { box-shadow: 0 0 0 3px rgba(255, 72, 1, 0.18); }
1919
.textarea-fallback[hidden] { display: none; }
@@ -28,9 +28,11 @@
2828
.cm-selectionBackground, .cm-focused .cm-selectionBackground { background: rgba(255, 72, 1, 0.18) !important; }
2929
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-variant-numeric: tabular-nums; }
3030
.syntax-inline { padding: .08rem .25rem; border-radius: .25rem; background: var(--accent-soft); color: var(--text); font-size: .94em; }
31-
.brand { font-weight: 800; }
31+
/* Nav tap targets: block padding grows the hit area to ~43px while
32+
the negative margin cancels the layout growth. */
33+
.brand { font-weight: 800; padding-block: .55rem; margin-block: -.55rem; }
3234
.nav-links { display: flex; gap: .35rem; }
33-
.nav-links a { padding: 0 .9rem; color: var(--muted); }
35+
.nav-links a { padding: .55rem .9rem; margin-block: -.55rem; color: var(--muted); }
3436
.hero { overflow: hidden; border: 1px solid var(--hairline); border-radius: 1rem; padding: clamp(1.25rem, 3.5vw, 2.5rem); margin-bottom: 1.25rem; background: linear-gradient(135deg, var(--surface), var(--surface-3)); box-shadow: 0 1px 3px rgba(82, 16, 0, 0.04), 0 4px 12px rgba(82, 16, 0, 0.02); transform-origin: top left; }
3537
.hero h1 { font-size: clamp(2rem, 4vw, 3rem); margin-bottom: var(--space-3); transform-origin: top left; }
3638
.hero p { max-width: 60ch; color: var(--muted); font-size: 1rem; }
@@ -40,7 +42,13 @@
4042
.hero h1 { animation: hero-h1-morph linear forwards; animation-timeline: scroll(root); animation-range: 0 240px; }
4143
.hero p { animation: hero-p-fade linear forwards; animation-timeline: scroll(root); animation-range: 0 140px; }
4244
body:has(.hero) { padding-top: var(--space-2); }
43-
body:has(.hero) header { opacity: 0; background: var(--header-veil-0); box-shadow: none; margin-bottom: var(--space-2); animation: header-emerge linear forwards; animation-timeline: scroll(root); animation-range: 40px 240px; }
45+
/* The brand blurs in on scroll, but the header (and its nav)
46+
stays readable from first paint — hiding the primary nav on
47+
landing fails the "where can I go?" test. The emerging veil
48+
lives on a pseudo-element and animates opacity only, which
49+
the compositor can do without repainting every scroll frame. */
50+
body:has(.hero) header { background: transparent; box-shadow: none; margin-bottom: var(--space-2); }
51+
body:has(.hero) header::before { content: ""; position: absolute; inset: 0; z-index: -1; background: var(--header-veil-solid); box-shadow: 0 1px 8px rgba(82, 16, 0, 0.06); opacity: 0; animation: header-veil-emerge linear forwards; animation-timeline: scroll(root); animation-range: 40px 240px; }
4452
body:has(.hero) header .brand { filter: blur(4px); transform: scale(0.88); animation: brand-focus linear forwards; animation-timeline: scroll(root); animation-range: 80px 240px; }
4553
}
4654
}
@@ -56,8 +64,8 @@
5664
@keyframes brand-focus {
5765
to { filter: blur(0); transform: scale(1); }
5866
}
59-
@keyframes header-emerge {
60-
to { opacity: 1; background: var(--header-veil-solid); box-shadow: 0 1px 8px rgba(82, 16, 0, 0.06); }
67+
@keyframes header-veil-emerge {
68+
to { opacity: 1; }
6169
}
6270
.site-search { position: relative; max-width: 44rem; margin: 0 0 var(--space-5); }
6371
.site-search input { width: 100%; min-height: 44px; padding: .6rem 1rem; border: 1px solid var(--hairline); border-radius: .75rem; background: var(--surface-2); color: var(--text); font: inherit; transition: border-color 160ms cubic-bezier(0.2, 0, 0, 1), box-shadow 160ms cubic-bezier(0.2, 0, 0, 1); }
@@ -74,6 +82,7 @@
7482
.home-section .eyebrow { margin: 0 0 var(--space-2); }
7583
.card { display: block; min-height: 10rem; border: 1px solid var(--hairline); border-radius: .75rem; padding: var(--space-3); background: var(--surface-2); color: inherit; text-decoration: none; box-shadow: 0 1px 3px rgba(82, 16, 0, 0.04), 0 4px 12px rgba(82, 16, 0, 0.02); transition-property: transform, background-color, border-color; transition-duration: 200ms; transition-timing-function: cubic-bezier(0, 0, 0.2, 1); }
7684
.card:hover { transform: translateY(-2px); background: var(--surface-3); border-color: var(--accent); }
85+
.card:active { transform: translateY(0) scale(0.99); }
7786
.card h2 { text-decoration: underline; text-decoration-color: var(--hairline); text-underline-offset: .18em; }
7887
.card:hover h2 { text-decoration-color: var(--accent); }
7988
.button { min-height: 40px; border: 1px solid var(--accent); border-radius: 9999px; padding: .72rem 1rem; background: var(--accent); color: white; cursor: pointer; font-weight: 650; box-shadow: 0 1px 3px rgba(82, 16, 0, 0.04), 0 4px 12px rgba(82, 16, 0, 0.02); transition-property: transform, opacity, border-style; transition-duration: 150ms; transition-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94); }
@@ -234,3 +243,18 @@
234243
.button { color: #FFF7EF; }
235244
.shiki, .shiki span { color: var(--shiki-dark) !important; }
236245
}
246+
/* Non-motion accessibility signals. Reduced motion is handled by
247+
the transition kill switch and no-preference gates above; these
248+
cover the other two: solid chrome when translucency is reduced,
249+
defined borders and full-contrast prose when contrast is raised. */
250+
@media (prefers-reduced-transparency: reduce) {
251+
header, body:has(.hero) header { background: var(--page); backdrop-filter: none; box-shadow: 0 1px 0 var(--hairline); }
252+
body:has(.hero) header::before { content: none; }
253+
.search-results { background: var(--surface); }
254+
}
255+
@media (prefers-contrast: more) {
256+
header, body:has(.hero) header { background: var(--page); backdrop-filter: none; box-shadow: 0 1px 0 var(--text); }
257+
body:has(.hero) header::before { content: none; }
258+
.card, .runner-panel, .site-search input, .copy-button, .tool-button { border-color: var(--text); }
259+
.meta, .lp-prose p, .lesson-step p, .nav-links a { color: var(--text); }
260+
}

0 commit comments

Comments
 (0)