human UI patterns
UnreviewedNon-negotiable UI rules for every Adom app. Read BEFORE writing any hover/click/drag element. Three rules get violated most often: tooltips MUST be body-appended position:fixed divs at z-index 99999 (
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.
Tooltips MUST be a body-appended
position:fixeddiv at z-index
99999 — never a CSS::afterpseudo on the trigger. The pseudo
gets clipped by any ancestor withoverflow: hidden,transform,filter,backdrop-filter, orisolation, 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?"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?"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:
- What the button does (one sentence).
- What will change in the app after you click it (one sentence).
- For destructive / irreversible actions, what is preserved and what
is lost. - 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):
- What the label means.
- The unit, if any (e.g. "in millimetres").
- 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. Usedata-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) onmouseenter, measure the trigger rect and add data-tooltip-v="top" ordata-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>, withposition: 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) HUDoverflow: 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):
| 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 § 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. Resettext-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:
| 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 beisPickable = 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:
- Add
aria-pressed="true|false"to the button (a11y + state probe
for tests). - Add an
.is-onclass (or equivalent) when the bound state is on. - 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. - 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 programmaticsetX(false)) MUST update the toggle
class on the button. Otherwise the button drifts out of sync. - 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 onclick="toggleHud('hud-foo')">foo</button>
Right way:
<button id="btn-foo" aria-pressed="false"
onclick="toggleHudButton('hud-foo', '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). Calle.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:
- 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), NOTcalc(100vh - …). The parent itself may be smaller than the viewport (tabs
bar, toolbar, IDE chrome). - 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 getsoverflow-y: auto,min-height: 0, andflex: 1 1 auto. Header + footer stay
pinned; middle scrolls. Never let the whole HUD scroll — users
lose the action buttons. - 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. - 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. - 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. - Minimum width still applies: the HUD should have a
min-widthso its own labels don't squeeze into two-letter
columns as the container shrinks. If the container narrows
belowmin-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: autoon.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:
Interruptible camera animations. The camera fly-to-next-step
must yield the moment the user orbits/pans/zooms. Hookpointerdownon 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.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.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."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. AGET /api/walkthroughstatus 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.
#e6edf3on dark backgrounds orcurrentColor
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/2tags 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.glborReveal 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-quicklookapp's.path-linkCSS 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:
- Select with mouse (often interrupting other selection state).
- Cmd/Ctrl-C.
- Switch to terminal.
- Type
code <paste>orls <paste>. - Realize they wanted Explorer, not editor; click into the sidebar.
- 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
.activeclass 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 viaadom-<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: 99999withtext-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 = falseso 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.