diff --git a/docs/public/screenshots/apply-patch-panel.png b/docs/public/screenshots/apply-patch-panel.png index a9fd4eb..0f39a0a 100644 Binary files a/docs/public/screenshots/apply-patch-panel.png and b/docs/public/screenshots/apply-patch-panel.png differ diff --git a/docs/public/screenshots/create-site-modal.png b/docs/public/screenshots/create-site-modal.png index b0c1aa9..c0dd31c 100644 Binary files a/docs/public/screenshots/create-site-modal.png and b/docs/public/screenshots/create-site-modal.png differ diff --git a/docs/public/screenshots/debug-log.png b/docs/public/screenshots/debug-log.png index 82a1e36..fa99c4e 100644 Binary files a/docs/public/screenshots/debug-log.png and b/docs/public/screenshots/debug-log.png differ diff --git a/docs/public/screenshots/mail-panel.png b/docs/public/screenshots/mail-panel.png index 38ea6b3..a7c58d1 100644 Binary files a/docs/public/screenshots/mail-panel.png and b/docs/public/screenshots/mail-panel.png differ diff --git a/docs/public/screenshots/setup-wizard.png b/docs/public/screenshots/setup-wizard.png index a89cfb2..b111e4e 100644 Binary files a/docs/public/screenshots/setup-wizard.png and b/docs/public/screenshots/setup-wizard.png differ diff --git a/docs/public/screenshots/site-menu.png b/docs/public/screenshots/site-menu.png index cfd9660..9998175 100644 Binary files a/docs/public/screenshots/site-menu.png and b/docs/public/screenshots/site-menu.png differ diff --git a/docs/public/screenshots/site-view-wide.png b/docs/public/screenshots/site-view-wide.png new file mode 100644 index 0000000..faa4b43 Binary files /dev/null and b/docs/public/screenshots/site-view-wide.png differ diff --git a/docs/public/screenshots/site-view.png b/docs/public/screenshots/site-view.png index 4dc2d5a..7d6ad69 100644 Binary files a/docs/public/screenshots/site-view.png and b/docs/public/screenshots/site-view.png differ diff --git a/docs/public/screenshots/stale-site-notice.png b/docs/public/screenshots/stale-site-notice.png index 7e41547..981146b 100644 Binary files a/docs/public/screenshots/stale-site-notice.png and b/docs/public/screenshots/stale-site-notice.png differ diff --git a/docs/public/screenshots/terminal.png b/docs/public/screenshots/terminal.png index 6f00ec2..d639e9b 100644 Binary files a/docs/public/screenshots/terminal.png and b/docs/public/screenshots/terminal.png differ diff --git a/docs/public/screenshots/trac-ticket-panel.png b/docs/public/screenshots/trac-ticket-panel.png index 6ca39c7..95a32e8 100644 Binary files a/docs/public/screenshots/trac-ticket-panel.png and b/docs/public/screenshots/trac-ticket-panel.png differ diff --git a/scripts/screenshots/capture.cjs b/scripts/screenshots/capture.cjs index 23ecdbc..f357d3e 100644 --- a/scripts/screenshots/capture.cjs +++ b/scripts/screenshots/capture.cjs @@ -41,6 +41,12 @@ const outDir = path.join(repoRoot, 'docs', 'public', 'screenshots'); // Every image the same size, every run: a fixed window and DPR 1. Without the // scale-factor switch a retina display doubles the pixel size of half the // screenshots and the docs pages render them inconsistently. +// +// A shot can override the width with its own `window` — see `site-view-wide` in +// shots.cjs. Layout that only appears past a breakpoint is invisible to a +// harness with one window size, which is how the content column's width cap +// went unphotographed: at 1200px the window is narrower than the cap, so every +// image looked identical whether the cap was there or not. const WINDOW = { width: 1200, height: 800 }; const ELECTRON_SWITCHES = ['--force-device-scale-factor=1', '--lang=en-GB']; @@ -78,6 +84,13 @@ function expandHome(p) { return p; } +async function setWindow(app, bounds) { + await app.evaluate(({ BrowserWindow }, size) => { + const win = BrowserWindow.getAllWindows()[0]; + win.setBounds({ x: 40, y: 40, ...size }); + }, bounds); +} + async function launchApp(env) { const app = await _electron.launch({ // From plain Node, require('electron') resolves to the binary's path — @@ -89,19 +102,26 @@ async function launchApp(env) { env: { ...process.env, TZ: 'UTC', ...env } }); const page = await app.firstWindow(); - await app.evaluate(({ BrowserWindow }, bounds) => { - const win = BrowserWindow.getAllWindows()[0]; - win.setBounds({ x: 40, y: 40, ...bounds }); - }, WINDOW); + await setWindow(app, WINDOW); return { app, page }; } +// Freeze CSS animations and transitions, and rewind them to their first frame, +// so a shot is a function of the app's state and nothing else. +// +// Without this, three images changed on every run with no code change at all: +// the "Checking GitHub…" spinner is a CSS animation, and each capture caught it +// at a different angle. That is noise in any diff, and worse than noise in a +// stack of branches — a rebase hits a binary conflict on a file where nothing +// actually changed, and binary conflicts have no resolution but to pick a side. +const SHOT_OPTIONS = { animations: 'disabled' }; + async function captureShot(page, shot) { const file = path.join(outDir, `${shot.slug}.png`); if (shot.target) { - await shot.target(page).screenshot({ path: file }); + await shot.target(page).screenshot({ path: file, ...SHOT_OPTIONS }); } else { - await page.screenshot({ path: file }); + await page.screenshot({ path: file, ...SHOT_OPTIONS }); } console.log(` ✓ ${shot.slug}.png`); } @@ -113,6 +133,18 @@ async function runFixtureTier(selected) { const { app, page } = await launchApp({ TOOLKIT_USER_DATA_DIR: userDataDir }); try { for (const shot of selected.filter((s) => s.variant === variant)) { + // Set unconditionally, not only when the shot asks for it: the + // previous shot may have widened the window, and a shot that + // silently inherits another's size is the bug this whole file + // exists to avoid. + // + // Merged over WINDOW rather than substituted for it, for the + // same reason. `setBounds` accepts a partial rectangle, so a + // shot declaring only `{ width: 1600 }` — the natural thing to + // write when only the width matters — would otherwise keep + // whatever height the shot before it left, and `--only=` + // would produce a different image than a full run. + await setWindow(app, { ...WINDOW, ...shot.window }); // Fresh renderer per shot: open menus and modals from the // previous shot cannot leak into this one. await page.reload(); @@ -164,6 +196,15 @@ async function main() { const known = shots.filter((s) => s.tier === args.tier).map((s) => s.slug); throw new Error(`No ${args.tier}-tier shot matches. Known slugs: ${known.join(', ')}`); } + // A `window` on a live shot does nothing — the maintainer owns the window in + // that tier — and a silently ignored key is the shape of a wasted hour. + const sized = selected.filter((s) => s.tier === 'live' && s.window); + if (sized.length) { + throw new Error( + `Live-tier shots cannot set "window": ${sized.map((s) => s.slug).join(', ')}. ` + + 'The maintainer sizes the window in that tier.' + ); + } fs.mkdirSync(outDir, { recursive: true }); console.log(`Capturing ${selected.length} ${args.tier}-tier screenshot(s) into ${path.relative(repoRoot, outDir)}/`); if (args.tier === 'fixture') await runFixtureTier(selected); diff --git a/scripts/screenshots/shots.cjs b/scripts/screenshots/shots.cjs index a688879..0503add 100644 --- a/scripts/screenshots/shots.cjs +++ b/scripts/screenshots/shots.cjs @@ -1,6 +1,6 @@ // The declarative list of documentation screenshots. // -// Each entry is { slug, tier, variant, prepare, target }: +// Each entry is { slug, tier, variant, prepare, target, window }: // - slug: the output filename, docs/public/screenshots/.png — docs pages // reference these names, so renaming one is a docs change too; // - tier 'fixture': captured fully automatically against seeded state; @@ -12,7 +12,13 @@ // if a label changes, the shot fails loudly instead of photographing the // wrong thing; // - target (optional): a locator for an element screenshot instead of the -// whole window. Panels read better cropped; whole-window shots orient. +// whole window. Panels read better cropped; whole-window shots orient; +// - window (optional, fixture tier only): { width, height } for this shot +// alone, merged over the harness default of 1200x800. Reach for it when the +// layout worth showing only appears at another size — `site-view-wide` is +// the case, and its comment explains why. Partial is fine: `{ width: 1600 }` +// keeps the default height. The live tier ignores it, because there the +// maintainer owns the window. // // Three shots that used to be fixture-tier are live-tier now, joining // dev-server-running, and moving them back would photograph a screen the 1.0 @@ -29,13 +35,34 @@ // tier still covers everything else. /** - * Clicks a site in the sidebar and waits for its view to render. + * Clicks a site in the sidebar and waits for its view to settle. + * + * The wait is not cosmetic. Selecting a site with a linked ticket fires the + * linked-pull-request lookup, which is a network call, and the panel shows a + * spinner until it answers. Without waiting, whether a shot catches the spinner + * or the result is a coin flip — the same run has produced site-view.png + * mid-check and trac-ticket-panel.png already resolved, which is two different + * answers to the same question in one set of docs images. + * + * It also made three PNGs change on every run with no code change at all. In a + * stack of branches that is worse than noise: rebasing hits a binary conflict on + * a file nothing actually changed, and a binary conflict has no resolution + * except to pick a side and re-capture. + * + * Bounded and swallowed rather than awaited indefinitely: a site with no ticket + * never shows the spinner at all, and a harness that hangs because GitHub is + * slow is worse than one that photographs a spinner. * * @param {import('playwright-core').Page} page * @param {string} label */ async function selectSite(page, label) { await page.getByText(label, { exact: true }).first().click(); + await page + .getByText('Checking GitHub…') + .first() + .waitFor({ state: 'hidden', timeout: 15000 }) + .catch(() => {}); } /** @@ -71,6 +98,27 @@ const shots = [ await page.getByRole('dialog').getByText('Site name').waitFor(); } }, + { + // The content column's width cap, which no other shot can show: at the + // default 1200px window the content area is 855px — the width every + // cropped panel shot here comes out at — which is narrower than the + // 880px cap, so the cap has no effect and the image looks the same with + // or without it. This is the only shot that proves --wpct-content-max-width + // does anything, and the only one that would catch its removal. + // + // Unlike its neighbours it is referenced by no docs page. It ships in + // the VitePress build as a review artifact, deliberately: the cap is + // otherwise unfalsifiable by eye, and test/content-column.test.cjs + // asserts this image exists and was captured at the declared width. + slug: 'site-view-wide', + tier: 'fixture', + variant: 'seeded', + window: { width: 1600, height: 800 }, + prepare: async (page) => { + await selectSite(page, 'my-first-patch'); + await page.getByRole('button', { name: 'Submit changes' }).waitFor(); + } + }, { slug: 'site-menu', tier: 'fixture', diff --git a/src/renderer/index.html b/src/renderer/index.html index 2aa1aa6..e17100b 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -61,10 +61,16 @@ `border-radius` here only rounds the glow around the wrapped action buttons, which set none of their own. The blocks set their radius inline, which the glow follows and this does not override. + + It reads --wpct-radius-control so the glow keeps the shape of the button + inside it. It was a hand-picked 10px, which was already larger than + anything @wordpress/components draws; once the dev-server button lost its + own oversized radius, a 10px glow around a 2px button read as a rounded + box floating behind a square one. */ .next-action-cue { - border-radius: 10px; - box-shadow: 0 0 0 3px rgba(240, 184, 73, 0.45), 0 0 14px 3px rgba(240, 184, 73, 0.30); + border-radius: var(--wpct-radius-control); + box-shadow: 0 0 0 3px rgba(var(--wpct-cue), 0.45), 0 0 14px 3px rgba(var(--wpct-cue), 0.30); } /* Visually hidden but read aloud — the spoken half of the next-action cue (#252), for the live region that names the next step. */ diff --git a/src/renderer/index.jsx b/src/renderer/index.jsx index 96b15eb..6a00f45 100644 --- a/src/renderer/index.jsx +++ b/src/renderer/index.jsx @@ -20,6 +20,15 @@ import { } from '@wordpress/components'; import { plus, chevronLeft, chevronRight, chevronDown, copy as copyIcon, check as checkIcon, edit, download, comment } from '@wordpress/icons'; import '@wordpress/components/build-style/style.css'; +// After the @wordpress/components stylesheet, deliberately: esbuild concatenates +// CSS imports in import order, so this is what lets the app's own tokens win +// against it without any build configuration. xterm's stylesheet is imported +// after these, which is harmless — it only selects inside .xterm. +import './styles/tokens.css'; +import { Section } from './ui/Section.jsx'; +import { ActionRow } from './ui/ActionRow.jsx'; +import { MetaText } from './ui/MetaText.jsx'; +import { StatusBadge } from './ui/StatusBadge.jsx'; import { Terminal } from 'xterm'; import 'xterm/css/xterm.css'; import { computeSetupStepState, setupStepStatuses, setupStepCopy, setupAutoStartDecision, setupStepLabel } from './setup-steps.cjs'; @@ -868,7 +877,12 @@ function App() {
-
+ {/* The measure was already capped, at a hand-picked 1040px. It now + reads from --wpct-content-max-width so the whole window agrees on + one line length, and it is narrower: 1040px is wide enough that + the eye loses the left spine travelling back from the end of a + meta line. */} +
{webAvailable ? (
-
- - {initialized ? 'Initialized' : 'Uninitialized'} - +
+ } + actions={ + confirmAnd('Delete this site from disk? This cannot be undone.', ()=>onDelete(sitePath)) } + ]) + ]} + /> + } + meta={<> + + {createdLabel ? Created {createdLabel} : null} {age.known ? ( - + {createdLabel ? : null} {age.stale ? ( ) : null} -
-
- - {sitePath} - + + {/* The path, the control that copies it and the control that opens it + are one meta block rather than three stacked rows. They answer the + same question — "where is this site?" — and separating them was + what left "Open directory in" looking like it belonged to nothing. + Detection runs when the menu is opened rather than on load: it is a + filesystem sweep, and the answer is only needed once someone asks. + It is re-read on every open, so an application installed while this + app is running shows up the next time the menu is used. */} + + {sitePath}
- {/* One control for one intention, directly under the path it acts on. - Detection runs when the menu is opened rather than on load: it is a - filesystem sweep, and the answer is only needed once someone asks. - It is re-read on every open, so an application installed while this - app is running shows up the next time the menu is used. */} -
(
+ {/* With no modal in the way, this is the only place a failed open can speak — and it carries the way out with it, rather than leaving the - contributor to find the menu again. */} + contributor to find the menu again. + + Still styled by hand: every notice box in the window is converted + together in Phase 4 of plans/ui-polish-pass.md, because introducing + for this one would leave two notice styles on screen until + the rest caught up. */} {editorNotice ? ( -
+
{editorNotice.message} {editorNotice.offerPicker ? ( ) : null}
) : null} -
-
- confirmAnd('Delete this site from disk? This cannot be undone.', ()=>onDelete(sitePath)) } - ]) - ]} - /> -
- + } + /> {updateIncomplete && !isUpdating ? (
@@ -4445,32 +4459,27 @@ function SiteRow({ sitePath, initialized, createdAt, label, onInitialized, onSit ) : ( null )} + {/* These two buttons were the clearest case of the complaint this pass + answers: they sat in bare whitespace between the header and the Trac + card, so they read as page furniture rather than as this site's own + actions. The rule above them is what makes them belong to the site + named directly overhead, and the status lines below them sit inside the + same region as the button that produces them. */} {skipInit ? ( -
-
+
+ -
+ {changesNote && changesNote.placement === 'buttons' ? ( -
+
{changesNoteBody}
) : null} {(isServerStarting || serverUrl) ? ( -
+
{serverUrl ? ( <> -
+
{ e.preventDefault(); window.api.openExternal(serverUrl); }}>{serverUrl} - + { e.preventDefault(); window.api.openExternal(adminUrl(serverUrl)); }}>wp-admin {running ? ( <> @@ -4515,7 +4523,7 @@ function SiteRow({ sitePath, initialized, createdAt, label, onInitialized, onSit ) : null}
- Log in with admin / password. + Log in with admin / password. ) : ( `Dev server is starting… (${formatElapsed(startElapsed)})` diff --git a/src/renderer/status-tone.cjs b/src/renderer/status-tone.cjs new file mode 100644 index 0000000..7f40f9c --- /dev/null +++ b/src/renderer/status-tone.cjs @@ -0,0 +1,69 @@ +// Which tone a status word wears, for every status in the app. +// +// The window shows status in two places and, until now, in two visual languages: +// the site header drew a rounded pill with its own green/amber pair, while the +// setup checklist wrote COMPLETED, IN PROGRESS and LOCKED as bare uppercase text +// a few hundred pixels below it. Same idea, two shapes, so neither read as the +// canonical way this app says "state". +// +// This is the single map behind . It is a lookup, not a component, +// so it can be unit-tested — the repo's split is pure logic in .cjs with +// node:test, presentational JSX untested (see pr-state.cjs, setup-steps.cjs). +// +// Note that the tones are semantic, not colours: a caller asks for a status and +// gets a tone name, and tokens.css owns what that tone looks like. Adding a +// status here should never mean picking a hex value. +'use strict'; + +// Every status the window can show, mapped to a tone in tokens.css. +// +// `pending` and `locked` share the neutral tone deliberately. Both mean "not +// yet", and colouring either one amber would say something failed. What +// separates them for a reader is the word and the step's own affordance — a +// locked step's button is disabled — not the badge's colour. +// +// The checklist statuses carry no label here on purpose. `setupStepLabel` in +// setup-steps.cjs already owns those words, and it makes a distinction this map +// cannot see: the same `current` status reads "Ready" before its action runs and +// "In progress" while it is running (#257). Repeating the words here would be a +// second source of truth that is already wrong. Callers pass the label as the +// badge's children; this map only says what colour it wears. +const STATUS_TONES = { + // Site states, shown in the header. Nothing else owns these words. + initialized: { label: 'Initialized', tone: 'success' }, + uninitialized: { label: 'Uninitialized', tone: 'warning' }, + + // Setup checklist step states, from computeSetupStepState in setup-steps.cjs. + complete: { label: '', tone: 'success' }, + current: { label: '', tone: 'info' }, + pending: { label: '', tone: 'neutral' }, + locked: { label: '', tone: 'neutral' } +}; + +const NEUTRAL = { label: '', tone: 'neutral' }; + +/** + * The label and tone for one status. + * + * An unrecognised status falls back to the neutral tone rather than throwing or + * picking a colour: a status this map has not been taught about is precisely the + * case where the app should not assert that something succeeded or failed. The + * returned label is empty so the caller's own text is used verbatim. + * + * @param {string} status + * @return {{label: string, tone: string}} + */ +function statusTone( status ) { + const key = typeof status === 'string' ? status.toLowerCase() : ''; + // `Object.hasOwn`, not a plain lookup: a bare `STATUS_TONES[key]` reads + // through Object.prototype, so `statusTone('constructor')` returned the + // Object constructor and `statusTone('__proto__')` returned the prototype + // itself. Both destructure to an undefined tone, which renders a badge + // classed `wpct-badge--undefined` — unstyled, and carrying no word either. + // Only already-lowercase prototype keys could reach it, since the lookup + // lowercases first, but the guarantee this function documents has to hold + // for every string rather than for most of them. + return Object.hasOwn( STATUS_TONES, key ) ? STATUS_TONES[ key ] : NEUTRAL; +} + +module.exports = { STATUS_TONES, statusTone }; diff --git a/src/renderer/styles/tokens.css b/src/renderer/styles/tokens.css new file mode 100644 index 0000000..858efdc --- /dev/null +++ b/src/renderer/styles/tokens.css @@ -0,0 +1,395 @@ +/* + * The one place a spacing, colour, radius or type decision is made. + * + * The renderer grew as inline `style={{}}` objects, so every panel picked its own + * values and no two agreed: eight margins, three border greys, two muted greys, + * three radii. None of that was a decision — it was 300 separate ones. These + * tokens are the dedupe, not an invention: each replaces a set of values already + * in the file. + * + * Values follow the WordPress Design System, because the app already renders + * @wordpress/components. Matching its scale is what stops the stock controls + * looking like foreign objects dropped into hand-painted panels. + * + * Imported from index.jsx *after* @wordpress/components/build-style/style.css, so + * the cascade puts these last. esbuild concatenates CSS imports in import order + * (--loader:.css=css), which gives that for free with no build change. + * + * Dark mode is deliberately absent. Every surface here is painted light by hand + * and @wordpress/components ships light styles only; test/color-scheme.test.cjs + * holds that line. A dark theme is its own project, not a second set of tokens. + */ + +:root { + /* + * The spacing scale. Six steps replacing the 4/6/8/10/12/14/16/24 in use + * today — the odd steps existed because nothing said which to reach for. + * + * Three of these carry meaning rather than size, and that is the point of + * the whole pass: -2 (8px) is *within* a group, -4 (16px) is *between* + * groups inside a card, -5 (24px) is *between* cards. Spacing is how a + * reader tells what belongs to what, so those three are not interchangeable. + */ + --wpct-space-1: 4px; + --wpct-space-2: 8px; + --wpct-space-3: 12px; + --wpct-space-4: 16px; + --wpct-space-5: 24px; + --wpct-space-6: 32px; + + /* Borders. One hairline for real edges, one fainter for dividers inside a + card — a divider that is as dark as the card's own border reads as a + second card starting. */ + --wpct-border: #e0e0e0; + --wpct-border-subtle: #f0f0f1; + + /* Text. Two values, not the four the file uses today. Muted is for + supporting lines only; anything a contributor has to read to act on is + --wpct-text. */ + --wpct-text: #1e1e1e; + --wpct-text-muted: #6c6f72; + + --wpct-accent: #3858e9; + --wpct-destructive: #d63638; + + /* Surfaces. */ + --wpct-surface: #fff; + --wpct-surface-subtle: #f0f0f1; + + /* + * Two radii, not three. WPDS rounds controls at 2px and surfaces at 4px; + * the 6/8/10/12 values in the file today are all larger than anything + * @wordpress/components draws, which is why hand-rolled boxes never quite + * sat next to the stock ones. + */ + --wpct-radius-control: 2px; + --wpct-radius-surface: 4px; + + --wpct-font-size-small: 12px; + --wpct-font-size-base: 13px; + + /* + * Notice palettes. Four triples, each currently hand-inlined in several + * places. Defined here now because StatusBadge's tones already read them; + * the notice boxes themselves are converted in a later phase. + */ + --wpct-info-background: #f0f6fc; + --wpct-info-border: #c5d9ed; + --wpct-info-text: #0b5d95; + + --wpct-warning-background: #fcf9e8; + --wpct-warning-border: #dba617; + --wpct-warning-text: #6e5406; + + --wpct-success-background: #f4fbf4; + --wpct-success-border: #94d3ae; + --wpct-success-text: #0f5132; + + --wpct-error-background: #fcf0f1; + --wpct-error-border: #d63638; + --wpct-error-text: #8a1f21; + + /* + * The next-action cue's glow (#252), as RGB channels so index.html can vary + * its alpha. + * + * #f0b849 is WordPress's own alert yellow — COLORS.alert.yellow in + * @wordpress/components, the colour it paints a warning Notice's left border + * with — and it is deliberately not --wpct-warning-border. The cue is not a + * warning: it says "do this next", and it is frequently drawn *around* a + * warning notice, where a glow in that notice's own border colour would + * disappear into it. + */ + --wpct-cue: 240, 184, 73; + + /* + * The readable measure for the content column. Without a cap, a heading and + * its meta line stretch the full width of a maximised window and the eye + * loses the left spine on the way back. + */ + --wpct-content-max-width: 880px; +} + +/* --------------------------------------------------------------------------- + * Primitives. + * + * Class names rather than inline styles, so a token change lands everywhere at + * once. Each of these has a component wrapper in src/renderer/ui/ — the CSS is + * here so the whole visual language reads in one file. + * ------------------------------------------------------------------------ */ + +/* Section: one card per section of the page. */ +.wpct-section { + background: var(--wpct-surface); + border: 1px solid var(--wpct-border); + border-radius: var(--wpct-radius-surface); + padding: var(--wpct-space-5); + display: flex; + flex-direction: column; + gap: var(--wpct-space-4); + color: var(--wpct-text); +} + +/* A section that carries no card chrome — used where the page itself is already + the container, as the site header is. It still owns the spine and the + between-group rhythm, which is what the rest of the card gives. */ +.wpct-section--plain { + background: none; + border: 0; + padding: 0; +} + +.wpct-section__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--wpct-space-4); + flex-wrap: wrap; +} + +/* The heading and anything that describes it stay together; the actions sit + opposite. Grouping rule 1: an action belongs to a region, never to whitespace. */ +.wpct-section__heading { + flex: 1 1 440px; + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--wpct-space-2); +} + +.wpct-section__title-row { + display: flex; + align-items: center; + gap: var(--wpct-space-2); + flex-wrap: wrap; + min-width: 0; +} + +.wpct-section__title { + margin: 0; + font-size: 20px; + line-height: 1.3; + font-weight: 600; + color: var(--wpct-text); +} + +/* Heading level drives size, so the page's one h1 stays the page's one h1 and + no caller has to pass a font size alongside a level. */ +h1.wpct-section__title { + font-size: 28px; + line-height: 1.2; +} + +.wpct-section__body { + display: flex; + flex-direction: column; + gap: var(--wpct-space-4); + min-width: 0; +} + +/* + * ActionRow: the bounded region a group of buttons lives in. + * + * `--divided` is grouping rule 4 — a rule separates two groups, so it goes on + * top of a trailing action row and never above the first group or below the + * last. + */ +.wpct-action-row { + display: flex; + align-items: center; + gap: var(--wpct-space-2); + flex-wrap: wrap; +} + +.wpct-action-row--divided { + border-top: 1px solid var(--wpct-border-subtle); + padding-top: var(--wpct-space-4); +} + +.wpct-action-row--end { + justify-content: flex-end; +} + +/* MetaText: the supporting line under a heading. One size, one grey. */ +.wpct-meta { + font-size: var(--wpct-font-size-small); + color: var(--wpct-text-muted); + display: flex; + align-items: center; + gap: var(--wpct-space-2); + flex-wrap: wrap; + min-width: 0; +} + +/* + * A meta line that is a sentence rather than a row of items. + * + * The flex layout above makes each contiguous run of text its own anonymous + * flex item, so "Log in with admin / password." lays + * out as five items with a gap between every one — including before the full + * stop — each free to wrap onto its own line. Prose needs normal inline flow. + */ +.wpct-meta--flow { + display: block; +} + +/* + * The path chip. + * + * Deliberately its own class rather than `.wpct-meta code`. That rule caught + * every `code` element in a meta line, which meant the `admin` / `password` in + * the dev-server credentials line picked up a chip background it never had and + * did not want — they are words in a sentence, not a value to be copied. + * + * --wpct-text rather than --wpct-text-muted: muted grey is for supporting text, + * and this is a string the contributor reads in order to act. It also has to + * clear AA on the chip's own background, which the muted grey does not — + * #6c6f72 on #f0f0f1 is 4.44:1, just under the 4.5:1 threshold at this size. + */ +.wpct-chip { + background: var(--wpct-surface-subtle); + border-radius: var(--wpct-radius-control); + padding: 2px 6px; + overflow-wrap: anywhere; + color: var(--wpct-text); + font-size: var(--wpct-font-size-small); +} + +/* A link-styled button that is part of a meta line rather than a control beside + it — @wordpress/components sizes its own text, so it has to be told to match + the line it sits in. */ +.wpct-meta-link.components-button { + display: inline-flex; + align-items: center; + gap: 2px; + font-size: var(--wpct-font-size-small); + height: auto; +} + +/* The amber dot beside a stale trunk snapshot. It is decoration for the label + next to it — aria-hidden at the call site — never the signal on its own. */ +.wpct-stale-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--wpct-warning-border); +} + +/* + * StatusBadge: one badge shape for every status in the app. + * + * It replaces both the rounded pill in the site header and the bare uppercase + * words in the setup checklist. Those said the same kind of thing in two visual + * languages, which is a large part of why the app read as unconsidered. + */ +.wpct-badge { + display: inline-flex; + align-items: center; + padding: 2px var(--wpct-space-2); + border-radius: var(--wpct-radius-control); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; + white-space: nowrap; + border: 1px solid transparent; +} + +.wpct-badge--success { + background: var(--wpct-success-background); + border-color: var(--wpct-success-border); + color: var(--wpct-success-text); +} + +.wpct-badge--warning { + background: var(--wpct-warning-background); + border-color: var(--wpct-warning-border); + color: var(--wpct-warning-text); +} + +.wpct-badge--info { + background: var(--wpct-info-background); + border-color: var(--wpct-info-border); + color: var(--wpct-info-text); +} + +.wpct-badge--error { + background: var(--wpct-error-background); + border-color: var(--wpct-error-border); + color: var(--wpct-error-text); +} + +/* Neutral is the "nothing has happened yet" tone — a locked setup step, an + unknown state. It must not read as a warning. */ +.wpct-badge--neutral { + background: var(--wpct-surface-subtle); + border-color: var(--wpct-border); + color: var(--wpct-text-muted); +} + +/* --------------------------------------------------------------------------- + * One site's page: the header, then a stack of cards. + * ------------------------------------------------------------------------ */ + +/* The 24px is --wpct-space-5 doing its one job — the gap *between* cards. The + trailing space is so the last card can be scrolled clear of the window edge. */ +.wpct-site-page { + display: flex; + flex-direction: column; + gap: var(--wpct-space-5); + padding-bottom: var(--wpct-space-6); +} + +/* --------------------------------------------------------------------------- + * The site's own actions: the region directly under the site header. + * ------------------------------------------------------------------------ */ + +.wpct-site-actions { + display: flex; + flex-direction: column; + gap: var(--wpct-space-3); +} + +/* The lines the buttons above produce — a URL, a note about uncommitted + changes. They are inside the same region as the control that causes them, + which is the whole point: a status line floating on its own says nothing + about what produced it. */ +.wpct-site-actions__status { + font-size: var(--wpct-font-size-base); + color: var(--wpct-text); + display: flex; + flex-direction: column; + gap: var(--wpct-space-1); +} + +/* + * The site's primary action. + * + * It used to be drawn at 15px on a 12px radius with its own padding — larger + * than anything @wordpress/components draws, which made it read as a control + * from a different app rather than as the important one. Emphasis now comes + * from width alone, and the shape is the stock button's. + */ +.wpct-primary-action.components-button { + min-width: 220px; + justify-content: center; + gap: var(--wpct-space-2); +} + +/* The dev server is running. Red because stopping is what the button now does. */ +.wpct-running-dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--wpct-destructive); + box-shadow: 0 0 0 4px rgba(214, 54, 56, 0.15); +} + +/* The readable measure, applied to the scrolling content column. */ +.wpct-content-column { + max-width: var(--wpct-content-max-width); + margin: 0 auto; + width: 100%; +} diff --git a/src/renderer/ui/ActionRow.jsx b/src/renderer/ui/ActionRow.jsx new file mode 100644 index 0000000..f6818df --- /dev/null +++ b/src/renderer/ui/ActionRow.jsx @@ -0,0 +1,42 @@ +/* + * A bounded region for a group of controls. + * + * Wherever the app reads as unfinished, the cause is usually a button with no + * region: "Start dev server" in bare whitespace, "Skip initialization wizard" + * dangling off a card's bottom edge. is the region. It owns the gap + * between its buttons too, which is why the global `button { margin-right: 8px }` + * in index.html can eventually go — spacing between controls is a property of the + * group they are in, not of every button in the window. + * + * `divided` draws the rule above the row. The rule is structure, not decoration: + * it means "a new group starts here", so it goes between two groups and never + * above the first or below the last. + */ + +import React from 'react'; + +/** + * @param {Object} props + * @param {boolean} [props.divided] Draw a hairline above the row. + * @param {boolean} [props.end] Align the controls to the right. + * @param {string} [props.className] Extra classes. + * @param {React.ReactNode} props.children + */ +export function ActionRow( { divided = false, end = false, className = '', children, ...rest } ) { + const classes = [ + 'wpct-action-row', + divided ? 'wpct-action-row--divided' : '', + end ? 'wpct-action-row--end' : '', + className + ] + .filter( Boolean ) + .join( ' ' ); + + return ( +
+ { children } +
+ ); +} + +export default ActionRow; diff --git a/src/renderer/ui/MetaText.jsx b/src/renderer/ui/MetaText.jsx new file mode 100644 index 0000000..9f0bfd3 --- /dev/null +++ b/src/renderer/ui/MetaText.jsx @@ -0,0 +1,42 @@ +/* + * The supporting line under a heading: a created date, a path, a trunk age. + * + * One size and one grey, where the file previously used 11px, 12px and 13px + * against #6c6f72 and #3c434a more or less at random. Three sizes of grey text + * on one screen reads as three levels of importance, and there was only ever + * one — so the differences were saying something untrue. + * + * It lays out as a wrapping flex row because that is what every caller does with + * it: a badge, a date, a dot separator, a path chip. Pass `column` for the cases + * that stack. + * + * Pass `flow` when the content is a sentence. A flex row makes each contiguous + * run of text its own anonymous flex item, so prose comes out gapped between + * every fragment and wrapping in the wrong places. + */ + +import React from 'react'; + +/** + * @param {Object} props + * @param {boolean} [props.column] Stack the children instead of inlining them. + * @param {boolean} [props.flow] The content is prose; lay it out inline. + * @param {string} [props.className] Extra classes. + * @param {React.ReactNode} props.children + */ +export function MetaText( { column = false, flow = false, className = '', children, ...rest } ) { + const classes = [ 'wpct-meta', flow ? 'wpct-meta--flow' : '', className ] + .filter( Boolean ) + .join( ' ' ); + // The one inline style left here, because it is the component's own single + // axis switch rather than a design decision a token should own. + const style = column ? { flexDirection: 'column', alignItems: 'flex-start' } : undefined; + + return ( +
+ { children } +
+ ); +} + +export default MetaText; diff --git a/src/renderer/ui/Section.jsx b/src/renderer/ui/Section.jsx new file mode 100644 index 0000000..56d1ecc --- /dev/null +++ b/src/renderer/ui/Section.jsx @@ -0,0 +1,78 @@ +/* + * A section of the page: a heading, the actions that belong to that heading, and + * the content below both. + * + * The complaint this answers is that buttons and links float in space. A control + * has to sit in a region, and the region has to be the thing it acts on — + * otherwise the eye cannot tell what "Refresh" refreshes.
is the outer + * half of that: it owns a heading row, and anything passed as `actions` is + * anchored to that row rather than left to drift near it. + * + * The styling lives in ../styles/tokens.css. These components are wrappers over + * class names, not style objects, so one token edit lands everywhere at once. + */ + +import React from 'react'; + +/** + * @param {Object} props + * @param {React.ReactNode} [props.title] Heading text. Rendered as `level`. + * @param {React.ReactNode} [props.titleAdornment] A control that acts on the title itself. + * @param {React.ReactNode} [props.meta] Supporting lines under the heading. + * @param {React.ReactNode} [props.actions] Controls that belong to the heading. + * @param {number} [props.level] Heading level, 1–6. Defaults to 2. + * @param {boolean} [props.plain] Drop the card chrome, keep the rhythm. + * @param {string} [props.className] Extra classes. + * @param {React.ReactNode} [props.children] The section's content. + */ +export function Section( { + title, + titleAdornment, + meta, + actions, + level = 2, + plain = false, + className = '', + children, + ...rest +} ) { + // Heading level is a prop because the site header is the page's h1 while + // every other section sits under it. Nesting is a document-structure + // question, and hard-coding h2 here would have made the page unnavigable by + // heading for anyone using a screen reader. + const Heading = `h${ Math.min( Math.max( level, 1 ), 6 ) }`; + const classes = [ 'wpct-section', plain ? 'wpct-section--plain' : '', className ] + .filter( Boolean ) + .join( ' ' ); + + return ( +
+ { title || meta || actions ? ( +
+
+ { title ? ( + // The adornment sits beside the heading rather than + // inside it: a rename pencil acts on the title, but + // it is not part of the document's outline, and a + // screen reader announcing "Rename site" as heading + // text would be reading furniture as content. +
+ { title } + { titleAdornment } +
+ ) : null } + { meta } +
+ { actions ? ( +
{ actions }
+ ) : null } +
+ ) : null } + { children ? ( +
{ children }
+ ) : null } +
+ ); +} + +export default Section; diff --git a/src/renderer/ui/StatusBadge.jsx b/src/renderer/ui/StatusBadge.jsx new file mode 100644 index 0000000..302b597 --- /dev/null +++ b/src/renderer/ui/StatusBadge.jsx @@ -0,0 +1,42 @@ +/* + * One badge shape for every status the window shows. + * + * It replaces two things that meant the same and looked nothing alike: the + * rounded pill in the site header (INITIALIZED / UNINITIALIZED) and the bare + * uppercase words in the setup checklist (COMPLETED / IN PROGRESS / LOCKED). + * Seeing both on one screen is a large part of why the app read as unconsidered. + * + * The colour never carries the meaning on its own — the word is always present, + * the same rule pr-state.cjs is built on. A contributor who cannot separate green + * from amber loses nothing here. + * + * The status-to-tone map is ../status-tone.cjs, unit-tested; this file is the + * presentational half and follows the repo's convention of leaving that untested. + */ + +import React from 'react'; +import { statusTone } from '../status-tone.cjs'; + +/** + * @param {Object} props + * @param {string} props.status A key from status-tone.cjs. + * @param {React.ReactNode} [props.children] Overrides the mapped label. + * @param {string} [props.className] Extra classes. + */ +export function StatusBadge( { status, children, className = '', ...rest } ) { + const { label, tone } = statusTone( status ); + const classes = [ 'wpct-badge', `wpct-badge--${ tone }`, className ] + .filter( Boolean ) + .join( ' ' ); + + // An unrecognised status resolves to an empty label, so a caller that passes + // its own text still renders — the badge degrades to neutral chrome around + // whatever it was given rather than disappearing. + return ( + + { children || label } + + ); +} + +export default StatusBadge; diff --git a/tests/unit/content-column.test.cjs b/tests/unit/content-column.test.cjs new file mode 100644 index 0000000..b72c696 --- /dev/null +++ b/tests/unit/content-column.test.cjs @@ -0,0 +1,111 @@ +'use strict'; + +// The content column's width cap, and the screenshot that proves it works. +// +// This is a string assertion guarding something a reviewer will not catch by +// eye, for the same reason as color-scheme.test.cjs: the failure is invisible +// on the machine most people look at it on. The default screenshot window is +// 1200px wide with a 281px sidebar (280 plus its right border), which leaves a +// content area of 855px — the width every cropped panel screenshot in +// docs/public/screenshots/ comes out at, and narrower than the 880px cap. So at +// the size every other image is taken, +// removing the cap entirely changes nothing. Every PNG in the docs would look +// correct while the app ran headings and meta lines to the edge of a maximised +// window. +// +// Two things are pinned here. The cap itself, and the wide screenshot that is +// the only image able to show it. Deleting the shot would leave the cap +// working but unphotographed, which is the state this test was written to end. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { shots } = require('../../scripts/screenshots/shots.cjs'); + +const root = path.join(__dirname, '..', '..'); +const TOKENS_CSS = fs.readFileSync(path.join(root, 'src', 'renderer', 'styles', 'tokens.css'), 'utf8'); +const INDEX_JSX = fs.readFileSync(path.join(root, 'src', 'renderer', 'index.jsx'), 'utf8'); + +const WIDE_SHOT = 'site-view-wide'; + +test('the content column is capped and centred', () => { + const rule = TOKENS_CSS.match(/\.wpct-content-column\s*\{([^}]*)\}/); + assert.ok(rule, 'tokens.css has no .wpct-content-column rule'); + assert.match(rule[1], /max-width:\s*var\(--wpct-content-max-width\)/, 'the column must read the cap token'); + assert.match(rule[1], /margin:\s*0 auto/, 'an uncentred cap leaves the content pinned to the left edge'); + assert.match(TOKENS_CSS, /--wpct-content-max-width:\s*\d+px/, 'the cap token has no value'); +}); + +// The rule existing is worth nothing if the scrolling container stopped using it. +test('the scrolling content container still applies the column', () => { + assert.match(INDEX_JSX, /className="wpct-content-column"/, 'no element uses .wpct-content-column'); +}); + +// The cap replaced a hand-picked inline `maxWidth: 1040`. An inline style beats +// a stylesheet, so one reintroduced here would override the token silently. +// +// This catches the two values that have actually been used rather than any +// number: a general `maxWidth:` ban would fire on the unrelated inline widths +// elsewhere in the file. It is a guard against the specific historical mistake, +// not a proof that no inline cap exists. +test('the old hand-picked cap is not reintroduced inline', () => { + assert.doesNotMatch(INDEX_JSX, /maxWidth:\s*(?:1040|880)\b/, 'the cap belongs in tokens.css, not in an inline style'); +}); + +/** + * The cap's value, as a number. + * + * Asserted rather than destructured so a deleted token fails with a sentence + * instead of "Cannot read properties of null". The test above covers the same + * ground, but these are independent cases and both run. + */ +function contentCap() { + const match = TOKENS_CSS.match(/--wpct-content-max-width:\s*(\d+)px/); + assert.ok(match, 'tokens.css no longer defines --wpct-content-max-width'); + return Number(match[1]); +} + +test('a screenshot is taken wide enough for the cap to have an effect', () => { + const wide = shots.find((shot) => shot.slug === WIDE_SHOT); + assert.ok(wide, `${WIDE_SHOT} is the only shot that can show the cap; it must not be removed`); + assert.ok( + wide.window?.width, + `${WIDE_SHOT} has no window override, so it is captured at the default width and shows nothing` + ); + + const cap = contentCap(); + // The sidebar's width and the content container's horizontal padding, read + // from the two inline styles in index.jsx. They are duplicated here, and + // there is nowhere better to put them until those styles move onto tokens in + // a later phase — so the assertion is deliberately `>` with hundreds of + // pixels of slack rather than an exact figure. It is asking "is this window + // wide enough to prove anything", not "is the content area exactly N". + const SIDEBAR = 280; + const PADDING_EACH_SIDE = 32; + const contentArea = wide.window.width - SIDEBAR - PADDING_EACH_SIDE * 2; + assert.ok( + contentArea > cap, + `the ${WIDE_SHOT} window is ${wide.window.width}px, leaving about ${contentArea}px of content area — ` + + `not wider than the ${cap}px cap, so the shot proves nothing` + ); +}); + +// `existsSync` alone would never fail again once the PNG was committed: an image +// captured before the cap regressed, or re-captured at the default width after +// someone edited the shot's `window`, would both pass. A PNG's pixel width lives +// in the IHDR chunk at bytes 16-20, so the committed file can be asked directly +// whether it is the wide one. +test('the committed wide screenshot really was captured wide', () => { + const png = path.join(root, 'docs', 'public', 'screenshots', `${WIDE_SHOT}.png`); + assert.ok(fs.existsSync(png), `${WIDE_SHOT}.png is missing — run "npm run shots"`); + + const wide = shots.find((shot) => shot.slug === WIDE_SHOT); + const captured = fs.readFileSync(png).readUInt32BE(16); + assert.equal( + captured, + wide.window.width, + `${WIDE_SHOT}.png is ${captured}px wide but the shot declares ${wide.window.width}px — ` + + 'the image is stale; run "npm run shots"' + ); +}); diff --git a/tests/unit/meta-text.test.cjs b/tests/unit/meta-text.test.cjs new file mode 100644 index 0000000..803d785 --- /dev/null +++ b/tests/unit/meta-text.test.cjs @@ -0,0 +1,100 @@ +'use strict'; + +// Two rules that a pre-PR review found, and that nothing else would catch twice. +// +// Both are stylesheet assertions for the same reason as color-scheme.test.cjs +// and content-column.test.cjs: the failure is invisible to whoever is looking. +// A reviewer sees a credentials line and reads the words, not the eight pixels +// between each of them; a chip's text passes or fails AA at a ratio nobody +// eyeballs. Reintroducing either would be a silent revert of a finding that has +// already been raised once. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const root = path.join(__dirname, '..', '..'); +const TOKENS_CSS = fs.readFileSync(path.join(root, 'src', 'renderer', 'styles', 'tokens.css'), 'utf8'); +const INDEX_JSX = fs.readFileSync(path.join(root, 'src', 'renderer', 'index.jsx'), 'utf8'); + +/** + * WCAG 2.x relative luminance, and the contrast ratio between two hex colours. + * + * Computed rather than hard-coded so the assertion follows the tokens: change + * --wpct-text or --wpct-surface-subtle and this still measures the real pair. + * + * @param {string} hex + * @return {number} + */ +function luminance(hex) { + const channels = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); + const linear = channels.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)); + return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]; +} + +function contrast(a, b) { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +} + +function token(name) { + const match = TOKENS_CSS.match(new RegExp(`${name}:\\s*(#[0-9a-f]{3,8})`, 'i')); + assert.ok(match, `tokens.css no longer defines ${name}`); + return match[1]; +} + +// The chip is a bordered grey box, so its text is not on the page background. +// --wpct-text-muted on --wpct-surface-subtle measures 4.44:1, which is under +// the threshold — that pairing was the original finding. +test('the path chip clears AA against its own background', () => { + const rule = TOKENS_CSS.match(/\.wpct-chip\s*\{([^}]*)\}/); + assert.ok(rule, 'tokens.css has no .wpct-chip rule'); + + const colour = rule[1].match(/color:\s*var\((--[\w-]+)\)/); + assert.ok(colour, '.wpct-chip must take its colour from a token'); + + const ratio = contrast(token(colour[1]), token('--wpct-surface-subtle')); + assert.ok( + ratio >= 4.5, + `the path chip is ${ratio.toFixed(2)}:1 against its background, under the 4.5:1 AA threshold at this size` + ); +}); + +// The rule this replaced was `.wpct-meta code`, which caught every code element +// in a meta line — including the `admin` and `password` in the dev-server +// credentials, which are words in a sentence rather than a value to copy. +test('the chip is a class, not every code element in a meta line', () => { + assert.doesNotMatch( + TOKENS_CSS, + /\.wpct-meta\s+code\s*\{/, + 'a descendant selector chips words that are prose; .wpct-chip is opt-in for a reason' + ); + assert.match(INDEX_JSX, //, 'nothing applies .wpct-chip'); +}); + +// .wpct-meta is a flex row, so each contiguous run of text in it becomes its own +// anonymous flex item — a sentence comes out with a gap between every fragment, +// including before the full stop, each free to wrap alone. --flow opts back into +// normal inline flow. +test('prose in a meta line lays out as prose', () => { + const rule = TOKENS_CSS.match(/\.wpct-meta--flow\s*\{([^}]*)\}/); + assert.ok(rule, 'tokens.css has no .wpct-meta--flow rule'); + assert.match(rule[1], /display:\s*block/, '--flow exists to undo the flex row'); + + // The rule has to win over .wpct-meta's `display: flex`. Equal specificity, + // so source order decides it, and that is easy to break by moving a block. + assert.ok( + TOKENS_CSS.indexOf('.wpct-meta--flow') > TOKENS_CSS.indexOf('.wpct-meta {'), + '.wpct-meta--flow must come after .wpct-meta or the flex row still wins' + ); +}); + +// The one call site, and the reason the prop exists at all. +test('the dev-server credentials line still asks for prose layout', () => { + assert.match( + INDEX_JSX, + /Log in with/, + 'the credentials sentence must keep `flow`, or it renders gapped between every word' + ); +}); diff --git a/tests/unit/status-tone.test.cjs b/tests/unit/status-tone.test.cjs new file mode 100644 index 0000000..eee03ae --- /dev/null +++ b/tests/unit/status-tone.test.cjs @@ -0,0 +1,95 @@ +'use strict'; + +// The one map behind every status badge in the window. Nothing renders the DOM +// in these tests, so what is testable is the map itself and the promise it makes +// to tokens.css: a status resolves to a tone name, and that tone is one the +// stylesheet actually draws. + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); +const { STATUS_TONES, statusTone } = require('../../src/renderer/status-tone.cjs'); +const { setupStepLabel } = require('../../src/renderer/setup-steps.cjs'); + +const TOKENS_CSS = path.join(__dirname, '..', '..', 'src', 'renderer', 'styles', 'tokens.css'); + +// The reason the map exists: the header pill and the checklist's uppercase words +// were two visual languages for the same idea. Both vocabularies resolve here. +test('site states and checklist step states both resolve to a tone', () => { + for (const status of ['initialized', 'uninitialized', 'complete', 'current', 'pending', 'locked']) { + assert.ok(STATUS_TONES[status], `${status} is shown in the window and must map to a tone`); + } +}); + +// The site states are the ones nothing else names, so this map has to. +test('the site states carry their own word', () => { + assert.strictEqual(statusTone('initialized').label, 'Initialized'); + assert.strictEqual(statusTone('uninitialized').label, 'Uninitialized'); +}); + +// setup-steps.cjs owns the step words, and makes a distinction this map cannot: +// the same `current` status reads "Ready" or "In progress" depending on whether +// the step's action is running (#257). Restating them here would be a second +// source of truth that disagrees with the first. +test('the checklist statuses carry no word, because setupStepLabel owns them', () => { + for (const status of ['complete', 'current', 'pending', 'locked']) { + assert.strictEqual( + STATUS_TONES[status].label, + '', + `${status} must not restate the word setupStepLabel already owns` + ); + } + assert.notStrictEqual(setupStepLabel('current', false), setupStepLabel('current', true)); +}); + +// A status the map has not been taught about is exactly the case where the app +// must not assert success or failure. Neutral, and the caller keeps its own text. +// +// The prototype keys are in this list because they were the hole: a plain +// `STATUS_TONES[key]` lookup reads through Object.prototype, so 'constructor' +// resolved to the Object constructor and '__proto__' to the prototype itself. +// Both carry no `tone`, which renders a badge classed `wpct-badge--undefined`. +// Only already-lowercase keys could reach it — the lookup lowercases first, so +// 'toString' was always safe and 'constructor' never was. +test('an unknown status is neutral and unlabelled, never green or red', () => { + const unknown = ['exploded', '', null, undefined, 42, {}, 'constructor', '__proto__', 'tostring', 'valueof']; + for (const input of unknown) { + const resolved = statusTone(input); + assert.strictEqual(resolved.tone, 'neutral', `${String(input)} must not claim an outcome`); + assert.strictEqual(resolved.label, '', `${String(input)} must not invent a label`); + } +}); + +// The badge's class is built as `wpct-badge--${tone}`, so a tone that is not a +// string does not degrade — it produces a class no stylesheet defines. +test('every resolved tone is a string, so no badge can render unstyled', () => { + for (const input of ['constructor', '__proto__', 'complete', 'nonsense', '']) { + assert.strictEqual(typeof statusTone(input).tone, 'string', `${String(input)} resolved to a non-string tone`); + } +}); + +test('status lookup ignores case, as the callers pass it through unchanged', () => { + assert.deepStrictEqual(statusTone('COMPLETE'), statusTone('complete')); +}); + +// Neither "not yet" state may be coloured as a problem. Amber here would tell a +// contributor that a step they have simply not reached has gone wrong. +test('pending and locked are neutral, because "not yet" is not a warning', () => { + assert.strictEqual(statusTone('pending').tone, 'neutral'); + assert.strictEqual(statusTone('locked').tone, 'neutral'); +}); + +// The map names tones; tokens.css draws them. A tone with no rule renders as an +// unstyled badge, which is the kind of break no unit test would otherwise catch. +test('every tone the map can return is drawn by tokens.css', () => { + const css = fs.readFileSync(TOKENS_CSS, 'utf8'); + const tones = new Set(Object.values(STATUS_TONES).map((entry) => entry.tone)); + tones.add('neutral'); + for (const tone of tones) { + assert.ok( + css.includes(`.wpct-badge--${tone}`), + `tokens.css has no .wpct-badge--${tone} rule, so that status would render unstyled` + ); + } +});