clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

main
forge / DESIGN.md
DESIGN.md 11.0K source

Design

How the forge's pages look, and the rules for changing them. They are written for whoever edits the templates, people and agents alike. CI holds the CSS to them (node lib/check-css.ts, run by .forge/ci), and the style guide at /design/ (site/design.njk) draws every token and control with the real stylesheet and macros. Check it after a change.

The look

A plain page (no texture, no grid) with the chrome on it, and the content in panels. Square corners, no shadows, hairline rules. Structure comes from the order of things, the panels and the rules between rows, in the spirit of the Vanilla framework. Phone first: lists restack, tabs scroll sideways.

Panels

Lists, file viewers, READMEs, diffs and discussions sit in panels; the header, a page's title and description, tabs and toolbars (the branch picker, breadcrumbs, filters) sit on the page. Use ui.panel in site/_includes/ui.njk rather than writing a box of your own.

  • A panel is --raised with a --hairline --border edge: square, no shadow. Panels stack --space-4 (16px) apart.
  • It may have a head strip, on --tint over a hairline, naming what the panel holds: quiet --muted sans at --text-sm, the key item in ink at --fw-medium (<b>, or a link with class key), shas, counts and ages in the mono, faint. The page's controls for what is in the panel (wrap lines, copy, viewed) go at its end (.end). A table's column names are a head strip too. A panel that folds is a details whose summary is its head strip.
  • Inside, rows are between hairlines and the last has none: the panel's edge is its rule. Rows have --space-2 --space-3 padding.
  • Hover tints the whole row with --hover: the background goes on the row (tr, li), never on a cell or a link, so it is one even block. A row's own link (a subject, a file, a branch) is in ink and stays ink on hover, underlined; other links are blue.
  • Prose in a panel (.markdown) has its code on --tint; diagrams take the panel's surface. An empty state is a quiet centred sentence (.empty) in the panel it would fill.
  • A section heading on the page (ui.heading) names several panels; one panel's name goes in its own head strip.
  • A head strip says something the tabs or page heading don't; never repeat them. A shelf's list under its tab has none (the tab names it and counts it); it gets one only while a day is picked in the activity grid above it, since then it says what the tab doesn't: "oct 8, 2026", "3 repos, 11 commits", "show all".

Tokens

Every colour, font size, weight, font family, space, rule width and the radius is a custom property in the token block at the top of site/assets/style.css, between the == tokens == and == end tokens == comments, with its dark value in the prefers-color-scheme: dark block under it. Nothing outside that block names a colour, size, weight or family: not style.css below it, not a template's {% css %} block, not a style="" attribute. If no token fits, add one there (with a comment if it starts a group: the style guide titles groups by those comments).

  • Colour: --bg the page, --raised panels over it (white in light mode), --tint a panel's head strip, --hover a hovered row, ink in three strengths --fg, --muted (at least 4.5:1 on the page and on every surface) and --faint (at least 4.5:1 on --raised), rules --border and --border-strong. One accent, #3148F6: --link for links (lighter in dark mode), --accent for the current tab's bar, focus, primary fills, --deep on hover. Meaning: --add-*, --del-*, --pass, --fail, --warn, --status-* fills. Avatars are drawn in --avatar-ground, -ink, -red, -blue and -yellow; the activity grid's days in --heat-0 (nothing) to --heat-4; a repo's languages in --lang-1 to -3 and --lang-rest.
  • Type: --text-xs 12, --text-code 12.5, --text-sm 13.5, --text-md 15 (body), --text-prose 16, --text-lg 18, --text-xl 22, --text-2xl 28. Weights --fw-regular 400 (body), --fw-medium 500 (links, controls), --fw-strong 650 (emphasis, headings).
  • Space: --space-1 to --space-10 on a 4px scale, --hairline 1px for rules, --bar 3px for the current tab, --radius 0.

Fonts

Self-hosted variable woff2 in site/assets/fonts/ (how they were cut: make-fonts.sh; licenses: the OFL-*.txt files there).

  • --sans, Open Sans: all interface text. Navigation, tabs, lists, subjects, page and section headings, forms, buttons, labels.
  • --mono, Noto Sans Mono: anything copied or compared. Hashes, review ids, branch names, paths, counts like +12, commit kinds, code, diffs, clone and install commands.
  • --serif, Noto Serif: only rendered prose people wrote. READMEs and markdown files, the wiki, release notes, review descriptions and comments, the guide's text. Never interface chrome.

Words

  • No capitals by CSS and no letter-spacing, anywhere. Interface chrome (tabs, buttons, labels, table headers, section headings, status labels, summaries) is written in lowercase in the templates. Hierarchy comes from weight, size and colour.
  • What users wrote (commit subjects, review titles, repo descriptions) is never case-transformed.
  • Kinds, topics and filters are plain words, not chips.
  • Never the middle dot (U+00B7), in code, comments, UI strings or commits; use a comma, a hyphen or a pipe.
  • Inline code never breaks at a hyphen (lib/markdown.ts keeps a hyphenated word whole and lets a path break after its slashes).

Times

A time in its own column is compact; a time in a sentence reads as a sentence. Every time is a <time> with the moment, in UTC, in its datetime and title; site/assets/ui.ts says it relative to now, every minute, with the reader's full local date and time on hover.

  • In a column, a head strip or a list's facts (the recent feed, shelves, a repo's head strip, commits, reviews, revisions, branches, tags, releases, sites) it is | date: "now", "5m", "40m", "5h", "1d", "3w", then "Sep 24", or "2025-03-01" from an earlier year.
  • In a sentence (a byline, "opened by cat, updated 5m ago", "active 2h ago", a comment's header) it is | ago: "just now", "5m ago", then "Sep 24, 2025". Its <time> has data-ago.
  • A time ending a row is at least --col-time wide (the widest compact one, "Sep 24"), right-aligned, in tabular figures, unwrapped; and a list's rows share their columns (a table, or a grid whose rows are subgrids), so every row's text cuts at the same edge. A kind column is a fixed width, a count column tabular and right-aligned, the same way.
  • The page has the day before the script runs, no wider than what replaces it, so nothing moves on load.

Words in docs

The guide, the README and other docs are written in AP style, for readability: no serial comma in a simple series; one through nine spelled out and figures from 10, always figures for versions, sizes, times and percentages; sentence-case headings; short sentences in the active voice; dates like Oct. 8, 2026. Interface chrome follows the lowercase rules above. Code, CLI output and the interface's compact times keep their own forms.

People and activity

  • A person is shown with their avatar (| avatar(size), lib/avatar.ts): a 3 by 3 square of bauhaus tiles drawn from their name at build time, after cat's bauhaus pattern generator. The same name gives the same art everywhere and forever; there are no uploads. 72px on their page, 36px in lists. Decoration beside the name, so aria-hidden.
  • The activity grid (ui.activity, data from lib/activity.ts, behavior in site/assets/ui.ts) is the one way the forge shows a year of days. A column a week, Sunday at the top, no month row, no weekday column, no range picker; square cells with --space-half gaps, the 53 weeks filling the panel's width. Narrower, the cells shrink to 10px and then the oldest weeks go, so the latest always show (27 weeks at 390px). Days are the reader's with JS (the page has each commit's or event's time), UTC without. Each day is a button named "2026-10-08: 11 commits"; clicking it (or Enter, Space) picks it with an ink outline and narrows the list directly under it, clicking it again or "show all" lets it go; arrow keys move by a day or a week. Without JS it is a static picture. It lives on an owner's page (commits on the default branches of their public repos, following the picked tab) and a repo's insights (its pushes, landed reviews, releases, site deploys and CI results); not on the commits tab.
  • On a repo's insights the grid is a chart like the others there, in a chart panel with its total and what it counts in the head strip. It is still (ui.activity with still): each day is a span, named and titled as a button would be ("2026-10-08: 3 events"), the same square with no hover, no pointer and nothing to pick, and no list goes under it. With JS it only recounts its days in the reader's time from activity.json; the address never names a day.
  • The address keeps a picked day so a narrowed view can be shared: #<day> (#2026-10-08), or #<shelf>/<day> on an owner's page with a shelf picked, beside the shelves' own #<shelf>. The day is always last and always YYYY-MM-DD.

Controls

Words for where you are, symbols for how you look at it.

  • Tabs say where you are (ui.tabs): the repo's sections, the shelves on an owner's page and /topics/ (with an "all" tab). A tab shows one panel: "all" is every repo in one list, newest first, not the shelves stacked; a shelf's tab is that shelf's repos, and the panel's head strip names what it shows ("all 31", "games 6").
  • A checkbox turns one thing on or off (ui.checkbox): wrap lines, viewed.
  • An icon toggle is a checkbox drawn as a symbol (ui.iconToggle): a button with aria-pressed, aria-label and title, the size of an icon. Example: the globe, "only repos with a site".
  • A segmented control picks one of a few (ui.segmented): the sort, newest first or by name.
  • Buttons do things (ui.button); one primary, filled with the accent, per place at most. A toggle that is on is filled with ink. A filled primary darkens on hover (--accent-hover); --deep is for text.
  • Links only go places. Never use a link, or a word that changes when clicked, as a toggle.

Use the macros in site/_includes/ui.njk (also ui.panel, ui.input, ui.textarea, ui.listTable, ui.status, ui.heading, ui.icon) rather than writing the markup again. A macro's class belongs to its element: .icon is ui.icon's svg and nothing else (a file list's icon cell is td.file-icon), since a cell that took the icon's class once took its 16px inline box too, and painted a hovered row in pieces. A new control goes there first, with its CSS in style.css under "controls", and onto /design/.

After a change

Run sh .forge/ci (it runs the CSS check and the scenarios). After a deploy, look at the pages: the home page, a repo, its commits, a review and its diff, a README, releases and /design/, in light and dark, at desktop and phone widths. Grepping HTML is not looking.