πŸ’¬ Sample prompts Paste any of these into Claude Code to use this skill
Tooltip rules How do I add a tooltip to my Adom app?
Toggle UI Show me the toggle-button styling rules
HUD pattern How do I make a draggable, collapsible HUD?
Z-index Why is my tooltip getting clipped by the panel?
AI-drivability Make this UI driveable from the CLI
Multi-unit display How should I show mm + mils + inches at once?
⚑ Install this skill

Paste this into Claude Code (VS Code panel, Adom editor, or terminal) to install:

Search the Adom Wiki for the skill "human UI patterns" (slug: human-ui-patterns) at https://wiki-ufypy5dpx93o.adom.cloud/wiki/skills/human-ui-patterns and install it into my local ~/.claude/skills/human-ui-patterns/ directory. Fetch the skill_source content from the wiki page and save it as SKILL.md. Then confirm it's installed by showing the first 5 lines.
?
What is a skill? Skills are instructions that teach AI assistants like Claude Code how to perform specific tasks. The description below is loaded into the AI as context when you invoke this skill. Well-written skills make the AI significantly more effective. Like Wikipedia, anyone can improve a skill by clicking Edit AI Skill — or have your AI submit an edit on your behalf.

Description

Edit AI Skill

name: human-ui-patterns description: > Non-negotiable UI rules for every Adom app. Read BEFORE writing any hover/click/drag element. Three rules get violated most often and must be checked first: tooltips MUST be body-appended position:fixed divs at z-index 99999 (never CSS ::after β€” gets clipped by HUD overflow:hidden / backdrop-filter / transform stacking contexts); toggle buttons MUST visually reflect their on/off state (a flat push-button styling for both states is a UX lie); every interactive element MUST carry a data-tooltip (no orphans). Also covers HUDs, click previews, draggable panels, NEVER ALL CAPS, multi-unit displays, viewport-clipping, AI-drivability. Trigger words: UI design, tooltip, hover preview, draggable HUD, floating panel, measure tool, UX polish, component panel, newbie-friendly, human factors, click preview, snap point, z-index, viewport clipping, UI review, toggle button, toggle state, button state, pressed button, active button.

Human UI Patterns

UI affordances that are trivially obvious to a human user AND trivially easy for an AI to forget. Every single one of these came from a real user call-out β€” "your tooltip is cut off below the fold," "you don't show me a preview of where my click will land," "your HUD is stealing my screen real estate," "newbies can't figure out what this button does." Treat this skill as a checklist for every clickable, hoverable, or draggable element.

Three rules that always get violated β€” check these FIRST

If you only have time to enforce three rules from this whole skill, make it these. They're regressions every Adom UI has shipped at least once and that real users have flagged out loud.

  1. Tooltips MUST be a body-appended position:fixed div at z-index 99999 β€” never a CSS ::after pseudo on the trigger. The pseudo gets clipped by any ancestor with overflow: hidden, transform, filter, backdrop-filter, or isolation, and there is NO z-index tiering that recovers it. Detail in Β§1d. User feedback 2026-04-28: "you failed on tooltips again. can we update the design skill to make it higher priority to ensure tooltips are above all other ui elements?"

  2. Every interactive element gets a data-tooltip β€” buttons, icon-only controls, jargon-labeled widgets, status pills, dropdowns, draggable handles. No orphans. Detail in Β§1a. User feedback 2026-04-28: "why doesn't everything have a tooltip?"

  3. A button bound to a binary state MUST visually reflect that state. A flat "push button" styling for both ON and OFF is a UX lie β€” the user has no way to tell whether the HUD they want is already open, whether the gizmo they expected is active, whether the layer they care about is visible. Detail in Β§"Toggle buttons". User feedback 2026-04-28: "if something is a toggle button, you better treat it as a toggle button."


1. Tooltips

1a. Every interactive element gets a descriptive tooltip

Buttons, icon-only controls, labels with technical jargon, and any non-obvious widget MUST carry a tooltip written for a new user who has never seen the app before.

Required content of a button tooltip:

  1. What the button does (one sentence).
  2. What will change in the app after you click it (one sentence).
  3. For destructive / irreversible actions, what is preserved and what is lost.
  4. For jargon-labeled buttons (e.g. "EBU R128", "MPSSE", "zUp"), define the jargon in a plain-English aside.

Required content of a label tooltip (for dim labels, status readouts, or small numbers whose meaning isn't obvious):

  1. What the label means.
  2. The unit, if any (e.g. "in millimetres").
  3. How a user would act on this value (is it a warning? a measurement? a configuration?).

Never use HTML title="" β€” browsers render them inconsistently (immediate popup on some, 2-second delay on others), don't support multi-line content well, and ignore your app's brand styling. Use data-tooltip="…" rendered by the body-appended fixed-div renderer in Β§1d. Do NOT render the tooltip via a CSS ::after pseudo on the trigger β€” that's the single most common regression in this codebase (clipped by ancestor stacking contexts).

1b. Tooltips reveal after a 500ms hover delay

Instant tooltips spam the user whenever their cursor passes over the HUD. A 500ms delay means intentional hovers get the explanation, cursor fly-bys don't fire anything. CSS pattern: see ui-implementation-reference.md Β§ 1b.

1c. Tooltips must NEVER render off-screen

Two rules: (1) max-width: min(320px, calc(100vw - 40px)); (2) on mouseenter, measure the trigger rect and add data-tooltip-v="top" or data-tooltip-align="right" if the tooltip would overflow the viewport. Full JS + CSS: see ui-implementation-reference.md Β§ 1c.

1d. Tooltips = ONE body-appended position:fixed div at z:99999 flat

Rule: a tooltip is the topmost layer of the entire app. Period. It uses a single <div> appended directly to <body>, with position: fixed, and z-index: 99999 flat. It is NEVER a CSS ::after pseudo on the trigger, and its z-index is NEVER tiered against its owner HUD's z-index. Every other HUD in the app caps its z at ≀99998.

Three failure modes that make this the only correct pattern: (1) HUD overflow: hidden clips ::after at the HUD edge regardless of z-index; (2) transform/filter/isolation on an ancestor creates a stacking context that bounds descendants' z-indexes; (3) owner-HUD tiering breaks when one HUD covers another HUD's button.

Z-INDEX LADDER (canonical β€” use these tokens, NOT hardcoded numbers)

Every Adom app SHOULD declare these CSS variables in :root and reference them everywhere instead of hardcoding numbers. The ladder exists so an AI session can never accidentally invert the stacking order by picking a number out of thin air.

:root {
  --z-hud:           50;     /* draggable HUDs */
  --z-toolbar:       80;     /* fixed header / footer / banner */
  --z-floating-menu: 200;    /* dropdowns, popovers, variant pickers, autocomplete */
  --z-toast:         400;    /* non-blocking confirmations */
  --z-modal:         600;    /* full-screen overlays, banners, completion dialogs */
  --z-tooltip:       99999;  /* always on top */
}

Order from bottom (lowest) to top (highest):

LayerTokenNumberExamples
Base contentautoβ€”the canvas, the page body
HUDs (draggable)--z-hud50source HUD, scene-graph HUD
Toolbars (fixed)--z-toolbar80header, footer, banner
Floating menus--z-floating-menu200dropdowns, variant pickers, autocomplete
Toasts--z-toast400success confirmations
Modal overlays--z-modal600bake-complete dialog, refusal banner
Tooltip--z-tooltip99999always wins

Why floating-menu > toolbar: a dropdown opened from a button in the toolbar must render ABOVE the toolbar's strip β€” otherwise the menu can't visually escape the toolbar's bounding box. Same for HUD: a menu opened from a button on a HUD must beat the HUD's z so the menu can extend outside the HUD chrome.

Why tooltip is two orders of magnitude above everything else: so an in-app tooltip ALWAYS wins. Tooltip text is supposed to clarify the element you're hovering, regardless of what other UI is on top of it.

The other failure mode that's NOT a z-index issue but always blamed on z-index: overflow: hidden (or clip) on an ANCESTOR clips an position:absolute descendant menu visually, regardless of any z-index. If your dropdown menu opens upward out of a toolbar with overflow: hidden, the menu disappears not because of z-index but because the parent box clips it. Fixes: (a) overflow-x: clip; overflow-y: visible (lets the menu escape vertically while still preventing horizontal scroll), or (b) reposition the menu to position: fixed so it lives at the document root not inside the clipped ancestor.

Required practice for every new app: declare the --z-* tokens in :root first, before writing any z-index. CSS lint should reject any literal z-index: N outside this declaration block. User feedback 2026-04-28: "can't you make a skill rule for ui design that documents what z-index huds vs tooltips run at so this doesn't happen anymore. i'm constantly prompting you to fix this."

Position is a SEPARATE concern from z-index β€” anchor the tooltip RELATIVE TO THE CURSOR, offset ~14 px into the best quadrant; do NOT anchor to the trigger element. The ONE cardinal sin: a tooltip that covers the thing you're hovering.

Full implementation code (body-appended div, position:fixed, pickAnchor algorithm, cursor-tracking, 4-iteration failure history): see ui-implementation-reference.md Β§ 1d.

1f. One tooltip at a time β€” most-nested element wins

Browsers happily fire :hover on every ancestor in the chain, so if both a button AND its parent row have data-tooltip, BOTH tooltips show at once, overlapping. Always suppress ancestor tooltips when a descendant tooltip is active.

Rule: at most ONE tooltip visible in the DOM at any moment, and it belongs to the innermost [data-tooltip] element under the cursor.

Implementation: global mouseover listener finds the innermost trigger via closest('[data-tooltip]'), walks up its ancestors, and puts data-tooltip-suppress="" on each ancestor that also carries data-tooltip. Clear on mouseout to a non-tooltip target.

JS + CSS implementation and the real failure case (nested chip row + group header showing two tooltips at once): see ui-implementation-reference.md Β§ 1f.

1e. Never use ALL CAPS for tooltip or label content

TEXT IN ALL CAPS READS AS SHOUTING to a human user. Write tooltip content in sentence case ("Reset camera to the default isometric view"), not in all-caps ("RESET CAMERA"). Same for in-app labels, section titles, button text, status pills β€” keep it sentence or title case.

Watch for an INHERITANCE BUG: if the parent has text-transform: uppercase, the ::after pseudo inherits it and renders every tooltip in all caps. Reset text-transform: none and letter-spacing: 0 on the tooltip element β€” snippet in ui-implementation-reference.md Β§ 1e.

If a visual heading emphasis is needed, use weight (600-700) and letter-spacing (0.02em), not caps.


2. Click previews β€” show what would happen BEFORE the click

Rule: any click that commits an irreversible-feeling action (place a marker, select an edge, add a component, drop a point) MUST show a live preview of what's about to happen as the user moves their mouse. A "will this click do what I want?" moment where the user has to click-and-undo is a failure.

Concrete cases:

ActionPreview
Click to place a measure pointTranslucent sphere at the nearest snap vertex follows the cursor
Click to select an edgeBright colored tube along the nearest edge of the hit mesh
Click to select a bodyOutline / highlight-layer stroke around the hovered mesh
Click to place a component in a schematicGhost component at cursor position, proper orientation
Click to add a vertex to a polygonGhost vertex + ghost polygon edge snapping to the last vertex
Click to drop a file onto a targetTarget area dims or outlines on dragover

Implementation note: the preview mesh/element MUST be isPickable = false (or equivalent) so subsequent hover-picks don't pick the preview itself instead of the underlying geometry. Real bug from adom-tsci's measure tool: the first preview sphere became the pick target for the next hover, stalling the tool.

When the user's intent changes (filter switches, tool closes, selection limit reached), dispose the preview immediately. Don't let stale ghost markers linger.


2b. Toggle buttons

A button bound to a binary state (HUD open/closed, gizmo active/inactive, layer visible/hidden, recording on/off, sharing on/off, mute on/off, etc.) MUST visually reflect that state. A flat "push-button" styling that looks identical for both ON and OFF is a UX lie: the user has no way to tell whether the action they want is already done.

Required:

  1. Add aria-pressed="true|false" to the button (a11y + state probe for tests).
  2. Add an .is-on class (or equivalent) when the bound state is on.
  3. CSS for .is-on (and/or [aria-pressed="true"]) MUST visibly differ from the resting state β€” different background, brighter border, inset shadow, accent-color text. Pick at least two of those, not just one. A 5% background-tint shift is too subtle.
  4. Two-way: clicking the button flips the state AND any other path that flips the state (e.g. the HUD's own X-button, a keyboard shortcut, a programmatic setX(false)) MUST update the toggle class on the button. Otherwise the button drifts out of sync.
  5. The tooltip should phrase the action conditionally: "Show sources HUD" when off, "Hide sources HUD" when on β€” same way real toggle UIs do. (Optional but high-polish.)

Anti-pattern that ships every time:

<!-- both states render identically β€” looks broken to users -->
<button 

Right way:

<button id="btn-foo" aria-pressed="false"
         'btn-foo')">foo</button>
function toggleHudButton(hudId, btnId) {
  var h = document.getElementById(hudId);
  var b = document.getElementById(btnId);
  var willShow = (getComputedStyle(h).display === 'none');
  h.style.display = willShow ? '' : 'none';
  b.classList.toggle('is-on', willShow);
  b.setAttribute('aria-pressed', willShow ? 'true' : 'false');
}
// Two-way sync: when the HUD's close-X is clicked, ALSO clear the
// button state.
hudCloseBtn.addEventListener('click', function () {
  hud.style.display = 'none';
  document.getElementById('btn-foo').classList.remove('is-on');
  document.getElementById('btn-foo').setAttribute('aria-pressed', 'false');
});
button.is-on,
button[aria-pressed="true"] {
  background: rgba(0, 184, 177, 0.22);    /* brand teal β€” visible */
  color: #00e6dc;
  border-color: rgba(0, 184, 177, 0.60);
  box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.35);
}

User feedback that drove this section (2026-04-28): "if something is a toggle button, you better treat it as a toggle button. add that to skill too and audit yourself cuz the provenance is a toggling of a hud and you don't make the button act that way."


3. HUDs and floating panels

3a. Every floating HUD must be draggable

A HUD that sits in a fixed spot steals screen real estate the user can't reclaim. Every HUD that opens over the main canvas MUST be draggable β€” the user grabs a visible handle (a grip strip, a header bar, the title) and moves the HUD anywhere. No exceptions.

Implementation: use a shared makeDraggable(element, handleEl) helper. Account for the offset-parent's viewport position β€” a naΓ―ve implementation gives a ~42px "cursor jump" when the HUD is inside a panel below a tab strip (clientY is viewport-relative but style.top is offset-parent-relative). Full makeDraggable implementation: see ui-implementation-reference.md Β§ 3a.

3b. Every HUD must be collapsible

Users need to keep the HUD OPEN but get it out of the way during other work (orbiting the view, clicking the canvas). Provide a minimise (βˆ’) button in the header that collapses the HUD to just the header strip, preserving its position and all state. Clicking the minimise again expands.

3bb. Double-clicking the drag handle toggles collapse

Every draggable HUD header should ALSO respond to dblclick by toggling the same collapsed/expanded state that the \u2212 / + icon controls. Reason: the minimise icon is a tiny target, and users who already know "drag this bar to move" naturally try double-clicking it to get it out of their way (Fusion 360, Blender, Figma, most IDE dockable panels behave this way). Don't make them aim.

Wire it on the handle element, not the whole HUD, so double-clicking inside the scrolling body doesn't trigger. Ignore dblclick on child icons that have their own action (βˆ’, Γ—, dropdown carets). Call e.preventDefault() to avoid text selection. Full snippet: see ui-implementation-reference.md Β§ 3bb.

3c. Every HUD must be dismissible, but re-openable from the main toolbar

When the user hits the Γ— to close a HUD, don't leave an orphan "show me again" floating button lying around the canvas. Instead, re-expose the HUD via a toggle button on the MAIN toolbar. That way, close = HUD disappears, toolbar remains; click the toolbar button to bring it back. Orphan hamburger buttons are clutter.

3d. HUDs: auto-fit CONTENT to the container, but never force-clamp the USER's drag

There are two different concerns inside the same element, and they have OPPOSITE correct behaviours:

(a) Auto-fit the HUD's own content growth β€” yes, constrain. If the user picks two chips and a measurement HUD would grow to 1050px tall in a 900px pane, that HUD must cap its own height and give the growable middle section its own scrollbar. This is the HUD taking responsibility for its intrinsic size.

(b) The user's drag position β€” NO, leave it alone. If the user chooses to park a HUD with its right half off the canvas because they want max board visibility, that's a valid choice. "Snapping the HUD back on-screen because half of it overlapped the edge" is paternalistic and infuriating β€” the user deliberately moved it there. A later resize should NOT re-snap it either.

Rules, non-negotiable:

  1. Cap max dimensions to the container, not the viewport. Use max-height: calc(100% - <margin>px) on the HUD against the positioned parent (e.g. the 3D viewer wrap), NOT calc(100vh - …). The parent itself may be smaller than the viewport (tabs bar, toolbar, IDE chrome).
  2. Internal scrolling for the variable-height section. A HUD typically has fixed top chrome (header, filter buttons) and fixed bottom chrome (footer, action buttons). The MIDDLE β€” the selections list, the rows of measurements, the list of items β€” must be the flex child that gets overflow-y: auto, min-height: 0, and flex: 1 1 auto. Header + footer stay pinned; middle scrolls. Never let the whole HUD scroll β€” users lose the action buttons.
  3. Do NOT clamp-to-container on drag. The drag handler faithfully follows the cursor β€” if the user drags the HUD partially off the container, respect it. The user may be deliberately parking it to get a wider look at the canvas.
  4. Do NOT re-snap on window resize. Once the user has placed a HUD, their position sticks. If the container shrinks and the HUD is now mostly off-screen, that's still the user's last intent β€” they can drag it back in three seconds. Snapping "for their own good" punishes the deliberate user.
  5. Reconsider content density when max-height is regularly hit. If a HUD regularly hits its cap even with scrolling, the fix is often less chrome, not more scroll β€” collapse attribute groups by default, truncate labels with … on hover, or drop decorative rows when a selection doesn't need them.
  6. Minimum width still applies: the HUD should have a min-width so its own labels don't squeeze into two-letter columns as the container shrinks. If the container narrows below min-width, the HUD overflows horizontally β€” that's fine, the user can drag it.

Real failure cases that motivate this split:

  • Overflow from content growth (must cap): measure HUD on adom-tsci grew to 1050px tall after a user picked two chips. IDE pane was 900px. Selection 2's X/Y/Z rows were off-screen, un-scrollable. Fix: max-height: calc(100% - 80px) on the HUD, overflow-y: auto on .mb-selections.
  • Over-constrained drag (must NOT clamp): after fixing the content cap, a first-pass added a clamp-to-container on drag and on resize. User feedback: "huds should be allowed to drag off the edge of the webview. you are constraining them too much now." Fix: drop the drag clamp and the resize re-clamp; only the HUD's own size-fit is responsive.

3f. Guided walkthroughs: interruptible, pausable, AI-drivable

For any board / scene / document tour (a "🎬 Walk me through this" feature), the user must feel in control at every moment. Four non-negotiable rules:

  1. Interruptible camera animations. The camera fly-to-next-step must yield the moment the user orbits/pans/zooms. Hook pointerdown on the canvas β†’ stop the running Babylon/Three animation and auto-pause the tour's step timer. Never force the user to wait for an animation to complete.

  2. Explicit pause state with visible indicator. Pause has a button AND a keyboard shortcut (Space). When paused, show a badge ("⏸ Paused") on the narration HUD and recolour the progress bar so the user can see the tour stopped. Resume re-flies the camera (since the user moved the view) and resumes the step timer with the REMAINING duration, not from scratch.

  3. Reading-speed-aware auto-advance. Step duration = max(minMs, text.length * 45ms + 2500ms + sentenceCount * 300ms). 45 ms / char β‰ˆ 220 wpm (comfortable) plus a 2.5 s floor so slow readers don't get rushed off the step before the camera lands. Don't use fixed "5 seconds per step."

  4. AI-drivability as a first-class concern. Every walkthrough action (start / pause / resume / next / prev / close) must also be HTTP + CLI reachable so the tour can be ralph-loop tested. A GET /api/walkthrough status endpoint reports {active, step, total, paused, currentStepId, title} so the ralph script knows which step it's on for shotlog captions. Without AI drivability you can't regression-test the tour when the underlying board changes.

Script format (WALKTHROUGH data array, focus kinds, highlight, minMs), ordering rules (biggest/most-important β†’ smallest/background for PCBs), HUD layout details, and real failure history: see ui-implementation-reference.md Β§ 3f.

3e. Group long lists with collapsible master toggles

When a panel contains many toggleable items that naturally cluster (by kind, layer, net, priority), expose group headers with:

  • A caret (β–Ύ/β–Έ) that collapses/expands just that group
  • A count (<visible>/<total>) showing group state at a glance
  • A master toggle icon (● all visible / β—‹ all hidden / ◐ mixed) that flips every item in the group at once

Keep per-item toggles inside each group β€” never replace them. The "hide every testpoint so I can see traces" operation is miserable one-at-a-time; the "hide THIS specific chip" operation needs the per-item row.

Default state: everything COLLAPSED. First-time users see a compact overview of groups and expand only what they care about.


4. Multi-unit displays

When a value has a cross-cultural unit convention, provide a second unit readout in parentheses. For PCB work: millimetres primary, inches or mils secondary. For audio: dB primary, numeric ratio secondary. For temperature: Β°C primary, Β°F secondary.

Expose this as a "Secondary Units" dropdown (None / Inches / mils / etc.) rather than hardcoding a second unit, so users who don't need it aren't distracted by it.

Change precision via a dropdown too β€” don't hardcode three decimals when some users want to see 0.1234 mm and others just 0.1 mm.


4b. Icons β€” always monochrome, always custom-drawn

Full rule lives in gallia/skills/brand/SKILL.md β†’ Icons. Summary of the parts UI designers forget most:

  • Never use Unicode emoji (πŸ“ πŸ” 🎬 ⎚ πŸ”§ …) as UI icons. Emoji render as multi-color by design (Twemoji / Apple Color / Noto) and mix miserably next to any hand-drawn SVG. This applies even to "quick prototype" toolbars β€” emoji end up shipping.
  • Monochrome only. #e6edf3 on dark backgrounds or currentColor so it inherits the surrounding text color. No gradients, no shadows, no brand-palette accents on icons.
  • Custom-drawn is the default, not the fallback. Every icon should be AI-drawn SVG that depicts the specific concept β€” calipers for Measure, a magnifier with a data dot for Inspect, a clapperboard for Walkthrough, a board-with-pins silhouette for the Components panel. Hand-drawing per-feature is how the toolbar actually teaches users what each button does.
  • MDI is the fallback, only for fully-generic concepts (chip / eye / cog) where nothing you'd draw is more specific. Still apply the monochrome rule.
  • viewBox="0 0 24 24" everywhere for consistent sizing; pick one style (stroked vs filled, 1.5–2 px stroke) per surface and stick with it.

If the toolbar/HUD/tooltip you're writing contains a single emoji character pretending to be an icon, you still have work to do.


5. Match existing well-loved tools

If the user's workflow involves a tool they already know well (Fusion 360, Photoshop, KiCad, Figma, …), copy their UX for comparable features before inventing your own. Users have muscle memory for these tools; reinventing the button layout or mode picker adds friction for zero benefit.

For measurements specifically, Fusion 360 is the reference:

  • Vertical label / control grid layout (Selection Filter, Precision, Secondary Units, Clear Selection, Show Snap Points, Close)
  • Selection filter with three icons (Point, Edge, Body)
  • 1 / 2 tags placed over the 3D view at each selection point
  • Floating distance label at the midpoint between two selections
  • Persistent highlighted edges/bodies after selection so the user can see what they picked

If you're about to invent something different, first ask yourself whether the user will have to re-learn muscle memory they already have.


5b. File and folder paths displayed in UI are CLICKABLE β€” reveal in VS Code

Whenever an Adom app shows a file or folder path in any UI surface (HUD label, toast, tooltip, list cell, info bar, error message, breadcrumb), that path is a link that opens the user's VS Code Explorer sidebar focused on that file. Always. No exceptions.

The user's mental model is "I see a path, I want to look at the file." Forcing them to copy/paste into a terminal to code it, or hunt for it in the explorer, is friction we control and shouldn't add.

How to wire it

The Adom container ships adom-vscode reveal <path> which reveals the path in the VS Code Explorer sidebar (and expands the tree to it). Webview UIs can't shell directly, so route through the app's own server:

// HTML side β€” turn the path into a button (NOT an anchor; nothing to navigate to).
<button class="path-link" data-tooltip="Reveal this file in the VS Code Explorer">
  /tmp/r0402.glb
</button>

// click handler β€” POSTs to the app's own backend
async function reveal(path) {
  const r = await fetch("api/reveal", { method: "POST",
    body: JSON.stringify({ path }) });
  const j = await r.json();
  showToast(j.ok ? "Revealed " + path : "Reveal failed: " + j.error,
            j.ok ? "ok" : "error");
}
// server side β€” the app shells to adom-vscode
(Method::Post, "/api/reveal") => {
    let out = std::process::Command::new("adom-vscode")
        .args(["reveal"]).arg(&path).output();
    // ... return {ok, path} or {ok: false, error}
}

Required behaviour

  • Tooltip every path link with data-tooltip="Reveal this <thing> in VS Code Explorer" (per Β§1a).
  • Toast on click with the result (Revealed /tmp/r0402.glb or Reveal failed: <reason>) per Β§6.
  • Visual treatment β€” accent-coloured underline on hover, focus ring for accessibility. Don't go heavy-handed (full button styling); paths should still read as text first, link second. The adom-quicklook app's .path-link CSS is a good template.
  • Don't open the file in VS Code's editor β€” that's a different verb (adom-vscode open). The default for "click on a path" is reveal in explorer because users want to see the surrounding folder, then decide what to do. If your tool genuinely wants edit-on-click, add a separate "open" button next to the reveal one β€” never silently choose for them.
  • Skip if the path is unreachable. If the path is on a different container or doesn't exist on this filesystem, don't make the label clickable; show it as plain dimmed text and (if useful) include a tooltip explaining why. Better than a click that fails silently.

⚠ The workspace-boundary footgun (caught the hard way 2026-04-28)

adom-vscode reveal <path> returns OK: exit 0 even when the path is outside VS Code's open workspace folders. VS Code's Explorer sidebar is a workspace tree; it can only render files under the folders the user has opened (typically $HOME/project/ on Adom containers). Files in /tmp/, /var/, or any other prefix produce a silent visual no-op β€” the CLI says success, the user sees nothing change.

This is a footgun for "I'll just download to /tmp and reveal" workflows (URL-source quicklooks, conversion intermediates, etc.).

Server-side pre-check before invoking adom-vscode reveal:

// On Adom containers the workspace root is $HOME/project/. Some users
// add more roots via VS Code's "Add Folder to Workspace"; until
// adom-vscode exposes a /workspace endpoint, $HOME/project is the
// safe baseline.
fn vscode_workspace_roots() -> Vec<PathBuf> {
    let mut out = Vec::new();
    if let Ok(home) = std::env::var("HOME") {
        let p = PathBuf::from(home).join("project");
        if p.exists() {
            if let Ok(c) = std::fs::canonicalize(&p) { out.push(c); }
        }
    }
    out
}
fn is_in_vscode_workspace(p: &Path) -> bool {
    let roots = vscode_workspace_roots();
    if roots.is_empty() { return true; } // no info β†’ trust adom-vscode
    roots.iter().any(|r| p.starts_with(r))
}

Response when outside:

{
  "ok": false,
  "outside_workspace": true,
  "path": "/tmp/r0402.glb",
  "workspace_roots": "/home/adom/project",
  "error": "File is outside the VS Code workspace (workspace: /home/adom/project). Move or copy it into the workspace to make it revealable."
}

JS branches the toast so the user sees something useful instead of a fake success:

if (j && j.outside_workspace) {
  showToast(
    "Outside VS Code workspace: " + j.path +
    ". Move/copy into " + j.workspace_roots + " to reveal.",
    "error"
  );
}

If your app downloads source files into /tmp/ (URL-source quicklooks, downstream conversion outputs), consider downloading into $HOME/project/.adom-cache/<app>/ instead so the file stays revealable. That's strictly better UX than a "reveal failed" toast every time.

When the local server is dead

If the user Ctrl-Cs your app's server but the Hydrogen tab is still open, clicks on the path link return whatever the proxy serves on connection-refused β€” typically a plain-text connect ECONNREFUSED 127.0.0.1:<port> body. Always read the response as text first then attempt JSON.parse, so a non-JSON body produces a useful toast instead of a SyntaxError("Unexpected token 'c'"):

const text = await r.text();
let j = null;
try { j = JSON.parse(text); } catch (_) {}
if (j && j.ok) { ... }
else if (j && j.error) { showToast("Reveal failed: " + j.error, "error"); }
else if (!r.ok) { /* 5xx β€” proxy or server */ }
else {
  showToast("Server unreachable β€” restart " + APP_NAME, "error");
}

Why this matters

Users who see paths in your UI and can't click them go through:

  1. Select with mouse (often interrupting other selection state).
  2. Cmd/Ctrl-C.
  3. Switch to terminal.
  4. Type code <paste> or ls <paste>.
  5. Realize they wanted Explorer, not editor; click into the sidebar.
  6. Navigate to the file manually.

Six steps for what should be one click. The Adom platform has every piece needed to do this for them β€” adom-vscode reveal is one shell call, and webview apps proxy it through their own server. There's no excuse for static path labels.

6. Feedback for every action

Every click, drag, toggle, or keyboard shortcut needs IMMEDIATE feedback:

  • A toast at the bottom-centre for stateless actions ("view β†’ top", "measure: on")
  • A changed button .active class for toggles
  • A visible preview / selection marker / highlight for spatial actions
  • A status pill in the header for long-running operations ("Building… 12s", "GLB: 10:28:39 AM")

Silent success is indistinguishable from failure. If a user clicks and nothing visually changes, they assume the click didn't register and click again, firing the action twice. Every "I clicked but nothing happened" bug is a feedback-latency bug.


7. AI-drivability is a feature, not an afterthought

Every user action in the UI SHOULD have a corresponding CLI or HTTP endpoint so Claude (or the user's own scripts) can drive the app without clicking. See the app-creator skill's Β§7 for the full HTTP pattern. Practical effect: a measure HUD's Close button, a toolbar's Wireframe toggle, a component panel's per-row visibility β€” every one of these should be reachable via adom-<app> <subcommand>.

When you extend a HUD with a new control, add the matching CLI subcommand in the same change. Otherwise the AI-driven ralph loop can't verify your addition works.


Checklist β€” review every UI change against these

  • Every new button/label/control has a data-tooltip?
  • Every tooltip is multi-line and written for a newbie?
  • Tooltips have z-index: 99999 with text-transform: none?
  • No tooltip or label text is written in ALL CAPS (shouting)?
  • Tooltips auto-flip when near viewport bottom/right?
  • Every irreversible-feeling click has a live preview on hover?
  • Preview meshes/elements are isPickable = false so they don't interfere with subsequent picks?
  • Every floating HUD is draggable via a visible grip?
  • The drag handler accounts for offset-parent viewport position?
  • Every HUD has max-height: calc(100% - <margin>) against its positioned container so growth cannot overflow?
  • The variable-height section uses internal overflow-y: auto while header + footer stay pinned?
  • The drag handler does NOT clamp to container edges (parking-off-screen is a valid user choice)?
  • Resize does NOT re-snap the HUD (user's drag position is sacrosanct)?
  • Every HUD has a minimise button that collapses to header?
  • Double-clicking the drag handle ALSO toggles collapse (don't force users to aim at the tiny minimise icon)?
  • Every HUD has a close button + a toolbar button to re-open?
  • Long toggle lists are grouped with master-toggle headers?
  • Default state for groups = collapsed, user expands to drill in?
  • Every action has immediate visible feedback (toast, class, preview)?
  • Every UI action has a matching CLI / HTTP endpoint?
  • Does this match the UX of a well-loved tool the user already knows (Fusion / KiCad / Figma / …)?

If any of these are unchecked, the UI isn't done yet.

Provenance captions on every shown artifact

Whenever a UI displays an image, render, table, code block, or embedded viewer that the user might confuse for content from another source, attach a one-line provenance caption directly underneath. The caption answers: who or what produced this, and what it is NOT.

Caption must state: (1) the producer tool/script/person; (2) negative attribution ("Not from the datasheet"); (3) inputs or fallbacks taken. In markdown use a > **Provenance β€” name.** ... blockquote; in interactive UIs use small italic text in text-secondary colour directly below the artifact β€” never behind a tooltip. Both heroes and provenance are mandatory: heroes give identity at a glance, provenance gives trust at a second glance.

Full conventions (markdown, interactive UI, iframe): see ui-implementation-reference.md Β§ Provenance captions.

Hero images: one-glance identity

Any browsable object (datasheet, symbol, footprint, molecule, skill, app, video, board, component, 3D model) should have exactly one hero image β€” a single picture that lets a human identify the thing in a fraction of a second, without reading the title. If the user can open a list of ten of these objects and not tell them apart at a glance, the design is broken.

Key rules: one hero per object; render at thumbnail (32–48 px) and medium (200–400 px); hero appears upper-left on detail pages and as the first visual in index/browse views; never leave it empty β€” use a deterministic placeholder (initials + colour from slug) if no hero is set.

Full rules (what to pick per object type, where to place it, delivery convention): see ui-implementation-reference.md Β§ Hero images.

Skill Source

Edit AI Skill
---
name: human-ui-patterns
description: >
  Non-negotiable UI rules for every Adom app. Read BEFORE writing any
  hover/click/drag element. Three rules get violated most often and
  must be checked first: tooltips MUST be body-appended `position:fixed`
  divs at z-index 99999 (never CSS `::after` β€” gets clipped by HUD
  `overflow:hidden` / `backdrop-filter` / `transform` stacking
  contexts); toggle buttons MUST visually reflect their on/off state
  (a flat push-button styling for both states is a UX lie); every
  interactive element MUST carry a `data-tooltip` (no orphans). Also
  covers HUDs, click previews, draggable panels, NEVER ALL CAPS,
  multi-unit displays, viewport-clipping, AI-drivability. Trigger
  words: UI design, tooltip, hover preview, draggable HUD, floating
  panel, measure tool, UX polish, component panel, newbie-friendly,
  human factors, click preview, snap point, z-index, viewport
  clipping, UI review, toggle button, toggle state, button state,
  pressed button, active button.
---

# Human UI Patterns

UI affordances that are trivially obvious to a human user AND trivially
easy for an AI to forget. Every single one of these came from a real
user call-out β€” "your tooltip is cut off below the fold," "you don't
show me a preview of where my click will land," "your HUD is stealing
my screen real estate," "newbies can't figure out what this button
does." Treat this skill as a checklist for every clickable,
hoverable, or draggable element.

## Three rules that always get violated β€” check these FIRST

If you only have time to enforce three rules from this whole skill,
make it these. They're regressions every Adom UI has shipped at least
once and that real users have flagged out loud.

1. **Tooltips MUST be a body-appended `position:fixed` div at z-index
   99999** β€” never a CSS `::after` pseudo on the trigger. The pseudo
   gets clipped by any ancestor with `overflow: hidden`, `transform`,
   `filter`, `backdrop-filter`, or `isolation`, and there is NO
   z-index tiering that recovers it. Detail in Β§1d. **User feedback
   2026-04-28: "you failed on tooltips again. can we update the
   design skill to make it higher priority to ensure tooltips are
   above all other ui elements?"**

2. **Every interactive element gets a `data-tooltip`** β€” buttons,
   icon-only controls, jargon-labeled widgets, status pills, dropdowns,
   draggable handles. No orphans. Detail in Β§1a. **User feedback
   2026-04-28: "why doesn't everything have a tooltip?"**

3. **A button bound to a binary state MUST visually reflect that
   state.** A flat "push button" styling for both ON and OFF is a UX
   lie β€” the user has no way to tell whether the HUD they want is
   already open, whether the gizmo they expected is active, whether
   the layer they care about is visible. Detail in Β§"Toggle buttons".
   **User feedback 2026-04-28: "if something is a toggle button, you
   better treat it as a toggle button."**

---

## 1. Tooltips

### 1a. Every interactive element gets a descriptive tooltip

Buttons, icon-only controls, labels with technical jargon, and any
non-obvious widget MUST carry a tooltip written for a new user who
has never seen the app before.

**Required content of a button tooltip:**

1. What the button does (one sentence).
2. What will change in the app after you click it (one sentence).
3. For destructive / irreversible actions, what is preserved and what
   is lost.
4. For jargon-labeled buttons (e.g. "EBU R128", "MPSSE", "zUp"),
   define the jargon in a plain-English aside.

**Required content of a label tooltip** (for dim labels, status
readouts, or small numbers whose meaning isn't obvious):

1. What the label means.
2. The unit, if any (e.g. "in millimetres").
3. How a user would act on this value (is it a warning? a measurement?
   a configuration?).

Never use HTML `title=""` β€” browsers render them inconsistently
(immediate popup on some, 2-second delay on others), don't support
multi-line content well, and ignore your app's brand styling. Use
`data-tooltip="…"` rendered by the body-appended fixed-div renderer
in Β§1d. **Do NOT** render the tooltip via a CSS `::after` pseudo on
the trigger β€” that's the single most common regression in this
codebase (clipped by ancestor stacking contexts).

### 1b. Tooltips reveal after a 500ms hover delay

Instant tooltips spam the user whenever their cursor passes over the
HUD. A 500ms delay means intentional hovers get the explanation,
cursor fly-bys don't fire anything. CSS pattern:
see [ui-implementation-reference.md](ui-implementation-reference.md) Β§ 1b.

### 1c. Tooltips must NEVER render off-screen

Two rules: (1) `max-width: min(320px, calc(100vw - 40px))`; (2) on
`mouseenter`, measure the trigger rect and add `data-tooltip-v="top"` or
`data-tooltip-align="right"` if the tooltip would overflow the viewport. Full
JS + CSS: see [ui-implementation-reference.md](ui-implementation-reference.md) Β§ 1c.

### 1d. Tooltips = ONE body-appended `position:fixed` div at z:99999 flat

**Rule:** a tooltip is the topmost layer of the entire app. Period.
It uses a single `<div>` appended directly to `<body>`, with
`position: fixed`, and `z-index: 99999` flat. It is NEVER a CSS
`::after` pseudo on the trigger, and its z-index is NEVER tiered
against its owner HUD's z-index. Every other HUD in the app caps
its z at ≀99998.

Three failure modes that make this the only correct pattern: (1) HUD
`overflow: hidden` clips `::after` at the HUD edge regardless of
z-index; (2) `transform`/`filter`/`isolation` on an ancestor creates a
stacking context that bounds descendants' z-indexes; (3) owner-HUD
tiering breaks when one HUD covers another HUD's button.

#### Z-INDEX LADDER (canonical β€” use these tokens, NOT hardcoded numbers)

Every Adom app SHOULD declare these CSS variables in `:root` and
reference them everywhere instead of hardcoding numbers. The ladder
exists so an AI session can never accidentally invert the stacking
order by picking a number out of thin air.

```css
:root {
  --z-hud:           50;     /* draggable HUDs */
  --z-toolbar:       80;     /* fixed header / footer / banner */
  --z-floating-menu: 200;    /* dropdowns, popovers, variant pickers, autocomplete */
  --z-toast:         400;    /* non-blocking confirmations */
  --z-modal:         600;    /* full-screen overlays, banners, completion dialogs */
  --z-tooltip:       99999;  /* always on top */
}
```

**Order from bottom (lowest) to top (highest):**

| Layer | Token | Number | Examples |
|---|---|---|---|
| Base content | `auto` | β€” | the canvas, the page body |
| HUDs (draggable) | `--z-hud` | 50 | source HUD, scene-graph HUD |
| Toolbars (fixed) | `--z-toolbar` | 80 | header, footer, banner |
| Floating menus | `--z-floating-menu` | 200 | dropdowns, variant pickers, autocomplete |
| Toasts | `--z-toast` | 400 | success confirmations |
| Modal overlays | `--z-modal` | 600 | bake-complete dialog, refusal banner |
| Tooltip | `--z-tooltip` | 99999 | always wins |

**Why `floating-menu > toolbar`:** a dropdown opened from a button in the toolbar must render ABOVE the toolbar's strip β€” otherwise the menu can't visually escape the toolbar's bounding box. Same for HUD: a menu opened from a button on a HUD must beat the HUD's z so the menu can extend outside the HUD chrome.

**Why `tooltip` is two orders of magnitude above everything else:** so an in-app tooltip ALWAYS wins. Tooltip text is supposed to clarify the element you're hovering, regardless of what other UI is on top of it.

**The other failure mode that's NOT a z-index issue but always blamed on z-index:** `overflow: hidden` (or `clip`) on an ANCESTOR clips an `position:absolute` descendant menu visually, regardless of any z-index. If your dropdown menu opens upward out of a toolbar with `overflow: hidden`, the menu disappears not because of z-index but because the parent box clips it. Fixes: (a) `overflow-x: clip; overflow-y: visible` (lets the menu escape vertically while still preventing horizontal scroll), or (b) reposition the menu to `position: fixed` so it lives at the document root not inside the clipped ancestor.

**Required practice for every new app:** declare the `--z-*` tokens in `:root` first, before writing any z-index. CSS lint should reject any literal `z-index: N` outside this declaration block. User feedback 2026-04-28: *"can't you make a skill rule for ui design that documents what z-index huds vs tooltips run at so this doesn't happen anymore. i'm constantly prompting you to fix this."*

Position is a SEPARATE concern from z-index β€” anchor the tooltip
RELATIVE TO THE CURSOR, offset ~14 px into the best quadrant; do NOT
anchor to the trigger element. The ONE cardinal sin: a tooltip that
covers the thing you're hovering.

Full implementation code (body-appended div, `position:fixed`,
`pickAnchor` algorithm, cursor-tracking, 4-iteration failure history):
see [ui-implementation-reference.md](ui-implementation-reference.md) Β§ 1d.

### 1f. One tooltip at a time β€” most-nested element wins

Browsers happily fire `:hover` on every ancestor in the chain, so
if both a button AND its parent row have `data-tooltip`, BOTH
tooltips show at once, overlapping. Always suppress ancestor
tooltips when a descendant tooltip is active.

**Rule:** at most ONE tooltip visible in the DOM at any moment, and
it belongs to the innermost `[data-tooltip]` element under the
cursor.

Implementation: global `mouseover` listener finds the innermost
trigger via `closest('[data-tooltip]')`, walks up its ancestors,
and puts `data-tooltip-suppress=""` on each ancestor that also
carries `data-tooltip`. Clear on `mouseout` to a non-tooltip
target.

JS + CSS implementation and the real failure case (nested chip row +
group header showing two tooltips at once):
see [ui-implementation-reference.md](ui-implementation-reference.md) Β§ 1f.

### 1e. Never use ALL CAPS for tooltip or label content

`TEXT IN ALL CAPS READS AS SHOUTING` to a human user. Write tooltip
content in sentence case ("Reset camera to the default isometric
view"), not in all-caps ("RESET CAMERA"). Same for in-app labels,
section titles, button text, status pills β€” keep it sentence or
title case.

Watch for an INHERITANCE BUG: if the parent has `text-transform: uppercase`,
the `::after` pseudo inherits it and renders every tooltip in all caps. Reset
`text-transform: none` and `letter-spacing: 0` on the tooltip element β€” snippet
in [ui-implementation-reference.md](ui-implementation-reference.md) Β§ 1e.

If a visual heading emphasis is needed, use **weight** (600-700) and
**letter-spacing** (0.02em), not caps.

---

## 2. Click previews β€” show what would happen BEFORE the click

**Rule:** any click that commits an irreversible-feeling action
(place a marker, select an edge, add a component, drop a point) MUST
show a live preview of what's about to happen as the user moves
their mouse. A "will this click do what I want?" moment where the
user has to click-and-undo is a failure.

Concrete cases:

| Action | Preview |
|---|---|
| Click to place a measure point | Translucent sphere at the nearest snap vertex follows the cursor |
| Click to select an edge | Bright colored tube along the nearest edge of the hit mesh |
| Click to select a body | Outline / highlight-layer stroke around the hovered mesh |
| Click to place a component in a schematic | Ghost component at cursor position, proper orientation |
| Click to add a vertex to a polygon | Ghost vertex + ghost polygon edge snapping to the last vertex |
| Click to drop a file onto a target | Target area dims or outlines on dragover |

**Implementation note:** the preview mesh/element MUST be
`isPickable = false` (or equivalent) so subsequent hover-picks don't
pick the preview itself instead of the underlying geometry. Real
bug from adom-tsci's measure tool: the first preview sphere became
the pick target for the next hover, stalling the tool.

When the user's intent changes (filter switches, tool closes,
selection limit reached), dispose the preview immediately. Don't let
stale ghost markers linger.

---

## 2b. Toggle buttons

A button bound to a binary state (HUD open/closed, gizmo active/inactive,
layer visible/hidden, recording on/off, sharing on/off, mute on/off,
etc.) MUST visually reflect that state. A flat "push-button" styling
that looks identical for both ON and OFF is a UX lie: the user has no
way to tell whether the action they want is already done.

**Required:**

1. Add `aria-pressed="true|false"` to the button (a11y + state probe
   for tests).
2. Add an `.is-on` class (or equivalent) when the bound state is on.
3. CSS for `.is-on` (and/or `[aria-pressed="true"]`) MUST visibly
   differ from the resting state β€” different background, brighter
   border, inset shadow, accent-color text. Pick at least two of
   those, not just one. A 5% background-tint shift is too subtle.
4. Two-way: clicking the button flips the state AND any other path
   that flips the state (e.g. the HUD's own X-button, a keyboard
   shortcut, a programmatic `setX(false)`) MUST update the toggle
   class on the button. Otherwise the button drifts out of sync.
5. The tooltip should phrase the action conditionally: "Show sources
   HUD" when off, "Hide sources HUD" when on β€” same way real
   toggle UIs do. (Optional but high-polish.)

**Anti-pattern that ships every time:**

```html
<!-- both states render identically β€” looks broken to users -->
<button onclick="toggleHud('hud-foo')">foo</button>
```

**Right way:**

```html
<button id="btn-foo" aria-pressed="false"
        onclick="toggleHudButton('hud-foo', 'btn-foo')">foo</button>
```

```js
function toggleHudButton(hudId, btnId) {
  var h = document.getElementById(hudId);
  var b = document.getElementById(btnId);
  var willShow = (getComputedStyle(h).display === 'none');
  h.style.display = willShow ? '' : 'none';
  b.classList.toggle('is-on', willShow);
  b.setAttribute('aria-pressed', willShow ? 'true' : 'false');
}
// Two-way sync: when the HUD's close-X is clicked, ALSO clear the
// button state.
hudCloseBtn.addEventListener('click', function () {
  hud.style.display = 'none';
  document.getElementById('btn-foo').classList.remove('is-on');
  document.getElementById('btn-foo').setAttribute('aria-pressed', 'false');
});
```

```css
button.is-on,
button[aria-pressed="true"] {
  background: rgba(0, 184, 177, 0.22);    /* brand teal β€” visible */
  color: #00e6dc;
  border-color: rgba(0, 184, 177, 0.60);
  box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.35);
}
```

**User feedback that drove this section (2026-04-28):** *"if something
is a toggle button, you better treat it as a toggle button. add that
to skill too and audit yourself cuz the provenance is a toggling of a
hud and you don't make the button act that way."*

---

## 3. HUDs and floating panels

### 3a. Every floating HUD must be draggable

A HUD that sits in a fixed spot steals screen real estate the user
can't reclaim. Every HUD that opens over the main canvas MUST be
draggable β€” the user grabs a visible handle (a grip strip, a header
bar, the title) and moves the HUD anywhere. No exceptions.

Implementation: use a shared `makeDraggable(element, handleEl)` helper.
Account for the offset-parent's viewport position β€” a naΓ―ve implementation
gives a ~42px "cursor jump" when the HUD is inside a panel below a tab strip
(`clientY` is viewport-relative but `style.top` is offset-parent-relative).
Full `makeDraggable` implementation:
see [ui-implementation-reference.md](ui-implementation-reference.md) Β§ 3a.

### 3b. Every HUD must be collapsible

Users need to keep the HUD OPEN but get it out of the way during
other work (orbiting the view, clicking the canvas). Provide a
minimise (`βˆ’`) button in the header that collapses the HUD to just
the header strip, preserving its position and all state. Clicking
the minimise again expands.

### 3bb. Double-clicking the drag handle toggles collapse

Every draggable HUD header should ALSO respond to `dblclick` by
toggling the same collapsed/expanded state that the `\u2212` / `+` icon
controls. Reason: the minimise icon is a tiny target, and users who
already know "drag this bar to move" naturally try double-clicking
it to get it out of their way (Fusion 360, Blender, Figma, most IDE
dockable panels behave this way). Don't make them aim.

Wire it on the handle element, not the whole HUD, so double-clicking
inside the scrolling body doesn't trigger. Ignore `dblclick` on child
icons that have their own action (`βˆ’`, `Γ—`, dropdown carets). Call
`e.preventDefault()` to avoid text selection. Full snippet:
see [ui-implementation-reference.md](ui-implementation-reference.md) Β§ 3bb.

### 3c. Every HUD must be dismissible, but re-openable from the main toolbar

When the user hits the `Γ—` to close a HUD, don't leave an orphan
"show me again" floating button lying around the canvas. Instead,
re-expose the HUD via a toggle button on the MAIN toolbar. That
way, close = HUD disappears, toolbar remains; click the toolbar
button to bring it back. Orphan hamburger buttons are clutter.

### 3d. HUDs: auto-fit CONTENT to the container, but never force-clamp the USER's drag

There are two different concerns inside the same element, and they
have OPPOSITE correct behaviours:

**(a) Auto-fit the HUD's own content growth** β€” yes, constrain. If
the user picks two chips and a measurement HUD would grow to 1050px
tall in a 900px pane, that HUD must cap its own height and give the
growable middle section its own scrollbar. This is the HUD taking
responsibility for its intrinsic size.

**(b) The user's drag position** β€” NO, leave it alone. If the user
chooses to park a HUD with its right half off the canvas because
they want max board visibility, that's a valid choice. "Snapping
the HUD back on-screen because half of it overlapped the edge" is
paternalistic and infuriating β€” the user deliberately moved it
there. A later resize should NOT re-snap it either.

Rules, non-negotiable:

1. **Cap max dimensions to the container, not the viewport.** Use
   `max-height: calc(100% - <margin>px)` on the HUD against the
   positioned parent (e.g. the 3D viewer wrap), NOT `calc(100vh -
   …)`. The parent itself may be smaller than the viewport (tabs
   bar, toolbar, IDE chrome).
2. **Internal scrolling for the variable-height section.** A HUD
   typically has fixed top chrome (header, filter buttons) and
   fixed bottom chrome (footer, action buttons). The MIDDLE β€” the
   selections list, the rows of measurements, the list of items β€”
   must be the flex child that gets `overflow-y: auto`,
   `min-height: 0`, and `flex: 1 1 auto`. Header + footer stay
   pinned; middle scrolls. Never let the whole HUD scroll β€” users
   lose the action buttons.
3. **Do NOT clamp-to-container on drag.** The drag handler
   faithfully follows the cursor β€” if the user drags the HUD
   partially off the container, respect it. The user may be
   deliberately parking it to get a wider look at the canvas.
4. **Do NOT re-snap on window resize.** Once the user has placed a
   HUD, their position sticks. If the container shrinks and the
   HUD is now mostly off-screen, that's still the user's last
   intent β€” they can drag it back in three seconds. Snapping "for
   their own good" punishes the deliberate user.
5. **Reconsider content density when max-height is regularly hit.**
   If a HUD regularly hits its cap even with scrolling, the fix is
   often less chrome, not more scroll β€” collapse attribute groups
   by default, truncate labels with `…` on hover, or drop
   decorative rows when a selection doesn't need them.
6. **Minimum width** still applies: the HUD should have a
   `min-width` so its own labels don't squeeze into two-letter
   columns as the container shrinks. If the container narrows
   below `min-width`, the HUD overflows horizontally β€” that's
   fine, the user can drag it.

Real failure cases that motivate this split:

- **Overflow from content growth (must cap):** measure HUD on
  adom-tsci grew to 1050px tall after a user picked two chips. IDE
  pane was 900px. Selection 2's X/Y/Z rows were off-screen,
  un-scrollable. Fix: `max-height: calc(100% - 80px)` on the HUD,
  `overflow-y: auto` on `.mb-selections`.
- **Over-constrained drag (must NOT clamp):** after fixing the
  content cap, a first-pass added a clamp-to-container on drag and
  on resize. User feedback: "huds should be allowed to drag off
  the edge of the webview. you are constraining them too much
  now." Fix: drop the drag clamp and the resize re-clamp; only the
  HUD's own size-fit is responsive.

### 3f. Guided walkthroughs: interruptible, pausable, AI-drivable

For any board / scene / document tour (a "🎬 Walk me through this"
feature), the user must feel in control at every moment. Four
non-negotiable rules:

1. **Interruptible camera animations.** The camera fly-to-next-step
   must yield the moment the user orbits/pans/zooms. Hook
   `pointerdown` on the canvas β†’ stop the running Babylon/Three
   animation and auto-pause the tour's step timer. Never force the
   user to wait for an animation to complete.

2. **Explicit pause state with visible indicator.** Pause has a
   button AND a keyboard shortcut (Space). When paused, show a badge
   ("⏸ Paused") on the narration HUD and recolour the progress bar
   so the user can see the tour stopped. Resume re-flies the camera
   (since the user moved the view) and resumes the step timer with
   the REMAINING duration, not from scratch.

3. **Reading-speed-aware auto-advance.** Step duration =
   `max(minMs, text.length * 45ms + 2500ms + sentenceCount * 300ms)`.
   45 ms / char β‰ˆ 220 wpm (comfortable) plus a 2.5 s floor so slow
   readers don't get rushed off the step before the camera lands.
   Don't use fixed "5 seconds per step."

4. **AI-drivability as a first-class concern.** Every walkthrough
   action (start / pause / resume / next / prev / close) must also
   be HTTP + CLI reachable so the tour can be ralph-loop tested. A
   `GET /api/walkthrough` status endpoint reports
   `{active, step, total, paused, currentStepId, title}` so the
   ralph script knows which step it's on for shotlog captions.
   Without AI drivability you can't regression-test the tour when
   the underlying board changes.

Script format (WALKTHROUGH data array, `focus` kinds, `highlight`, `minMs`),
ordering rules (biggest/most-important β†’ smallest/background for PCBs), HUD
layout details, and real failure history:
see [ui-implementation-reference.md](ui-implementation-reference.md) Β§ 3f.

### 3e. Group long lists with collapsible master toggles

When a panel contains many toggleable items that naturally cluster
(by kind, layer, net, priority), expose group headers with:

- A caret (`β–Ύ`/`β–Έ`) that collapses/expands just that group
- A count (`<visible>/<total>`) showing group state at a glance
- A master toggle icon (`●` all visible / `β—‹` all hidden /
  `◐` mixed) that flips every item in the group at once

Keep per-item toggles inside each group β€” never replace them. The
"hide every testpoint so I can see traces" operation is miserable
one-at-a-time; the "hide THIS specific chip" operation needs the
per-item row.

Default state: everything COLLAPSED. First-time users see a compact
overview of groups and expand only what they care about.

---

## 4. Multi-unit displays

When a value has a cross-cultural unit convention, provide a second
unit readout in parentheses. For PCB work: millimetres primary,
inches or mils secondary. For audio: dB primary, numeric ratio
secondary. For temperature: Β°C primary, Β°F secondary.

Expose this as a "Secondary Units" dropdown (None / Inches / mils /
etc.) rather than hardcoding a second unit, so users who don't need
it aren't distracted by it.

Change precision via a dropdown too β€” don't hardcode three decimals
when some users want to see 0.1234 mm and others just 0.1 mm.

---

## 4b. Icons β€” always monochrome, always custom-drawn

**Full rule lives in `gallia/skills/brand/SKILL.md` β†’ Icons.** Summary
of the parts UI designers forget most:

- **Never use Unicode emoji** (`πŸ“ πŸ” 🎬 ⎚ πŸ”§ …`) as UI icons. Emoji
  render as multi-color by design (Twemoji / Apple Color / Noto) and
  mix miserably next to any hand-drawn SVG. This applies even to
  "quick prototype" toolbars β€” emoji end up shipping.
- **Monochrome only.** `#e6edf3` on dark backgrounds or `currentColor`
  so it inherits the surrounding text color. No gradients, no
  shadows, no brand-palette accents on icons.
- **Custom-drawn is the default, not the fallback.** Every icon
  should be AI-drawn SVG that depicts the specific concept β€”
  calipers for Measure, a magnifier with a data dot for Inspect, a
  clapperboard for Walkthrough, a board-with-pins silhouette for
  the Components panel. Hand-drawing per-feature is how the toolbar
  actually teaches users what each button does.
- **MDI is the fallback**, only for fully-generic concepts (chip /
  eye / cog) where nothing you'd draw is more specific. Still apply
  the monochrome rule.
- `viewBox="0 0 24 24"` everywhere for consistent sizing; pick one
  style (stroked vs filled, 1.5–2 px stroke) per surface and stick
  with it.

If the toolbar/HUD/tooltip you're writing contains a single emoji
character pretending to be an icon, you still have work to do.

---

## 5. Match existing well-loved tools

If the user's workflow involves a tool they already know well (Fusion
360, Photoshop, KiCad, Figma, …), **copy their UX for comparable
features before inventing your own**. Users have muscle memory for
these tools; reinventing the button layout or mode picker adds
friction for zero benefit.

For measurements specifically, Fusion 360 is the reference:
- Vertical label / control grid layout (Selection Filter, Precision,
  Secondary Units, Clear Selection, Show Snap Points, Close)
- Selection filter with three icons (Point, Edge, Body)
- `1` / `2` tags placed over the 3D view at each selection point
- Floating distance label at the midpoint between two selections
- Persistent highlighted edges/bodies after selection so the user
  can see what they picked

If you're about to invent something different, first ask yourself
whether the user will have to re-learn muscle memory they already
have.

---

## 5b. File and folder paths displayed in UI are CLICKABLE β€” reveal in VS Code

Whenever an Adom app shows a file or folder path in any UI surface (HUD label, toast, tooltip, list cell, info bar, error message, breadcrumb), that path is a **link** that opens the user's VS Code Explorer sidebar focused on that file. Always. No exceptions.

The user's mental model is "I see a path, I want to look at the file." Forcing them to copy/paste into a terminal to `code` it, or hunt for it in the explorer, is friction we control and shouldn't add.

### How to wire it

The Adom container ships [`adom-vscode reveal <path>`](../adom/SKILL.md) which reveals the path in the VS Code Explorer sidebar (and expands the tree to it). Webview UIs can't shell directly, so route through the app's own server:

```js
// HTML side β€” turn the path into a button (NOT an anchor; nothing to navigate to).
<button class="path-link" data-tooltip="Reveal this file in the VS Code Explorer">
  /tmp/r0402.glb
</button>

// click handler β€” POSTs to the app's own backend
async function reveal(path) {
  const r = await fetch("api/reveal", { method: "POST",
    body: JSON.stringify({ path }) });
  const j = await r.json();
  showToast(j.ok ? "Revealed " + path : "Reveal failed: " + j.error,
            j.ok ? "ok" : "error");
}
```

```rust
// server side β€” the app shells to adom-vscode
(Method::Post, "/api/reveal") => {
    let out = std::process::Command::new("adom-vscode")
        .args(["reveal"]).arg(&path).output();
    // ... return {ok, path} or {ok: false, error}
}
```

### Required behaviour

- **Tooltip** every path link with `data-tooltip="Reveal this <thing> in VS Code Explorer"` (per Β§1a).
- **Toast** on click with the result (`Revealed /tmp/r0402.glb` or `Reveal failed: <reason>`) per Β§6.
- **Visual treatment** β€” accent-coloured underline on hover, focus ring for accessibility. Don't go heavy-handed (full button styling); paths should still read as text first, link second. The `adom-quicklook` app's `.path-link` CSS is a good template.
- **Don't open the file in VS Code's editor** β€” that's a different verb (`adom-vscode open`). The default for "click on a path" is *reveal in explorer* because users want to see the surrounding folder, then decide what to do. If your tool genuinely wants edit-on-click, add a separate "open" button next to the reveal one β€” never silently choose for them.
- **Skip if the path is unreachable.** If the path is on a different container or doesn't exist on this filesystem, don't make the label clickable; show it as plain dimmed text and (if useful) include a tooltip explaining why. Better than a click that fails silently.

### ⚠ The workspace-boundary footgun (caught the hard way 2026-04-28)

**`adom-vscode reveal <path>` returns `OK:` exit 0 even when the path is outside VS Code's open workspace folders.** VS Code's Explorer sidebar is a *workspace tree*; it can only render files under the folders the user has opened (typically `$HOME/project/` on Adom containers). Files in `/tmp/`, `/var/`, or any other prefix produce a silent visual no-op β€” the CLI says success, the user sees nothing change.

This is a footgun for "I'll just download to /tmp and reveal" workflows (URL-source quicklooks, conversion intermediates, etc.).

**Server-side pre-check before invoking `adom-vscode reveal`:**

```rust
// On Adom containers the workspace root is $HOME/project/. Some users
// add more roots via VS Code's "Add Folder to Workspace"; until
// adom-vscode exposes a /workspace endpoint, $HOME/project is the
// safe baseline.
fn vscode_workspace_roots() -> Vec<PathBuf> {
    let mut out = Vec::new();
    if let Ok(home) = std::env::var("HOME") {
        let p = PathBuf::from(home).join("project");
        if p.exists() {
            if let Ok(c) = std::fs::canonicalize(&p) { out.push(c); }
        }
    }
    out
}
fn is_in_vscode_workspace(p: &Path) -> bool {
    let roots = vscode_workspace_roots();
    if roots.is_empty() { return true; } // no info β†’ trust adom-vscode
    roots.iter().any(|r| p.starts_with(r))
}
```

**Response when outside:**

```json
{
  "ok": false,
  "outside_workspace": true,
  "path": "/tmp/r0402.glb",
  "workspace_roots": "/home/adom/project",
  "error": "File is outside the VS Code workspace (workspace: /home/adom/project). Move or copy it into the workspace to make it revealable."
}
```

**JS branches the toast** so the user sees something useful instead of a fake success:

```js
if (j && j.outside_workspace) {
  showToast(
    "Outside VS Code workspace: " + j.path +
    ". Move/copy into " + j.workspace_roots + " to reveal.",
    "error"
  );
}
```

If your app downloads source files into `/tmp/` (URL-source quicklooks, downstream conversion outputs), consider downloading into `$HOME/project/.adom-cache/<app>/` instead so the file stays revealable. That's strictly better UX than a "reveal failed" toast every time.

### When the local server is dead

If the user `Ctrl-C`s your app's server but the Hydrogen tab is still open, clicks on the path link return whatever the proxy serves on connection-refused β€” typically a plain-text `connect ECONNREFUSED 127.0.0.1:<port>` body. **Always read the response as text first then attempt JSON.parse**, so a non-JSON body produces a useful toast instead of a `SyntaxError("Unexpected token 'c'")`:

```js
const text = await r.text();
let j = null;
try { j = JSON.parse(text); } catch (_) {}
if (j && j.ok) { ... }
else if (j && j.error) { showToast("Reveal failed: " + j.error, "error"); }
else if (!r.ok) { /* 5xx β€” proxy or server */ }
else {
  showToast("Server unreachable β€” restart " + APP_NAME, "error");
}
```

### Why this matters

Users who see paths in your UI and *can't click them* go through:
1. Select with mouse (often interrupting other selection state).
2. Cmd/Ctrl-C.
3. Switch to terminal.
4. Type `code <paste>` or `ls <paste>`.
5. Realize they wanted Explorer, not editor; click into the sidebar.
6. Navigate to the file manually.

Six steps for what should be one click. The Adom platform has every piece needed to do this for them β€” `adom-vscode reveal` is one shell call, and webview apps proxy it through their own server. There's no excuse for static path labels.

## 6. Feedback for every action

Every click, drag, toggle, or keyboard shortcut needs IMMEDIATE
feedback:

- A toast at the bottom-centre for stateless actions ("view β†’ top",
  "measure: on")
- A changed button `.active` class for toggles
- A visible preview / selection marker / highlight for spatial
  actions
- A status pill in the header for long-running operations
  ("Building… 12s", "GLB: 10:28:39 AM")

Silent success is indistinguishable from failure. If a user clicks
and nothing visually changes, they assume the click didn't register
and click again, firing the action twice. Every "I clicked but
nothing happened" bug is a feedback-latency bug.

---

## 7. AI-drivability is a feature, not an afterthought

Every user action in the UI SHOULD have a corresponding CLI or HTTP
endpoint so Claude (or the user's own scripts) can drive the app
without clicking. See the `app-creator` skill's Β§7 for the full
HTTP pattern. Practical effect: a measure HUD's `Close` button,
a toolbar's `Wireframe` toggle, a component panel's per-row
visibility β€” every one of these should be reachable via
`adom-<app> <subcommand>`.

When you extend a HUD with a new control, add the matching CLI
subcommand in the same change. Otherwise the AI-driven ralph loop
can't verify your addition works.

---

## Checklist β€” review every UI change against these

- [ ] Every new button/label/control has a `data-tooltip`?
- [ ] Every tooltip is multi-line and written for a newbie?
- [ ] Tooltips have `z-index: 99999` with `text-transform: none`?
- [ ] No tooltip or label text is written in ALL CAPS (shouting)?
- [ ] Tooltips auto-flip when near viewport bottom/right?
- [ ] Every irreversible-feeling click has a live preview on hover?
- [ ] Preview meshes/elements are `isPickable = false` so they
      don't interfere with subsequent picks?
- [ ] Every floating HUD is draggable via a visible grip?
- [ ] The drag handler accounts for offset-parent viewport position?
- [ ] Every HUD has `max-height: calc(100% - <margin>)` against its
      positioned container so growth cannot overflow?
- [ ] The variable-height section uses internal `overflow-y: auto`
      while header + footer stay pinned?
- [ ] The drag handler does NOT clamp to container edges
      (parking-off-screen is a valid user choice)?
- [ ] Resize does NOT re-snap the HUD (user's drag position is
      sacrosanct)?
- [ ] Every HUD has a minimise button that collapses to header?
- [ ] Double-clicking the drag handle ALSO toggles collapse (don't
      force users to aim at the tiny minimise icon)?
- [ ] Every HUD has a close button + a toolbar button to re-open?
- [ ] Long toggle lists are grouped with master-toggle headers?
- [ ] Default state for groups = collapsed, user expands to drill in?
- [ ] Every action has immediate visible feedback (toast, class, preview)?
- [ ] Every UI action has a matching CLI / HTTP endpoint?
- [ ] Does this match the UX of a well-loved tool the user already
      knows (Fusion / KiCad / Figma / …)?

If any of these are unchecked, the UI isn't done yet.

## Provenance captions on every shown artifact

Whenever a UI displays an image, render, table, code block, or embedded viewer that the user might confuse for content from another source, attach a one-line **provenance caption** directly underneath. The caption answers: *who or what produced this, and what it is NOT*.

Caption must state: (1) the producer tool/script/person; (2) negative attribution ("Not from the datasheet"); (3) inputs or fallbacks taken. In markdown use a `> **Provenance β€” name.** ...` blockquote; in interactive UIs use small italic text in `text-secondary` colour directly below the artifact β€” never behind a tooltip. Both heroes and provenance are mandatory: heroes give identity at a glance, provenance gives trust at a second glance.

Full conventions (markdown, interactive UI, iframe):
see [ui-implementation-reference.md](ui-implementation-reference.md) Β§ Provenance captions.

## Hero images: one-glance identity

Any browsable object (datasheet, symbol, footprint, molecule, skill, app, video, board, component, 3D model) should have exactly one **hero image** β€” a single picture that lets a human identify the thing in a fraction of a second, without reading the title. If the user can open a list of ten of these objects and not tell them apart at a glance, the design is broken.

Key rules: one hero per object; render at thumbnail (32–48 px) and medium (200–400 px); hero appears upper-left on detail pages and as the first visual in index/browse views; never leave it empty β€” use a deterministic placeholder (initials + colour from slug) if no hero is set.

Full rules (what to pick per object type, where to place it, delivery convention):
see [ui-implementation-reference.md](ui-implementation-reference.md) Β§ Hero images.

Sub-Skills
?
What are Sub-Skills?

Sub-skills are community-contributed AI skill extensions for this component. They teach AI assistants about specific tools, configurators, or workflows.

Examples:

  • A manufacturer’s configuration tool for a motor controller
  • A community-written design guide for an amplifier circuit
  • An automated test/validation script for a sensor module

How to add one: Click Add Sub-Skill, provide the URL to your skill and a brief description. Submissions are reviewed by the Adom team before going live.

No sub-skills yet. Be the first to contribute one!

Recent activity

1 commit
  • 🏷
    Release v1.0.0 John Lauer 26 days ago
    Initial publish β€” pins every UI frustration that bubbled up during adom-tsci so future tools start from the right defaults instead of re-discovering them
0 revisions · Updated 2026-05-01 14:13:27