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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions ui/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<outputs>/<identity>/<run id>/` 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.
28 changes: 16 additions & 12 deletions ui/e2e/chrome.spec.ts
Original file line number Diff line number Diff line change
@@ -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 ({
Expand Down Expand Up @@ -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(),
Expand Down
17 changes: 12 additions & 5 deletions ui/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ({
Expand Down Expand Up @@ -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/)
Expand Down
Loading
Loading