App Guidelines · Tokens 0.5.4
Random Fact
Two chromes, one control height, one radius, nine type styles and one set of tokens. Every app is built from the same small system.
Every control, corner, type style and color below is drawn with the values @randomfact/tokens 0.5.4 ships, at their specified sizes, in the real webfonts. Contrast ratios are computed live from the hexes. Accepted 2026-09-23
00
The palette
The six brand anchors are the same hex in both themes and pair within a layer. The surface system and the data tokens (success, danger, warning) take a value per theme. Brand ink, --rf-brand, is blue in light and sky in dark: links, marketing headings, the focus ring and any brand-colored mark. Blue itself does not flip, and there is no lifted blue. Shown so the app layer can be judged over the ground it sits on.
Anchors, paired by layer
Surfaces and semantic colors
Brand ink and the data tokens
Anchors, paired by layer
Surfaces and semantic colors
Brand ink and the data tokens
01
Two chromes
Shell (Can, Convo): sidebar + a 56px page-header bar, mobile-first. Workbench (Investment Analyzer, Transaction Analyzer): a 56px top bar with wordmark, tabs, workspace trigger, icon actions and ⋮. Both ship from @randomfact/ui; nothing else is allowed.
Shell · AppShell
PLAT-58 App design system
Both chromes in @randomfact/ui, one control scale, the extended palette.
PLAT-53 Overview capture on release
Regenerate the screenshots as part of pnpm release.
Page title in the bar is the Section heading style (Exo 600 16px). Sidebar toggle and search are 36-square IconButtons; New ticket is the default Button.
Workbench · WorkbenchShell
September 2026
Scroll inside this frame: the header is sticky, its surface is the page at .84 (light) / .80 (dark) under a 4px blur, with the hairline in --rf-line.
Uncategorized
14 transactions waiting for a category.
Rules
3 rules ran this month.
Exports
Last export 12 Sep 2026.
Team
2 members.
Header row min-h 56, 36px controls, 16px glyphs, 2px progress overlay that never grows the bar. The header is one row at every width: the tabs scroll inside their own strip, and actions the row can't fit go into ⋮, as here. On a phone the wordmark's words hide, leaving the logo, and the workspace name truncates. The top bar draws the wordmark itself; the app's logo, which sits to its left, is left out here.
Shell · AppShell
PLAT-58 App design system
Both chromes in @randomfact/ui, one control scale, the extended palette.
PLAT-53 Overview capture on release
Regenerate the screenshots as part of pnpm release.
Page title in the bar is the Section heading style (Exo 600 16px). Sidebar toggle and search are 36-square IconButtons; New ticket is the default Button.
Workbench · WorkbenchShell
September 2026
Scroll inside this frame: the header is sticky, its surface is the page at .84 (light) / .80 (dark) under a 4px blur, with the hairline in --rf-line.
Uncategorized
14 transactions waiting for a category.
Rules
3 rules ran this month.
Exports
Last export 12 Sep 2026.
Team
2 members.
Header row min-h 56, 36px controls, 16px glyphs, 2px progress overlay that never grows the bar. The header is one row at every width: the tabs scroll inside their own strip, and actions the row can't fit go into ⋮, as here. On a phone the wordmark's words hide, leaving the logo, and the workspace name truncates. The top bar draws the wordmark itself; the app's logo, which sits to its left, is left out here.
02
One control height
Every control that sits in a row is 36px, control text 14px, every glyph 16px. Button has no sm/lg; icon-only is a 36-square IconButton. The one exception is RowAction, 28px, inside a table row or list item only. Chips and badges are one size too: 20px, 12/500, rounded-sm, and never align with a control.
Alert, Progress, Slider and Sheet are not row controls. An alert is as tall as its text (14px, 16px glyph), with its tone on the border and glyph and its words on the two text tiers. A progress bar is an 8px rounded-full track. A slider is a 6px track in --muted with a brand-ink range and a 16px thumb edged in brand ink. A sheet is a side panel docked to a viewport edge, with no radius, closed by the 36px IconButton. Command and the floating selection bar are app-local, in Transaction Analyzer.
| Payee | Category | Amount | ||
|---|---|---|---|---|
| Northwind Traders | Groceries | −1,204.95 | ||
| Contoso Energy | Utilities | −88.20 | ||
| Salary | Income | 4,250.00 | ||
| Total | 2,956.85 |
Rows: Montserrat 400 14px, numbers tabular-nums right-aligned, total row 600. Chips are the data-color pair at one 20px size, rounded-sm. Input text is 16px below md (iOS zoom), 14px from md; shown at 14 here.
Alert, Progress, Slider and Sheet are not row controls. An alert is as tall as its text (14px, 16px glyph), with its tone on the border and glyph and its words on the two text tiers. A progress bar is an 8px rounded-full track. A slider is a 6px track in --muted with a brand-ink range and a 16px thumb edged in brand ink. A sheet is a side panel docked to a viewport edge, with no radius, closed by the 36px IconButton. Command and the floating selection bar are app-local, in Transaction Analyzer.
| Payee | Category | Amount | ||
|---|---|---|---|---|
| Northwind Traders | Groceries | −1,204.95 | ||
| Contoso Energy | Utilities | −88.20 | ||
| Salary | Income | 4,250.00 | ||
| Total | 2,956.85 |
Rows: Montserrat 400 14px, numbers tabular-nums right-aligned, total row 600. Chips are the data-color pair at one 20px size, rounded-sm. Input text is 16px below md (iOS zoom), 14px from md; shown at 14 here.
03
One radius
--radius: 0.625rem (10px) and the shadcn derivation, published as aliases. Every app draws these four radii and none declares its own. Each tile shows its corner at 3× zoom.
chips, badges, kbd
every control, menus, menu items
popovers, dialogs, sheets, alerts
cards and panels
Q3 invoices
12 open, 3 overdue
Write the alias, never bare rounded: bare rounded reads --radius itself (10px), larger than rounded-md.
chips, badges, kbd
every control, menus, menu items
popovers, dialogs, sheets, alerts
cards and panels
Q3 invoices
12 open, 3 overdue
Write the alias, never bare rounded: bare rounded reads --radius itself (10px), larger than rounded-md.
04
Type styles
Exo speaks (titles, figures), Montserrat reads. Nine styles, no fractional sizes, 12px is the floor, no italic. Display figure is for the one number a page is about; any other number is a Figure. fonts.css loads exactly Exo 600/700 and Montserrat 400/500/600.
--text-2--text-2The weights the apps load
The apps load only these five. A weight they leave out renders as the nearest loaded one: an Exo element left at 400 renders 600, a Montserrat element at 700 renders 600. (This site loads the full variable fonts, so that fallback can't be shown live here.)
Numbers in tables
| Groceries | 1,204.95 |
| Utilities | 88.20 |
| Transport | 312.00 |
| Total | 1,605.15 |
Amounts are right-aligned and every digit is the same width, so a column lines up by place value. A single headline number, like a balance, uses the Figure style instead.
The brand sets amounts like these in its monospace, DM Mono, but the apps' shared fonts don't include it yet, so they use Montserrat's same-width digits. Investment Analyzer already uses DM Mono for typing numbers in.
The app wordmark
Investment Analyzerby Random FactThe app's name in Exo 700 at 16px, over “by Random Fact” in Montserrat 400 at 12px, in sentence case. The app's logo sits to its left (left out here). The top bar draws the wordmark, so an app never sets it itself.
--text-2--text-2The weights the apps load
The apps load only these five. A weight they leave out renders as the nearest loaded one: an Exo element left at 400 renders 600, a Montserrat element at 700 renders 600. (This site loads the full variable fonts, so that fallback can't be shown live here.)
Numbers in tables
| Groceries | 1,204.95 |
| Utilities | 88.20 |
| Transport | 312.00 |
| Total | 1,605.15 |
Amounts are right-aligned and every digit is the same width, so a column lines up by place value. A single headline number, like a balance, uses the Figure style instead.
The brand sets amounts like these in its monospace, DM Mono, but the apps' shared fonts don't include it yet, so they use Montserrat's same-width digits. Investment Analyzer already uses DM Mono for typing numbers in.
The app wordmark
Investment Analyzerby Random FactThe app's name in Exo 700 at 16px, over “by Random Fact” in Montserrat 400 at 12px, in sentence case. The app's logo sits to its left (left out here). The top bar draws the wordmark, so an app never sets it itself.
05
Two text tiers
--text-1 and --text-2, no opacity modifiers, no third tier. Brand ink, --rf-brand, is the one other ink, for links and brand marks. Ratios are measured against the page and the card.
/70: an opacity modifier is a third tier by another name—/70: an opacity modifier is a third tier by another name—06
Workbench chrome
Three tokens: --rf-header-bg, --rf-line, --rf-sand. Tab ink is --text-2 inactive and --text-1 on hover and active; the hover fill is sand at 60%. Frame 1640px with a 16px gutter, shared by the bar and the page.
07
Data color
Twelve categorical hues in four families, plus a three-step neutral ramp whose first step is “Other”. A chart hue clears 3:1 on the page; a chip's ink clears 4.5:1 on its ground over the card. The chart order is the one that keeps adjacent series apart under color-blindness. There is no red or pink hue. State is a separate pair: success, warning and danger each have an ink on an opaque ground, also 4.5:1.
Chart order, hue on the page
A chart never cycles the order: a thirteenth series folds into Other. A dot that names something outside a chart, in a list or a rail, may take the order once more as a hollow ring with a 2px stroke; past 24 it folds into Other too.
The neutral ramp
Same cool tint, each step further from the page: darker in light, lighter in dark. A neutral flow (a draw, a sink) fills with neutral-1 and neutral-2 only. neutral-3 is for axes, dividers and text: near-black in light, and in dark too close to the sky brand ink to fill a mark.
Chips, ink on ground over the card, by family
Status chips, ink on an opaque ground over the card
State takes a status variant (Badge variant="success|warning|danger"); identity takes a Chip hue. A status is never painted in a categorical hue, nor a category in a status pair.
Chart order, hue on the page
A chart never cycles the order: a thirteenth series folds into Other. A dot that names something outside a chart, in a list or a rail, may take the order once more as a hollow ring with a 2px stroke; past 24 it folds into Other too.
The neutral ramp
Same cool tint, each step further from the page: darker in light, lighter in dark. A neutral flow (a draw, a sink) fills with neutral-1 and neutral-2 only. neutral-3 is for axes, dividers and text: near-black in light, and in dark too close to the sky brand ink to fill a mark.
Chips, ink on ground over the card, by family
Status chips, ink on an opaque ground over the card
State takes a status variant (Badge variant="success|warning|danger"); identity takes a Chip hue. A status is never painted in a categorical hue, nor a category in a status pair.
08
The token rule
No app declares an --rf-* property, and no component file or stylesheet carries a raw hex or a Tailwind color-ramp class. A design value an app needs is a change to tokens plus the app's adoption; @randomfact/ui/testing enforces it.
Every app's stylesheet
@import "@randomfact/tokens/fonts.css"; @import "tailwindcss"; @import "@randomfact/tokens/theme.css";
Nothing else from the package. palette.css is the file theme.css imports, not an entry.
The gate fails on
- an
--rf-*or--color-rf-*declaration (bar the four--rf-md-*knobs) - an import of
@randomfact/tokens/palette.css - a
components/ui/folder - a raw hex or Tailwind color-ramp class in a component file or stylesheet
- a
--radius:declaration - a text size off the type table (
text-lg,text-[11px],fontSize: 13), including a chart label under 12px - a weight above 600 on text that isn't set in Exo
- an opacity modifier on ink (
text-muted-foreground/70)
Allowed by name
- setting a published knob:
--rf-md-block-gap,--rf-md-heading-leadon a docs selector - a brand mark's SVG hexes, as a permanent
allow.hexentry: a logo is artwork, not a design value - a chart not yet moved onto
@randomfact/tokens/palette, listed inchartModules: a transition allowance, not a home for a chart's own colors - an
allowentry with a non-emptyreason, which fails as stale once it matches nothing
09
One layer order
What sits over a page stacks in one order, lowest first. The bottom-right corner belongs to the update notice: nothing else an app floats takes it, and toasts sit top-center.
| Layer | z-index | What sits there |
|---|---|---|
| Page chrome | 20 | PageHeader |
| 30 | WorkbenchShell header, AppShell scrim | |
| Update notice | 35 | UpdateNotice |
| Sidebar | 40 | AppShell sidebar |
| Modal | 50 | dialogs, sheets, popovers, menus |
| Lightbox | 100 | the marketing screenshot lightbox |
The update notice appears once the server has a newer build than the tab is running: a popover surface, one sentence in Body and one 36px Reload button, 16px inside the bottom-right corner. It is not a toast: it has no dismiss and no timeout, stays until the user reloads, and never reloads by itself. It sits above the headers and below the sidebar and any dialog. Anything an app pins to the bottom edge clears it by --rf-update-notice-clearance.
| Layer | z-index | What sits there |
|---|---|---|
| Page chrome | 20 | PageHeader |
| 30 | WorkbenchShell header, AppShell scrim | |
| Update notice | 35 | UpdateNotice |
| Sidebar | 40 | AppShell sidebar |
| Modal | 50 | dialogs, sheets, popovers, menus |
| Lightbox | 100 | the marketing screenshot lightbox |
The update notice appears once the server has a newer build than the tab is running: a popover surface, one sentence in Body and one 36px Reload button, 16px inside the bottom-right corner. It is not a toast: it has no dismiss and no timeout, stays until the user reloads, and never reloads by itself. It sits above the headers and below the sidebar and any dialog. Anything an app pins to the bottom edge clears it by --rf-update-notice-clearance.