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/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/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..9da8f26b 100644 --- a/templates/base.html +++ b/templates/base.html @@ -15,10 +15,12 @@ + {# Opts in to cross-document view-transitions; the page CSP allows it by hash (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 f3b9d58e..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.

+