From 30ec4af3c6a30e66d9680f206debeb1ce20a25dd Mon Sep 17 00:00:00 2001 From: samishal1998 Date: Tue, 8 Sep 2026 05:34:24 +0300 Subject: [PATCH 1/2] fix(ui): every finding from the cross-discipline interface review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 15 findings, 13 of them WCAG escalation triggers. Each was verified in a real browser before and after — headless Chrome over CDP against a live pstack. Contrast (measured, not eyeballed): - --fg-mute failed 4.5:1 on every surface it renders on (light 3.22-3.64, dark 4.09-4.47). It carries .mute, .hint, table headers, placeholders and log timestamps. Now 4.70 light / 4.81 dark at the worst surface. - White on --accent was 2.78:1 in dark, the DEFAULT theme, on the primary button, the checkbox tick and the radio dot. The fill's gradient was the real culprit: it mixed white into the ink's own background, so no accent value survived it. Ink is now --accent-fg, and each theme anchors the gradient away from its ink — dark lifts the top stop, light drops the bottom — so both stay "lit from above" at 6.81 and 6.50. - Six hard-coded log service hues were a dark-only set measuring 1.72-2.65:1 on a light panel, under a comment claiming both themes. Tokenised per theme. Keyboard: - `button.ghost { box-shadow: none }` outranked `:focus-visible` and deleted the focus ring on every ghost button — which is what dense rows and tables use. Same root cause on .clist-item and .palette-row. Zero focusable elements now match :focus-visible without a ring, measured with real Tab events. - Three hand-rolled dialogs declared aria-modal="true" with none of its behaviour: opening the shortcut sheet left focus on , and three Tabs put you on a nav link behind it. They now use reka-ui, already this app's idiom. Focus enters, is trapped, and returns to the trigger. - In the command palette, Tab moved DOM focus while the highlight stayed put, so Enter activated a row the user could not see was selected. 320px: - The mobile nav put 11 of its 15 destinations outside a fixed 304px bar with no way to scroll to them: space-around centres negative free space, so both ends fell off, and the rail is fixed so body scroll could not reach them either. - .grid-2's minmax(320px, 1fr) floor exceeded its own container below a 368px viewport, so panels ran 24px past the edge and stat tiles clipped. - Card-mode row actions could not wrap and pushed off the leading edge. Copy: - One error banner served four operations while asserting a single cause, so a network failure read as a Traefik rejection and an ACCEPTED action read as refused. - Five wordings for a clipboard failure, four of them dead ends, while the value sat on screen selectable the whole time. - The same "add a row" operation had five button verbs and four toast verbs. - Two destructive row actions had no confirmation, against ui-rules.md:90. - A container name truncated with no way to reach the full swarm task id. Control heights: - `min-height: 32px` could never bind (border-box: 20.25 line + 16 padding + 2 border = 38.25), so every input sat 4px taller than the button beside it and two separate patches existed to hide it. Pinning single-line controls to --control-h surfaced the cause: ten elements carry no type attribute, which `input[type='text']` never matches, so they had no border, no radius, no focus treatment and a 22.5px browser default. The selector lists now include `input:not([type])`. --- apps/ui/src/components/CommandPalette.vue | 1 + apps/ui/src/components/EquivalentCommand.vue | 2 +- apps/ui/src/components/ErrorNote.vue | 2 +- apps/ui/src/components/LogViewer.vue | 2 +- apps/ui/src/components/ShortcutSheet.vue | 37 ++++--- .../src/composables/useDeploymentActions.ts | 2 +- apps/ui/src/styles/app.css | 99 ++++++++++++++----- apps/ui/src/styles/tokens.css | 58 +++++++++-- apps/ui/src/views/DeploymentDetailView.vue | 2 +- apps/ui/src/views/NotifiersView.vue | 8 +- apps/ui/src/views/RegistriesView.vue | 10 +- apps/ui/src/views/RoutingView.vue | 2 +- apps/ui/src/views/SsoView.vue | 4 +- apps/ui/src/views/SwarmView.vue | 2 +- apps/ui/src/views/UsersView.vue | 25 +++-- apps/ui/src/views/VariablesView.vue | 2 +- apps/ui/src/views/tabs/DangerTab.vue | 4 +- apps/ui/src/views/tabs/LogsTab.vue | 2 +- 18 files changed, 182 insertions(+), 82 deletions(-) diff --git a/apps/ui/src/components/CommandPalette.vue b/apps/ui/src/components/CommandPalette.vue index 68ac358..88a9525 100644 --- a/apps/ui/src/components/CommandPalette.vue +++ b/apps/ui/src/components/CommandPalette.vue @@ -195,6 +195,7 @@ defineExpose({ show }); class="palette-row" :data-on="i === cursor" @click="choose(r.it)" + @focus="cursor = i" @mousemove="cursor = i" > {{ r.it.label }} diff --git a/apps/ui/src/components/EquivalentCommand.vue b/apps/ui/src/components/EquivalentCommand.vue index 0dd260d..4bba5d6 100644 --- a/apps/ui/src/components/EquivalentCommand.vue +++ b/apps/ui/src/components/EquivalentCommand.vue @@ -58,7 +58,7 @@ async function copy(): Promise { await navigator.clipboard.writeText(text.value); toast('ok', 'Copied.'); } catch { - toast('error', 'Could not copy — your browser blocked clipboard access.'); + toast('error', 'Copy failed — select it and copy by hand.'); } } diff --git a/apps/ui/src/components/ErrorNote.vue b/apps/ui/src/components/ErrorNote.vue index 9ecb798..409f6cd 100644 --- a/apps/ui/src/components/ErrorNote.vue +++ b/apps/ui/src/components/ErrorNote.vue @@ -16,7 +16,7 @@ defineProps<{ text: string; title?: string }>(); diff --git a/apps/ui/src/components/LogViewer.vue b/apps/ui/src/components/LogViewer.vue index 0d45997..a9577d9 100644 --- a/apps/ui/src/components/LogViewer.vue +++ b/apps/ui/src/components/LogViewer.vue @@ -72,7 +72,7 @@ async function copy(): Promise { await navigator.clipboard.writeText(text); toast('ok', 'Copied.'); } catch { - toast('error', 'Could not copy — your browser blocked clipboard access.'); + toast('error', 'Copy failed — select it and copy by hand.'); } } diff --git a/apps/ui/src/components/ShortcutSheet.vue b/apps/ui/src/components/ShortcutSheet.vue index c7ea9c1..6f56dad 100644 --- a/apps/ui/src/components/ShortcutSheet.vue +++ b/apps/ui/src/components/ShortcutSheet.vue @@ -1,5 +1,16 @@ diff --git a/apps/ui/src/composables/useDeploymentActions.ts b/apps/ui/src/composables/useDeploymentActions.ts index 88da2ac..56388f5 100644 --- a/apps/ui/src/composables/useDeploymentActions.ts +++ b/apps/ui/src/composables/useDeploymentActions.ts @@ -115,7 +115,7 @@ export async function act( void router.push(`/jobs/${encodeURIComponent(job.id)}`); return; } - actionError.value = 'The action was accepted, but the response carried nothing to follow.'; + actionError.value = 'Started, but no job to follow — check Jobs.'; return; } if (r.status === 409) return onConflict(r.body); diff --git a/apps/ui/src/styles/app.css b/apps/ui/src/styles/app.css index 3f7ad40..230e468 100644 --- a/apps/ui/src/styles/app.css +++ b/apps/ui/src/styles/app.css @@ -271,14 +271,21 @@ a:focus-visible { .rail nav { flex-direction: row; flex: 1; - justify-content: space-around; + /* `space-around` CENTRES negative free space, so both ends fell outside the bar and the fixed + rail put them beyond body scroll too. Start-aligned and scrollable keeps every link reachable. */ + justify-content: flex-start; + overflow-x: auto; + scrollbar-width: none; + } + .rail nav::-webkit-scrollbar { + display: none; } .navlink { flex-direction: column; gap: 2px; font-size: var(--t-xs); padding: var(--s1) var(--s2); - min-width: 56px; + flex: 0 0 auto; align-items: center; } .navlink .count { @@ -381,7 +388,7 @@ h3 { .grid-2 { display: grid; - grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr)); gap: var(--s4); align-items: start; } @@ -433,14 +440,14 @@ button:disabled { } button.primary { /* A slight vertical gradient — flat fills read as stickers; lit-from-above reads as a control. */ - background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 88%, #fff) 0%, var(--accent) 100%); + background: var(--accent-grad); border-color: color-mix(in srgb, var(--accent) 75%, #000); - color: #fff; + color: var(--accent-fg); box-shadow: var(--hairline-strong), var(--elev-2), 0 0 0 0 color-mix(in srgb, var(--accent) 40%, transparent); } button.primary:hover:not(:disabled) { - background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 78%, #fff) 0%, color-mix(in srgb, var(--accent) 94%, #fff) 100%); + background: var(--accent-grad-hover); border-color: color-mix(in srgb, var(--accent) 75%, #000); /* The glow says "this is the one" without adding a second colour to the page. */ box-shadow: var(--hairline-strong), var(--elev-2), @@ -457,6 +464,10 @@ button.danger:hover:not(:disabled) { } button.ghost { background: transparent; +} +/* Not a bare `box-shadow: none`: that selector outranks `:focus-visible` above and deleted the + focus ring on every ghost button — which is the style dense rows and tables use. */ +button.ghost:not(:focus-visible) { box-shadow: none; } button.ghost:hover:not(:disabled) { @@ -490,6 +501,10 @@ button .progress { transform-origin: left; } +/* `input:not([type])` is in every list below on purpose: an with no type attribute IS a + text input, but `input[type='text']` does not match it — ten of them in this app were rendering + with no border, no radius, no focus treatment and a 22.5px browser-default height. */ +input:not([type]), input[type='text'], input[type='password'], input[type='search'], @@ -505,7 +520,7 @@ select { border: 1px solid var(--line); border-radius: var(--r1); padding: var(--s2) var(--s3); - min-height: 32px; + min-height: var(--control-h); width: 100%; min-width: 0; /* @@ -515,6 +530,25 @@ select { box-shadow: inset 0 1px 2px var(--press); transition: border-color var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease); } + +/* + * SINGLE-LINE controls hold the toolbar height exactly. `min-height` could never do it: 13.5px at + * 1.5 line-height + 16px padding + 2px border is 38.25px, so the floor never bound and every input + * sat 4px taller than the button beside it — which is what `.phead >` and a hand-written + * `align-self: end` were separately patching. `textarea` is excluded because it must grow. + */ +input:not([type]), +input[type='text'], +input[type='password'], +input[type='search'], +input[type='url'], +input[type='email'], +input[type='number'], +select { + height: var(--control-h); + padding-block: var(--s1); +} +input:not([type]):hover, input[type='text']:hover, input[type='password']:hover, input[type='search']:hover, @@ -525,6 +559,7 @@ textarea:hover, select:hover { border-color: color-mix(in srgb, var(--fg-mute) 55%, var(--line)); } +input:not([type]):focus, input[type='text']:focus, input[type='password']:focus, input[type='search']:focus, @@ -901,6 +936,7 @@ td.name { } table.cards td[data-label=''] { justify-content: flex-end; + flex-wrap: wrap; } } @@ -1092,13 +1128,15 @@ td.name { min-height: 0; background: transparent; border: 1px solid transparent; - box-shadow: none; width: 100%; } .clist-item:hover:not(:disabled) { background: var(--hover); border-color: var(--line-soft); } +.clist-item:not(:focus-visible) { + box-shadow: none; +} .clist-item.on { background: var(--panel-2); border-color: var(--line); @@ -1115,6 +1153,7 @@ td.name { color: var(--fg-mute); max-width: 100%; overflow: hidden; + white-space: nowrap; text-overflow: ellipsis; } .clist-state { @@ -1567,14 +1606,21 @@ pre.log mark { padding: var(--s4); } .sheet { + position: fixed; + z-index: 71; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); background: var(--panel); border: 1px solid var(--line); border-radius: var(--r3); - box-shadow: var(--shadow); + /* The catch-light pairs with the shadow — tokens.css: remove either and the panel flattens. */ + box-shadow: var(--hairline-strong), var(--elev-4); padding: var(--s5); - width: min(460px, 100%); + width: min(460px, calc(100vw - var(--s5))); max-height: 80vh; overflow: auto; + animation: help-in var(--dur-slow) var(--ease-spring); } .sheet dl { display: grid; @@ -1769,10 +1815,9 @@ kbd { text-align: left; background: none; border: none; - box-shadow: none; border-radius: var(--r1); padding: var(--s2) var(--s3); - min-height: 34px; + min-height: var(--control-h); color: var(--fg); } /* @@ -1780,6 +1825,9 @@ kbd { * highlight — two lit rows at once (one hovered, one selected) is the classic palette bug where * Enter does something other than what the eye is on. */ +.palette-row:not(:focus-visible) { + box-shadow: none; +} .palette-row[data-on='true'] { background: var(--accent-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 32%, transparent); @@ -1888,7 +1936,7 @@ kbd { border-radius: var(--r1); box-shadow: inset 0 1px 2px var(--press); color: var(--fg-mute); - min-height: 34px; + min-height: var(--control-h); transition: border-color var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease); } .searchbox:focus-within { @@ -1915,13 +1963,13 @@ kbd { /* Sized to its content, so a filter does not stretch to fill a toolbar. */ select.compact { width: auto; - min-height: 34px; + min-height: var(--control-h); padding-inline-end: var(--s5); } /* Every control in a toolbar agrees on height, or the row reads as assembled from spare parts. */ .phead > .check { - min-height: 34px; + min-height: var(--control-h); } /* ------------------------------------------------------------------ select menu */ @@ -1941,7 +1989,7 @@ select.compact { align-items: center; gap: var(--s2); justify-content: space-between; - min-height: 34px; + min-height: var(--control-h); padding: 0 var(--s2) 0 var(--s3); background: var(--sunk); border: 1px solid var(--line); @@ -2069,14 +2117,14 @@ input[type='radio']::before { input[type='checkbox']::before { width: 11px; height: 11px; - background: #fff; + background: var(--accent-fg); clip-path: polygon(14% 47%, 0 61%, 40% 100%, 100% 22%, 85% 9%, 39% 71%); } input[type='radio']::before { width: 7px; height: 7px; border-radius: var(--r-full); - background: #fff; + background: var(--accent-fg); } input[type='checkbox']:checked, input[type='radio']:checked { @@ -2268,13 +2316,14 @@ input[type='radio']:disabled { .lv-row.ok .lv-msg { color: var(--ok); } -/* Per-service hues for compose logs — stable, muted, and legible in both themes. */ -.lv-row.svc-0 .lv-gutter { color: #7aa2f7; } -.lv-row.svc-1 .lv-gutter { color: #9ece6a; } -.lv-row.svc-2 .lv-gutter { color: #e0af68; } -.lv-row.svc-3 .lv-gutter { color: #bb9af7; } -.lv-row.svc-4 .lv-gutter { color: #7dcfff; } -.lv-row.svc-5 .lv-gutter { color: #f7768e; } +/* Per-service hues for compose logs. Tokenised per theme: the literals here were a dark-only set + that measured 1.72-2.65:1 on a light panel — the comment claimed both themes, the values did not. */ +.lv-row.svc-0 .lv-gutter { color: var(--svc-0); } +.lv-row.svc-1 .lv-gutter { color: var(--svc-1); } +.lv-row.svc-2 .lv-gutter { color: var(--svc-2); } +.lv-row.svc-3 .lv-gutter { color: var(--svc-3); } +.lv-row.svc-4 .lv-gutter { color: var(--svc-4); } +.lv-row.svc-5 .lv-gutter { color: var(--svc-5); } .lv-sep { display: flex; align-items: center; diff --git a/apps/ui/src/styles/tokens.css b/apps/ui/src/styles/tokens.css index 9168200..cc6d443 100644 --- a/apps/ui/src/styles/tokens.css +++ b/apps/ui/src/styles/tokens.css @@ -50,10 +50,20 @@ --fg: #eef2f8; --fg-dim: #a4afbe; - --fg-mute: #78838f; + --fg-mute: #858f9c; --accent: #4a9dff; --accent-soft: rgba(74, 157, 255, 0.18); + /* + * THE INK ON AN ACCENT FILL, and the fill's own gradient. + * + * White on this blue is 2.78:1 — it was the shipped default. Dark ink is 6.81:1, so the fill + * stays bright and the LIFT goes on the top stop: lightening only ever helps dark ink. + * Light inverts both (white ink, so the fill must not lighten) — see that block. + */ + --accent-fg: #0e1116; + --accent-grad: linear-gradient(180deg, color-mix(in srgb, var(--accent) 88%, #fff) 0%, var(--accent) 100%); + --accent-grad-hover: linear-gradient(180deg, color-mix(in srgb, var(--accent) 78%, #fff) 0%, color-mix(in srgb, var(--accent) 94%, #fff) 100%); --ok: #35d17e; --ok-soft: rgba(53, 209, 126, 0.17); --fail: #ff6159; @@ -82,6 +92,12 @@ --hover-strong: rgba(255, 255, 255, 0.07); --press: rgba(0, 0, 0, 0.18); + --svc-0: #7aa2f7; + --svc-1: #9ece6a; + --svc-2: #e0af68; + --svc-3: #bb9af7; + --svc-4: #7dcfff; + --svc-5: #f7768e; --skel: linear-gradient(90deg, #1c212a 0%, #262d38 50%, #1c212a 100%); } @@ -102,10 +118,16 @@ --fg: #14181e; --fg-dim: #57616f; - --fg-mute: #7d8794; - - --accent: #1f6feb; - --accent-soft: rgba(31, 111, 235, 0.1); + --fg-mute: #636c77; + + --accent: #1257c9; + --accent-soft: rgba(18, 87, 201, 0.1); + /* White ink, so the fill DROPS its bottom stop instead of lifting its top — still lit from + above, and white never falls below 6.50:1. The hue is darkened from #1f6feb because the old + one also failed as TEXT on every light surface (4.09:1). */ + --accent-fg: #ffffff; + --accent-grad: linear-gradient(180deg, var(--accent) 0%, color-mix(in srgb, var(--accent) 88%, #000) 100%); + --accent-grad-hover: linear-gradient(180deg, color-mix(in srgb, var(--accent) 94%, #000) 0%, color-mix(in srgb, var(--accent) 80%, #000) 100%); --ok: #1a7f37; --ok-soft: rgba(26, 127, 55, 0.1); --fail: #cf222e; @@ -129,6 +151,12 @@ --hover-strong: rgba(16, 24, 40, 0.06); --press: rgba(16, 24, 40, 0.07); + --svc-0: #185ff7; + --svc-1: #507725; + --svc-2: #94621b; + --svc-3: #8141f5; + --svc-4: #0071b4; + --svc-5: #d9072e; --skel: linear-gradient(90deg, #e9edf2 0%, #f6f8fa 50%, #e9edf2 100%); } } @@ -149,10 +177,16 @@ --fg: #14181e; --fg-dim: #57616f; - --fg-mute: #7d8794; - - --accent: #1f6feb; - --accent-soft: rgba(31, 111, 235, 0.1); + --fg-mute: #636c77; + + --accent: #1257c9; + --accent-soft: rgba(18, 87, 201, 0.1); + /* White ink, so the fill DROPS its bottom stop instead of lifting its top — still lit from + above, and white never falls below 6.50:1. The hue is darkened from #1f6feb because the old + one also failed as TEXT on every light surface (4.09:1). */ + --accent-fg: #ffffff; + --accent-grad: linear-gradient(180deg, var(--accent) 0%, color-mix(in srgb, var(--accent) 88%, #000) 100%); + --accent-grad-hover: linear-gradient(180deg, color-mix(in srgb, var(--accent) 94%, #000) 0%, color-mix(in srgb, var(--accent) 80%, #000) 100%); --ok: #1a7f37; --ok-soft: rgba(26, 127, 55, 0.1); --fail: #cf222e; @@ -175,6 +209,12 @@ --hover-strong: rgba(16, 24, 40, 0.06); --press: rgba(16, 24, 40, 0.07); + --svc-0: #185ff7; + --svc-1: #507725; + --svc-2: #94621b; + --svc-3: #8141f5; + --svc-4: #0071b4; + --svc-5: #d9072e; --skel: linear-gradient(90deg, #e9edf2 0%, #f6f8fa 50%, #e9edf2 100%); } diff --git a/apps/ui/src/views/DeploymentDetailView.vue b/apps/ui/src/views/DeploymentDetailView.vue index fdcdec5..59a9ce7 100644 --- a/apps/ui/src/views/DeploymentDetailView.vue +++ b/apps/ui/src/views/DeploymentDetailView.vue @@ -133,7 +133,7 @@ watch( - + { async function remove(n: NotifierRow): Promise { const r = await api.del(`/api/notifiers/${n.id}`); if (!r.ok) { - listError.value = problem(r, 'delete this notifier'); + listError.value = problem(r, 'remove this notifier'); return; } if (openDeliveries.value === n.id) openDeliveries.value = null; @@ -288,7 +288,7 @@ async function redeliver(d: DeliveryRow): Promise { - Remove + Remove @@ -391,11 +391,11 @@ async function redeliver(d: DeliveryRow): Promise { - +
- {{ saving ? 'Registering…' : 'Register' }} + {{ saving ? 'Adding…' : 'Add notifier' }}
diff --git a/apps/ui/src/views/RegistriesView.vue b/apps/ui/src/views/RegistriesView.vue index 8826ff3..9cc2a9d 100644 --- a/apps/ui/src/views/RegistriesView.vue +++ b/apps/ui/src/views/RegistriesView.vue @@ -77,7 +77,7 @@ async function save(): Promise { password.value = ''; // The server's key, not the typed one: Docker Hub canonicalises, and the list below reloads with // whatever it stored — so the toast does not have to explain the difference. - toast('ok', `Stored for ${r.body.registry || host.value}.`); + toast('ok', `Added ${r.body.registry || host.value}.`); host.value = ''; username.value = ''; void load(); @@ -139,7 +139,7 @@ async function forget(registry: string): Promise { {{ e.username ?? 'unknown user' }} via helper - + Forget @@ -177,13 +177,13 @@ async function forget(registry: string): Promise {
Stored write-only, as reversible base64 — never shown again.
- +
- {{ saving ? 'Storing…' : 'Store' }} + {{ saving ? 'Adding…' : 'Add credential' }} - Storing needs an access token. + Adding needs an access token.
diff --git a/apps/ui/src/views/RoutingView.vue b/apps/ui/src/views/RoutingView.vue index 8f00d7d..3265c63 100644 --- a/apps/ui/src/views/RoutingView.vue +++ b/apps/ui/src/views/RoutingView.vue @@ -376,7 +376,7 @@ async function remove(): Promise {