From 8d52fc5240c12711ae0b218bc431a87009ffd523 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 3 Sep 2026 19:00:20 +0200 Subject: [PATCH 01/28] Restyle the architecture docs site and animate the sidebar Restate Starlight's palette and type scale as one flat monochrome system, so the chrome reads as a single surface divided by hairlines and colour is left to the things that carry meaning. Collapsing the left panel now animates: the pane hides with visibility rather than display, the content inset derives from the pane width, and every box sized from it transitions on one curve. A drag or a held arrow key already tracks its input, so the handle marks the gesture and the transitions stand aside. --- apps/petrinaut-docs/README.md | 86 ++- apps/petrinaut-docs/astro.config.mjs | 28 +- apps/petrinaut-docs/src/components/Head.astro | 6 +- .../src/components/SiteTitle.astro | 82 ++- apps/petrinaut-docs/src/styles/chrome.css | 564 ++++++++++++++++-- 5 files changed, 676 insertions(+), 90 deletions(-) diff --git a/apps/petrinaut-docs/README.md b/apps/petrinaut-docs/README.md index 2e7f2ceecb0..30fa883775f 100644 --- a/apps/petrinaut-docs/README.md +++ b/apps/petrinaut-docs/README.md @@ -90,30 +90,68 @@ its own `cookie@2.x` without changing hoisting for the rest of the monorepo. ## Chrome overrides [`src/styles/chrome.css`](src/styles/chrome.css), registered as Starlight's -`customCss`, narrows both side panels to give the content column more width, -tones down the header, and rounds the corners on markdown images so the embedded -diagrams match the bordered cards beside them. The left nav takes its own -`--pnd-sidebar-width` because Starlight sizes both panels from -`--sl-sidebar-width`, and collapsing the nav has to zero one of them without -flattening the other. The collapse toggle and resize handle come from -`components.SiteTitle`, the header's leftmost slot, next to the rail they act -on — and the fixed header is the one piece of chrome still on screen once the -nav is gone. Both -controls remember their state in `localStorage`, restored by a `head` script so -a collapsed sidebar does not render open and then jump. - -## The code font - -Code renders in JetBrains Mono, requested through Astro's font support in -[`astro.config.mjs`](astro.config.mjs) and emitted by `` in -[`src/components/Head.astro`](src/components/Head.astro). - -A build downloads one variable file covering weights 400 to 700, subsets it to -latin, and writes it beside the other assets, so a reader makes no request to a -font host. The head carries a `preload` link and `font-display: swap`, and Astro -generates a metric-matched fallback, which is what keeps text from shifting when -the file arrives. The downloaded originals are cached under `.astro/`, which is -ignored. +`customCss`, holds the whole of the site's appearance in three parts. + +**Tokens.** Starlight's palette and type scale are restated in a flat +monochrome key: one neutral ramp serving both themes, a header and sidebar that +share the page background and are divided by hairlines rather than by fills, and +an accent pointed at the strongest foreground instead of a colour. That last one +is why removing the blue took no hunting: Starlight routes links, the active nav +row and the active table-of-contents row through the accent tokens. Links stay +distinguishable by their underline. Type is 17px on a 1.65 leading with a +heading scale shorter than Starlight's, so hierarchy comes from weight and space +rather than size. + +Colour is left to what carries meaning: the diff badges, the asides, and the +accents the bundle's own cards paint themselves. Nothing in the bundle is styled +from here. Its CSS derives everything from `currentColor`, so it follows this +palette on its own, in both themes and in any other host. + +**Components.** Sidebar and table-of-contents rows as pills, a filled search +field, flat code frames (through the `--ec-*` variables Expressive Code reads), +underlined monochrome links, and rounded corners on markdown images so the +embedded diagrams match the bordered cards beside them. + +**The sidebar.** The left nav takes its own `--pnd-sidebar-width` because +Starlight sizes both panels from `--sl-sidebar-width`, and collapsing the nav has +to zero one of them without flattening the other. The collapse toggle and resize +handle come from `components.SiteTitle`, which renders at the header's leading +edge, next to the rail they act on. The fixed header is also the one piece of chrome +still on screen once the nav is gone. Both controls remember their state in +`localStorage`, restored by a `head` script so a collapsed sidebar does not +render open and then jump. + +Collapsing is animated, which is what the shape of those rules is for. The pane +keeps its box and hides with `visibility` rather than `display: none`, so there +is something to animate and the rows stay painted for the whole collapse. The +content column derives its inset as `max(sidebar-width, 2rem)`, so the toggle +moves one length between two ends instead of jumping between two unrelated +values. Every box that sizes off the sidebar width or `--sl-content-width` +transitions on the same curve, otherwise the column separates from the pane it +is following. + +A drag is the opposite case: it already tracks the pointer frame by frame, and a +transition on top of that makes the pane lag. The handle sets +`data-pnd-resizing` for the length of a gesture, either a pointer drag or a held +arrow key, and the transitions do not apply while it is set. The handle keeps a +6px hit area with a 2px mark that fades in on hover, and writes to +`localStorage` once on release rather than on every pointer position. All of the +motion is behind `prefers-reduced-motion`. + +## The fonts + +Body text renders in Inter and code in JetBrains Mono, both requested through +Astro's font support in [`astro.config.mjs`](astro.config.mjs) and emitted by +`` in [`src/components/Head.astro`](src/components/Head.astro). Inter +stands in for the grotesque the reference design uses, which is not licensed for +redistribution. + +A build downloads one variable file per family covering weights 400 to 700, +subsets it to latin, and writes it beside the other assets, so a reader makes no +request to a font host. The head carries a `preload` link and `font-display: +swap`, and Astro generates a metric-matched fallback, which is what keeps text +from shifting when the file arrives. The downloaded originals are cached under +`.astro/`, which is ignored. ## Deployment diff --git a/apps/petrinaut-docs/astro.config.mjs b/apps/petrinaut-docs/astro.config.mjs index 33853d50532..41f7aec06bf 100644 --- a/apps/petrinaut-docs/astro.config.mjs +++ b/apps/petrinaut-docs/astro.config.mjs @@ -303,11 +303,33 @@ export default defineConfig({ build: { format: "file" }, /* - * One variable file covers every weight the code blocks use, downloaded and - * subset at build time so a reader makes no request to a font host. Italic is - * left out: no code style in the docs uses it. + * One variable file per family covers every weight the site uses, downloaded + * and subset at build time so a reader makes no request to a font host. + * Italic is left out of both: no style in the docs uses it. + * + * Inter stands in for the grotesque the reference design uses, which is not + * licensed for redistribution. It is the closest freely available match on + * the details that carry that look: a tall x-height, flat terminals, and + * digits that hold their width. */ fonts: [ + { + provider: fontProviders.fontsource(), + name: "Inter", + cssVariable: "--pnd-font-sans", + weights: ["400 700"], + styles: ["normal"], + subsets: ["latin"], + fallbacks: [ + "-apple-system", + "BlinkMacSystemFont", + "Segoe UI", + "Roboto", + "Helvetica Neue", + "Arial", + "sans-serif", + ], + }, { provider: fontProviders.fontsource(), name: "JetBrains Mono", diff --git a/apps/petrinaut-docs/src/components/Head.astro b/apps/petrinaut-docs/src/components/Head.astro index 8d5b9f56bf2..64dd0ce63bd 100644 --- a/apps/petrinaut-docs/src/components/Head.astro +++ b/apps/petrinaut-docs/src/components/Head.astro @@ -1,8 +1,9 @@ --- /** - * Starlight's head, plus the font faces and preload links for the code font. + * Starlight's head, plus the font faces and preload links for the two families + * the site uses. * - * Astro's `` emits both, so the family has to be requested from a + * Astro's `` emits both, so each family has to be requested from a * component in the head rather than from configuration alone. */ @@ -11,4 +12,5 @@ import { Font } from "astro:assets"; --- + diff --git a/apps/petrinaut-docs/src/components/SiteTitle.astro b/apps/petrinaut-docs/src/components/SiteTitle.astro index 732e74cbca7..1a58fbe51cf 100644 --- a/apps/petrinaut-docs/src/components/SiteTitle.astro +++ b/apps/petrinaut-docs/src/components/SiteTitle.astro @@ -95,26 +95,74 @@ import DiffBadges from "./DiffBadges.astro"; remember("pnd:sidebar", collapsed ? "collapsed" : "expanded"); }); - /** @param {number} edge Where the pane's trailing edge should land, in px. */ - const resizeTo = (edge) => { - const width = `${Math.round(Math.min(Math.max(edge, 224), 480))}px`; + /** + * The pane width that puts its trailing edge at `edge`, clamped to the + * range the rail stays usable in. + * + * @param {number} edge In px, from the viewport's inline start. + * @returns {number} + */ + const widthFrom = (edge) => + Math.round(Math.min(Math.max(edge, 224), 480)); + + /** @param {number} width */ + const applyWidth = (width) => { + root.style.setProperty("--pnd-sidebar-open-width", `${width}px`); + }; + + /** The edge the handle is currently drawn at, in px. */ + const currentEdge = () => handle.getBoundingClientRect().left + 3; - root.style.setProperty("--pnd-sidebar-open-width", width); - remember("pnd:sidebar-width", width); + /** + * Marks a gesture that tracks its own input. `chrome.css` reads this to + * drop the toggle's width transitions while it is set: a transition on a + * value the pointer is already moving leaves the pane trailing the cursor + * by the whole duration. + * + * @param {boolean} active + */ + const setResizing = (active) => { + if (active) { + root.dataset.pndResizing = ""; + } else { + delete root.dataset.pndResizing; + } }; handle.addEventListener("pointerdown", (event) => { handle.setPointerCapture(event.pointerId); + setResizing(true); + + let width = widthFrom(currentEdge()); + let frame = 0; /** @param {PointerEvent} move */ - const onMove = (move) => resizeTo(move.clientX); + const onMove = (move) => { + width = widthFrom(move.clientX); + // A pointer reports several moves per frame at high polling rates, and + // every write invalidates layout for the pane, the content column and + // both panels sized from it. One write per frame is all a display can + // show anyway. + frame ||= requestAnimationFrame(() => { + frame = 0; + applyWidth(width); + }); + }; handle.addEventListener("pointermove", onMove); // `lostpointercapture` covers the cancelled gesture as well as the // released one, so the drag cannot outlive the pointer holding it. handle.addEventListener( "lostpointercapture", - () => handle.removeEventListener("pointermove", onMove), + () => { + handle.removeEventListener("pointermove", onMove); + cancelAnimationFrame(frame); + applyWidth(width); + setResizing(false); + // Once per gesture, not once per move: `localStorage` writes + // synchronously and a drag produces hundreds of positions. + remember("pnd:sidebar-width", `${width}px`); + }, { once: true }, ); }); @@ -123,12 +171,26 @@ import DiffBadges from "./DiffBadges.astro"; const step = event.key === "ArrowLeft" ? -16 : event.key === "ArrowRight" ? 16 : 0; - if (step !== 0) { - event.preventDefault(); - resizeTo(handle.getBoundingClientRect().left + 3 + step); + if (step === 0) { + return; } + + event.preventDefault(); + // A held arrow key tracks its input as much as a drag does, so it + // suppresses the transition the same way rather than chasing each + // repeat 200ms behind. + setResizing(true); + + const width = widthFrom(currentEdge() + step); + + applyWidth(width); + remember("pnd:sidebar-width", `${width}px`); }); + // Both, because a key released after focus has moved on fires only `blur`. + handle.addEventListener("keyup", () => setResizing(false)); + handle.addEventListener("blur", () => setResizing(false)); + // A gradient cannot ask whether the text fits, so the labels that do not // fit are marked here and the fade keys off the mark. Deferred because this // script is in the header, which the browser parses before the sidebar. diff --git a/apps/petrinaut-docs/src/styles/chrome.css b/apps/petrinaut-docs/src/styles/chrome.css index 66a389c5c7f..93f06e522f8 100644 --- a/apps/petrinaut-docs/src/styles/chrome.css +++ b/apps/petrinaut-docs/src/styles/chrome.css @@ -7,10 +7,391 @@ * there, so the `:root[data-has-sidebar]` prefixes below are load-bearing * specificity, not decoration. * + * The file runs in three parts: the tokens, which restate Starlight's own + * palette and type scale in a flat monochrome key; the component rules that + * follow from them; and the sidebar's collapse, resize and motion behaviour. + * * Every sidebar rule is gated on `[data-has-sidebar]`: the 404 page uses the * splash template, which has no sidebar to toggle, resize, or collapse. */ +/* Tokens ----------------------------------------------------------------- */ + +:root { + /* + * One neutral ramp for both themes, matching the scale openai.com uses. The + * chrome is monochrome. Colour is left to what carries meaning: the diff + * badges, the asides, and the accents the bundle's own cards paint + * themselves. + */ + --pnd-gray-0: #fff; + --pnd-gray-50: #f9f9f9; + --pnd-gray-75: #f3f3f3; + --pnd-gray-100: #e8e8e8; + --pnd-gray-200: #cdcdcd; + --pnd-gray-300: #afafaf; + --pnd-gray-400: #8f8f8f; + --pnd-gray-500: #5d5d5d; + --pnd-gray-600: #414141; + --pnd-gray-700: #303030; + --pnd-gray-800: #212121; + --pnd-gray-900: #181818; + --pnd-gray-1000: #000; + + --pnd-radius-sm: 0.375rem; + --pnd-radius: 0.625rem; + --pnd-radius-lg: 0.875rem; + + --pnd-motion-duration: 200ms; + --pnd-motion-ease: cubic-bezier(0.32, 0.72, 0, 1); +} + +/* + * Dark is the default theme (`Page.astro` stamps `data-theme="dark"`), so the + * dark values are the unqualified ones and light overrides below. + * + * Starlight's ramp runs from `--sl-color-white` as the strongest foreground to + * `--sl-color-black` as the page background, and inverts both in light mode, so + * `--sl-color-white` holds a black value further down. `--sl-color-gray-7` is + * only defined for light mode upstream, so dark has to name it or surfaces that + * use it fall back to nothing. + */ +:root { + --sl-color-white: var(--pnd-gray-0); + --sl-color-gray-1: var(--pnd-gray-75); + --sl-color-gray-2: var(--pnd-gray-200); + --sl-color-gray-3: var(--pnd-gray-400); + --sl-color-gray-4: var(--pnd-gray-500); + --sl-color-gray-5: var(--pnd-gray-700); + --sl-color-gray-6: var(--pnd-gray-800); + --sl-color-gray-7: var(--pnd-gray-900); + --sl-color-black: var(--pnd-gray-1000); + + /* Surfaces that sit on the page background rather than replacing it. */ + --pnd-surface: var(--pnd-gray-900); + --pnd-surface-strong: var(--pnd-gray-800); + --pnd-row-hover: var(--pnd-gray-900); + --pnd-row-active: var(--pnd-gray-800); + + /* + * Monochrome accent. Starlight routes links, the active nav row and the + * active table-of-contents row through the accent tokens, so pointing them at + * the strongest foreground is what removes the blue without having to find + * every rule that used it. Links stay distinguishable by their underline; see + * the content section. + */ + --sl-color-accent-low: var(--pnd-surface-strong); + --sl-color-accent: var(--sl-color-white); + --sl-color-accent-high: var(--sl-color-white); + --sl-color-text-accent: var(--sl-color-white); + --sl-color-bg-accent: var(--sl-color-white); + --sl-color-text-invert: var(--sl-color-black); + + --sl-color-text: var(--sl-color-gray-2); + --sl-color-bg-inline-code: var(--pnd-surface-strong); + + /* + * Header and sidebar share the page background, so the chrome is one surface + * divided by hairlines. + */ + --sl-color-bg-nav: var(--sl-color-bg); + --sl-color-bg-sidebar: var(--sl-color-bg); + --sl-color-hairline: var(--pnd-gray-800); + --sl-color-hairline-light: var(--pnd-gray-700); + --sl-color-hairline-shade: var(--pnd-gray-800); + + --sl-color-backdrop-overlay: rgb(0 0 0 / 0.6); + + /* Flat by default. The two lifted surfaces are the search dialog and the + mobile table-of-contents dropdown, which need to sit above the page. */ + --sl-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.25); + --sl-shadow-md: 0 1px 2px rgb(0 0 0 / 0.25), 0 8px 24px rgb(0 0 0 / 0.35); + --sl-shadow-lg: 0 1px 2px rgb(0 0 0 / 0.3), 0 16px 48px rgb(0 0 0 / 0.45); +} + +:root[data-theme="light"] { + --sl-color-white: var(--pnd-gray-1000); + --sl-color-gray-1: var(--pnd-gray-900); + --sl-color-gray-2: var(--pnd-gray-700); + --sl-color-gray-3: var(--pnd-gray-500); + --sl-color-gray-4: var(--pnd-gray-400); + --sl-color-gray-5: var(--pnd-gray-200); + --sl-color-gray-6: var(--pnd-gray-100); + --sl-color-gray-7: var(--pnd-gray-75); + --sl-color-black: var(--pnd-gray-0); + + --pnd-surface: var(--pnd-gray-50); + --pnd-surface-strong: var(--pnd-gray-75); + --pnd-row-hover: var(--pnd-gray-50); + --pnd-row-active: var(--pnd-gray-75); + + --sl-color-bg-inline-code: var(--pnd-gray-75); + --sl-color-hairline: var(--pnd-gray-100); + --sl-color-hairline-light: var(--pnd-gray-100); + --sl-color-hairline-shade: var(--pnd-gray-100); + + --sl-color-backdrop-overlay: rgb(0 0 0 / 0.3); + + --sl-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.06); + --sl-shadow-md: 0 1px 2px rgb(0 0 0 / 0.06), 0 8px 24px rgb(0 0 0 / 0.08); + --sl-shadow-lg: 0 1px 2px rgb(0 0 0 / 0.08), 0 16px 48px rgb(0 0 0 / 0.12); +} + +/* Type ------------------------------------------------------------------- */ + +/* + * Starlight composes its own system stack after both of these, so each stays + * the fallback. The sans face is requested in `astro.config.mjs` and emitted by + * `components/Head.astro`, same as the mono one. + */ +:root { + --sl-font: var(--pnd-font-sans); + --sl-font-mono: var(--pnd-font-mono); + + /* + * 17px on a 1.65 leading, and a heading scale shorter than Starlight's, whose + * desktop h1 is 42px. Hierarchy comes from weight and space instead of size. + */ + --sl-text-body: 1.0625rem; + --sl-line-height: 1.65; + --sl-line-height-headings: 1.25; + --sl-text-h1: 2rem; + --sl-text-h2: 1.5rem; + --sl-text-h3: 1.25rem; + --sl-text-h4: 1.0625rem; + --sl-text-h5: 0.9375rem; +} + +body { + letter-spacing: -0.011em; +} + +/* The tightening that reads as deliberate at heading sizes is too much at + body size, so the two are set apart rather than inherited. */ +h1, +h2, +h3, +h4, +h5, +h6 { + letter-spacing: -0.022em; +} + +/* Header ---------------------------------------------------------------- */ + +.header { + border-bottom: 1px solid var(--sl-color-hairline); + box-shadow: none; +} + +.site-title { + gap: 0.6rem; + font-size: var(--sl-text-sm); + font-weight: 500; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.site-title img { + height: 1.5rem; +} + +/* Search --------------------------------------------------------------- */ + +/* + * Filled, with a hairline border and the same radius as the rest of the + * chrome. + */ +button[data-open-modal] { + border-radius: var(--pnd-radius); + transition: + background-color 140ms ease, + border-color 140ms ease, + color 140ms ease; +} + +@media (min-width: 50rem) { + button[data-open-modal] { + border-color: var(--sl-color-hairline); + background-color: var(--pnd-surface); + color: var(--sl-color-gray-3); + } + + button[data-open-modal]:hover { + border-color: var(--sl-color-hairline-light); + background-color: var(--pnd-surface-strong); + color: var(--sl-color-white); + } +} + +button[data-open-modal] > kbd { + border-radius: var(--pnd-radius-sm); + background-color: transparent; + color: var(--sl-color-gray-4); +} + +dialog { + border-color: var(--sl-color-hairline-light); + border-radius: var(--pnd-radius-lg); + background-color: var(--sl-color-bg); +} + +/* Sidebar nav ----------------------------------------------------------- */ + +/* + * Rows are pills: one radius, one hover fill, and an active row that reads by + * weight and contrast rather than by a coloured block. Sizes are equal across + * levels, and a top-level entry is distinguished by weight. + */ +.sidebar-content a, +.sidebar-content summary { + border-radius: var(--pnd-radius-sm); + padding-block: 0.3rem; + transition: + background-color 120ms ease, + color 120ms ease; +} + +.sidebar-content a { + color: var(--sl-color-gray-3); +} + +.sidebar-content a:hover, +.sidebar-content a:focus-visible, +.sidebar-content summary:hover { + background-color: var(--pnd-row-hover); + color: var(--sl-color-white); +} + +.sidebar-content a[aria-current="page"], +.sidebar-content a[aria-current="page"]:hover, +.sidebar-content a[aria-current="page"]:focus { + background-color: var(--pnd-row-active); + color: var(--sl-color-white); + font-weight: 500; +} + +/* + * Top-level rows, whether a link or a group's label. Equal in size to their + * children: the hierarchy is weight and colour. The colour is restated because + * `.sidebar-content a` above outranks Starlight's own `.large`, which would + * leave a top-level link muted beside a top-level group. + */ +.sidebar-content .large { + font-size: var(--sl-text-sm); + font-weight: 500; + color: var(--sl-color-gray-1); +} + +/* The guides make a deeply nested tree readable, so they stay. They use the + lighter hairline: the flat one disappears against a pure-black pane. */ +.sidebar-content ul ul li { + border-inline-start-color: var(--sl-color-hairline-light); +} + +.sidebar-content .caret { + color: var(--sl-color-gray-4); +} + +/* Thin and monochrome, and only on the pane that scrolls independently of the + page. Standard properties only: the `::-webkit-scrollbar` form would swap + WebKit's overlay scrollbar for a permanent gutter. */ +.sidebar-pane { + scrollbar-width: thin; + scrollbar-color: var(--sl-color-gray-5) transparent; +} + +/* On this page ---------------------------------------------------------- */ + +/* + * Sized as a label. Starlight sizes it as a heading, at `--sl-text-h5` in the + * strongest foreground, which gave the right-hand column a title competing with + * the page's own. + */ +.right-sidebar-panel h2 { + margin-bottom: 0.75rem; + font-size: var(--sl-text-xs); + font-weight: 500; + color: var(--sl-color-gray-3); +} + +.right-sidebar-panel a { + border-radius: var(--pnd-radius-sm); + padding-block: 0.3rem; + transition: color 120ms ease; +} + +.right-sidebar-panel a[aria-current="true"] { + color: var(--sl-color-white); + font-weight: 500; +} + +.right-sidebar { + border-inline-start: 1px solid var(--sl-color-hairline); +} + +/* Content --------------------------------------------------------------- */ + +/* + * Starlight rules a line between every pair of content panels, which on a + * generated page falls directly under the title and reads as a header border. + * The space alone separates them. + */ +.content-panel + .content-panel { + border-top: 0; +} + +/* + * Links carry an underline rather than a colour, so they stay visible in a + * monochrome palette. The resting colour is one step down from the text, so the + * underline does not outweigh the word it marks. + */ +.sl-markdown-content a:not(:where(.not-content *)) { + text-decoration: underline; + text-decoration-color: var(--sl-color-gray-4); + text-decoration-thickness: 1px; + text-underline-offset: 0.2em; + transition: text-decoration-color 120ms ease; +} + +.sl-markdown-content a:not(:where(.not-content *)):hover { + text-decoration-color: currentColor; +} + +.sl-markdown-content :is(th, td):not(:where(.not-content *)) { + border-color: var(--sl-color-hairline); +} + +/* + * Rendered diagrams arrive as plain markdown images with a white background + * baked into the SVG, so they read as cards once their edge matches the layer + * cards beside them. + */ +.sl-markdown-content img { + border: 1px solid var(--sl-color-hairline-light); + border-radius: var(--pnd-radius-lg); +} + +/* + * Expressive Code declares these on `:root` inside `@layer starlight.components` + * and again on the block itself for a code block whose theme differs from the + * page's, so both scopes are named here. Layer order settles the first. Only a + * rule on the element settles the second. + */ +:root, +.expressive-code { + --ec-brdRad: var(--pnd-radius); + --ec-brdCol: var(--sl-color-hairline-light); + --ec-codeBg: var(--pnd-surface); + --ec-frm-edBg: var(--pnd-surface); + --ec-frm-trmBg: var(--pnd-surface); + --ec-frm-edTabBarBg: var(--pnd-surface-strong); + --ec-frm-frameBoxShdCssVal: none; + --ec-frm-shdCol: transparent; +} + +/* Sidebar geometry ------------------------------------------------------ */ + :root { /* * Starlight derives both side panels from `--sl-sidebar-width`. The left nav @@ -34,42 +415,44 @@ /* 50rem is where Starlight turns the mobile drawer into a fixed rail. */ @media (min-width: 50rem) { :root[data-has-sidebar] { - --sl-content-inline-start: var(--pnd-sidebar-width); + /* + * One expression covering both states, so collapsing moves the column + * between two ends of a single length. This used to be the pane's width + * with a separate 2rem rule for the collapsed state, which is the same + * thing to lay out but two unrelated values to animate between. 2rem is + * the floor because below 72rem nothing else insets the column from the + * edge. + */ + --sl-content-inline-start: max(var(--pnd-sidebar-width), 2rem); } .sidebar-pane { width: var(--pnd-sidebar-width); + /* + * A scroll container with `overflow-y: auto` computes `overflow-x` to + * `auto` as well, which flashes a horizontal scrollbar across the whole + * collapse as the rows outgrow the narrowing pane. + */ + overflow-x: clip; } + /* + * Collapsed, the pane keeps its box so `width` has something to animate, and + * `visibility` is what takes it out of the tab order and the accessibility + * tree. Transitioning `visibility` holds `visible` for the whole collapse and + * flips it at the end, so the rows travel with the pane instead of being + * blanked on the first frame. This replaces a `display: none`, which no + * transition can survive. + */ :root[data-pnd-sidebar="collapsed"] .sidebar-pane { - display: none; + visibility: hidden; + /* + * Zero rather than transparent: the trailing border is a pixel of box that + * a `width: 0` pane still reserves, which left an empty hairline column + * against the viewport edge. + */ + border-inline-end-width: 0; } - - /* Not zero: below 72rem nothing else insets the column from the edge. */ - :root[data-has-sidebar][data-pnd-sidebar="collapsed"] { - --sl-content-inline-start: 2rem; - } -} - -/* Code font -------------------------------------------------------------- */ - -/* Starlight composes its own system stack after this, so it stays the fallback. */ -:root { - --sl-font-mono: var(--pnd-font-mono); -} - -/* Header ---------------------------------------------------------------- */ - -.site-title { - gap: 0.6rem; - font-size: var(--sl-text-sm); - font-weight: 500; - letter-spacing: 0.09em; - text-transform: uppercase; -} - -.site-title img { - height: 1.5rem; } /* Sidebar controls ------------------------------------------------------ */ @@ -95,16 +478,17 @@ height: var(--sl-menu-button-size); padding: 0; border: 0; - border-radius: 0.25rem; + border-radius: var(--pnd-radius-sm); background: none; color: var(--sl-color-gray-3); cursor: pointer; + transition: + background-color 120ms ease, + color 120ms ease; } .pnd-sidebar-toggle:hover { - /* One step lighter than the header's own background, which in dark mode - is `--sl-color-gray-6` itself — that value would make hover invisible. */ - background: var(--sl-color-gray-5); + background: var(--pnd-row-active); color: var(--sl-color-white); } @@ -123,14 +507,101 @@ touch-action: none; } - .pnd-sidebar-resize:hover, .pnd-sidebar-resize:focus-visible { - background: var(--sl-color-accent); outline: none; } + /* + * The hit area stays 6px wide while the mark is 2px, centred on the pane's + * own border, so the target is easy to hit and the mark reads as that edge + * thickening. Filling all 6px with the accent colour, as this used to, made + * the edge jump three pixels wider and change hue when the pointer crossed + * it. + */ + .pnd-sidebar-resize::after { + content: ""; + position: absolute; + inset-block: 0; + inset-inline-start: 2px; + width: 2px; + background: var(--sl-color-gray-4); + opacity: 0; + transition: opacity 140ms ease; + } + + .pnd-sidebar-resize:hover::after, + .pnd-sidebar-resize:focus-visible::after, + :root[data-pnd-resizing] .pnd-sidebar-resize::after { + background: var(--sl-color-gray-3); + opacity: 1; + } + :root[data-pnd-sidebar="collapsed"] .pnd-sidebar-resize { - display: none; + visibility: hidden; + pointer-events: none; + } +} + +/* + * The pointer spends a drag outside the 6px strip. Pointer capture keeps the + * events coming, but without these the cursor flickers to whatever it is over + * and the drag selects the page text it crosses. + */ +:root[data-pnd-resizing] { + user-select: none; +} + +:root[data-pnd-resizing] * { + cursor: col-resize; +} + +/* Sidebar motion -------------------------------------------------------- */ + +/* + * Toggling is a jump between two widths, so it is animated. A drag and a held + * arrow key already track their input frame by frame, and a transition on top + * of that makes the pane lag behind the pointer. The handle sets + * `data-pnd-resizing` for the length of the gesture, and these rules do not + * apply while it is set. + * + * Everything listed sizes off the sidebar width or `--sl-content-width`. They + * have to move on the same curve over the same duration, or the content column + * tears away from the pane it is following. + * + * Transitions do not run on an element's first style computation, so a sidebar + * restored as collapsed by the head script renders collapsed rather than + * animating open on load. + */ +@media (prefers-reduced-motion: no-preference) and (min-width: 50rem) { + :root:not([data-pnd-resizing]) .sidebar-pane { + transition: + width var(--pnd-motion-duration) var(--pnd-motion-ease), + border-inline-end-width var(--pnd-motion-duration) var(--pnd-motion-ease), + visibility var(--pnd-motion-duration) linear; + } + + :root:not([data-pnd-resizing]) .main-frame { + transition: padding-inline-start var(--pnd-motion-duration) + var(--pnd-motion-ease); + } + + :root:not([data-pnd-resizing]) .content-panel > .sl-container { + transition: max-width var(--pnd-motion-duration) var(--pnd-motion-ease); + } + + :root:not([data-pnd-resizing]) .pnd-sidebar-resize { + transition: + inset-inline-start var(--pnd-motion-duration) var(--pnd-motion-ease), + visibility var(--pnd-motion-duration) linear; + } +} + +/* 72rem is where the on-this-page column appears and starts sharing the row's + width with the content, so both size off `--sl-content-width` from here up. */ +@media (prefers-reduced-motion: no-preference) and (min-width: 72rem) { + :root:not([data-pnd-resizing]) .main-pane, + :root:not([data-pnd-resizing]) .right-sidebar-container { + transition: width var(--pnd-motion-duration) var(--pnd-motion-ease); } } @@ -188,8 +659,8 @@ block-size: 0; overflow: hidden; transition: - block-size 220ms ease, - content-visibility 220ms allow-discrete; + block-size var(--pnd-motion-duration) var(--pnd-motion-ease), + content-visibility var(--pnd-motion-duration) allow-discrete; } :root[data-pnd-ready] .sidebar-content details[open]::details-content { @@ -198,19 +669,6 @@ } } -/* Content --------------------------------------------------------------- */ - -/* - * Rendered diagrams arrive as plain markdown images with a white background - * baked into the SVG, so they read as cards once their edge matches the layer - * cards beside them. Radius and border are `.arch-card`'s own values, which is - * why the border is a `color-mix` rather than a Starlight hairline token. - */ -.sl-markdown-content img { - border: 1px solid color-mix(in srgb, currentColor 22%, transparent); - border-radius: 0.5rem; -} - /* Diff badges ------------------------------------------------------------ */ /* @@ -247,6 +705,7 @@ .sidebar-content .sl-badge { flex: none; padding: 0 0.3rem; + border-radius: var(--pnd-radius-sm); font-size: 0.6rem; line-height: 1.4; } @@ -279,10 +738,13 @@ align-items: center; gap: 0.35rem; padding: 0.05rem 0.55rem; - border: 1px solid var(--sl-color-gray-5); + border: 1px solid var(--sl-color-hairline-light); border-radius: 999px; color: var(--sl-color-gray-2); text-decoration: none; + transition: + border-color 120ms ease, + color 120ms ease; } .pnd-diff-compare .pnd-diff-chip:hover { From 6ca3ec5733a622f9769cc74eea5620829b1137ad Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 3 Sep 2026 19:27:51 +0200 Subject: [PATCH 02/28] Remove the chrome dividers and fill the compare chips The borders framing the header, the sidebar and the on-this-page column are gone, leaving the sidebar's depth guide as the only rule drawn in the chrome, where it says how deep a row sits in the tree. The search field and the compare chips now read by fill rather than by an outline, one step off the page background in either theme. --- apps/petrinaut-docs/README.md | 20 ++++++--- apps/petrinaut-docs/src/styles/chrome.css | 53 +++++++++++------------ 2 files changed, 40 insertions(+), 33 deletions(-) diff --git a/apps/petrinaut-docs/README.md b/apps/petrinaut-docs/README.md index 30fa883775f..e130ff63914 100644 --- a/apps/petrinaut-docs/README.md +++ b/apps/petrinaut-docs/README.md @@ -94,8 +94,8 @@ its own `cookie@2.x` without changing hoisting for the rest of the monorepo. **Tokens.** Starlight's palette and type scale are restated in a flat monochrome key: one neutral ramp serving both themes, a header and sidebar that -share the page background and are divided by hairlines rather than by fills, and -an accent pointed at the strongest foreground instead of a colour. That last one +share the page background and carry no divider between them, and an accent +pointed at the strongest foreground instead of a colour. That last one is why removing the blue took no hunting: Starlight routes links, the active nav row and the active table-of-contents row through the accent tokens. Links stay distinguishable by their underline. Type is 17px on a 1.65 leading with a @@ -107,10 +107,18 @@ accents the bundle's own cards paint themselves. Nothing in the bundle is styled from here. Its CSS derives everything from `currentColor`, so it follows this palette on its own, in both themes and in any other host. -**Components.** Sidebar and table-of-contents rows as pills, a filled search -field, flat code frames (through the `--ec-*` variables Expressive Code reads), -underlined monochrome links, and rounded corners on markdown images so the -embedded diagrams match the bordered cards beside them. +**Components.** Sidebar and table-of-contents rows as pills, flat code frames +(through the `--ec-*` variables Expressive Code reads), underlined monochrome +links, and rounded corners on markdown images so the embedded diagrams match the +bordered cards beside them. Controls read by fill: the search field and the +diff-build compare chips sit one step off the page background rather than +carrying an outline. + +The sidebar's depth guide is the only rule drawn anywhere in the chrome, and it +is there because it carries information: how deep a row sits in the tree. The +borders that framed the header, the sidebar and the on-this-page column are +gone. Element borders stay where they separate content from the page: tables, +code frames, and the markdown images that hold the diagrams. **The sidebar.** The left nav takes its own `--pnd-sidebar-width` because Starlight sizes both panels from `--sl-sidebar-width`, and collapsing the nav has diff --git a/apps/petrinaut-docs/src/styles/chrome.css b/apps/petrinaut-docs/src/styles/chrome.css index 93f06e522f8..9ebd92fbc76 100644 --- a/apps/petrinaut-docs/src/styles/chrome.css +++ b/apps/petrinaut-docs/src/styles/chrome.css @@ -91,8 +91,9 @@ --sl-color-bg-inline-code: var(--pnd-surface-strong); /* - * Header and sidebar share the page background, so the chrome is one surface - * divided by hairlines. + * Header and sidebar share the page background and carry no divider. The only + * rule left in the chrome is the sidebar's depth guide, which says something: + * how deep a row sits in the tree. */ --sl-color-bg-nav: var(--sl-color-bg); --sl-color-bg-sidebar: var(--sl-color-bg); @@ -180,7 +181,7 @@ h6 { /* Header ---------------------------------------------------------------- */ .header { - border-bottom: 1px solid var(--sl-color-hairline); + border-bottom: 0; box-shadow: none; } @@ -199,27 +200,26 @@ h6 { /* Search --------------------------------------------------------------- */ /* - * Filled, with a hairline border and the same radius as the rest of the - * chrome. + * Filled rather than outlined, at the same radius as the rest of the chrome. + * The fill is what marks it as a control, so hovering deepens the fill instead + * of drawing an edge. */ button[data-open-modal] { border-radius: var(--pnd-radius); transition: background-color 140ms ease, - border-color 140ms ease, color 140ms ease; } @media (min-width: 50rem) { button[data-open-modal] { - border-color: var(--sl-color-hairline); + border-color: transparent; background-color: var(--pnd-surface); color: var(--sl-color-gray-3); } button[data-open-modal]:hover { - border-color: var(--sl-color-hairline-light); - background-color: var(--pnd-surface-strong); + background-color: var(--sl-color-hairline-light); color: var(--sl-color-white); } } @@ -283,8 +283,11 @@ dialog { color: var(--sl-color-gray-1); } -/* The guides make a deeply nested tree readable, so they stay. They use the - lighter hairline: the flat one disappears against a pure-black pane. */ +/* + * The depth guides are the only rule left in the chrome, and the reason is that + * they carry information: how deep a row sits in the tree. They use the lighter + * hairline, since the flat one disappears against a pure-black pane. + */ .sidebar-content ul ul li { border-inline-start-color: var(--sl-color-hairline-light); } @@ -327,7 +330,7 @@ dialog { } .right-sidebar { - border-inline-start: 1px solid var(--sl-color-hairline); + border-inline-start: 0; } /* Content --------------------------------------------------------------- */ @@ -428,6 +431,7 @@ dialog { .sidebar-pane { width: var(--pnd-sidebar-width); + border-inline-end: 0; /* * A scroll container with `overflow-y: auto` computes `overflow-x` to * `auto` as well, which flashes a horizontal scrollbar across the whole @@ -446,12 +450,6 @@ dialog { */ :root[data-pnd-sidebar="collapsed"] .sidebar-pane { visibility: hidden; - /* - * Zero rather than transparent: the trailing border is a pixel of box that - * a `width: 0` pane still reserves, which left an empty hairline column - * against the viewport edge. - */ - border-inline-end-width: 0; } } @@ -513,10 +511,9 @@ dialog { /* * The hit area stays 6px wide while the mark is 2px, centred on the pane's - * own border, so the target is easy to hit and the mark reads as that edge - * thickening. Filling all 6px with the accent colour, as this used to, made - * the edge jump three pixels wider and change hue when the pointer crossed - * it. + * trailing edge, so the target is easy to hit and the mark stays thin. With + * no divider drawn between the panes, this is the only thing that marks that + * edge, and it appears under the pointer that is looking for it. */ .pnd-sidebar-resize::after { content: ""; @@ -576,7 +573,6 @@ dialog { :root:not([data-pnd-resizing]) .sidebar-pane { transition: width var(--pnd-motion-duration) var(--pnd-motion-ease), - border-inline-end-width var(--pnd-motion-duration) var(--pnd-motion-ease), visibility var(--pnd-motion-duration) linear; } @@ -737,18 +733,21 @@ dialog { display: inline-flex; align-items: center; gap: 0.35rem; - padding: 0.05rem 0.55rem; - border: 1px solid var(--sl-color-hairline-light); + padding: 0.15rem 0.6rem; + border: 0; border-radius: 999px; + /* One step off the page rather than an outline, so the chips separate from + the header without adding a line to it. */ + background: var(--pnd-surface-strong); color: var(--sl-color-gray-2); text-decoration: none; transition: - border-color 120ms ease, + background-color 120ms ease, color 120ms ease; } .pnd-diff-compare .pnd-diff-chip:hover { - border-color: var(--sl-color-gray-3); + background: var(--sl-color-hairline-light); color: var(--sl-color-white); } From 181460e4cb96d1735cc6fa283caffb775547664c Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 4 Sep 2026 01:12:33 +0200 Subject: [PATCH 03/28] Rest the chrome at 70% and blur the content under the header Both rails and the collapse toggle sit at 70% until the pointer or keyboard focus reaches them, over 300ms, with the whole rail as the hover target. prefers-reduced-transparency turns the effect off. A 30px band under the header blurs and tints the content passing beneath it. backdrop-filter cannot ramp on its own, so the ramp is three stacked layers of increasing radius, each masked to a shorter band, and each filtering what the one beneath it produced. Blocks now sit 1.5rem apart with 2.25em above a section heading, and body copy is 17px: Starlight defines --sl-text-body but never applies it, so the size had to be set here. --- apps/petrinaut-docs/README.md | 21 ++- .../src/components/SiteTitle.astro | 14 ++ apps/petrinaut-docs/src/styles/chrome.css | 172 +++++++++++++++++- 3 files changed, 204 insertions(+), 3 deletions(-) diff --git a/apps/petrinaut-docs/README.md b/apps/petrinaut-docs/README.md index e130ff63914..fb0d02d9bc5 100644 --- a/apps/petrinaut-docs/README.md +++ b/apps/petrinaut-docs/README.md @@ -100,7 +100,10 @@ is why removing the blue took no hunting: Starlight routes links, the active nav row and the active table-of-contents row through the accent tokens. Links stay distinguishable by their underline. Type is 17px on a 1.65 leading with a heading scale shorter than Starlight's, so hierarchy comes from weight and space -rather than size. +rather than size. Starlight defines `--sl-text-body` but never applies it, so +the body size is set here or copy stays at the browser default. Blocks sit +1.5rem apart and a section heading takes 2.25em above it, which is what makes a +new section read as a break rather than another paragraph. Colour is left to what carries meaning: the diff badges, the asides, and the accents the bundle's own cards paint themselves. Nothing in the bundle is styled @@ -120,6 +123,22 @@ borders that framed the header, the sidebar and the on-this-page column are gone. Element borders stay where they separate content from the page: tables, code frames, and the markdown images that hold the diagrams. +Both rails and the collapse toggle rest at 70% and come to full strength when +the pointer or keyboard focus reaches them, over 300ms. The hover target is the +whole rail, so the reveal does not depend on landing on a row, and the opacity +sits on each rail's inner element rather than the scrolling pane, which already +owns a transition for the collapse. `prefers-reduced-transparency` turns the +whole effect off. + +Under the header, over the content column only, a 30px band blurs and tints +whatever scrolls beneath it, so text does not meet the header on a hard edge. +`backdrop-filter` cannot ramp on its own, so the ramp is three stacked layers, +each blurring harder than the last and masked to a shorter band; each filters +what the one beneath it produced, which is what makes the blur compound rather +than step. The band is positioned against the viewport from +`components.SiteTitle`, like the resize handle, and its right inset restates +Starlight's own width for the on-this-page column since no variable holds it. + **The sidebar.** The left nav takes its own `--pnd-sidebar-width` because Starlight sizes both panels from `--sl-sidebar-width`, and collapsing the nav has to zero one of them without flattening the other. The collapse toggle and resize diff --git a/apps/petrinaut-docs/src/components/SiteTitle.astro b/apps/petrinaut-docs/src/components/SiteTitle.astro index 1a58fbe51cf..116f6d3e356 100644 --- a/apps/petrinaut-docs/src/components/SiteTitle.astro +++ b/apps/petrinaut-docs/src/components/SiteTitle.astro @@ -51,6 +51,20 @@ import DiffBadges from "./DiffBadges.astro"; > +{/* + The band of blur under the header, over the content column only. Rendered + here for the same reason as the resize handle: this component renders at the + header's leading edge, and the element is positioned against the viewport + rather than laid out in the header. Three spans because the ramp is three + backdrop filters of increasing radius, each masked to a shorter band. See + `chrome.css`. +*/} + +