diff --git a/public/editor.dd81f5171b14.js b/public/editor.a4a7766e1b9b.js similarity index 91% rename from public/editor.dd81f5171b14.js rename to public/editor.a4a7766e1b9b.js index 646b689..5db42ec 100644 --- a/public/editor.dd81f5171b14.js +++ b/public/editor.a4a7766e1b9b.js @@ -1,5 +1,5 @@ import { EditorState } from 'https://esm.sh/@codemirror/state@6.5.2'; -import { EditorView } from 'https://esm.sh/@codemirror/view@6.41.1?deps=@codemirror/state@6.5.2'; +import { EditorView, lineNumbers } from 'https://esm.sh/@codemirror/view@6.41.1?deps=@codemirror/state@6.5.2'; import { defaultHighlightStyle, syntaxHighlighting } from 'https://esm.sh/@codemirror/language@6.12.3?deps=@codemirror/state@6.5.2,@codemirror/view@6.41.1'; import { python } from 'https://esm.sh/@codemirror/lang-python@6.2.1?deps=@codemirror/state@6.5.2,@codemirror/view@6.41.1,@codemirror/language@6.12.3'; @@ -15,6 +15,7 @@ if (textarea && form) { extensions: [ python(), syntaxHighlighting(defaultHighlightStyle), + lineNumbers(), EditorView.lineWrapping, EditorView.updateListener.of((update) => { if (update.docChanged) textarea.value = update.state.doc.toString(); diff --git a/public/editor.js b/public/editor.js index 646b689..5db42ec 100644 --- a/public/editor.js +++ b/public/editor.js @@ -1,5 +1,5 @@ import { EditorState } from 'https://esm.sh/@codemirror/state@6.5.2'; -import { EditorView } from 'https://esm.sh/@codemirror/view@6.41.1?deps=@codemirror/state@6.5.2'; +import { EditorView, lineNumbers } from 'https://esm.sh/@codemirror/view@6.41.1?deps=@codemirror/state@6.5.2'; import { defaultHighlightStyle, syntaxHighlighting } from 'https://esm.sh/@codemirror/language@6.12.3?deps=@codemirror/state@6.5.2,@codemirror/view@6.41.1'; import { python } from 'https://esm.sh/@codemirror/lang-python@6.2.1?deps=@codemirror/state@6.5.2,@codemirror/view@6.41.1,@codemirror/language@6.12.3'; @@ -15,6 +15,7 @@ if (textarea && form) { extensions: [ python(), syntaxHighlighting(defaultHighlightStyle), + lineNumbers(), EditorView.lineWrapping, EditorView.updateListener.of((update) => { if (update.docChanged) textarea.value = update.state.doc.toString(); diff --git a/public/site.1452cc5609f2.css b/public/site.57a55415849b.css similarity index 83% rename from public/site.1452cc5609f2.css rename to public/site.57a55415849b.css index 588fc11..ee3d941 100644 --- a/public/site.1452cc5609f2.css +++ b/public/site.57a55415849b.css @@ -20,7 +20,8 @@ .cm-scroller { font-family: inherit; line-height: 1.5; } .cm-content { padding: 0; } .cm-line { padding: 0; } - .cm-gutters { display: none; } + .cm-gutters { background: transparent; border-right: 1px solid var(--hairline-soft); color: var(--muted); font-variant-numeric: tabular-nums; } + .cm-lineNumbers .cm-gutterElement { padding: 0 var(--space-2) 0 0; min-width: 2ch; text-align: right; font-size: .85em; } .cm-activeLine, .cm-activeLineGutter { background: transparent; } .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; } @@ -28,9 +29,38 @@ .brand { font-weight: 800; } .nav-links { display: flex; gap: .35rem; } .nav-links a { padding: 0 .9rem; color: var(--muted); } - .hero { overflow: hidden; border: 1px solid var(--hairline); border-radius: 1rem; padding: clamp(1.5rem, 5vw, 4rem); 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); } - .hero p { max-width: 66ch; color: var(--muted); font-size: 1.08rem; } + .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; } + @supports (animation-timeline: scroll()) { + @media (prefers-reduced-motion: no-preference) { + .hero { animation: hero-fade linear forwards; animation-timeline: scroll(root); animation-range: 0 280px; } + .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: rgba(245, 241, 235, 0); box-shadow: none; margin-bottom: var(--space-2); animation: header-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; } + } + } + @keyframes hero-fade { + to { opacity: 0; transform: scale(0.92) translateY(-8px); } + } + @keyframes hero-h1-morph { + to { transform: scale(0.32) translate(-32%, -50%); opacity: 0; } + } + @keyframes hero-p-fade { + to { opacity: 0; transform: translateY(-4px); } + } + @keyframes brand-focus { + to { filter: blur(0); transform: scale(1); } + } + @keyframes header-emerge { + to { opacity: 1; background: rgba(245, 241, 235, 0.95); box-shadow: 0 1px 8px rgba(82, 16, 0, 0.06); } + } .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: var(--space-3); } + .home-section { margin-top: var(--space-6); } + .home-section:first-of-type { margin-top: 0; } + .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 h2 { text-decoration: underline; text-decoration-color: var(--hairline); text-underline-offset: .18em; } @@ -81,9 +111,12 @@ .playground { margin-top: var(--space-6); padding-top: var(--space-4); border-top: 1px solid var(--hairline); } .playground > h2 { font-size: clamp(1.5rem, 2.5vw, 2rem); letter-spacing: -0.03em; margin-bottom: var(--space-3); } .runner-grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(18rem, .75fr); gap: var(--space-4); align-items: stretch; } + @media (max-width: 980px) { .runner-grid { grid-template-columns: 1fr; } } .runner-panel { min-height: 18rem; display: flex; flex-direction: column; border: 1px dashed var(--hairline); border-radius: .75rem; padding: var(--space-3); background: var(--surface); } - .runner-panel h2 { margin: 0 0 var(--space-3); padding-bottom: var(--space-2); border-bottom: 1px solid var(--hairline-soft); font-size: 1.05rem; letter-spacing: -0.02em; } + .runner-panel h3 { margin: 0 0 var(--space-3); padding-bottom: var(--space-2); border-bottom: 1px solid var(--hairline-soft); font-size: 1.05rem; letter-spacing: -0.02em; } .runner-panel pre { flex: 1; min-height: 0; overflow: visible; white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; } + .runner-editor { border-style: solid; background: var(--surface-2); cursor: text; box-shadow: inset 0 1px 2px rgba(82, 16, 0, 0.04); transition: box-shadow 160ms cubic-bezier(0.2, 0, 0, 1); } + .runner-editor:focus-within { box-shadow: inset 0 1px 2px rgba(82, 16, 0, 0.04), 0 0 0 3px rgba(255, 72, 1, 0.12); } .execution-time { min-height: 1.5rem; margin: var(--space-2) 0 0; padding-top: var(--space-2); border-top: 1px solid var(--hairline-soft); color: var(--muted); font-size: .88rem; font-variant-numeric: tabular-nums; } .playground-toolbar { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; margin: .8rem 0 1rem; } .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; } diff --git a/public/site.css b/public/site.css index 588fc11..ee3d941 100644 --- a/public/site.css +++ b/public/site.css @@ -20,7 +20,8 @@ .cm-scroller { font-family: inherit; line-height: 1.5; } .cm-content { padding: 0; } .cm-line { padding: 0; } - .cm-gutters { display: none; } + .cm-gutters { background: transparent; border-right: 1px solid var(--hairline-soft); color: var(--muted); font-variant-numeric: tabular-nums; } + .cm-lineNumbers .cm-gutterElement { padding: 0 var(--space-2) 0 0; min-width: 2ch; text-align: right; font-size: .85em; } .cm-activeLine, .cm-activeLineGutter { background: transparent; } .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; } @@ -28,9 +29,38 @@ .brand { font-weight: 800; } .nav-links { display: flex; gap: .35rem; } .nav-links a { padding: 0 .9rem; color: var(--muted); } - .hero { overflow: hidden; border: 1px solid var(--hairline); border-radius: 1rem; padding: clamp(1.5rem, 5vw, 4rem); 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); } - .hero p { max-width: 66ch; color: var(--muted); font-size: 1.08rem; } + .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; } + @supports (animation-timeline: scroll()) { + @media (prefers-reduced-motion: no-preference) { + .hero { animation: hero-fade linear forwards; animation-timeline: scroll(root); animation-range: 0 280px; } + .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: rgba(245, 241, 235, 0); box-shadow: none; margin-bottom: var(--space-2); animation: header-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; } + } + } + @keyframes hero-fade { + to { opacity: 0; transform: scale(0.92) translateY(-8px); } + } + @keyframes hero-h1-morph { + to { transform: scale(0.32) translate(-32%, -50%); opacity: 0; } + } + @keyframes hero-p-fade { + to { opacity: 0; transform: translateY(-4px); } + } + @keyframes brand-focus { + to { filter: blur(0); transform: scale(1); } + } + @keyframes header-emerge { + to { opacity: 1; background: rgba(245, 241, 235, 0.95); box-shadow: 0 1px 8px rgba(82, 16, 0, 0.06); } + } .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: var(--space-3); } + .home-section { margin-top: var(--space-6); } + .home-section:first-of-type { margin-top: 0; } + .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 h2 { text-decoration: underline; text-decoration-color: var(--hairline); text-underline-offset: .18em; } @@ -81,9 +111,12 @@ .playground { margin-top: var(--space-6); padding-top: var(--space-4); border-top: 1px solid var(--hairline); } .playground > h2 { font-size: clamp(1.5rem, 2.5vw, 2rem); letter-spacing: -0.03em; margin-bottom: var(--space-3); } .runner-grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(18rem, .75fr); gap: var(--space-4); align-items: stretch; } + @media (max-width: 980px) { .runner-grid { grid-template-columns: 1fr; } } .runner-panel { min-height: 18rem; display: flex; flex-direction: column; border: 1px dashed var(--hairline); border-radius: .75rem; padding: var(--space-3); background: var(--surface); } - .runner-panel h2 { margin: 0 0 var(--space-3); padding-bottom: var(--space-2); border-bottom: 1px solid var(--hairline-soft); font-size: 1.05rem; letter-spacing: -0.02em; } + .runner-panel h3 { margin: 0 0 var(--space-3); padding-bottom: var(--space-2); border-bottom: 1px solid var(--hairline-soft); font-size: 1.05rem; letter-spacing: -0.02em; } .runner-panel pre { flex: 1; min-height: 0; overflow: visible; white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; } + .runner-editor { border-style: solid; background: var(--surface-2); cursor: text; box-shadow: inset 0 1px 2px rgba(82, 16, 0, 0.04); transition: box-shadow 160ms cubic-bezier(0.2, 0, 0, 1); } + .runner-editor:focus-within { box-shadow: inset 0 1px 2px rgba(82, 16, 0, 0.04), 0 0 0 3px rgba(255, 72, 1, 0.12); } .execution-time { min-height: 1.5rem; margin: var(--space-2) 0 0; padding-top: var(--space-2); border-top: 1px solid var(--hairline-soft); color: var(--muted); font-size: .88rem; font-variant-numeric: tabular-nums; } .playground-toolbar { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; margin: .8rem 0 1rem; } .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; } diff --git a/scripts/build_prototypes.py b/scripts/build_prototypes.py index 2406792..76b689d 100644 --- a/scripts/build_prototypes.py +++ b/scripts/build_prototypes.py @@ -102,7 +102,7 @@ def render_article(example: dict, *, banners: dict[str, str] | None = None) -> s output = html.escape(example.get("expected_output", "")) return f"""
-
← All examplesPython docs reference
+
↑ All examplesPython docs reference

{html.escape(example['section'])}

{html.escape(example['title'])}

@@ -115,10 +115,10 @@ def render_article(example: dict, *, banners: dict[str, str] | None = None) -> s

Run the complete example

-

Example code

+

Example code

{code}
-

Expected output

{output}
+

Expected output

{output}
diff --git a/src/app.py b/src/app.py index e11cc60..d605513 100644 --- a/src/app.py +++ b/src/app.py @@ -499,22 +499,38 @@ def _layout(title: str, content: str, description: str | None = None, path: str def render_home() -> str: - cards = [] + # Group examples by section in the order each section first appears + # in the manifest. Each section gets its own .home-section wrapper + # holding an eyebrow (tight, ~12px above its cards) and the + # section's grid; sections are spaced ~48px apart for clear + # separation. The shared outer .grid is gone — using one grid + # per section gives explicit control over the eyebrow's vertical + # relationship to its own cards vs the previous section. + by_section: dict[str, list[dict]] = {} for example in list_examples(): - cards.append( + by_section.setdefault(example["section"], []).append(example) + sections_html = [] + for section, examples in by_section.items(): + card_markup = "".join( _replace( - '

__SECTION__

__TITLE__

__SUMMARY__

', + '

__TITLE__

__SUMMARY__

', { - "SECTION": html.escape(example["section"]), "SLUG": html.escape(example["slug"]), "TITLE": html.escape(example["title"]), "SUMMARY": html.escape(example["summary"]), }, ) + for example in examples + ) + sections_html.append( + f'
' + f'

{html.escape(section)}

' + f'
{card_markup}
' + f'
' ) content = _replace( _template("home.html"), - {"PYTHON_VERSION": html.escape(PYTHON_VERSION), "CARDS": "".join(cards)}, + {"PYTHON_VERSION": html.escape(PYTHON_VERSION), "CARDS": "".join(sections_html)}, ) return _layout( "Python By Example", @@ -542,7 +558,7 @@ def render_journeys_index():

Journeys

Python learning journeys

-

These paths compose individual examples into larger mental maps. They are inspired by the way Apprenticeship Patterns treats small patterns as material for longer learning journeys.

+

These paths compose individual examples into larger mental maps. They are inspired by the way Apprenticeship Patterns treats small patterns as material for longer learning journeys.

{"".join(cards)}
''' @@ -576,7 +592,7 @@ def render_journey_page(journey): sections.append(f'

{html.escape(section["title"])}

{html.escape(section["summary"])}

{figure_html}
') content = f'''
-
← All examplesPython docs reference
+
↑ All examplesPython docs reference

Journey

{html.escape(journey["title"])}

diff --git a/src/asset_manifest.py b/src/asset_manifest.py index 929c743..915e4d8 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.1452cc5609f2.css', 'SYNTAX_JS': '/syntax-highlight.3b6c7f730d46.js', 'EDITOR_JS': '/editor.dd81f5171b14.js'} -HTML_CACHE_VERSION = '2ef350ca9050' +ASSET_PATHS = {'SITE_CSS': '/site.57a55415849b.css', 'SYNTAX_JS': '/syntax-highlight.3b6c7f730d46.js', 'EDITOR_JS': '/editor.a4a7766e1b9b.js'} +HTML_CACHE_VERSION = '324f7ab4825b' diff --git a/src/marginalia.py b/src/marginalia.py index bd2305b..0ecea4c 100644 --- a/src/marginalia.py +++ b/src/marginalia.py @@ -2025,6 +2025,46 @@ def render_for_section(section_title: str) -> str: return f'
{_render_svg(name)}{cap}
' +# ─── Section-figure scores ──────────────────────────────────────────── +# Score every journey-section figure against +# docs/journey-visualisation-rubric.md (10-point scale). +# Keyed by section title to match SECTION_FIGURES. +SECTION_FIGURE_SCORES: dict[str, tuple[float, str]] = { + # Runtime + "Start with executable evidence.": (9.0, "program → output, the smallest mechanism"), + "Separate value, identity, and absence.": (9.0, "shared vs separate object identity"), + "Read expressions as object operations.": (9.0, "syntax dispatches to method"), + # Control Flow + "Choose between paths.": (9.0, "value flows through predicate to branches"), + "Name and shape decisions.": (8.5, "walrus name + value; abstract"), + "Stop as soon as the answer is known.": (9.0, "first match; break short-circuits"), + # Iteration + "Choose the right loop shape.": (8.5, "loop body + back-edge; abstract"), + "See the protocol behind `for`.": (9.5, "the canonical iter()/next() picture"), + "Compose lazy value streams.": (9.0, "filter → map; values flow lazily"), + # Workers + "Replace unavailable process boundaries with portable evidence.": (8.0, "constraint figure; no clean mechanism"), + "Keep network lessons local to the protocol boundary.": (8.0, "constraint figure; protocol shape"), + "Preserve the lesson while respecting the runtime.": (8.0, "constraint figure; lesson survives"), + # Shapes + "Pick the container that matches the question.": (9.0, "list/tuple/dict/set per question"), + "Move between shapes deliberately.": (9.0, "input → transform → result"), + "Cross text and data boundaries.": (9.0, "text in, structured value out"), + # Interfaces + "Start with functions as named behavior.": (8.5, "args → body → return; abstract"), + "Use functions as values.": (9.0, "second name binds same function"), + "Bundle behavior with state.": (9.0, "class groups state + methods"), + # Types + "Keep runtime and static analysis separate.": (9.0, "annotations as ghost over signature"), + "Describe realistic data shapes.": (9.0, "x: int|str|None branches"), + "Scale annotations for reusable libraries.": (9.0, "T preserved across the call"), + # Reliability + "Make failure explicit.": (9.0, "try/except/else/finally as lanes"), + "Control resource and module boundaries.": (9.0, "in → body → out with __exit__ dashed"), + "Handle operations that outlive one expression.": (9.0, "loop and coroutine swap on await"), +} + + # ─── Scores (v2 rubric — see docs/example-figure-rubric.md) ──────────── # Score every attached example figure against the v2 rubric. The dict is # the single source of truth for both the gestalt review pages @@ -2150,3 +2190,124 @@ def render_for_section(section_title: str) -> str: def figure_score(slug: str) -> tuple[float, str] | None: """Return the v2 score and rationale for an attached example slug, if any.""" return SCORES.get(slug) + + +# ─── Example quality scores ────────────────────────────────────────── +# Score every example PAGE against docs/example-quality-rubric.md. +# These are HEURISTIC baselines computed from observable structural +# signals (cells with output, see_also density, notes count, +# explanation depth). Manual rubric review can refine any entry; the +# point of the registry is to surface distribution and outliers, not +# to pretend a script can grade pedagogy. + +EXAMPLE_QUALITY_SCORES: dict[str, tuple[float, str]] = { + "hello-world": (7.1, "isolated"), + "values": (8.2, "isolated"), + "literals": (8.8, "graph-rich, note-heavy, multi-cell"), + "numbers": (9.0, "graph-rich, note-heavy"), + "booleans": (8.2, "isolated, note-heavy"), + "operators": (8.8, "graph-rich, note-heavy, multi-cell"), + "none": (8.2, "isolated"), + "variables": (8.2, "isolated"), + "constants": (7.7, "isolated"), + "truthiness": (7.9, "isolated"), + "equality-and-identity": (8.4, "isolated, note-heavy"), + "mutability": (8.2, "isolated"), + "object-lifecycle": (7.4, "isolated"), + "strings": (8.2, "isolated, note-heavy"), + "bytes-and-bytearray": (9.0, "graph-rich, note-heavy"), + "string-formatting": (8.2, "isolated"), + "conditionals": (8.2, "isolated"), + "guard-clauses": (7.4, "isolated"), + "assignment-expressions": (8.5, "graph-rich"), + "for-loops": (7.3, "isolated"), + "break-and-continue": (8.5, "graph-rich"), + "loop-else": (8.5, "graph-rich"), + "iterating-over-iterables": (8.8, "graph-rich"), + "iterators": (8.8, "graph-rich"), + "iterator-vs-iterable": (9.0, "graph-rich"), + "sentinel-iteration": (7.4, "isolated"), + "match-statements": (8.2, "isolated"), + "advanced-match-patterns": (8.8, "graph-rich"), + "while-loops": (8.0, "isolated"), + "lists": (8.2, "isolated"), + "tuples": (9.0, "graph-rich, note-heavy"), + "unpacking": (8.2, "isolated"), + "dicts": (8.4, "isolated, note-heavy"), + "sets": (8.2, "isolated, note-heavy"), + "slices": (8.0, "isolated"), + "comprehensions": (8.2, "isolated, note-heavy"), + "comprehension-patterns": (8.5, "graph-rich"), + "sorting": (8.2, "isolated"), + "collections-module": (7.4, "isolated"), + "copying-collections": (7.4, "isolated"), + "functions": (8.4, "isolated, note-heavy"), + "keyword-only-arguments": (8.2, "isolated"), + "positional-only-parameters": (8.5, "graph-rich"), + "args-and-kwargs": (8.2, "isolated"), + "multiple-return-values": (8.0, "isolated"), + "closures": (8.2, "isolated, note-heavy"), + "partial-functions": (7.4, "isolated"), + "scope-global-nonlocal": (8.5, "graph-rich"), + "recursion": (8.0, "isolated"), + "lambdas": (8.2, "isolated"), + "generators": (9.0, "graph-rich, note-heavy"), + "yield-from": (8.5, "graph-rich"), + "generator-expressions": (8.2, "isolated"), + "itertools": (8.2, "isolated, note-heavy"), + "decorators": (8.8, "graph-rich"), + "classes": (9.0, "graph-rich, note-heavy"), + "inheritance-and-super": (8.5, "graph-rich"), + "classmethods-and-staticmethods": (9.0, "graph-rich, note-heavy"), + "dataclasses": (8.8, "graph-rich"), + "properties": (8.2, "isolated"), + "special-methods": (8.5, "graph-rich, note-heavy, multi-cell"), + "truth-and-size": (8.8, "graph-rich"), + "container-protocols": (8.8, "graph-rich"), + "callable-objects": (8.8, "graph-rich"), + "operator-overloading": (8.8, "graph-rich"), + "attribute-access": (8.8, "graph-rich"), + "bound-and-unbound-methods": (9.0, "graph-rich, note-heavy"), + "descriptors": (8.0, "graph-rich"), + "metaclasses": (8.5, "graph-rich"), + "context-managers": (8.8, "graph-rich, note-heavy"), + "delete-statements": (8.8, "graph-rich"), + "exceptions": (8.2, "isolated, note-heavy"), + "assertions": (8.5, "graph-rich"), + "exception-chaining": (8.5, "graph-rich"), + "exception-groups": (8.5, "graph-rich"), + "warnings": (7.4, "isolated"), + "modules": (9.0, "graph-rich, note-heavy"), + "import-aliases": (8.5, "graph-rich, note-heavy"), + "packages": (9.0, "graph-rich, note-heavy"), + "virtual-environments": (7.7, "isolated"), + "type-hints": (8.8, "graph-rich, note-heavy, multi-cell"), + "runtime-type-checks": (8.8, "graph-rich"), + "union-and-optional-types": (8.8, "graph-rich"), + "type-aliases": (8.8, "graph-rich"), + "typed-dicts": (8.8, "graph-rich"), + "structured-data-shapes": (9.0, "graph-rich, note-heavy"), + "literal-and-final": (7.4, "isolated"), + "callable-types": (8.8, "graph-rich"), + "generics-and-typevar": (8.8, "graph-rich"), + "paramspec": (7.4, "isolated"), + "overloads": (7.4, "isolated"), + "casts-and-any": (8.8, "graph-rich"), + "newtype": (8.8, "graph-rich"), + "protocols": (8.8, "graph-rich"), + "abstract-base-classes": (9.0, "graph-rich, note-heavy"), + "enums": (8.0, "isolated, note-heavy"), + "regular-expressions": (8.8, "graph-rich, note-heavy, multi-cell"), + "number-parsing": (7.7, "isolated"), + "custom-exceptions": (8.2, "isolated"), + "json": (9.0, "graph-rich, note-heavy"), + "logging": (7.4, "isolated"), + "testing": (8.8, "graph-rich"), + "subprocesses": (7.7, "isolated"), + "threads-and-processes": (7.9, "isolated"), + "networking": (7.7, "isolated"), + "datetime": (8.2, "isolated"), + "csv-data": (7.4, "isolated"), + "async-await": (9.0, "graph-rich, note-heavy"), + "async-iteration-and-context": (8.8, "graph-rich"), +} diff --git a/src/templates/example.html b/src/templates/example.html index aa50583..2d1ae28 100644 --- a/src/templates/example.html +++ b/src/templates/example.html @@ -1,5 +1,5 @@ diff --git a/src/templates/home.html b/src/templates/home.html index 36f7380..6bbf92c 100644 --- a/src/templates/home.html +++ b/src/templates/home.html @@ -2,4 +2,4 @@

Python By Example

Learn Python with small, editable examples backed by the official Python __PYTHON_VERSION__ docs. Run each snippet in an isolated Dynamic Python Worker using the newest Python version currently supported by Cloudflare Workers/Pyodide.

-
__CARDS__
+__CARDS__ diff --git a/src/templates/layout.html b/src/templates/layout.html index dadc319..883757a 100644 --- a/src/templates/layout.html +++ b/src/templates/layout.html @@ -20,8 +20,9 @@
- +
__CONTENT__
+ diff --git a/tests/test_app.py b/tests/test_app.py index 25c4a1a..cc1c81b 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -321,7 +321,7 @@ def test_cf_workers_design_system_and_playground_lessons(self): self.assertNotIn('data-share', html) self.assertIn('output-panel', html) self.assertIn(".runner-panel", css) - self.assertIn(".runner-panel h2", css) + self.assertIn(".runner-panel h3", css) self.assertIn("text-underline-offset", css) self.assertIn('aria-live="polite"', html) self.assertIn('min-height: 18rem', css) diff --git a/tests/test_marginalia_geometry.py b/tests/test_marginalia_geometry.py index 7ce25e9..34b3279 100644 --- a/tests/test_marginalia_geometry.py +++ b/tests/test_marginalia_geometry.py @@ -394,6 +394,57 @@ def test_every_section_figure_caption_is_unique(self): self.assertEqual(duplicates, {}, f"duplicate section captions: {duplicates}") + def test_every_section_has_a_score(self): + from src.marginalia import SECTION_FIGURES, SECTION_FIGURE_SCORES + + unscored = set(SECTION_FIGURES) - set(SECTION_FIGURE_SCORES) + unattached = set(SECTION_FIGURE_SCORES) - set(SECTION_FIGURES) + self.assertEqual(unscored, set(), f"unscored sections: {sorted(unscored)}") + self.assertEqual(unattached, set(), f"scored but unattached: {sorted(unattached)}") + + def test_every_section_score_in_range(self): + from src.marginalia import SECTION_FIGURE_SCORES + + failures: list[str] = [] + for title, entry in SECTION_FIGURE_SCORES.items(): + if not isinstance(entry, tuple) or len(entry) != 2: + failures.append(f"{title!r}: not a (score, commentary) tuple") + continue + score, commentary = entry + if not isinstance(score, (int, float)) or not 0 <= score <= 10: + failures.append(f"{title!r}: score {score!r} outside [0, 10]") + if not isinstance(commentary, str) or not commentary.strip(): + failures.append(f"{title!r}: empty commentary") + self.assertEqual(failures, [], "\n " + "\n ".join(failures)) + + + def test_every_example_has_a_quality_score(self): + from src.example_loader import load_examples + from src.marginalia import EXAMPLE_QUALITY_SCORES + + _, examples = load_examples() + slugs = {ex["slug"] for ex in examples} + unscored = slugs - set(EXAMPLE_QUALITY_SCORES) + ghost = set(EXAMPLE_QUALITY_SCORES) - slugs + self.assertEqual(unscored, set(), f"unscored examples: {sorted(unscored)}") + self.assertEqual(ghost, set(), f"scored but no example: {sorted(ghost)}") + + def test_every_example_quality_score_in_range(self): + from src.marginalia import EXAMPLE_QUALITY_SCORES + + failures: list[str] = [] + for slug, entry in EXAMPLE_QUALITY_SCORES.items(): + if not isinstance(entry, tuple) or len(entry) != 2: + failures.append(f"{slug}: not a tuple") + continue + score, commentary = entry + if not isinstance(score, (int, float)) or not 0 <= score <= 10: + failures.append(f"{slug}: score {score!r} outside [0, 10]") + if not isinstance(commentary, str) or not commentary.strip(): + failures.append(f"{slug}: empty commentary") + self.assertEqual(failures, [], "\n " + "\n ".join(failures)) + + class FigureCaptionContract(unittest.TestCase): """Contract 5b: every attachment caption is unique.