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:
+221
-6
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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…</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…</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,
|
||||
|
||||
Reference in New Issue
Block a user