diff --git a/CHANGELOG.md b/CHANGELOG.md index ddef0e7..d6976cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,17 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/en/1.1.0 ## Unreleased +### Fixed + +- Example-page runner wiring (Run interception, Reset, the share button, and keyboard navigation) no longer waits for the CDN-backed highlighter and editor modules: `runner.js` loads `async`, so a slow or unreachable esm.sh cannot stall it — ordered module scripts otherwise execute strictly after every preceding module settles, including their top-level awaits. + ### Added +- `/about` page describing how the site is made — the verified-output pipeline, the runner sandbox, the figure grammar, and the quality gates — with a design-language section that renders the live CSS tokens (color swatches, spacing scale, type specimens, and a real walkthrough cell as a specimen). Linked from the nav on every page and listed in the sitemap. +- Copy buttons on read-only source cells: injected client-side onto `.cell-source` wrappers (so no-JS pages render no dead buttons), clipboard API with a hidden-textarea fallback, and a quiet icon-in-pill treatment — the Lucide copy glyph drawn as a `currentColor` CSS mask (checkmark on success, x on failure), a visually hidden live region announcing the result, and a ~44px tap target around the small pill. These are the site's first and only UI icons; every other action stays text. +- Keyboard navigation on example pages: `←`/`→` follow the existing `rel="prev"`/`rel="next"` links, ignoring keystrokes with modifiers or focus inside the editor, inputs, or any editable surface. Arrows only fire while the editor is unedited (so a slip never discards work), and the prev/next links carry title hints naming the shortcut. +- "Copy link" button in the runner toolbar: copies a URL embedding the editor's current code as the `#code=` fragment the page has decoded since the first commit (the clean page URL when the code is unedited). The decoder now resizes the editor after loading a shared payload. The button sits apart from the Run/Reset pair at the toolbar's far edge — it exports the code rather than acting on it — and a link recipient sees a notice that the editor holds shared code instead of the example's expected output. + - Banner position grammar from `docs/visual-explainer-spec.md` is now production: `render_banner(slug, position)` supports `before`, `after-cell-N` (legacy anchor `cell-N`), and `after-walkthrough`, with multiple figures per position rendering as one small-multiple banner. The mutability page ships the canonical two-figure pair (aliased mutation vs. frozen tuple). - Curated pair banners on contrast cells: `positional-only-parameters` shows the `/` and `*` separator twins side by side, `metaclasses` pairs the metaclass triangle with the familiar class triangle, and `tuples` pairs the frozen tuple with the growing list on the intent-contrast cell. `iterator-vs-iterable` gains the one-pass caret figure on the exhaustion cell. @@ -23,6 +32,11 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/en/1.1.0 ### Changed +- 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). +- Body type is `100%/1.6` instead of a fixed 16px, so browser text-size settings are respected. The never-rendering brand faces (FT Kunst Grotesk, Apercu Mono Pro — installed nowhere, shipped never) are removed from the stacks, which now declare the real design: `system-ui` prose and `ui-monospace`-first code. The `/about` type specimen says so. +- `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. +- The home search exposes combobox/listbox semantics (`aria-expanded`, `role="option"`) to match its existing keyboard behavior. +- The runner output chip's navy is now tokenized (`--terminal-bg`/`--terminal-ink`) and appears in the `/about` design language. - 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. - Journey meta descriptions no longer claim gap placeholders; all previously declared gaps are filled. - Python tooling is pinned to 3.13 through `uv`; CI and preview workflows are hardened, generated-file recovery retries from a clean worktree, and quality gates enforce live registry/score invariants. diff --git a/Makefile b/Makefile index d0ef0eb..1404ce3 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,8 @@ build: embed-examples embed-editorial-registry build-search-index fingerprint pr check-generated: build check-social-cards git diff --exit-code src/example_sources_data.py src/editorial_registry_data.py src/asset_manifest.py public/_headers public/prototyping public/search-index.json - test -z "$$(git status --porcelain public/prototyping public/*.css public/*.js public/*.json)" + # Tracked generated changes may be staged for commit; reject only untracked output here because git diff above already catches unstaged tracked drift. + test -z "$$(git ls-files --others --exclude-standard -- public/prototyping public/*.css public/*.js public/*.json)" fingerprint: embed-examples embed-editorial-registry build-search-index $(PY) scripts/fingerprint_assets.py diff --git a/README.md b/README.md index 6597c21..ad74856 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,10 @@ Production: (`workers.dev` is disabled so the - Per-example social-card images composed from the marginalia figure set - Learner-behavior reporting from Worker wide events (`docs/learner-analytics.md`) - A quality-gate suite for registries, rubric scores, coverage, and teaching structure that fails builds when content regresses +- `/about` page describing the verification pipeline, runner sandbox, figure grammar, and live design tokens +- Copy buttons on read-only source cells +- Keyboard prev/next navigation (`←`/`→`) on example pages +- "Copy link" sharing of the runner's current code via `#code=` URLs ## Attribution @@ -37,6 +41,7 @@ The project uses: - [FastAPI](https://fastapi.tiangolo.com/) for application routing. - [Shiki](https://github.com/shikijs/shiki) for read-only syntax highlighting. - [CodeMirror](https://codemirror.net/) for the editable example editor. +- [Lucide](https://lucide.dev/) for the copy/check/x glyphs on the code-cell copy button. Python documentation links point to the official [Python documentation](https://docs.python.org/3.13/). diff --git a/docs/elevation-strategy.md b/docs/elevation-strategy.md new file mode 100644 index 0000000..a78b5bf --- /dev/null +++ b/docs/elevation-strategy.md @@ -0,0 +1,253 @@ +# Website elevation strategy + +Drafted 2026-07-10. Status: proposal. + +This document answers one question: how do we make Python By Example +more successful without disrupting its essence? It builds on +`docs/rubric-saturation.md` (internal quality has plateaued at a high +level), `docs/learner-analytics.md` (the external signal we can already +measure), and `docs/research-similar-sites.md` (the competitive +landscape). + +## The essence (what must not change) + +Everything below is the product. Elevation work that erodes any of it +is a regression, whatever it does for traffic. + +- **One concept per page.** Compact literate walkthroughs: prose beside + the source it explains, output evidence beside the source that + produced it. +- **Everything runs.** The complete program is editable and executes in + an isolated Dynamic Worker. Expected output is verified, not claimed. +- **Anchored to authority.** Every page links the official Python docs + for the current Cloudflare-supported Python version. +- **Low chrome, article-like.** Not a notebook, not a course, not a + reference dump. Reading order and quiet layout are features. +- **Curated craft.** The locked figure grammar, quality rubrics, + registries, and contract tests are the moat. Nothing ships below the + gates. +- **Respectful by default.** No accounts, no ads, privacy-safe wide + events only. + +## Where the project stands + +The last two improvement rounds made the inside excellent: 109 +examples all above the quality gate, 100% figure coverage with a mean +rubric score ≈ 8.7 and nothing below 8.0, journeys with outcome +contracts, zero orphaned examples, and a green steady state that +`docs/lessons-learned.md` explicitly warns against inflating further. +SEO metadata, structured data, sitemap, social cards, dark mode, search, +and learner analytics all landed in the previous elevation pass. + +The constraint has therefore moved. Page quality is no longer what +limits success; **reach, engagement depth, and coverage of what +learners actually look for** are. `docs/lessons-learned.md` already +points the way: steer content work by external signal rather than +internal quality scores. + +## What "more successful" means + +A shared scorecard, so "success" is not vibes. Everything below is +measurable with the existing wide events plus `scripts/learner_report.py` +and a search-console account. + +1. **Reach** — unique learners per week on example pages; search + impressions and clicks. +2. **Engagement** — POST runs per week; edited-run share (the ideal + documented in `docs/learner-analytics.md`: high edited share, low + error share); journey traffic. +3. **Return** — repeat visits per week (approximate is fine; no + accounts). +4. **Authority** — inbound links, listings in "learn Python" + collections, and citations by AI assistants. +5. **Demand conversion** — recurring `/examples/` 404s turned into + shipped pages. + +## Thrust 1 — Close the demand loop + +The instrumentation exists; the ritual does not. The wide events +already record which pages are read, which examples are run and +edited, where edited code errors, and which nonexistent example URLs +are requested. Make consuming that signal a habit: + +- Schedule a Workers Logs export (Logpush or a periodic dashboard + export) and run `scripts/learner_report.py` on a weekly cadence. +- Treat recurring missing-example 404s as the top of the content + backlog — the docs already call this "the strongest possible signal + for what to write next." +- Treat high error-share pages as walkthrough gaps: learners tried + something the page did not prepare them for. Extend the cells or + notes there. +- Register the sitemap with Google Search Console and Bing Webmaster + Tools (if not already done) and fold query/impression data into the + same weekly look. Queries that show impressions without clicks are + title/summary problems; queries with no impressions are coverage + problems. + +## Thrust 2 — Cover what learners actually search for, without sprawl + +The catalog is deep on language semantics (Types 14, Data Model 12, +Basics 12, Functions 11, Collections 11) and comparatively light on the +task-shaped standard-library surfaces that dominate real Python search +demand (Standard Library 9, Text 3, Async 2). Go By Example's +most-linked pages are exactly the task-shaped ones: reading and +writing files, command-line arguments, environment variables. We have +no file-I/O or pathlib page today. + +Candidate additions — each still one mechanism per page, each through +the same rubric gates: + +- **Files and paths**: reading/writing text files, `pathlib`. Pyodide's + in-memory filesystem makes these genuinely runnable in the Dynamic + Worker — a live advantage over static competitors. +- **Process environment**: environment variables; command-line + arguments (`argparse`'s smallest loop). Both fit the proven + runtime-boundary framing already used by `networking` and + `subprocesses`: teach normal Python first, state the sandbox + constraint once. +- **Everyday stdlib**: `random`, `time`/`perf_counter`, `math` and + `statistics`, `hashlib`, `tempfile`, `functools` beyond + `partial` (`lru_cache`, `reduce`), `textwrap`. + +Filter and order this list by the 404 demand from Thrust 1 rather than +shipping it wholesale; cap the pace at what can clear the 9.0 gate. +The scope boundary should be stated once and kept: **language plus +standard library**. Third-party frameworks (pandas, Django, FastAPI) +are off-essence. + +Separately, sharpen how the existing contrast work meets search +intent. The confusable-pair contrasts already reach visitors: every +owner page is a public example, all of them appear in journey +sections, roughly half the pairs are stated in visitor-facing journey +text (e.g. "distinguish value equality from object identity"), and +iterator vs iterable has a dedicated page. What does not exist is a +surface shaped like the search query: no page enumerates the pairs, +and the registry itself is read only at build time. A small "X vs Y" +contrasts index — generated from the registry, linking to the owning +pages — would target those high-intent queries directly instead of +leaving each contrast mid-page where only an on-page reader finds it. + +## Thrust 3 — Deepen the on-page loop + +Small affordances, all quiet enough to preserve the low-chrome feel, +all aimed at the scorecard's engagement and return lines: + +- **Share this code.** `example.html` already restores code from a + `#code=` base64 fragment on load, but nothing ever writes one. Add a + "copy link to this code" affordance near the runner so every + experiment becomes a shareable artifact. Half the mechanism is + already built; the missing half is a distribution channel. +- **Copy buttons on code blocks.** Learners copy code constantly; + making them select text by hand is friction with no editorial value. +- **"Try it" nudges.** A one-line micro-challenge near the runner + ("change X to Y — predict the output before running") directly + targets the edited-share-up, error-share-down ideal. Start as a + notes convention or an optional block on the ten most-run pages and + measure whether edited share moves. +- **Example → journey orientation.** Journey pages link examples, but + example pages never mention the journeys they belong to. A one-line + "Part of the Iteration journey" link closes that edge of the graph + and gives a learner who landed from search a curated next step + beyond prev/next. +- **Keyboard prev/next** (`←`/`→`) to reinforce the tour feel. +- **Local progress marks.** A localStorage-only check on examples a + learner has read or run, rendered as a quiet dot on home cards and + journey lists. No accounts, no server state — but journeys gain a + sense of completion, which is the cheapest honest return-visit + mechanic available. + +## Thrust 4 — Earn distribution + +- **Publish the method.** An `/about` (or colophon) page telling the + editorial story: literate cells with verified output, the locked + figure grammar, geometry contract tests, quality gates. The `docs/` + folder is already public-grade; no competitor has this story, and it + is exactly the trust signal search engines and human referrers + reward. +- **Be citable by machines.** Publish `/llms.txt` and per-example + Markdown endpoints (`/examples/.md`). The sources are already + Markdown, and `docs/lessons-learned.md` already requires teaching + artifacts to be clean enough to serve as public material. AI + assistants are now a first-class referrer of learners; being the + cleanest citable source for "Python by example" queries is cheap and + fully on-essence. +- **Feeds.** An Atom feed of new and substantially revised examples so + newsletters and aggregators can pick up changes without watching the + repo. +- **Edit this page on GitHub.** A per-example source link converts + readers into contributors (CONTRIBUTING and issue templates already + exist) and is a further trust signal. +- **Launch moments.** The site has never had one. Milestone-shaped + posts — Show HN, r/Python, Python Weekly, an awesome-python listing + PR — with the runnable-literate angle as the hook. Sequence these + *after* the quick loop-deepening wins so spike traffic lands on the + sharpest version of the page. +- **Stay current as an event.** When Cloudflare's Python Workers + runtime moves past 3.13, migrate fast (the tooling exists: + `make verify-python-version`) and ship a small "new in this Python, + by example" set. Version currency is a recurring, newsworthy proof + that the site is alive — most competitor catalogs visibly rot. + +## Guardrails — what we will not do + +- No accounts, no ads, no comments, no gamification beyond quiet local + progress marks. +- No third-party framework or library tutorials; the boundary is the + language and its standard library. +- No course-ification; journeys remain curated arcs over the same + example pages. +- No content that bypasses the gates: every new page passes the same + rubric, formatter, parity, and verified-output checks regardless of + how fast we want to grow. +- No chrome creep: share, copy, progress, and breadcrumb affordances + must stay visually quiet. The reading experience is the product. + +## Sequencing + +**Now (days; mostly small, single-surface changes)** + +1. Share-this-code link + copy buttons on code blocks. + *(Both shipped 2026-07-10.)* +2. `/about` page + edit-on-GitHub links. + *(`/about` shipped 2026-07-10; edit-on-GitHub links are still open.)* +3. `/llms.txt`, per-example Markdown endpoints, Atom feed. +4. Search-console registration + the first weekly learner-report + ritual. *(Google Search Console ownership verified 2026-07-10; + sitemap submission waits on the next deploy.)* + +**Next (weeks)** + +5. First demand-driven stdlib tranche: files/pathlib, environment + variables, command-line arguments, `random`, `time` — ordered by + observed 404s, gated by the rubric. +6. Example → journey breadcrumbs; keyboard prev/next. + *(Keyboard prev/next shipped 2026-07-10; breadcrumbs are still + open.)* +7. "X vs Y" contrasts index from the confusable-pair registry. +8. "Try it" nudges on the ten most-run pages; measure edited share. +9. First launch moment, once 1–3 are live. + +**Horizon (quarter-plus)** + +10. Local journey progress marks. +11. Python version migration moment (runtime-dependent) plus a + what's-new example set. +12. Translations — only with a sustainable community model, never + machine-dumped. +13. Embeddable example widget for blogs — only if inbound demand + appears. + +## Risks + +- **Distribution before sharpening wastes the spike.** A Show HN that + lands on pages without share/copy affordances converts one visit + into zero artifacts. Sequence quick wins first. +- **Stdlib expansion drifts into reference sprawl.** The 404-demand + filter, the one-mechanism-per-page rule, and the broad-surface-title + lesson in `docs/lessons-learned.md` are the existing defenses; keep + them binding. +- **The analytics ritual decays.** Keep it one command and one short + dated summary; if it takes an hour it will stop happening. +- **Runner cost or abuse under spike traffic.** Turnstile protection + and the smoke bypass already exist; watch turnstile outcomes in the + weekly report during launch weeks. diff --git a/docs/lessons-learned.md b/docs/lessons-learned.md index 80af107..083031a 100644 --- a/docs/lessons-learned.md +++ b/docs/lessons-learned.md @@ -139,4 +139,6 @@ git diff --check - **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. - **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. - **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. +- **The system stack is the typography; never lead a stack with a face nobody has.** From the first commit the font stacks led with FT Kunst Grotesk and Apercu Mono Pro, but no `@font-face` ever shipped and the faces were not installed anywhere — not on visitors' machines, not on the maintainer's Mac, not on the build machine that rasterizes the social cards. They rendered for no one, ever, and were removed on 2026-07-10. The stacks now declare what actually renders: `system-ui` for prose, `ui-monospace` first for code. Body type is `100%/1.6` so browser text-size settings are respected, and `/about` documents the stack honestly. +- **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. - **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. diff --git a/public/editor.32983031ee9a.js b/public/editor.a550bf2266bd.js similarity index 81% rename from public/editor.32983031ee9a.js rename to public/editor.a550bf2266bd.js index 11b232d..e774839 100644 --- a/public/editor.32983031ee9a.js +++ b/public/editor.a550bf2266bd.js @@ -34,15 +34,28 @@ if (textarea && form) { textarea.insertAdjacentElement('afterend', view.dom); textarea.hidden = true; + function resize() { + view.dom.style.minHeight = ''; + const minimum = Number.parseFloat(getComputedStyle(view.dom).minHeight) || 0; + view.dom.style.minHeight = `${Math.max(minimum, view.contentDOM.scrollHeight)}px`; + } + + function scheduleResize() { + requestAnimationFrame(resize); + } + function setValue(value) { view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } }); textarea.value = value; + scheduleResize(); } + resize(); + function syncTextarea() { textarea.value = view.state.doc.toString(); } form.addEventListener('submit', syncTextarea); - window.pythonByExampleEditor = { setValue, syncTextarea, focus: () => view.focus() }; + window.pythonByExampleEditor = { setValue, syncTextarea, resize: scheduleResize, focus: () => view.focus() }; } diff --git a/public/editor.js b/public/editor.js index 11b232d..e774839 100644 --- a/public/editor.js +++ b/public/editor.js @@ -34,15 +34,28 @@ if (textarea && form) { textarea.insertAdjacentElement('afterend', view.dom); textarea.hidden = true; + function resize() { + view.dom.style.minHeight = ''; + const minimum = Number.parseFloat(getComputedStyle(view.dom).minHeight) || 0; + view.dom.style.minHeight = `${Math.max(minimum, view.contentDOM.scrollHeight)}px`; + } + + function scheduleResize() { + requestAnimationFrame(resize); + } + function setValue(value) { view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } }); textarea.value = value; + scheduleResize(); } + resize(); + function syncTextarea() { textarea.value = view.state.doc.toString(); } form.addEventListener('submit', syncTextarea); - window.pythonByExampleEditor = { setValue, syncTextarea, focus: () => view.focus() }; + window.pythonByExampleEditor = { setValue, syncTextarea, resize: scheduleResize, focus: () => view.focus() }; } diff --git a/public/og/about.jpg b/public/og/about.jpg new file mode 100644 index 0000000..59f707d Binary files /dev/null and b/public/og/about.jpg differ diff --git a/public/og/manifest.json b/public/og/manifest.json index 771523a..78ebe08 100644 --- a/public/og/manifest.json +++ b/public/og/manifest.json @@ -1,5 +1,6 @@ { "inputs": { + "about.jpg": "fc631a56878ddf2984336d15058f4704dd7fe58030ca8bf82cf27ed559f9a044", "abstract-base-classes.jpg": "4eb9d5a3d66a2b155193e94b811323feea80f393735e574c2de4dfad78168848", "advanced-match-patterns.jpg": "007a8737ec0e21fdeac4e7ef62fc6e96d3b18ae063c0683bdd9efc2f40201a77", "args-and-kwargs.jpg": "81c412d318a63158b0b2719d31553c9fd1b1ffc44ccd9f42b3f7422f97853647", diff --git a/public/runner.eddb185f7433.js b/public/runner.d3515b57bc3e.js similarity index 55% rename from public/runner.eddb185f7433.js rename to public/runner.d3515b57bc3e.js index c2dbf88..eed3988 100644 --- a/public/runner.eddb185f7433.js +++ b/public/runner.d3515b57bc3e.js @@ -4,8 +4,16 @@ function initializeRunner() { if (!textarea || !form) return; const originalCode = textarea.dataset.originalCode ?? textarea.defaultValue; + const resizeFallbackEditor = () => { + // When the CDN-backed editor is unavailable, keep a shared multi-line + // payload visible in the native textarea instead of leaving it at the + // authored example's height. + textarea.style.height = 'auto'; + textarea.style.height = `${Math.max(textarea.scrollHeight, 18 * 16)}px`; + }; const setCode = (value) => { textarea.value = value; + resizeFallbackEditor(); window.pythonByExampleEditor?.setValue(value); }; @@ -17,17 +25,70 @@ function initializeRunner() { }); } + let sharedCodeLoaded = false; const hash = new URL(window.location.href).hash; if (hash.startsWith('#code=')) { try { setCode(decodeURIComponent(escape(atob(hash.slice(6))))); + sharedCodeLoaded = textarea.value !== originalCode; } catch (_) { // Ignore malformed share fragments and leave the authored example intact. } } + // Share the current editor code as the #code= fragment the loader + // above has accepted since day one; unedited code shares the clean + // page URL. The encoder mirrors the decoder exactly. + async function copyTextToClipboard(text) { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + return; + } + const holder = document.createElement('textarea'); + holder.value = text; + holder.setAttribute('readonly', ''); + holder.style.position = 'fixed'; + holder.style.left = '-9999px'; + document.body.appendChild(holder); + holder.select(); + const copied = document.execCommand('copy'); + holder.remove(); + if (!copied) throw new Error('execCommand copy failed'); + } + + const toolbar = form.querySelector('.playground-toolbar'); + if (toolbar) { + const shareButton = document.createElement('button'); + shareButton.type = 'button'; + shareButton.className = 'tool-button share-button'; + shareButton.textContent = 'Copy link'; + shareButton.setAttribute('aria-live', 'polite'); + shareButton.setAttribute('aria-label', 'Copy a link to this example with the current code'); + let shareRestore = null; + shareButton.addEventListener('click', async () => { + window.pythonByExampleEditor?.syncTextarea(); + const code = textarea.value; + const pageUrl = window.location.origin + window.location.pathname; + const url = code === originalCode ? pageUrl : pageUrl + '#code=' + btoa(unescape(encodeURIComponent(code))); + clearTimeout(shareRestore); + let feedback = 'Link copied'; + if (url.length > 8000) { + feedback = 'Code too long to link'; + } else { + try { await copyTextToClipboard(url); } catch (_) { feedback = 'Copy failed'; } + } + shareButton.textContent = feedback; + shareRestore = setTimeout(() => { shareButton.textContent = 'Copy link'; }, 1600); + }); + toolbar.append(shareButton); + } + const outputPanel = document.querySelector('.output-panel'); if (!outputPanel) return; + if (sharedCodeLoaded) { + outputPanel.querySelector('h3').textContent = 'Output'; + outputPanel.querySelector('code').textContent = 'This link included edited code. Press Run to execute it.'; + } const challengeBox = document.querySelector('[data-turnstile-sitekey]'); let turnstileWidgetId = null; let loadingTurnstile = null; @@ -136,8 +197,21 @@ function initializeRunner() { }); } -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializeRunner, { once: true }); -} else { - initializeRunner(); -} +// layout.html emits this async module after the page content, so its targets +// are already parsed. Do not wait for DOMContentLoaded: the head's CDN-backed +// editor module can otherwise hold that event while Run/Reset/share remain dead. +initializeRunner(); + +// Left/right arrows page through the catalog via the existing +// rel=prev/next links. Modifier keys and any editable surface are +// ignored so the shortcut never interferes with editing. +document.addEventListener('keydown', (event) => { + if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + const target = event.target; + if (target instanceof Element && target.closest('input, textarea, select, button, .cm-editor, [contenteditable="true"]')) return; + const codeField = document.getElementById('code-editor'); + if (codeField && codeField.value !== (codeField.dataset.originalCode ?? codeField.defaultValue)) return; + const link = document.querySelector(event.key === 'ArrowLeft' ? '.example-nav a[rel="prev"]' : '.example-nav a[rel="next"]'); + if (link) window.location.href = link.href; +}); diff --git a/public/runner.js b/public/runner.js index c2dbf88..eed3988 100644 --- a/public/runner.js +++ b/public/runner.js @@ -4,8 +4,16 @@ function initializeRunner() { if (!textarea || !form) return; const originalCode = textarea.dataset.originalCode ?? textarea.defaultValue; + const resizeFallbackEditor = () => { + // When the CDN-backed editor is unavailable, keep a shared multi-line + // payload visible in the native textarea instead of leaving it at the + // authored example's height. + textarea.style.height = 'auto'; + textarea.style.height = `${Math.max(textarea.scrollHeight, 18 * 16)}px`; + }; const setCode = (value) => { textarea.value = value; + resizeFallbackEditor(); window.pythonByExampleEditor?.setValue(value); }; @@ -17,17 +25,70 @@ function initializeRunner() { }); } + let sharedCodeLoaded = false; const hash = new URL(window.location.href).hash; if (hash.startsWith('#code=')) { try { setCode(decodeURIComponent(escape(atob(hash.slice(6))))); + sharedCodeLoaded = textarea.value !== originalCode; } catch (_) { // Ignore malformed share fragments and leave the authored example intact. } } + // Share the current editor code as the #code= fragment the loader + // above has accepted since day one; unedited code shares the clean + // page URL. The encoder mirrors the decoder exactly. + async function copyTextToClipboard(text) { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + return; + } + const holder = document.createElement('textarea'); + holder.value = text; + holder.setAttribute('readonly', ''); + holder.style.position = 'fixed'; + holder.style.left = '-9999px'; + document.body.appendChild(holder); + holder.select(); + const copied = document.execCommand('copy'); + holder.remove(); + if (!copied) throw new Error('execCommand copy failed'); + } + + const toolbar = form.querySelector('.playground-toolbar'); + if (toolbar) { + const shareButton = document.createElement('button'); + shareButton.type = 'button'; + shareButton.className = 'tool-button share-button'; + shareButton.textContent = 'Copy link'; + shareButton.setAttribute('aria-live', 'polite'); + shareButton.setAttribute('aria-label', 'Copy a link to this example with the current code'); + let shareRestore = null; + shareButton.addEventListener('click', async () => { + window.pythonByExampleEditor?.syncTextarea(); + const code = textarea.value; + const pageUrl = window.location.origin + window.location.pathname; + const url = code === originalCode ? pageUrl : pageUrl + '#code=' + btoa(unescape(encodeURIComponent(code))); + clearTimeout(shareRestore); + let feedback = 'Link copied'; + if (url.length > 8000) { + feedback = 'Code too long to link'; + } else { + try { await copyTextToClipboard(url); } catch (_) { feedback = 'Copy failed'; } + } + shareButton.textContent = feedback; + shareRestore = setTimeout(() => { shareButton.textContent = 'Copy link'; }, 1600); + }); + toolbar.append(shareButton); + } + const outputPanel = document.querySelector('.output-panel'); if (!outputPanel) return; + if (sharedCodeLoaded) { + outputPanel.querySelector('h3').textContent = 'Output'; + outputPanel.querySelector('code').textContent = 'This link included edited code. Press Run to execute it.'; + } const challengeBox = document.querySelector('[data-turnstile-sitekey]'); let turnstileWidgetId = null; let loadingTurnstile = null; @@ -136,8 +197,21 @@ function initializeRunner() { }); } -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializeRunner, { once: true }); -} else { - initializeRunner(); -} +// layout.html emits this async module after the page content, so its targets +// are already parsed. Do not wait for DOMContentLoaded: the head's CDN-backed +// editor module can otherwise hold that event while Run/Reset/share remain dead. +initializeRunner(); + +// Left/right arrows page through the catalog via the existing +// rel=prev/next links. Modifier keys and any editable surface are +// ignored so the shortcut never interferes with editing. +document.addEventListener('keydown', (event) => { + if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + const target = event.target; + if (target instanceof Element && target.closest('input, textarea, select, button, .cm-editor, [contenteditable="true"]')) return; + const codeField = document.getElementById('code-editor'); + if (codeField && codeField.value !== (codeField.dataset.originalCode ?? codeField.defaultValue)) return; + const link = document.querySelector(event.key === 'ArrowLeft' ? '.example-nav a[rel="prev"]' : '.example-nav a[rel="next"]'); + if (link) window.location.href = link.href; +}); diff --git a/public/search.ab0effeac6ce.js b/public/search.158bd7afa114.js similarity index 95% rename from public/search.ab0effeac6ce.js rename to public/search.158bd7afa114.js index 2951819..2496724 100644 --- a/public/search.ab0effeac6ce.js +++ b/public/search.158bd7afa114.js @@ -51,13 +51,16 @@ function wireSearch() { function hideResults() { results.hidden = true; results.replaceChildren(); + input.setAttribute('aria-expanded', 'false'); } // Result nodes are built with textContent so catalog fields can never // be parsed as markup. function resultNode(entry) { const item = document.createElement('li'); + item.setAttribute('role', 'presentation'); const link = document.createElement('a'); + link.setAttribute('role', 'option'); link.href = `/examples/${encodeURIComponent(entry.slug)}`; const title = document.createElement('strong'); title.textContent = entry.title; @@ -79,6 +82,7 @@ function wireSearch() { empty.textContent = 'No matching examples.'; results.replaceChildren(empty); results.hidden = false; + input.setAttribute('aria-expanded', 'true'); } else { hideResults(); } @@ -86,6 +90,7 @@ function wireSearch() { } results.replaceChildren(...matches.map(resultNode)); results.hidden = false; + input.setAttribute('aria-expanded', 'true'); } input.addEventListener('focus', loadIndex, { once: true }); diff --git a/public/search.js b/public/search.js index 2951819..2496724 100644 --- a/public/search.js +++ b/public/search.js @@ -51,13 +51,16 @@ function wireSearch() { function hideResults() { results.hidden = true; results.replaceChildren(); + input.setAttribute('aria-expanded', 'false'); } // Result nodes are built with textContent so catalog fields can never // be parsed as markup. function resultNode(entry) { const item = document.createElement('li'); + item.setAttribute('role', 'presentation'); const link = document.createElement('a'); + link.setAttribute('role', 'option'); link.href = `/examples/${encodeURIComponent(entry.slug)}`; const title = document.createElement('strong'); title.textContent = entry.title; @@ -79,6 +82,7 @@ function wireSearch() { empty.textContent = 'No matching examples.'; results.replaceChildren(empty); results.hidden = false; + input.setAttribute('aria-expanded', 'true'); } else { hideResults(); } @@ -86,6 +90,7 @@ function wireSearch() { } results.replaceChildren(...matches.map(resultNode)); results.hidden = false; + input.setAttribute('aria-expanded', 'true'); } input.addEventListener('focus', loadIndex, { once: true }); diff --git a/public/site.400d69f4ef8b.css b/public/site.4ad21a6ce8a3.css similarity index 70% rename from public/site.400d69f4ef8b.css rename to public/site.4ad21a6ce8a3.css index 3745517..b700b56 100644 --- a/public/site.400d69f4ef8b.css +++ b/public/site.4ad21a6ce8a3.css @@ -1,7 +1,7 @@ -:root { color-scheme: light; --accent: #FF4801; --accent-text: #C83800; --accent-action: #C83800; --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; } +:root { color-scheme: light; --accent: #FF4801; --accent-text: #C83800; --accent-action: #C83800; --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; } * { box-sizing: border-box; } html { -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; } - 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); } + body { max-width: 1040px; margin: 0 auto; padding: var(--space-4); color: var(--text); font: 100%/1.6 system-ui, -apple-system, Segoe UI, sans-serif; background: radial-gradient(circle at top left, rgba(255, 72, 1, 0.10), transparent 34rem), var(--page); } 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); } .skip-link { position: absolute; left: -9999px; } .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,11 +13,11 @@ h1 { font-size: clamp(2.4rem, 4.5vw, 3.75rem); margin: 0 0 1rem; } h2 { margin-top: 0; } p, li { text-wrap: pretty; } - 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); } - 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); } + 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); } + 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 ui-monospace, 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); } textarea:focus { box-shadow: 0 0 0 3px rgba(255, 72, 1, 0.18); } .textarea-fallback[hidden] { display: none; } - .cm-editor { flex: 1; min-height: 18rem; background: transparent; color: var(--text); font: 14px/1.5 Apercu Mono Pro, SF Mono, Fira Code, Consolas, monospace; } + .cm-editor { flex: 1; min-height: 18rem; background: transparent; color: var(--text); font: 14px/1.5 ui-monospace, SF Mono, Fira Code, Consolas, monospace; } .cm-editor.cm-focused { outline: 0; box-shadow: 0 0 0 3px rgba(255, 72, 1, 0.18); } .cm-scroller { font-family: inherit; line-height: 1.5; } .cm-content { padding: 0; } @@ -28,9 +28,11 @@ .cm-selectionBackground, .cm-focused .cm-selectionBackground { background: rgba(255, 72, 1, 0.18) !important; } code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-variant-numeric: tabular-nums; } .syntax-inline { padding: .08rem .25rem; border-radius: .25rem; background: var(--accent-soft); color: var(--text); font-size: .94em; } - .brand { font-weight: 800; } + /* Nav tap targets: block padding grows the hit area to ~43px while + the negative margin cancels the layout growth. */ + .brand { font-weight: 800; padding-block: .55rem; margin-block: -.55rem; } .nav-links { display: flex; gap: .35rem; } - .nav-links a { padding: 0 .9rem; color: var(--muted); } + .nav-links a { padding: .55rem .9rem; margin-block: -.55rem; color: var(--muted); } .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; } .hero h1 { font-size: clamp(2rem, 4vw, 3rem); margin-bottom: var(--space-3); transform-origin: top left; } .hero p { max-width: 60ch; color: var(--muted); font-size: 1rem; } @@ -40,7 +42,13 @@ .hero h1 { animation: hero-h1-morph linear forwards; animation-timeline: scroll(root); animation-range: 0 240px; } .hero p { animation: hero-p-fade linear forwards; animation-timeline: scroll(root); animation-range: 0 140px; } body:has(.hero) { padding-top: var(--space-2); } - 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; } + /* The brand blurs in on scroll, but the header (and its nav) + stays readable from first paint — hiding the primary nav on + landing fails the "where can I go?" test. The emerging veil + lives on a pseudo-element and animates opacity only, which + the compositor can do without repainting every scroll frame. */ + body:has(.hero) header { background: transparent; box-shadow: none; margin-bottom: var(--space-2); } + 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; } 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; } } } @@ -56,10 +64,10 @@ @keyframes brand-focus { to { filter: blur(0); transform: scale(1); } } - @keyframes header-emerge { - to { opacity: 1; background: var(--header-veil-solid); box-shadow: 0 1px 8px rgba(82, 16, 0, 0.06); } + @keyframes header-veil-emerge { + to { opacity: 1; } } - .site-search { position: relative; max-width: 44rem; margin: 0 0 var(--space-5); } + .site-search { position: relative; width: 100%; max-width: none; margin: 0 0 var(--space-5); } .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); } .site-search input:focus { outline: 0; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } .site-search input::placeholder { color: var(--muted); } @@ -74,6 +82,7 @@ .home-section .eyebrow { margin: 0 0 var(--space-2); } .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); } .card:hover { transform: translateY(-2px); background: var(--surface-3); border-color: var(--accent); } + .card:active { transform: translateY(0) scale(0.99); } .card h2 { text-decoration: underline; text-decoration-color: var(--hairline); text-underline-offset: .18em; } .card:hover h2 { text-decoration-color: var(--accent); } .button { min-height: 40px; border: 1px solid var(--accent-action); border-radius: 9999px; padding: .72rem 1rem; background: var(--accent-action); 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); } @@ -109,6 +118,23 @@ .cell-code-stack code { white-space: pre-wrap; } .cell-source pre, .cell-source .shiki-block, .cell-output pre { margin: 0; padding: 0; border-left: 0; border-radius: 0; background: transparent !important; color: var(--text); box-shadow: none; white-space: pre-wrap; overflow-wrap: anywhere; overflow-x: visible; } .cell-source .shiki-block .line { display: inline; } + .cell-source { position: relative; } + /* Icon-in-pill copy affordance. The glyphs are Lucide copy/check/x + drawn as currentColor masks, so every token state (muted, hover, + accent-on-copied, dark mode) flows through with no extra rules. + The ::before overlay expands the tap target to ~44px without + growing the visible pill. */ + .copy-button { position: absolute; top: -0.35rem; right: 0; display: grid; place-items: center; width: 1.9rem; height: 1.65rem; padding: 0; border: 1px solid var(--hairline); border-radius: 9999px; background: var(--surface-2); color: var(--muted); cursor: pointer; transition-property: color, border-color; transition-duration: 150ms; } + .copy-button::before { content: ""; position: absolute; inset: -0.55rem; } + .copy-button:hover, .copy-button:focus-visible { color: var(--text); border-color: var(--accent); } + .copy-button:active { transform: scale(0.96); } + .copy-button.copied { color: var(--accent); border-color: var(--accent); } + .copy-icon { display: block; width: 14px; height: 14px; background: currentColor; -webkit-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='9' y='9' width='13' height='13' rx='2'/%3E%3Cpath d='M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1'/%3E%3C/svg%3E") center/contain no-repeat; mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='9' y='9' width='13' height='13' rx='2'/%3E%3Cpath d='M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1'/%3E%3C/svg%3E") center/contain no-repeat; } + .copy-button.copied .copy-icon { -webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E"); } + .copy-button.failed .copy-icon { -webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M18 6 6 18M6 6l12 12'/%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M18 6 6 18M6 6l12 12'/%3E%3C/svg%3E"); } + .copy-status { position: absolute; left: -9999px; } + @media (prefers-reduced-motion: no-preference) { .copy-button.copied .copy-icon { animation: copy-pop 150ms cubic-bezier(0, 0, 0.2, 1); } } + @keyframes copy-pop { from { transform: scale(0.6); opacity: 0; } } .cell-output { margin-top: var(--space-3); padding: var(--space-3) 0 0; border-top: 1px solid var(--hairline-soft); background: transparent; } .unsupported-cell .cell-code-stack { border-left-style: dashed; } .notebook-notes { margin-top: var(--space-5); } @@ -133,6 +159,9 @@ .tool-button { min-height: 40px; border: 1px solid var(--hairline); border-radius: 9999px; padding: .62rem .9rem; background: var(--surface-2); color: var(--text); cursor: pointer; transition-property: transform, background-color, border-style; transition-duration: 150ms; } .tool-button:hover { background: var(--surface-3); border-color: var(--accent); } .tool-button:active { transform: scale(0.96); } + /* The share action exports the code rather than acting on it, so it + sits apart from the Run/Reset pair at the toolbar's far edge. */ + .share-button { margin-left: auto; } .output-panel { background: var(--surface); } .journey-card ul { margin: var(--space-3) 0 0; padding-left: 1.2rem; color: var(--muted); } .journey-card li { text-wrap: pretty; } @@ -159,6 +188,22 @@ .example-nav { margin-top: var(--space-5); padding-top: var(--space-3); border-top: 1px solid var(--hairline); } footer { margin-block: 2rem; color: var(--muted); } .site-footer-note { font-size: .82rem; text-align: center; } + /* About page: prose sections plus a design-language section that + renders the live tokens. Swatches composite each token over + --surface so translucent tokens (veils, softs) display honestly; + set the token per swatch with style="--swatch: var(--accent)". */ + .about-page { max-width: 820px; margin-inline: auto; } + .about-section { margin-block: var(--space-5); padding-top: var(--space-4); border-top: 1px solid var(--hairline); } + .about-section h2 { margin-bottom: var(--space-2); } + .about-section p:not(.cell-label) { max-width: 68ch; color: var(--muted); } + .about-section > .cell-label { margin-top: var(--space-4); } + .token-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr)); gap: var(--space-2) var(--space-3); margin: 0; padding: 0; list-style: none; } + .token { display: flex; align-items: center; gap: .6rem; } + .token code { font-size: .8rem; color: var(--muted); } + .token-swatch { flex: none; width: 2.1rem; height: 1.4rem; border: 1px solid var(--hairline); border-radius: .35rem; background: linear-gradient(var(--swatch, transparent), var(--swatch, transparent)), var(--surface); } + .token-bar { flex: none; height: .5rem; border: 1px solid var(--hairline); border-radius: 9999px; background: var(--accent-soft); } + .type-specimen p { margin: 0 0 var(--space-2); } + .type-specimen .meta { font-size: .88rem; } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition-duration: 1ms !important; } } /* Cell banner: a figure attached to a cell renders in a banner row AFTER that cell, spanning the full content width. Cells always @@ -200,3 +245,18 @@ .button { color: #FFF7EF; } .shiki, .shiki span { color: var(--shiki-dark) !important; } } + /* Non-motion accessibility signals. Reduced motion is handled by + the transition kill switch and no-preference gates above; these + cover the other two: solid chrome when translucency is reduced, + defined borders and full-contrast prose when contrast is raised. */ + @media (prefers-reduced-transparency: reduce) { + header, body:has(.hero) header { background: var(--page); backdrop-filter: none; box-shadow: 0 1px 0 var(--hairline); } + body:has(.hero) header::before { content: none; } + .search-results { background: var(--surface); } + } + @media (prefers-contrast: more) { + header, body:has(.hero) header { background: var(--page); backdrop-filter: none; box-shadow: 0 1px 0 var(--text); } + body:has(.hero) header::before { content: none; } + .card, .runner-panel, .site-search input, .copy-button, .tool-button { border-color: var(--text); } + .meta, .lp-prose p, .lesson-step p, .nav-links a { color: var(--text); } + } diff --git a/public/site.css b/public/site.css index 3745517..b700b56 100644 --- a/public/site.css +++ b/public/site.css @@ -1,7 +1,7 @@ -:root { color-scheme: light; --accent: #FF4801; --accent-text: #C83800; --accent-action: #C83800; --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; } +:root { color-scheme: light; --accent: #FF4801; --accent-text: #C83800; --accent-action: #C83800; --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; } * { box-sizing: border-box; } html { -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; } - 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); } + body { max-width: 1040px; margin: 0 auto; padding: var(--space-4); color: var(--text); font: 100%/1.6 system-ui, -apple-system, Segoe UI, sans-serif; background: radial-gradient(circle at top left, rgba(255, 72, 1, 0.10), transparent 34rem), var(--page); } 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); } .skip-link { position: absolute; left: -9999px; } .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,11 +13,11 @@ h1 { font-size: clamp(2.4rem, 4.5vw, 3.75rem); margin: 0 0 1rem; } h2 { margin-top: 0; } p, li { text-wrap: pretty; } - 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); } - 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); } + 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); } + 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 ui-monospace, 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); } textarea:focus { box-shadow: 0 0 0 3px rgba(255, 72, 1, 0.18); } .textarea-fallback[hidden] { display: none; } - .cm-editor { flex: 1; min-height: 18rem; background: transparent; color: var(--text); font: 14px/1.5 Apercu Mono Pro, SF Mono, Fira Code, Consolas, monospace; } + .cm-editor { flex: 1; min-height: 18rem; background: transparent; color: var(--text); font: 14px/1.5 ui-monospace, SF Mono, Fira Code, Consolas, monospace; } .cm-editor.cm-focused { outline: 0; box-shadow: 0 0 0 3px rgba(255, 72, 1, 0.18); } .cm-scroller { font-family: inherit; line-height: 1.5; } .cm-content { padding: 0; } @@ -28,9 +28,11 @@ .cm-selectionBackground, .cm-focused .cm-selectionBackground { background: rgba(255, 72, 1, 0.18) !important; } code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-variant-numeric: tabular-nums; } .syntax-inline { padding: .08rem .25rem; border-radius: .25rem; background: var(--accent-soft); color: var(--text); font-size: .94em; } - .brand { font-weight: 800; } + /* Nav tap targets: block padding grows the hit area to ~43px while + the negative margin cancels the layout growth. */ + .brand { font-weight: 800; padding-block: .55rem; margin-block: -.55rem; } .nav-links { display: flex; gap: .35rem; } - .nav-links a { padding: 0 .9rem; color: var(--muted); } + .nav-links a { padding: .55rem .9rem; margin-block: -.55rem; color: var(--muted); } .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; } .hero h1 { font-size: clamp(2rem, 4vw, 3rem); margin-bottom: var(--space-3); transform-origin: top left; } .hero p { max-width: 60ch; color: var(--muted); font-size: 1rem; } @@ -40,7 +42,13 @@ .hero h1 { animation: hero-h1-morph linear forwards; animation-timeline: scroll(root); animation-range: 0 240px; } .hero p { animation: hero-p-fade linear forwards; animation-timeline: scroll(root); animation-range: 0 140px; } body:has(.hero) { padding-top: var(--space-2); } - 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; } + /* The brand blurs in on scroll, but the header (and its nav) + stays readable from first paint — hiding the primary nav on + landing fails the "where can I go?" test. The emerging veil + lives on a pseudo-element and animates opacity only, which + the compositor can do without repainting every scroll frame. */ + body:has(.hero) header { background: transparent; box-shadow: none; margin-bottom: var(--space-2); } + 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; } 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; } } } @@ -56,10 +64,10 @@ @keyframes brand-focus { to { filter: blur(0); transform: scale(1); } } - @keyframes header-emerge { - to { opacity: 1; background: var(--header-veil-solid); box-shadow: 0 1px 8px rgba(82, 16, 0, 0.06); } + @keyframes header-veil-emerge { + to { opacity: 1; } } - .site-search { position: relative; max-width: 44rem; margin: 0 0 var(--space-5); } + .site-search { position: relative; width: 100%; max-width: none; margin: 0 0 var(--space-5); } .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); } .site-search input:focus { outline: 0; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } .site-search input::placeholder { color: var(--muted); } @@ -74,6 +82,7 @@ .home-section .eyebrow { margin: 0 0 var(--space-2); } .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); } .card:hover { transform: translateY(-2px); background: var(--surface-3); border-color: var(--accent); } + .card:active { transform: translateY(0) scale(0.99); } .card h2 { text-decoration: underline; text-decoration-color: var(--hairline); text-underline-offset: .18em; } .card:hover h2 { text-decoration-color: var(--accent); } .button { min-height: 40px; border: 1px solid var(--accent-action); border-radius: 9999px; padding: .72rem 1rem; background: var(--accent-action); 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); } @@ -109,6 +118,23 @@ .cell-code-stack code { white-space: pre-wrap; } .cell-source pre, .cell-source .shiki-block, .cell-output pre { margin: 0; padding: 0; border-left: 0; border-radius: 0; background: transparent !important; color: var(--text); box-shadow: none; white-space: pre-wrap; overflow-wrap: anywhere; overflow-x: visible; } .cell-source .shiki-block .line { display: inline; } + .cell-source { position: relative; } + /* Icon-in-pill copy affordance. The glyphs are Lucide copy/check/x + drawn as currentColor masks, so every token state (muted, hover, + accent-on-copied, dark mode) flows through with no extra rules. + The ::before overlay expands the tap target to ~44px without + growing the visible pill. */ + .copy-button { position: absolute; top: -0.35rem; right: 0; display: grid; place-items: center; width: 1.9rem; height: 1.65rem; padding: 0; border: 1px solid var(--hairline); border-radius: 9999px; background: var(--surface-2); color: var(--muted); cursor: pointer; transition-property: color, border-color; transition-duration: 150ms; } + .copy-button::before { content: ""; position: absolute; inset: -0.55rem; } + .copy-button:hover, .copy-button:focus-visible { color: var(--text); border-color: var(--accent); } + .copy-button:active { transform: scale(0.96); } + .copy-button.copied { color: var(--accent); border-color: var(--accent); } + .copy-icon { display: block; width: 14px; height: 14px; background: currentColor; -webkit-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='9' y='9' width='13' height='13' rx='2'/%3E%3Cpath d='M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1'/%3E%3C/svg%3E") center/contain no-repeat; mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='9' y='9' width='13' height='13' rx='2'/%3E%3Cpath d='M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1'/%3E%3C/svg%3E") center/contain no-repeat; } + .copy-button.copied .copy-icon { -webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E"); } + .copy-button.failed .copy-icon { -webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M18 6 6 18M6 6l12 12'/%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M18 6 6 18M6 6l12 12'/%3E%3C/svg%3E"); } + .copy-status { position: absolute; left: -9999px; } + @media (prefers-reduced-motion: no-preference) { .copy-button.copied .copy-icon { animation: copy-pop 150ms cubic-bezier(0, 0, 0.2, 1); } } + @keyframes copy-pop { from { transform: scale(0.6); opacity: 0; } } .cell-output { margin-top: var(--space-3); padding: var(--space-3) 0 0; border-top: 1px solid var(--hairline-soft); background: transparent; } .unsupported-cell .cell-code-stack { border-left-style: dashed; } .notebook-notes { margin-top: var(--space-5); } @@ -133,6 +159,9 @@ .tool-button { min-height: 40px; border: 1px solid var(--hairline); border-radius: 9999px; padding: .62rem .9rem; background: var(--surface-2); color: var(--text); cursor: pointer; transition-property: transform, background-color, border-style; transition-duration: 150ms; } .tool-button:hover { background: var(--surface-3); border-color: var(--accent); } .tool-button:active { transform: scale(0.96); } + /* The share action exports the code rather than acting on it, so it + sits apart from the Run/Reset pair at the toolbar's far edge. */ + .share-button { margin-left: auto; } .output-panel { background: var(--surface); } .journey-card ul { margin: var(--space-3) 0 0; padding-left: 1.2rem; color: var(--muted); } .journey-card li { text-wrap: pretty; } @@ -159,6 +188,22 @@ .example-nav { margin-top: var(--space-5); padding-top: var(--space-3); border-top: 1px solid var(--hairline); } footer { margin-block: 2rem; color: var(--muted); } .site-footer-note { font-size: .82rem; text-align: center; } + /* About page: prose sections plus a design-language section that + renders the live tokens. Swatches composite each token over + --surface so translucent tokens (veils, softs) display honestly; + set the token per swatch with style="--swatch: var(--accent)". */ + .about-page { max-width: 820px; margin-inline: auto; } + .about-section { margin-block: var(--space-5); padding-top: var(--space-4); border-top: 1px solid var(--hairline); } + .about-section h2 { margin-bottom: var(--space-2); } + .about-section p:not(.cell-label) { max-width: 68ch; color: var(--muted); } + .about-section > .cell-label { margin-top: var(--space-4); } + .token-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr)); gap: var(--space-2) var(--space-3); margin: 0; padding: 0; list-style: none; } + .token { display: flex; align-items: center; gap: .6rem; } + .token code { font-size: .8rem; color: var(--muted); } + .token-swatch { flex: none; width: 2.1rem; height: 1.4rem; border: 1px solid var(--hairline); border-radius: .35rem; background: linear-gradient(var(--swatch, transparent), var(--swatch, transparent)), var(--surface); } + .token-bar { flex: none; height: .5rem; border: 1px solid var(--hairline); border-radius: 9999px; background: var(--accent-soft); } + .type-specimen p { margin: 0 0 var(--space-2); } + .type-specimen .meta { font-size: .88rem; } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition-duration: 1ms !important; } } /* Cell banner: a figure attached to a cell renders in a banner row AFTER that cell, spanning the full content width. Cells always @@ -200,3 +245,18 @@ .button { color: #FFF7EF; } .shiki, .shiki span { color: var(--shiki-dark) !important; } } + /* Non-motion accessibility signals. Reduced motion is handled by + the transition kill switch and no-preference gates above; these + cover the other two: solid chrome when translucency is reduced, + defined borders and full-contrast prose when contrast is raised. */ + @media (prefers-reduced-transparency: reduce) { + header, body:has(.hero) header { background: var(--page); backdrop-filter: none; box-shadow: 0 1px 0 var(--hairline); } + body:has(.hero) header::before { content: none; } + .search-results { background: var(--surface); } + } + @media (prefers-contrast: more) { + header, body:has(.hero) header { background: var(--page); backdrop-filter: none; box-shadow: 0 1px 0 var(--text); } + body:has(.hero) header::before { content: none; } + .card, .runner-panel, .site-search input, .copy-button, .tool-button { border-color: var(--text); } + .meta, .lp-prose p, .lesson-step p, .nav-links a { color: var(--text); } + } diff --git a/public/syntax-highlight.305edd86ffcd.js b/public/syntax-highlight.305edd86ffcd.js deleted file mode 100644 index 0cfc842..0000000 --- a/public/syntax-highlight.305edd86ffcd.js +++ /dev/null @@ -1,23 +0,0 @@ -import { codeToHtml } from 'https://esm.sh/shiki@1.29.2'; - -const blocks = document.querySelectorAll('pre code.language-python'); - -for (const block of blocks) { - const source = block.textContent; - try { - const highlighted = await codeToHtml(source, { - lang: 'python', - themes: { light: 'github-light', dark: 'github-dark' }, - defaultColor: 'light', - }); - const wrapper = document.createElement('div'); - wrapper.innerHTML = highlighted; - const shikiPre = wrapper.firstElementChild; - if (!shikiPre) continue; - shikiPre.classList.add('shiki-block'); - const currentPre = block.closest('pre'); - currentPre.replaceWith(shikiPre); - } catch (error) { - block.dataset.highlightFailed = 'true'; - } -} diff --git a/public/syntax-highlight.d6178fb3eef5.js b/public/syntax-highlight.d6178fb3eef5.js new file mode 100644 index 0000000..aa99729 --- /dev/null +++ b/public/syntax-highlight.d6178fb3eef5.js @@ -0,0 +1,86 @@ +// Copy affordance for read-only source blocks. The button attaches to +// the .cell-source wrapper (not the
) so the Shiki replacement
+// below leaves it in place, and it is injected client-side so no-JS
+// pages render no dead buttons.
+async function copyText(text) {
+  if (navigator.clipboard && window.isSecureContext) {
+    await navigator.clipboard.writeText(text);
+    return;
+  }
+  const holder = document.createElement('textarea');
+  holder.value = text;
+  holder.setAttribute('readonly', '');
+  holder.style.position = 'fixed';
+  holder.style.left = '-9999px';
+  document.body.appendChild(holder);
+  holder.select();
+  const copied = document.execCommand('copy');
+  holder.remove();
+  if (!copied) throw new Error('execCommand copy failed');
+}
+
+for (const source of document.querySelectorAll('.cell-source')) {
+  const button = document.createElement('button');
+  button.type = 'button';
+  button.className = 'copy-button';
+  button.title = 'Copy';
+  button.setAttribute('aria-label', 'Copy this source fragment');
+  button.setAttribute('aria-live', 'polite');
+  const icon = document.createElement('span');
+  icon.className = 'copy-icon';
+  icon.setAttribute('aria-hidden', 'true');
+  const status = document.createElement('span');
+  status.className = 'copy-status';
+  button.append(icon, status);
+  let restore = null;
+  button.addEventListener('click', async () => {
+    const code = source.querySelector('pre code') || source.querySelector('pre');
+    if (!code) return;
+    clearTimeout(restore);
+    button.classList.remove('copied', 'failed');
+    try {
+      await copyText(code.textContent);
+      button.classList.add('copied');
+      status.textContent = 'Copied';
+    } catch (error) {
+      button.classList.add('failed');
+      status.textContent = 'Copy failed';
+    }
+    restore = setTimeout(() => {
+      button.classList.remove('copied', 'failed');
+      status.textContent = '';
+    }, 1600);
+  });
+  source.append(button);
+}
+
+// Shiki is imported dynamically so a CDN failure degrades to plain
+// server-rendered code while the copy buttons above keep working.
+let codeToHtml = null;
+try {
+  ({ codeToHtml } = await import('https://esm.sh/shiki@1.29.2'));
+} catch (error) {
+  codeToHtml = null;
+}
+
+const blocks = codeToHtml ? document.querySelectorAll('pre code.language-python') : [];
+
+for (const block of blocks) {
+  const source = block.textContent;
+  try {
+    const highlighted = await codeToHtml(source, {
+      lang: 'python',
+      themes: { light: 'github-light', dark: 'github-dark' },
+      defaultColor: 'light',
+    });
+    const wrapper = document.createElement('div');
+    wrapper.innerHTML = highlighted;
+    const shikiPre = wrapper.firstElementChild;
+    if (!shikiPre) continue;
+    shikiPre.classList.add('shiki-block');
+    const currentPre = block.closest('pre');
+    currentPre.replaceWith(shikiPre);
+  } catch (error) {
+    block.dataset.highlightFailed = 'true';
+  }
+}
diff --git a/public/syntax-highlight.js b/public/syntax-highlight.js
index 0cfc842..aa99729 100644
--- a/public/syntax-highlight.js
+++ b/public/syntax-highlight.js
@@ -1,6 +1,69 @@
-import { codeToHtml } from 'https://esm.sh/shiki@1.29.2';
+// Copy affordance for read-only source blocks. The button attaches to
+// the .cell-source wrapper (not the 
) so the Shiki replacement
+// below leaves it in place, and it is injected client-side so no-JS
+// pages render no dead buttons.
+async function copyText(text) {
+  if (navigator.clipboard && window.isSecureContext) {
+    await navigator.clipboard.writeText(text);
+    return;
+  }
+  const holder = document.createElement('textarea');
+  holder.value = text;
+  holder.setAttribute('readonly', '');
+  holder.style.position = 'fixed';
+  holder.style.left = '-9999px';
+  document.body.appendChild(holder);
+  holder.select();
+  const copied = document.execCommand('copy');
+  holder.remove();
+  if (!copied) throw new Error('execCommand copy failed');
+}
+
+for (const source of document.querySelectorAll('.cell-source')) {
+  const button = document.createElement('button');
+  button.type = 'button';
+  button.className = 'copy-button';
+  button.title = 'Copy';
+  button.setAttribute('aria-label', 'Copy this source fragment');
+  button.setAttribute('aria-live', 'polite');
+  const icon = document.createElement('span');
+  icon.className = 'copy-icon';
+  icon.setAttribute('aria-hidden', 'true');
+  const status = document.createElement('span');
+  status.className = 'copy-status';
+  button.append(icon, status);
+  let restore = null;
+  button.addEventListener('click', async () => {
+    const code = source.querySelector('pre code') || source.querySelector('pre');
+    if (!code) return;
+    clearTimeout(restore);
+    button.classList.remove('copied', 'failed');
+    try {
+      await copyText(code.textContent);
+      button.classList.add('copied');
+      status.textContent = 'Copied';
+    } catch (error) {
+      button.classList.add('failed');
+      status.textContent = 'Copy failed';
+    }
+    restore = setTimeout(() => {
+      button.classList.remove('copied', 'failed');
+      status.textContent = '';
+    }, 1600);
+  });
+  source.append(button);
+}
+
+// Shiki is imported dynamically so a CDN failure degrades to plain
+// server-rendered code while the copy buttons above keep working.
+let codeToHtml = null;
+try {
+  ({ codeToHtml } = await import('https://esm.sh/shiki@1.29.2'));
+} catch (error) {
+  codeToHtml = null;
+}
 
-const blocks = document.querySelectorAll('pre code.language-python');
+const blocks = codeToHtml ? document.querySelectorAll('pre code.language-python') : [];
 
 for (const block of blocks) {
   const source = block.textContent;
diff --git a/scripts/build_social_cards.py b/scripts/build_social_cards.py
index 39f13ad..a89ff8e 100644
--- a/scripts/build_social_cards.py
+++ b/scripts/build_social_cards.py
@@ -85,8 +85,22 @@ def render_home_card_html() -> str:
     return _card_shell(_card_body(eyebrow="Learn Python by running it", title="Python By Example", summary=f"{len(list_examples())} concise, editable Python {PYTHON_VERSION} examples with verified output."))
 
 
+def render_about_social_card_html() -> str:
+    return _card_shell(
+        _card_body(
+            eyebrow="Python By Example · About",
+            title="How this site is made",
+            summary="Verified examples, an isolated runner, a locked figure grammar, and explicit quality gates.",
+        )
+    )
+
+
 def card_html() -> dict[str, str]:
-    cards = {"home": render_home_card_html(), "journeys": render_journeys_index_social_card_html()}
+    cards = {
+        "about": render_about_social_card_html(),
+        "home": render_home_card_html(),
+        "journeys": render_journeys_index_social_card_html(),
+    }
     cards.update({example["slug"]: render_social_card_html(example) for example in list_examples()})
     cards.update({f"journey-{journey['slug']}": render_journey_social_card_html(journey) for journey in JOURNEYS})
     return cards
diff --git a/scripts/check_browser_layout.mjs b/scripts/check_browser_layout.mjs
index 9617e90..8286ee5 100755
--- a/scripts/check_browser_layout.mjs
+++ b/scripts/check_browser_layout.mjs
@@ -7,7 +7,7 @@ import path from 'node:path';
 const chromePath = process.env.CHROME_PATH || (process.platform === 'darwin'
   ? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
   : '/usr/bin/google-chrome');
-const target = process.argv[2] || 'http://127.0.0.1:9696/layout-options/mobile-run-first';
+const target = process.argv[2] || 'http://127.0.0.1:9696/examples/values';
 const url = `${target}${target.includes('?') ? '&' : '?'}browser_layout_check=${Date.now()}`;
 const port = 9333 + Math.floor(Math.random() * 1000);
 const profile = await mkdtemp(path.join(tmpdir(), 'pythonbyexample-chrome-'));
@@ -45,6 +45,7 @@ let nextId = 1;
 function connect(wsUrl) {
   const socket = new WebSocket(wsUrl);
   const pending = new Map();
+  const listeners = new Map();
   socket.addEventListener('message', event => {
     const message = JSON.parse(event.data);
     if (message.id && pending.has(message.id)) {
@@ -52,7 +53,9 @@ function connect(wsUrl) {
       pending.delete(message.id);
       if (message.error) reject(new Error(JSON.stringify(message.error)));
       else resolve(message.result);
+      return;
     }
+    for (const listener of listeners.get(message.method) || []) listener(message.params);
   });
   const opened = new Promise((resolve, reject) => {
     socket.addEventListener('open', resolve, { once: true });
@@ -65,6 +68,12 @@ function connect(wsUrl) {
       socket.send(JSON.stringify({ id, method, params }));
       return new Promise((resolve, reject) => pending.set(id, { resolve, reject }));
     },
+    on(method, listener) {
+      const handlers = listeners.get(method) || new Set();
+      handlers.add(listener);
+      listeners.set(method, handlers);
+      return () => handlers.delete(listener);
+    },
     close() { socket.close(); },
   };
 }
@@ -89,7 +98,7 @@ try {
   let pageReady = false;
   for (let i = 0; i < 300; i++) {
     const ready = await client.send('Runtime.evaluate', {
-      expression: `location.href === ${JSON.stringify(url)} && document.readyState === 'complete' && !!document.querySelector('.lesson-step code') && !!window.pythonByExampleEditor && !!document.querySelector('.cm-content')`,
+      expression: `location.href === ${JSON.stringify(url)} && document.readyState === 'complete' && !!document.querySelector('.lesson-step code') && !!window.pythonByExampleEditor && !!document.querySelector('.cm-content') && !!document.querySelector('.copy-button') && !!document.querySelector('.share-button')`,
       returnByValue: true,
     });
     if (ready.result.value) {
@@ -159,6 +168,122 @@ try {
     returnByValue: true,
   });
 
+  async function evaluateValue(expression) {
+    const result = await client.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
+    if (result.exceptionDetails) throw new Error(`Browser evaluation failed: ${result.exceptionDetails.text}`);
+    return result.result.value;
+  }
+
+  async function waitFor(expression, label, attempts = 300) {
+    for (let i = 0; i < attempts; i++) {
+      if (await evaluateValue(expression)) return;
+      await sleep(100);
+    }
+    throw new Error(`Timed out waiting for ${label}`);
+  }
+
+  // Exercise the public browser contracts rather than merely asserting source
+  // strings: copy text, fragment sharing/restoration, resize, and arrow guards.
+  await client.send('Page.navigate', { url: target });
+  await waitFor("document.readyState === 'complete' && !!window.pythonByExampleEditor && !!document.querySelector('.copy-button') && !!document.querySelector('.share-button')", 'feature test page');
+  const share = await evaluateValue(`(async () => {
+    const writes = [];
+    window.__pbeClipboardWrites = writes;
+    try {
+      Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async text => writes.push(text) } });
+    } catch (_) {}
+    document.execCommand = command => {
+      if (command !== 'copy') return false;
+      writes.push(document.activeElement?.value || '');
+      return true;
+    };
+    const code = Array.from({ length: 80 }, (_, index) => 'print("π ' + index + '")').join('\\n');
+    window.pythonByExampleEditor.setValue(code);
+    await new Promise(resolve => requestAnimationFrame(resolve));
+    document.querySelector('.share-button').click();
+    await new Promise(resolve => setTimeout(resolve, 50));
+    return { code, url: writes.at(-1) || '', editorHeight: document.querySelector('.cm-editor').getBoundingClientRect().height };
+  })()`);
+
+  const sharedReloadUrl = new URL(share.url);
+  sharedReloadUrl.searchParams.set('browser_share_test', Date.now());
+  await client.send('Page.navigate', { url: sharedReloadUrl.href });
+  await waitFor(`document.readyState === 'complete' && !!window.pythonByExampleEditor && document.getElementById('code-editor')?.value === ${JSON.stringify(share.code)}`, 'shared-code restoration');
+  const restored = await evaluateValue(`({
+    editorHeight: document.querySelector('.cm-editor').getBoundingClientRect().height,
+    notice: document.querySelector('.output-panel code')?.textContent,
+  })`);
+  const sourceCopy = await evaluateValue(`(async () => {
+    const writes = window.__pbeClipboardWrites = [];
+    try {
+      Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async text => writes.push(text) } });
+    } catch (_) {}
+    document.execCommand = command => {
+      if (command !== 'copy') return false;
+      writes.push(document.activeElement?.value || '');
+      return true;
+    };
+    const source = document.querySelector('.cell-source');
+    const expected = source.querySelector('pre').textContent;
+    const button = source.querySelector('.copy-button');
+    button.click();
+    await new Promise(resolve => setTimeout(resolve, 50));
+    return { expected, copied: writes.at(-1) || '', state: button.className, status: button.querySelector('.copy-status')?.textContent };
+  })()`);
+  const arrow = await evaluateValue(`(async () => {
+    const textarea = document.getElementById('code-editor');
+    const modifiedPath = location.pathname;
+    document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
+    await new Promise(resolve => setTimeout(resolve, 50));
+    const blockedModified = location.pathname === modifiedPath;
+    window.pythonByExampleEditor.setValue(textarea.dataset.originalCode || textarea.defaultValue);
+    const next = document.querySelector('.example-nav a[rel="next"]')?.href || '';
+    document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
+    return { blockedModified, next };
+  })()`);
+  await waitFor(`location.href === ${JSON.stringify(arrow.next)} && !!window.pythonByExampleEditor && !!document.querySelector('.cm-content')`, 'clean arrow navigation');
+  const editableBlocked = await evaluateValue(`(async () => {
+    const path = location.pathname;
+    document.querySelector('.cm-content').dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
+    await new Promise(resolve => setTimeout(resolve, 50));
+    return location.pathname === path;
+  })()`);
+
+  async function searchWidthAt(label, width, height, mobile) {
+    await client.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile });
+    await client.send('Page.navigate', { url: `${new URL(target).origin}/?browser_search_width=${label}` });
+    await waitFor("document.readyState === 'complete' && !!document.querySelector('.site-search')", `${label} search page`);
+    return evaluateValue(`(() => {
+      const search = document.querySelector('.site-search').getBoundingClientRect();
+      const main = document.querySelector('main').getBoundingClientRect();
+      const input = document.querySelector('.site-search input').getBoundingClientRect();
+      return { label: ${JSON.stringify(label)}, searchWidth: search.width, mainWidth: main.width, inputWidth: input.width };
+    })()`);
+  }
+
+  const searchWidths = [];
+  searchWidths.push(await searchWidthAt('desktop', 1200, 900, false));
+  searchWidths.push(await searchWidthAt('mobile-portrait', 390, 844, true));
+  searchWidths.push(await searchWidthAt('mobile-landscape', 844, 390, true));
+  await client.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 1800, deviceScaleFactor: 2, mobile: true });
+
+  // Keep esm.sh import requests pending. The runner has no imports and is
+  // emitted after its markup, so its controls must attach before the editor's
+  // CDN graph can let DOMContentLoaded fire.
+  let heldCdnRequests = 0;
+  const removePauseListener = client.on('Fetch.requestPaused', () => { heldCdnRequests += 1; });
+  await client.send('Fetch.enable', { patterns: [{ urlPattern: 'https://esm.sh/*' }] });
+  const offlineCode = Array.from({ length: 80 }, (_, index) => `print(${index})`).join('\n');
+  const offlineUrl = `${target}#code=${Buffer.from(offlineCode, 'utf8').toString('base64')}`;
+  await client.send('Page.navigate', { url: offlineUrl });
+  await waitFor(`!!document.querySelector('.share-button') && !!document.querySelector('.copy-button') && document.getElementById('code-editor')?.value === ${JSON.stringify(offlineCode)}`, 'runner controls while CDN is pending');
+  const offlineRunner = await evaluateValue(`({
+    editorLoaded: !!window.pythonByExampleEditor,
+    fallbackHeight: document.getElementById('code-editor').getBoundingClientRect().height,
+  })`);
+  removePauseListener();
+  await client.send('Fetch.disable');
+
   const failures = [];
   if (interaction.result.value?.ariaLabel !== 'Editable Python example code') {
     failures.push('CodeMirror editor is missing its accessible name');
@@ -170,8 +295,34 @@ try {
     if (block.ratio > 1.25) failures.push(`block ${block.index} visual line height ratio ${block.ratio.toFixed(2)} > 1.25`);
   }
   if (!metrics.loadedShiki) failures.push('Shiki did not load');
+  if (!share.url.includes('#code=')) failures.push('Copy link did not copy a shared-code fragment');
+  if (restored.notice !== 'This link included edited code. Press Run to execute it.') failures.push('Shared-code recipient notice did not render');
+  if (restored.editorHeight < 1000) failures.push(`Shared CodeMirror payload did not resize the editor (${restored.editorHeight}px)`);
+  if (sourceCopy.copied !== sourceCopy.expected || !sourceCopy.state.includes('copied') || sourceCopy.status !== 'Copied') {
+    failures.push('Source copy button did not copy its exact source or report success');
+  }
+  if (!arrow.blockedModified) failures.push('Arrow navigation discarded edited code');
+  if (!arrow.next) failures.push('Clean example has no next navigation target');
+  if (!editableBlocked) failures.push('Arrow navigation fired from the editable CodeMirror surface');
+  for (const measurement of searchWidths) {
+    if (Math.abs(measurement.searchWidth - measurement.mainWidth) > 1 || Math.abs(measurement.inputWidth - measurement.searchWidth) > 1) {
+      failures.push(`${measurement.label} search is not full width (${measurement.searchWidth}px search, ${measurement.mainWidth}px main, ${measurement.inputWidth}px input)`);
+    }
+  }
+  if (heldCdnRequests === 0) failures.push('CDN-pending runner test did not hold an esm.sh request');
+  if (offlineRunner.editorLoaded) failures.push('CDN-pending test unexpectedly loaded CodeMirror');
+  if (offlineRunner.fallbackHeight < 1000) failures.push(`Shared fallback textarea did not resize (${offlineRunner.fallbackHeight}px)`);
 
-  console.log(JSON.stringify({ runnerInteraction: interaction.result.value }, null, 2));
+  console.log(JSON.stringify({
+    runnerInteraction: interaction.result.value,
+    share: { codeLength: share.code.length, urlLength: share.url.length, editorHeight: share.editorHeight },
+    restored,
+    sourceCopy: { copiedLength: sourceCopy.copied.length, state: sourceCopy.state, status: sourceCopy.status },
+    arrow,
+    searchWidths,
+    offlineRunner,
+    heldCdnRequests,
+  }, null, 2));
   client.close();
   if (failures.length) {
     console.error(`Browser layout check failed: ${failures.join('; ')}`);
diff --git a/scripts/lint_seo_cache.py b/scripts/lint_seo_cache.py
index 3d872c5..e941524 100755
--- a/scripts/lint_seo_cache.py
+++ b/scripts/lint_seo_cache.py
@@ -11,7 +11,7 @@
 sys.path.insert(0, str(ROOT))
 
 from scripts.fingerprint_assets import ASSETS, PUBLIC, html_version  # noqa: E402
-from src.app import JOURNEYS, SITE_URL, list_examples, render_example_page, render_home, render_journeys_index, render_journey_page, render_sitemap  # noqa: E402
+from src.app import JOURNEYS, SITE_URL, list_examples, render_about, render_example_page, render_home, render_journeys_index, render_journey_page, render_sitemap  # noqa: E402
 from src.asset_manifest import ASSET_PATHS, HTML_CACHE_VERSION  # noqa: E402
 
 META_DESCRIPTION_RE = re.compile(r'')
@@ -77,6 +77,8 @@ def assert_sitemap(failures: list[str]) -> None:
         url = f"{SITE_URL}/examples/{example['slug']}"
         if f"{url}" not in sitemap:
             fail(f"sitemap: missing {url}", failures)
+    if f"{SITE_URL}/about" not in sitemap:
+        fail("sitemap: missing about URL", failures)
     if f"{SITE_URL}/journeys" not in sitemap:
         fail("sitemap: missing journeys index URL", failures)
     for journey in JOURNEYS:
@@ -127,6 +129,7 @@ def main() -> int:
     assert_worker_cache_policy(failures)
     assert_sitemap(failures)
     assert_page_metadata("home", render_home(), "/", failures)
+    assert_page_metadata("about", render_about(), "/about", failures)
     assert_page_metadata("journeys-index", render_journeys_index(), "/journeys", failures)
     for example in list_examples():
         assert_page_metadata(
@@ -146,7 +149,7 @@ def main() -> int:
         for failure in failures:
             print(f"FAIL: {failure}", file=sys.stderr)
         return 1
-    print(f"SEO/cache lint passed for 1 home page, 1 journey index, {len(JOURNEYS)} journeys, and {len(list_examples())} example pages.")
+    print(f"SEO/cache lint passed for 1 home page, 1 about page, 1 journey index, {len(JOURNEYS)} journeys, and {len(list_examples())} example pages.")
     return 0
 
 
diff --git a/scripts/smoke_deployment.py b/scripts/smoke_deployment.py
index 98b24d2..7dca4ba 100755
--- a/scripts/smoke_deployment.py
+++ b/scripts/smoke_deployment.py
@@ -18,6 +18,7 @@
 
 SMOKE_PATHS = [
     "/",
+    "/about",
     "/examples/values",
     "/examples/async-await",
     "/examples/networking",
diff --git a/src/app.py b/src/app.py
index 4512822..93059b9 100644
--- a/src/app.py
+++ b/src/app.py
@@ -201,14 +201,12 @@ def _layout(title: str, content: str, description: str | None = None, path: str
     description = _meta_description(description or "Learn Python with concise, editable examples that run in isolated Cloudflare Dynamic Python Workers.")
     canonical_url = f"{SITE_URL}{path}"
     page_title = title if title == "Python By Example" else f"{title} · Python By Example"
-    editor_scripts = (
-        "".join(
-            f''
-            for name in ("EDITOR_JS", "RUNNER_JS")
-        )
-        if include_editor
-        else ""
-    )
+    # The CDN-backed editor stays in the head, but the dependency-free runner
+    # is emitted after page content. Its async module can then attach Run,
+    # Reset, share, and navigation without waiting for the editor's esm.sh
+    # graph or for DOMContentLoaded (which that graph delays).
+    editor_scripts = f'' if include_editor else ""
+    runner_script = f'' if include_editor else ""
     if include_search:
         editor_scripts += f''
     return _replace(
@@ -226,6 +224,7 @@ def _layout(title: str, content: str, description: str | None = None, path: str
             "SITE_CSS": html.escape(ASSET_PATHS["SITE_CSS"]),
             "SYNTAX_JS": html.escape(ASSET_PATHS["SYNTAX_JS"]),
             "EDITOR_SCRIPTS": editor_scripts,
+            "RUNNER_SCRIPT": runner_script,
             "CONTENT": content,
         },
     )
@@ -378,8 +377,34 @@ def render_journey_page(journey):
     )
 
 
+def render_about() -> str:
+    content = _replace(
+        _template("about.html"),
+        {
+            "EXAMPLE_COUNT": str(len(list_examples())),
+            "JOURNEY_COUNT": str(len(JOURNEYS)),
+            "PYTHON_VERSION": html.escape(PYTHON_VERSION),
+        },
+    )
+    return _layout(
+        "About",
+        content,
+        description=f"How Python By Example is made: verified output for every example, an isolated Python {PYTHON_VERSION} runner, a locked figure grammar, and the design tokens behind every page.",
+        path="/about",
+        og_image=f"{SITE_URL}/og/about.jpg",
+        structured_data={
+            "@context": "https://schema.org",
+            "@type": "AboutPage",
+            "name": "About Python By Example",
+            "url": f"{SITE_URL}/about",
+            "description": f"How Python By Example teaches Python {PYTHON_VERSION}: verified example output, sandboxed execution, a locked figure grammar, and explicit quality gates.",
+            "inLanguage": "en",
+        },
+    )
+
+
 def render_sitemap() -> str:
-    paths = ["/", "/journeys"]
+    paths = ["/", "/about", "/journeys"]
     paths.extend(f'/journeys/{journey["slug"]}' for journey in JOURNEYS)
     paths.extend(f'/examples/{example["slug"]}' for example in list_examples())
     entries = "".join(f"{html.escape(SITE_URL + path)}" for path in paths)
@@ -593,12 +618,12 @@ def render_example_page(
     editable_code = example["code"] if code is None else code
     previous_example, next_example = _example_neighbors(example["slug"])
     previous_link = (
-        f''
+        f''
         if previous_example
         else ""
     )
     next_link = (
-        f''
+        f''
         if next_example
         else ""
     )
@@ -686,6 +711,8 @@ def route(url: str, method: str = "GET", turnstile_site_key: str | None = None)
             render_cell_output_flow_option(get_example("values")),
             headers={"Content-Type": "text/html; charset=utf-8"},
         )
+    if method == "GET" and path == "/about":
+        return AppResponse(render_about(), headers={"Content-Type": "text/html; charset=utf-8"})
     if method == "GET" and path == "/journeys":
         return AppResponse(render_journeys_index(), headers={"Content-Type": "text/html; charset=utf-8"})
     if method == "GET" and path.startswith("/journeys/"):
diff --git a/src/asset_manifest.py b/src/asset_manifest.py
index 8f63169..18c2f38 100644
--- a/src/asset_manifest.py
+++ b/src/asset_manifest.py
@@ -1,3 +1,3 @@
 # Generated by scripts/fingerprint_assets.py. Do not edit by hand.
-ASSET_PATHS = {'SITE_CSS': '/site.400d69f4ef8b.css', 'SYNTAX_JS': '/syntax-highlight.305edd86ffcd.js', 'EDITOR_JS': '/editor.32983031ee9a.js', 'RUNNER_JS': '/runner.eddb185f7433.js', 'SEARCH_JS': '/search.ab0effeac6ce.js', 'SEARCH_INDEX': '/search-index.332712b2e502.json'}
-HTML_CACHE_VERSION = '12f2e5d32f16'
+ASSET_PATHS = {'SITE_CSS': '/site.4ad21a6ce8a3.css', 'SYNTAX_JS': '/syntax-highlight.d6178fb3eef5.js', 'EDITOR_JS': '/editor.a550bf2266bd.js', 'RUNNER_JS': '/runner.d3515b57bc3e.js', 'SEARCH_JS': '/search.158bd7afa114.js', 'SEARCH_INDEX': '/search-index.332712b2e502.json'}
+HTML_CACHE_VERSION = '9ae21c5f386e'
diff --git a/src/main.py b/src/main.py
index fa8f3c3..09724b9 100644
--- a/src/main.py
+++ b/src/main.py
@@ -16,6 +16,7 @@
     JOURNEYS_BY_SLUG,
     build_dynamic_worker_code,
     get_example,
+    render_about,
     render_cell_output_flow_option,
     render_example_not_found,
     render_example_page,
@@ -238,6 +239,11 @@ async def cell_output_flow_option():
     return _html(render_cell_output_flow_option(get_example("values")))
 
 
+@app.get("/about", response_class=HTMLResponse)
+async def about_page():
+    return _html(render_about())
+
+
 @app.get("/journeys", response_class=HTMLResponse)
 async def journeys_index():
     return _html(render_journeys_index())
@@ -501,6 +507,8 @@ async def not_found(path: str, request: Request):
             render_sitemap(),
             headers={"Content-Type": "application/xml; charset=utf-8"},
         )
+    if path == "about":
+        return _html(render_about())
     if path == "journeys":
         return _html(render_journeys_index())
     if path.startswith("journeys/"):
diff --git a/src/templates/about.html b/src/templates/about.html
new file mode 100644
index 0000000..a007c4f
--- /dev/null
+++ b/src/templates/about.html
@@ -0,0 +1,75 @@
+
+ +
+

About

+

How this site is made

+

__EXAMPLE_COUNT__ examples and __JOURNEY_COUNT__ journeys, all on Python __PYTHON_VERSION__. Those counts are computed from the catalog when this page renders, not typed into this sentence.

+
+
+

One concept per page

+

Python By Example teaches Python through small literate programs. Each page holds one concept. Prose sits beside the source it explains, and each source fragment is followed by the output it produced. Links point at the official Python __PYTHON_VERSION__ documentation instead of restating it. __JOURNEY_COUNT__ journeys arrange the same examples into longer arcs, each section with named learner outcomes.

+
+
+

Every page runs

+

The complete program at the bottom of each example is editable. Run sends it to an isolated Cloudflare Dynamic Worker with outbound network disabled and a one-second CPU budget. The output panel shows the real stdout of the code you ran, and the measured execution time below it.

+
+
+

Output is verified, not promised

+

The expected output beside each fragment is not written by hand. A build step executes every teaching cell and every complete program, then fails if the recorded output differs from what actually printed. Cells the sandbox cannot execute are labelled as standard Python instead of pretending to run.

+
+
+

The figures

+

The small diagrams are composed from a locked drawing grammar: one palette, fixed stroke weights, a short list of shapes, and no escape hatch for raw SVG. Geometry tests check every figure for clipping and collisions before it ships. Some pages have no figure on purpose; each omission carries a recorded reason.

+
+
+

The quality gates

+

A written rubric scores every example, with a target of 9.0 and a hard minimum of 8.5. A page below target must name the work that remains; Hello World carries the only standing waiver, because first programs are traditionally tiny. Separate checks cover confusable pairs (iterator vs iterable, is vs ==), documented footguns, journey outcomes, and the links between examples.

+
+
+

The design language

+

Every page draws from the tokens below, and this section reads them from the same stylesheet it documents. In dark mode the warm palette inverts; --figure-paper stays light, so figures sit on a paper chip instead of being recoloured, and the terminal tokens keep the runner's output chip dark in both schemes.

+

Color

+
    +
  • --accent
  • +
  • --accent-text
  • +
  • --accent-action
  • +
  • --accent-hover
  • +
  • --accent-soft
  • +
  • --text
  • +
  • --muted
  • +
  • --page
  • +
  • --surface
  • +
  • --surface-2
  • +
  • --surface-3
  • +
  • --hairline
  • +
  • --hairline-soft
  • +
  • --header-veil
  • +
  • --header-veil-0
  • +
  • --header-veil-solid
  • +
  • --figure-paper
  • +
  • --terminal-bg
  • +
  • --terminal-ink
  • +
+

Spacing

+
    +
  • --space-1
  • +
  • --space-2
  • +
  • --space-3
  • +
  • --space-4
  • +
  • --space-5
  • +
  • --space-6
  • +
+

Type

+
+

Names bind to objects; objects carry the type. Prose sets 100%/1.6 in your platform's system face, respecting your browser's text-size setting; code sets your platform's monospace.

+
+

The rail

+

Source

value = 21 * 2
+print(f"value = {value}")

Output

value = 42
+

The block above is not a screenshot. It is the same markup every walkthrough cell uses: monospace source on the accent rail, output under a hairline, and a copy button added when JavaScript is available.

+
+
+

Lineage and stack

+

The format follows Go by Example's example-per-page teaching and Rust By Example's expectation that examples run. Literate programming supplied the premise that prose and source should explain each other; marimo's source/output adjacency shaped the cells. The site runs on Cloudflare Python Workers with FastAPI; Shiki highlights read-only code and CodeMirror powers the editor. The source is public under the MIT license at github.com/adewale/pythonbyexample.

+
+
diff --git a/src/templates/home.html b/src/templates/home.html index 7a0d782..b0d1cd8 100644 --- a/src/templates/home.html +++ b/src/templates/home.html @@ -4,7 +4,7 @@

Python By Example

__CARDS__ diff --git a/src/templates/layout.html b/src/templates/layout.html index 2dcb759..d9abf85 100644 --- a/src/templates/layout.html +++ b/src/templates/layout.html @@ -22,9 +22,10 @@
- +
__CONTENT__
+ __RUNNER_SCRIPT__ diff --git a/tests/test_app.py b/tests/test_app.py index e32e620..069a3d7 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -288,7 +288,7 @@ def test_example_page_contains_code_docs_and_run_form(self): self.assertIn("Executed in 12.3 ms", render_example_page(get_example("hello-world"), output="hello world\n", execution_time_ms=12.3)) self.assertIn("Execution time appears here after you run the example.", render_example_page(get_example("hello-world"))) self.assertIn("Expected output", render_example_page(get_example("hello-world"))) - self.assertIn('rel="next" href="/examples/values"', html) + self.assertIn('rel="next" title="Next example (right arrow key)" href="/examples/values"', html) self.assertIn('method="post"', html) self.assertIn('