documentation/STATIC-SITE-DESIGN.md · main · 2026-09-21

Static Site Design

What the site in a bucket looks like and what a visual change must satisfy; STATIC-SITE.md says how that site is built and served.

Principles · Tokens · Layout · Components · States · Repo-shape rules · Fixtures and visual tests · Change rules

Principles

The site is the app shell, the pre-rendered pages and the two stylesheets. Five rules hold across all three.

  1. A document first, an app second. Every page reads without JS. The app adds to what the reader sees and never replaces it under them.
  2. One vocabulary. The page layer and the app render a component from the same class names and the same tokens. Two markups for one component is a defect.
  3. Content sets the tone. Body text is serif; chrome, code and metadata are small mono. Nothing is decorative.
  4. Every rule holds on any repository: no README, no issues, a tree of thousands of files, an .mdx documentation site.
  5. Fail in place. A missing asset or a failed fetch shows a one-line notice where the content would be, never a blank and never an endless spinner.

Tokens

Declared once in pages-core.css, consumed by both stylesheets and both renderers.

GroupTokensRule
Type--fs-h1 2.25rem, --fs-h2 2rem, --fs-h3 1.75rem, --fs-h4 1.5rem, --fs-body 21px with --lh-body 1.2, --fs-md 0.95rem, --fs-ui 0.8rem, --fs-code 0.85rem, --fs-dense 0.72remno font-size literal outside these
Fonts--serif EB Garamond, Georgia; --mono IBM Plex Monoa page that has not loaded pages-full.css reads in Georgia at the same size
Palette--bg, --text, --link, --card, from the light set --pl-* or the dark set --pd-*; file-type hues --i-*no hex outside :root
State colors--open #1f9d55, --closed #8957e5, --merged #8250df, --warn #bf8700, --danger #cf222eone meaning per color, everywhere
Derived--muted, --line, --panel, --chip, --code-bg, --btn; tints --link-t1..3, --open-t1..3, --warn-t1..3, --danger-t1..3computed with color-mix on body, never restated
Radius--r-pill 999px, --r-panel 10px, --r-ctl 6pxmarks under 4px stay literal
Spacing--sp-1 0.2rem, --sp-2 0.4rem, --sp-3 0.6rem, --sp-4 0.9rem, --sp-5 1.2rem, --sp-6 2.5remevery padding, margin, gap and offset on the nearest step; TestSitePagesSpacingOnTheScale holds it
Padding pairs--pad-panel 0.75rem 0.9rem, --pad-row 0.4rem 0.75rem, --pad-ctl 0.2rem 0.6rembuilt from the spacing scale
Layout--nav-w 220px, --shell-max 1012px, --shell-pad 1.25rem, --nav-gap 1.5rem; one breakpoint at 720pxthe breakpoint is shared by both stylesheets
Themethe system preference decides; a stored choice stamps .dark-mode or .light-mode on html for a page and on body for the appa stored choice outranks the system preference

Off the spacing scale: a mark under 4px, and an em value, which scales with its own text. A value off the scale gets a row here.

A card is the one surface with a palette entry of its own rather than a mix off the page: --pl-card is #fff8e5, a lift off the light parchment, and --pd-card stays the dark panel mix. Every other surface keeps its derived token.

Layout

Every surface is a fragment route on index.html, parsed by parseRoute in gs-core.js. A route the page layer pre-renders names the page kind that serves it, and its key is in the page keys.

RouteFragmentPage
home#/, or a bare #<anchor> into the READMEthe front page
index#/timeline, #/issues, #/prs, #/releases, #/memos, #/milestones, #/sprintsa list page per type directory
commits#/commits, #/commits/<n>, #/commits:<anchor>a list page
commit#commit:<hash>@<branch>an item page for a gitmsg item, the app alone for a code commit
file#file:<path>@<branch>a file page
code#/code, #/treethe app, and where f/index.html boots
compare#/compare:<base>...<head>, #compare:<base>...<head>the app
branch#branch:<name>the app
tag#tag:<name>the app
list#list:<id>the app
branches#/branchesthe app
tags#/tagsthe app
graph#/graphthe app
board#/boardthe app
search#/search/<query>the app
lists#/liststhe app
analytics#/analyticsthe app
config#/configthe app
notfounda fragment that parses as none of the abovethe app

Components

One builder per component in JS and one template in Go. "Both" means the page layer and the app render it; "app" means the app alone.

ComponentClassesRenderersNotes
Sidebar.nav (app), .page-nav (pages), .nav-group, .nav-section, .nav-icon, .nav-tree-slot (app), .nav-footerbothevery section shows on every repository, and a list reached from it shows its empty state; the title is site.title, else the bucket name; the app's file tree scrolls inside .nav-tree-slot, so no nav row runs under the pinned credit
Card.card > .card-head > .type-glyph + .chip + a.subject, then .meta, then a chip row; a trailing chip slot after the subjectbothevery list row is a card: items, commits, releases, board cards, search results, recent activity; the app builds them all from card in gs-render.js; its ground is --card; the head's one slot carries the retracted marker, and a body-only card, which has no head, leads its meta row with it and stands on its body, of which a row shows the first line; under a head the meta row sits one --sp-1 step below the subject, and it and the chip row share one indent past the glyph column
Member row.pm-member holding a state chip or a .hash, then the linked subjectappthe rows of a milestone, a sprint and a tag page; mono at --fs-ui, the link keeping its color
Graph row.graph-row-text holding the ref chips, a.hash, a.graph-subject, then .meta.graph-metaappone line, all of it mono at --fs-ui; the subject keeps the link color
Feedback card.card.feedback, the verdict on .verdict-<state> as a 3px left border and on a chipbothapproved or changes-requested; the file and line anchor ride a plain chip, dropped inline under the line they anchor; padding, radius and background come from .card
Chip.chip plus one variant class, built by chipEl in JS and the chip template in Gobothmono, --fs-ui, pill radius, tint fills from the token scale; a chip never carries the edited marker
Chip variants, both.state.<state> through the one state-class rule (open, closed, merged, completed, active, planned, canceled, unknown), .pre.state ("prerelease"), .chip-retracted ("retracted"), .verdict-<state> with the hyphen read as a spaceboththe plain chip carries a version, a branch name, a file anchor and "draft"
Chip variants, app.chip-count, .chip-label, .chip-assignee, .chip-priority.prio-<level>, .chip-due, .chip-origin, .chip-bot, .chip-review, .pm-sub-chip, .chip-signed, .version-label, .caveat, .board-wip-over, .reviewer-chip, .branch-tip, .tag-tip, .merged-branch, .filter-chip, .facet-chip, and the .card-chips rowappa new variant gets its entry here first
Type glyph.type-glyph.tg-<type>bothplain characters, so a page without JS carries the same mark; the type is the header's, else the extension's default, and it is tinted by state on issues and pull requests
Meta row.meta holding span.author, span.reltime, a.hash in that order, separated by a middle dot; inside .detail-meta on a detail pagebothmono and muted; the author is the display name with the email in title, "unknown" when both are empty
Meta row bitsthe precise stamp in the time's title; the relative time in the app and the YYYY-MM-DD UTC date on a page; the hash as the 12-character short form linking to the itemboththe page layer's type label, head → base, "due", the sprint range and "signed" follow the hash
List pageh1, cards, .filter-chip filters, and .load-more in the app where a page carries its sealed-page linksboththe heading is the nav label verbatim, and so are the <title>, the description and the feed title
Detail page.card-head > h1.subject plus the head's one chip slot, then .detail-meta, .body, the thread, .version-row history, .asset-list on releases, diff and review sections on pull requests; the app wraps it in .detailboththe state, draft, prerelease, retracted and version chips ride the head's one slot, never the meta line; a body-only type promotes no first line and heads with the meta row alone; the app's raw toggle and copy-link control share the top bar's one .page-actions row; a title that is an identifier, a branch, tag or list name, takes .mono
Release headthe tag as the subject, then one version chipboththe chip is dropped when the head already names the version
Release rowthe release head over a meta row of the author, the date and the asset countboththe count stands where every other row links its hash, and goes when the release names no artifact
Release notesdl.release-notes with a dt and a dd per commitbotha release body block whose every line reads <hash> <message> renders as these rows: the hash a mono link to its commit route, the message beside it; they take the trailer layout and its mono voice, and every other block of the body stays prose
Release assets.assets > .assets-head, then an .asset-list of .asset-row artifacts and a second holding the checksums and the SBOM on a chip, then .asset-signedbotha row links its name when artifact-url gives it an https: or root-relative target, else the name stands as selectable mono text
Threadcomment cards in time order under a Comments (N) heading, one rail per depth levelbotha reply follows the one it answers, siblings run oldest first, depth caps at four; the type glyph leads a comment's meta row; a missing parent falls back to a quote
Trailers.detail dl with a dt and dd per fieldappmono, muted, --fs-ui; it carries the header fields no other component on the page shows, so the route's ext and type, the head's state, draft, retracted, tag, version and prerelease, and the meta row's origin-author-name, origin-author-email and origin-time stay out; origin-platform and origin-url fold into one origin row
Markdown.markdown, headings with md- ids, lists, tables, fences, images, blockquotesbothone grammar, ported between JS and Go, asserted equal; a fence pads --sp-2 over --sp-3, so the first line's leading evens the two
Codetree (.tree-row, .tree-node, chevrons, tree search), blob (highlighted, raw pane, images, video), diff (.diff-section, unified or split, inline feedback)appa blob the view labels rather than renders carries its one sentence in .empty under the breadcrumb, with the submodule's full sha in the label's title
Boardcolumns, WIP indicator, collapsed columns, group-byappa board card is chrome: mono at --fs-ui, line-height 1
Stat tile.stat-cell holding .stat-value or .stat-value-text, then .stat-labelappall three are mono: a figure is a count, not prose
Searchinput, scope help, tier note, snippets, result cardsapp
Notice.notice for degraded content, .empty, .loading, .err.notice and .empty both; .loading and .err appone sentence in place of the content; the wording is in States
Section label.assets-head, .thread-head, .contrib-head, .version-history-head, .review-summary-head, .diff-offscreen-head, .pm-members-head, .pm-subissues-head, .pm-rel-label, .board-col-head, .board-groupby-label, .board-lane-head, .facet-label, .search-group-head, .config-head, .view-countbothone uppercase mono voice at --fs-ui, muted; the family comes from the shared rule, never from a class on the markup
Controls.action-link, .back, .page-actions, .view-modes, .view-toggle, .share-link, .load-morebothmono, --r-ctl, --btn surface; a surface's controls sit on one .page-actions row, never on two

sitetest/parity_fixtures.json pins the shapes both renderers share: siteHeadChips and headChips, siteHeadSubject and headSubject, the card skeleton, the meta row skeleton, the front page's truncation wording, the list labels and the empty sentences. verify_styles.js pins the app's own elements.

States

Every component defines these where they apply. The wording is fixed, so it reads the same on every page.

StateTreatment
Emptyone sentence in .empty, both renderers: "No in this repository." with the list's noun (issues, pull requests, releases, posts, memos, milestones, sprints, lists, branches, tags, commits), "No activity in this repository yet." on the timeline, "No results for “”." on a search; sentence case, a period, never an empty container
Loading"Loading…" in .loading, app only, only where content will land; "Loading diff…" on a diff expand is the one variant; the page layer's boot cover (html.gs-boot body::before) carries the same word and the same tokens, pinned by verify_upgrade_boot.js
Errorone sentence in .err, in place, naming what failed, with the rest of the page usable: "This section failed to load.", "Branch not found: ", "Object not found."; app only, since a page has no read-time failure; the three bucket-level notices (a stalled view, no refs manifest, a 403) carry the fix in the sentence
Not found"Branch not found: ", "Object not found.", one shape for every kind
Retracteda tombstone: "retracted " as the subject, .chip-retracted, no body
Editedan "edited" bit in the meta row after the hash, with the edit's precise time in title; "edited by " when the editor is not the author; both renderers, and never a chip
Stalea commit no longer on its branch: dimmed text, no chip
Truncatedone sentence in .notice as the last row, both renderers: "N more not shown." for a list, tree or diff, "N more replies not shown." for a thread, "Truncated. The full file is in the repository." for a file or README, "Search truncated at N entries; refine the query." for a search; a .load-more or .show-more control below it where the app can expand, a link to the app route where a page cannot; a truncation whose control already names the total, as the front page's root listing does with "Show all N", carries that control alone and no sentence
Retryan app fetch retries 429, 5xx and header timeouts with backoff, then shows the error state

Repo-shape rules

Each rule has a fixture that checks it, in Fixtures and visual tests.

ShapeRule
No READMEthe front page shows the branch strip, the file list and recent activity, with no README block and no placeholder
README with hero HTMLthe front page has no heading of its own, the README is the document and its headings stand as written; the repo title is the <title>, the sidebar and the mobile bar
Default branch not mainevery "default branch" read uses HEAD from refs.json; nothing assumes a name
An extension with no itemsits sidebar section stays and its list page shows the empty state
Code only, no gitmsg branchesevery section stays and every list shows its empty state
Large tree, 5,000 files or morethe tree caps its rows with "show N more" and offers the tree search; no full expansion
Binary, LFS pointer, submodule, symlinkthe blob view labels the object and renders nothing else: "Binary file, 1.2 MB", "Git LFS pointer", "Submodule at ", "Symlink to "
Markdown flavoursthe page layer and the app agree on which files render as prose, and both drop a prose document's YAML front matter, and an .mdx document's import, export and standalone JSX blocks, before rendering what is left
Non-Latin or right-to-left textbody text inherits its direction, the chrome stays left-to-right, and truncation is by character, never by byte
Long history, 100,000 commitseverything is served from the index; a screen never walks more than WALK_CAP commits
Many branches or tags, 1,000 or morebranch and tag lists page, and graph chips collapse to "+N"

Fixtures and visual tests

Each shape above has one fixture bucket in the repo-shape goldens. A shape with no fixture is not a rule yet, and a new shape gets its fixture and its row in the same branch.

The tokens, the components and their states are checked by verify_styles.js, one of the browser suites. Its baselines record a probed element's font family, so a component that drops out of the mono voice fails the suite. Both suites need Chrome.

verify_styles.js also holds the phone rule: at 390 px the front page, a list page, an item page and the code route stay inside the viewport, in the served document and in the app it boots into. Both suites set the viewport through cdp.js, since a headless window stops at the platform's own floor.

Change rules