Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
name: Preview viz
name: Preview

on:
push:
branches:
- claude/tuftean-marginalia-viz-TB0fw
workflow_dispatch:
inputs:
name:
description: Preview Worker name prefix
required: false
default: preview
type: string

permissions:
contents: read

concurrency:
group: preview-viz
group: preview-${{ inputs.name || 'preview' }}
cancel-in-progress: true

jobs:
Expand Down Expand Up @@ -45,11 +48,11 @@ jobs:
run: |
set -x
uv run pywrangler preview \
--name viz \
--name "${{ inputs.name || 'preview' }}" \
--message "${{ github.sha }}" \
--json
- name: Smoke test deployed Preview
run: scripts/smoke_deployment.py https://viz-pythonbyexample.adewale-883.workers.dev
run: scripts/smoke_deployment.py "https://${{ inputs.name || 'preview' }}-pythonbyexample.adewale-883.workers.dev"
- name: Dump wrangler logs on failure
if: failure()
run: |
Expand Down
43 changes: 43 additions & 0 deletions .github/workflows/regenerate-generated-files.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: Regenerate generated files

# Merges made through the GitHub UI bypass the local git hooks that keep
# src/asset_manifest.py and friends in sync, so a merge to main can land with
# stale generated files (and a red Verify run). This workflow regenerates them
# and pushes a fix-up commit. Pushes made with GITHUB_TOKEN do not trigger
# other workflows, so the fix-up commit is not re-verified by CI; it only ever
# contains deterministic `make build` output.

on:
push:
branches:
- main

permissions:
contents: write

concurrency:
group: regenerate-generated-files
cancel-in-progress: false

jobs:
regenerate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Regenerate embedded examples and fingerprinted assets
run: make build
- name: Commit and push regenerated files if they drifted
run: |
git add -A src/example_sources_data.py src/asset_manifest.py public
if git diff --cached --quiet; then
echo "Generated files are current."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -m "Regenerate fingerprinted assets"
git pull --rebase origin main
git push origin main
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,19 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/en/1.1.0

## Unreleased

## 2026-05-16

### Added

- Production custom domain `www.pythonbyexample.dev` (with `workers.dev` as fallback).
- Learning journeys: curated multi-example arcs with per-section learner outcomes and a journeys index.
- Marginalia figure system: a locked SVG diagram grammar (`src/marginalia_grammar.py`), curator-owned example-cell figures and journey section figures (`src/marginalia.py`), and gestalt review pages under `/prototyping/*`.
- Figure geometry contract tests covering clipping, collision, palette, caption uniqueness, and anchor resolution for every registered figure.
- Quality registries (`docs/quality-registries.toml`) with criterion-level scoring, confusable-pair checks, footgun coverage, no-figure rationales, journey outcome contracts, and enforced quality-score gates.
- Optional Turnstile protection for edited-code runs: secret-gated, session-scoped clearance cookie, Invisible-mode widget loaded only when the server requires a challenge, and a smoke-test bypass header.
- Structured wide-event observability for the Worker, with smoke checks asserting the custom event payload.
- Deployment smoke script (`scripts/smoke_deployment.py`) checking rendered pages and representative Dynamic Worker POST runs.
- Footer link to the GitHub repository.
- MIT license.
- SEO metadata for the home page and all example pages.
- SEO/cache linter for future page additions.
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
.PHONY: test embed-examples build check-generated fingerprint browser-layout-test seo-cache-lint verify-examples check-registry-integrity check-confusable-pairs check-broad-surface-tours check-footgun-coverage check-notes-supported score-example-criteria check-quality-scores check-no-figure-rationales check-journey-outcomes quality-checks rubric-audit format-examples verify-python-version verify smoke-deployment dev deploy lint

test:
python3 -m unittest discover -s tests -v
uv run --python 3.13 python -m unittest discover -s tests -v

embed-examples:
scripts/embed_example_sources.py
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Production: <https://www.pythonbyexample.dev> (`workers.dev` remains enabled as

## Features

- 104 curated Python 3.13 examples in learning order
- 109 curated Python 3.13 examples in learning order
- Literate source/output cells for each example walkthrough
- Editable complete examples powered by CodeMirror
- Read-only syntax highlighting powered by Shiki
Expand Down Expand Up @@ -60,9 +60,11 @@ This project is developed with red-green-refactor TDD:
Install dependencies with `uv`, then run:

```bash
python3 -m unittest discover -s tests -v
make test
```

The test suite (and the example loader it imports) requires Python 3.13; `make test` runs it through `uv run --python 3.13` so a system `python3` on another version still works.

After cloning, install the local git hooks once so merges and rebases regenerate `src/asset_manifest.py` instead of producing conflicts:

```bash
Expand Down
11 changes: 9 additions & 2 deletions docs/example-figure-rubric.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,15 @@ the figure can merge.
- **Stroke-weight discipline.** Only `W_HAIRLINE`, `W_STROKE`,
`W_EMPHASIS`, `W_GHOST`. *Contract 5c.*
- **Emphasis scarcity, enforced.** At most ONE accent mark
(`EMPHASIS`-coloured arrowhead, caret, dot, or rect stroke) per
figure. Was a soft v1 criterion; now hard. *Contract 9.*
(`EMPHASIS`-coloured arrowhead, caret, dot, traced path, or rect
stroke) per figure. Was a soft v1 criterion; now hard. The census
is semantic, counted by the grammar itself (`Canvas.accent_count`),
because an arrow's shaft and a standalone gate line are
indistinguishable in raw SVG. Two codified carve-outs: all gates in
a figure collectively count as ONE accent (repeated structural
punctuation — every pause point on a ribbon — reads as one system),
and a `lanes` traced path plus its terminal dot count as one mark.
A gate set plus any focal accent still fails. *Contract 9.*
- **Banner-fit, enforced.** Every figure's intrinsic width
(Canvas.w + 2 · PAD_X) must fit `.cell-banner--1`'s 440px max
ceiling. *Contract 8.*
Expand Down
2 changes: 1 addition & 1 deletion src/asset_manifest.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Generated by scripts/fingerprint_assets.py. Do not edit by hand.
ASSET_PATHS = {'SITE_CSS': '/site.e87d4baf77e6.css', 'SYNTAX_JS': '/syntax-highlight.3b6c7f730d46.js', 'EDITOR_JS': '/editor.a4a7766e1b9b.js'}
HTML_CACHE_VERSION = '35cf77d48ca5'
HTML_CACHE_VERSION = 'c09a7489d1e1'
57 changes: 11 additions & 46 deletions src/marginalia.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,36 +34,17 @@

def aliasing_mutation(c: Canvas) -> None:
"""Two names binding to one mutable list, before and after a mutation."""
c.tag(0, 12, "before")
c.name_box(0, 18, "first")
c.name_box(0, 48, "second")
c.closed_arrow(60, 30, 86, 46, emphasis=False)
c.closed_arrow(60, 60, 86, 46, emphasis=False)
c.object_box(88, 32, "", '["python"]', w=88, h=28)

c.tag(0, 100, "after append")
c.name_box(0, 108, "first")
c.name_box(0, 138, "second")
c.closed_arrow(60, 120, 86, 136, emphasis=False)
c.closed_arrow(60, 150, 86, 136, emphasis=False)
c.object_box(88, 122, "", '["python","workers"]', w=130, h=28)
c.two_names_one_object(0, 18, "before", "first", "second", '["python"]', object_w=88)
c.two_names_one_object(0, 108, "after append", "first", "second",
'["python","workers"]', object_w=130)


def tuple_no_mutation(c: Canvas) -> None:
"""The contrast: two names binding to one immutable tuple — no mutation possible."""
c.tag(0, 12, "tuple — frozen")
c.name_box(0, 18, "first")
c.name_box(0, 48, "second")
c.closed_arrow(60, 30, 86, 46, emphasis=False)
c.closed_arrow(60, 60, 86, 46, emphasis=False)
c.object_box(88, 32, "", '("python",)', w=110, h=28)

c.tag(0, 100, "no .append")
c.name_box(0, 108, "first")
c.name_box(0, 138, "second")
c.closed_arrow(60, 120, 86, 136, emphasis=False)
c.closed_arrow(60, 150, 86, 136, emphasis=False)
c.object_box(88, 122, "", '("python",)', w=110, h=28)
c.two_names_one_object(0, 18, "tuple — frozen", "first", "second",
'("python",)', object_w=110)
c.two_names_one_object(0, 108, "no .append", "first", "second",
'("python",)', object_w=110)
c.label(150, 170, "tuples raise AttributeError", anchor="middle")


Expand Down Expand Up @@ -597,14 +578,7 @@ def dataclass_fields(c: Canvas) -> None:

def class_triangle(c: Canvas) -> None:
"""Classes · instance → class → type — every Python value sits on this triangle."""
c.dot(20, 28)
c.label(20, 54, "instance", anchor="middle")
c.closed_arrow(26, 28, 86, 28, emphasis=False)
c.frame(88, 10, 60, 36, label="class")
c.mono(118, 32, "Class")
c.closed_arrow(148, 28, 208, 28, emphasis=False)
c.frame(210, 10, 60, 36, label="type")
c.mono(240, 32, "type")
c.type_triangle("type", "type")


def exception_cause_context(c: Canvas) -> None:
Expand Down Expand Up @@ -729,17 +703,15 @@ def sort_stability(c: Canvas) -> None:
def kw_only_separator(c: Canvas) -> None:
"""Keyword-only arguments · `*` divides positional from keyword-only."""
c.mono(0, 18, "def f(a, b, *, c, d): …", anchor="start")
# JetBrains Mono advances ~6px per char at fs=10; '*' sits at index 12.
c.dashed(75, 22, 75, 38)
c.mono_divider(0, 12, 22, 38) # the '*' sits at index 12
c.label(33, 50, "positional or kw", anchor="middle")
c.label(120, 50, "keyword only", anchor="middle")


def positional_only_separator(c: Canvas) -> None:
"""Positional-only parameters · `/` divides positional-only from positional-or-kw."""
c.mono(0, 18, "def f(a, b, /, c, d): …", anchor="start")
# JetBrains Mono advances ~6px per char at fs=10; '/' sits at index 12.
c.dashed(75, 22, 75, 38)
c.mono_divider(0, 12, 22, 38) # the '/' sits at index 12
c.label(33, 50, "positional only", anchor="middle")
c.label(120, 50, "positional or kw", anchor="middle")

Expand Down Expand Up @@ -899,14 +871,7 @@ def property_fork(c: Canvas) -> None:

def metaclass_triangle(c: Canvas) -> None:
"""Metaclasses · instance → class → metaclass; the metaclass is the type of the class."""
c.dot(20, 30)
c.label(20, 56, "instance", anchor="middle")
c.closed_arrow(26, 30, 86, 30, emphasis=False)
c.frame(88, 12, 60, 36, label="class")
c.mono(118, 34, "Class")
c.closed_arrow(148, 30, 218, 30, emphasis=False)
c.frame(220, 12, 80, 36, label="metaclass")
c.mono(260, 34, "type")
c.type_triangle("metaclass", "type", third_w=80)


def sys_path_resolution(c: Canvas) -> None:
Expand Down
Loading
Loading