From 7209ab8fdc01fb87d5b975a03b36abb739b6b3ad Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Mon, 7 Sep 2026 20:46:41 -0500 Subject: [PATCH] Refactor UI components for improved styling and functionality - Updated JobPage to enhance media display with improved link and image handling. - Enhanced JobsPage with visual indicators for running jobs and refined layout. - Modified ModelsPage to improve button styling and added last used indicators. - Improved PromptsPage layout and added directory display for better context. - Refined ServerPage styling for consistency and clarity. - Enhanced WorkflowPage with better proof display and improved JSON toggle functionality. - Updated WorkflowsPage to improve layout, filtering, and card display for workflows. - Added tests for WorkflowsPage to ensure correct filtering and display of workflow data. - Standardized font usage across components for consistency. --- ui/CLAUDE.md | 54 +++ ui/e2e/chrome.spec.ts | 28 +- ui/e2e/smoke.spec.ts | 17 +- ui/src/App.svelte | 363 +++++++++------- ui/src/app.css | 327 +++++++++++---- ui/src/lib/FolderGroups.svelte | 12 +- ui/src/lib/KeyboardHelp.svelte | 2 +- ui/src/lib/StatusPopover.svelte | 3 +- ui/src/lib/editor/FlowView.svelte | 17 +- ui/src/lib/editor/QuantizationEditor.svelte | 2 +- ui/src/lib/editor/StepEditor.svelte | 13 +- ui/src/lib/pages/EditorPage.svelte | 7 +- ui/src/lib/pages/GalleryPage.svelte | 56 ++- ui/src/lib/pages/JobPage.svelte | 43 +- ui/src/lib/pages/JobsPage.svelte | 31 +- ui/src/lib/pages/ModelsPage.svelte | 25 +- ui/src/lib/pages/PromptsPage.svelte | 28 +- ui/src/lib/pages/ServerPage.svelte | 6 +- ui/src/lib/pages/WorkflowPage.svelte | 211 +++++++--- ui/src/lib/pages/WorkflowsPage.svelte | 441 +++++++++++++------- ui/src/lib/pages/WorkflowsPage.test.ts | 28 +- ui/src/lib/schema/SchemaNode.svelte | 2 +- 22 files changed, 1165 insertions(+), 551 deletions(-) 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 @@ +
-
- - - - {#if memory?.info?.gpu_available} - - {memory.info.gpu_device_name} · {gb( - memory.info.gpu_memory_allocated_mb ?? 0, - )} / - {gb(memory.info.gpu_memory_total_mb ?? 0)} GB - - {/if} - {#if vramPct !== null} -
-
75} - class:critical={vramPct > 92} - style:width={vramPct + '%'} - >
-
- {/if} - - - - - - + +
+ {#if currentJob} + + + running + + {/if} + {#if vramPct !== null} + + {:else} + + {/if} + + + + + + + + + + +
@@ -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 shapesPresent as value (value)} + + {/each} +
+{/if} + {#if traitsPresent.length}
{#each traitsPresent as trait (trait)}