fix: drawer modal close button + tab redesign

Two bugs and a redesign:

1) **X close button didn't close the modal**. The previous JS bound
   close via event delegation on the modal root, but
   panel.addEventListener('click', e => e.stopPropagation())
   swallowed the X click before it ever bubbled up. Switched to
   direct binding on every [data-drawer-close] element with an
   idempotent guard so HTMX swaps that re-render the panel don't
   double-bind.

2) **Stale legacy header in the server-rendered drawer body**. The
   /chats/<id>/drawer endpoint renders its own <header
   class="drawer-header"> with a duplicate <h2> and a broken
   inline-onclick close (targets the OLD id="drawer"
   semantics). Post-process: lift the bot name out of the legacy
   header into the modal title, then remove the header.

3) **Tabs**. The drawer has 10 sections — too dense as a single
   stack. Group into 4 tabs:
     Scene  : Scene + Activity
     Cast   : Guest + Group + Edges
     Story  : Events + Threads + Branches
     Turns  : Recent turns + Significance review

   Implementation is client-side post-swap so the
   /chats/<id>/drawer server response stays unchanged. Walks
   .drawer-section blocks, buckets by their <h3>, builds a
   <nav role="tablist"> and <section role="tabpanel">
   tree, and toggles visibility on click. Empty buckets (e.g. no
   Guest tab on a 1:1 chat) are hidden. Re-runs on every HTMX
   afterSwap so in-drawer form submits keep the tabs.

CSS tabs match the editorial aesthetic: no pills, no fills — a
single muted-amber underline rule under the active tab, Newsreader
serif label, ink-faint inactive / ink-default active. Empty hover
state, focus ring uses the amber accent.
This commit is contained in:
Joseph Doherty
2026-04-27 15:23:04 -04:00
parent 50ab0c8229
commit 2d1900bc8f
2 changed files with 233 additions and 17 deletions
+68 -4
View File
@@ -285,17 +285,81 @@ body.drawer-modal-open { overflow: hidden; }
/* 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. */
/* Tabs nav — sits at the top of .drawer-content and lets the user
pivot between Scene / Cast / Story / Turns groups. Underline-style
active indicator (a single muted-amber rule) keeps the editorial
feel — no pills, no boxes, no hover-fills. */
.drawer-panel-body .drawer-tabs {
display: flex;
gap: 6px;
margin: 0 -8px 14px; /* bleed the divider rule slightly past the body padding */
padding: 0 8px 0;
border-bottom: 1px solid var(--rule);
flex-wrap: wrap;
}
.drawer-panel-body .drawer-tab {
appearance: none;
background: transparent;
border: none;
padding: 10px 14px 12px;
margin-bottom: -1px; /* sit on top of the parent's border-bottom */
font-family: var(--serif);
font-size: 15px;
font-weight: 400;
letter-spacing: 0.02em;
color: var(--ink-faint);
border-bottom: 2px solid transparent;
cursor: pointer;
transition:
color var(--duration) var(--ease),
border-color var(--duration) var(--ease);
border-radius: 0; /* strip the global button radius */
}
.drawer-panel-body .drawer-tab:hover {
color: var(--ink);
background: transparent;
border-color: transparent;
}
.drawer-panel-body .drawer-tab.is-active {
color: var(--ink);
border-bottom-color: var(--accent);
background: transparent;
}
.drawer-panel-body .drawer-tab.is-active:hover {
background: transparent;
color: var(--ink);
}
.drawer-panel-body .drawer-tab:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 2px;
}
/* Panes — only one visible at a time. Uses [hidden] so the JS can
toggle attribute-driven instead of class-driven. */
.drawer-panel-body .drawer-tab-pane[hidden] { display: none; }
/* Sections inside a pane: drop the section-level rules since the
tabs already segment the content. Keep the section h3 as a sub-
heading inside its pane — useful when a tab groups multiple
sections (e.g. Cast = Guest + Group + Edges). */
.drawer-panel-body .drawer-section {
padding: 14px 0;
padding: 14px 0 18px;
border-bottom: 1px solid var(--rule);
}
.drawer-panel-body .drawer-section:last-child { border-bottom: none; }
.drawer-panel-body .drawer-tab-pane > .drawer-section:first-child { padding-top: 6px; }
.drawer-panel-body .drawer-tab-pane > .drawer-section:last-child { border-bottom: none; padding-bottom: 4px; }
/* When a pane has only one section, suppress the redundant h3 since
the tab label is the same name. */
.drawer-panel-body .drawer-tab-pane:has(> .drawer-section:only-child) > .drawer-section > h3 {
display: 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;
font-size: 12px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--accent);
}
+165 -13
View File
@@ -62,14 +62,28 @@
</div>
<script>
// 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.
// Drawer modal — open/close, focus management, and post-swap
// tab-grouping. The server's /chats/<id>/drawer response is left
// unchanged; this script post-processes the swapped HTML to:
// 1. Pull the bot name from the legacy <header><h2> and use it as
// the modal title.
// 2. Remove the legacy header (it has its own onclick="hidden"
// close that targets the OLD drawer semantics — broken now).
// 3. Walk .drawer-section blocks and group them into 4 tabs by
// their <h3> title:
// Scene : Scene, Activity
// Cast : Guest, Group, Edges
// Story : Events, Threads, Branches
// Turns : Recent turns, Significance review
// A tab nav is rendered above the sections; clicking switches
// which group is visible. Empty tabs (no matching sections) are
// hidden.
(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 titleEl = modal.querySelector('#drawer-modal-title');
const body = modal.querySelector('.drawer-panel-body');
const panel = modal.querySelector('.drawer-panel');
let lastFocus = null;
@@ -87,7 +101,10 @@
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());
requestAnimationFrame(() => {
const closeBtn = modal.querySelector('.drawer-panel-close');
if (closeBtn) closeBtn.focus();
});
}
function close() {
@@ -107,14 +124,29 @@
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();
}
});
// Bind close DIRECTLY to every element flagged data-drawer-close.
// Event delegation through .stopPropagation() previously swallowed
// the close button's click (it sits inside .drawer-panel, which
// stops propagation to keep backdrop clicks from leaking through
// the panel itself). Direct binding sidesteps that and keeps the
// panel-stops-propagation rule for everything else.
function bindCloseTargets(root) {
root.querySelectorAll('[data-drawer-close]').forEach((el) => {
// Idempotent: only bind once per element.
if (el.dataset.drawerCloseBound === '1') return;
el.dataset.drawerCloseBound = '1';
el.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
close();
});
});
}
bindCloseTargets(modal);
// Clicks inside the panel that AREN'T close targets must not
// reach the backdrop click handler. (We don't have one currently
// — backdrop close is via data-drawer-close on the backdrop div —
// but stopPropagation here is defensive against future handlers.)
panel.addEventListener('click', (e) => e.stopPropagation());
// Escape closes only when the modal is open.
@@ -124,6 +156,126 @@
close();
}
});
// ---- Tabs: group server-rendered .drawer-section blocks ----
const TAB_GROUPS = [
{ id: 'scene', label: 'Scene', sections: ['Scene', 'Activity'] },
{ id: 'cast', label: 'Cast', sections: ['Guest', 'Group', 'Edges'] },
{ id: 'story', label: 'Story', sections: ['Events', 'Threads', 'Branches'] },
{ id: 'turns', label: 'Turns', sections: ['Recent turns', 'Significance review'] },
];
function tabIdForSection(h3Text) {
const t = (h3Text || '').trim();
for (const g of TAB_GROUPS) {
if (g.sections.includes(t)) return g.id;
}
return 'scene'; // unknown sections fall into the first tab
}
function buildTabs() {
// Clean up the legacy server-rendered header inside the body
// (duplicate close + duplicate title).
const legacyHeader = body.querySelector(':scope > .drawer-content > .drawer-header');
if (legacyHeader) {
// Promote the bot name to the modal title before discarding.
const h2 = legacyHeader.querySelector('h2');
if (h2 && h2.textContent.trim()) {
titleEl.textContent = h2.textContent.trim();
}
legacyHeader.remove();
}
// The drawer-content wrapper holds all the sections. Group them.
const content = body.querySelector('.drawer-content');
if (!content) return;
const sections = Array.from(content.querySelectorAll(':scope > .drawer-section'));
if (sections.length === 0) return;
// Bucket sections by tab id.
const buckets = new Map(TAB_GROUPS.map((g) => [g.id, []]));
for (const sec of sections) {
const h3 = sec.querySelector(':scope > h3');
const tabId = tabIdForSection(h3 ? h3.textContent : '');
buckets.get(tabId).push(sec);
}
// Build the tab nav. Skip empty buckets so the nav reflects
// what the chat actually has (e.g. no Guest tab when 1:1).
const nav = document.createElement('nav');
nav.className = 'drawer-tabs';
nav.setAttribute('role', 'tablist');
const panes = document.createElement('div');
panes.className = 'drawer-tab-panes';
let firstActive = null;
for (const group of TAB_GROUPS) {
const items = buckets.get(group.id);
if (!items.length) continue;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'drawer-tab';
btn.setAttribute('role', 'tab');
btn.id = `drawer-tab-${group.id}`;
btn.dataset.tabTarget = group.id;
btn.textContent = group.label;
btn.setAttribute('aria-controls', `drawer-pane-${group.id}`);
nav.appendChild(btn);
const pane = document.createElement('section');
pane.className = 'drawer-tab-pane';
pane.id = `drawer-pane-${group.id}`;
pane.setAttribute('role', 'tabpanel');
pane.setAttribute('aria-labelledby', `drawer-tab-${group.id}`);
// Move the section nodes into the pane (preserves any HTMX
// event listeners and the sections' interactive forms).
for (const sec of items) pane.appendChild(sec);
panes.appendChild(pane);
if (!firstActive) firstActive = group.id;
}
// Replace the existing content with [nav][panes].
content.innerHTML = '';
content.appendChild(nav);
content.appendChild(panes);
// Tab click handler.
nav.addEventListener('click', (e) => {
const target = e.target;
if (!(target instanceof HTMLElement)) return;
const tabId = target.dataset.tabTarget;
if (!tabId) return;
activateTab(content, tabId);
});
if (firstActive) activateTab(content, firstActive);
}
function activateTab(content, tabId) {
content.querySelectorAll('.drawer-tab').forEach((btn) => {
const isActive = btn.dataset.tabTarget === tabId;
btn.classList.toggle('is-active', isActive);
btn.setAttribute('aria-selected', String(isActive));
btn.setAttribute('tabindex', isActive ? '0' : '-1');
});
content.querySelectorAll('.drawer-tab-pane').forEach((pane) => {
const isActive = pane.id === `drawer-pane-${tabId}`;
pane.toggleAttribute('hidden', !isActive);
});
}
// Run after every HTMX swap into the panel body. Covers the
// initial open AND any subsequent server-driven re-render
// (e.g. an in-drawer form submit that returns refreshed HTML).
body.addEventListener('htmx:afterSwap', () => {
buildTabs();
bindCloseTargets(modal);
});
})();
</script>
<script>