From 99d0da5fdb182563f6182c2ce35353c373f36fff Mon Sep 17 00:00:00 2001 From: chadbyte Date: Fri, 18 Sep 2026 09:21:40 +1200 Subject: [PATCH] fix(permissions): refine chip layout and loading states Balance Ask, Auto and Skip segments across standalone and split headers. Use neutral theme surfaces and centered loading without layout shifts, preserve accessible names, and restore tooltips after capability changes. --- lib/public/css/overlays.css | 43 +++++++++++----------- lib/public/css/pane.css | 28 ++++++++------ lib/public/index.html | 6 +-- lib/public/modules/permission-control.js | 10 ++--- test/fixtures/permission-mode-browser.html | 31 +++++++++++++--- test/permission-mode-ui.test.js | 36 +++++++++++++----- 6 files changed, 99 insertions(+), 55 deletions(-) diff --git a/lib/public/css/overlays.css b/lib/public/css/overlays.css index 9a0bd89c..96353019 100644 --- a/lib/public/css/overlays.css +++ b/lib/public/css/overlays.css @@ -518,7 +518,7 @@ button.top-bar-pill.pill-accent:hover { background: color-mix(in srgb, var(--acc .popover-copy:hover { color: var(--text); border-color: var(--text-dimmer); } .popover-copy.copied { color: var(--success); border-color: var(--success); } -/* Skip permissions pill */ +/* Quiet native toolbar control for session permission mode. */ .session-permission-control { display: inline-flex; align-items: center; @@ -535,35 +535,40 @@ button.top-bar-pill.pill-accent:hover { background: color-mix(in srgb, var(--acc flex: 0 0 auto; gap: 1px; padding: 2px; - border: 1px solid color-mix(in srgb, var(--border, #45484f) 88%, transparent); - border-radius: 10px; - background: color-mix(in srgb, var(--surface-raised, rgba(0,0,0,.18)) 86%, transparent); - box-shadow: inset 0 1px 0 rgba(255,255,255,.04), 0 2px 8px rgba(0,0,0,.12); + border: 1px solid color-mix(in srgb, var(--border) 82%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--bg-alt) 72%, transparent); } .session-permission-segmented button { position: relative; - flex: 0 0 auto; + flex: 0 0 48px; + width: 48px; + height: 22px; border: 0; border-radius: 7px; - padding: 4px 9px; - color: var(--text-muted, inherit); + padding: 3px 4px; + color: var(--text-secondary); background: transparent; font: inherit; + font-weight: 500; font-size: 11px; line-height: 1; white-space: nowrap; cursor: pointer; - transition: color .15s ease, background .15s ease, box-shadow .15s ease; -} -.session-permission-segmented button:hover:not(:disabled) { color: var(--text, #fff); background: rgba(255,255,255,.07); } -.session-permission-segmented button:focus-visible { outline: 2px solid var(--accent, #6f8cff); outline-offset: 1px; z-index: 1; } -.session-permission-segmented button.active { background: var(--accent, #6f8cff); color: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.2); } -.session-permission-segmented button:disabled { cursor: not-allowed; opacity: .5; } + transition: color .15s ease, background .15s ease, box-shadow .15s ease, border-color .15s ease; +} +.session-permission-segmented button:hover:not(:disabled) { color: var(--text); background: color-mix(in srgb, var(--bg) 76%, transparent); } +.session-permission-segmented button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; z-index: 1; } +.session-permission-segmented button.active { border: 1px solid var(--border); background: var(--input-bg); color: var(--text); font-weight: var(--font-weight-heading, 600); box-shadow: 0 1px 2px rgba(var(--shadow-rgb), .12); } +:root.light-theme .session-permission-segmented button.active { background: var(--bg); } +.session-permission-segmented button:disabled { cursor: not-allowed; } +.session-permission-control:not(.permission-pending) .session-permission-segmented button:disabled { color: var(--text-muted); opacity: .65; } .session-permission-control.permission-pending .session-permission-segmented button:disabled { cursor: wait; } -.session-permission-segmented button.pending { color: #fff; } -.permission-spinner { display: inline-block; visibility: hidden; width: 10px; height: 10px; margin-left: 4px; vertical-align: -1px; border: 1.5px solid currentColor; border-right-color: transparent; border-radius: 50%; } +.session-permission-segmented button.pending { color: var(--text); background: color-mix(in srgb, var(--bg) 42%, transparent); } +.session-permission-segmented button.pending .permission-label { visibility: hidden; } +.permission-spinner { position: absolute; inset: 50% auto auto 50%; display: block; visibility: hidden; width: 10px; height: 10px; transform: translate(-50%, -50%); border: 1.5px solid currentColor; border-right-color: transparent; border-radius: 50%; } .session-permission-segmented button.pending .permission-spinner { visibility: visible; animation: permission-spin .7s linear infinite; } -@keyframes permission-spin { to { transform: rotate(360deg); } } +@keyframes permission-spin { to { transform: translate(-50%, -50%) rotate(360deg); } } .session-permission-status { flex: 0 1 auto; min-width: 0; @@ -592,12 +597,8 @@ button.top-bar-pill.pill-accent:hover { background: color-mix(in srgb, var(--acc } .permission-runtime-fallback .session-permission-status { color: var(--warning, #d49a55); } .permission-globally-forced .session-permission-status { color: var(--warning, #d49a55); } -.permission-label-short { display: none; } @media (max-width: 760px) { - .permission-label-long { display: none; } - .permission-label-short { display: inline; } .session-permission-control { column-gap: 4px; } - .session-permission-segmented button { padding-inline: 7px; } .session-permission-status { max-width: 132px; font-size: 9px; diff --git a/lib/public/css/pane.css b/lib/public/css/pane.css index 94a7d5a1..ed570aa2 100644 --- a/lib/public/css/pane.css +++ b/lib/public/css/pane.css @@ -365,31 +365,35 @@ body.pane-mode #input-area { line-height: 1; } -/* Header-scoped (two-class) selectors: pane.css loads BEFORE menus.css in - style.css, so the base .session-full-access rules would win a specificity - tie and keep the pill at its 22px title-bar size inside the 24px pane - header. Scope under .split-pane-header to outrank them. */ +/* Header-scoped selectors keep the compact permission control clear of the + title and close affordance while preserving the shared header rhythm. */ +.split-pane-header { + height: 28px; + min-height: 28px; +} + .split-pane-header .split-pane-full-access { - height: 22px; + height: 24px; align-self: center; margin-left: 0; padding: 0; gap: 4px; - font-size: 10px; + font-size: 11px; letter-spacing: 0.02em; flex-wrap: nowrap; flex: 0 0 auto; - overflow: hidden; } .split-pane-header .session-permission-segmented button { - min-height: 18px; - padding: 3px 6px; - font-size: 10px; + min-height: 20px; + height: 20px; + font-size: 11px; } -.split-pane-header .permission-label-long { display: none; } -.split-pane-header .permission-label-short { display: inline; } +.split-pane-header .session-permission-segmented { + height: 24px; + padding: 1px; +} .split-pane-header .session-permission-status { flex: 0 0 auto; diff --git a/lib/public/index.html b/lib/public/index.html index 13bd63d1..cdc1f35d 100644 --- a/lib/public/index.html +++ b/lib/public/index.html @@ -410,9 +410,9 @@

diff --git a/lib/public/modules/permission-control.js b/lib/public/modules/permission-control.js index 821c5a26..38f43c22 100644 --- a/lib/public/modules/permission-control.js +++ b/lib/public/modules/permission-control.js @@ -46,9 +46,9 @@ export function createPermissionControl(className, ariaLabel) { control.setAttribute("role", "group"); control.setAttribute("aria-label", ariaLabel || "Session permission mode"); control.innerHTML = '
' + - '' + - '' + - '' + + '' + + '' + + '' + '
'; control.querySelector(".session-permission-status").setAttribute("aria-atomic", "true"); return control; @@ -101,6 +101,7 @@ export function renderPermissionControl(control, state) { control.classList.toggle("permission-globally-forced", globallyForced); control.classList.toggle("permission-runtime-fallback", state.permissionMode === "auto" && !!state.effectivePermissionMode && state.effectivePermissionMode !== "auto"); control.setAttribute("aria-busy", pending ? "true" : "false"); + var buttonTitles = { default: "Ask permissions", auto: "Auto permissions", bypassPermissions: "Skip permissions" }; var buttons = control.querySelectorAll("[data-permission-mode]"); for (var i = 0; i < buttons.length; i++) { var mode = buttons[i].dataset.permissionMode; @@ -113,8 +114,7 @@ export function renderPermissionControl(control, state) { buttons[i].classList.toggle("pending", requested); buttons[i].setAttribute("aria-pressed", selected ? "true" : "false"); buttons[i].setAttribute("aria-busy", requested ? "true" : "false"); - if (unsupported) buttons[i].title = "Auto permissions are unavailable for this Claude session."; - else buttons[i].removeAttribute("title"); + buttons[i].title = unsupported ? "Auto permissions are unavailable for this Claude session." : buttonTitles[mode]; } var status = control.querySelector(".session-permission-status"); if (status) { diff --git a/test/fixtures/permission-mode-browser.html b/test/fixtures/permission-mode-browser.html index d20f8d28..3b91a98c 100644 --- a/test/fixtures/permission-mode-browser.html +++ b/test/fixtures/permission-mode-browser.html @@ -1,24 +1,23 @@ - + Production permission control browser fixture @@ -42,6 +42,14 @@

Production permission controls

+ + @@ -81,6 +89,9 @@

Production permission controls

var log = document.getElementById("fixture-log"); var onlineButton = document.getElementById("toggle-online"); var sessionButton = document.getElementById("switch-session"); + var themeMode = document.getElementById("theme-mode"); + var splitWidth = document.getElementById("split-width"); + var splitPane = document.querySelector(".fixture-split-pane"); createStore({ currentSlug: "fixture-project", @@ -166,6 +177,16 @@

Production permission controls

store.subscribe(renderProductionControls); renderProductionControls(); + themeMode.addEventListener("change", function () { + document.documentElement.classList.toggle("light-theme", this.value === "light"); + document.documentElement.classList.toggle("dark-theme", this.value !== "light"); + writeLog("Production theme tokens switched to " + this.value + "."); + }); + splitWidth.addEventListener("change", function () { + splitPane.style.setProperty("--fixture-split-width", this.value + "px"); + writeLog("Split Driver header width set to " + this.value + "px."); + }); + document.getElementById("release-held").addEventListener("click", function () { if (!heldRequest) { writeLog("No held production request."); return; } var request = heldRequest; diff --git a/test/permission-mode-ui.test.js b/test/permission-mode-ui.test.js index e3bccfb7..88ccd6f7 100644 --- a/test/permission-mode-ui.test.js +++ b/test/permission-mode-ui.test.js @@ -122,11 +122,10 @@ test("standalone header exposes Ask Auto Skip permissions and runtime status", f var html = read("lib/public/index.html"); var start = html.indexOf('id="header-full-access-btn"'); var control = html.slice(start, start + 1200); - assert.match(control, /data-permission-mode="default">.*permission-label">Ask/s); - assert.match(control, /data-permission-mode="auto">.*permission-label">Auto/s); + assert.match(control, /data-permission-mode="default"[^>]*aria-label="Ask permissions"[^>]*title="Ask permissions"[^>]*>.*permission-label">Ask/s); + assert.match(control, /data-permission-mode="auto"[^>]*aria-label="Auto permissions"[^>]*title="Auto permissions"[^>]*>.*permission-label">Auto/s); assert.match(control, /permission-spinner/); - assert.match(control, /aria-label="Skip permissions"/); - assert.match(control, />Skip permissions]*aria-label="Skip permissions"[^>]*title="Skip permissions"[^>]*>.*permission-label">Skip]*aria-live="polite"|aria-live="polite"[^>]*session-permission-status/); assert.doesNotMatch(control, /role="switch"|session-full-access-track/); }); @@ -218,9 +217,12 @@ test("production rendering shows Auto only for Claude and disables it only when assert.equal(unavailable.buttons[1].disabled, true); assert.match(unavailable.buttons[1].title, /unavailable for this Claude session/); assert.equal(unavailable.status.textContent, "Auto unavailable for this Claude session"); + f.api.renderPermissionControl(unavailable.control, { projectSlug: "project-a", sessionId: 7, vendor: "claude", + permissionMode: "default", effectivePermissionMode: "default", permissionCapabilities: { auto: true }, connected: true }); + assert.equal(unavailable.buttons[1].title, "Auto permissions"); }); -test("pending rendering marks the control and requested segment busy with an inline spinner", function () { +test("pending rendering marks the control and requested segment busy with a centered spinner", function () { var f = permissionClientFixture(); var ui = controlFixture(); f.api.sendPermissionMode("project-a", 7, "auto"); @@ -480,8 +482,24 @@ test("browser fixture mounts production controls and routes mock socket results assert.match(sharedCss, /\.session-permission-control\.permission-pending \.session-permission-status:not\(\.hidden\) \{/); assert.match(sharedCss, /\.permission-spinner \{[^}]*visibility: hidden/s); assert.match(paneCss, /\.split-pane-header \.session-permission-status/); - assert.match(paneCss, /\.split-pane-header \.split-pane-full-access \{[^}]*height: 22px[^}]*font-size: 10px/s); - assert.match(paneCss, /\.split-pane-header \.session-permission-segmented button \{[^}]*min-height: 18px[^}]*font-size: 10px/s); - assert.match(paneCss, /\.split-pane-header \.permission-label-long \{ display: none; \}/); - assert.match(paneCss, /\.split-pane-header \.permission-label-short \{ display: inline; \}/); + assert.match(sharedCss, /button\.active \{[^}]*background: var\(--input-bg\)[^}]*color: var\(--text\)[^}]*font-weight: var\(--font-weight-heading/s); + assert.match(sharedCss, /:root\.light-theme \.session-permission-segmented button\.active \{[^}]*background: var\(--bg\)/s); + assert.match(sharedCss, /button \{[^}]*font-weight: 500/s); + assert.match(sharedCss, /permission-control:not\(\.permission-pending\).*button:disabled \{[^}]*color: var\(--text-muted\)[^}]*opacity: \.65/s); + assert.match(sharedCss, /button\.pending \.permission-label \{ visibility: hidden; \}/); + assert.match(sharedCss, /\.permission-spinner \{ position: absolute;[^}]*transform: translate\(-50%, -50%\)/s); + assert.match(sharedCss, /@keyframes permission-spin \{[^}]*translate\(-50%, -50%\) rotate\(360deg\)/s); + assert.match(paneCss, /\.split-pane-header \{[^}]*height: 28px[^}]*min-height: 28px/s); + assert.match(paneCss, /\.split-pane-header \.split-pane-full-access \{[^}]*height: 24px[^}]*font-size: 11px/s); + assert.match(paneCss, /\.split-pane-header \.session-permission-segmented button \{[^}]*height: 20px[^}]*font-size: 11px/s); + assert.match(paneCss, /\.split-pane-header \.session-permission-segmented \{[^}]*height: 24px[^}]*padding: 1px/s); + assert.doesNotMatch(paneCss, /\.split-pane-header \.split-pane-full-access \{[^}]*overflow: hidden/s); + assert.match(fixture, /id="theme-mode"/); + assert.match(fixture, /id="split-width"/); + assert.match(fixture, /value="390"/); + assert.match(fixture, /classList\.toggle\("light-theme"/); + assert.match(fixture, /\.fixture-split-pane \{ --fixture-split-width: 356px; flex: 0 0 var\(--fixture-split-width\)/); + assert.match(fixture, /\.fixture-split-pane \{ flex: none; \}/); + assert.match(fixture, /setProperty\("--fixture-split-width"/); + assert.doesNotMatch(fixture, /style\.flexBasis/); });