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/, run by .forge/), and the style
guide at / (site/) 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/ rather than writing a box of your own.
- A panel is
--raisedwith a--hairline--borderedge: square, no shadow. Panels stack--space-4(16px) apart. - It may have a head strip, on
--tintover a hairline, naming what the panel holds: quiet--mutedsans at--text-sm, the key item in ink at--fw-medium(<b>, or a link with classkey), 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 adetailswhose 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-3padding. - 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/, 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:
--bgthe page,--raisedpanels over it (white in light mode),--tinta panel's head strip,--hovera 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--borderand--border-strong. One accent,#3148F6:--linkfor links (lighter in dark mode),--accentfor the current tab's bar, focus, primary fills,--deepon hover. Meaning:--add-*,--del-*,--pass,--fail,--warn,--status-*fills. Avatars are drawn in--avatar-ground,-ink,-red,-blueand-yellow; the activity grid's days in--heat-0(nothing) to--heat-4; a repo's languages in--lang-1to-3and--lang-rest. - Type:
--text-xs12,--text-code12.5,--text-sm13.5,--text-md15 (body),--text-prose16,--text-lg18,--text-xl22,--text-2xl28. Weights--fw-regular400 (body),--fw-medium500 (links, controls),--fw-strong650 (emphasis, headings). - Space:
--space-1to--space-10on a 4px scale,--hairline1px for rules,--bar3px for the current tab,--radius0.
Fonts
Self-hosted variable woff2 in site/ (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/keeps a hyphenated word whole and lets a path break after its slashes).markdown.ts
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/ 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>hasdata-ago. - A time ending a row is at least
--col-timewide (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/): 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, soavatar.ts aria-hidden. - The activity grid (
ui.activity, data fromlib/, behavior inactivity.ts site/) 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 withassets/ ui.ts --space-halfgaps, 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.activitywithstill): 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 fromactivity.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>/on an owner's page with a shelf picked, beside the shelves' own<day> #<shelf>. The day is always last and alwaysYYYY-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/(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").topics/ - 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 witharia-pressed,aria-labelandtitle, 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);--deepis 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/ (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 /.
After a change
Run sh .forge/ (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 /, in light and dark, at
desktop and phone widths. Grepping HTML is not looking.