diff --git a/ui/CLAUDE.md b/ui/CLAUDE.md
index 8198cb39..920679a4 100644
--- a/ui/CLAUDE.md
+++ b/ui/CLAUDE.md
@@ -21,4 +21,58 @@ job's files load from where they were written.
`main.ts` before the first request); a page refetches on a switch by reading
`workspace.current` inside its load effect.
+## Design system
+
+The look is "contact sheet", and it rests on two rules that `src/app.css`
+encodes. Follow them when adding UI.
+
+**Colour means machine state.** The greys are deliberately achromatic
+(equal-RGB, not blue-tinted slate) because this app's output is pictures and
+any chroma in the surrounding surfaces biases how a generated colour reads.
+So the chrome carries no accent: interactive elements are ink (`--accent` is
+the *interactive ink*, a near-black in light and a near-white in dark; the
+primary button is a solid ink fill, links are ink with an underline).
+`--live`, a darkroom safelight amber, is the one signal colour and marks only
+what is running, where keyboard focus is, and when VRAM is under pressure -
+never decoration. `--good` / `--bad` stay state colours too. Every token pair
+passes WCAG AA in both themes; keep it that way.
+
+**Mono is what the engine reads.** `--font-mono` for anything the engine
+resolves literally - a workflow or prompt name, a `variable:` / `asset:` /
+`prompt:` reference, a path, a seed, a run id, a measurement (add `.num` for
+tabular figures). `--font-sans` for anything written for a person -
+descriptions, hints, control labels. Headings are mono because they name
+things the engine resolves, and they are sentence case: no ALL-CAPS eyebrow
+labels and no tracked-out letter-spacing on small labels.
+
+Type scale is `--t-xs` .. `--t-xl` off a 15px base; radius says what a thing
+is (`--radius-frame` a picture, `--radius-2` a surface, 999px a chip) rather
+than being one global value. One filled button per view: the thing you came
+to do. `.quiet` is outlined, `.bare` is text-only.
+
+**Show the proof.** A list of things that produce images shows the images.
+The workflows catalog and the workflow page both read `api.gallery()` and
+match a workflow's name to an output entry's `folder` - a run writes to
+`///` and the gallery reports that identity with
+the run id already stripped, so the match is direct equality. Proofs load
+separately from the listing so the catalog never waits on them, and a
+workflow that has never run gets no frame at all rather than a grey
+placeholder (a fresh workspace would otherwise be a wall of empty plates).
+Within a folder, workflows that have produced something sort first.
+
+Every picture in the app sits in the global `.frame` (app.css): the media
+fills it edge to edge, with no inner padding and no rounding of its own,
+which is what makes it read as a proof on a sheet rather than as another
+rounded card. Gallery tiles, a workflow's recent outputs and a job's results
+all use it; the catalog card's `.cardframe` is the one variant, because it
+is a banner inside a card rather than a free-standing frame. Set the size on
+the frame - the media inside always fills it.
+
+Where `--live` appears, and nowhere else: the header's running link and VRAM
+pressure, a running job's row and status chip, the job page's progress bar
+and current step, the flow view's active step, a model download in flight,
+and the focus ring. Resting meters (free disk, VRAM under pressure's
+threshold) stay `--muted`; selection is the user's state, not the machine's,
+so it reads as a heavier ink edge instead.
+
See docs/SERVER.md.
diff --git a/ui/e2e/chrome.spec.ts b/ui/e2e/chrome.spec.ts
index e568251b..bcc43b03 100644
--- a/ui/e2e/chrome.spec.ts
+++ b/ui/e2e/chrome.spec.ts
@@ -1,20 +1,21 @@
import { expect, test } from '@playwright/test'
-test('the status strip carries worker state and docs, off the nav row', async ({
+test('worker state and docs sit on the nav row, in one header row', async ({
page,
}) => {
await page.goto('/')
- const strip = page.locator('header .statusbar')
- await expect(strip).toBeVisible()
- // worker state renders in the strip (fixture worker is idle at start)
- await expect(strip).toContainText(/idle|GB/)
- // docs links moved down out of the nav row
+ // One header row: the state the old second row carried now sits at the
+ // right of the nav, so no page spends a strip on the word "idle"
+ const row = page.locator('header .navrow')
+ await expect(row).toHaveCount(1)
+ const state = page.locator('header .state')
+ await expect(state).toBeVisible()
+ // worker state renders there (fixture worker is idle at start)
+ await expect(state).toContainText(/idle|GB/)
await expect(
- strip.getByRole('link', { name: 'documentation on GitHub' }),
- ).toBeVisible()
- await expect(
- page.locator('header .navrow').getByRole('link', { name: 'Workflows' }),
+ state.getByRole('link', { name: 'documentation on GitHub' }),
).toBeVisible()
+ await expect(row.getByRole('link', { name: 'Workflows' })).toBeVisible()
})
test('? opens the shortcuts overlay; Escape closes it; typing ? in a field does not', async ({
@@ -85,8 +86,11 @@ test('the status popover traps focus and returns it to its trigger button', asyn
test('tab order walks the nav row in reading order', async ({ page }) => {
await page.goto('/')
- // From the top of the document, Tab lands on the nav links in their
- // visual order - the baseline "rational tab order" check on the chrome
+ // From the top of the document, Tab lands on the header's links in their
+ // visual order - the baseline "rational tab order" check on the chrome.
+ // The wordmark is the first stop: it is a link home.
+ await page.keyboard.press('Tab')
+ await expect(page.locator('header .brand')).toBeFocused()
await page.keyboard.press('Tab')
await expect(
page.getByRole('link', { name: 'Workflows' }).first(),
diff --git a/ui/e2e/smoke.spec.ts b/ui/e2e/smoke.spec.ts
index 552d1f3e..7e331f79 100644
--- a/ui/e2e/smoke.spec.ts
+++ b/ui/e2e/smoke.spec.ts
@@ -14,18 +14,23 @@ test('workflow browser lists, describes and filters', async ({ page }) => {
await expect(page.getByRole('link', { name: /^z-image / })).toHaveCount(0)
})
-test('workflow page shows JSON and a run form', async ({ page }) => {
+test('workflow page leads with the run form and reveals JSON on request', async ({
+ page,
+}) => {
await page.goto('/#/workflows/models/z-image')
await expect(
page.getByRole('heading', { name: 'models/z-image' }),
).toBeVisible()
- // JSON is shown by default via Monaco
- await expect(page.locator('.monaco-editor').first()).toBeVisible({
- timeout: 20_000,
- })
// the variables form renders with defaults as placeholders
await expect(page.getByLabel('num_inference_steps')).toBeVisible()
await expect(page.getByRole('button', { name: /Run/ })).toBeEnabled()
+ // the definition is collapsed by default - this page is for setting
+ // variables and running, not for reading the file
+ await expect(page.locator('.monaco-editor')).toHaveCount(0)
+ await page.getByRole('button', { name: 'show JSON' }).click()
+ await expect(page.locator('.monaco-editor').first()).toBeVisible({
+ timeout: 20_000,
+ })
})
test('editor opens a workflow with introspected arguments', async ({
@@ -307,6 +312,8 @@ test('editor flags a dangling reference without asking the server', async ({
test('the theme toggle re-themes an open Monaco editor', async ({ page }) => {
// Playwright's default colour scheme is light, so "system" starts light
await page.goto('/#/workflows/models/z-image')
+ // the definition is collapsed by default, so ask for it first
+ await page.getByRole('button', { name: 'show JSON' }).click()
const editor = page.locator('.monaco-editor').first()
await expect(editor).toBeVisible({ timeout: 20_000 })
await expect(editor).toHaveClass(/\bvs\b/)
diff --git a/ui/src/App.svelte b/ui/src/App.svelte
index 712152a4..05d64142 100644
--- a/ui/src/App.svelte
+++ b/ui/src/App.svelte
@@ -120,11 +120,19 @@
+
@@ -301,117 +342,126 @@
display: flex;
align-items: center;
flex-wrap: wrap;
- gap: 0.5rem 1.5rem;
- padding: 0.7rem 1.2rem 0.5rem;
- }
- .statusbar {
- position: relative;
- display: flex;
- align-items: center;
- gap: 0.6rem 1rem;
- padding: 0.25rem 1.2rem;
- border-top: 1px solid var(--line);
- font-size: 0.8rem;
- min-height: 1.6rem;
- }
- .statusbtn {
- background: none;
- border: 0;
- padding: 0.1rem 0.4rem;
- margin-left: -0.4rem;
- font-size: 0.8rem;
- font-weight: 600;
- cursor: pointer;
- display: inline-flex;
- align-items: center;
- gap: 0.45rem;
- border-radius: var(--radius-1);
- }
- .statusbtn:hover {
- background: var(--panel-2);
- color: var(--ink);
- filter: none;
- }
- .statusbar .flex {
- flex: 1;
+ gap: 0.5rem 1.4rem;
+ padding: 0.55rem 1.2rem;
}
.brand {
+ font-family: var(--font-mono);
font-weight: 700;
- font-size: 1rem;
+ font-size: 0.95rem;
+ letter-spacing: -0.02em;
+ color: var(--ink);
}
- .accent {
- color: var(--accent);
+ .brand .dim {
+ color: var(--muted);
+ font-weight: 500;
}
nav {
display: flex;
flex-wrap: wrap;
- gap: 0.4rem 1.1rem;
+ align-items: center;
+ gap: 0.4rem 1.05rem;
flex: 1;
}
+ /* Separates the daily work from the reference pages without hiding the
+ reference pages behind a menu */
+ .navrule {
+ width: 1px;
+ align-self: stretch;
+ margin: 0.1rem 0;
+ background: var(--line);
+ }
nav a {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: var(--muted);
+ font-size: var(--t-sm);
font-weight: 600;
- padding: 0.2rem 0;
+ padding: 0.15rem 0;
border-bottom: 2px solid transparent;
+ white-space: nowrap;
+ }
+ nav a.second {
+ font-weight: 500;
}
nav a:hover {
color: var(--ink);
}
nav a.active {
- color: var(--accent);
- border-bottom-color: var(--accent);
+ color: var(--ink);
+ border-bottom-color: var(--ink);
}
- .helplink {
- display: inline-flex;
+ .state {
+ position: relative;
+ display: flex;
align-items: center;
- color: var(--muted);
- padding: 0.2rem;
+ gap: 0.3rem;
+ font-size: var(--t-sm);
}
- .helplink:hover {
- color: var(--ink);
+ .state :global(button) {
+ padding: 0.25rem 0.45rem;
+ }
+ .state :global(button.icon) {
+ display: inline-flex;
}
- .runningnow {
+ /* The signal colour, earning its keep: the GPU is busy */
+ .live {
display: inline-flex;
align-items: center;
- gap: 0.45rem;
- color: var(--accent);
+ gap: 0.4rem;
+ color: var(--live);
font-weight: 600;
+ padding: 0.25rem 0.5rem;
+ border-radius: var(--radius-1);
+ background: color-mix(in srgb, var(--live) 12%, transparent);
}
.vram {
- max-width: 22ch;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .themebtn {
display: inline-flex;
- padding: 0.3rem 0.45rem;
+ align-items: center;
+ gap: 0.45rem;
+ }
+ .vramtext {
+ font-size: var(--t-xs);
+ white-space: nowrap;
}
.meter {
- width: 90px;
- height: 6px;
+ display: block;
+ width: 54px;
+ height: 5px;
border-radius: 3px;
background: var(--panel-2);
overflow: hidden;
+ flex: none;
}
.meter .fill {
+ display: block;
height: 100%;
- background: var(--accent);
+ background: var(--muted);
transition: width 0.4s ease;
}
+ /* Pressure is machine state, so it takes the signal colour */
.meter .fill.hot {
- background: var(--warn);
+ background: var(--live);
}
.meter .fill.critical {
background: var(--bad);
}
+ .helplink {
+ display: inline-flex;
+ align-items: center;
+ color: var(--muted);
+ padding: 0.25rem;
+ border-radius: var(--radius-1);
+ }
+ .helplink:hover {
+ color: var(--ink);
+ background: var(--panel-2);
+ }
main {
- max-width: 1100px;
+ max-width: 1180px;
margin: 0 auto;
- padding: 1.2rem;
+ padding: 1.6rem 1.2rem 4rem;
}
main.wide {
max-width: 1560px;
@@ -420,7 +470,7 @@
/* Below the nav's natural width the header wraps to a second row rather
than overflowing - a sticky header does not pin horizontally, so any
document-level scroll would slide it off screen */
- @media (max-width: 900px) {
+ @media (max-width: 1080px) {
.navrow {
gap: 0.5rem 1rem;
}
@@ -428,27 +478,30 @@
order: 3;
flex-basis: 100%;
}
+ .state {
+ margin-left: auto;
+ }
+ .vramtext {
+ display: none;
+ }
}
/* Icons alone below the small breakpoint: eight labelled links do not fit
a phone, and each keeps its title for the tooltip */
@media (max-width: 640px) {
.navrow {
- padding: 0.6rem 0.8rem 0.5rem;
- }
- .statusbar {
- padding: 0.25rem 0.8rem;
+ padding: 0.5rem 0.8rem;
}
main {
- padding: 0.8rem;
+ padding: 1rem 0.8rem 3rem;
}
.navlabel {
display: none;
}
nav {
- gap: 0.4rem 1.3rem;
+ gap: 0.4rem 1.25rem;
}
- .vram {
- max-width: 14ch;
+ .meter {
+ display: none;
}
}
diff --git a/ui/src/app.css b/ui/src/app.css
index 4de95a4e..b2e375ac 100644
--- a/ui/src/app.css
+++ b/ui/src/app.css
@@ -1,70 +1,117 @@
+/* Contact sheet.
+ *
+ * This app's whole output is pictures, so the chrome is achromatic on
+ * purpose: any chroma in the surfaces around an image biases how you read
+ * the colour the model actually produced. Greys here are equal-RGB rather
+ * than the usual blue-tinted slate, and the generated images are meant to
+ * be the only saturated thing on screen.
+ *
+ * Colour therefore means one thing: machine state. `--live` (a darkroom
+ * safelight amber) marks what is running, where focus is, and when VRAM is
+ * under pressure. It is never decoration. Interactive elements are ink -
+ * the primary button is a solid ink fill, links are ink with an underline -
+ * so nothing in the chrome competes with the work.
+ *
+ * Type carries the other rule: anything the ENGINE reads literally (a
+ * workflow name, a `variable:` reference, a path, a seed, a run id, a
+ * measurement) is set in --font-mono; anything written for a PERSON is set
+ * in --font-sans. That is why headings are mono - they name things the
+ * engine resolves - and descriptions are not.
+ *
+ * `--accent` is kept as the name for "interactive ink" because 50-odd rules
+ * across the app already reference it; `--live` is the state colour those
+ * rules must NOT use.
+ */
:root {
- --bg: #14181b;
- --panel: #1c2226;
- --panel-2: #232b30;
- --line: #2e383e;
- --ink: #e4eaed;
- --muted: #8fa0a8;
- --accent: #4cb8cc;
- --accent-ink: #0b2b31;
- --good: #63b784;
- --bad: #e07a6a;
- --warn: #d9a84e;
- font-family:
- system-ui,
- -apple-system,
- 'Segoe UI',
- sans-serif;
- color-scheme: dark;
+ /* the sheet: paper, neutral, so colour is judged honestly */
+ --bg: #f7f7f7;
+ --panel: #ffffff;
+ --panel-2: #ededed;
+ --line: #dcdcdc;
+ --ink: #1c1c1c;
+ --muted: #6b6b6b;
+ --accent: #1c1c1c;
+ --accent-ink: #ffffff;
+ --live: #a85b00;
+ --good: #2f6b3f;
+ --bad: #a33224;
+ --warn: #a85b00;
+ color-scheme: light;
}
-:root[data-theme='dark'] {
- --bg: #14181b;
- --panel: #1c2226;
- --panel-2: #232b30;
- --line: #2e383e;
- --ink: #e4eaed;
- --muted: #8fa0a8;
- --accent: #4cb8cc;
- --accent-ink: #0b2b31;
- --good: #63b784;
- --bad: #e07a6a;
- --warn: #d9a84e;
- color-scheme: dark;
+/* the darkroom */
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme='light']) {
+ --bg: #131313;
+ --panel: #1b1b1b;
+ --panel-2: #242424;
+ --line: #333333;
+ --ink: #e8e8e8;
+ --muted: #9a9a9a;
+ --accent: #e8e8e8;
+ --accent-ink: #131313;
+ --live: #e8a33d;
+ --good: #6fbf87;
+ --bad: #e3796a;
+ --warn: #e8a33d;
+ color-scheme: dark;
+ }
}
-@media (prefers-color-scheme: light) {
- :root:not([data-theme='dark']) {
- --bg: #f2f5f6;
- --panel: #ffffff;
- --panel-2: #eaeff1;
- --line: #d5dde0;
- --ink: #1c2428;
- --muted: #5b6a72;
- --accent: #0b7285;
- --accent-ink: #ffffff;
- --good: #2b7a4b;
- --bad: #b34031;
- --warn: #9a6a12;
- color-scheme: light;
- }
+:root[data-theme='dark'] {
+ --bg: #131313;
+ --panel: #1b1b1b;
+ --panel-2: #242424;
+ --line: #333333;
+ --ink: #e8e8e8;
+ --muted: #9a9a9a;
+ --accent: #e8e8e8;
+ --accent-ink: #131313;
+ --live: #e8a33d;
+ --good: #6fbf87;
+ --bad: #e3796a;
+ --warn: #e8a33d;
+ color-scheme: dark;
}
:root[data-theme='light'] {
- --bg: #f2f5f6;
+ --bg: #f7f7f7;
--panel: #ffffff;
- --panel-2: #eaeff1;
- --line: #d5dde0;
- --ink: #1c2428;
- --muted: #5b6a72;
- --accent: #0b7285;
+ --panel-2: #ededed;
+ --line: #dcdcdc;
+ --ink: #1c1c1c;
+ --muted: #6b6b6b;
+ --accent: #1c1c1c;
--accent-ink: #ffffff;
- --good: #2b7a4b;
- --bad: #b34031;
- --warn: #9a6a12;
+ --live: #a85b00;
+ --good: #2f6b3f;
+ --bad: #a33224;
+ --warn: #a85b00;
color-scheme: light;
}
+/* Two families, both from system stacks - the server runs on localhost and
+ may be offline, so a webfont CDN is not available to us. */
+:root {
+ --font-sans:
+ -apple-system, BlinkMacSystemFont, 'Segoe UI Variable Text', 'Segoe UI',
+ Inter, system-ui, sans-serif;
+ --font-mono:
+ ui-monospace, 'SF Mono', 'Cascadia Code', 'JetBrains Mono', 'Roboto Mono',
+ monospace;
+
+ /* ~1.25 from a 15px base. Prose sits at --t-base; the old 14px body was
+ below comfortable reading size for the descriptions this app shows. */
+ --t-xs: 0.733rem;
+ --t-sm: 0.867rem;
+ --t-base: 1rem;
+ --t-md: 1.2rem;
+ --t-lg: 1.6rem;
+ --t-xl: 2.2rem;
+
+ font-family: var(--font-sans);
+}
+
/* Layout tokens. Spacing steps and radii for all new CSS; existing rules
migrate as their files are touched. Container-query breakpoints cannot
read custom properties, so the scale is a convention instead:
@@ -76,6 +123,11 @@
--space-3: 0.7rem;
--space-4: 1rem;
--space-5: 1.5rem;
+
+ /* Radius says what kind of thing something is rather than being one
+ global value: a picture is nearly square-cornered, a surface is
+ rounded, a chip is a pill. */
+ --radius-frame: 3px;
--radius-1: 6px;
--radius-2: 8px;
}
@@ -96,8 +148,11 @@ textarea,
filter 0.15s ease,
opacity 0.15s ease;
}
+/* Focus is machine state - where the keyboard is - so it gets the signal
+ colour rather than the interactive ink, which would be invisible against
+ an ink button. */
:focus-visible {
- outline: 2px solid var(--accent);
+ outline: 2px solid var(--live);
outline-offset: 1px;
}
@@ -112,10 +167,10 @@ textarea,
}
.pulse-dot {
display: inline-block;
- width: 8px;
- height: 8px;
+ width: 7px;
+ height: 7px;
border-radius: 50%;
- background: var(--accent);
+ background: var(--live);
animation: dw-pulse 1.6s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
@@ -127,29 +182,47 @@ body {
margin: 0;
background: var(--bg);
color: var(--ink);
- font-size: 14px;
- line-height: 1.5;
+ font-size: 15px;
+ line-height: 1.55;
+ -webkit-font-smoothing: antialiased;
}
a {
color: var(--accent);
text-decoration: none;
}
+/* Prose links keep an underline: with interactive elements set in ink
+ rather than a colour, the underline is what makes them links. Links that
+ are already unmistakably controls (nav items, cards, icon buttons) opt
+ out with .plain. */
+a:not(.plain):not(.chip):not(.panel):hover {
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+
+/* One filled button per view: the thing you came to do. Everything else is
+ .quiet (outlined) or .bare (text). */
button {
background: var(--accent);
color: var(--accent-ink);
- border: 0;
- border-radius: 6px;
- padding: 0.45rem 0.9rem;
- font-size: 0.9rem;
+ border: 1px solid var(--accent);
+ border-radius: var(--radius-1);
+ padding: 0.4rem 0.9rem;
+ font-family: inherit;
+ font-size: var(--t-sm);
font-weight: 600;
cursor: pointer;
}
button:hover {
- filter: brightness(1.1);
+ filter: brightness(1.35);
+}
+:root[data-theme='dark'] button:hover,
+:root:not([data-theme='light']) button:hover {
+ filter: brightness(0.85);
}
button:disabled {
- opacity: 0.45;
+ opacity: 0.4;
cursor: default;
+ filter: none;
}
button.quiet {
background: transparent;
@@ -157,43 +230,88 @@ button.quiet {
border: 1px solid var(--line);
font-weight: 500;
}
+button.quiet:hover {
+ color: var(--ink);
+ border-color: var(--muted);
+ filter: none;
+}
+button.bare {
+ background: transparent;
+ color: var(--muted);
+ border-color: transparent;
+ font-weight: 500;
+}
+button.bare:hover {
+ color: var(--ink);
+ background: var(--panel-2);
+ filter: none;
+}
input,
textarea,
select {
background: var(--panel-2);
color: var(--ink);
border: 1px solid var(--line);
- border-radius: 6px;
+ border-radius: var(--radius-1);
padding: 0.4rem 0.6rem;
- font-size: 0.9rem;
+ font-size: var(--t-sm);
width: 100%;
font-family: inherit;
}
input:focus,
textarea:focus {
- outline: 2px solid var(--accent);
+ outline: 2px solid var(--live);
outline-offset: -1px;
}
+/* A checkbox or radio left alone paints in the browser's own blue, which is
+ exactly the chroma this design keeps off the screen - and it would be the
+ only blue in the app. Checked controls take the interactive ink instead;
+ the browser picks a contrasting mark. */
+input[type='checkbox'],
+input[type='radio'] {
+ accent-color: var(--accent);
+}
code,
pre {
- font-family: ui-monospace, 'Cascadia Code', monospace;
- font-size: 0.85em;
+ font-family: var(--font-mono);
+ font-size: 0.87em;
}
pre {
background: var(--panel-2);
border: 1px solid var(--line);
- border-radius: 6px;
+ border-radius: var(--radius-1);
padding: 0.8rem;
overflow-x: auto;
margin: 0;
}
-h1 {
- font-size: 1.25rem;
+
+/* Headings name things the engine resolves, so they are mono - sentence
+ case and tight, never tracked out or capitalised into a label. */
+h1,
+h2,
+h3 {
+ font-family: var(--font-mono);
+ font-weight: 600;
+ line-height: 1.15;
+ letter-spacing: -0.01em;
margin: 0;
}
+h1 {
+ font-size: var(--t-lg);
+}
h2 {
- font-size: 1rem;
- margin: 0 0 0.6rem;
+ font-size: var(--t-md);
+ margin: 0 0 var(--space-3);
+}
+h3 {
+ font-size: var(--t-base);
+}
+
+/* Machine values line up when they are figures in a column - measurements,
+ sizes, counts, durations. */
+.num {
+ font-family: var(--font-mono);
+ font-variant-numeric: tabular-nums;
}
/* Every panel is a query container, so the label/field grids nested inside
@@ -205,28 +323,28 @@ h2 {
.panel {
background: var(--panel);
border: 1px solid var(--line);
- border-radius: 8px;
- padding: 1rem;
+ border-radius: var(--radius-2);
+ padding: var(--space-4);
container-type: inline-size;
}
.chip {
display: inline-block;
padding: 0.1rem 0.55rem;
border-radius: 999px;
- font-size: 0.75rem;
+ font-size: var(--t-xs);
font-weight: 600;
- letter-spacing: 0.02em;
+ letter-spacing: 0.01em;
}
input.ref,
textarea.ref {
- color: var(--accent);
- font-family: ui-monospace, 'Cascadia Code', monospace;
- font-size: 0.85rem;
+ font-family: var(--font-mono);
+ font-size: var(--t-sm);
}
button.danger:hover,
a.danger:hover {
border-color: var(--bad);
color: var(--bad);
+ background: transparent;
filter: none;
}
.panel.error-edge {
@@ -243,15 +361,15 @@ a.danger:hover {
color: var(--muted);
}
.chip.running {
- background: var(--accent);
- color: var(--accent-ink);
+ background: var(--live);
+ color: var(--panel);
}
.chip.succeeded {
- background: color-mix(in srgb, var(--good) 22%, transparent);
+ background: color-mix(in srgb, var(--good) 20%, transparent);
color: var(--good);
}
.chip.failed {
- background: color-mix(in srgb, var(--bad) 22%, transparent);
+ background: color-mix(in srgb, var(--bad) 20%, transparent);
color: var(--bad);
}
.chip.cancelled {
@@ -261,3 +379,38 @@ a.danger:hover {
.muted {
color: var(--muted);
}
+
+/* Prose stays inside a comfortable measure wherever it runs long. */
+.prose {
+ max-width: 68ch;
+}
+
+/* A contact-sheet frame. Every place the app shows something a workflow
+ produced - a catalog card's proof, a workflow's recent outputs, a job's
+ results, a gallery tile - uses this, so a picture reads the same wherever
+ it turns up. The picture fills the frame edge to edge: no inner padding
+ and no rounding on the image itself, which is what makes it read as a
+ proof on a sheet rather than as another rounded card. Set the size on
+ the frame; the media inside always fills it. */
+.frame {
+ display: block;
+ position: relative;
+ background: var(--panel-2);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-frame);
+ overflow: hidden;
+}
+.frame > img,
+.frame > video {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+/* The frame is only ever the picture, so any interactive frame gets its
+ affordance from the border rather than from a colour wash over it */
+a.frame:hover,
+button.frame:hover {
+ border-color: var(--ink);
+ filter: none;
+}
diff --git a/ui/src/lib/FolderGroups.svelte b/ui/src/lib/FolderGroups.svelte
index 9560656d..3b0fe29f 100644
--- a/ui/src/lib/FolderGroups.svelte
+++ b/ui/src/lib/FolderGroups.svelte
@@ -101,9 +101,11 @@
opacity: 1;
}
.groupnew:hover {
- color: var(--accent);
+ color: var(--ink);
border-color: var(--line);
}
+ /* A folder name is a path segment the engine resolves, so it is mono
+ like every other name in the app */
.group {
display: flex;
align-items: center;
@@ -111,8 +113,10 @@
background: none;
border: none;
color: var(--muted);
+ font-family: var(--font-mono);
font-weight: 600;
- font-size: 0.95rem;
+ font-size: var(--t-sm);
+ letter-spacing: -0.01em;
padding: 0;
margin: 0;
cursor: pointer;
@@ -121,8 +125,12 @@
color: var(--ink);
filter: none;
}
+ /* Cards are deliberately not equal height - one that has produced
+ something carries a picture and one that has not does not - so rows
+ align to the top rather than stretching the short ones */
.grid {
display: grid;
+ align-items: start;
gap: 0.6rem;
}
diff --git a/ui/src/lib/KeyboardHelp.svelte b/ui/src/lib/KeyboardHelp.svelte
index 62a3a36f..18ff6a07 100644
--- a/ui/src/lib/KeyboardHelp.svelte
+++ b/ui/src/lib/KeyboardHelp.svelte
@@ -61,7 +61,7 @@
color: var(--muted);
}
kbd {
- font-family: ui-monospace, 'Cascadia Code', monospace;
+ font-family: var(--font-mono);
font-size: 0.8rem;
border: 1px solid var(--line);
border-bottom-width: 2px;
diff --git a/ui/src/lib/StatusPopover.svelte b/ui/src/lib/StatusPopover.svelte
index 6131a59f..73f42b0b 100644
--- a/ui/src/lib/StatusPopover.svelte
+++ b/ui/src/lib/StatusPopover.svelte
@@ -107,8 +107,7 @@
font-weight: 600;
color: var(--muted);
font-size: 0.75rem;
- text-transform: uppercase;
- letter-spacing: 0.05em;
+ text-transform: none;
}
dd {
margin: 0;
diff --git a/ui/src/lib/editor/FlowView.svelte b/ui/src/lib/editor/FlowView.svelte
index 360331cb..7299f932 100644
--- a/ui/src/lib/editor/FlowView.svelte
+++ b/ui/src/lib/editor/FlowView.svelte
@@ -133,7 +133,7 @@
feed. A step with more than one incoming arrow multiplies its inputs
together (CLAUDE.md's cartesian-product gotcha) - its border is
highlighted and the multiplier is noted.{#if showsRun}
- A finished step is outlined in green, the one running now in the accent
+ A finished step is outlined in green, the one running now in amber
colour.{/if}{#if onselect}
Click a step to jump to it in the form view.{/if}
@@ -217,7 +217,7 @@
gap: var(--space-2);
}
.hint code {
- font-family: ui-monospace, 'Cascadia Code', monospace;
+ font-family: var(--font-mono);
font-size: 0.78rem;
}
.scrollarea {
@@ -242,7 +242,7 @@
font-size: 10px;
fill: var(--muted);
text-anchor: middle;
- font-family: ui-monospace, 'Cascadia Code', monospace;
+ font-family: var(--font-mono);
}
.node.clickable {
cursor: pointer;
@@ -269,8 +269,9 @@
stroke: var(--good);
stroke-width: 2;
}
+ /* The step running right now - machine state, so the signal colour */
.node.active .box {
- stroke: var(--accent);
+ stroke: var(--live);
stroke-width: 2.5;
animation: dw-pulse 1.6s ease-in-out infinite;
}
@@ -287,19 +288,17 @@
.stepkind {
font-size: 10px;
fill: var(--accent);
- text-transform: uppercase;
- letter-spacing: 0.04em;
+ text-transform: none;
}
.stepdetail {
font-size: 10px;
fill: var(--muted);
- font-family: ui-monospace, 'Cascadia Code', monospace;
+ font-family: var(--font-mono);
}
.entrytag {
font-size: 9px;
fill: var(--good);
- text-transform: uppercase;
- letter-spacing: 0.04em;
+ text-transform: none;
}
.fanlabel {
font-size: 10px;
diff --git a/ui/src/lib/editor/QuantizationEditor.svelte b/ui/src/lib/editor/QuantizationEditor.svelte
index cf937bb0..45886ca6 100644
--- a/ui/src/lib/editor/QuantizationEditor.svelte
+++ b/ui/src/lib/editor/QuantizationEditor.svelte
@@ -69,7 +69,7 @@
gap: 0.4rem;
}
.ctype {
- font-family: ui-monospace, monospace;
+ font-family: var(--font-mono);
font-size: 0.8rem;
}
diff --git a/ui/src/lib/editor/StepEditor.svelte b/ui/src/lib/editor/StepEditor.svelte
index b2b48baf..1d3ebf2c 100644
--- a/ui/src/lib/editor/StepEditor.svelte
+++ b/ui/src/lib/editor/StepEditor.svelte
@@ -624,8 +624,7 @@
}
h3 {
font-size: 0.8rem;
- text-transform: uppercase;
- letter-spacing: 0.05em;
+ text-transform: none;
color: var(--muted);
margin: 1rem 0 0.5rem;
}
@@ -637,8 +636,7 @@
summary {
cursor: pointer;
font-size: 0.8rem;
- text-transform: uppercase;
- letter-spacing: 0.05em;
+ text-transform: none;
color: var(--muted);
font-weight: 600;
user-select: none;
@@ -698,7 +696,7 @@
margin-top: 0.8rem;
}
.raw textarea {
- font-family: ui-monospace, monospace;
+ font-family: var(--font-mono);
font-size: 0.82rem;
}
.error {
@@ -758,8 +756,7 @@
}
.digestsection {
font-size: 0.7rem;
- text-transform: uppercase;
- letter-spacing: 0.05em;
+ text-transform: none;
flex: none;
width: 90px;
}
@@ -773,7 +770,7 @@
z-index: 1;
}
.flowchip {
- font-family: ui-monospace, 'Cascadia Code', monospace;
+ font-family: var(--font-mono);
font-size: 0.72rem;
padding: 0.05rem 0.5rem;
border-radius: 999px;
diff --git a/ui/src/lib/pages/EditorPage.svelte b/ui/src/lib/pages/EditorPage.svelte
index 22937f96..5ab14a66 100644
--- a/ui/src/lib/pages/EditorPage.svelte
+++ b/ui/src/lib/pages/EditorPage.svelte
@@ -787,8 +787,7 @@
.wfidlabel {
color: var(--muted);
font-size: 0.75rem;
- text-transform: uppercase;
- letter-spacing: 0.06em;
+ text-transform: none;
}
/* Reads as a title until you reach for it, rather than as an unexplained
text box sitting next to a breadcrumb */
@@ -825,7 +824,7 @@
min-width: 0;
}
.pathchip {
- font-family: ui-monospace, 'Cascadia Code', monospace;
+ font-family: var(--font-mono);
font-size: 0.8rem;
padding: 0.25rem 0.6rem;
max-width: 100%;
@@ -882,7 +881,7 @@
margin-top: 0.8rem;
}
.filefoot .path {
- font-family: ui-monospace, 'Cascadia Code', monospace;
+ font-family: var(--font-mono);
font-size: 0.8rem;
min-width: 0;
}
diff --git a/ui/src/lib/pages/GalleryPage.svelte b/ui/src/lib/pages/GalleryPage.svelte
index 833eda39..2ac8375e 100644
--- a/ui/src/lib/pages/GalleryPage.svelte
+++ b/ui/src/lib/pages/GalleryPage.svelte
@@ -261,7 +261,7 @@
Gallery
- {files.length} files
+ {files.length} files
@@ -345,8 +345,8 @@
{#if selected}
-
{selected.name}
-
{mb(selected.size)} · {day(selected.mtime)}
+
{selected.name}
+
{mb(selected.size)} · {day(selected.mtime)}
{#if embeddedWorkflow}
{#each group.files as file (file)}
{#if isImage(file)}
-
{:else if isVideo(file)}
-
-
+
+
+
+
{:else}
- {file.split('/').pop()} {file.split('/').pop()}
{/if}
{/each}
@@ -348,9 +355,11 @@
background: var(--good);
border-color: var(--good);
}
+ /* The step the worker is on right now - machine state, so it takes the
+ signal colour rather than the interactive ink */
.dot.active {
- background: var(--accent);
- border-color: var(--accent);
+ background: var(--live);
+ border-color: var(--live);
animation: dw-pulse 1.6s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
@@ -368,7 +377,7 @@
}
.fill {
height: 100%;
- background: var(--accent);
+ background: var(--live);
transition: width 0.3s;
}
.count {
@@ -378,21 +387,27 @@
.media {
display: flex;
flex-wrap: wrap;
- gap: 0.7rem;
+ gap: var(--space-2);
+ align-items: flex-start;
}
- .media img,
- .media video {
+ /* What the run produced, framed the way the catalog and the gallery
+ frame it - the picture flush to its edges, no rounding of its own */
+ .media :global(.frame) {
max-width: min(340px, 100%);
- border-radius: 6px;
- display: block;
+ }
+ .media :global(.frame > video) {
+ height: auto;
+ }
+ .media a.filelink {
+ font-family: var(--font-mono);
+ font-size: var(--t-sm);
}
.error {
color: var(--bad);
}
.stephead {
font-size: 0.78rem;
- text-transform: uppercase;
- letter-spacing: 0.05em;
+ text-transform: none;
margin: var(--space-3) 0 var(--space-2);
}
.stephead:first-of-type {
diff --git a/ui/src/lib/pages/JobsPage.svelte b/ui/src/lib/pages/JobsPage.svelte
index ffbe29ab..921d38f3 100644
--- a/ui/src/lib/pages/JobsPage.svelte
+++ b/ui/src/lib/pages/JobsPage.svelte
@@ -113,8 +113,9 @@