From f49c85dfca7e56de4591fcf2cc484dc96fc6b3cc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 16:57:04 +0000 Subject: [PATCH 1/3] feat(ds): soften motion across nav, status & cards for calmer UX Improves the calm "windstil" feel of the design system without breaking its brutalist tone: - design-system: add --duration-slow, --ease-soft, --ease-out tokens; swap harsh blink-step status pulse for an eased opacity+scale pulse; add page-enter / rise-in entrance utilities with stagger delays; add subtle press feedback (translateY 1px) on .btn:active; give :focus-visible a small offset transition; lift help-card on hover with arrow nudge; add scroll-padding-top so anchor targets clear the sticky 56px navbar (auto under prefers-reduced-motion). - nav: animate desktop dropdown reveal (fade+rise+scale); slide-in mobile drawer; max-height transition on mobile accordion groups; CSS-driven hamburger cross via [aria-expanded="true"] (replaces inline JS transforms); animated underline on .nav-link via ::after. - nav.js: drop inline span style writes now that CSS owns the hamburger state. --- frontend/design-system.css | 69 +++++++++++++++++++++++++++++++++++--- frontend/nav.css | 66 +++++++++++++++++++++++++++++++----- frontend/nav.js | 6 ---- 3 files changed, 123 insertions(+), 18 deletions(-) diff --git a/frontend/design-system.css b/frontend/design-system.css index a233384..5d95b9c 100644 --- a/frontend/design-system.css +++ b/frontend/design-system.css @@ -74,7 +74,10 @@ /* motion */ --duration-fast: 120ms; --duration-base: 200ms; + --duration-slow: 320ms; --ease-sharp: cubic-bezier(0.2, 0, 0, 1); + --ease-soft: cubic-bezier(0.4, 0, 0.2, 1); + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); /* ─── v3 legacy token aliases (remove after Phase D HTML migration) ─── */ --black: #0B3A6A; @@ -102,8 +105,13 @@ html { scroll-behavior: smooth; + /* keep anchor targets clear of the 56px sticky navbar */ + scroll-padding-top: 72px; -webkit-text-size-adjust: 100%; } +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } +} img, video, svg { display: block; @@ -183,6 +191,10 @@ h1 em::after { :focus-visible { outline: 2px solid var(--cobalt); outline-offset: 2px; + transition: outline-offset var(--duration-fast) var(--ease-out); +} +:focus-visible:not(:active) { + outline-offset: 3px; } .section-cobalt :focus-visible, .section-navy :focus-visible, @@ -1107,11 +1119,15 @@ h1 em::after { border: 1.5px solid transparent; transition: background var(--duration-base) var(--ease-sharp), color var(--duration-base) var(--ease-sharp), - border-color var(--duration-base) var(--ease-sharp); + border-color var(--duration-base) var(--ease-sharp), + transform var(--duration-fast) var(--ease-out); white-space: nowrap; line-height: 1; user-select: none; } +.btn:active:not(:disabled) { + transform: translateY(1px); +} .btn:disabled { opacity: .35; cursor: not-allowed; @@ -1228,11 +1244,16 @@ h1 em::after { background: var(--lime); border: 1px solid var(--navy); flex-shrink: 0; - animation: blink-step 2s steps(2, end) infinite; + animation: status-pulse 2.4s var(--ease-soft) infinite; +} +@keyframes status-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: .55; transform: scale(.92); } } +/* legacy keyframe alias — keep for any hand-rolled usage */ @keyframes blink-step { 0%, 100% { opacity: 1; } - 50% { opacity: 0; } + 50% { opacity: .5; } } .status-row { @@ -1365,6 +1386,38 @@ pre code { background: none; padding: 0; } } +/* ═══════════════════════════════════════════════════ + MOTION UTILITIES + ═══════════════════════════════════════════════════ */ +@keyframes ds-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes ds-rise-in { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes ds-scale-in { + from { opacity: 0; transform: scale(.98); } + to { opacity: 1; transform: scale(1); } +} + +/* Page-level entrance — applied to
via .page-enter */ +.page-enter { + animation: ds-fade-in var(--duration-slow) var(--ease-out) both; +} +/* Block-level entrance — for cards, hero sections, lists, etc. */ +.rise-in { + animation: ds-rise-in var(--duration-slow) var(--ease-out) both; +} +/* Stagger helpers (use with .rise-in) */ +.rise-in.delay-1 { animation-delay: 60ms; } +.rise-in.delay-2 { animation-delay: 120ms; } +.rise-in.delay-3 { animation-delay: 180ms; } +.rise-in.delay-4 { animation-delay: 240ms; } +.rise-in.delay-5 { animation-delay: 300ms; } + + /* ═══════════════════════════════════════════════════ MOTION SAFETY ═══════════════════════════════════════════════════ */ @@ -1790,11 +1843,15 @@ pre code { background: none; padding: 0; } text-decoration: none; color: var(--ink); transition: border-color var(--duration-base) var(--ease-sharp), - background var(--duration-base) var(--ease-sharp); + background var(--duration-base) var(--ease-sharp), + transform var(--duration-base) var(--ease-out), + box-shadow var(--duration-base) var(--ease-out); } .help-card:hover { border-color: var(--cobalt); background: var(--ink-ghost); + transform: translateY(-2px); + box-shadow: 0 6px 0 -3px var(--ink-ghost); } .help-card .help-card-title { font-size: var(--text-base); @@ -1813,6 +1870,10 @@ pre code { background: none; padding: 0; } font-family: var(--mono); font-size: var(--text-xs); color: var(--cobalt); + transition: transform var(--duration-base) var(--ease-out); +} +.help-card:hover .help-card-arrow { + transform: translateX(4px); } /* Contact / CTA prompt box */ diff --git a/frontend/nav.css b/frontend/nav.css index f5b5d30..6b7c857 100644 --- a/frontend/nav.css +++ b/frontend/nav.css @@ -103,10 +103,16 @@ nav.nav .nav-dropdown-menu { z-index: 400; max-height: calc(100vh - 70px); overflow-y: auto; + transform-origin: top left; } nav.nav .nav-dropdown.open .nav-dropdown-menu, nav.nav .nav-dropdown:hover .nav-dropdown-menu { display: block; + animation: nav-dropdown-in var(--duration-base) var(--ease-out) both; +} +@keyframes nav-dropdown-in { + from { opacity: 0; transform: translateY(-4px) scale(.98); } + to { opacity: 1; transform: translateY(0) scale(1); } } /* Divider inside panel */ @@ -167,17 +173,32 @@ nav.nav .nav-link { text-transform: uppercase; padding: var(--space-2) var(--space-3); text-decoration: none; - transition: color var(--duration-base) var(--ease-sharp); + transition: color var(--duration-base) var(--ease-sharp), + box-shadow var(--duration-base) var(--ease-out); white-space: nowrap; display: flex; align-items: center; height: 32px; + position: relative; +} +nav.nav .nav-link::after { + content: ''; + position: absolute; + left: var(--space-3); + right: var(--space-3); + bottom: 4px; + height: 2px; + background: var(--cobalt); + transform: scaleX(0); + transform-origin: left center; + transition: transform var(--duration-base) var(--ease-out); } nav.nav .nav-link:hover { color: var(--cobalt); } +nav.nav .nav-link:hover::after { transform: scaleX(.4); } nav.nav .nav-link.active { color: var(--cobalt); - box-shadow: inset 0 -2px 0 var(--cobalt); } +nav.nav .nav-link.active::after { transform: scaleX(1); } /* ════════════════════════════════════════ HAMBURGER @@ -205,9 +226,21 @@ nav.nav .nav-hamburger span { width: 22px; height: 2px; background: var(--ink); - transition: transform var(--duration-base) var(--ease-sharp), - opacity var(--duration-fast); + transition: transform var(--duration-base) var(--ease-out), + opacity var(--duration-fast) var(--ease-out); pointer-events: none; + transform-origin: center; +} +/* Open state — animate to a clean cross via CSS instead of inline JS */ +nav.nav .nav-hamburger[aria-expanded="true"] span:nth-child(1) { + transform: translateY(7px) rotate(45deg); +} +nav.nav .nav-hamburger[aria-expanded="true"] span:nth-child(2) { + opacity: 0; + transform: scaleX(.4); +} +nav.nav .nav-hamburger[aria-expanded="true"] span:nth-child(3) { + transform: translateY(-7px) rotate(-45deg); } /* ════════════════════════════════════════ @@ -226,7 +259,14 @@ nav.nav .nav-hamburger span { overflow-y: auto; flex-direction: column; } -.nav-mobile.open { display: flex; } +.nav-mobile.open { + display: flex; + animation: nav-mobile-in var(--duration-slow) var(--ease-out) both; +} +@keyframes nav-mobile-in { + from { opacity: 0; transform: translateY(-8px); } + to { opacity: 1; transform: translateY(0); } +} /* Accordion group */ .nav-mobile-group { @@ -264,13 +304,23 @@ nav.nav .nav-hamburger span { .nav-mobile-group.open .nav-mobile-group-btn { color: #0B3A6A; } .nav-mobile-group.open .nav-mobile-group-btn::after { content: '\2212'; color: #B2FF3F; } -/* Group items */ +/* Group items — animated reveal using max-height + opacity */ .nav-mobile-group-items { - display: none; + display: flex; flex-direction: column; + max-height: 0; + opacity: 0; + overflow: hidden; + padding: 0 0 0; + transition: max-height var(--duration-base) var(--ease-out), + opacity var(--duration-fast) var(--ease-out), + padding var(--duration-base) var(--ease-out); +} +.nav-mobile-group.open .nav-mobile-group-items { + max-height: 720px; + opacity: 1; padding: 0 0 var(--space-3); } -.nav-mobile-group.open .nav-mobile-group-items { display: flex; } .nav-mobile-group-items a { display: block; diff --git a/frontend/nav.js b/frontend/nav.js index 8f3865f..eafcb0d 100644 --- a/frontend/nav.js +++ b/frontend/nav.js @@ -131,10 +131,6 @@ hamburger.setAttribute('aria-expanded', 'true'); hamburger.setAttribute('aria-label', 'Close menu'); lockScroll(); - var spans = hamburger.querySelectorAll('span'); - if (spans[0]) spans[0].style.transform = 'translateY(7px) rotate(45deg)'; - if (spans[1]) spans[1].style.opacity = '0'; - if (spans[2]) spans[2].style.transform = 'translateY(-7px) rotate(-45deg)'; if (closeBtn) closeBtn.focus(); } @@ -143,8 +139,6 @@ hamburger.setAttribute('aria-expanded', 'false'); hamburger.setAttribute('aria-label', 'Open menu'); unlockScroll(); - var spans = hamburger.querySelectorAll('span'); - spans.forEach(function (s) { s.removeAttribute('style'); }); hamburger.focus(); } From d76f3b96706ab56bede291d71da5b4316e53adac Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 17:30:42 +0000 Subject: [PATCH 2/3] =?UTF-8?q?feat(ds):=20zoom=20in=20on=20first=20paint?= =?UTF-8?q?=20=E2=80=94=20dropzone=20breathe,=20empty=20+=20skeleton=20+?= =?UTF-8?q?=20indeterminate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit found several "map too far zoomed out at start of nav" UX moments: the dropzone reads as a cold rectangle on first visit, dashboard tables show only "—" for new users, and "Preparing…" states leave users staring at frozen 0% bars. These additions are opt-in primitives that surfaces can adopt page by page. - Dropzone: subtle 3.6s lime breathe ring on idle (pauses on hover/focus/drag), distinct cobalt 4px ring on .drag-over so the active drop target is unmissable. - .empty-state component (with .is-compact variant) — glyph + title + hint + actions slot; replaces walls of em-dashes in zero-data tables with an inviting "what to do next" surface. - .skeleton + .skeleton-line (sm/md/lg) + .skeleton-block — calm shimmer for loading placeholders; the dashboard's flickering height:28px placeholder divs can adopt this directly. - .progress-indeterminate — slim 3px bar with a sliding cobalt segment for "Preparing…" / "Connecting…" states with unknown ETA. - Reduced-motion: skeleton stays grey (not transparent), indeterminate bar pins to a visible position, dropzone breathe stops. --- frontend/design-system.css | 163 ++++++++++++++++++++++++++++++++++++- 1 file changed, 160 insertions(+), 3 deletions(-) diff --git a/frontend/design-system.css b/frontend/design-system.css index 5d95b9c..d7b56a0 100644 --- a/frontend/design-system.css +++ b/frontend/design-system.css @@ -518,10 +518,31 @@ h1 em::after { padding: 40px var(--space-6); background: var(--bone); cursor: pointer; - transition: background var(--duration-base) var(--ease-sharp); + transition: background var(--duration-base) var(--ease-sharp), + border-color var(--duration-base) var(--ease-sharp), + box-shadow var(--duration-base) var(--ease-out); +} +/* Idle "breathe" — subtle lime ring that invites interaction without + shouting. Pauses on hover/focus/drag so feedback states are clean. */ +.drop:not(:hover):not(:focus-within):not(.drag-over), +.dropzone:not(:hover):not(:focus-within):not(.drag-over) { + animation: drop-breathe 3.6s var(--ease-soft) infinite; +} +@keyframes drop-breathe { + 0%, 100% { box-shadow: 0 0 0 0 rgba(178,255,63,0); } + 50% { box-shadow: 0 0 0 4px rgba(178,255,63,.18); } +} +.drop:hover, .drop:focus-within, +.dropzone:hover, .dropzone:focus-within { + background: var(--ink-ghost); + box-shadow: 0 0 0 2px var(--ink-hair); +} +.drop.drag-over, +.dropzone.drag-over { + background: var(--ink-ghost); + border-color: var(--cobalt); + box-shadow: 0 0 0 4px rgba(29,78,216,.18); } -.drop:hover, .drop.drag-over, -.dropzone:hover, .dropzone.drag-over { background: var(--ink-ghost); } /* "01" bleeding label */ .drop-label, @@ -1418,6 +1439,132 @@ pre code { background: none; padding: 0; } .rise-in.delay-5 { animation-delay: 300ms; } +/* ═══════════════════════════════════════════════════ + EMPTY STATE + For zero-data tables, dashboards, lists. Replaces a sea of "—" + with a glyph + title + hint + optional CTA so first-time users + know what the surface is for and what to do next. + ═══════════════════════════════════════════════════ */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + gap: var(--space-3); + padding: var(--space-8) var(--space-5); + border: 1px dashed var(--ink-hair); + background: var(--bone); + color: var(--ink-dim); +} +.empty-state-glyph { + width: 40px; + height: 40px; + display: grid; + place-items: center; + border: 1.5px solid var(--ink-hair); + color: var(--ink-dim); + font-family: var(--mono); + font-size: var(--text-lg); + font-weight: 700; + margin-bottom: var(--space-1); + background: var(--bone); +} +.empty-state-title { + font-family: var(--sans); + font-size: var(--text-md); + font-weight: 700; + color: var(--navy); + margin: 0; +} +.empty-state-hint { + font-family: var(--mono); + font-size: var(--text-xs); + letter-spacing: .04em; + color: var(--ink-dim); + max-width: 44ch; + line-height: 1.55; + margin: 0; +} +.empty-state-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + margin-top: var(--space-3); + justify-content: center; +} + +/* Compact variant — for inside table rows or narrow cards */ +.empty-state.is-compact { + padding: var(--space-5) var(--space-4); + gap: var(--space-2); +} +.empty-state.is-compact .empty-state-glyph { + width: 28px; + height: 28px; + font-size: var(--text-sm); +} + + +/* ═══════════════════════════════════════════════════ + SKELETON LOADING + Replaces flicker placeholder divs with a calm shimmer that + reads as "we're loading", not "we're broken". + ═══════════════════════════════════════════════════ */ +.skeleton { + display: block; + background: linear-gradient( + 90deg, + var(--ink-ghost) 0%, + var(--ink-hair) 50%, + var(--ink-ghost) 100% + ); + background-size: 200% 100%; + animation: skeleton-shimmer 1.4s var(--ease-soft) infinite; + color: transparent; + user-select: none; + pointer-events: none; + min-height: 1em; +} +.skeleton-line { height: 12px; margin: 6px 0; } +.skeleton-line.is-sm { height: 8px; width: 40%; } +.skeleton-line.is-md { height: 12px; width: 70%; } +.skeleton-line.is-lg { height: 18px; width: 50%; } +.skeleton-block { height: 64px; } +@keyframes skeleton-shimmer { + from { background-position: 200% 0; } + to { background-position: -200% 0; } +} + + +/* ═══════════════════════════════════════════════════ + INDETERMINATE PROGRESS + For "Preparing…" / "Connecting…" — anything where ETA is + unknown. Reads as motion, not as a stuck bar. + ═══════════════════════════════════════════════════ */ +.progress-indeterminate { + position: relative; + width: 100%; + height: 3px; + background: var(--ink-ghost); + overflow: hidden; +} +.progress-indeterminate::after { + content: ''; + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 38%; + background: var(--cobalt); + animation: progress-slide 1.4s var(--ease-soft) infinite; +} +@keyframes progress-slide { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(280%); } +} + + /* ═══════════════════════════════════════════════════ MOTION SAFETY ═══════════════════════════════════════════════════ */ @@ -1428,6 +1575,16 @@ pre code { background: none; padding: 0; } transition-duration: .01ms !important; } .status-dot, .dot { animation: none; opacity: 1; } + /* Skeleton: stop shimmering, stay visibly grey so the placeholder + still reads as "loading" rather than a transparent gap. */ + .skeleton { animation: none; background: var(--ink-ghost); } + /* Indeterminate progress: pin the bar centre so the track isn't blank. */ + .progress-indeterminate::after { + animation: none; + transform: translateX(80%); + } + /* Dropzone: drop the breathing ring entirely. */ + .drop, .dropzone { animation: none; } } /* ═══════════════════════════════════════════════════ From d36e8b069225a3b1e33d32907aa24e0988fd68e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 17:36:11 +0000 Subject: [PATCH 3/3] feat(ui): adopt empty-state, skeleton & indeterminate primitives across key pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the new design-system primitives into the three pages flagged as worst offenders for "starts too zoomed-out" UX. dashboard.html: - stat-card placeholders now use .skeleton lines instead of flickering opacity:.3 height:28px divs (reads as loading, not as broken) - delivery table loading state shows progress-indeterminate + skeleton rows in place of "Loading..." - delivery table zero-data shows .empty-state with title, hint and a "Run E2E test" CTA so new users have a clear first move - audit log gets the same treatment (skeleton on load, .empty-state with explanatory hint when data has loaded but is empty) get.html: - "Preparing..." no longer leaves the user staring at a 0% bar — an indeterminate sliding bar shows up first; once a real percentage arrives, setStatus swaps in the determinate progress-bar drop.html: - camera failure (NotAllowedError / NotFoundError / non-secure context) no longer silently hides the scanner box; it shows an .empty-state explaining the cause and pointing at the manual-link fallback below --- frontend/dashboard.html | 27 +++++++++++++++++++++++---- frontend/drop.html | 18 ++++++++++++++++-- frontend/get.html | 14 ++++++++++++-- 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/frontend/dashboard.html b/frontend/dashboard.html index edc8441..630ab33 100644 --- a/frontend/dashboard.html +++ b/frontend/dashboard.html @@ -471,7 +471,14 @@ `).join("")} `; } else { - dt.innerHTML=`
No deliveries this session
`; + dt.innerHTML=`
+ +

No deliveries yet

+

Run the E2E test above, or send a file from the SDK to see it land here in real time.

+
+ +
+
`; } } const pi=document.getElementById("poll-info"); @@ -522,7 +529,7 @@

- ${[1,2,3,4,5,6,7,8].map(()=>`
`).join("")} + ${[1,2,3,4,5,6,7,8].map(()=>`
`).join("")}
@@ -565,7 +572,12 @@

Relay: ${relay.l}

-
Loading...
+
+
+
+
+
+
@@ -575,7 +587,8 @@

CT log →
-
Loading...
+
+
`; @@ -592,6 +605,12 @@

${esc(e.hash||"")}${e.device?" · "+esc(e.device):""}${e.latency_ms?" · "+e.latency_ms+"ms":""} ` ).join(""); + } else if (data) { + document.getElementById("audit-log").innerHTML=`
+ +

No audit events yet

+

Inbound, ACK and burn events will appear here as the relay processes traffic for your key.

+
`; } } diff --git a/frontend/drop.html b/frontend/drop.html index ba1f125..05935df 100644 --- a/frontend/drop.html +++ b/frontend/drop.html @@ -1027,8 +1027,22 @@

Device-to-device transfer

document.getElementById('scanner-video').srcObject = cameraStream; scanQR(); } catch(e) { - // Camera not available — hide scanner - document.getElementById('scanner-wrap').style.display = 'none'; + // Camera unavailable — replace the black box with an empty-state + // that explains why and points to the manual-link fallback below. + var reason = 'No camera available on this device.'; + if (e && e.name === 'NotAllowedError') reason = 'Camera permission was blocked. Allow camera access in your browser to scan QR codes.'; + else if (e && e.name === 'NotFoundError') reason = 'No camera detected on this device.'; + else if (window.isSecureContext === false) reason = 'Camera requires a secure (HTTPS) connection.'; + var wrap = document.getElementById('scanner-wrap'); + if (wrap) { + wrap.style.background = 'transparent'; + wrap.style.aspectRatio = 'auto'; + wrap.innerHTML = '
' + + '' + + '

Camera unavailable

' + + '

' + reason + ' You can paste the link manually below instead.

' + + '
'; + } } } diff --git a/frontend/get.html b/frontend/get.html index 1d5d462..c080ad1 100644 --- a/frontend/get.html +++ b/frontend/get.html @@ -303,7 +303,8 @@

Secure file incoming

Preparing...
-
+
+
Starting...
@@ -434,7 +435,16 @@

Something went wrong

} function setStatus(msg, pct) { - document.getElementById('pbar').style.width = pct + '%'; + var ind = document.getElementById('indeterminate-bar'); + var wrap = document.getElementById('progress-wrap'); + if (pct > 0) { + if (ind) ind.hidden = true; + if (wrap) wrap.hidden = false; + document.getElementById('pbar').style.width = pct + '%'; + } else { + if (ind) ind.hidden = false; + if (wrap) wrap.hidden = true; + } document.getElementById('status-msg').textContent = msg; }