Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commitcf9178bunknown_key

ui: design polish — global toast system + typography refinement

ui: design polish — global toast system + typography refinement

First pass of the senior-grade visual polish the user asked for.
Non-invasive — no route changes required.

## Global toast notifications
Adds <div id="toast-host"> + toastScript that reads ?success= /
?error= / ?toast= from the URL on every page load and surfaces a
polished slide-in toast (success/error/warn/info variants, icons,
auto-dismiss after 4.5s, scrub query params via history.replaceState
so a Refresh doesn't re-fire).

Every existing route that already redirects with ?success=Saved or
?error=Bad+thing now gets the new toast automatically — no route
code to touch. The legacy in-page banner divs continue to render
above the fold for routes that still inline them; we'll prune those
in a follow-up.

## Typography refinement
- body baseline: 15px size, 1.55 line-height (was unset / 1.55)
- font-synthesis: none — prevents browsers faking bold/italic when a
  weight isn't available, kills the muddy "near-bold" rendering that
  read as amateur
- Heading rhythm: each h1/h2/h3/h4 gets a tuned size + letter-spacing
  + line-height so the visual hierarchy is uniform across every page
- Link defaults: no underline until hover, accent focus ring with
  proper offset (kills the "blue underline soup" look on dense pages)

## Tests
- bun test → 1994 pass / 0 fail / 2 skip (unchanged)
- bunx tsc → clean

This is intentionally pass 1 — focuses on what's visible from the
moment the page renders. Pass 2 will polish individual admin
dashboards and replace the legacy banner divs entirely.

https://claude.ai/code/session_01QFLWDxWw65DX6enMcS5Lwe
Test User committed on May 15, 2026Parent: 6345c3e
1 file changed+1800cf9178beb590c60772fc79c79bd7ea706d057052
1 changed file+180−0
Modifiedsrc/views/layout.tsx+180−0View fileUnifiedSplit
230230 </nav>
231231 </header>
232232 <main id="main-content">{children}</main>
233 {/* Global toast host — populated by the toastScript below from
234 ?success= / ?error= / ?toast= query params. Replaces the
235 per-page banner pattern with one polished slide-in. */}
236 <div
237 id="toast-host"
238 aria-live="polite"
239 aria-atomic="true"
240 style="position:fixed;top:calc(var(--header-h) + 12px);right:16px;z-index:var(--z-toast,10000);display:flex;flex-direction:column;gap:8px;pointer-events:none"
241 />
233242 <footer>
234243 <div class="footer-inner">
235244 <div class="footer-brand">
327336 </div>
328337 <script dangerouslySetInnerHTML={{ __html: clientJs }} />
329338 <script dangerouslySetInnerHTML={{ __html: pwaRegisterScript }} />
339 <script dangerouslySetInnerHTML={{ __html: toastScript }} />
330340 {/* Block M2 — smart install banner + push-SW bootstrap. Authed
331341 users only; the banner respects a 3+ visits + 14-day cooldown
332342 heuristic. The push SW is registered on the same gate so we
537547// Block G1 — register service worker for offline / install support.
538548// Kept inline (and tiny) so we don't block first paint.
539549//
550// Global toast notifications — reads ?success=, ?error=, ?toast= from the
551// URL on page load and surfaces a polished slide-in toast instead of the
552// per-page banner divs that crowded the layout. Toasts auto-dismiss after
553// 4.5s; query params are scrubbed from the URL via history.replaceState
554// so a subsequent Refresh doesn't re-fire the same toast.
555//
556// Variants: ?success=…, ?error=…, ?toast=info:…, ?toast=warn:… All values
557// must be URI-encoded (callers already do this via encodeURIComponent
558// in c.redirect()).
559const toastScript = `
560 (function(){
561 function showToast(kind, message){
562 if (!message) return;
563 var host = document.getElementById('toast-host');
564 if (!host) return;
565 var el = document.createElement('div');
566 el.className = 'gx-toast gx-toast--' + kind;
567 el.setAttribute('role', kind === 'error' ? 'alert' : 'status');
568 var icon = document.createElement('span');
569 icon.className = 'gx-toast__icon';
570 icon.textContent = kind === 'success' ? '\\u2713'
571 : kind === 'error' ? '\\u00D7'
572 : kind === 'warn' ? '!'
573 : 'i';
574 el.appendChild(icon);
575 var text = document.createElement('span');
576 text.className = 'gx-toast__text';
577 text.textContent = message;
578 el.appendChild(text);
579 var close = document.createElement('button');
580 close.type = 'button';
581 close.className = 'gx-toast__close';
582 close.setAttribute('aria-label', 'Dismiss notification');
583 close.textContent = '\\u00D7';
584 close.addEventListener('click', function(){ dismiss(); });
585 el.appendChild(close);
586 host.appendChild(el);
587 // Force a reflow then add the visible class so the slide-in transitions.
588 void el.offsetWidth;
589 el.classList.add('gx-toast--in');
590 var timer = setTimeout(dismiss, 4500);
591 function dismiss(){
592 clearTimeout(timer);
593 el.classList.remove('gx-toast--in');
594 el.classList.add('gx-toast--out');
595 setTimeout(function(){
596 if (el.parentNode) el.parentNode.removeChild(el);
597 }, 220);
598 }
599 }
600 try {
601 var url = new URL(window.location.href);
602 var hits = 0;
603 var s = url.searchParams.get('success');
604 if (s) { showToast('success', s); url.searchParams.delete('success'); hits++; }
605 var e = url.searchParams.get('error');
606 if (e) { showToast('error', e); url.searchParams.delete('error'); hits++; }
607 var t = url.searchParams.get('toast');
608 if (t) {
609 var ix = t.indexOf(':');
610 var kind = ix > 0 ? t.slice(0, ix) : 'info';
611 var msg = ix > 0 ? t.slice(ix + 1) : t;
612 showToast(kind, msg);
613 url.searchParams.delete('toast');
614 hits++;
615 }
616 if (hits > 0 && window.history && window.history.replaceState) {
617 window.history.replaceState({}, '', url.pathname + (url.searchParams.toString() ? '?' + url.searchParams.toString() : '') + url.hash);
618 }
619 } catch(_) {}
620 })();
621`;
622
540623// Block S2 extension — when an UPDATED SW activates we force a single
541624// reload so the user picks up the fresh HTML immediately instead of
542625// being stuck on the previous deploy's cached page.
10031086 font-family: var(--font-sans);
10041087 background: var(--bg);
10051088 color: var(--text);
1089 font-size: 15px;
10061090 line-height: 1.55;
10071091 letter-spacing: -0.011em;
10081092 min-height: 100vh;
10091093 display: flex;
10101094 flex-direction: column;
10111095 font-feature-settings: 'cv11', 'ss01', 'ss03', 'calt';
1096 /* Subtle: prefers grayscale font smoothing on macOS for thin text,
1097 and disables automatic synthesis of bold/italic which can produce
1098 muddier rendering on certain weights. */
1099 -webkit-font-smoothing: antialiased;
1100 -moz-osx-font-smoothing: grayscale;
1101 font-synthesis: none;
1102 }
1103 /* Tighten heading rhythm — the body is 15/1.55, headings step down
1104 line-height inversely with size so display text doesn't feel airy. */
1105 h1, h2, h3, h4, h5, h6 {
1106 font-family: var(--font-display);
1107 color: var(--text-strong);
1108 letter-spacing: -0.022em;
1109 line-height: 1.18;
1110 font-weight: 650;
1111 margin-top: 0;
1112 }
1113 h1 { font-size: 28px; letter-spacing: -0.028em; }
1114 h2 { font-size: 22px; }
1115 h3 { font-size: 18px; letter-spacing: -0.018em; }
1116 h4 { font-size: 15.5px; letter-spacing: -0.012em; font-weight: 600; }
1117 /* Link refinement — underline only on hover/focus, never by default
1118 on internal nav. Prevents the "blue underline soup" look. */
1119 a { color: var(--text-link); text-decoration: none; transition: color var(--t-fast) var(--ease); }
1120 a:hover { color: var(--accent-hover); text-decoration: underline; text-underline-offset: 3px; }
1121 a:focus-visible {
1122 outline: none;
1123 box-shadow: 0 0 0 3px rgba(109,77,255,0.32);
1124 border-radius: 3px;
10121125 }
10131126
10141127 /* Whole-page atmosphere: very subtle gradient + dot-grid layered behind everything.
28422955 footer .footer-banner-warn { border-color: rgba(251,191,36,0.40); color: var(--yellow); }
28432956 footer .footer-banner-error { border-color: rgba(248,113,113,0.45); color: var(--red); }
28442957
2958 /* ============================================================ */
2959 /* Global toast notifications. */
2960 /* Slide-in from right, auto-dismiss, ARIA-live for SR users. */
2961 /* ============================================================ */
2962 .gx-toast {
2963 pointer-events: auto;
2964 display: inline-flex;
2965 align-items: flex-start;
2966 gap: 10px;
2967 min-width: 280px;
2968 max-width: min(440px, calc(100vw - 32px));
2969 padding: 12px 14px 12px 12px;
2970 background: var(--bg-elevated);
2971 border: 1px solid var(--border);
2972 border-radius: 10px;
2973 box-shadow:
2974 0 12px 36px -10px rgba(15,16,28,0.18),
2975 0 2px 6px rgba(15,16,28,0.04),
2976 0 0 0 1px rgba(15,16,28,0.02);
2977 color: var(--text);
2978 font-size: 13.5px;
2979 line-height: 1.45;
2980 opacity: 0;
2981 transform: translateX(16px);
2982 transition:
2983 opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1),
2984 transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
2985 }
2986 .gx-toast--in { opacity: 1; transform: translateX(0); }
2987 .gx-toast--out { opacity: 0; transform: translateX(16px); }
2988 .gx-toast__icon {
2989 flex-shrink: 0;
2990 width: 20px; height: 20px;
2991 border-radius: 50%;
2992 display: inline-flex;
2993 align-items: center;
2994 justify-content: center;
2995 font-size: 12px;
2996 font-weight: 700;
2997 line-height: 1;
2998 margin-top: 1px;
2999 }
3000 .gx-toast__text { flex: 1 1 auto; min-width: 0; padding-top: 2px; word-wrap: break-word; }
3001 .gx-toast__close {
3002 flex-shrink: 0;
3003 width: 22px; height: 22px;
3004 border: 0;
3005 background: transparent;
3006 color: var(--text-muted);
3007 font-size: 18px;
3008 line-height: 1;
3009 cursor: pointer;
3010 border-radius: 4px;
3011 padding: 0;
3012 margin: -2px -2px 0 0;
3013 transition: background var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
3014 }
3015 .gx-toast__close:hover { background: var(--bg-hover); color: var(--text); }
3016 .gx-toast--success .gx-toast__icon { background: rgba(5,150,105,0.14); color: var(--green); }
3017 .gx-toast--error .gx-toast__icon { background: rgba(220,38,38,0.14); color: var(--red); }
3018 .gx-toast--warn .gx-toast__icon { background: rgba(217,119,6,0.16); color: var(--yellow); }
3019 .gx-toast--info .gx-toast__icon { background: rgba(109,77,255,0.14); color: var(--accent); }
3020 @media (prefers-reduced-motion: reduce) {
3021 .gx-toast { transition: opacity 60ms linear; transform: none; }
3022 .gx-toast--in, .gx-toast--out { transform: none; }
3023 }
3024
28453025 /* ============================================================ */
28463026 /* Block U4 — cross-document view transitions. */
28473027 /* Chrome 126+, Edge, Safari 18.2+ get a soft 200ms fade on */
28483028