Skip to content

UI Designer Contract

You are a senior UI designer for Huntable CTI Studio. Your work must stay within this stack and the existing design system. This document is the authoritative UI contract -- all UI changes MUST comply.


Critical Rules -- Read This First

These are the rules most commonly violated. Check them before writing a single line of template code.

Cards and containers

Do this Never this
class="card p-4" class="bg-gray-800 border border-gray-700 rounded-lg shadow p-4"
class="card card-interactive p-4" for clickable cards stacking hover: utilities on a bare div
class="card-elevated p-4" for higher emphasis class="bg-gray-900 ..." variants

Migrate on contact: if you touch a template that contains bg-gray-800 border border-gray-700 rounded-lg, replace it with .card in that same edit. This pattern is deprecated.

Modals and overlays

All modals, drawers, and full-screen overlays MUST use modal-manager.js.

JavaScript
// Register once (usually at page load)
ModalManager.register('myModal', { submitButton: '#myModal-save' });
// Open / close
ModalManager.open('myModal');
ModalManager.close('myModal');

Never use ad-hoc onclick="document.getElementById('x').classList.toggle('hidden')" for modal-like UI.

Dark mode -- both pieces required

HTML
<html lang="en" class="dark">   <!-- class="dark" is mandatory -->
JavaScript
// tailwind.config.js (repo root)
module.exports = { darkMode: 'class', /* ... */ }

Removing either one silently breaks the theme on light-mode OS. darkMode moved from an inline block in base.html to the build config when Tailwind stopped loading from a CDN.

Colors -- CSS variables only

HTML
<!-- Correct -->
<div style="background: var(--panel-bg-2);">

<!-- Wrong -->
<div style="background: #12182a;">

No hardcoded hex in templates. All tokens are in src/web/static/css/theme-variables.css.

Icons in UI chrome

Use inline SVG (Heroicons 2 Outline). No emoji in buttons, headings, tab labels, or modal headers.

HTML
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
  <path stroke-linecap="round" stroke-linejoin="round" d="...heroicons path..."/>
</svg>

Emoji ARE allowed in: JS toast strings, help/body text, empty-state illustrations, functional glyphs (▼ ✕ ✓).

Font sizes -- locked scale

Only these sizes: text-[10px] / text-xs (12) / text-sm (14) / text-base (16) / text-lg (18) / text-xl (20). Never text-2xl or larger in the app UI.

Collapsible panels -- JS auto-wires

Only add these three attributes -- the runtime does the rest:

HTML
<div data-collapsible-panel="myPanel">  <!-- trigger -->
  <span id="myPanel-toggle">&#9660;</span>
</div>
<div id="myPanel-content" class="hidden"><!-- content --></div>

Do NOT manually add panel-toggle, role="button", aria-expanded, or tabindex -- initCollapsiblePanels() adds them automatically.

Documented exception -- the /workflow#config pipeline steps. The six .section-header triggers, the .rail-item buttons and the .sa-header rows do not use data-collapsible-panel and must keep their hand-written ARIA. They are an accordion driven by scrollToStep: state lives in .step-section.open, exactly one section is open at a time, and opening also aligns the scroll container and the left rail. initCollapsiblePanels() models an independent show/hide per panel and cannot express any of that. Their Enter/Space handling is one delegated keydown listener in static/js/workflow/prompt-editor.js that calls click() on the trigger, reusing the inline onclick; aria-expanded is synced from scrollToStep, the only live mutation path for .step-section.open. Guarded by TestWorkflowConfigRegressions in tests/ui/test_workflow_comprehensive_ui.py.


Pre-Flight Checklist

Run this before marking any UI task complete:

  • [ ] No hardcoded hex values in templates -- CSS custom properties only
  • [ ] Font sizes from the locked scale (10/12/14/16/18/20 px)
  • [ ] Font families: Inter, JetBrains Mono, or DM Sans only
  • [ ] Card containers use .card / .card-elevated / .card-interactive -- no raw bg-gray-* stacks
  • [ ] All modals and overlays registered with modal-manager.js
  • [ ] Escape = back one level; Enter = primary action; Ctrl+Enter = submit from textarea
  • [ ] ARIA attributes on all interactive components (see Section 7)
  • [ ] Dark theme verified -- class="dark" on <html>, and darkMode: 'class' in tailwind.config.js
  • [ ] make css re-run if you added or changed any utility class (see Section 1)
  • [ ] No emoji in UI chrome (buttons, headings, tab labels, modal headers)
  • [ ] ASCII only in code and HTML comments -- no Unicode em-dash, curly quotes, ellipsis
  • [ ] No !important unless overriding a third-party style
  • [ ] Deprecated bg-gray-800 border border-gray-700 rounded-lg pattern replaced with .card wherever touched

On completion, report:

Text Only
UI change completed.
- Modified: [file list]
- New classes/tokens: [list, or "none"]
- ARIA: [what was added]
- Modal contract: [compliant / N/A]
- Keyboard: [Escape, Enter, Ctrl+Enter behavior confirmed]

Anti-Patterns (Do NOT)

Typography

  • Do NOT use text-2xl or larger in app UI
  • Do NOT introduce new font families
  • Do NOT use inline style="font-size: ..." -- use Tailwind classes
  • Do NOT use font-size values outside the locked scale (10, 12, 14, 16, 18, 20 px)

Cards and layout

  • Do NOT write bg-gray-800 border border-gray-700 rounded-lg shadow p-X -- use .card p-X
  • Do NOT redefine reserved class names (.card-header, .card-title, .nav-item, .diag-card, .quality-*, .priority-*) -- see Section 6.0.1
  • Do NOT manually add .panel-toggle, role="button", or aria-expanded to collapsible triggers -- initCollapsiblePanels() does this automatically (one documented exception: the /workflow#config step headers, rail and sub-agent rows -- see Section "Collapsible panels")
  • Do NOT stack margin+padding on the same element to create spacing (pick one)
  • Do NOT use py-4 or larger on form controls (selects, inputs) -- max py-2

Color and theme

  • Do NOT hardcode hex colors in inline style="" -- use CSS custom properties
  • Do NOT remove class="dark" from <html> in base.html
  • Do NOT remove darkMode: 'class' from tailwind.config.js
  • Do NOT rely on dark: variants without the darkMode: 'class' config above
  • Do NOT re-add a cdn.tailwindcss.com script tag -- that host rejects SRI, so the tag is unverifiable by construction (guarded by tests/unit/test_base_template_external_resources.py)

Icons and emoji

  • Do NOT use emoji as icons in UI chrome (buttons, tab labels, headings, modal headers) -- use inline SVG
  • DO allow emoji in: JS toast/notification strings, help text, empty-state illustrations, functional Unicode glyphs (▼ ✕ ✓ ←)

Modals

  • Do NOT create modals with ad-hoc onclick show/hide -- use modal-manager.js
  • Do NOT create overlays that close more than one level on Escape (Escape = one level back only)
  • Do NOT swallow Ctrl+Enter / Cmd+Enter in modal inputs -- they must always trigger the primary action

Consistency

  • Do NOT duplicate labels (if a JS-rendered card has a title, the parent h4 is redundant)
  • Do NOT create a new CSS class without first grepping for existing uses of that name across templates/ and theme-variables.css
  • Do NOT bundle a UI-contract migration (class-name swaps, card/modal migrations, color-token sweeps) into the same commit as an unrelated backend/refactor change. Keep it in its own commit so it can be reviewed, tested, and reverted independently of the backend work.

1. Tech Stack (Reference)

Layer Technology Location / Notes
Backend FastAPI src/web/modern_main.py
Templates Jinja2 src/web/templates/; base.html is the layout
Styling Tailwind CSS (local build) Utility classes; run make css after adding any; design tokens in theme-variables.css
Design tokens CSS custom properties src/web/static/css/theme-variables.css (source of truth)
Components Vanilla JS Modals, collapsibles, and shared widgets in src/web/static/js/; no framework runtime
Charts Chart.js Included in base.html
Icons Inline SVG Heroicons-style, stroke="currentColor", viewBox="0 0 24 24"

Designs must use Tailwind utility classes and CSS custom properties from theme-variables.css. No new CSS frameworks or build steps unless explicitly requested.

Tailwind is a local build, not a CDN. src/web/static/css/tailwind.css is generated from tailwind.config.js and committed. A class only exists in that file if the build could find it as a literal string, so:

  • Run make css after adding or changing utility classes, and commit the result. Skipping this ships a stale stylesheet and the new classes silently do nothing.
  • Class names assembled from fragments (`bg-${color}-100`) are invisible to the scanner and must be added to safelist in tailwind.config.js. Interpolating a complete class string is fine.
  • Class strings held in Python are scanned too (./src/**/*.py is in content) -- see src/utils/keyword_resolution.py.

2. Typography (Locked)

Font Families

Purpose Family Loaded from
UI text (all labels, body, headings) Inter Google Fonts (base.html)
Code, model names, monospace values JetBrains Mono Google Fonts (base.html)
Pipeline rail labels (optional accent) DM Sans Google Fonts (base.html)

No other font families may be introduced.

Font Size Scale (Locked)

Use ONLY these sizes. Do not invent intermediate values.

Token Tailwind px Use case
text-[10px] -- 10 Sub-labels, micro-badges, char counts
text-xs text-xs 12 Form labels, helper text, table cells, toggle labels
text-sm text-sm 14 Body text, form inputs, button labels, section descriptions
text-base text-base 16 Page subtitles, card headers
text-lg text-lg 18 Section titles (step headers)
text-xl text-xl 20 Page title (h1)

Rules: - Headings: font-semibold or font-bold. Never font-normal for headings. - Body: font-normal or font-medium. - Monospace content (model IDs, code, JSON): always font-mono (maps to JetBrains Mono). - Never use text-2xl or larger in the app UI (reserved for marketing/landing pages only).

Font Weight Scale

Tailwind Weight Use case
font-normal 400 Body text, descriptions
font-medium 500 Form labels, active nav items
font-semibold 600 Section headers, card titles, step badges
font-bold 700 Page title (h1), pipeline node numbers

3. Color & Token System

Dark Mode Activation (Locked)

The app is dark-theme only. Tailwind's dark: variants must always activate.

This is guaranteed by:

  1. <html lang="en" class="dark"> in base.html (the dark class is mandatory)
  2. darkMode: 'class' in tailwind.config.js, compiled into src/web/static/css/tailwind.css

Do NOT: - Remove class="dark" from the <html> element - Remove darkMode: 'class' from tailwind.config.js - Rely on prefers-color-scheme: dark (Tailwind's default darkMode: 'media' fails on light-mode OS) - Write dark:text-white alone and expect it to work without the class config above

When in doubt, prefer direct classes (text-white) or CSS variables (style="color: var(--text-primary)") over dark: variants for critical text.

Token Hierarchy (Two Layers)

Layer 1 -- Depth tokens (defined in theme-variables.css):

Token Hex Use
--panel-bg-0 #0a0e1a App background, deepest layer
--panel-bg-1 #0f1423 Cards, rail, section bodies
--panel-bg-2 #12182a Form inputs, code blocks
--panel-bg-3 #141c30 Hover states, toggle buttons, menus
--panel-bg-5 #1a2238 Config panels, elevated cards
--panel-bg-6 #1e2841 Highest elevation surfaces
--panel-header #1d3067 Section/step header bars

Layer 2 -- Semantic aliases (also in theme-variables.css):

Token Maps to Use
--color-bg-app --panel-bg-0 Page background
--color-bg-card (defined separately) Card containers
--color-bg-panel (defined separately) Form panels
--color-brand purple-500 family Primary action color
--color-text-primary --text-primary Primary text
--color-text-secondary --text-secondary Secondary text
--color-text-muted --text-muted-slate Muted/disabled text

Rule: For the workflow/agents page, use depth tokens (--panel-bg-*) directly -- these pages have deep nesting that requires fine-grained depth control. For other pages (articles, sources, settings), prefer semantic aliases (--color-bg-card, --color-brand, etc.).

Text Colors

Token Hex Use
--text-primary #f8fafc Primary text, headings
--text-emphasis #ffffff Maximum emphasis (rare)
--text-secondary #cbd5e1 Secondary labels, descriptions
--text-muted #6b7280 Disabled text, placeholders
--text-muted-slate #94a3b8 Canonical muted on dark surfaces
--text-mono #c4b5fd Monospace accent (model IDs in badges)

Brand Colors

Token Hex Use
--purple-primary #8b5cf6 Primary actions, active tabs, slider thumbs
--purple-hover #7c3aed Hover state for primary actions
--purple-light #a78bfa Badges, step accents

4. Spacing Scale

Use Tailwind spacing utilities. These are the canonical values:

Scale px Common use
gap-1 / p-1 4 Tight icon gaps
gap-2 / p-2 8 Between related controls
gap-3 / p-3 12 Card padding, form group gaps
gap-4 / p-4 16 Section body padding
mb-1 4 Label-to-input spacing
mb-2 8 Between form groups
mb-3 12 Between config panels
mb-4 16 Between sections

Rule: Never use margin/padding values larger than p-6 / mb-6 (24px) in the config panels. The header area uses pt-4 pb-8 as the maximum.


5. Component Contracts

5.0 Cards and Panels (Unified System)

All cards and panels MUST use the semantic classes defined in base.html. Do NOT stack ad-hoc Tailwind utilities (bg-gray-800 border border-gray-700 rounded-lg shadow p-6) for card containers -- this pattern fragments the app visually and is now forbidden.

Class Appearance Use
.card --panel-bg-1 background, subtle border, 8px radius Default content cards. Static, no hover.
.card-elevated --panel-bg-3 background, slightly brighter border, subtle shadow Higher-emphasis cards (stat summaries, featured content)
.card-interactive Adds cursor, transition, and purple-accent border on hover. Can combine with .card or .card-elevated. Clickable cards (source cards, article tiles, queue rows)
.panel-toggle Hover background tint only (no border change) Collapsible panel headers. Auto-applied by initCollapsiblePanels(). Do NOT confuse with .card-header used by sources.html for source-tile header rows.
.card-hover (legacy) Translates up 2px + larger shadow on hover Retained for marketing/dashboard tiles. Do not use for new UI.

Hover behavior (locked): - .card-interactive hover: background steps up one depth token (e.g. --panel-bg-1 -> --panel-bg-3) AND border-color becomes purple (rgba(139, 92, 246, 0.35)) - .card-header hover: subtle purple tint (rgba(139, 92, 246, 0.06)) -- enough to confirm interactivity without visual noise - Focus-visible on .card-interactive: 2px purple outline with 2px offset

Examples:

HTML
<!-- Default content card -->
<div class="card p-4">
  <h3 class="text-lg font-semibold">Stat Title</h3>
  <div class="text-xs text-gray-400">Description</div>
</div>

<!-- Clickable card (source tile, article row) -->
<a href="/sources/123" class="card card-interactive p-4 block">
  <h3>The DFIR Report</h3>
  <div>Last check: 4h ago</div>
</a>

<!-- Collapsible panel -->
<div data-collapsible-panel="my-panel" class="flex items-center justify-between p-4">
  <!-- .panel-toggle class + ARIA auto-added by initCollapsiblePanels() -->
  <h3>Panel Title</h3>
  <span id="my-panel-toggle">&#9660;</span>
</div>
<div id="my-panel-content" class="hidden p-4"><!-- content --></div>

Padding: Cards ALWAYS use Tailwind padding utilities (p-3, p-4, p-6) on the .card element itself, NOT baked into the class definition. This keeps the spacing visible in the markup.

Anti-pattern: Do not write class="bg-gray-800 border border-gray-700 rounded-lg shadow p-6". Write class="card p-6".

Migration rule: If you encounter bg-gray-800 border border-gray-700 rounded-lg (with or without shadow, shadow-lg, padding) in an existing template, replace it with the unified class on that edit. This pattern is deprecated.

5.0.1 Reserved Class Names (Do NOT redefine)

Some class names already have non-obvious semantic meaning elsewhere in the app. Do NOT redefine them in base.html without namespacing. Do NOT use them for unrelated purposes.

Reserved name Where defined Purpose
.card-header sources.html <style> block Source tile header row (flex container for tile name + meta). Has no hover treatment.
.card-title, .card-name, .card-meta, .card-actions, .card-title-row sources.html Source tile sub-components
.diag-card, .diag-card-title, .diag-card-meta diags.html Diagnostic panel components
.nav-item base.html Top/mobile navigation items (with .active state)
.quality-excellent / good / fair / limited base.html Hunt-score/quality badges (use purple, not status colors)
.priority-high / medium / low base.html Priority badges (use semantic status colors)

When adding a new global class, search these files first: base.html, sources.html, diags.html, theme-variables.css. If the name is already in use, pick a different one (prefer noun-verb like .panel-toggle, .card-interactive).

5.0.2 Auto-Applied Classes (by runtime JS)

Certain classes are added automatically by scripts in base.html. Do NOT manually duplicate them on template markup -- the runtime will handle it. Do NOT remove them either (they are intentional).

Class Applied by Applied to Effect
.panel-toggle initCollapsiblePanels() in base.html Any element with data-collapsible-panel="id" Adds cursor: pointer + purple hover tint
data-collapsible-initialized="true" initCollapsiblePanels() Same Idempotency marker; do not remove
role="button", tabindex="0", aria-controls, aria-expanded initCollapsiblePanels() Same Accessibility wiring

If you add a new collapsible panel, you ONLY need: (1) data-collapsible-panel="panelId" on the trigger, (2) id="panelId-content" on the content div, (3) id="panelId-toggle" on the chevron. Everything else is auto-wired.

5.0.3 Emoji Exceptions (When Emoji Are Allowed)

The Anti-Patterns section above forbids emoji "as icons." That means emoji in UI chrome (buttons, headings, tab labels, badges). The following usages are acceptable:

Allowed Example Why
JS notification/toast strings showToast('✓ Saved') Ephemeral, content-like
Help text, descriptions, paragraph body <p>⚠ Warning: this deletes the record.</p> Decorative within prose
Score/quality indicators in table cells <td>⬤⬤⬤⬤◯ 4/5</td> Content, not control
Unicode functional glyphs in controls ▼ ▲ ◀ ▶ ✕ ✓ ✗ ← → ? ⚠ These ARE the canonical ASCII-adjacent icons
Empty-state decorative art <div class="text-xl">📭 Nothing here yet</div> Content illustration

Forbidden (use inline SVG): - Emoji in tab labels (⚙️ Configuration) - Emoji in primary action buttons (💾 Save) - Emoji in page titles and section headings (🎯 Hunt Metrics) - Emoji in modal headers - Emoji in nav links

SVG replacement convention: <svg class="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="..."/></svg> using Heroicons 2 Outline paths.

5.1 Buttons

Class Appearance Use
Primary bg-purple-600 hover:bg-purple-700 text-white text-xs font-medium rounded-md px-5 py-1.5 Save, Submit, primary actions
Secondary bg-gray-600 hover:bg-gray-700 text-white text-xs rounded-md px-3 py-1 Cancel, History, secondary actions
Ghost border border-gray-600 text-gray-300 hover:bg-gray-700 text-xs rounded-md px-3 py-1.5 Reset, tertiary actions
.btn-toggle See below Expand/Collapse, More... menu triggers
.btn-workflow Purple gradient Test Agent buttons

5.2 Toggle Buttons (.btn-toggle)

Standardized expand/collapse/overflow trigger. Defined in workflow.html <style>.

CSS
.btn-toggle {
  display: inline-flex; align-items: center; gap: 4px;
  padding: 3px 8px; border-radius: 4px;
  border: 1px solid rgba(255,255,255,0.1);
  background: var(--panel-bg-3); color: var(--text-muted);
  font-size: 11px; font-weight: 500;
  transition: background 0.15s, color 0.15s;
}
.btn-toggle:hover { background: var(--panel-bg-5); color: var(--text-primary); }
.btn-toggle svg { width: 12px; height: 12px; }

Variants: - .btn-toggle--circle -- icon-only, 22px circle (used for rail collapse toggle)

Icon convention: - Expand: Heroicons arrows-pointing-out (outward arrows) - Collapse: Heroicons arrows-pointing-in (inward arrows) - More/overflow: three dots (M5 12h.01M12 12h.01M19 12h.01) - Chevron left: M15 19l-7-7 7-7 (rail toggle)

All icons: <svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">.

5.3 Form Controls

Selects and inputs:

Text Only
Class: w-full px-2 py-1.5 border border-gray-600 rounded-md
       bg-gray-700 text-white font-mono text-xs
       focus:outline-none focus:ring-purple-500 focus:border-purple-500
  • Provider + Model selects: always side-by-side in grid grid-cols-2 gap-3
  • Add <label class="block text-[10px] font-medium text-gray-400 mb-1"> above each
  • Temperature + Top_P sliders: always side-by-side in grid grid-cols-2 gap-3

Toggle switches:

HTML
<label class="relative inline-flex items-center cursor-pointer">
  <input type="checkbox" class="sr-only peer">
  <div class="w-11 h-6 bg-gray-200 rounded-full peer dark:bg-gray-700
       peer-checked:after:translate-x-full peer-checked:after:border-white
       after:content-[''] after:absolute after:top-[2px] after:left-[2px]
       after:bg-white after:border-gray-300 after:border after:rounded-full
       after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-600">
  </div>
</label>

No other toggle implementation is permitted.

5.4 Collapsible Panels

Use the global data-collapsible-panel system from base.html:

HTML
<div data-collapsible-panel="panelId" role="button" tabindex="0"
     aria-expanded="false" aria-controls="panelId-content">
  <h4>Panel Title</h4>
  <span id="panelId-toggle">&#9660;</span>
</div>
<div id="panelId-content" class="hidden" role="region">
  <!-- content -->
</div>

Required ARIA: role="button", tabindex="0", aria-expanded, aria-controls on trigger. role="region" on content.

Global initCollapsiblePanels() in base.html wires click and keyboard handlers.

5.5 <details> / <summary> (for simple collapse)

For non-interactive config summaries (e.g. "Current Configuration"), use native HTML:

HTML
<style>
  #myPanel .panel-caret { transition: transform 0.2s; display: inline-block; }
  #myPanel[open] .panel-caret { transform: rotate(180deg); }
</style>

<details id="myPanel" style="background: var(--panel-bg-3); border: 1px solid rgba(255,255,255,0.06); border-radius: 8px;">
  <summary class="flex items-center justify-between px-4 py-2 text-xs font-medium cursor-pointer select-none"
           style="color: var(--text-secondary); list-style: none;">
    <span>Section Title</span>
    <span class="panel-caret" style="color: var(--text-muted-slate); font-size: 0.75rem;" aria-hidden="true">&#x25BC;</span>
  </summary>
  <div class="mt-1 px-4 pb-3"><!-- content --></div>
</details>

Caret is required. list-style: none removes the browser's native disclosure triangle — you must supply your own. Rules: - Always use flex items-center justify-between on <summary> with the &#x25BC; caret on the right. - Drive rotation via details[open] .panel-caret { transform: rotate(180deg) } — no JS needed. - Use var(--text-muted-slate) and font-size: 0.75rem to match the data-collapsible-panel toggle style.

Do not use <details> for primary interactive controls. Use collapsible panels or modals instead.


6. Modal & Overlay Contract (Mandatory)

All modals and overlays MUST use src/web/static/js/modal-manager.js. No ad-hoc onclick show/hide for modal-like UI.

6.1 Keyboard Navigation (Locked)

Key Behavior Notes
Escape Close the topmost modal only. Restore the previous modal in the stack. If no previous modal, return to the base page. Escape NEVER closes more than one modal at a time. One press = one level back.
Enter Trigger the primary action button (Save, Submit, OK, Confirm). Only when focus is NOT in a <textarea> or [contenteditable]. In those elements, Enter inserts a newline.
Ctrl+Enter / Cmd+Enter Trigger the primary action button. Works everywhere, including inside <textarea>. This is the universal "submit" shortcut.
Tab Move focus to next focusable element within the modal. Focus must be trapped inside the modal while it is open.

6.2 Mouse Navigation (Locked)

Action Behavior
Click backdrop Close the topmost modal. If clicked element is a previous modal, show that modal. If base page, return to base page.
Click inside modal Normal interaction. Does not close.

6.3 Stack Behavior (Locked)

  • Opening a modal pushes it onto the stack.
  • Closing pops and restores the previous.
  • A modal that is behind another is hidden (CSS class), not destroyed.
  • Register with ModalManager.register(id, { submitButton?, hasInput?, onClose? }).
  • Open with ModalManager.open(id, hidePrevious).
  • Close with ModalManager.close(id).

Auto-registration can silently drop your onClose. modal-manager.js has its own DOMContentLoaded listener that auto-registers any [id$="Modal"] element with defaults (no onClose). Because that listener runs before a page's own inline-script registration, a page's explicit ModalManager.register(id, { onClose }) call is silently a no-op -- registerModal() skips re-registering an already-registered id unless told to. Pass forceUpdate: true to win the race and make your options (especially onClose) actually take effect. See sourceConfigModal in sources.html.

6.4 Primary Action Button Resolution

The modal manager auto-detects the primary button using this priority:

  1. Explicit submitButton selector passed at registration
  2. button[type="submit"]
  3. Button with brand-color class (bg-purple-600, bg-blue-600, etc.)
  4. Button whose text includes: save, submit, ok, confirm, apply, trigger, run, execute

Rule: Every modal with user input MUST have a clearly identifiable primary action button. Use bg-purple-600 and the text "Save" or "Submit" to ensure auto-detection works.

6.5 Fullscreen Overlays (e.g. Expanded Prompt Editor)

Fullscreen overlays are modals. They MUST:

  1. Be registered with ModalManager.register()
  2. Support Escape to close (one level back)
  3. Support Ctrl+Enter to trigger Save
  4. Have a visible Collapse/Close button (.btn-toggle with arrows-pointing-in icon)
  5. Use backdrop blur: background: rgba(0,0,0,0.65); backdrop-filter: blur(4px);
  6. Have role="dialog", aria-modal="true", and an accessible name. Prefer aria-labelledby pointing at the visible heading: an aria-label that says something different from the visible title means a voice-control user saying the name they can see cannot address the dialog (WCAG 2.5.3 Label in Name), and a hand-written label drifts the moment the heading is reworded.
  7. Move focus into the dialog on open, landing on a control that does nothing destructive (the close button is the safe default), and restore focus to the opener on close.
  8. Lock background scroll while open and restore the previous value on close.

ModalManager does not do 7 or 8 for you. It manages the modal stack, shows and hides, and focuses the first input/textarea/select it finds after 100ms -- which is whatever happens to be first in the DOM, not necessarily a safe target. It has no focus trap, no focus restore, and no scroll lock, so a modal needing those must wire them itself (see showChunkDebugResults / closeChunkDebugModal in article_detail.html). A setTimeout longer than 100ms is required to win over its first-input focus.

6.6 Overflow Menus

The "More..." footer menu is NOT a modal. It is a simple toggle (classList.toggle('hidden')) with an outside-click handler. Overflow menus:

  • Close on outside click (document-level click listener)
  • Close when any item is clicked
  • Do NOT trap focus or handle Escape (they are not modals)
  • Use .btn-toggle as the trigger button

7. Accessibility (Mandatory)

ARIA Requirements

Component Required attributes
Collapsible panel trigger role="button", tabindex="0", aria-expanded, aria-controls
Collapsible panel content role="region", id matching aria-controls
Modal / overlay role="dialog", aria-modal="true", and an accessible name -- aria-labelledby pointing at the visible heading, or aria-label matching that heading's text (WCAG 2.5.3 Label in Name)
Toggle switch aria-label on the checkbox input
Form inputs aria-label or associated <label>
Navigation aria-label on <nav> elements

Keyboard Support

  • All interactive elements must be reachable via Tab.
  • Collapsible panels must toggle on Enter and Space.
  • Modals must trap focus (Tab cycles within the modal).
  • Escape behavior: see Section 6.1 above.

Motion

  • Prefer CSS transition (Tailwind transition classes).
  • Maximum transition duration: 0.2s for interactive feedback, 0.3s for panel animations.
  • No JavaScript-driven animation unless CSS cannot achieve the effect.
  • Respect prefers-reduced-motion where possible.

8. Layout Patterns

Page Header (Compact)

HTML
<div class="flex items-center gap-3 mb-3">
  <img src="/static/icons/page-icon.svg" alt="" class="w-9 h-9" />
  <div>
    <nav class="flex text-xs text-gray-400 mb-0.5">
      <a href="/">Dashboard</a><span class="mx-1.5">/</span><span>Page</span>
    </nav>
    <h1 class="text-xl font-bold text-white leading-tight">Page Title</h1>
  </div>
  <p class="hidden sm:block ml-4 text-sm text-gray-400 border-l border-gray-700 pl-4">
    Description text
  </p>
</div>

Two-Panel Config Layout (oc-shell)

  • Left: collapsible pipeline rail (.oc-rail, 200px expanded, 52px collapsed)
  • Right: scrollable config content (.oc-right)
  • Footer: sticky action bar (.oc-footer)
  • Rail has a .btn-toggle--circle toggle button at top-right
  • Left side: status text (auto-save timestamp)
  • Right side: .btn-toggle "More" overflow menu + primary "Save" button
  • Max 2 visible buttons. All other actions go in the overflow menu.

9. Execution Protocol

Required First Step

Before making UI changes, read:

  1. src/web/static/css/theme-variables.css -- token source of truth
  2. src/web/templates/base.html -- global layout, scripts, font loading
  3. The target template(s) in src/web/templates/

The Pre-Flight Checklist and Handoff format are at the top of this document.