workspaces 0.1.21 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2aca45b37e744578006f9b80c3b42a8d664d3b989e9443c10505e357b739338d
4
- data.tar.gz: cdfb7b28c2fb5f66ace3e778cceb9654a07271fbad73321768829a498248feb5
3
+ metadata.gz: 9b215ed643db37f770915a5da966e4afd5c052ccf8a1bc271ae371f95aed5851
4
+ data.tar.gz: a6c97523fd83b4ccfa72e1d007c60fce8f98e16bcf0ecfcbe6fb7891bc4ae4f0
5
5
  SHA512:
6
- metadata.gz: 764c88c9ebb620cace6381b7e95b1ba32e2cb6e4e80da503a28f68ec1acb783a84a99bf4eb7213cb97e2d4787ce9117bee0c09504649367517b56bb593821a1d
7
- data.tar.gz: dab1db619647ee35bdcd731c6072b762e9788a42877eb684411368d1e7461230267adedbf8a875354f9d0ec50f91f3a81e4654b75f6c29e14c6b0a0f7d0b8896
6
+ metadata.gz: 54a95866acd2a0b6ec251e06996ff1b366d72d885ac7d87ae4cb2e3c68af2a19f59dc92c435ed61a0aac8278f9e816451cb6c4f7fa866cd0748242d7059f2644
7
+ data.tar.gz: 469c1c9aaed1ff6aabbbe3d515d8b6bd99b0e1e1089f6c7a8f7d90278e37c23a07aea8f0cc067d1b6107fd25f234794c145d358cb56ebcbd6116bba6f5ac06f5
data/DESIGN.md CHANGED
@@ -8,17 +8,39 @@ GET is read-only. All creation and lifecycle controls submit same-origin POST fo
8
8
 
9
9
  ## Visual direction
10
10
 
11
- Restrained tooling UI: a near-black page, one slightly lighter card surface with a hairline border and no shadow, a compact spacing rhythm, clear status affordances, and no decorative animation. Every text pairing is at least 4.5:1 against its surface.
11
+ Restrained tooling UI built on the **Arb design system, Native v1** (`~/code/arb-design-system`, commit `88765ca`): a deep-teal page in the dark theme and limestone in the light one, one slightly lighter card surface with a hairline border and no shadow, a compact spacing rhythm, clear status affordances, and no decorative animation. Every text pairing is at least 4.5:1 against its surface, and `spec/workspaces/arb_design_system_spec.rb` measures each one in both themes.
12
+
13
+ Only colour changed in this migration. Type, spacing, radii and motion timing are the ones the dashboard already had, because the Arb kit defines none of them. No state, label, handler, route or permission changed.
12
14
 
13
15
  ## Tokens used
14
16
 
15
17
  ### Color
16
18
 
17
- - `--bg` `#0b0f17`: page. `--bg-elevated` `#141a26`: card surface. `--border` `#2a3447`: card edge. `--divider` `#252e41`: row dividers. `--border-strong` `#3f4b63`: the table header rule.
18
- - `--control-border`: the edge of inputs, outline buttons and tabs. Currently `#3f4b63`, about 2:1 against the card. WCAG 1.4.11 asks 3:1 for a control's boundary, so this is a known shortfall, kept to match the design handoff; `#5b6b8a` or lighter reaches 3:1. It is one token, so changing it is a one-line edit.
19
- - Text: `--text-strong` `#f8fafc` (headings, workspace names, input values), `--text` `#e2e8f0` (body), `--text-cell` `#cbd5e1` (table cells, logs), `--muted` `#a8b3c7` (secondary), `--placeholder` `#8a97ad`.
20
- - `--accent` `#6fa8ff` with `--on-accent` `#06101f` text on buttons; `--accent-hover` `#8dbbff` for link and button hover. `--success`, `--danger` and `--warning` are unchanged.
21
- - **Status pills** take three custom properties, `--pill-bg`, `--pill-text` and `--pill-dot`, set per `data-status`. Sleeping indigo, idle amber and stopped red come from the handoff. Ready green, preparing cyan, error deeper red and interrupted orange were chosen to the same contrast (text 8:1 or better, dot 3:1 or better). Any other status gets the neutral slate pair.
19
+ `dashboard/arb-palette.css` is the kit's `tokens/palette.css` copied unchanged (a two-line note on top says where from). It defines 28 `--arb-*` tokens per theme, selected by `data-theme` on `<html>`. **The theme follows the visitor's operating-system setting, with dark as the default**; a one-line script in the `<head>` sets it before anything paints, and a page without script stays dark. There is no theme switch, because the kit says to follow the host's existing behaviour and the dashboard had none.
20
+
21
+ `styles.css` names no colour of its own, which a spec enforces (no hex, `rgb`, `hsl`, `color-mix` or faded opacity). Its `:root` maps a short list of roles onto the palette, and everything below uses the roles or `--arb-*` directly:
22
+
23
+ | Role in `styles.css` | Arb token | Used for |
24
+ |---|---|---|
25
+ | `--bg` | `bg` | the page |
26
+ | `--bg-elevated` | `surface` | panels, the card, inputs, log blocks |
27
+ | `--arb-elevated` | `elevated` | the progress track, neutral pill fill |
28
+ | `--text`, `--muted` | `text`, `muted` | body and supporting text (the old strong, cell and placeholder greys are gone: one text colour, one muted) |
29
+ | `--border` | `border` | every edge and divider, including control edges, so controls now meet 3:1 (the old control edge was about 2:1) |
30
+ | `--accent`, `--accent-hover`, `--accent-pressed`, `--on-accent` | `accent`, `action_hover`, `action_pressed`, `on_accent` | primary buttons and links |
31
+ | `--focus` | `focus` | the focus ring |
32
+ | `--danger` / `--arb-error*`, `--arb-on-error` | `error`, `error_hover`, `error_pressed`, `on_error` | Delete, the setup error callout, the danger zone, failed steps |
33
+ | `--warning`, `--success` | `warning`, `success` | slow-progress fill, vault note, completed step icon |
34
+ | `--arb-selected-bg`, `--arb-selected-border` | `selected_bg`, `selected_border` | the chosen preset, the preset badge, the log tab you are on |
35
+ | `--arb-surface-hover`, `--arb-surface-pressed` | `surface_hover`, `surface_pressed` | hover and pressed on subtle buttons, rows, tabs |
36
+ | `--arb-disabled-*` | `disabled_bg`, `disabled_fg`, `disabled_border` | disabled buttons and fields, at full opacity |
37
+
38
+ - **Focus** is a 2px `focus` ring with a 2px gap, as the kit specifies. The gap is the outline's own offset, which is transparent and so shows the surface actually behind the control.
39
+ - **Selected** is never colour alone: the chosen preset and the current log tab carry a check mark, because the light theme's selected and pressed fills are the same colour.
40
+ - **Disabled** controls use the disabled tokens at full opacity, not a faded copy of the enabled style.
41
+ - **Status pills** pair a glyph with the word the server already sends: ready is a check on `success_bg`, error a cross on `error_bg`, interrupted an exclamation mark on `warning_bg` (a run that was cut off, not one that failed), preparing a ringed dot in `accent`. Idle, stopped and sleeping have no palette role of their own, so they are told apart by glyph and word (empty ring, filled square, half ring) in `steel` on a neutral fill. The earlier design gave each its own hue; the palette has no roles to carry that, and the guide forbids inventing near-matches.
42
+ - **Motion**: the dots that pulsed forever (next to the current step, and on a busy pill) are gone, because a pulse that never stops says "working" whether or not anything is. The bar, the step name and its number carry progress. The step status icon no longer pulses either.
43
+ - **Forced colors**: shapes that carry meaning get a real border so they survive the system palette.
22
44
 
23
45
  ### Radius and depth
24
46
 
@@ -39,7 +61,7 @@ Restrained tooling UI: a near-black page, one slightly lighter card surface with
39
61
 
40
62
  - **Creation form**: four labelled fields in one wrapping row (`flex: 1 1 220px`, 16px gap), each with a real `<label for>`, then a `type="submit"` button on its own line.
41
63
  - **Workspace inventory**: a centered column up to 1180px. Rows are ordered by last startup, newest first; workspaces that never started (or whose start time cannot be read) come last, and ties fall back to the id so the list never shuffles between loads. Moments are compared as moments, not text. The page header carries "N managed worktrees · none running" (or "· K running"), counted from the data. The table sits in an `overflow-x: auto` wrapper with `min-width: 820px`, so a phone scrolls it sideways instead of cramming or hiding columns. Rows have 16px padding; workspace names are monospaced links with no underline until hover. Times read "Oct 27, 10:48", are UTC (the headers say so) and keep the full ISO value in a `title`. A missing start reads "Never" and a missing creation time "Unavailable", in the muted colour.
42
- - **Status pill**: a dot and one word (13px, 600, fully rounded), never colour alone. A busy workspace adds "(busy)" and pulses its dot, which stops under reduced motion.
64
+ - **Status pill**: a glyph and one word (13px, 600, fully rounded), never colour alone. A busy workspace adds "(busy)" to the word; nothing pulses.
43
65
  - **Setup progress**: names and ordered states come from repository run steps; failures are highlighted. Restart shows the background launch and later readiness commands.
44
66
  - **Actions by state**: idle/stopped offers Start; active operations show progress without lifecycle buttons; failure offers Retry; ready offers Open workspace plus rebuild/restart/stop.
45
67
  - **PR source update**: a secondary Update from PR form only for PR-sourced workspaces; it fetches the latest PR head, fast-forwards the checkout, and preserves local edits without rebuilding or restarting. A persistent result message explains when rebuild is needed or when a retained stash needs manual recovery.
@@ -79,12 +101,14 @@ Restrained tooling UI: a near-black page, one slightly lighter card surface with
79
101
  - **Editable environment**: labelled monospace multiline `NAME=value` control for non-secret recipe defaults and added workspace settings; busy state disables the fieldset and save button, values are escaped in HTML, other legacy overrides remain hidden. Omitted default names revert to recipe defaults; omitted added names are unset.
80
102
  - **Environment presets (JS-enhanced)**: optional preset selector is hidden by default and revealed only when JavaScript is active and presets exist. Selecting a preset replaces the entire draft editor content and announces unsaved state without submitting. Draft edits remain local until Save & restart.
81
103
  - **Danger Zone**: isolated section on the detail page with explicit loss-of-uncommitted-and-untracked-work copy, an exact-workspace-ID confirmation field, and a destructive action. Deletion remains possible without JavaScript, is disabled while setup is busy, and never appears as a list-row action.
82
- - **Workspace favicon**: a small two-pane workspace mark served by the dashboard host. Use `--accent` blue for localhost and `--warning` gold for hosted origins so browser tabs signal the environment without relying on page text. The icon contains no workspace-specific data.
104
+ - **Workspace favicon**: the Arb Previews icon (`dashboard/arb-previews-favicon.svg`, copied unchanged from the design system's `arb-previews/favicon.svg`), served by the dashboard host for local and hosted origins alike. The earlier blue-for-localhost, gold-for-hosted tab signal is gone: the icon is one mark and is not recoloured.
83
105
 
84
106
  ## Accessibility constraints
85
107
 
86
108
  - Semantic sections/headings for all regions
87
- - Focus-visible outlines on actionable controls
109
+ - Focus-visible outlines on actionable controls: a 2px ring with a 2px gap, in the palette's focus colour
110
+ - Selected controls carry a check mark as well as a fill; statuses carry a glyph as well as a colour
111
+ - Forced-colors and reduced-motion are handled
88
112
  - `aria-live` limited to phase-change and connection warnings (not full log stream)
89
113
  - No-JS refresh path always available
90
114
 
data/README.md CHANGED
@@ -95,6 +95,27 @@ workspace's directory with the workspace's environment, and exits with the comma
95
95
  restart, rebuild, PR update or removal holding the lock still takes the preview offline, since
96
96
  those replace the server or its files.
97
97
 
98
+ ### Log tails
99
+
100
+ The dashboard shows the end of each log, not all of it. Normally that is the last 6 KB or 80
101
+ lines of a step's output, and 8 KB or 100 lines of the setup and web server logs, which keeps
102
+ every poll small.
103
+
104
+ When something has failed (a step failed, or a run was interrupted) the page gets far more of
105
+ what matters: the failed step's output, and the setup and server logs, are sent as the last
106
+ **128 KB or 2000 lines**. That is enough for the error and the backtrace after it, which the
107
+ everyday tail would cut off. Steps that did not fail keep the small tail, and a step that is
108
+ merely running does too. The full files stay on disk under the workspaces home (`logs/`), and
109
+ every tail is secret-scrubbed first.
110
+
111
+ ### Copying from a log
112
+
113
+ The page refreshes itself, and replacing a log's text drops any selection inside it. So it never
114
+ rewrites a log whose text has not changed, and while part of a log (a step's output, the full
115
+ setup log, the web server log or the error panel) is selected, its new text waits and is applied
116
+ once, when the selection ends. Only the log being read waits; everything else keeps updating. A
117
+ step that finishes while you have text selected in it stays open until you let go.
118
+
98
119
  ### Step timings
99
120
 
100
121
  Per-step durations are persisted in seconds in workspace step status JSON, so they
@@ -13,6 +13,13 @@ module Workspaces
13
13
  # Each step shows its own tail, so the per-step bounds are smaller than the combined log's.
14
14
  STEP_LOG_BYTES = 6 * 1024
15
15
  STEP_LOG_LINES = 80
16
+ # What the page gets once something has failed. The error is at the end of the output, but a failing
17
+ # command prints a backtrace of a hundred lines or more after it, and bundler, yarn and rails print
18
+ # their progress before it, so the everyday tail cuts the message off. Only the step that failed (and
19
+ # the setup and server logs of a workspace that failed) get this; everything else stays small, because
20
+ # every poll sends these.
21
+ FAILURE_LOG_BYTES = 128 * 1024
22
+ FAILURE_LOG_LINES = 2000
16
23
  DEFAULT_HOME = File.expand_path('~/.workspaces')
17
24
  DEFAULT_SUBDOMAIN_PREFIX = 'ws-'
18
25
  DEFAULT_FRONT_DOOR_PORT = 4747
@@ -144,6 +151,12 @@ module Workspaces
144
151
  "#{public_origin}/workspaces/#{validate_id!(id)}"
145
152
  end
146
153
 
154
+ # The list of every workspace. A card on a preview host is a different origin from the dashboard, so it
155
+ # needs the whole URL; a card on the dashboard host can use the path.
156
+ def self.index_url
157
+ "#{public_origin}/workspaces"
158
+ end
159
+
147
160
  def self.settings_url(id)
148
161
  "#{dashboard_url(id)}/settings"
149
162
  end
@@ -274,9 +274,10 @@ module Workspaces
274
274
  'steps' => entry.fetch('steps', []), 'total_seconds' => entry['total_seconds'],
275
275
  'resume' => resume_info(id, entry),
276
276
  'estimates' => entry.fetch('estimates', {}),
277
- 'step_logs' => include_log && log == 'setup' ? step_logs(id, entry.fetch('steps', [])) : {},
277
+ 'step_logs' => include_log && log == 'setup' ? step_logs(id, entry.fetch('steps', []), status) : {},
278
278
  'backend_log_available' => backend_log_available?(id),
279
- 'log_tail' => include_log ? log_tail(id, log) : '', 'log_source' => log, 'port' => entry['port'],
279
+ 'log_tail' => include_log ? log_tail(id, log, failed: failed_status?(status)) : '', 'log_source' => log,
280
+ 'port' => entry['port'],
280
281
  'environment_keys' => environment_keys(id),
281
282
  'editable_environment' => editable_environment(id),
282
283
  'secret_names' => secret_names(id),
@@ -434,30 +435,42 @@ module Workspaces
434
435
  WorkspaceLock.held?(id)
435
436
  end
436
437
 
437
- def log_tail(id, log)
438
+ # Something went wrong, so the logs are worth reading in full: the larger tail is for these states.
439
+ def failed_status?(status)
440
+ %w(error interrupted).include?(status)
441
+ end
442
+
443
+ def log_tail(id, log, failed: false)
438
444
  path = log == 'backend' ? Config.backend_log_path(id, create: false) : Config.setup_log_path(id, create: false)
439
445
  return '' unless path.file?
440
446
 
441
- raw = File.open(path, 'rb') do |file|
442
- file.seek(-Config::SAFE_LOG_BYTES, IO::SEEK_END) if file.size > Config::SAFE_LOG_BYTES
443
- file.read
444
- end
445
- scrub(raw, id: id).lines.last(Config::SAFE_LOG_LINES).join
447
+ bytes = failed ? Config::FAILURE_LOG_BYTES : Config::SAFE_LOG_BYTES
448
+ lines = failed ? Config::FAILURE_LOG_LINES : Config::SAFE_LOG_LINES
449
+ tail_file(path, id, bytes: bytes, lines: lines).fetch('text')
446
450
  end
447
451
 
448
452
  # Output for each recipe step, keyed by step id. Only steps that have written something
449
453
  # appear, so a pending step has no entry and the page can show it as not started.
450
- def step_logs(id, steps)
454
+ def step_logs(id, steps, status = nil)
451
455
  steps.each_with_object({}) do |step, logs|
452
456
  path = Config.step_log_path(id, step['key'], create: false)
453
457
  next unless path.file?
454
458
 
455
- logs[step['key']] = tail_file(path, id, bytes: Config::STEP_LOG_BYTES, lines: Config::STEP_LOG_LINES)
459
+ failed = failed_step?(step, status)
460
+ logs[step['key']] = tail_file(path, id, bytes: failed ? Config::FAILURE_LOG_BYTES : Config::STEP_LOG_BYTES,
461
+ lines: failed ? Config::FAILURE_LOG_LINES : Config::STEP_LOG_LINES)
456
462
  end
457
463
  rescue ArgumentError
458
464
  {}
459
465
  end
460
466
 
467
+ # The step that failed, or the one a run was cut off in. A step that is merely running is not one.
468
+ def failed_step?(step, status)
469
+ return true if step['state'] == 'failed'
470
+
471
+ status == 'interrupted' && step['state'] == 'active'
472
+ end
473
+
461
474
  def backend_log_available?(id)
462
475
  path = Config.backend_log_path(id, create: false)
463
476
  path.file? && !path.zero?
@@ -107,7 +107,7 @@
107
107
  const output = details.querySelector('pre');
108
108
  output.setAttribute('aria-label', step.label + ' output');
109
109
  const entry = stepLogs && stepLogs[step.key];
110
- if (entry && output.textContent !== entry.text) writeLog(output, entry.text);
110
+ if (entry) writeLog(output, entry.text);
111
111
  applyStepOpenState(details, step);
112
112
  });
113
113
  }
@@ -164,11 +164,11 @@
164
164
  : 'everything, in order';
165
165
  }
166
166
 
167
- function applyStepOpenState(details, step) {
168
- const previous = stepState[step.key];
169
- stepState[step.key] = step.state;
170
- if (previous === step.state || userToggled.has(step.key)) return;
171
- const shouldOpen = step.state === 'active' || step.state === 'failed';
167
+ // A step that finished is closed by the page, which hides its log, and text selected inside a hidden
168
+ // element is lost. So a close waits while part of that step is selected, and happens when the selection ends.
169
+ const pendingClose = new Set();
170
+
171
+ function setStepOpen(details, shouldOpen) {
172
172
  if (details.open === shouldOpen) return;
173
173
  details.dataset.programmatic = 'true';
174
174
  details.open = shouldOpen;
@@ -176,13 +176,63 @@
176
176
  setTimeout(() => { details.dataset.programmatic = 'false'; }, 0);
177
177
  }
178
178
 
179
+ function applyStepOpenState(details, step) {
180
+ const previous = stepState[step.key];
181
+ stepState[step.key] = step.state;
182
+ if (previous === step.state || userToggled.has(step.key)) return;
183
+ const shouldOpen = step.state === 'active' || step.state === 'failed';
184
+ if (shouldOpen) pendingClose.delete(details);
185
+ if (!shouldOpen && details.open && hasSelectionIn(details)) {
186
+ pendingClose.add(details);
187
+ return;
188
+ }
189
+ pendingClose.delete(details);
190
+ setStepOpen(details, shouldOpen);
191
+ }
192
+
193
+ // Replacing a log's text drops any selection inside it, which made copying out of a log that keeps
194
+ // updating a race against the next poll. So: text that has not changed is never rewritten, and while
195
+ // part of a log is selected its new text is held back and applied, once, when the selection ends.
196
+ // Only the log being read waits; every other element keeps updating.
197
+ const heldText = new Map();
198
+
199
+ function hasSelectionIn(element) {
200
+ const selection = window.getSelection && window.getSelection();
201
+ if (!selection || selection.isCollapsed) return false;
202
+ if (element.contains(selection.anchorNode) || element.contains(selection.focusNode)) return true;
203
+ // A selection that starts above a log and ends below it contains the log without either end in it.
204
+ return typeof selection.containsNode === 'function' && selection.containsNode(element, true);
205
+ }
206
+
179
207
  // Writing a log must not yank a reader who scrolled up; follow the tail only if already there.
180
208
  function writeLog(pre, text) {
209
+ const next = text || '';
210
+ if (pre.textContent === next) {
211
+ heldText.delete(pre);
212
+ return;
213
+ }
214
+ if (hasSelectionIn(pre)) {
215
+ heldText.set(pre, next);
216
+ return;
217
+ }
218
+ heldText.delete(pre);
181
219
  const atBottom = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 18;
182
- pre.textContent = text || '';
220
+ pre.textContent = next;
183
221
  if (atBottom) pre.scrollTop = pre.scrollHeight;
184
222
  }
185
223
 
224
+ // When the selection moves or is cleared, catch up any log that was waiting on it.
225
+ document.addEventListener('selectionchange', () => {
226
+ heldText.forEach((text, pre) => {
227
+ if (!hasSelectionIn(pre)) writeLog(pre, text);
228
+ });
229
+ pendingClose.forEach((details) => {
230
+ if (hasSelectionIn(details)) return;
231
+ pendingClose.delete(details);
232
+ if (!userToggled.has(details.dataset.stepLog)) setStepOpen(details, false);
233
+ });
234
+ });
235
+
186
236
  // Durations read the way people say them: 0.5s, 12.3s, 1m 10s, 1h 5m 3s. This mirrors
187
237
  // Workspaces::DurationFormat in Ruby; a spec runs both over the same inputs.
188
238
  function formatDuration(seconds) {
@@ -258,6 +308,8 @@
258
308
  }
259
309
 
260
310
  function renderLead(view) {
311
+ const lead = node('lead');
312
+ if (lead && view.state) lead.dataset.state = view.state;
261
313
  node('status').textContent = view.headline || view.title;
262
314
  node('summary').textContent = view.summary || '';
263
315
  const note = node('sleep-note');
@@ -335,7 +387,7 @@
335
387
  function renderStatus(view) {
336
388
  node('preview').hidden = !view.ready;
337
389
  reloadWhenReady(view);
338
- node('error').textContent = view.error || '';
390
+ writeLog(node('error'), view.error || '');
339
391
  node('error-panel').hidden = !view.error;
340
392
  if (window.WorkspaceEnvironment)
341
393
  window.WorkspaceEnvironment.updateStatus(view.busy);
@@ -0,0 +1,82 @@
1
+ /* Arb design system, Native v1: tokens/palette.css from ~/code/arb-design-system (commit 88765ca), copied unchanged below this note.
2
+ Do not edit by hand. To update, copy the new palette.css over everything after this comment. */
3
+ /* Source: palette.tokens.json. Opaque sRGB pairings only; see README.md. */
4
+ :root {
5
+ --arb-mark-shell: #123A3D;
6
+ --arb-mark-river: #58D9C4;
7
+ --arb-mark-brackets: #F4F1E8;
8
+ }
9
+
10
+ [data-theme="dark"] {
11
+ color-scheme: dark;
12
+ --arb-bg: #0B171A;
13
+ --arb-surface: #12262B;
14
+ --arb-elevated: #1B343B;
15
+ --arb-text: #F4F1E8;
16
+ --arb-muted: #B4C3C8;
17
+ --arb-accent: #58D9C4;
18
+ --arb-on-accent: #073A35;
19
+ --arb-steel: #ABC8D2;
20
+ --arb-border: #6F8991;
21
+ --arb-focus: #76CCE5;
22
+ --arb-success: #9DD48E;
23
+ --arb-warning: #F1C56B;
24
+ --arb-error: #FF9B96;
25
+ --arb-action-hover: #78E3D0;
26
+ --arb-action-pressed: #3CBDA9;
27
+ --arb-surface-hover: #1B343B;
28
+ --arb-surface-pressed: #203A41;
29
+ --arb-selected-bg: #173E40;
30
+ --arb-selected-border: #58D9C4;
31
+ --arb-disabled-bg: #1B343B;
32
+ --arb-disabled-fg: #91A5AC;
33
+ --arb-disabled-border: #6F8991;
34
+ --arb-success-bg: #173322;
35
+ --arb-warning-bg: #3E321A;
36
+ --arb-error-bg: #40262A;
37
+ --arb-error-hover: #FFB4AE;
38
+ --arb-error-pressed: #EE817D;
39
+ --arb-on-error: #351415;
40
+ }
41
+
42
+ [data-theme="light"] {
43
+ color-scheme: light;
44
+ --arb-bg: #F5F2E9;
45
+ --arb-surface: #FFFFFF;
46
+ --arb-elevated: #E8EEEC;
47
+ --arb-text: #123A3D;
48
+ --arb-muted: #4B676E;
49
+ --arb-accent: #0A766F;
50
+ --arb-on-accent: #FFFFFF;
51
+ --arb-steel: #496B79;
52
+ --arb-border: #71868B;
53
+ --arb-focus: #246C89;
54
+ --arb-success: #2D6D45;
55
+ --arb-warning: #8A5A10;
56
+ --arb-error: #B64344;
57
+ --arb-action-hover: #08655F;
58
+ --arb-action-pressed: #07554F;
59
+ --arb-surface-hover: #E8EEEC;
60
+ --arb-surface-pressed: #E1EFEA;
61
+ --arb-selected-bg: #E1EFEA;
62
+ --arb-selected-border: #0A766F;
63
+ --arb-disabled-bg: #E1E7E5;
64
+ --arb-disabled-fg: #4E656B;
65
+ --arb-disabled-border: #71868B;
66
+ --arb-success-bg: #E5F1E6;
67
+ --arb-warning-bg: #FBF0D7;
68
+ --arb-error-bg: #FBE9E7;
69
+ --arb-error-hover: #A03638;
70
+ --arb-error-pressed: #8B2C2E;
71
+ --arb-on-error: #FFFFFF;
72
+ }
73
+
74
+ /* Optional descriptive aliases. Canonical names mirror JSON keys. */
75
+ [data-theme] {
76
+ --arb-canvas: var(--arb-bg);
77
+ --arb-raised: var(--arb-elevated);
78
+ --arb-text-secondary: var(--arb-muted);
79
+ --arb-river: var(--arb-accent);
80
+ --arb-on-river: var(--arb-on-accent);
81
+ --arb-boundary: var(--arb-border);
82
+ }
@@ -0,0 +1,11 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" role="img" aria-labelledby="title desc">
2
+ <title id="title">Arb Previews</title>
3
+ <desc id="desc">A browser window with two preview panes. Original vector artwork in the Arb Native palette. Optically tuned for 16 pixels.</desc>
4
+ <rect id="tile" width="16" height="16" rx="4" fill="#123A3D"/>
5
+ <g id="preview-window" fill="none" stroke-linejoin="round">
6
+ <rect id="window-frame" x="2.5" y="3.5" width="11" height="9" rx="1.5" stroke="#F4F1E8" stroke-width="1.5"/>
7
+ <path id="browser-header" d="M2.5 6.5H13.5" stroke="#F4F1E8" stroke-width="1.5"/>
8
+ <path id="preview-divider" d="M8 7.25V11.75" stroke="#58D9C4" stroke-width="1.5"/>
9
+ <g id="browser-controls" fill="#58D9C4"><circle cx="4.5" cy="5" r="0.5"/><circle cx="6.5" cy="5" r="0.5"/></g>
10
+ </g>
11
+ </svg>
@@ -2,7 +2,8 @@
2
2
  <section class="card" aria-labelledby="status">
3
3
  <header class="card__header">
4
4
  <p class="card__name"><span class="eyebrow">Workspace</span><span class="card__id"><%= @id %></span></p>
5
- <a class="icon-link" href="<%= escape(settings_href) %>" aria-label="Settings and details" title="Settings and details"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false"><path fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" d="M10.52 5.57L10.69 2.69L13.31 2.69L13.48 5.57A6.6 6.6 0 0 1 15.50 6.40L17.66 4.49L19.51 6.34L17.60 8.50A6.6 6.6 0 0 1 18.43 10.52L21.31 10.69L21.31 13.31L18.43 13.48A6.6 6.6 0 0 1 17.60 15.50L19.51 17.66L17.66 19.51L15.50 17.60A6.6 6.6 0 0 1 13.48 18.43L13.31 21.31L10.69 21.31L10.52 18.43A6.6 6.6 0 0 1 8.50 17.60L6.34 19.51L4.49 17.66L6.40 15.50A6.6 6.6 0 0 1 5.57 13.48L2.69 13.31L2.69 10.69L5.57 10.52A6.6 6.6 0 0 1 6.40 8.50L4.49 6.34L6.34 4.49L8.50 6.40A6.6 6.6 0 0 1 10.52 5.57ZM9.20 12.00a2.8 2.8 0 1 0 5.6 0a2.8 2.8 0 1 0 -5.6 0Z"/></svg></a>
5
+ <span class="card__links"><a class="icon-link" href="<%= escape(index_href) %>" aria-label="All workspaces" title="All workspaces"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false"><path fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"/></svg></a>
6
+ <a class="icon-link" href="<%= escape(settings_href) %>" aria-label="Settings and details" title="Settings and details"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false"><path fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" d="M10.52 5.57L10.69 2.69L13.31 2.69L13.48 5.57A6.6 6.6 0 0 1 15.50 6.40L17.66 4.49L19.51 6.34L17.60 8.50A6.6 6.6 0 0 1 18.43 10.52L21.31 10.69L21.31 13.31L18.43 13.48A6.6 6.6 0 0 1 17.60 15.50L19.51 17.66L17.66 19.51L15.50 17.60A6.6 6.6 0 0 1 13.48 18.43L13.31 21.31L10.69 21.31L10.52 18.43A6.6 6.6 0 0 1 8.50 17.60L6.34 19.51L4.49 17.66L6.40 15.50A6.6 6.6 0 0 1 5.57 13.48L2.69 13.31L2.69 10.69L5.57 10.52A6.6 6.6 0 0 1 6.40 8.50L4.49 6.34L6.34 4.49L8.50 6.40A6.6 6.6 0 0 1 10.52 5.57ZM9.20 12.00a2.8 2.8 0 1 0 5.6 0a2.8 2.8 0 1 0 -5.6 0Z"/></svg></a></span>
6
7
  </header>
7
8
  <div class="card__text">
8
9
  <h1 id="status" role="status"><%= escape(@presentation['headline']) %></h1>
@@ -86,8 +86,8 @@
86
86
  presetButtons.forEach((button) => {
87
87
  const isSelected = !!selectedPreset && button.dataset.environmentPreset === selectedPreset;
88
88
  button.setAttribute('aria-pressed', isSelected ? 'true' : 'false');
89
- if (isSelected) button.classList.add('button--success');
90
- else button.classList.remove('button--success');
89
+ if (isSelected) button.classList.add('button--selected');
90
+ else button.classList.remove('button--selected');
91
91
  });
92
92
  }
93
93
 
@@ -1,29 +1,23 @@
1
1
  :root {
2
- /* Surfaces. Page, then card, then the hairlines that divide them. */
3
- --bg: #0b0f17;
4
- --bg-elevated: #141a26;
5
- --border: #2a3447;
6
- --divider: #252e41;
7
- --border-strong: #3f4b63;
8
- /* The edge of an input or outline button. WCAG asks 3:1 for a control's boundary against what is
9
- behind it; #3f4b63 is about 2:1 on the card. One token, so raising it is a one-line change. */
10
- --control-border: #3f4b63;
11
- /* Text. Every pairing with a surface is at least 4.5:1. */
12
- --text-strong: #f8fafc;
13
- --text: #e2e8f0;
14
- --text-cell: #cbd5e1;
15
- --muted: #a8b3c7;
16
- --placeholder: #8a97ad;
17
- --accent: #6fa8ff;
18
- --accent-hover: #8dbbff;
19
- --on-accent: #06101f;
20
- --success: #2fbf8f;
21
- --danger: #ff6f74;
22
- --warning: #f1bb53;
2
+ /* Colours come from the Arb palette (arb-palette.css), per theme, and only through these roles. Nothing
3
+ below this block names a colour: add a role here, never a literal. A role the palette lacks is a gap to
4
+ raise with the palette's owner, not something to approximate with a mix or an opacity. */
5
+ --bg: var(--arb-bg);
6
+ --bg-elevated: var(--arb-surface);
7
+ --border: var(--arb-border);
8
+ --text: var(--arb-text);
9
+ --muted: var(--arb-muted);
10
+ --accent: var(--arb-accent);
11
+ --accent-hover: var(--arb-action-hover);
12
+ --accent-pressed: var(--arb-action-pressed);
13
+ --on-accent: var(--arb-on-accent);
14
+ --success: var(--arb-success);
15
+ --danger: var(--arb-error);
16
+ --warning: var(--arb-warning);
17
+ --focus: var(--arb-focus);
23
18
  --radius: 12px;
24
19
  --radius-control: 8px;
25
20
  --control-height: 44px;
26
- --shadow: none;
27
21
  --font-sans: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
28
22
  --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
29
23
  --space-1: 4px;
@@ -37,6 +31,12 @@
37
31
  --panel-padding: 28px;
38
32
  }
39
33
 
34
+ /* Focus: a 2px ring in the palette's focus colour, with a 2px gap. The gap is required: the ring is only approved
35
+ against surfaces, not directly against a button's fill. It is the outline's own offset, which is transparent, so
36
+ it shows whatever surface the control actually sits on (the page or a panel) and needs no colour of its own. The
37
+ ring takes no space and moves nothing. */
38
+ :focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
39
+
40
40
  * {
41
41
  box-sizing: border-box;
42
42
  }
@@ -45,7 +45,7 @@
45
45
  which tag was used. Reset them once; every gap is declared by a stack rule below
46
46
  using the --space-* scale. */
47
47
  h1, h2, h3, h4, p, ul, ol, dl, dd, pre, fieldset, figure { margin: 0; }
48
- h2 { font-size: 20px; font-weight: 600; line-height: 1.3; color: var(--text-strong); }
48
+ h2 { font-size: 20px; font-weight: 600; line-height: 1.3; color: var(--text); }
49
49
  label { font-size: 14px; font-weight: 600; }
50
50
  ol, ul { padding-inline-start: var(--space-6); }
51
51
  legend { padding: 0; }
@@ -67,27 +67,27 @@ input:not([type='checkbox']):not([type='radio']), select {
67
67
  max-inline-size: 100%;
68
68
  block-size: var(--control-height);
69
69
  padding-inline: var(--space-3);
70
- color: var(--text-strong);
70
+ color: var(--text);
71
71
  background: var(--bg);
72
- border: 1px solid var(--control-border);
72
+ border: 1px solid var(--border);
73
73
  border-radius: var(--radius-control);
74
74
  font-family: var(--font-mono);
75
75
  font-size: 14px;
76
76
  }
77
77
  select { font-family: var(--font-sans); font-size: 15px; }
78
- ::placeholder { color: var(--placeholder); opacity: 1; }
79
- input:focus-visible, textarea:focus-visible, select:focus-visible, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
78
+ ::placeholder { color: var(--muted); opacity: 1; }
80
79
  summary { cursor: pointer; color: var(--muted); }
81
- summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
82
80
  .workspace-actions { display: flex; flex-wrap: wrap; gap: var(--space-3); }
83
81
  /* The server-rendered action forms live in a wrapper; let them flow with the sibling button. */
84
82
  #actions { display: contents; }
85
- .setup-error { --panel-padding: var(--space-4); padding: var(--panel-padding); border-color: var(--danger); box-shadow: none; }
86
- .setup-error h3 { color: var(--danger); margin-block-start: 0; }
83
+ /* Two classes, so it beats the shared panel rule further down, which would otherwise put the full outline back. */
84
+ .error-panel.setup-error { --panel-padding: var(--space-4); padding: var(--panel-padding); border: 0; border-inline-start: 4px solid var(--arb-error); border-radius: var(--radius-control); background: var(--arb-error-bg); }
85
+ .setup-error h3, .setup-error h2 { color: var(--arb-error); margin-block-start: 0; }
86
+ .setup-error pre { background: var(--bg); }
87
87
  .step-outcome { margin-inline-start: auto; font-size: 12px; }
88
- .activity-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: workspace-pulse 1.5s infinite; }
89
- @keyframes workspace-pulse { 50% { opacity: 0.3; } }
90
- @media (prefers-reduced-motion: reduce) { .activity-dot { animation: none; } }
88
+ /* The dot beside the current step is dropped: a pulse that never stops says "working" whether or not anything is
89
+ happening. The step's name, its number and the bar are what show progress. */
90
+ .activity-dot { display: none; }
91
91
  dd, h1, code { overflow-wrap: anywhere; }
92
92
  dt + dd { margin-block-start: var(--space-1); }
93
93
  dd + dt { margin-block-start: var(--space-3); }
@@ -115,6 +115,8 @@ a {
115
115
  gap: var(--space-6);
116
116
  }
117
117
 
118
+ .dashboard--narrow { inline-size: min(100% - var(--space-6), 60rem); }
119
+
118
120
  .dashboard__header {
119
121
  display: flex;
120
122
  flex-wrap: wrap;
@@ -134,7 +136,7 @@ a {
134
136
 
135
137
  h1 {
136
138
  margin: 0;
137
- color: var(--text-strong);
139
+ color: var(--text);
138
140
  font-size: 32px;
139
141
  font-weight: 700;
140
142
  line-height: 1.2;
@@ -163,10 +165,9 @@ h1 {
163
165
  .log-panel,
164
166
  .noscript-note {
165
167
  background: var(--bg-elevated);
166
- border: 1px solid var(--border);
168
+ border: 1px solid var(--arb-elevated);
167
169
  border-radius: var(--radius);
168
170
  padding: var(--panel-padding);
169
- box-shadow: var(--shadow);
170
171
  }
171
172
 
172
173
  /* Disclosure panels keep their native layout; only the open state adds spacing below. */
@@ -189,13 +190,36 @@ h1 {
189
190
  .lead { gap: var(--space-4); padding-block: var(--space-8); }
190
191
  .lead__text { display: grid; gap: var(--space-2); }
191
192
  .lead h2 { font-size: 24px; line-height: 1.25; }
192
- .lead__summary { max-inline-size: 38rem; color: var(--muted); font-size: 15px; line-height: 1.5; }
193
+ .lead__summary { max-inline-size: 38rem; color: var(--text); font-size: 16px; line-height: 1.5; }
194
+ /* A run that failed or was cut off says so three ways: the words, a glyph beside the headline, and an accent
195
+ bar on the panel. The colour is the accent, never the only signal. Interrupted is a warning, not an error. */
196
+ .lead[data-state='error'], .lead[data-state='interrupted'] { border-inline-start: 4px solid var(--arb-error); }
197
+ .lead[data-state='interrupted'] { border-inline-start-color: var(--arb-warning); }
198
+ .lead[data-state='error'] h2::before, .lead[data-state='interrupted'] h2::before {
199
+ content: '!';
200
+ content: '!' / '';
201
+ display: inline-grid;
202
+ place-items: center;
203
+ inline-size: 1.2em;
204
+ block-size: 1.2em;
205
+ margin-inline-end: var(--space-3);
206
+ border: 2px solid currentColor;
207
+ border-radius: 999px;
208
+ color: var(--arb-error);
209
+ font-size: 0.8em;
210
+ font-weight: 800;
211
+ line-height: 1;
212
+ vertical-align: 0.08em;
213
+ }
214
+ .lead[data-state='interrupted'] h2::before { color: var(--arb-warning); }
215
+ .lead[data-state='interrupted'] .error-panel.setup-error { border-inline-start-color: var(--arb-warning); background: var(--arb-warning-bg); }
216
+ .lead[data-state='interrupted'] .setup-error h3, .lead[data-state='interrupted'] .setup-error h2 { color: var(--arb-warning); }
193
217
  .lead__actions { display: flex; flex-wrap: wrap; gap: var(--space-3); }
194
218
  .lead__actions:empty { display: none; }
195
219
 
196
220
  /* The build progress bar: a plain track and fill, then one line saying which step and how long. */
197
221
  .build-progress { display: grid; gap: var(--space-3); }
198
- .progress-bar { block-size: 10px; border-radius: 999px; background: color-mix(in oklab, var(--muted) 22%, transparent); overflow: hidden; }
222
+ .progress-bar { block-size: 10px; border-radius: 999px; background: var(--arb-elevated); border: 1px solid var(--border); overflow: hidden; }
199
223
  .progress-bar__fill { display: block; block-size: 100%; border-radius: inherit; background: var(--accent); transition: inline-size 600ms ease; }
200
224
  .progress-bar[data-slow='true'] .progress-bar__fill { background: var(--warning); }
201
225
  .progress-meta { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: var(--space-1) var(--space-4); }
@@ -214,31 +238,31 @@ h1 {
214
238
  align-items: baseline;
215
239
  gap: var(--space-1) var(--space-3);
216
240
  padding-block-end: var(--space-4);
217
- border-block-end: 1px solid var(--border);
241
+ border-block-end: 1px solid var(--arb-elevated);
218
242
  }
219
- .section-heading h2 { margin: 0; font-size: 1.125rem; }
220
- .section-heading p { margin: 0; font-size: 13px; }
243
+ .section-heading h2 { margin: 0; font-size: 1.25rem; font-weight: 700; }
244
+ .section-heading p { margin: 0; font-size: 14px; }
221
245
  /* Which preset is active, readable at a glance. Same tint as a pressed preset button. */
222
246
  .badge {
223
247
  display: inline-flex;
224
248
  align-items: center;
225
249
  gap: var(--space-2);
226
250
  padding: var(--space-1) var(--space-3);
227
- border: 1px solid color-mix(in oklab, var(--success) 55%, var(--border));
251
+ border: 1px solid var(--arb-selected-border);
228
252
  border-radius: 999px;
229
- background: color-mix(in oklab, var(--success) 22%, transparent);
253
+ background: var(--arb-selected-bg);
230
254
  color: var(--text);
231
255
  font-size: 12px;
232
256
  font-weight: 600;
233
257
  line-height: 1.4;
234
258
  white-space: nowrap;
235
259
  }
236
- .badge::before { content: ''; inline-size: 6px; block-size: 6px; border-radius: 999px; background: var(--success); }
260
+ .badge::before { content: ''; inline-size: 6px; block-size: 6px; border-radius: 999px; background: var(--arb-selected-border); }
237
261
  #technical-section .technical > section:not(.danger-zone) { padding: 0; border: 0; box-shadow: none; background: none; }
238
- #technical-section .danger-zone { display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--danger); border-radius: var(--radius); }
262
+ #technical-section .danger-zone { display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--arb-error); background: var(--arb-error-bg); border-radius: var(--radius); }
239
263
  #technical-section .log-panel { padding: 0; }
240
264
  .log-panel > #log { margin-block-start: 0; }
241
- #technical-section .danger-zone h3 { color: var(--danger); font-size: 1rem; }
265
+ #technical-section .danger-zone h3 { color: var(--arb-error); font-size: 1rem; }
242
266
  #technical-section h3 { font-size: 1rem; }
243
267
 
244
268
  .status-row {
@@ -265,8 +289,10 @@ h1 {
265
289
 
266
290
  /* Step logs, in the style of GitHub Actions: one row per recipe step with a status icon,
267
291
  its name, how long it took, and a chevron. Open a row to read just that step's output. */
268
- .step-logs { display: grid; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
269
- .step-log + .step-log { border-block-start: 1px solid var(--border); }
292
+ .step-logs { display: grid; border: 1px solid var(--arb-elevated); border-radius: 10px; overflow: hidden; }
293
+ .step-log { border-inline-start: 4px solid transparent; }
294
+ .step-log + .step-log { border-block-start: 1px solid var(--arb-elevated); }
295
+ .step-log[data-step-state='failed'] { border-inline-start-color: var(--arb-error); background: var(--arb-error-bg); }
270
296
  .step-log > summary {
271
297
  display: flex;
272
298
  align-items: center;
@@ -280,8 +306,8 @@ h1 {
280
306
  transition: background-color 120ms ease;
281
307
  }
282
308
  .step-log > summary::-webkit-details-marker { display: none; }
283
- .step-log > summary:hover { background: color-mix(in oklab, var(--accent) 8%, transparent); }
284
- .step-log > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
309
+ .step-log > summary:hover { background: var(--arb-surface-hover); }
310
+ .step-log > summary:focus-visible { outline-offset: -4px; }
285
311
  .step-log > summary::after {
286
312
  content: '';
287
313
  flex: none;
@@ -295,7 +321,7 @@ h1 {
295
321
  transition: transform 160ms ease;
296
322
  }
297
323
  .step-log[open] > summary::after { margin-block-start: -3px; transform: rotate(45deg); }
298
- .step-log[open] > summary { border-block-end: 1px solid var(--border); }
324
+ .step-log[open] > summary { border-block-end: 1px solid var(--arb-elevated); }
299
325
  .step-log__name { flex: 1; min-inline-size: 0; overflow-wrap: anywhere; font-weight: 600; }
300
326
  .step-log__outcome { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; }
301
327
  .step-log--full > summary { color: var(--muted); }
@@ -310,17 +336,17 @@ h1 {
310
336
 
311
337
  /* The status icon: a ring for waiting, a pulsing ring while running, a check, a cross. */
312
338
  .step-log__status { flex: none; inline-size: 16px; block-size: 16px; border: 2px solid var(--muted); border-radius: 999px; position: relative; }
313
- .step-log[data-step-state='active'] .step-log__status { border-color: var(--accent); background: color-mix(in oklab, var(--accent) 25%, transparent); animation: workspace-pulse 1.5s infinite; }
339
+ .step-log[data-step-state='active'] .step-log__status { border-color: var(--accent); background: radial-gradient(circle, var(--accent) 0 3px, transparent 3.5px); }
314
340
  .step-log[data-step-state='complete'] .step-log__status { border-color: var(--success); background: var(--success); }
315
341
  .step-log[data-step-state='complete'] .step-log__status::after { content: ''; position: absolute; inset-inline-start: 4px; inset-block-start: 1px; inline-size: 4px; block-size: 8px; border-inline-end: 2px solid var(--bg); border-block-end: 2px solid var(--bg); transform: rotate(40deg); }
316
- .step-log[data-step-state='failed'] .step-log__status { border-color: var(--danger); background: var(--danger); }
342
+ .step-log[data-step-state='failed'] .step-log__status { border-color: var(--arb-error); background: var(--arb-error); }
317
343
  .step-log[data-step-state='failed'] .step-log__status::before,
318
344
  .step-log[data-step-state='failed'] .step-log__status::after { content: ''; position: absolute; inset-inline-start: 5px; inset-block-start: 2px; inline-size: 2px; block-size: 8px; background: var(--bg); transform: rotate(45deg); }
319
345
  .step-log[data-step-state='failed'] .step-log__status::after { transform: rotate(-45deg); }
320
- .step-log[data-step-state='failed'] > summary .step-log__name { color: color-mix(in oklab, var(--danger) 70%, var(--text)); }
346
+ .step-log[data-step-state='failed'] > summary .step-log__name { color: var(--arb-error); }
321
347
  .step-log[data-step-state='pending'] > summary .step-log__name { color: var(--muted); font-weight: 400; }
322
348
  .step-log[data-step-state='unknown'] .step-log__status { border-style: dashed; }
323
- @media (prefers-reduced-motion: reduce) { .step-log > summary, .step-log > summary::after, .step-log[data-step-state='active'] .step-log__status { transition: none; animation: none; } }
349
+ @media (prefers-reduced-motion: reduce) { .step-log > summary, .step-log > summary::after { transition: none; } }
324
350
 
325
351
  .env-panel {
326
352
  display: grid;
@@ -328,23 +354,25 @@ h1 {
328
354
  }
329
355
 
330
356
  .env-fieldset {
331
- border: 1px solid var(--border);
332
- border-radius: 10px;
357
+ border: 0;
333
358
  margin: 0;
334
- padding: var(--space-4);
359
+ padding: 0;
335
360
  display: grid;
336
- gap: var(--space-4);
361
+ gap: var(--space-5);
337
362
  }
338
363
 
339
364
  .env-legend {
340
- color: var(--muted);
341
- font-size: 13px;
342
- padding-inline: var(--space-2);
365
+ color: var(--text);
366
+ font-size: 1rem;
367
+ font-weight: 700;
368
+ padding: 0;
369
+ margin-block-end: var(--space-1);
343
370
  }
344
371
 
345
372
  .env-input-label {
346
373
  display: block;
347
374
  font-size: 14px;
375
+ font-weight: 600;
348
376
  }
349
377
 
350
378
  .env-fieldset textarea {
@@ -353,9 +381,9 @@ h1 {
353
381
  min-block-size: 12rem;
354
382
  resize: vertical;
355
383
  padding: var(--space-3);
356
- color: var(--text-strong);
384
+ color: var(--text);
357
385
  background: var(--bg);
358
- border: 1px solid var(--control-border);
386
+ border: 1px solid var(--border);
359
387
  border-radius: var(--radius-control);
360
388
  font-family: var(--font-mono);
361
389
  line-height: 1.5;
@@ -378,9 +406,9 @@ h1 {
378
406
  min-inline-size: 0;
379
407
  block-size: var(--control-height);
380
408
  padding: 0 var(--space-3);
381
- color: var(--text-strong);
409
+ color: var(--text);
382
410
  background: var(--bg);
383
- border: 1px solid var(--control-border);
411
+ border: 1px solid var(--border);
384
412
  border-radius: var(--radius-control);
385
413
  font-family: var(--font-mono);
386
414
  font-size: 14px;
@@ -388,9 +416,40 @@ h1 {
388
416
 
389
417
  .env-row[data-secret] .env-row__value { border-style: dashed; }
390
418
  .env-row__stored { display: inline-flex; align-items: center; gap: var(--space-2); font-size: 13px; }
391
- .env-row__secret { display: inline-flex; align-items: center; gap: var(--space-1); font-size: 13px; white-space: nowrap; }
419
+ .env-row__secret { display: inline-flex; align-items: center; gap: var(--space-2); min-block-size: var(--space-6); font-size: 14px; white-space: nowrap; }
420
+ /* A checkbox drawn from the palette, so every state is one we measured and none depends on the browser's own look
421
+ (a disabled native one ignores the palette and fades to about 1.5:1 on a light panel). Unchecked: the control
422
+ edge on the field colour. Checked: the action fill with its label colour for the tick. Disabled: the disabled
423
+ tokens, tick included, so a stored secret still reads as checked. */
424
+ input[type='checkbox'] {
425
+ appearance: none;
426
+ flex: none;
427
+ display: inline-grid;
428
+ place-content: center;
429
+ inline-size: 18px;
430
+ block-size: 18px;
431
+ margin: 0;
432
+ border: 2px solid var(--border);
433
+ border-radius: 4px;
434
+ background: var(--bg);
435
+ }
436
+ input[type='checkbox']:checked { background: var(--accent); border-color: var(--accent); }
437
+ input[type='checkbox']:checked::after {
438
+ content: '';
439
+ inline-size: 4px;
440
+ block-size: 9px;
441
+ margin-block-start: -2px;
442
+ border-inline-end: 2px solid var(--on-accent);
443
+ border-block-end: 2px solid var(--on-accent);
444
+ transform: rotate(40deg);
445
+ }
446
+ input[type='checkbox']:disabled, input[type='checkbox']:disabled:checked { background: var(--arb-disabled-bg); border-color: var(--arb-disabled-border); }
447
+ input[type='checkbox']:disabled:checked::after { border-color: var(--arb-disabled-fg); }
392
448
  .env-row-actions { display: flex; gap: var(--space-2); }
393
449
  .env-vault-note { color: var(--warning); }
450
+ /* Instructions are body copy, so they take the primary text colour; muted stays for metadata and hints. */
451
+ .muted.env-secret-help, .muted#environment-preset-help { color: var(--text); }
452
+ .muted.env-vault-note { color: var(--text); border-inline-start: 3px solid var(--arb-warning); padding-inline-start: var(--space-3); }
394
453
 
395
454
  @media (max-width: 560px) {
396
455
  .env-row { grid-template-columns: 1fr 1fr; }
@@ -403,8 +462,9 @@ h1 {
403
462
  gap: var(--space-3);
404
463
  }
405
464
 
406
- fieldset:disabled {
407
- opacity: 0.7;
465
+ fieldset:disabled { color: var(--arb-disabled-fg); }
466
+ fieldset:disabled input, fieldset:disabled textarea, fieldset:disabled select {
467
+ background: var(--arb-disabled-bg); color: var(--arb-disabled-fg); border-color: var(--arb-disabled-border);
408
468
  }
409
469
 
410
470
  .controls-panel {
@@ -439,10 +499,15 @@ fieldset:disabled {
439
499
  padding-inline: var(--space-3);
440
500
  }
441
501
 
442
- .env-preset-button[aria-pressed='true'] {
443
- border-color: color-mix(in oklab, var(--success) 55%, var(--border));
444
- background: color-mix(in oklab, var(--success) 22%, transparent);
502
+ .env-preset-button[aria-pressed='true'],
503
+ .button--selected {
504
+ border-color: var(--arb-selected-border);
505
+ background: var(--arb-selected-bg);
506
+ color: var(--text);
445
507
  }
508
+ /* The light theme's selected and pressed surfaces are the same colour, so a selected control needs its own
509
+ persistent marker rather than relying on the fill: a check, beside the label. */
510
+ .env-preset-button[aria-pressed='true']::before { content: '\2713'; margin-inline-end: var(--space-2); font-weight: 700; }
446
511
 
447
512
  .button {
448
513
  justify-self: start;
@@ -459,33 +524,21 @@ fieldset:disabled {
459
524
  background: transparent;
460
525
  color: var(--text);
461
526
  cursor: pointer;
462
- transition: transform 120ms ease, border-color 120ms ease, background-color 120ms ease;
527
+ transition: border-color 120ms ease, background-color 120ms ease;
463
528
  }
529
+ .button:hover:not(:disabled) { background: var(--arb-surface-hover); }
530
+ .button:active:not(:disabled) { background: var(--arb-surface-pressed); }
464
531
 
465
- .button:hover,
466
- .button:focus-visible {
467
- transform: translateY(-1px);
468
- }
469
-
470
- .button:focus-visible {
471
- outline: 2px solid var(--accent);
472
- outline-offset: 1px;
473
- }
474
532
 
475
533
  .button--primary {
476
534
  background: var(--accent);
477
535
  color: var(--on-accent);
478
536
  }
479
537
  .button--primary:hover:not(:disabled) { background: var(--accent-hover); }
480
-
481
- .button--primary:disabled {
482
- opacity: 0.55;
483
- cursor: not-allowed;
484
- transform: none;
485
- }
538
+ .button--primary:active:not(:disabled) { background: var(--accent-pressed); }
486
539
 
487
540
  .button--subtle {
488
- border-color: var(--control-border);
541
+ border-color: var(--border);
489
542
  }
490
543
 
491
544
  .button--small { min-block-size: 32px; padding: 4px 10px; font-size: 12px; }
@@ -495,43 +548,62 @@ fieldset:disabled {
495
548
  .step-log__actions form { margin: 0; }
496
549
 
497
550
  .button--danger {
498
- background: var(--danger);
499
- color: var(--bg);
551
+ background: var(--arb-error);
552
+ color: var(--arb-on-error);
500
553
  }
554
+ .button--danger:hover:not(:disabled) { background: var(--arb-error-hover); }
555
+ .button--danger:active:not(:disabled) { background: var(--arb-error-pressed); }
501
556
 
502
- .button:disabled { opacity: 0.55; cursor: not-allowed; transform: none; }
557
+ /* Disabled is its own set of colours at full opacity, never a faded version of the enabled button. */
558
+ .button:disabled {
559
+ background: var(--arb-disabled-bg);
560
+ color: var(--arb-disabled-fg);
561
+ border-color: var(--arb-disabled-border);
562
+ cursor: not-allowed;
563
+ }
503
564
 
504
- .danger-zone { border-color: var(--danger); }
505
- .danger-zone h2 { color: var(--danger); }
565
+ .danger-zone { border-color: var(--arb-error); }
566
+ .danger-zone h2 { color: var(--arb-error); }
506
567
  .danger-zone form { display: grid; justify-items: start; gap: var(--space-3); }
507
568
 
508
569
  .table-scroll { overflow-x: auto; }
509
570
  .workspace-table { inline-size: 100%; min-inline-size: 820px; border-collapse: collapse; }
510
571
  .workspace-table th,
511
- .workspace-table td { padding: 16px var(--space-4); text-align: start; vertical-align: middle; border-block-end: 1px solid var(--divider); overflow-wrap: anywhere; }
572
+ .workspace-table td { padding: 16px var(--space-4); text-align: start; vertical-align: middle; border-block-end: 1px solid var(--border); overflow-wrap: anywhere; }
512
573
  .workspace-table th:first-child,
513
574
  .workspace-table td:first-child { padding-inline-start: 0; }
514
575
  .workspace-table th:last-child,
515
576
  .workspace-table td:last-child { padding-inline-end: 0; }
516
- .workspace-table thead th { padding-block: 0 12px; color: var(--muted); font-size: 12px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; border-block-end-color: var(--border-strong); }
577
+ .workspace-table thead th { padding-block: 0 12px; color: var(--muted); font-size: 12px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; border-block-end-color: var(--border); }
517
578
  .workspace-table tbody tr:last-child > * { border-block-end: 0; }
518
579
  .workspace-table tbody th { font-family: var(--font-mono); font-size: 14px; font-weight: 600; }
519
- .workspace-table tbody th a { color: var(--text-strong); text-decoration: none; }
580
+ .workspace-table tbody th a { color: var(--text); text-decoration: none; }
520
581
  .workspace-table tbody th a:hover { color: var(--accent-hover); text-decoration: underline; }
521
- .workspace-table tbody th a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 2px; }
522
- .workspace-table td { color: var(--text-cell); }
582
+ .workspace-table td { color: var(--text); }
523
583
  .workspace-table__date { font-size: 14px; font-variant-numeric: tabular-nums; white-space: nowrap; }
524
584
  .workspace-table__date .never { color: var(--muted); }
525
585
 
526
- /* A status is a dot and a word, never colour alone. The colours come from three custom properties so a
527
- new status is one rule. Unlisted statuses fall back to the neutral pair. */
586
+ /* A status is a glyph and a word, never colour alone. One rule per status the product reports (the words
587
+ come from the server and are unchanged); the colours come from the palette's status roles.
588
+
589
+ ready success on success_bg check
590
+ error error on error_bg cross
591
+ interrupted warning on warning_bg exclamation (a run that was cut off, not one that failed)
592
+ preparing accent on a surface ring with a dot
593
+ idle steel on a surface empty ring
594
+ stopped steel on a surface filled square
595
+ sleeping steel on a surface half-filled ring
596
+ anything else neutral, empty ring
597
+
598
+ idle, stopped and sleeping are told apart by their shape and their word, not by a colour the palette has no
599
+ role for. Where there is a tinted background it is the matching *_bg; elsewhere the pill sits on a general surface. */
528
600
  .status-pill {
529
- --pill-bg: #252e41; --pill-text: #cbd5e1; --pill-dot: #94a3b8;
601
+ --pill-bg: var(--arb-elevated); --pill-text: var(--text); --pill-glyph: var(--arb-steel);
530
602
  display: inline-flex;
531
603
  align-items: center;
532
- gap: 7px;
604
+ gap: var(--space-2);
533
605
  margin: 0;
534
- padding: 3px 10px;
606
+ padding: 3px 10px 3px 8px;
535
607
  border-radius: 999px;
536
608
  background: var(--pill-bg);
537
609
  color: var(--pill-text);
@@ -540,16 +612,33 @@ fieldset:disabled {
540
612
  line-height: 1.5;
541
613
  white-space: nowrap;
542
614
  }
543
- .status-dot { inline-size: 7px; block-size: 7px; border-radius: 999px; background: var(--pill-dot); }
544
- .status-pill[data-status='sleeping'] { --pill-bg: #232c4a; --pill-text: #c7d2fe; --pill-dot: #a5b4fc; }
545
- .status-pill[data-status='idle'] { --pill-bg: #3a2e10; --pill-text: #fcd34d; --pill-dot: #fbbf24; }
546
- .status-pill[data-status='stopped'] { --pill-bg: #43191f; --pill-text: #fca5a5; --pill-dot: #f87171; }
547
- .status-pill[data-status='ready'] { --pill-bg: #12301f; --pill-text: #86efac; --pill-dot: #4ade80; }
548
- .status-pill[data-status='preparing'] { --pill-bg: #0f2f3d; --pill-text: #a5f3fc; --pill-dot: #22d3ee; }
549
- .status-pill[data-status='error'] { --pill-bg: #5a1a22; --pill-text: #fecaca; --pill-dot: #f87171; }
550
- .status-pill[data-status='interrupted'] { --pill-bg: #43250f; --pill-text: #fdba74; --pill-dot: #fb923c; }
551
- .status-pill[data-busy='true'] .status-dot { animation: workspace-pulse 1.5s infinite; }
552
- @media (prefers-reduced-motion: reduce) { .status-pill[data-busy='true'] .status-dot { animation: none; } }
615
+ .status-dot {
616
+ flex: none;
617
+ display: inline-grid;
618
+ place-items: center;
619
+ inline-size: 14px;
620
+ block-size: 14px;
621
+ box-sizing: border-box;
622
+ border-radius: 999px;
623
+ border: 2px solid var(--pill-glyph);
624
+ color: var(--pill-glyph);
625
+ font-size: 11px;
626
+ font-weight: 800;
627
+ line-height: 1;
628
+ }
629
+ .status-pill[data-status='ready'] { --pill-bg: var(--arb-success-bg); --pill-text: var(--arb-success); --pill-glyph: var(--arb-success); }
630
+ .status-pill[data-status='ready'] .status-dot { border-color: transparent; }
631
+ .status-pill[data-status='ready'] .status-dot::before { content: '\2713'; }
632
+ .status-pill[data-status='error'] { --pill-bg: var(--arb-error-bg); --pill-text: var(--arb-error); --pill-glyph: var(--arb-error); }
633
+ .status-pill[data-status='error'] .status-dot { border-color: transparent; }
634
+ .status-pill[data-status='error'] .status-dot::before { content: '\2715'; }
635
+ .status-pill[data-status='interrupted'] { --pill-bg: var(--arb-warning-bg); --pill-text: var(--arb-warning); --pill-glyph: var(--arb-warning); }
636
+ .status-pill[data-status='interrupted'] .status-dot { border-color: transparent; }
637
+ .status-pill[data-status='interrupted'] .status-dot::before { content: '!'; }
638
+ .status-pill[data-status='preparing'] { --pill-glyph: var(--arb-accent); }
639
+ .status-pill[data-status='preparing'] .status-dot { background: radial-gradient(circle, var(--pill-glyph) 0 2px, transparent 2.5px); }
640
+ .status-pill[data-status='stopped'] .status-dot { border-radius: 2px; background: var(--pill-glyph); }
641
+ .status-pill[data-status='sleeping'] .status-dot { background: linear-gradient(90deg, var(--pill-glyph) 50%, transparent 50%); }
553
642
 
554
643
  /* The create form: four fields in one wrapping row, the button on its own line below. */
555
644
  .create-fields { display: flex; flex-wrap: wrap; gap: var(--space-4); }
@@ -560,10 +649,6 @@ fieldset:disabled {
560
649
 
561
650
  ol > li + li { margin-block-start: var(--space-2); }
562
651
 
563
- .button--success {
564
- background: color-mix(in oklab, var(--success) 22%, transparent);
565
- border-color: color-mix(in oklab, var(--success) 55%, var(--border));
566
- }
567
652
 
568
653
  .muted {
569
654
  margin: 0;
@@ -583,9 +668,9 @@ ol > li + li { margin-block-start: var(--space-2); }
583
668
  margin: 0;
584
669
  padding: var(--space-3);
585
670
  border-radius: 10px;
586
- border: 1px solid var(--border);
671
+ border: 0;
587
672
  background: var(--bg);
588
- color: var(--text-cell);
673
+ color: var(--text);
589
674
  font-family: var(--font-mono);
590
675
  font-size: 12px;
591
676
  line-height: 1.4;
@@ -614,21 +699,22 @@ ol > li + li { margin-block-start: var(--space-2); }
614
699
  .log-switch a {
615
700
  color: var(--muted);
616
701
  padding: var(--space-2) var(--space-3);
617
- border: 1px solid var(--control-border);
702
+ border: 1px solid var(--border);
618
703
  border-radius: var(--radius-control);
619
704
  text-decoration: none;
620
705
  }
706
+ .log-switch a:hover:not([aria-current='page']) { background: var(--arb-surface-hover); color: var(--text); }
621
707
 
622
708
  .log-switch a[aria-current='page'] {
623
709
  color: var(--text);
624
- border-color: var(--accent);
710
+ background: var(--arb-selected-bg);
711
+ border-color: var(--arb-selected-border);
712
+ font-weight: 600;
625
713
  }
714
+ /* The tab you are on says so in words for assistive technology (aria-current) and in a marker for everyone,
715
+ because the light theme's selected fill is the same colour as its pressed fill. */
716
+ .log-switch a[aria-current='page']::before { content: '\2713'; margin-inline-end: var(--space-2); }
626
717
 
627
- .log-switch a:focus-visible,
628
- .detail-link:focus-visible {
629
- outline: 2px solid var(--accent);
630
- outline-offset: 2px;
631
- }
632
718
 
633
719
  .detail-link {
634
720
  color: var(--muted);
@@ -681,13 +767,14 @@ p:empty:not([role]):not([aria-live]) { display: none; }
681
767
 
682
768
  /* The card: the default page for a workspace. One small square in the middle of the screen. */
683
769
  .card-page { min-block-size: 100dvb; display: grid; place-items: center; padding: var(--space-6) var(--space-3); }
684
- .card { inline-size: min(100%, 26rem); display: grid; gap: var(--space-5); align-content: start; padding: var(--panel-padding); background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); }
770
+ .card { inline-size: min(100%, 26rem); display: grid; gap: var(--space-5); align-content: start; padding: var(--panel-padding); background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius); }
685
771
  .card__header { display: flex; justify-content: space-between; align-items: start; gap: var(--space-3); }
772
+ .card__links { display: flex; align-items: center; gap: var(--space-1); flex: none; }
686
773
  .card__name { display: grid; gap: var(--space-1); min-inline-size: 0; }
687
- .card__id { overflow-wrap: anywhere; font-family: var(--font-mono); font-size: 14px; font-weight: 600; color: var(--text-strong); }
774
+ .card__id { overflow-wrap: anywhere; font-family: var(--font-mono); font-size: 14px; font-weight: 600; color: var(--text); }
688
775
  .card__text { display: grid; gap: var(--space-2); }
689
776
  .card h1 { font-size: 24px; line-height: 1.25; }
690
- .card__summary { color: var(--muted); line-height: 1.5; }
777
+ .card__summary { color: var(--text); line-height: 1.5; }
691
778
  .card__actions { display: grid; gap: var(--space-3); }
692
779
  .card__actions:not(:has(*:not([hidden]))) { display: none; }
693
780
  .card__actions .button, .card__actions form, .card__start .button { inline-size: 100%; text-align: center; }
@@ -696,5 +783,23 @@ p:empty:not([role]):not([aria-live]) { display: none; }
696
783
  .card .setup-error pre { max-block-size: 8rem; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
697
784
  .card select { inline-size: 100%; }
698
785
  .icon-link { display: inline-flex; padding: var(--space-2); border-radius: var(--radius-control); color: var(--muted); }
699
- .icon-link:hover { color: var(--text); background: color-mix(in oklab, var(--accent) 12%, transparent); }
700
- .icon-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
786
+ .icon-link:hover { color: var(--text); background: var(--arb-surface-hover); }
787
+
788
+ /* A selected control keeps its fill while hovered or pressed; only an unselected one takes the hover surface. */
789
+ .env-preset-button[aria-pressed='true']:hover:not(:disabled),
790
+ .env-preset-button[aria-pressed='true']:active:not(:disabled),
791
+ .button--selected:hover:not(:disabled),
792
+ .button--selected:active:not(:disabled) { background: var(--arb-selected-bg); }
793
+
794
+ /* High-contrast and forced-colors modes replace our colours with the system's. Anything drawn only with a
795
+ background colour would then vanish, so the shapes that carry meaning get a real border. Text and focus
796
+ rings keep working because they were never colour-only. */
797
+ @media (forced-colors: active) {
798
+ .button, .badge, .status-pill, .progress-bar, .env-preset-button { border: 1px solid ButtonText; }
799
+ .progress-bar__fill { background: Highlight; }
800
+ .step-log__status, .status-dot { forced-color-adjust: none; border-color: ButtonText; color: ButtonText; }
801
+ .env-preset-button[aria-pressed='true'], .button--selected { border-width: 2px; }
802
+ :focus-visible { outline-color: Highlight; }
803
+ .lead[data-state='error'], .lead[data-state='interrupted'], .setup-error, .step-log[data-step-state='failed'] { border-inline-start-color: ButtonText; }
804
+ .lead h2::before { forced-color-adjust: none; color: ButtonText; }
805
+ }
@@ -1,9 +1,9 @@
1
- <main class="dashboard" id="workspace" data-id="<%= @id %>" data-status="<%= field('status') %>" data-started="<%= field('prepare_started_at') %>" data-completed="<%= field('completed_at') %>" data-busy="<%= @presentation['busy'] %>">
1
+ <main class="dashboard dashboard--narrow" id="workspace" data-id="<%= @id %>" data-status="<%= field('status') %>" data-started="<%= field('prepare_started_at') %>" data-completed="<%= field('completed_at') %>" data-busy="<%= @presentation['busy'] %>">
2
2
  <header class="dashboard__header">
3
3
  <div class="dashboard__title"><p class="eyebrow">Managed workspace</p><h1 class="mono-title"><%= @id %></h1></div>
4
4
  <div class="header-actions"><a class="button button--subtle" href="/workspaces/<%= @id %>">Back to workspace</a><a class="button button--subtle" href="/workspaces">All workspaces</a></div>
5
5
  </header>
6
- <section class="status-panel lead" id="lead" data-section="lead" aria-labelledby="status" <%= 'hidden' unless section?('lead') %>>
6
+ <section class="status-panel lead" id="lead" data-section="lead" data-state="<%= escape(@presentation['state']) %>" aria-labelledby="status" <%= 'hidden' unless section?('lead') %>>
7
7
  <div class="lead__text">
8
8
  <h2 id="status" role="status"><%= escape(@presentation['headline']) %></h2>
9
9
  <p id="summary" class="lead__summary"><%= escape(@presentation['summary']) %></p>
@@ -40,6 +40,12 @@ module Workspaces
40
40
 
41
41
  PREVIEW_STATUS_KEYS = %w(state busy ready headline summary progress lead_action current_preset sleep open).freeze
42
42
  PREVIEW_ERROR = 'Setup failed. Open settings for the full log.'.freeze
43
+ # The Arb palette (dashboard/arb-palette.css) defines its colours per theme, selected by data-theme on the root
44
+ # element. The theme follows the visitor's operating-system setting, with dark when there is none. A tiny script
45
+ # in the head sets it before anything paints, so a light-mode visitor never sees a dark flash; a page without
46
+ # script stays dark, which the palette and the stylesheet are both tested in.
47
+ THEME_SCRIPT = 'document.documentElement.dataset.theme=' \
48
+ "window.matchMedia&&window.matchMedia('(prefers-color-scheme: light)').matches?'light':'dark'".freeze
43
49
 
44
50
  # What the card polls for on a preview host, where the workspace's own pages share the origin.
45
51
  # Only what the card draws: no log, no steps, no environment and no error text.
@@ -58,18 +64,15 @@ module Workspaces
58
64
  new({}).render_checkout_authentication_error(path)
59
65
  end
60
66
 
67
+ # The Arb Previews icon from the Arb design system (arb-previews/favicon.svg, unchanged), tuned for 16 pixels.
68
+ # It carries no environment signal: the old icon was blue on localhost and gold when hosted, and the kit
69
+ # forbids recolouring or badging its marks, so the page itself (the address bar, the host in the URL) is how
70
+ # a localhost dashboard is told from a hosted one.
71
+ # The Arb Previews icon, from the design system (arb-previews/favicon.svg, copied unchanged). It is the 16 px
72
+ # tuned vector, so it is served as it is: the kit forbids recolouring or badging it, which means the tab no
73
+ # longer shows whether this dashboard is the local one or a hosted one.
61
74
  def self.favicon
62
- local = Config.public_uri.host.match?(/(?:\A|\.)localhost\z/i)
63
- color = local ? '#4f8dff' : '#f1bb53'
64
- <<~SVG
65
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Workspaces">
66
- <rect x="3" y="3" width="58" height="58" rx="13" fill="#0f1524"/>
67
- <rect x="11" y="13" width="42" height="38" rx="5" fill="none" stroke="#{color}" stroke-width="4"/>
68
- <path d="M11 25h42M32 25v26" fill="none" stroke="#{color}" stroke-width="4"/>
69
- <circle cx="18" cy="19" r="2" fill="#{color}"/>
70
- <circle cx="25" cy="19" r="2" fill="#{color}"/>
71
- </svg>
72
- SVG
75
+ File.read(File.join(__dir__, 'dashboard/arb-previews-favicon.svg'), encoding: 'UTF-8')
73
76
  end
74
77
 
75
78
  def initialize(snapshot, preview: false)
@@ -110,11 +113,14 @@ module Workspaces
110
113
  end
111
114
 
112
115
  def document(title, body)
113
- css = File.read(File.join(__dir__, 'dashboard/styles.css'), encoding: 'UTF-8')
116
+ palette = File.read(File.join(__dir__, 'dashboard/arb-palette.css'), encoding: 'UTF-8')
117
+ css = palette + File.read(File.join(__dir__, 'dashboard/styles.css'), encoding: 'UTF-8')
114
118
  script = File.read(File.join(__dir__, 'dashboard/app.js'), encoding: 'UTF-8') +
115
119
  File.read(File.join(__dir__, 'dashboard/environment.js'), encoding: 'UTF-8')
116
- '<!doctype html><html lang="en"><head><meta charset="utf-8">' \
120
+ '<!doctype html><html lang="en" data-theme="dark"><head><meta charset="utf-8">' \
117
121
  '<meta name="viewport" content="width=device-width, initial-scale=1">' \
122
+ '<meta name="color-scheme" content="dark light">' \
123
+ "<script>#{THEME_SCRIPT}</script>" \
118
124
  '<link rel="icon" type="image/svg+xml" href="/favicon.svg">' \
119
125
  "<title>#{escape(title)}</title><style>#{css}</style></head>" \
120
126
  "<body>#{body}<script>#{script}</script></body></html>"
@@ -319,6 +325,11 @@ module Workspaces
319
325
  @preview ? Config.settings_url(@id) : "/workspaces/#{@id}/settings"
320
326
  end
321
327
 
328
+ # Back to the list of every workspace, from the card.
329
+ def index_href
330
+ @preview ? Config.index_url : '/workspaces'
331
+ end
332
+
322
333
  # Where the card's start form posts. A shared card with presets saves the chosen one through the
323
334
  # environment form; a preview card starts through its own origin.
324
335
  def start_form_action
@@ -1,3 +1,3 @@
1
1
  module Workspaces
2
- VERSION = '0.1.21'
2
+ VERSION = '0.1.22'
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: workspaces
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.21
4
+ version: 0.1.22
5
5
  platform: ruby
6
6
  authors:
7
7
  - Workspaces contributors
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-11-03 00:00:00.000000000 Z
11
+ date: 2026-11-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -79,6 +79,8 @@ files:
79
79
  - lib/workspaces/config.rb
80
80
  - lib/workspaces/coordinator.rb
81
81
  - lib/workspaces/dashboard/app.js
82
+ - lib/workspaces/dashboard/arb-palette.css
83
+ - lib/workspaces/dashboard/arb-previews-favicon.svg
82
84
  - lib/workspaces/dashboard/card.html.erb
83
85
  - lib/workspaces/dashboard/checkout_authentication_error.html.erb
84
86
  - lib/workspaces/dashboard/environment.js