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.
// 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¶
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¶
<!-- 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.
<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:
<div data-collapsible-panel="myPanel"> <!-- trigger -->
<span id="myPanel-toggle">▼</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 rawbg-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>, anddarkMode: 'class'intailwind.config.js - [ ]
make cssre-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
!importantunless overriding a third-party style - [ ] Deprecated
bg-gray-800 border border-gray-700 rounded-lgpattern replaced with.cardwherever touched
On completion, report:
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-2xlor 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", oraria-expandedto collapsible triggers --initCollapsiblePanels()does this automatically (one documented exception: the/workflow#configstep 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-4or larger on form controls (selects, inputs) -- maxpy-2
Color and theme¶
- Do NOT hardcode hex colors in inline
style=""-- use CSS custom properties - Do NOT remove
class="dark"from<html>inbase.html - Do NOT remove
darkMode: 'class'fromtailwind.config.js - Do NOT rely on
dark:variants without thedarkMode: 'class'config above - Do NOT re-add a
cdn.tailwindcss.comscript tag -- that host rejects SRI, so the tag is unverifiable by construction (guarded bytests/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
onclickshow/hide -- usemodal-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/andtheme-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 cssafter 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 tosafelistintailwind.config.js. Interpolating a complete class string is fine. - Class strings held in Python are scanned too (
./src/**/*.pyis incontent) -- seesrc/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:
<html lang="en" class="dark">inbase.html(thedarkclass is mandatory)darkMode: 'class'intailwind.config.js, compiled intosrc/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:
<!-- 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">▼</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>.
.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:
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:
<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:
<div data-collapsible-panel="panelId" role="button" tabindex="0"
aria-expanded="false" aria-controls="panelId-content">
<h4>Panel Title</h4>
<span id="panelId-toggle">▼</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:
<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">▼</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 ▼ 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:
- Explicit
submitButtonselector passed at registration button[type="submit"]- Button with brand-color class (
bg-purple-600,bg-blue-600, etc.) - 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:
- Be registered with
ModalManager.register() - Support Escape to close (one level back)
- Support Ctrl+Enter to trigger Save
- Have a visible Collapse/Close button (
.btn-togglewith arrows-pointing-in icon) - Use backdrop blur:
background: rgba(0,0,0,0.65); backdrop-filter: blur(4px); - Have
role="dialog",aria-modal="true", and an accessible name. Preferaria-labelledbypointing at the visible heading: anaria-labelthat 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. - 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.
- 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-toggleas 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.2sfor interactive feedback,0.3sfor panel animations. - No JavaScript-driven animation unless CSS cannot achieve the effect.
- Respect
prefers-reduced-motionwhere possible.
8. Layout Patterns¶
Page Header (Compact)¶
<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--circletoggle button at top-right
Footer Action Bar¶
- 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:
src/web/static/css/theme-variables.css-- token source of truthsrc/web/templates/base.html-- global layout, scripts, font loading- The target template(s) in
src/web/templates/
The Pre-Flight Checklist and Handoff format are at the top of this document.