diff --git a/apps/petrinaut-docs/README.md b/apps/petrinaut-docs/README.md index 2e7f2ceecb0..48d2328e97f 100644 --- a/apps/petrinaut-docs/README.md +++ b/apps/petrinaut-docs/README.md @@ -90,30 +90,146 @@ 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 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 +heading scale shorter than Starlight's, so hierarchy comes from weight and space +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 +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, 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, the on-this-page column and the +bar it collapses into below 72rem are gone. That bar needed the one under it +least of all: the fade band starts below it, so content dissolves before it +reaches a line. Element borders stay where they separate content from the page: tables, +code frames, and the markdown images that hold the diagrams. + +Both rails rest at 70% and come to full strength when the pointer or keyboard +focus reaches them, over 300ms. The collapse toggle and the theme select rest +further back at 50%: one small control in a wide header carries less than a +column of text does. A press dips the toggle and springs it back past its own +size. + +The header keeps no social links, so Starlight's `.social-icons::after` rule +would leave a rule dividing nothing; the group is hidden while it holds no +links, which brings it back if one is configured again. The theme select's +focus ring is restated in the palette's greys, since the browser's own arrives +as a thick blue rectangle over a control that has no border of its own. 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 band blurs and tints whatever +scrolls beneath it, so text does not meet the header on a hard edge. It runs to +two heights: the tint over 45px and the blur over 60px. Matching them hid the +blur completely, because the tint is opaque where the blur is strongest; the +stretch below the tint is where a reader actually sees content blurred. + +`backdrop-filter` takes a single radius, so the ramp comes from the mask rather +than from the filter: one layer fills the band at 0.5px of blur, at full strength +for its top half and out to nothing by its bottom, which is what keeps the blur +from ending on a line across the page. + +Both bands grow out of scroll and neither exists before it. The header script +writes progress from 0 to 1 over the first 20px, and a band's height, its tint +and its opacity all come off that one number, so an unscrolled page has nothing +laid over it at all. Twenty is well under one row, so a band is there as soon as +anything has gone up behind the header and the ramp only takes the hard edge off +its arrival. Nothing eases it: the value already tracks the scroll frame by +frame, and a transition on top would only make a band lag the rows it covers. + +The nav reads its own `scrollTop` rather than the window's, since the rail +scrolls independently of the page, and carries a second band at its foot, +mirrored: box, layer, mask and tint all run upwards from the bottom edge. That +one is driven by what is left below the fold rather than by what has gone above +it, so a rail short enough to need no scrolling never shows one and the band +closes as the last rows arrive. Opening a group changes how much is left without +scrolling anything, so a `ResizeObserver` on the rail's content recomputes it. The content band starts below +`--sl-mobile-toc-height` as well as the header: between 50rem and 72rem +Starlight moves the on-this-page column into a bar under the header, which the +band would otherwise blur. 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 +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`. + +## Diagrams are inlined + +The bundle emits each rendered diagram as a plain markdown image, which is what +keeps it portable. Behind an `` an SVG is a separate document, so nothing +on the page reaches it: not this site's palette, not its custom properties, not +the theme it is currently set to. +[`src/plugins/inline-diagrams.mjs`](src/plugins/inline-diagrams.mjs) replaces +those images with the SVG itself at build time, which hands it all three. The +generator writes every colour in the SVG as +`var(--pnd-diagram-*, )`, and `chrome.css` defines those +properties for dark mode; light mode needs nothing, because the fallbacks are +already the light palette. + +The plugin is registered through `markdown.rehypePlugins`, which is why +`@astrojs/markdown-remark` is a dependency: Astro no longer installs it by +default, and the hook does nothing without it. + +## 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..570b66addf4 100644 --- a/apps/petrinaut-docs/astro.config.mjs +++ b/apps/petrinaut-docs/astro.config.mjs @@ -7,6 +7,7 @@ import starlight from "@astrojs/starlight"; import { defineConfig, fontProviders } from "astro/config"; import { resolveDiffCompareContext } from "./src/diff-context"; +import { rehypeInlineDiagrams } from "./src/plugins/inline-diagrams.mjs"; /** * Renders the architecture bundle produced by `@local/petrinaut-arch-docs`. @@ -290,10 +291,28 @@ export default defineConfig({ define: { __PND_DIFF_COMPARE__: JSON.stringify(diffCompareContext), }, + + /* + * Lightning CSS folds the animation longhands into the `animation` + * shorthand, and puts the timeline in it: `animation: linear both + * pnd-fade-grow scroll(root)`. No browser accepts a timeline there — it was + * dropped from the shorthand while the spec settled — so the whole + * declaration is invalid and `animation-name` computes to `none`. That + * killed the scroll-driven fade bands in built output only, while the + * unminified dev server kept working, and the header script had already + * stood down because the browser does support the feature. + * + * esbuild minifies these rules without rewriting them. + */ + build: { cssMinify: "esbuild" }, }, ...(hasAuthoredIndex ? {} : { redirects: { "/": "/architecture" } }), + // Diagrams arrive as markdown images and are inlined so the page's palette + // reaches them; see the plugin. + markdown: { rehypePlugins: [rehypeInlineDiagrams] }, + // The bundle's inter-page links are relative and assume slugs map to URLs // without a trailing slash. `format: "file"` writes `views.html` rather than // `views/index.html`, so there is no trailing-slash form of a URL for a reader @@ -303,11 +322,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", @@ -355,13 +396,6 @@ export default defineConfig({ } catch {}`, }, ], - social: [ - { - icon: "github", - label: "GitHub", - href: "https://github.com/hashintel/hash/tree/main/libs/@hashintel/petrinaut", - }, - ], sidebar: buildSidebar(), // No llms.txt plugin: the bundle emits its own `architecture.md` and // `architecture.json`, which `scripts/sync-bundle.mjs` copies into diff --git a/apps/petrinaut-docs/package.json b/apps/petrinaut-docs/package.json index 05d336031fb..f03d044e4ad 100644 --- a/apps/petrinaut-docs/package.json +++ b/apps/petrinaut-docs/package.json @@ -6,13 +6,14 @@ "license": "(MIT OR Apache-2.0)", "type": "module", "scripts": { - "build": "astro build", + "build": "astro build && node scripts/check-built-css.mjs", "dev": "astro dev --port ${PORT:-4321}", "lint:tsc": "astro check --minimumSeverity error", "preview": "astro preview", "sync:bundle": "node scripts/sync-bundle.mjs" }, "dependencies": { + "@astrojs/markdown-remark": "7.2.4", "@astrojs/react": "6.0.2", "@astrojs/starlight": "0.41.5", "astro": "7.1.6", @@ -26,7 +27,9 @@ "@types/node": "22.18.13", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", - "typescript": "5.9.3" + "hast-util-from-html": "2.0.3", + "typescript": "5.9.3", + "unist-util-visit": "5.1.0" }, "engines": { "node": "22.x" diff --git a/apps/petrinaut-docs/scripts/check-built-css.mjs b/apps/petrinaut-docs/scripts/check-built-css.mjs new file mode 100644 index 00000000000..6bc497cce88 --- /dev/null +++ b/apps/petrinaut-docs/scripts/check-built-css.mjs @@ -0,0 +1,156 @@ +/** + * Checks the built stylesheets for rewrites that silently disable the chrome. + * + * This exists because one already happened. A CSS minifier folded the scroll + * timeline into the `animation` shorthand — `animation: linear both + * pnd-fade-grow scroll(root)` — and no browser accepts a timeline there, so the + * declaration was invalid and `animation-name` computed to `none`. The fade + * bands were dead in every built page while the unminified dev server kept + * working, and nothing failed: the header script stands down whenever the + * browser supports the CSS path, which it did. + * + * A build that produces no bands should not be publishable, so the shapes that + * carried that bug are asserted against here rather than left to a reader. + */ + +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; + +const assetsDir = path.join(import.meta.dirname, "..", "dist", "_astro"); + +/** A timeline inside the `animation` shorthand: the rewrite that broke it. */ +const timelineInShorthand = + /animation\s*:[^;}]*\b(?:scroll|view)\s*\(|animation\s*:[^;}]*--pnd-nav-scroll/u; + +/** + * Every custom property naming a resting opacity, wherever it is declared. + * + * @param {string} css + * @returns {Set} + */ +const idleOpacityTokens = (css) => + new Set( + [...css.matchAll(/(--pnd-[a-z-]*idle-opacity)\s*:/gu)].map( + (match) => match[1], + ), + ); + +/** + * The body of the `prefers-reduced-transparency` rule, braces balanced. + * + * @param {string} css + * @returns {string | null} + */ +const reducedTransparencyBlock = (css) => { + const opening = css.search(/@media[^{]*prefers-reduced-transparency[^{]*\{/u); + + if (opening === -1) { + return null; + } + + let depth = 0; + + for (let at = css.indexOf("{", opening); at < css.length; at += 1) { + if (css[at] === "{") { + depth += 1; + } else if (css[at] === "}") { + depth -= 1; + + if (depth === 0) { + return css.slice(opening, at + 1); + } + } + } + + return null; +}; + +/** + * Which resting opacities the reduced-transparency rule forgets to lift. + * + * A reader who asks for less transparency should get all of the chrome at full + * strength, and each of these tokens holds back one piece of it. Nothing ties a + * new token to that rule, so splitting one out silently leaves its element + * faded — which is how the left rail came to be the only thing still at 52.5% + * for exactly the readers who had asked it not to be. + * + * @param {string} css + * @returns {string[]} + */ +const unreset = (css) => { + const tokens = idleOpacityTokens(css); + + if (tokens.size === 0) { + return []; + } + + const block = reducedTransparencyBlock(css); + + if (block === null) { + return [...tokens]; + } + + return [...tokens].filter((token) => !block.includes(`${token}:`)); +}; + +/** + * @type {{ + * name: string, + * failing: (css: string) => boolean, + * detail?: (css: string) => string, + * }[]} + */ +const checks = [ + { + name: "no scroll timeline folded into the `animation` shorthand", + failing: (css) => timelineInShorthand.test(css), + }, + { + name: "the scroll-driven bands still declare a timeline", + failing: (css) => + css.includes("pnd-fade-grow") && !css.includes("animation-timeline:"), + }, + { + name: "`--pnd-fade-progress` is still registered, so it can interpolate", + failing: (css) => + css.includes("pnd-fade-grow") && + !css.includes("@property --pnd-fade-progress"), + }, + { + name: "every resting opacity is lifted under prefers-reduced-transparency", + failing: (css) => unreset(css).length > 0, + detail: (css) => unreset(css).join(", "), + }, +]; + +const files = (await readdir(assetsDir)).filter((name) => + name.endsWith(".css"), +); + +if (files.length === 0) { + console.error(`no stylesheets in ${assetsDir}; run the build first`); + process.exit(1); +} + +const failures = []; + +for (const file of files) { + const css = await readFile(path.join(assetsDir, file), "utf8"); + + for (const check of checks) { + if (check.failing(css)) { + const detail = check.detail?.(css); + failures.push(`${file}: ${check.name}${detail ? ` (${detail})` : ""}`); + } + } +} + +if (failures.length > 0) { + console.error("built CSS check failed:"); + for (const failure of failures) { + console.error(` ${failure}`); + } + process.exit(1); +} + +console.log(`built CSS check passed over ${files.length} stylesheets`); 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..039ffd50431 100644 --- a/apps/petrinaut-docs/src/components/SiteTitle.astro +++ b/apps/petrinaut-docs/src/components/SiteTitle.astro @@ -51,6 +51,22 @@ 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. One span, carrying the blur that the + band's mask ramps out. See `chrome.css`. +*/} + + +{/* The same band over the nav, grown out of how far the rail has scrolled, and + a mirrored one at its foot for whatever is still below the fold. */} + + +