Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ jobs:
- name: Lint
run: yarn lint

- name: Style ratchet
# Local styles that redo a kit piece may only go down; no class name may be used without a style
# (docs/design-system.md, scripts/style-ratchet.mjs).
run: yarn ratchet

- name: Test
run: yarn test --ci

Expand Down
19 changes: 11 additions & 8 deletions docs/design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,15 +81,18 @@ Services, Pipelines and its run list, Environments, Images, Release log, Deploym
Release matrix and records, Topology, Overview's Recent activity, pod logs, the dialogs and the Ops Wall. Status chips
too (2026-10-09): every state display is a `StatusChip`.

**Inside the tabs: not done.** Below the header, most tabs still declare their own section titles, chips, tables, cards
and buttons. A tab is done when it declares no title, chip, table-header, panel or button style of its own.
**Inside the tabs: done** (2026-10-11, tower#80, #81, #83). Section titles, notes, label/value facts, tables, cards,
tiles, banners and headline numbers use the kit on every tab. Local styles that redo a kit piece went from 328 to
119; what is left is bespoke on purpose (the Pipeline and Rollout DAGs, the Release matrix grid, EnvPicker, the
Canary ramp chart, the Topology flow strip, the Ops Wall layout, the break-glass session strip, the App
Configuration form) or layout.

| Tab | Inside the tab | Status |
|---|---|---|
| Environments | kit only | done |
| App Configuration, Glidepath | `Subtabs`, `PendingPanel`; form inputs and section cards are the older MUI ones | partly |
| Releases | `Subtabs`; the command deck, matrix, log and record panels have their own styles | partly |
| Overview, Pipelines, Deployments, Images, SLOs, Pull requests, Topology, Notifications, cloud Deployments | section titles, chips, tables, cards, buttons of their own | not started |
**The ratchet.** `yarn ratchet` (scripts/style-ratchet.mjs, run in CI) counts those local styles per kind and fails if
any count goes up; when one goes down, run `node scripts/style-ratchet.mjs --update` and commit the baseline
(scripts/style-ratchet.json). It also fails on a `classes.x` whose sheet has no `x`: such an element renders with no
style and tsc does not see it, because the class map is loosely typed. That happens most when a style is removed from
a sheet another file borrows (the break-glass views share `useBreakglassStyles`, the Rollout DAG borrows the Pipeline
DAG's).

To check a change by eye, run the backstage app locally (`yarn start` with guest sign-in) and copy this package's
built `dist/` into its `node_modules/@jfillman/tower/dist`; the dev server needs a restart to pick it up.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"build": "backstage-cli package build",
"clean": "backstage-cli package clean",
"lint": "eslint 'src/**/*.{ts,tsx}'",
"ratchet": "node scripts/style-ratchet.mjs",
"test": "backstage-cli package test",
"tsc": "tsc"
},
Expand Down
79 changes: 79 additions & 0 deletions scripts/style-ratchet.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
{
"counts": {
"sectionTitle": 18,
"table": 29,
"fact": 15,
"note": 9,
"card": 21,
"banner": 4,
"stat": 2,
"handRolledLink": 12,
"offScaleRadius": 9
},
"dangling": [
"src/PipelineFlow.tsx: classes.connector",
"src/PipelineFlow.tsx: classes.detailsBody",
"src/PipelineFlow.tsx: classes.detailsToggle",
"src/PipelineFlow.tsx: classes.dot",
"src/PipelineFlow.tsx: classes.drawer",
"src/PipelineFlow.tsx: classes.drawerTitle",
"src/PipelineFlow.tsx: classes.flow",
"src/PipelineFlow.tsx: classes.friendlyBody",
"src/PipelineFlow.tsx: classes.friendlyHeadline",
"src/PipelineFlow.tsx: classes.grid",
"src/PipelineFlow.tsx: classes.kv",
"src/PipelineFlow.tsx: classes.kvLabel",
"src/PipelineFlow.tsx: classes.kvValue",
"src/PipelineFlow.tsx: classes.label",
"src/PipelineFlow.tsx: classes.mini",
"src/PipelineFlow.tsx: classes.node",
"src/PipelineFlow.tsx: classes.nodeSelected",
"src/PipelineFlow.tsx: classes.note",
"src/PipelineFlow.tsx: classes.pill",
"src/PipelineFlow.tsx: classes.pillRow",
"src/PipelineFlow.tsx: classes.seg",
"src/tabs/dashboard/OpsWallDashboard.tsx: c.sourcesLabel",
"src/tabs/deployments/TroubleshootBanner.tsx: classes.livePulse",
"src/ui/index.tsx: s.count",
"src/ui/index.tsx: s.empty",
"src/ui/index.tsx: s.framed",
"src/ui/index.tsx: s.head",
"src/ui/index.tsx: s.headEnd",
"src/ui/index.tsx: s.headFramed",
"src/ui/index.tsx: s.headStart",
"src/ui/index.tsx: s.hint",
"src/ui/index.tsx: s.inset",
"src/ui/index.tsx: s.section",
"src/ui/index.tsx: s.title",
"src/ui/index.tsx: s.title2",
"src/ui/index.tsx: ui.button",
"src/ui/index.tsx: ui.buttonDanger",
"src/ui/index.tsx: ui.buttonPrimary",
"src/ui/index.tsx: ui.buttonSmall",
"src/ui/index.tsx: ui.chip",
"src/ui/index.tsx: ui.chipBad",
"src/ui/index.tsx: ui.chipFlight",
"src/ui/index.tsx: ui.chipGround",
"src/ui/index.tsx: ui.chipOk",
"src/ui/index.tsx: ui.chipSky",
"src/ui/index.tsx: ui.columnLabel",
"src/ui/index.tsx: ui.dot",
"src/ui/index.tsx: ui.field",
"src/ui/index.tsx: ui.fieldLabel",
"src/ui/index.tsx: ui.fieldSource",
"src/ui/index.tsx: ui.fieldSourceSet",
"src/ui/index.tsx: ui.iconButton",
"src/ui/index.tsx: ui.input",
"src/ui/index.tsx: ui.marker",
"src/ui/index.tsx: ui.pageActions",
"src/ui/index.tsx: ui.pageHead",
"src/ui/index.tsx: ui.pageSub",
"src/ui/index.tsx: ui.pageTitle",
"src/ui/index.tsx: ui.panel",
"src/ui/index.tsx: ui.panelAccent",
"src/ui/index.tsx: ui.sectionLabel",
"src/ui/index.tsx: ui.subtab",
"src/ui/index.tsx: ui.subtabOn",
"src/ui/index.tsx: ui.subtabs"
]
}
152 changes: 152 additions & 0 deletions scripts/style-ratchet.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
#!/usr/bin/env node
// Style ratchet (docs/design-system.md). Tabs use the kit in src/ui; this check keeps them from drifting back.
//
// 1. Counts the local makeStyles entries outside src/ui that redo a kit piece (a section title, a table cell, a
// label/value pair, an empty note, a card, a banner, a headline number, a hand-rolled sky link, a radius off the
// 6/4 scale). A count may go down, never up. When it goes down, run with --update and commit the new baseline.
// 2. Lists `classes.x` uses whose sheet (local, or a hook exported from another file) has no `x`. Those render with
// no style at all and tsc cannot see them (the class map is loosely typed). Known false positives (a variable name
// reused for two sheets) are in the baseline; a new entry fails.
//
// Usage: node scripts/style-ratchet.mjs [--update]
import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';

const ROOT = new URL('..', import.meta.url).pathname;
const BASELINE = join(ROOT, 'scripts/style-ratchet.json');

const FAMILIES = {
sectionTitle: /^(sectionTitle|sectionHead|sectionSub|panelTitle|title|subTitle|heading|cardTitle|blockTitle)$/,
table: /^(table|th|td|tr|row|headRow|thead|headCell)$/,
fact: /^(kv|kvLabel|kvValue|kvRow|kvK|kvV|metaLabel|metaValue|metaItem|label|value|k|v|statLabel|statValue)$/,
note: /^(note|empty|emptyRow|hint|emptyNote|warnNote|detailNote|overflowNote|countNote)$/,
card: /^(card|panel|metaCard|deck)$/,
banner: /^(banner|callout|opBanner|condition|warn|notifCard)$/,
stat: /^(stat|statVal|big|kpi)$/,
};

function walk(dir, out = []) {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
if (statSync(p).isDirectory()) walk(p, out);
else if (/\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name)) out.push(p);
}
return out;
}

/** Top-level entries of every makeStyles sheet in a file: [{ hook, name, body }]. */
function sheets(src) {
const out = [];
const re = /(?:export )?const (\w+) = makeStyles(?:<[^>]*>)?\(\s*(?:\(\)\s*=>\s*\()?\{/g;
let m;
while ((m = re.exec(src))) {
let i = m.index + m[0].length;
const start = i;
let depth = 1;
while (depth && i < src.length) {
if (src[i] === '{') depth++;
else if (src[i] === '}') depth--;
i++;
}
const body = src.slice(start, i - 1);
const entries = [];
let d = 0;
let cur;
for (const line of body.split('\n')) {
if (d === 0) {
const km = /^\s{2}['"]?([\w$-]+)['"]?\s*:/.exec(line);
if (km) {
cur = { name: km[1], body: '' };
entries.push(cur);
}
}
if (cur) cur.body += `${line}\n`;
d += (line.match(/\{/g) ?? []).length - (line.match(/\}/g) ?? []).length;
}
out.push({ hook: m[1], exported: m[0].startsWith('export'), entries });
}
return out;
}

const files = walk(join(ROOT, 'src'));
const sources = Object.fromEntries(files.map(f => [relative(ROOT, f), readFileSync(f, 'utf8')]));

// 1. family counts
const counts = Object.fromEntries([...Object.keys(FAMILIES), 'handRolledLink', 'offScaleRadius'].map(k => [k, 0]));
const where = Object.fromEntries(Object.keys(counts).map(k => [k, []]));
for (const [file, src] of Object.entries(sources)) {
if (file.startsWith('src/ui/')) continue;
for (const sheet of sheets(src)) {
for (const { name, body } of sheet.entries) {
for (const [family, re] of Object.entries(FAMILIES)) {
if (re.test(name)) {
counts[family]++;
where[family].push(`${file}:${name}`);
}
}
if (/t\.sky\b/.test(body) && /textDecoration|cursor: 'pointer'/.test(body) && !/skySoft/.test(body)) {
counts.handRolledLink++;
where.handRolledLink.push(`${file}:${name}`);
}
const r = /borderRadius: (\d+)\b/.exec(body);
if (r && /border/.test(body.replace(/borderRadius/g, '')) && /t\.(panel|panelAlt|bg)\b/.test(body)) {
const v = Number(r[1]);
if (![0, 2, 3, 4, 6].includes(v) && v < 11) {
counts.offScaleRadius++;
where.offScaleRadius.push(`${file}:${name}`);
}
}
}
}
}

// 2. dangling class references
const exported = {};
for (const src of Object.values(sources)) {
for (const s of sheets(src)) if (s.exported) exported[s.hook] = new Set(s.entries.map(e => e.name));
}
const dangling = [];
for (const [file, src] of Object.entries(sources)) {
const all = { ...exported };
for (const s of sheets(src)) all[s.hook] = new Set(s.entries.map(e => e.name));
const vars = {};
const bind = (v, hook) => {
if (!all[hook]) return;
vars[v] ??= new Set();
for (const k of all[hook]) vars[v].add(k);
};
for (const m of src.matchAll(/const (\w+) = (use\w+)\(\{/g)) bind(m[1], m[2]);
for (const m of src.matchAll(/(\w+)\??: ReturnType<typeof (use\w+)>/g)) bind(m[1], m[2]);
for (const [v, keys] of Object.entries(vars)) {
const used = new Set([...src.matchAll(new RegExp(`\\b${v}\\.(\\w+)\\b`, 'g'))].map(m => m[1]));
for (const u of used) if (!keys.has(u)) dangling.push(`${file}: ${v}.${u}`);
}
}
dangling.sort();

const current = { counts, dangling };
if (process.argv.includes('--update')) {
writeFileSync(BASELINE, `${JSON.stringify(current, null, 2)}\n`);
console.log('style-ratchet: baseline written', counts);
process.exit(0);
}

const base = JSON.parse(readFileSync(BASELINE, 'utf8'));
let failed = false;
for (const [k, v] of Object.entries(counts)) {
const was = base.counts[k] ?? 0;
if (v > was) {
failed = true;
console.error(`style-ratchet: ${k} went up ${was} -> ${v}. Use the kit (docs/design-system.md). Current:\n ${where[k].join('\n ')}`);
} else if (v < was) {
console.log(`style-ratchet: ${k} went down ${was} -> ${v}; run with --update and commit the baseline.`);
}
}
const known = new Set(base.dangling);
const fresh = dangling.filter(d => !known.has(d));
if (fresh.length) {
failed = true;
console.error(`style-ratchet: class names used but not defined in their sheet (they render unstyled):\n ${fresh.join('\n ')}`);
}
if (failed) process.exit(1);
console.log('style-ratchet: ok', counts);
Loading