From e661dede56c47044b4554373e1932af8c5595ce0 Mon Sep 17 00:00:00 2001 From: Abhyudaya Baiju Tharakan Date: Sun, 25 Oct 2026 01:16:16 +0530 Subject: [PATCH 1/3] fix(dashboard): leave official_only unset when the Limits box is clear Saving the Limits dialog sent official_only: false for a key with no restriction. Storage keeps that as 0, and a non-null official_only marks the account as controlled, so every save turned on key-control handling for keys the operator never restricted. A clear box now sends null. --- static/js/key-controls.js | 3 ++- templates/users.html | 2 +- tests/test_dashboard_scripts.mjs | 5 ++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/static/js/key-controls.js b/static/js/key-controls.js index 70a79916..d3e00a2c 100644 --- a/static/js/key-controls.js +++ b/static/js/key-controls.js @@ -5,7 +5,8 @@ /** The PUT /users//controls body for the dialog's field values. */ function controlsPayload(fields) { return { - official_only: fields.official_only === "on" || fields.official_only === true, + // A clear box stores no restriction (null): an explicit false would still mark the key controlled. + official_only: fields.official_only === "on" || fields.official_only === true ? true : null, forced_provider_preferences: fields.forced_provider_preferences ? JSON.parse(fields.forced_provider_preferences) : null, secret_scan_mode: fields.secret_scan_mode || null, shadow_eval_rate: amount(fields.shadow_eval_rate), diff --git a/templates/users.html b/templates/users.html index f3b9d58e..42ac0b54 100644 --- a/templates/users.html +++ b/templates/users.html @@ -221,5 +221,5 @@

Save this API key now

{% block scripts %} - + {% endblock %} diff --git a/tests/test_dashboard_scripts.mjs b/tests/test_dashboard_scripts.mjs index b2579d56..5758c0e0 100644 --- a/tests/test_dashboard_scripts.mjs +++ b/tests/test_dashboard_scripts.mjs @@ -352,8 +352,11 @@ test('usage page and key limit dialog helpers format budgets and build control p daily_budget_usd: '2.5', monthly_budget_usd: '', allowed_models: 'auto:*\nfree:*, openai:gpt-4.1', allowed_ips: ' 203.0.113.0/24 ', expires_at: '2026-12-31T23:30', }); - assert.equal(JSON.stringify(payload), JSON.stringify({ official_only: false, forced_provider_preferences: null, secret_scan_mode: null, shadow_eval_rate: null, daily_budget_usd: 2.5, monthly_budget_usd: null, + assert.equal(JSON.stringify(payload), JSON.stringify({ official_only: null, forced_provider_preferences: null, secret_scan_mode: null, shadow_eval_rate: null, daily_budget_usd: 2.5, monthly_budget_usd: null, allowed_models: ['auto:*', 'free:*', 'openai:gpt-4.1'], allowed_ips: ['203.0.113.0/24'], expires_at: '2026-12-31T23:30:00Z' })); + const restricted = context.window.MultiLLMKeyControls.controlsPayload({ official_only: 'on', forced_provider_preferences: '{"data_collection":"deny"}' }); + assert.equal(restricted.official_only, true); + assert.equal(JSON.stringify(restricted.forced_provider_preferences), '{"data_collection":"deny"}'); for (const source of ['static/js/usage.js', 'static/js/key-controls.js']) { assert.doesNotMatch(readFileSync(source, 'utf8'), /innerHTML/, source); } From 87d01e9376ac742e109ace81046a5292fe656de9 Mon Sep 17 00:00:00 2001 From: Abhyudaya Baiju Tharakan Date: Sun, 25 Oct 2026 01:16:23 +0530 Subject: [PATCH 2/3] feat(dashboard): add page transitions and fix dialog and form layout Same-origin navigations now cross-fade: the signed-in header holds still and the current-page underline slides to the new link. Dialogs rise in over a fading backdrop, the mobile menu fades in, and buttons press in slightly. Reduced motion cancels the page-transition animations and the press effects. The @view-transition opt-in is inline in base.html. Chromium missed it in shell.css whenever that stylesheet was still being revalidated, and static files are no-cache, so most navigations skipped the transition. The page CSP allows exactly that rule by its sha256 hash; there is still no unsafe-inline. Fixes found in a desktop and phone review: - Dialogs opened in the top-left corner because the base reset zeroes the margin that centres a modal dialog. They are centred again, scroll within the viewport and keep their action row in view. - The child-safety and cascade panels had no form layout; they now use the shared form stack, checkbox field and field classes, and JSON editors are monospace. - The model catalog heading tag overflowed on phones; it now wraps. - Bare disclosure summaries get a 40 px hit area. - Root font smoothing. Changed assets get new ?v= versions and the service worker cache moves to multillm-proxy-v14. --- docs/design.md | 4 +- routes/dashboard_security.py | 15 ++++-- static/css/auto-routes.css | 3 +- static/css/components.css | 55 +++++++++++++++++++++- static/css/controls.css | 16 ++++++- static/css/shell.css | 79 ++++++++++++++++++++++++++++++++ static/service-worker.js | 10 ++-- templates/base.html | 11 +++-- templates/operations.html | 26 +++++++---- templates/safety-settings.html | 31 ++++++++----- templates/users.html | 11 +++-- tests/outbound_inventory.json | 2 +- tests/test_control_plane_ui.py | 10 ++-- tests/test_dashboard_scripts.mjs | 4 +- tests/test_dashboard_sso.py | 6 +++ 15 files changed, 233 insertions(+), 50 deletions(-) diff --git a/docs/design.md b/docs/design.md index 7dffda28..9d0adbeb 100644 --- a/docs/design.md +++ b/docs/design.md @@ -94,7 +94,9 @@ on the page background; `ink-400` is decorative only. - Color is never the only cue; live regions announce copy results and async status. - Plain-text rendering for model output and remote content. -- Motion is limited to state feedback and respects `prefers-reduced-motion`. +- Motion is limited to state feedback, dialog and menu entrances, and a short + cross-fade between pages in which the current-page underline slides to its + new link. All of it respects `prefers-reduced-motion`. ## Local preview diff --git a/routes/dashboard_security.py b/routes/dashboard_security.py index 3cb2b4a5..9f655f6a 100644 --- a/routes/dashboard_security.py +++ b/routes/dashboard_security.py @@ -1,5 +1,7 @@ """Dashboard single sign-on through Cloudflare Access, the audit log page and page hardening.""" +import base64 +import hashlib import logging from typing import Optional @@ -13,12 +15,19 @@ logger = logging.getLogger(__name__) -# Templates load only same-origin scripts and styles and hold no inline code; JSON data +# Chromium misses a cross-document view-transition opt-in that arrives in a stylesheet still +# being revalidated (static files are no-cache), so base.html carries this one rule inline and the +# policy allows exactly it by hash. The transition's animations stay in shell.css. +VIEW_TRANSITION_STYLE = "@view-transition{navigation:auto}" +VIEW_TRANSITION_STYLE_SOURCE = "'sha256-%s'" % base64.b64encode( + hashlib.sha256(VIEW_TRANSITION_STYLE.encode()).digest()).decode() + +# Templates load only same-origin scripts and styles and hold no other inline code; JSON data # blocks are not executed. Fetches, the service worker and the manifest stay same-origin. CONTENT_SECURITY_POLICY = "; ".join(( "default-src 'self'", "script-src 'self'", - "style-src 'self'", + f"style-src 'self' {VIEW_TRANSITION_STYLE_SOURCE}", "img-src 'self' data: blob:", "font-src 'self'", "connect-src 'self'", @@ -130,7 +139,7 @@ def dashboard_sso_context(): "enabled": dashboard_sso.configured(), "only": dashboard_sso.sso_only(), "logout_url": dashboard_sso.logout_url(), - }} + }, "view_transition_style": VIEW_TRANSITION_STYLE} @app.after_request def dashboard_security_headers(response): diff --git a/static/css/auto-routes.css b/static/css/auto-routes.css index 5140456c..eeaca5e3 100644 --- a/static/css/auto-routes.css +++ b/static/css/auto-routes.css @@ -58,9 +58,10 @@ .auto-route-candidate-heading, .auto-route-section-heading { display: flex; + flex-wrap: wrap; align-items: flex-start; justify-content: space-between; - gap: 1rem; + gap: 0.5rem 1rem; } .auto-route-candidate-heading { diff --git a/static/css/components.css b/static/css/components.css index 43bb894c..3fcdfdad 100644 --- a/static/css/components.css +++ b/static/css/components.css @@ -382,6 +382,12 @@ background: var(--rose-700); } +/* Bare disclosures get a 40 px target; styled ones set their own. */ +:where(.app-main) details:not(.disclosure, .endpoint-group) > summary { + padding-block: 0.6rem; + cursor: pointer; +} + /* Disclosure: a quiet expandable section for secondary detail. */ .disclosure > summary { display: flex; @@ -483,19 +489,57 @@ animation: spin 0.75s linear infinite; } -/* Dialog */ +/* Dialog: rises in over a darkening backdrop; closing is shorter and quieter. The base + reset zeroes every margin, so centring is restored here. */ .dialog { width: min(100% - 2rem, 34rem); + max-height: calc(100dvh - 2rem); + margin: auto; padding: 0; + overflow-y: auto; + overscroll-behavior: contain; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface); color: var(--text); box-shadow: var(--shadow-lg); + opacity: 0; + translate: 0 0.5rem; + scale: 0.98; + transition: opacity 150ms ease-in, translate 150ms ease-in, scale 150ms ease-in, + overlay 150ms allow-discrete, display 150ms allow-discrete; +} + +.dialog[open] { + opacity: 1; + translate: 0; + scale: 1; + transition-duration: var(--motion); + transition-timing-function: var(--ease); +} + +@starting-style { + .dialog[open] { + opacity: 0; + translate: 0 0.5rem; + scale: 0.98; + } } .dialog::backdrop { - background: rgb(7 19 31 / 62%); + background-color: rgb(7 19 31 / 0%); + transition: background-color 150ms ease-in, overlay 150ms allow-discrete, display 150ms allow-discrete; +} + +.dialog[open]::backdrop { + background-color: rgb(7 19 31 / 62%); + transition-duration: var(--motion); +} + +@starting-style { + .dialog[open]::backdrop { + background-color: rgb(7 19 31 / 0%); + } } .dialog__body { @@ -516,11 +560,18 @@ line-height: 1.55; } +/* Stays in view at the foot of a long dialog. */ .dialog__actions { + position: sticky; + bottom: 0; display: flex; + margin: 0 -1.5rem -1.5rem; + padding: 0.85rem 1.5rem 1rem; justify-content: flex-end; flex-wrap: wrap; gap: 0.55rem; + border-top: 1px solid var(--border-subtle); + background: var(--surface); } /* Notifications */ diff --git a/static/css/controls.css b/static/css/controls.css index cdaaed39..60e5dbef 100644 --- a/static/css/controls.css +++ b/static/css/controls.css @@ -20,7 +20,14 @@ text-decoration: none; white-space: nowrap; cursor: pointer; - transition: background var(--motion-fast) ease, border-color var(--motion-fast) ease, color var(--motion-fast) ease; + transition: background var(--motion-fast) ease, border-color var(--motion-fast) ease, color var(--motion-fast) ease, + scale var(--motion-fast) var(--ease); +} + +@media (prefers-reduced-motion: no-preference) { + .button:active:not(:disabled, [aria-disabled="true"], [aria-busy="true"]) { + scale: 0.97; + } } .button:hover { @@ -163,6 +170,13 @@ resize: vertical; } +/* JSON and other structured text. */ +.field-textarea--code { + font-family: var(--font-mono); + font-size: var(--text-sm); + tab-size: 2; +} + :is(.app-main, dialog) :where(input, select, textarea):focus-visible, .field:focus-visible, .field-select:focus-visible, diff --git a/static/css/shell.css b/static/css/shell.css index 319fa2f1..ef374b30 100644 --- a/static/css/shell.css +++ b/static/css/shell.css @@ -85,6 +85,8 @@ html { font-family: var(--font-sans); line-height: 1.5; -webkit-text-size-adjust: 100%; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; text-rendering: optimizeLegibility; scroll-padding-top: calc(var(--header-height) + 4rem); } @@ -282,6 +284,12 @@ samp { background: var(--surface); } +/* Holds still while the page beneath it cross-fades. The public header is left out because it + scrolls away on small screens. */ +.app-header { + view-transition-name: masthead; +} + .app-header__inner, .site-header__inner { display: flex; @@ -304,6 +312,7 @@ samp { .primary-nav__link, .site-nav__link { + position: relative; display: inline-flex; padding: 0 0.8rem; align-items: center; @@ -327,6 +336,21 @@ samp { box-shadow: inset 0 -3px 0 var(--accent-fill); } +/* The current page's underline is its own element so it can slide to the next page's link. */ +.primary-nav__link[aria-current="page"] { + box-shadow: none; +} + +.primary-nav__indicator { + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 3px; + background: var(--accent-fill); + view-transition-name: nav-indicator; +} + .app-header__actions, .site-header__actions { display: flex; @@ -384,6 +408,17 @@ samp { border-radius: var(--radius-sm); background: var(--surface); cursor: pointer; + transition: background-color var(--motion-fast) ease, scale var(--motion-fast) var(--ease); +} + +.mobile-menu-button:hover { + background: var(--surface-muted); +} + +@media (prefers-reduced-motion: no-preference) { + .mobile-menu-button:active { + scale: 0.94; + } } .mobile-menu-button span:not(.sr-only) { @@ -405,6 +440,17 @@ samp { background: var(--surface); } +.mobile-nav:not([hidden]) { + transition: opacity var(--motion) var(--ease), translate var(--motion) var(--ease); +} + +@starting-style { + .mobile-nav:not([hidden]) { + opacity: 0; + translate: 0 -0.35rem; + } +} + .mobile-nav a { display: flex; min-height: 2.75rem; @@ -633,6 +679,33 @@ samp { } } +/* Page transitions: same-origin navigations cross-fade, the header holds still and the + current-page underline slides to its new link. The opt-in is inline in base.html so it is + present before stylesheets revalidate; reduced motion cancels these animations. */ +::view-transition-old(root) { + animation: 120ms ease-in both page-fade-out; +} + +::view-transition-new(root) { + animation: var(--motion) var(--ease) both page-fade-in; +} + +::view-transition-group(nav-indicator) { + animation-duration: 280ms; + animation-timing-function: var(--ease); +} + +@keyframes page-fade-out { + to { opacity: 0; } +} + +@keyframes page-fade-in { + from { + opacity: 0; + translate: 0 0.25rem; + } +} + @media (prefers-reduced-motion: reduce) { *, *::before, @@ -642,4 +715,10 @@ samp { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; } + + ::view-transition-group(*), + ::view-transition-old(*), + ::view-transition-new(*) { + animation: none !important; + } } diff --git a/static/service-worker.js b/static/service-worker.js index 45f63c36..ece4198d 100644 --- a/static/service-worker.js +++ b/static/service-worker.js @@ -1,14 +1,14 @@ -const CACHE_NAME = 'multillm-proxy-v13'; +const CACHE_NAME = 'multillm-proxy-v14'; const OFFLINE_URL = '/static/offline.html'; const PRECACHE_URLS = [ OFFLINE_URL, '/static/css/style.css', - '/static/css/shell.css?v=2', - '/static/css/controls.css?v=1', - '/static/css/components.css?v=1', + '/static/css/shell.css?v=3', + '/static/css/controls.css?v=2', + '/static/css/components.css?v=2', '/static/css/content.css?v=1', '/static/css/public.css?v=2', - '/static/css/auto-routes.css?v=8', + '/static/css/auto-routes.css?v=9', '/static/css/documentation.css?v=10', '/static/css/operations.css?v=2', '/static/css/surfaces.css?v=2', diff --git a/templates/base.html b/templates/base.html index 0f6c64d9..44866287 100644 --- a/templates/base.html +++ b/templates/base.html @@ -15,10 +15,12 @@ + {# Page transition opt-in; allowed by hash in the page CSP (routes/dashboard_security.py). #} + {% if view_transition_style %}{% endif %} - - - + + + {% block styles %}{% endblock %} @@ -51,7 +53,8 @@ diff --git a/templates/operations.html b/templates/operations.html index b191952b..7231b7ee 100644 --- a/templates/operations.html +++ b/templates/operations.html @@ -4,7 +4,7 @@ {% block styles %} - + {% endblock %} {% block content %} @@ -168,16 +168,22 @@

Route trace

Verification-gated cascades

Try cheaper chat models first and advance when an answer fails a check. The final tier always answers.

-
- - -
- - -

Use 2–4 tiers in cost order. Checks: complete, json, tools, no_refusal, agreement, judge. Agreement adds a call; judge requires a model and defaults to a minimum score of 7.

- +
+
+ + +
+ +
+ + +

Use 2–4 tiers in cost order. Checks: complete, json, tools, no_refusal, agreement, judge. Agreement adds a call; judge requires a model and defaults to a minimum score of 7.

+
+
+ +

+
-

diff --git a/templates/safety-settings.html b/templates/safety-settings.html index 11850dc1..b949e83c 100644 --- a/templates/safety-settings.html +++ b/templates/safety-settings.html @@ -4,25 +4,32 @@

Optionally check submitted text before generation. Off by default.

Text only -
+
-
+
Child-safety screening settings -
-

Loading screening settings…

+

Loading screening settings…

diff --git a/templates/users.html b/templates/users.html index 42ac0b54..e730be95 100644 --- a/templates/users.html +++ b/templates/users.html @@ -170,10 +170,15 @@

Limits for

- -

Exclude subscription, reseller and free providers, including routed candidates.

+