Skip to main content

max / makenotwork

Cut style.css into sixteen parts, joined by build.rs 11,057 lines, the largest file in the tree. Now sixteen files under css/, concatenated by build.rs back into static/style.css. Every part is a contiguous line range of the original and they load in the original order, so the join is byte-identical and the split changed no rendering. That is the acceptance test, not a claim: all 8051 rule lines are identical, in the same order. Contiguity is the guarantee rather than a convenience, because CSS resolves ties by source order, so a rule moved between parts could change which of two equal-specificity rules wins. build.rs emits the `@layer components` opener and closer around the join rather than leaving them in a part, so every file on disk is standalone-valid CSS instead of fifteen unparseable fragments. static/style.css stays committed. Gitignoring it would sever git blame on 11,000 annotated lines, and lint-frontend.sh, the frontend_payload test and discover.rs all read the built sheet directly. The door that leaves open, editing the output instead of a part, is closed by a new reconcile rule in lint-frontend.sh that rebuilds the join and diffs it. A second new rule fails when a sheet the linter reads is missing: today that makes rules 1 and 2 grep nothing and pass, which is the silence they exist to break. The parts live in css/, not static/css/: src/lib.rs mounts ServeDir over static/, so parts kept there would be served as well as embedded. The guard lists keep naming static/style.css and are not edited. They should weigh what ships, and check_vocabulary_paths reads one file at a time, so an element rule and its revert-layer remedy have to stay in the same file. CSS_KIB_HIGH_WATER falls 337 to 336: the leaner preamble is a net 200 bytes smaller than what it replaced, and that seal is a ratchet.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
Author: Max Johnson <me@maxj.phd> · 2026-09-05 03:19 UTC
Signed with PGP, not checked
Commit: 8fc63e4bfeaf42bef8197a2094d08bf30934f595
Parent: fc54f0f
25 files changed, +10032 insertions, -19 deletions
M server/build.rs +50 -1
@@ -2,7 +2,7 @@
2 2 use std::fmt::Write as _;
3 3 use std::hash::{Hash, Hasher};
4 4 use std::process::Command;
5 - use std::{fs, path::Path};
5 + use std::{fs, path::Path, path::PathBuf};
6 6
7 7 fn main() {
8 8 // Set GIT_HASH env var for compile-time inclusion via option_env!()
@@ -114,6 +114,8 @@
114 114
115 115 cut_house_faces();
116 116
117 + concat_site_sheet();
118 +
117 119 println!("cargo::rerun-if-changed=build.rs");
118 120
119 121 // The landing shots' own dimensions, so the description can reserve their
@@ -587,6 +589,53 @@
587 589 }
588 590 }
589 591
592 + /// Join `css/*.css` into `static/style.css`.
593 + ///
594 + /// The parts are contiguous line ranges of what the sheet already was, loaded
595 + /// in name order, so the join is byte-identical and the split cannot change one
596 + /// pixel. `00-preamble.css` is the comment header and holds no rules; the
597 + /// `@layer components` opener and closer are emitted here rather than living in
598 + /// a part, so every part on disk is standalone-valid CSS.
599 + ///
600 + /// The output stays committed. It is what the head links, what
601 + /// `lint-frontend.sh` reads, what `frontend_payload` weighs and what
602 + /// `discover.rs` greps, and 11,000 lines of annotated CSS keep their blame.
603 + /// `lint-frontend.sh` rebuilds this join and diffs it, which closes the one
604 + /// door a committed generated file leaves open.
605 + fn concat_site_sheet() {
606 + let mut parts: Vec<PathBuf> = fs::read_dir("css")
607 + .expect("read css/")
608 + .map(|e| e.expect("css/ entry").path())
609 + .filter(|p| p.extension().is_some_and(|e| e == "css"))
610 + .collect();
611 + parts.sort();
612 + println!("cargo::rerun-if-changed=css");
613 + for part in &parts {
614 + println!("cargo::rerun-if-changed={}", part.display());
615 + }
616 +
617 + let (preamble, rest) = parts.split_first().expect("css/ holds no parts");
618 + assert!(
619 + preamble.file_name().is_some_and(|n| n == "00-preamble.css"),
620 + "css/ must sort with 00-preamble.css first, found {}",
621 + preamble.display()
622 + );
623 +
624 + let mut out = fs::read_to_string(preamble).expect("read css/00-preamble.css");
625 + out.push_str("@layer components {\n");
626 + for part in rest {
627 + out.push_str(
628 + &fs::read_to_string(part).unwrap_or_else(|e| panic!("read {}: {e}", part.display())),
629 + );
630 + }
631 + out.push_str("}\n");
632 +
633 + // `write_if_changed` for the reason the JS block in `main` documents: this
634 + // is a file the script both writes and watches, and an unconditional write
635 + // rebuilds the crate on every cargo invocation forever.
636 + write_if_changed(Path::new("static/style.css"), &out);
637 + }
638 +
590 639 /// Compile the TypeScript frontend (`frontend/`) to browser ESM in
591 640 /// `static/dist/` via `npm run build` (which runs `tsc`).
592 641 ///
@@ -2,11 +2,11 @@
2 2
3 3 The MNW UI is composed from a small fixed set of primitives. Every screen is assembled from these, like an OS rendering applications, not like a website where each page reinvents its own widgets. If a screen needs something not in this charter, the answer is to extend the charter, not to write a one-off.
4 4
5 - Source of truth for visual identity: the cross-project brand system (internal). Source of truth for primitives: this file plus `static/style.css`.
5 + Source of truth for visual identity: the cross-project brand system (internal). Source of truth for primitives: this file plus `css/05-core.css`.
6 6
7 7 ## Tokens
8 8
9 - Every visual value is a token defined in `:root` (`static/style.css`). No raw hex, no off-scale spacing, no bespoke shadow.
9 + Every visual value is a token defined in `:root` (`css/05-core.css`). No raw hex, no off-scale spacing, no bespoke shadow.
10 10
11 11 | Tier | Token group | Where defined |
12 12 |---|---|---|
@@ -122,7 +122,7 @@
122 122 | Breadcrumb | `.breadcrumb` | — | — |
123 123 | Pagination | `_ui.html` macro `pagination` → `.pagination` | — | `.active` |
124 124
125 - Tokens live in `static/style.css` `:root`. The four parameterized macros (`empty_state`, `form_field`, `confirm_dialog`, `pagination`) are in `templates/partials/_ui.html`.
125 + Tokens live in `css/05-core.css` `:root`. The four parameterized macros (`empty_state`, `form_field`, `confirm_dialog`, `pagination`) are in `templates/partials/_ui.html`.
126 126
127 127 ### Composition guide for new features
128 128
@@ -10,10 +10,10 @@
10 10
11 11 | Group | Tokens | File |
12 12 |-------|--------|------|
13 - | Surfaces | `--surface-page`, `--surface-raised`, `--surface-sunken`, `--surface-overlay`, `--surface-well`, `--bevel-light`, `--bevel-dark` | `static/style.css` `:root` |
14 - | Content | `--content`, `--content-secondary`, `--content-muted`, `--content-on-action` | `static/style.css` `:root` |
15 - | Action and status | `--action`, `--action-hover`, `--success`, `--warning`, `--danger`, `--info` | `static/style.css` `:root` |
16 - | Lines and focus | `--border`, `--border-strong`, `--focus-ring`, `--hover-surface`, `--overlay` | `static/style.css` `:root` |
13 + | Surfaces | `--surface-page`, `--surface-raised`, `--surface-sunken`, `--surface-overlay`, `--surface-well`, `--bevel-light`, `--bevel-dark` | `css/05-core.css` `:root` |
14 + | Content | `--content`, `--content-secondary`, `--content-muted`, `--content-on-action` | `css/05-core.css` `:root` |
15 + | Action and status | `--action`, `--action-hover`, `--success`, `--warning`, `--danger`, `--info` | `css/05-core.css` `:root` |
16 + | Lines and focus | `--border`, `--border-strong`, `--focus-ring`, `--hover-surface`, `--overlay` | `css/05-core.css` `:root` |
17 17 | Typeface | `--font-mono`, `--font-sans`, `--font-display` | `static/typography.css` (generated) |
18 18 | Type size, spacing, radius | `--text-*`, `--gap-*`, `--radius-*` | `static/geometry.css` (generated) |
19 19
@@ -260,7 +260,7 @@
260 260
261 261 | What | Where |
262 262 |------|-------|
263 - | CSS | `static/style.css`, `static/layout.css` |
263 + | CSS | `css/*.css` (joined into `static/style.css`), `static/layout.css` |
264 264 | Generated tokens | `static/typography.css`, `static/geometry.css`, `static/timing.css` |
265 265 | Document head and script tail | `src/shell.rs` |
266 266 | Base template | `templates/base.html` |
@@ -25,6 +25,42 @@
25 25
26 26 violations=0
27 27
28 + # 0a. Every sheet the rules below read must exist.
29 + #
30 + # A missing generated sheet makes rules 1 and 2 grep nothing and pass,
31 + # which is exactly the silence they exist to break. Fail loudly instead.
32 + missing=""
33 + for sheet in $SITE_SHEETS $GENERATED_SHEETS; do
34 + [ -f "$sheet" ] || missing="$missing $sheet"$'\n'
35 + done
36 + if [ -n "$missing" ]; then
37 + echo "[0a] stylesheets missing; run 'cargo build' to generate them:"
38 + printf '%s' "$missing"
39 + exit 1
40 + fi
41 +
42 + # 0b. static/style.css is generated by build.rs from css/*.css and committed.
43 + #
44 + # Committed so 11,000 lines of annotated CSS keep their blame and so every
45 + # guard, test and doc path that reads the built sheet keeps working. That
46 + # leaves one door open: editing the output instead of a part. This closes
47 + # it by rebuilding the join here and diffing.
48 + built=$(
49 + cat "$ROOT/css/00-preamble.css"
50 + echo "@layer components {"
51 + for part in "$ROOT"/css/*.css; do
52 + [ "$(basename "$part")" = "00-preamble.css" ] && continue
53 + cat "$part"
54 + done
55 + echo "}"
56 + )
57 + if ! printf '%s\n' "$built" | diff -q - "$STATIC/style.css" >/dev/null; then
58 + echo "[0b] static/style.css does not match css/*.css."
59 + echo " It is generated: edit the part, not the output, then 'cargo build'."
60 + printf '%s\n' "$built" | diff - "$STATIC/style.css" | head -20
61 + exit 1
62 + fi
63 +
28 64 report() {
29 65 local rule="$1"; shift
30 66 local msg="$1"; shift
@@ -1,6 +1,12 @@
1 1 /* ============================================================================
2 2 MNW global stylesheet. Charter: docs/design-system.md.
3 3
4 + THIS FILE IS ONE PART OF THE SHEET, AND HOLDS NO RULES.
5 +
6 + The sheet is written as `css/*.css` and joined by build.rs into
7 + `static/style.css`. Edit a part, never the output. What each part holds, and
8 + why contiguity is the guarantee: css/README.md.
9 +
4 10 Build new features by COMPOSING these primitives, in this order of preference:
5 11
6 12 1. Use a UTILITY class for one-off adjustments (.mb-section, .text-sm, .nowrap)
@@ -18,8 +24,7 @@
18 24 --------------------------------------------------------------------------
19 25 FILE STRUCTURE (search by section name for anchor)
20 26
21 - FOUNDATIONS
22 - @font-face declarations (top of file)
27 + FOUNDATIONS (05-core.css)
23 28 :root tokens (color, type, space, radius, shadow)
24 29 Element base (h1/h2/h3, p, a, button, input, table)
25 30
@@ -118,11 +123,11 @@
118 123 same layer, and they still beat this file on a tie because a layer resolves
119 124 internally by source order and they load later.
120 125
121 - Two things stay OUT of the layer, deliberately. The @font-face rules above
122 - are not style rules and take no part in the cascade. And the theme block
123 - theming.rs injects into <head> is unlayered, which is load-bearing: an
124 - unlayered rule outranks every layer, so a creator's chosen theme keeps
125 - overriding this file's :root without needing to know the order. */
126 + One thing stays OUT of the layer, deliberately: the theme block theming.rs
127 + injects into <head> is unlayered, which is load-bearing. An unlayered rule
128 + outranks every layer, so a creator's chosen theme keeps overriding this
129 + sheet's :root without needing to know the order. (The @font-face rules this
130 + used to name as the second exception left for typography.css.) */
126 131 @layer components {
127 132 :root {
128 133 /* Typography: nothing. All three tokens come from typography.css, which
@@ -52,7 +52,7 @@
52 52 /// against.
53 53 ///
54 54 /// Lower it whenever the total falls. Raising it is a decision, not a fix.
55 - const CSS_KIB_HIGH_WATER: usize = 337;
55 + const CSS_KIB_HIGH_WATER: usize = 336;
56 56
57 57 /// The sheets this repo writes by hand.
58 58 ///
@@ -275,7 +275,7 @@
275 275 /// the renderer's.
276 276 ///
277 277 /// The scope-to-class mapping moved with it. It lived in
278 - /// `static/style.css`, matching syntect's emitted class names against seven
278 + /// the site stylesheet, matching syntect's emitted class names against seven
279 279 /// custom properties; it is [`syntax_of`] now, and the stylesheet keys off
280 280 /// what quasi-webview writes.
281 281 #[must_use]
@@ -49,7 +49,7 @@
49 49 ///
50 50 /// A markdown editor is one surface, so the sizes are named for the room they
51 51 /// are given rather than for the screen that gives it. The classes are matched
52 - /// in `static/style.css`, under `RICH FIELD`.
52 + /// in `css/60-interaction.css`, under `RICH FIELD`.
53 53 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
54 54 pub enum Height {
55 55 /// A few lines, for an editor behind a disclosure the reader opened to add
@@ -1,0 +1,130 @@
1 + /* ============================================================================
2 + MNW global stylesheet. Charter: docs/design-system.md.
3 +
4 + THIS FILE IS ONE PART OF THE SHEET, AND HOLDS NO RULES.
5 +
6 + The sheet is written as `css/*.css` and joined by build.rs into
7 + `static/style.css`. Edit a part, never the output. What each part holds, and
8 + why contiguity is the guarantee: css/README.md.
9 +
10 + Build new features by COMPOSING these primitives, in this order of preference:
11 +
12 + 1. Use a UTILITY class for one-off adjustments (.mb-section, .text-sm, .nowrap)
13 + 2. Use a LAYOUT PRIMITIVE to position content (.container, .stack-row, .field-row, .list-row)
14 + 3. Use a COMPONENT PRIMITIVE for a UI element (.card, .callout, .badge, .progress-bar, .empty-state, .input--sm, .small)
15 + 4. Extend a primitive with a modifier (.card--bordered, .callout--warning, .stack-row--bordered)
16 + 5. ONLY THEN consider a new class, and add it to the right section below
17 +
18 + A new class is a smell, not a goal. Before writing one:
19 + - grep this file for the visual shape you want; almost everything is here
20 + - if you can't find it, consider whether the shape belongs in the design system
21 + (then add it to the appropriate section here AND to docs/design-system.md)
22 + - page-scoped rules (`.foo-page .bar`) are a last resort, not a first move
23 +
24 + --------------------------------------------------------------------------
25 + FILE STRUCTURE (search by section name for anchor)
26 +
27 + FOUNDATIONS (05-core.css)
28 + :root tokens (color, type, space, radius, shadow)
29 + Element base (h1/h2/h3, p, a, button, input, table)
30 +
31 + UTILITIES
32 + Spacing (.m-0, .mt-*, .mb-*, .ml-*, .my-*)
33 + Sizing (.col-*, .w-*, .maxw-*, .minw-*)
34 + Text (.text-sm, .text-xs, .text-center, .text-right, .fw-bold,
35 + .nowrap, .muted, .dimmed, .dimmer, .is-faded, .danger-text)
36 + State (.hidden, .is-selected, .is-active, .unstyled-link)
37 + Shapes (.square-cover, .scroll-x)
38 +
39 + LAYOUT PRIMITIVES (page-scaffolding shapes)
40 + .container (width set by body class)
41 + .stack-row + --bordered / --tight / --top (toolbar / header bar)
42 + .field-row + .form-group.is-grow (inline form row)
43 + .list-row + .list-row-title (vertical item list)
44 + .form-row (2-col grid form)
45 + .cover-row + .cover-thumb + .cover-empty (image picker)
46 +
47 + COMPONENT PRIMITIVES (reusable UI blocks)
48 + .content-section (light-bg page section box)
49 + .card / .card-muted / .card--bordered / .card--selectable
50 + .form-group / .form-section / details.form-section
51 + .section-header / .section-lead / .section-group-label
52 + .badge + data-tone (success/warning/danger)
53 + .callout + --danger / --warning / --solid-warning
54 + .banner + --info / --warning (full-bleed page-top notice)
55 + .alert + -note / -tip / -warning / -caution (left-border inline notice)
56 + .modal + .modal-overlay
57 + .empty-state + --compact / --chart / --lg
58 + .progress-bar-container + .progress-bar (--slim, --rounded, --highlight)
59 + .upload-status + -row + -msg.is-success / .is-error
60 + .field-status / .save-status + .success / .error / .saving
61 + .toast + .toast--success / --error / --warning
62 + .breadcrumb / .pagination
63 + .tabs + .tab.chosen (chosen is makeover's)
64 +
65 + CONTROL MODIFIERS (compose onto buttons/inputs)
66 + .small / .btn-compact / .btn-link / .btn-tiny (button sizes)
67 + .btn-danger / .saved (button intents)
68 + .input--xs / --sm / --mono / --upper / --numeric (input sizes/shapes)
69 +
70 + FEATURE SUBAPPS (their own CSS scope)
71 + .git-* (git source browser at /source/*)
72 + Pricing calculator on /pricing
73 + Markdown preview / docs-ui in /docs/*
74 +
75 + PAGE-SCOPED LAYOUTS (when truly page-specific)
76 + .item-page / .project-page / .article-page / .purchase-page / .cart-* /
77 + .library-page / .feed-page / .discover-page / .landing / .creators-page /
78 + .health-page / .import-page / .delete-account-page / .export-page /
79 + .receipt-page / .fan-plus-page / .stripe-disclaimer-page / .buy-page /
80 + .user-page / .collection-page / .changelog-page / .policy-page /
81 + .tag-tree-page / .project-blog-page / .project-paywall-page /
82 + .confirm-delete-page / .admin-page / .dashboard-page
83 +
84 + DASHBOARD TABS (one section per tab partial)
85 + Per-tab classes scoped under their canonical body class. Use modifier
86 + composition where possible; only add tab-specific rules when the shape
87 + genuinely diverges from a primitive.
88 +
89 + RESPONSIVE (@media at end of file)
90 + 768px tablet
91 + 480px phone
92 +
93 + --------------------------------------------------------------------------
94 + When in doubt: read docs/design-system.md first. Update both files together
95 + when you add a primitive.
96 + ============================================================================ */
97 +
98 + /* No `@font-face` here at all any more, and no font token either. All three
99 + faces are fetched by the generated `typography.css`: Quasi Mono and Quasi
100 + Body from makeover's house layer, and Young Serif from MNW's own override,
101 + declared in build.rs. What left first: four blocks for IBM Plex Mono and
102 + Lato, regular and bold, 101 KB of woff2 covering two weights, replaced by
103 + 81 KB carrying the whole 200-800 axis. What left second: the Young Serif
104 + block that used to sit here, and the `--font-display` beside it.
105 +
106 + Young Serif is still the brand tier and still MNW's alone. That has not
107 + changed and is not what moved. What changed is that the exemption is a
108 + mechanism now rather than a gap in the vocabulary: the face is declared
109 + once, as an override of the `display` slot, and reads as one. */
110 +
111 + /* Everything below is in the `components` cascade layer.
112 +
113 + _head_assets.html declares the order once, `@layer makeover, base,
114 + components, responsive`, and until now three of those four names were
115 + empty: this file adopted none of them, so 11,788 unlayered lines outranked
116 + every named layer by construction and the generated sheets lost every
117 + contest they entered.
118 +
119 + Adopting `components` changes no rendering today. `components` already sits
120 + after `makeover` in the declared order, so this file wins the same contests
121 + it was winning before; what changes is that it wins them by a stated rule
122 + rather than by being unlayered. wizard.css and media-player.css adopt the
123 + same layer, and they still beat this file on a tie because a layer resolves
124 + internally by source order and they load later.
125 +
126 + One thing stays OUT of the layer, deliberately: the theme block theming.rs
127 + injects into <head> is unlayered, which is load-bearing. An unlayered rule
128 + outranks every layer, so a creator's chosen theme keeps overriding this
129 + sheet's :root without needing to know the order. (The @font-face rules this
130 + used to name as the second exception left for typography.css.) */
@@ -1,0 +1,1644 @@
1 + :root {
2 + /* Typography: nothing. All three tokens come from typography.css, which
3 + loads before this file.
4 +
5 + `--font-mono` and `--font-sans` are the house slots and `--font-display`
6 + is MNW's own, an override declared in build.rs. Do not redefine any of the
7 + three here -- that is the divergence the unification deleted, and this
8 + file is the one that would win. A product face belongs in the override,
9 + where the whole tree can read it. */
10 +
11 + /* --------------------------------------------------------------------
12 + INTENT LAYER, the shared theme vocabulary (themeable)
13 +
14 + These are the intent tokens emitted by theme_common::intent_css_vars():
15 + colors declared by ROLE (surface/content/action/status/line/category),
16 + plus the derived interactive states (hover/active/selection/row-stripe/
17 + contrast) the crate computes once for every product. The values below
18 + are the platform default (shared/themes/makenotwork.toml resolved) so
19 + non-creator pages render without injection; creator profile/project/item
20 + pages inject a <style> that supersedes this block with the chosen theme.
21 + Generated. Keep in sync with `cargo run --example dump_intents makenotwork`.
22 + -------------------------------------------------------------------- */
23 + --surface-page: #ddd6c9;
24 + --surface-raised: #efe9e0;
25 + --surface-sunken: #cdc5b5;
26 + --surface-overlay: #efe9e0;
27 + /* The content surface cut into a raised one, so a list reads as content in a
28 + container rather than as bands on a panel. Derived by makeover from
29 + surface-raised, away from resolved content, so it goes lighter on a light
30 + theme and darker on a dark one. */
31 + --surface-well: #fffef5;
32 + /* The two edges of a bevel. makeover derives both from the theme's raised
33 + surface, so they arrive with the rest of the intent set through
34 + intent_css_vars() and every creator theme gains them automatically. */
35 + --bevel-light: #fffef5;
36 + --bevel-dark: #b5afa6;
37 + --content: #3d3530;
38 + --content-secondary: #5c554f;
39 + --content-muted: #8a8480;
40 + --content-on-action: #ffffff;
41 + --action: #6c5ce7;
42 + --action-hover: #7a6cf8;
43 + --danger: #c0392b;
44 + --success: #27ae60;
45 + --warning: #f59e0b;
46 + --info: #17a2b8;
47 + --border: #b3ab97;
48 + --border-strong: #a39c88;
49 + --focus-ring: #6c5ce7;
50 + --hover-surface: #cdc5b5;
51 + --category-one: #c0392b;
52 + --category-two: #27ae60;
53 + --category-three: #6c5ce7;
54 + --category-four: #f59e0b;
55 + --category-five: #a29bfe;
56 + --category-six: #17a2b8;
57 + --overlay: rgba(3, 1, 0, 0.5);
58 + /* What a surface floating over the page casts onto it. Same tone as the
59 + scrim above and pinned near-black on every theme, because a shadow is
60 + absence of light rather than a tint. layout.css composes the geometry
61 + into --elevation-overlay; a consumer that needs a different geometry
62 + builds its own from this token rather than from a literal. */
63 + --elevation: rgba(3, 1, 0, 0.18);
64 +
65 + /* --------------------------------------------------------------------
66 + APP-LOCAL CONSTANTS, not theme colors, not derivable from intents.
67 +
68 + Component CSS references the intent tokens above directly (no brand
69 + aliases). Status backgrounds derive from the intents inline via
70 + color-mix(); these few stay fixed:
71 + - pure black/white contrast anchors
72 + - Stripe's brand color (an external constant)
73 + - diff add/del, a hard readability convention that must not re-theme
74 + -------------------------------------------------------------------- */
75 + --primary-dark: #000000;
76 + --primary-light: #ffffff;
77 + --stripe: #635BFF;
78 +
79 + /* The faint accent tint every selection and highlight recipe fills with.
80 + Derived from --action, so it re-tints with the creator's theme. Named
81 + because the charter names it and because a dozen rules read it. */
82 + --highlight-faint: color-mix(in oklch, var(--action) 8%, transparent);
83 +
84 + /* Diff / code review, fixed green-add / red-del convention */
85 + --diff-add: #2d7d2d;
86 + --diff-add-bg: rgba(45, 125, 45, 0.08);
87 + --diff-del: #a83232;
88 + --diff-del-bg: rgba(168, 50, 50, 0.08);
89 +
90 + /* Radius lives in geometry.css now, generated from makeover-geometry:
91 + --radius-square / -fine / -control / -panel / -round. Containers are
92 + square and controls keep a corner, so the rounding survives only where a
93 + thing is meant to be pressed. */
94 +
95 + /* Syntax highlighting for the git source browser: base16 Tomorrow, used
96 + whole rather than assembled colour by colour. It stays fixed across all
97 + 31 themes on purpose, the same argument as the diff add/del pair above:
98 + a reader recognises a highlighting palette, and re-tinting it per theme
99 + costs that recognition to gain nothing. Named so it is legible as one
100 + palette instead of seven hex literals scattered through the git rules. */
101 + --syntax-keyword: #8959a8;
102 + --syntax-string: #718c00;
103 + --syntax-comment: #8e908c;
104 + --syntax-constant: #f5871f;
105 + --syntax-entity: #4271ae;
106 + --syntax-variable: #c82829;
107 + --syntax-support: #3e999f;
108 +
109 + /* Stacking order, named for what floats over what. Local rather than
110 + generated: this is a claim about MNW's own furniture, not an invariant
111 + like spacing. The numbers are spaced so a value can be slotted between
112 + two of them without renumbering, and nothing outside this block should
113 + write a z-index literal. Anything not listed here does not float. */
114 + --z-raised: 1; /* lifted within the flow: a saved badge on a card */
115 + --z-nav: 10; /* the nav panel that drops out of the mobile header */
116 + --z-header: 11; /* the header's own toggle and search panel, both of
117 + which have to stay above the panel they open */
118 + --z-dropdown: 20; /* menus and suggestion lists opened from a control */
119 + --z-picker: 30; /* pickers that open over a dropdown */
120 + --z-context: 40; /* right-click / overflow context menus */
121 + --z-overlay: 50; /* full-page scrims */
122 + --z-modal: 60; /* dialogs, above their scrim */
123 + --z-toast: 70; /* transient messages, above a dialog */
124 + --z-banner: 80; /* page-level interruptions, above everything */
125 + --z-skip-link: 90; /* the keyboard escape hatch outranks all of it */
126 +
127 + /* Depth, and there are only two kinds of it.
128 +
129 + A surface IN the page takes a bevel: an edge, not a distance. That is
130 + --shadow-raised/-card/-inset, aliasing the pair layout.css derives from
131 + each theme's own raised surface. --shadow-raised and --shadow-card
132 + collapse onto one value because an edge has no heavier and lighter
133 + version to pick between.
134 +
135 + A surface floating OVER the page takes --elevation-overlay, also from
136 + layout.css, derived from the theme's page colour. Menus, toasts,
137 + popovers, dropdowns and pickers float. A card, a plate and a framed
138 + image do not; they are .raised.
139 +
140 + There used to be a third kind, --shadow-1/-2/-3, a blurred scale of
141 + rgba-black literals picked against parchment and then applied to all 31
142 + themes a creator can select. It was the same defect the bevel trio was
143 + rewritten to undo, one scale later, and the elevation intent is what
144 + replaced it. No literal survives here now, so every consumer re-themes
145 + without being opened. Geometry stays consumer-side: a site that needs a
146 + different blur composes it from var(--elevation), never from a literal. */
147 + --shadow-raised: var(--bevel-raised);
148 + --shadow-card: var(--bevel-raised);
149 + --shadow-inset: var(--bevel-inset);
150 + }
151 +
152 + /* Reset */
153 + * {
154 + margin: 0;
155 + padding: 0;
156 + box-sizing: border-box;
157 + }
158 +
159 + /* Base styles */
160 + body {
161 + margin: 0;
162 + background-color: var(--surface-page);
163 + color: var(--content);
164 + font-family: var(--font-sans);
165 + line-height: 1.6;
166 + }
167 +
168 + /* Padded page layout for most content pages */
169 + .padded-page {
170 + padding: var(--gap-pane);
171 + }
172 +
173 + /* Centered page layout for landing, login, signup */
174 + .centered-page {
175 + display: flex;
176 + flex-direction: column;
177 + justify-content: center;
178 + align-items: center;
179 + min-height: 100vh;
180 + gap: var(--gap-page);
181 + }
182 +
183 + .centered-page h1 {
184 + font-size: var(--text-hero);
185 + }
186 +
187 + .message-container {
188 + max-width: 500px;
189 + text-align: center;
190 + }
191 +
192 + .centered-wrapper {
193 + justify-content: center;
194 + text-align: center;
195 + }
196 +
197 + h1 {
198 + font-family: var(--font-display);
199 + font-weight: normal;
200 + color: var(--content);
201 + text-align: center;
202 + font-size: var(--text-display);
203 + }
204 +
205 + h2,
206 + h3 {
207 + font-family: var(--font-mono);
208 + font-weight: normal;
209 + color: var(--content);
210 + }
211 +
212 + /* Heading classes (charter: docs/design-system.md, every h1/h2 in a
213 + template must carry one of these so the role is explicit).
214 +
215 + .brand-h1: the "Makenot.work" wordmark on auth/wizard pages.
216 + .page-title: page-level h1 (Young Serif, centered).
217 + .subtitle-h2: page subtitle h2 used under .brand-h1 in auth/wizards.
218 + .subsection-title: h2 inside dashboards, tabs, and prose partials
219 + (mono, no border, the default-h2 role made explicit).
220 + .section-header: h2 prose sub-section heading with bottom border
221 + (existing rule, see SECTIONS block). */
222 + .brand-h1,
223 + .page-title {
224 + font-family: var(--font-display);
225 + font-weight: normal;
226 + color: var(--content);
227 + text-align: center;
228 + font-size: var(--text-display);
229 + }
230 +
231 + .subtitle-h2 {
232 + font-family: var(--font-mono);
233 + font-weight: normal;
234 + color: var(--content);
235 + text-align: center;
236 + font-size: var(--text-head);
237 + }
238 +
239 + .subsection-title {
240 + font-family: var(--font-mono);
241 + font-weight: normal;
242 + color: var(--content);
243 + }
244 +
245 + p {
246 + font-family: var(--font-sans);
247 + color: var(--content);
248 + }
249 +
250 + a {
251 + color: var(--content);
252 + text-decoration: none;
253 + }
254 +
255 + a:hover {
256 + text-decoration: underline;
257 + }
258 +
259 + /* The signature dot */
260 + .dot {
261 + color: var(--action);
262 + }
263 +
264 + /* Container, default 1200px max-width. Modifier classes for narrower pages,
265 + or comma-grouped page-scoped overrides below for body-class-based control. */
266 + .container {
267 + max-width: 1200px;
268 + margin: 0 auto;
269 + padding: var(--gap-page);
270 + }
271 +
272 + /* Per-page container width overrides (selected via body class). One rule
273 + per width tier; new pages should add their body class here rather than
274 + defining a new .foo-page .container rule. */
275 + .delete-account-page .container,
276 + .receipt-page .container { max-width: 600px; margin: 0 auto; }
277 +
278 + /* The profile is described, so it has no container: the measure lands on the
279 + element the renderer draws the page into. The width is this page's own and
280 + the rest of the block was the design system's. */
281 + .user-page main { max-width: 600px; }
282 +
283 + .import-page .container,
284 + .export-page .container { max-width: 800px; margin: 0 auto; }
285 +
286 + .fan-plus-page main,
287 + .creators-page main { max-width: 900px; margin: 0 auto; }
288 +
289 + /* READING COLUMN, the 680px measure every long-form surface uses. Narrower
290 + than .container and with tighter horizontal padding, so it is its own
291 + primitive rather than a .container width tier.
292 +
293 + These four wrappers were four identical rules until 2026-08-16. They are
294 + grouped rather than replaced by one class because the charter treats a new
295 + class as a smell, and because grouping keeps each selector's own
296 + specificity intact. A fifth long-form surface joins this list; it does not
297 + copy the recipe.
298 +
299 + .media-container's rule used to live in media-player.css. It is here now so
300 + the measure has one definition; that sheet keeps its descendant rules. */
301 + .article-page .article-container,
302 + .doc-container,
303 + .docs-index,
304 + .media-container {
305 + max-width: 680px;
306 + margin: 0 auto;
307 + padding: var(--gap-page) var(--gap-pane) var(--gap-page);
308 + }
309 +
310 + /* ===========================================
311 + BUTTONS
312 + =========================================== */
313 +
314 + button {
315 + color: var(--content);
316 + background: var(--surface-overlay);
317 + padding: 6px 12px;
318 + border: 1px solid var(--content);
319 + font-family: inherit;
320 + cursor: pointer;
321 + box-shadow: var(--shadow-raised);
322 + transition: box-shadow 0.1s ease, background 0.2s ease;
323 + }
324 +
325 + button:hover {
326 + background: var(--hover-surface);
327 + }
328 +
329 + button:active {
330 + box-shadow: var(--shadow-inset);
331 + }
332 +
333 + /* A described act carries the generated `.button` class, and the element rule
334 + above sets the same surface, bevel and tone the generated class does. This
335 + file is in @layer components, which the order statement puts after
336 + `makeover`, so the element rule wins whatever the specificity. Without the
337 + handoff below a described act renders in --surface-overlay with
338 + --shadow-raised and, worse, in --content rather than its tone: a
339 + destructive act looks identical to an ordinary one. Same mechanism and same
340 + remedy as `.tab.chosen` further down; read the comment there.
341 +
342 + The split is deliberate. The app keeps padding, border and font from the
343 + element rule, makeover keeps surface, bevel and tone. That is the
344 + arrangement that already makes `.field` work, where the app's input rule
345 + happens to set only geometry and typography.
346 +
347 + Tone is handed back on `[data-tone]` rather than on `.button`, because an
348 + untoned button has no colour in the layer to roll back to and would land on
349 + the UA default instead of --content. */
350 + .button,
351 + .chip,
352 + .segment,
353 + .toggle {
354 + /* respec-ok: a layer handoff, not a value. */
355 + background: revert-layer;
356 + box-shadow: revert-layer;
357 + }
358 +
359 + .button:hover,
360 + .chip:hover,
361 + .segment:hover,
362 + .toggle:hover {
363 + /* respec-ok: a layer handoff, not a value. */
364 + background: revert-layer;
365 + }
366 +
367 + .button:active,
368 + .chip:active,
369 + .segment:active,
370 + .toggle:active {
371 + /* respec-ok: a layer handoff, not a value. */
372 + background: revert-layer;
373 + box-shadow: revert-layer;
374 + }
375 +
376 + .button[data-tone] {
377 + /* respec-ok: a layer handoff, not a value. */
378 + color: revert-layer;
379 + }
380 +
381 + /* A described link, same handoff on the `a` and `button` element rules.
382 + `a { color: var(--content); text-decoration: none }` renders a described
383 + link as body text -- the reader cannot tell it is a link at all, which is
384 + the same defect as the lost tone above and worse for being silent.
385 +
386 + `button.link` is the second arm: a link that posts is a button in the
387 + markup, and makeover already flattens it (`background: none`, no border, no
388 + padding, `font: inherit`). The element rule's chrome beat all four, so it
389 + rendered as an ordinary raised button. Only the properties makeover sets on
390 + this class are handed back; `font` is handed back on the button arm alone,
391 + because an `a.link` has no lower-layer font to roll back to and would land
392 + on the UA default face. */
393 + .link {
394 + /* respec-ok: a layer handoff, not a value. */
395 + color: revert-layer;
396 + text-decoration: revert-layer;
397 + }
398 +
399 + button.link {
400 + /* respec-ok: a layer handoff, not a value. */
401 + background: revert-layer;
402 + border: revert-layer;
403 + padding: revert-layer;
404 + font: revert-layer;
405 + box-shadow: revert-layer;
406 + cursor: revert-layer;
407 + }
408 +
409 + /* A described facet value, the third arm of the same handoff. makeover 0.67.0
410 + names the element-chrome reset, and a facet control withdraws the button's
411 + fill, stroke and bevel with it: a value reads flat until it is hovered,
412 + pressed or in force. The element rule above supplies all three, so without
413 + this a described facet renders as an ordinary raised button and
414 + `[aria-pressed="true"]` is its only correct state -- which is the whole of
415 + what the panel had to say and the one thing it could not.
416 +
417 + Wider than the `.button` group above by one property. That group keeps the
418 + app's border deliberately, because a described act is still a button. Here
419 + the flat rest look IS the withdrawal of the border, so it is handed back
420 + with the other two. */
421 + .facet-take,
422 + .facet-prune {
423 + /* respec-ok: a layer handoff, not a value. */
424 + background: revert-layer;
425 + border: revert-layer;
426 + box-shadow: revert-layer;
427 + }
428 +
429 + .facet-take:hover,
430 + .facet-prune:hover {
431 + /* respec-ok: a layer handoff, not a value. */
432 + background: revert-layer;
433 + }
434 +
435 + .facet-take:active,
436 + .facet-prune:active,
437 + .facet-take[aria-pressed="true"],
438 + .facet-prune[aria-pressed="true"] {
439 + /* respec-ok: a layer handoff, not a value. */
440 + background: revert-layer;
441 + box-shadow: revert-layer;
442 + }
443 +
444 + /* A described field on a `select` or a `textarea`. The input rule further down
445 + sets padding and font only, which is why the shipped `.field` precedents
446 + survived; these two also set `background` and `box-shadow`, so a described
447 + field on either renders in --surface-sunken rather than the well. The focus
448 + ring is handed back with them: makeover insets it (`calc(-1 * 2px)`) so it
449 + does not collide with the bevel, and the app's outer 2px was winning. */
450 + .field {
451 + /* respec-ok: a layer handoff, not a value. */
452 + background: revert-layer;
453 + box-shadow: revert-layer;
454 + }
455 +
456 + .field:focus-visible {
457 + /* respec-ok: a layer handoff, not a value. */
458 + outline-offset: revert-layer;
459 + }
460 +
461 + .btn-primary {
462 + background: var(--primary-dark);
463 + color: var(--primary-light);
464 + border: none;
465 + padding: var(--gap-section) var(--gap-pane);
466 + font-family: inherit;
467 + font-size: var(--text-body);
468 + cursor: pointer;
469 + box-shadow: var(--shadow-raised);
470 + transition: box-shadow 0.1s ease, background 0.2s ease;
471 + display: inline-block;
472 + text-decoration: none;
473 + }
474 +
475 + /* A solid-fill button shifts its own fill rather than taking --hover-surface.
476 + The charter's hover rule is "one step lighter than the resting fill", and on
477 + a neutral control that step is --hover-surface; on this one it is not, since
478 + the label is --primary-light and a parchment fill under it is unreadable. */
479 + .btn-primary:hover {
480 + background: color-mix(in oklch, var(--primary-dark) 85%, var(--primary-light));
481 + text-decoration: none;
482 + }
483 +
484 + .btn-primary:active {
485 + box-shadow: var(--shadow-inset);
486 + }
487 +
488 + .btn-secondary {
489 + background: var(--surface-sunken);
490 + color: var(--content);
491 + border: none;
492 + padding: var(--gap-section) var(--gap-pane);
493 + font-family: var(--font-mono);
494 + font-size: var(--text-body);
495 + cursor: pointer;
496 + box-shadow: var(--shadow-raised);
497 + transition: box-shadow 0.1s ease, background 0.2s ease;
498 + display: inline-block;
499 + text-decoration: none;
500 + }
Lines truncated
@@ -1,0 +1,539 @@
1 + /* ===========================================
2 + ITEM PAGE
3 + =========================================== */
4 +
5 + /* Item detail page (templates/pages/item.html). Scoped under .item-page
6 + body class so the .item-title / .item-meta / .item-price / .item-footer
7 + names don't collide with the store-front item-card on project.html. */
8 + .item-page .item-layout {
9 + display: grid;
10 + grid-template-columns: 400px 1fr;
11 + gap: var(--gap-page);
12 + margin-bottom: var(--gap-page);
13 + }
14 +
15 + .item-page .item-layout.no-media { grid-template-columns: 1fr; }
16 +
17 + .item-page .item-title {
18 + font-size: var(--text-title);
19 + margin-bottom: var(--gap-peer);
20 + }
21 +
22 + .item-page .item-creator {
23 + font-size: var(--text-body);
24 + opacity: 0.7;
25 + margin-bottom: var(--gap-pane);
26 + }
27 +
28 + .item-page .item-creator a { color: var(--content); }
29 +
30 + .item-page .item-price {
31 + font-size: var(--text-display);
32 + margin-bottom: var(--gap-pane);
33 + }
34 +
35 + .item-page .item-meta {
36 + display: grid;
37 + grid-template-columns: 1fr 1fr;
38 + gap: var(--gap-section);
39 + margin-bottom: var(--gap-pane);
40 + padding-bottom: var(--gap-pane);
41 + border-bottom: 1px solid var(--border);
42 + }
43 +
44 + .item-page .meta-item { font-size: var(--text-note); }
45 + .item-page .meta-label { opacity: 0.7; margin-bottom: var(--gap-bound); }
46 + .item-page .meta-value { font-weight: bold; }
47 +
48 + .item-page .item-tags { margin-bottom: var(--gap-pane); }
49 +
50 + /* purchase-box uses .card-muted for the box; only its h3/ul/li specifics remain. */
51 + .purchase-box h3 {
52 + font-size: var(--text-body);
53 + font-weight: normal;
54 + margin-bottom: var(--gap-section);
55 + }
56 + .purchase-box ul { list-style: none; margin-bottom: var(--gap-section); }
57 + .purchase-box li { padding: var(--gap-bound) 0; }
58 + .purchase-box li::before {
59 + content: "→ ";
60 + opacity: 0.5;
61 + }
62 +
63 + .item-page .btn-primary {
64 + width: 100%;
65 + padding: var(--gap-section) var(--gap-page);
66 + font-size: var(--text-lead);
67 + }
68 +
69 + .item-page .btn-secondary {
70 + width: 100%;
71 + margin-top: var(--gap-peer);
72 + }
73 +
74 + .item-page .payment-note {
75 + font-size: var(--text-note);
76 + opacity: 0.6;
77 + text-align: center;
78 + margin-top: var(--gap-section);
79 + }
80 +
81 + /* bundle-child uses .list-row co-class; only the gap-4 override remains. */
82 + .bundle-child { gap: var(--gap-section); padding: var(--gap-section) 0; }
83 +
84 + .item-page .bundle-child-type {
85 + font-size: var(--text-fine);
86 + padding: var(--gap-bound) var(--gap-group);
87 + background: var(--surface-sunken);
88 + white-space: nowrap;
89 + }
90 +
91 + .item-page .bundle-child-title { flex: 1; }
92 + .item-page .bundle-child-title a { color: var(--content); }
93 + .item-page .bundle-child-price { font-size: var(--text-note); opacity: 0.7; }
94 +
95 + .item-page .bundle-notice {
96 + background: var(--surface-sunken);
97 + padding: var(--gap-pane);
98 + margin-bottom: var(--gap-pane);
99 + text-align: center;
100 + }
101 +
102 + .item-page .bundle-notice a { color: var(--content); }
103 +
104 + .item-page .item-description p { margin-bottom: var(--gap-section); text-align: left; }
105 + .item-page .item-description ul { margin-left: var(--gap-pane); margin-bottom: var(--gap-section); }
106 + .item-page .item-description li { margin-bottom: var(--gap-peer); }
107 +
108 + .item-page .features-grid {
109 + display: grid;
110 + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
111 + gap: var(--gap-pane);
112 + }
113 + .item-page .version-number { font-size: var(--text-lead); }
114 + .item-page .item-footer {
115 + margin-top: var(--gap-page);
116 + padding-top: var(--gap-page);
117 + border-top: 1px solid var(--border);
118 + text-align: center;
119 + opacity: 0.6;
120 + font-size: var(--text-note);
121 + }
122 +
123 + .item-page .item-footer a { color: var(--content); }
124 +
125 + @media (max-width: 839px) {
126 + .item-page .item-layout { grid-template-columns: 1fr; }
127 + }
128 +
129 + @media (max-width: 599px) {
130 + .item-page .item-title { font-size: var(--text-head); }
131 + .item-page .item-price { font-size: var(--text-title); }
132 + .item-page .item-meta { grid-template-columns: 1fr; gap: var(--gap-peer); }
133 + .item-page .item-media,
134 + .item-page .item-details { padding: var(--gap-section); }
135 + .item-page .item-description { padding: var(--gap-section); }
136 + .item-page .item-description h2 { font-size: var(--text-subhead); }
137 + .item-page .purchase-box { padding: var(--gap-section); }
138 + }
139 +
140 + /* ===========================================
141 + PROJECT PAGE (store front)
142 + ===========================================
143 +
144 + The store front is described (`src/quasi/project.rs`), so the header, the
145 + item cards, the two view copies, the tab strip and the tier cards are all
146 + the design system's markup now: a rule restating one of them fails the
147 + drift check in build.rs. What is this page's rather than the system's is
148 + how wide it runs, which is the measure on the body, and nothing else was
149 + left. */
150 +
151 + /* ===========================================
152 + LIBRARY DOWNLOADS PAGE
153 + =========================================== */
154 +
155 + /* Library downloads page (templates/pages/library_downloads.html).
156 + Scoped under .library-page body class. Class names like .item-footer,
157 + .bundle-child, etc. overlap with .item-page; the scope keeps them
158 + distinct. */
159 + .library-page .library-header {
160 + display: grid;
161 + grid-template-columns: 200px 1fr;
162 + gap: var(--gap-pane);
163 + margin-bottom: var(--gap-page);
164 + align-items: center;
165 + }
166 +
167 + .library-page .library-header.no-cover { grid-template-columns: 1fr; }
168 +
169 + .library-page .library-cover img {
170 + width: 100%;
171 + aspect-ratio: 1 / 1;
172 + object-fit: cover;
173 + display: block;
174 + }
175 +
176 + .library-page .library-title {
177 + font-size: var(--text-title);
178 + margin-bottom: var(--gap-bound);
179 + }
180 +
181 + /* Byline sits under the centered H1 (global `h1 { text-align: center }`),
182 + so center it too, otherwise it floats flush-left while the title is
183 + centered above. Same for the back-link breadcrumb. When a cover is
184 + present the grid scope below restores left-alignment in the text column. */
185 + .library-page .library-creator {
186 + font-size: var(--text-body);
187 + opacity: 0.7;
188 + margin-bottom: var(--gap-section);
189 + text-align: center;
190 + }
191 +
192 + .library-page .library-creator a { color: var(--content); }
193 +
194 + .library-page .library-back {
195 + font-size: var(--text-note);
196 + opacity: 0.7;
197 + text-align: center;
198 + }
199 + .library-page .library-back a { color: var(--content); }
200 +
201 + /* When a cover is present, the title block sits in the right grid column;
202 + left-align in that context so the column reads as a coherent block next
203 + to the cover instead of a centered island. */
204 + .library-page .library-header:not(.no-cover) .library-title,
205 + .library-page .library-header:not(.no-cover) .library-creator {
206 + text-align: left;
207 + }
208 +
209 + .library-page .downloads-hero h2 {
210 + font-size: var(--text-head);
211 + margin-bottom: var(--gap-section);
212 + padding-bottom: var(--gap-peer);
213 + border-bottom: 1px solid var(--border);
214 + }
215 +
216 + .library-page .download-row {
217 + display: flex;
218 + align-items: center;
219 + gap: var(--gap-section);
220 + padding: var(--gap-group) 0;
221 + border-bottom: 1px solid var(--border);
222 + }
223 +
224 + .library-page .download-row:last-child { border-bottom: none; }
225 +
226 + .library-page .download-version {
227 + min-width: 4em;
228 + font-family: var(--font-mono);
229 + font-size: var(--text-note);
230 + }
231 +
232 + .library-page .download-label { flex: 1; }
233 +
234 + .library-page .download-size {
235 + font-size: var(--text-note);
236 + opacity: 0.6;
237 + min-width: 5em;
238 + text-align: right;
239 + }
240 +
241 + .library-page .download-notice {
242 + background: var(--surface-sunken);
243 + padding: var(--gap-pane);
244 + margin-bottom: var(--gap-page);
245 + font-size: var(--text-note);
246 + opacity: 0.8;
247 + }
248 +
249 + .library-page .download-notice strong { font-family: var(--font-mono); }
250 +
251 + /* Inline download list on library_audio.html / library_video.html
252 + (below the media player). */
253 +
254 + .library-page .library-downloads h3 {
255 + font-size: var(--text-lead);
256 + margin-bottom: var(--gap-section);
257 + }
258 +
259 + .library-page .library-section h2 {
260 + font-size: var(--text-subhead);
261 + margin-bottom: var(--gap-section);
262 + padding-bottom: var(--gap-peer);
263 + border-bottom: 1px solid var(--border);
264 + }
265 +
266 + .library-page .bundle-child {
267 + display: flex;
268 + align-items: center;
269 + gap: var(--gap-section);
270 + padding: var(--gap-section) 0;
271 + border-bottom: 1px solid var(--border);
272 + }
273 +
274 + .library-page .bundle-child:last-child { border-bottom: none; }
275 +
276 + .library-page .bundle-child-type {
277 + font-size: var(--text-fine);
278 + padding: var(--gap-bound) var(--gap-group);
279 + background: var(--surface-sunken);
280 + white-space: nowrap;
281 + }
282 +
283 + .library-page .bundle-child-title { flex: 1; }
284 + .library-page .bundle-child-title a { color: var(--content); }
285 +
286 + .library-page .item-footer {
287 + margin-top: var(--gap-page);
288 + padding-top: var(--gap-page);
289 + border-top: 1px solid var(--border);
290 + text-align: center;
291 + opacity: 0.6;
292 + font-size: var(--text-note);
293 + }
294 +
295 + .library-page .item-footer a { color: var(--content); }
296 +
297 + @media (max-width: 599px) {
298 + .library-page .library-header { grid-template-columns: 1fr; }
299 + .library-page .library-cover img { max-width: 200px; }
300 + }
301 +
302 + /* ===========================================
303 + ARTICLE & BLOG READER
304 + =========================================== */
305 +
306 + /* Article reader pages (templates/pages/text_reader.html, blog_post.html,
307 + library_text.html). Long-form article layout, centered narrow column,
308 + author header, large heading, magazine-style body type. Scoped under
309 + .article-page body class. The container itself takes the shared reading
310 + column near the top of this file. */
311 +
312 + .article-page .author-header {
313 + display: flex;
314 + align-items: center;
315 + gap: var(--gap-section);
316 + margin-bottom: var(--gap-page);
317 + }
318 +
319 + .article-page .author-avatar {
320 + width: 48px;
321 + height: 48px;
322 + background: var(--surface-sunken);
323 + border-radius: var(--radius-round);
324 + display: flex;
325 + align-items: center;
326 + justify-content: center;
327 + font-family: var(--font-display);
328 + font-size: var(--text-subhead);
329 + color: var(--content);
330 + }
331 +
332 + .article-page .author-info { flex: 1; }
333 +
334 + .article-page .author-name {
335 + font-family: var(--font-mono);
336 + font-size: var(--text-body);
337 + color: var(--content);
338 + }
339 +
340 +
341 + .article-page .author-name a:hover { text-decoration: underline; }
342 +
343 + .article-page .article-meta {
344 + font-family: var(--font-mono);
345 + font-size: var(--text-note);
346 + color: var(--content-muted);
347 + }
348 +
349 + .article-page .article-title {
350 + font-family: var(--font-display);
351 + font-size: var(--text-display);
352 + font-weight: normal;
353 + line-height: 1.2;
354 + margin-bottom: var(--gap-pane);
355 + color: var(--content);
356 + text-align: left;
357 + }
358 +
359 + .article-page .article-deck {
360 + font-family: var(--font-mono);
361 + font-size: var(--text-subhead);
362 + color: var(--content-muted);
363 + margin-bottom: var(--gap-page);
364 + line-height: 1.6;
365 + }
366 +
367 + .article-page .article-body {
368 + font-family: var(--font-sans);
369 + font-size: var(--text-lead);
370 + line-height: 1.9;
371 + }
372 +
373 + .article-page .article-body p { margin-bottom: var(--gap-pane); }
374 +
375 + .article-page .article-body h1 {
376 + font-family: var(--font-display);
377 + font-size: var(--text-title);
378 + font-weight: normal;
379 + margin-top: var(--gap-page);
380 + margin-bottom: var(--gap-section);
381 + color: var(--content);
382 + text-align: left;
383 + }
384 +
385 + .article-page .article-body h2 {
386 + font-family: var(--font-mono);
387 + font-size: var(--text-head);
388 + font-weight: normal;
389 + margin-top: var(--gap-page);
390 + margin-bottom: var(--gap-section);
391 + color: var(--content);
392 + }
393 +
394 + .article-page .article-body h3 {
395 + font-family: var(--font-mono);
396 + font-size: var(--text-subhead);
397 + font-weight: normal;
398 + margin-top: var(--gap-page);
399 + margin-bottom: var(--gap-section);
400 + color: var(--content);
401 + }
402 +
403 + .article-page .article-body blockquote {
404 + border-left: 3px solid var(--action);
405 + padding-left: var(--gap-pane);
406 + margin: var(--gap-page) 0;
407 + font-style: italic;
408 + color: var(--content-muted);
409 + }
410 +
411 + .article-page .article-body ul,
412 + .article-page .article-body ol {
413 + margin-bottom: var(--gap-pane);
414 + padding-left: var(--gap-pane);
415 + }
416 +
417 + .article-page .article-body li { margin-bottom: var(--gap-peer); }
418 +
419 + .article-page .article-body a {
420 + color: var(--action);
421 + text-decoration: underline;
422 + text-decoration-color: color-mix(in oklch, var(--action) 40%, transparent);
423 + text-underline-offset: 2px;
424 + }
425 +
426 + .article-page .article-body a:hover {
427 + text-decoration-color: var(--action);
428 + }
429 +
430 + .article-page .article-body strong { font-weight: bold; }
431 + .article-page .article-body em { font-style: italic; }
432 +
433 + .article-page .article-body code {
434 + font-family: var(--font-mono);
435 + background: var(--surface-sunken);
436 + padding: var(--gap-bound) var(--gap-peer);
437 + font-size: var(--text-body);
438 + }
439 +
440 + .article-page .article-body pre {
441 + background: var(--surface-sunken);
442 + padding: var(--gap-section);
443 + overflow-x: auto;
444 + margin-bottom: var(--gap-pane);
445 + }
446 +
447 + .article-page .article-body pre code {
448 + background: none;
449 + padding: 0;
450 + }
451 +
452 + .article-page .article-body hr {
453 + border: none;
454 + text-align: center;
455 + margin: var(--gap-page) 0;
456 + }
457 +
458 + .article-page .article-body hr::before {
459 + content: "* * *";
460 + color: var(--content-muted);
461 + letter-spacing: 1rem;
462 + }
463 +
464 + .article-page .article-footer {
465 + margin-top: var(--gap-page);
466 + padding-top: var(--gap-page);
467 + border-top: 1px solid var(--border);
468 + }
469 +
470 + .article-page .author-bio {
471 + display: flex;
472 + gap: var(--gap-section);
473 + padding: var(--gap-pane);
474 + background: var(--surface-overlay);
475 + }
476 +
477 + .article-page .author-bio .author-avatar {
478 + width: 64px;
479 + height: 64px;
480 + font-size: var(--text-head);
481 + flex-shrink: 0;
482 + }
483 +
484 + .article-page .author-bio-content { flex: 1; }
485 +
486 + .article-page .author-bio-name {
487 + font-family: var(--font-mono);
488 + font-size: var(--text-lead);
489 + margin-bottom: var(--gap-peer);
490 + }
491 +
492 +
493 + .article-page .author-bio-name a:hover { text-decoration: underline; }
494 +
495 + .article-page .article-tags {
496 + margin-top: var(--gap-page);
497 + display: flex;
498 + gap: var(--gap-peer);
499 + flex-wrap: wrap;
500 + }
Lines truncated