feat: drawer is now a centered modal popup (director's notebook aesthetic)

Drawer was a fixed right-side <aside> sliding off the chat. Replaces
that with a centered modal dialog overlay that floats on top of the
chat — backdrop click or Escape closes, focus restores to the toggle
button on close.

Aesthetic direction is editorial: warm-paper panel on a deep
ink-blue inky-blur backdrop, a single muted-amber accent (the
hairline rule along the top of the panel and the dot after the
title), Newsreader serif for the title only (body keeps system-ui
for read-flow), controlled motion (no bounce) — translate(8px) +
scale(0.98) to neutral over 180ms, with prefers-reduced-motion
killing the transform entirely.

Implementation:
- chat/templates/base.html: load Newsreader from Google Fonts
  (preconnected, swap, only the two weights we use).
- chat/templates/chat.html: replace the <aside id="drawer"> with
  modal markup (role="dialog", aria-modal). HTMX trigger swaps
  from "revealed" to a custom "drawer-open from:body" event,
  dispatched by the open() handler so the drawer body re-fetches
  on every open. Escape, backdrop click, and the close × all
  close. Focus returns to the toggle on close.
- chat/static/app.css: full drawer-modal stylesheet scoped via a
  --paper / --ink / --accent token block on the modal root, with
  scoped overrides for the existing .drawer-section / .drawer-row
  / button classes the server renders into the panel body so the
  legacy drawer markup still renders cleanly on warm paper.

aria-controls on the toggle now points at the dialog (drawer-modal),
aria-haspopup="dialog" for AT semantics.
This commit is contained in:
Joseph Doherty
2026-04-27 15:17:34 -04:00
parent 49be3cf4b9
commit 50ab0c8229
3 changed files with 322 additions and 20 deletions
+221 -6
View File
@@ -106,12 +106,227 @@ code { font-family: ui-monospace, "SF Mono", Menlo, monospace; }
}
.turn-input { display: flex; flex-direction: column; gap: 8px; padding-top: 12px; border-top: 1px solid #e5e5e5; }
.turn-input textarea { padding: 8px; font: inherit; border: 1px solid #ccc; border-radius: 3px; resize: vertical; }
.drawer { position: fixed; top: 0; right: 0; width: 360px; height: 100vh; background: #fff; border-left: 1px solid #e5e5e5; padding: 16px; overflow-y: auto; z-index: 10; }
.drawer[hidden] { display: none; }
.drawer-content { display: flex; flex-direction: column; gap: 16px; }
.drawer-header { display: flex; align-items: center; justify-content: space-between; padding-bottom: 8px; border-bottom: 1px solid #e5e5e5; }
.drawer-close { border: none; background: transparent; color: #1c1c1c; font-size: 24px; padding: 0 4px; cursor: pointer; }
.drawer-section h3 { margin: 0 0 8px; font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; color: #666; }
/* ===========================================================
Drawer — director's notebook overlay
===========================================================
Editorial popup design: a warm-paper panel floats over an inky
blurred backdrop. Single accent serif (Newsreader) at the title,
single muted-amber accent for primary interactives, generous
spacing, controlled motion.
Design tokens (scoped to the drawer so the rest of the app stays
on its existing palette).
*/
.drawer-modal {
--paper: #f6f1e8; /* warm off-white panel */
--paper-edge: #e7dfce;
--ink: #1a1d29; /* deep ink-blue */
--ink-soft: #38405a;
--ink-faint: #6c7390;
--accent: #b97e30; /* muted amber */
--accent-soft: #efd9b1;
--rule: rgba(26, 29, 41, 0.10);
--shadow-near: 0 1px 2px rgba(26, 29, 41, 0.08);
--shadow-far: 0 32px 64px -24px rgba(26, 29, 41, 0.45),
0 12px 24px -12px rgba(26, 29, 41, 0.25);
--serif: "Newsreader", "Iowan Old Style", Georgia, serif;
--duration: 180ms;
--ease: cubic-bezier(0.22, 0.61, 0.36, 1);
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: clamp(16px, 4vw, 48px);
/* Open/close transitions live here so the backdrop and panel
can fade together; .is-open promotes both to their visible
end-states. */
opacity: 0;
transition: opacity var(--duration) var(--ease);
}
.drawer-modal[hidden] { display: none; }
.drawer-modal.is-open { opacity: 1; }
.drawer-modal-backdrop {
position: absolute;
inset: 0;
background:
radial-gradient(circle at 30% 25%, rgba(26, 29, 41, 0.55), rgba(26, 29, 41, 0.85) 75%);
backdrop-filter: blur(6px) saturate(1.05);
-webkit-backdrop-filter: blur(6px) saturate(1.05);
}
/* The chat behind the modal stops scrolling and loses focus
entirely. body class set by the JS; resets on close. */
body.drawer-modal-open { overflow: hidden; }
.drawer-panel {
position: relative;
width: 100%;
max-width: 720px;
max-height: min(82vh, 760px);
display: flex;
flex-direction: column;
background: var(--paper);
border-radius: 6px;
box-shadow: var(--shadow-far);
/* Subtle warm-paper texture: a single soft inner highlight at the
top edge plus a faint vignette toward the bottom. Cheap, no
external image. */
background-image:
linear-gradient(180deg,
rgba(255, 255, 255, 0.50) 0%,
rgba(255, 255, 255, 0.00) 18%,
rgba(0, 0, 0, 0.00) 80%,
rgba(120, 100, 70, 0.06) 100%);
/* A 1px ink rule at the very top, set INSIDE the radius so the
corners stay clean. ::before serves as a hairline accent. */
overflow: hidden;
/* Open/close: the backdrop fades; the panel additionally lifts
slightly and scales from 98% to 100%. Controlled, no bounce. */
transform: translateY(8px) scale(0.98);
transition:
transform var(--duration) var(--ease),
opacity var(--duration) var(--ease);
opacity: 0.98;
}
.drawer-modal.is-open .drawer-panel {
transform: translateY(0) scale(1);
opacity: 1;
}
.drawer-panel::before {
content: "";
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg,
transparent 0%, var(--accent) 14%, var(--accent) 86%, transparent 100%);
opacity: 0.85;
}
.drawer-panel-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
padding: 22px 28px 14px;
border-bottom: 1px solid var(--rule);
flex-shrink: 0;
}
.drawer-panel-header h2 {
margin: 0;
font-family: var(--serif);
font-weight: 500;
font-size: clamp(22px, 2.4vw, 28px);
letter-spacing: -0.01em;
color: var(--ink);
/* Tiny editorial flourish: lowercase the title so it reads like
a column header in a printed broadside. */
text-transform: lowercase;
}
.drawer-panel-header h2::after {
content: "";
display: inline-block;
width: 6px;
height: 6px;
margin-left: 10px;
border-radius: 50%;
background: var(--accent);
vertical-align: middle;
transform: translateY(-2px);
}
.drawer-panel-close {
appearance: none;
background: transparent;
border: none;
border-radius: 4px;
color: var(--ink-soft);
font-family: var(--serif);
font-size: 28px;
line-height: 1;
width: 36px;
height: 36px;
cursor: pointer;
transition:
background-color var(--duration) var(--ease),
color var(--duration) var(--ease),
transform var(--duration) var(--ease);
}
.drawer-panel-close:hover {
background: rgba(26, 29, 41, 0.06);
color: var(--ink);
transform: rotate(90deg);
}
.drawer-panel-close:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.drawer-panel-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 18px 28px 28px;
/* Restrict typography inside the body to the existing app font
so the existing drawer markup (forms, lists, buttons rendered
by /chats/<id>/drawer) keeps its current density and read-flow.
We only re-color a few items so they sit on the warm paper. */
color: var(--ink);
}
.drawer-panel-body .drawer-panel-loading {
font-family: var(--serif);
font-style: italic;
color: var(--ink-faint);
}
/* Scoped overrides for the drawer-content the server renders into
.drawer-panel-body. Keeps the existing class names working but
re-tunes them for the warm-paper context. */
.drawer-panel-body .drawer-section {
padding: 14px 0;
border-bottom: 1px solid var(--rule);
}
.drawer-panel-body .drawer-section:last-child { border-bottom: none; }
.drawer-panel-body .drawer-section h3 {
margin: 0 0 10px;
font-family: var(--serif);
font-weight: 500;
font-size: 13px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent);
}
.drawer-panel-body .activity-row,
.drawer-panel-body .edge-row { margin-bottom: 12px; }
.drawer-panel-body .activity-row strong,
.drawer-panel-body .edge-row strong { display: block; color: var(--ink); }
.drawer-panel-body .muted { color: var(--ink-faint); }
.drawer-panel-body button,
.drawer-panel-body .btn {
background: var(--ink);
border: 1px solid var(--ink);
color: var(--paper);
border-radius: 3px;
}
.drawer-panel-body button:hover,
.drawer-panel-body .btn:hover {
background: var(--accent);
border-color: var(--accent);
color: var(--ink);
}
/* Respect reduced-motion preference: no scale, no rotate, no
blur transition — just the opacity fade. */
@media (prefers-reduced-motion: reduce) {
.drawer-modal,
.drawer-panel,
.drawer-panel-close { transition-duration: 0ms; }
.drawer-panel { transform: none; }
.drawer-panel-close:hover { transform: none; }
}
.activity-row, .edge-row { margin-bottom: 12px; }
.activity-row strong, .edge-row strong { display: block; }
.memory-list { list-style: none; padding: 0; margin: 0; }
+7
View File
@@ -5,6 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}chat{% endblock %}</title>
<link rel="stylesheet" href="/static/app.css">
<!-- Newsreader: refined editorial serif for accent typography
(drawer modal title, etc.). Body stays system-ui for read-
flow legibility. Subset to the weight we use to keep the
payload tiny. -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Newsreader:opsz,wght@6..72,400;6..72,500&display=swap">
<script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
<!-- htmx 1.x bundles its SSE extension at /dist/ext/sse.js. The
standalone htmx-ext-sse@2.x package is for htmx 2.x and is
+94 -14
View File
@@ -7,7 +7,9 @@
<header class="chat-header">
<h1>{{ host_bot.name }}</h1>
<div class="chat-meta muted">{{ chat.time }}</div>
<button class="drawer-toggle" type="button" aria-controls="drawer" aria-expanded="false">Drawer</button>
<button class="drawer-toggle" type="button"
aria-controls="drawer-modal" aria-expanded="false"
aria-haspopup="dialog">Drawer</button>
</header>
<section class="timeline" id="timeline"
@@ -30,21 +32,99 @@
<button type="submit">Send</button>
</form>
<aside class="drawer" id="drawer" hidden
hx-get="/chats/{{ chat.id }}/drawer"
hx-trigger="revealed"
hx-swap="innerHTML">
<p class="muted">Loading drawer&hellip;</p>
</aside>
</div>
<!-- Drawer modal — director's notebook overlay.
Sits outside .chat-shell so its position:fixed backdrop covers the
whole viewport. The panel still pulls its inner HTML from
/chats/<id>/drawer via HTMX; trigger is a custom 'drawer-open'
event that the open/close script dispatches each time the modal
opens, so the content refreshes on every open. -->
<div class="drawer-modal" id="drawer-modal" hidden
role="dialog"
aria-modal="true"
aria-labelledby="drawer-modal-title">
<div class="drawer-modal-backdrop" data-drawer-close></div>
<article class="drawer-panel">
<header class="drawer-panel-header">
<h2 id="drawer-modal-title">Drawer</h2>
<button class="drawer-panel-close" type="button"
data-drawer-close
aria-label="Close drawer">×</button>
</header>
<div class="drawer-panel-body" id="drawer"
hx-get="/chats/{{ chat.id }}/drawer"
hx-trigger="drawer-open from:body"
hx-swap="innerHTML">
<p class="muted drawer-panel-loading">Loading&hellip;</p>
</div>
</article>
</div>
<script>
document.querySelector('.drawer-toggle')?.addEventListener('click', (e) => {
const drawer = document.getElementById('drawer');
const isHidden = drawer.hasAttribute('hidden');
if (isHidden) drawer.removeAttribute('hidden');
else drawer.setAttribute('hidden', '');
e.target.setAttribute('aria-expanded', String(isHidden));
});
// Drawer modal — open/close + focus management. Re-fetches content
// from the server on every open by dispatching a 'drawer-open' event
// that the panel's hx-trigger picks up.
(function () {
const modal = document.getElementById('drawer-modal');
const toggle = document.querySelector('.drawer-toggle');
if (!modal || !toggle) return;
const closeButton = modal.querySelector('.drawer-panel-close');
const panel = modal.querySelector('.drawer-panel');
let lastFocus = null;
function open() {
if (!modal.hasAttribute('hidden')) return;
lastFocus = document.activeElement;
modal.removeAttribute('hidden');
// Force reflow so the .is-open class triggers the transition.
void modal.offsetWidth;
modal.classList.add('is-open');
toggle.setAttribute('aria-expanded', 'true');
document.body.classList.add('drawer-modal-open');
// Re-fetch drawer content via the panel's hx-trigger.
document.body.dispatchEvent(new CustomEvent('drawer-open'));
// Focus the close button so Escape / Enter both work
// immediately and screen readers announce the dialog.
requestAnimationFrame(() => closeButton.focus());
}
function close() {
if (modal.hasAttribute('hidden')) return;
modal.classList.remove('is-open');
toggle.setAttribute('aria-expanded', 'false');
document.body.classList.remove('drawer-modal-open');
// Wait for the fade-out before fully hiding so the transition
// can play. Match the CSS duration.
setTimeout(() => {
modal.setAttribute('hidden', '');
if (lastFocus && typeof lastFocus.focus === 'function') {
lastFocus.focus();
}
}, 180);
}
toggle.addEventListener('click', open);
// Backdrop click closes; clicks INSIDE the panel must not bubble
// up and trip the backdrop handler.
modal.addEventListener('click', (e) => {
const target = e.target;
if (target instanceof HTMLElement && target.hasAttribute('data-drawer-close')) {
close();
}
});
panel.addEventListener('click', (e) => e.stopPropagation());
// Escape closes only when the modal is open.
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !modal.hasAttribute('hidden')) {
e.preventDefault();
close();
}
});
})();
</script>
<script>
// Streaming UX (T34): typing indicator, Stop button, send-lock,