{
  "schema_version": 1,
  "type": "skill",
  "slug": "human-ui-patterns",
  "title": "human UI patterns",
  "brief": "Non-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 (",
  "version": "1.0.0",
  "tags": [],
  "license": "MIT",
  "sample_prompts": [
    {
      "label": "Tooltip rules",
      "prompt": "How do I add a tooltip to my Adom app?"
    },
    {
      "label": "Toggle UI",
      "prompt": "Show me the toggle-button styling rules"
    },
    {
      "label": "HUD pattern",
      "prompt": "How do I make a draggable, collapsible HUD?"
    },
    {
      "label": "Z-index",
      "prompt": "Why is my tooltip getting clipped by the panel?"
    },
    {
      "label": "AI-drivability",
      "prompt": "Make this UI driveable from the CLI"
    },
    {
      "label": "Multi-unit display",
      "prompt": "How should I show mm + mils + inches at once?"
    }
  ],
  "source_path": "SKILL.md",
  "readme": "---\nname: human-ui-patterns\ndescription: >\n  Non-negotiable UI rules for every Adom app. Read BEFORE writing any\n  hover/click/drag element. Three rules get violated most often and\n  must be checked first: tooltips MUST be body-appended `position:fixed`\n  divs at z-index 99999 (never CSS `::after` — gets clipped by HUD\n  `overflow:hidden` / `backdrop-filter` / `transform` stacking\n  contexts); toggle buttons MUST visually reflect their on/off state\n  (a flat push-button styling for both states is a UX lie); every\n  interactive element MUST carry a `data-tooltip` (no orphans). Also\n  covers HUDs, click previews, draggable panels, NEVER ALL CAPS,\n  multi-unit displays, viewport-clipping, AI-drivability. Trigger\n  words: UI design, tooltip, hover preview, draggable HUD, floating\n  panel, measure tool, UX polish, component panel, newbie-friendly,\n  human factors, click preview, snap point, z-index, viewport\n  clipping, UI review, toggle button, toggle state, button state,\n  pressed button, active button.\n---\n\n# Human UI Patterns\n\nUI affordances that are trivially obvious to a human user AND trivially\neasy for an AI to forget. Every single one of these came from a real\nuser call-out — \"your tooltip is cut off below the fold,\" \"you don't\nshow me a preview of where my click will land,\" \"your HUD is stealing\nmy screen real estate,\" \"newbies can't figure out what this button\ndoes.\" Treat this skill as a checklist for every clickable,\nhoverable, or draggable element.\n\n## Three rules that always get violated — check these FIRST\n\nIf you only have time to enforce three rules from this whole skill,\nmake it these. They're regressions every Adom UI has shipped at least\nonce and that real users have flagged out loud.\n\n1. **Tooltips MUST be a body-appended `position:fixed` div at z-index\n   99999** — never a CSS `::after` pseudo on the trigger. The pseudo\n   gets clipped by any ancestor with `overflow: hidden`, `transform`,\n   `filter`, `backdrop-filter`, or `isolation`, and there is NO\n   z-index tiering that recovers it. Detail in §1d. **User feedback\n   2026-04-28: \"you failed on tooltips again. can we update the\n   design skill to make it higher priority to ensure tooltips are\n   above all other ui elements?\"**\n\n2. **Every interactive element gets a `data-tooltip`** — buttons,\n   icon-only controls, jargon-labeled widgets, status pills, dropdowns,\n   draggable handles. No orphans. Detail in §1a. **User feedback\n   2026-04-28: \"why doesn't everything have a tooltip?\"**\n\n3. **A button bound to a binary state MUST visually reflect that\n   state.** A flat \"push button\" styling for both ON and OFF is a UX\n   lie — the user has no way to tell whether the HUD they want is\n   already open, whether the gizmo they expected is active, whether\n   the layer they care about is visible. Detail in §\"Toggle buttons\".\n   **User feedback 2026-04-28: \"if something is a toggle button, you\n   better treat it as a toggle button.\"**\n\n---\n\n## 1. Tooltips\n\n### 1a. Every interactive element gets a descriptive tooltip\n\nButtons, icon-only controls, labels with technical jargon, and any\nnon-obvious widget MUST carry a tooltip written for a new user who\nhas never seen the app before.\n\n**Required content of a button tooltip:**\n\n1. What the button does (one sentence).\n2. What will change in the app after you click it (one sentence).\n3. For destructive / irreversible actions, what is preserved and what\n   is lost.\n4. For jargon-labeled buttons (e.g. \"EBU R128\", \"MPSSE\", \"zUp\"),\n   define the jargon in a plain-English aside.\n\n**Required content of a label tooltip** (for dim labels, status\nreadouts, or small numbers whose meaning isn't obvious):\n\n1. What the label means.\n2. The unit, if any (e.g. \"in millimetres\").\n3. How a user would act on this value (is it a warning? a measurement?\n   a configuration?).\n\nNever use HTML `title=\"\"` — browsers render them inconsistently\n(immediate popup on some, 2-second delay on others), don't support\nmulti-line content well, and ignore your app's brand styling. Use\n`data-tooltip=\"…\"` rendered by the body-appended fixed-div renderer\nin §1d. **Do NOT** render the tooltip via a CSS `::after` pseudo on\nthe trigger — that's the single most common regression in this\ncodebase (clipped by ancestor stacking contexts).\n\n### 1b. Tooltips reveal after a 500ms hover delay\n\nInstant tooltips spam the user whenever their cursor passes over the\nHUD. A 500ms delay means intentional hovers get the explanation,\ncursor fly-bys don't fire anything. CSS pattern:\nsee [ui-implementation-reference.md](ui-implementation-reference.md) § 1b.\n\n### 1c. Tooltips must NEVER render off-screen\n\nTwo rules: (1) `max-width: min(320px, calc(100vw - 40px))`; (2) on\n`mouseenter`, measure the trigger rect and add `data-tooltip-v=\"top\"` or\n`data-tooltip-align=\"right\"` if the tooltip would overflow the viewport. Full\nJS + CSS: see [ui-implementation-reference.md](ui-implementation-reference.md) § 1c.\n\n### 1d. Tooltips = ONE body-appended `position:fixed` div at z:99999 flat\n\n**Rule:** a tooltip is the topmost layer of the entire app. Period.\nIt uses a single `<div>` appended directly to `<body>`, with\n`position: fixed`, and `z-index: 99999` flat. It is NEVER a CSS\n`::after` pseudo on the trigger, and its z-index is NEVER tiered\nagainst its owner HUD's z-index. Every other HUD in the app caps\nits z at ≤99998.\n\nThree failure modes that make this the only correct pattern: (1) HUD\n`overflow: hidden` clips `::after` at the HUD edge regardless of\nz-index; (2) `transform`/`filter`/`isolation` on an ancestor creates a\nstacking context that bounds descendants' z-indexes; (3) owner-HUD\ntiering breaks when one HUD covers another HUD's button.\n\n#### Z-INDEX LADDER (canonical — use these tokens, NOT hardcoded numbers)\n\nEvery Adom app SHOULD declare these CSS variables in `:root` and\nreference them everywhere instead of hardcoding numbers. The ladder\nexists so an AI session can never accidentally invert the stacking\norder by picking a number out of thin air.\n\n```css\n:root {\n  --z-hud:           50;     /* draggable HUDs */\n  --z-toolbar:       80;     /* fixed header / footer / banner */\n  --z-floating-menu: 200;    /* dropdowns, popovers, variant pickers, autocomplete */\n  --z-toast:         400;    /* non-blocking confirmations */\n  --z-modal:         600;    /* full-screen overlays, banners, completion dialogs */\n  --z-tooltip:       99999;  /* always on top */\n}\n```\n\n**Order from bottom (lowest) to top (highest):**\n\n| Layer | Token | Number | Examples |\n|---|---|---|---|\n| Base content | `auto` | — | the canvas, the page body |\n| HUDs (draggable) | `--z-hud` | 50 | source HUD, scene-graph HUD |\n| Toolbars (fixed) | `--z-toolbar` | 80 | header, footer, banner |\n| Floating menus | `--z-floating-menu` | 200 | dropdowns, variant pickers, autocomplete |\n| Toasts | `--z-toast` | 400 | success confirmations |\n| Modal overlays | `--z-modal` | 600 | bake-complete dialog, refusal banner |\n| Tooltip | `--z-tooltip` | 99999 | always wins |\n\n**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.\n\n**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.\n\n**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.\n\n**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.\"*\n\nPosition is a SEPARATE concern from z-index — anchor the tooltip\nRELATIVE TO THE CURSOR, offset ~14 px into the best quadrant; do NOT\nanchor to the trigger element. The ONE cardinal sin: a tooltip that\ncovers the thing you're hovering.\n\nFull implementation code (body-appended div, `position:fixed`,\n`pickAnchor` algorithm, cursor-tracking, 4-iteration failure history):\nsee [ui-implementation-reference.md](ui-implementation-reference.md) § 1d.\n\n### 1f. One tooltip at a time — most-nested element wins\n\nBrowsers happily fire `:hover` on every ancestor in the chain, so\nif both a button AND its parent row have `data-tooltip`, BOTH\ntooltips show at once, overlapping. Always suppress ancestor\ntooltips when a descendant tooltip is active.\n\n**Rule:** at most ONE tooltip visible in the DOM at any moment, and\nit belongs to the innermost `[data-tooltip]` element under the\ncursor.\n\nImplementation: global `mouseover` listener finds the innermost\ntrigger via `closest('[data-tooltip]')`, walks up its ancestors,\nand puts `data-tooltip-suppress=\"\"` on each ancestor that also\ncarries `data-tooltip`. Clear on `mouseout` to a non-tooltip\ntarget.\n\nJS + CSS implementation and the real failure case (nested chip row +\ngroup header showing two tooltips at once):\nsee [ui-implementation-reference.md](ui-implementation-reference.md) § 1f.\n\n### 1e. Never use ALL CAPS for tooltip or label content\n\n`TEXT IN ALL CAPS READS AS SHOUTING` to a human user. Write tooltip\ncontent in sentence case (\"Reset camera to the default isometric\nview\"), not in all-caps (\"RESET CAMERA\"). Same for in-app labels,\nsection titles, button text, status pills — keep it sentence or\ntitle case.\n\nWatch for an INHERITANCE BUG: if the parent has `text-transform: uppercase`,\nthe `::after` pseudo inherits it and renders every tooltip in all caps. Reset\n`text-transform: none` and `letter-spacing: 0` on the tooltip element — snippet\nin [ui-implementation-reference.md](ui-implementation-reference.md) § 1e.\n\nIf a visual heading emphasis is needed, use **weight** (600-700) and\n**letter-spacing** (0.02em), not caps.\n\n---\n\n## 2. Click previews — show what would happen BEFORE the click\n\n**Rule:** any click that commits an irreversible-feeling action\n(place a marker, select an edge, add a component, drop a point) MUST\nshow a live preview of what's about to happen as the user moves\ntheir mouse. A \"will this click do what I want?\" moment where the\nuser has to click-and-undo is a failure.\n\nConcrete cases:\n\n| Action | Preview |\n|---|---|\n| Click to place a measure point | Translucent sphere at the nearest snap vertex follows the cursor |\n| Click to select an edge | Bright colored tube along the nearest edge of the hit mesh |\n| Click to select a body | Outline / highlight-layer stroke around the hovered mesh |\n| Click to place a component in a schematic | Ghost component at cursor position, proper orientation |\n| Click to add a vertex to a polygon | Ghost vertex + ghost polygon edge snapping to the last vertex |\n| Click to drop a file onto a target | Target area dims or outlines on dragover |\n\n**Implementation note:** the preview mesh/element MUST be\n`isPickable = false` (or equivalent) so subsequent hover-picks don't\npick the preview itself instead of the underlying geometry. Real\nbug from adom-tsci's measure tool: the first preview sphere became\nthe pick target for the next hover, stalling the tool.\n\nWhen the user's intent changes (filter switches, tool closes,\nselection limit reached), dispose the preview immediately. Don't let\nstale ghost markers linger.\n\n---\n\n## 2b. Toggle buttons\n\nA button bound to a binary state (HUD open/closed, gizmo active/inactive,\nlayer visible/hidden, recording on/off, sharing on/off, mute on/off,\netc.) MUST visually reflect that state. A flat \"push-button\" styling\nthat looks identical for both ON and OFF is a UX lie: the user has no\nway to tell whether the action they want is already done.\n\n**Required:**\n\n1. Add `aria-pressed=\"true|false\"` to the button (a11y + state probe\n   for tests).\n2. Add an `.is-on` class (or equivalent) when the bound state is on.\n3. CSS for `.is-on` (and/or `[aria-pressed=\"true\"]`) MUST visibly\n   differ from the resting state — different background, brighter\n   border, inset shadow, accent-color text. Pick at least two of\n   those, not just one. A 5% background-tint shift is too subtle.\n4. Two-way: clicking the button flips the state AND any other path\n   that flips the state (e.g. the HUD's own X-button, a keyboard\n   shortcut, a programmatic `setX(false)`) MUST update the toggle\n   class on the button. Otherwise the button drifts out of sync.\n5. The tooltip should phrase the action conditionally: \"Show sources\n   HUD\" when off, \"Hide sources HUD\" when on — same way real\n   toggle UIs do. (Optional but high-polish.)\n\n**Anti-pattern that ships every time:**\n\n```html\n<!-- both states render identically — looks broken to users -->\n<button onclick=\"toggleHud('hud-foo')\">foo</button>\n```\n\n**Right way:**\n\n```html\n<button id=\"btn-foo\" aria-pressed=\"false\"\n        onclick=\"toggleHudButton('hud-foo', 'btn-foo')\">foo</button>\n```\n\n```js\nfunction toggleHudButton(hudId, btnId) {\n  var h = document.getElementById(hudId);\n  var b = document.getElementById(btnId);\n  var willShow = (getComputedStyle(h).display === 'none');\n  h.style.display = willShow ? '' : 'none';\n  b.classList.toggle('is-on', willShow);\n  b.setAttribute('aria-pressed', willShow ? 'true' : 'false');\n}\n// Two-way sync: when the HUD's close-X is clicked, ALSO clear the\n// button state.\nhudCloseBtn.addEventListener('click', function () {\n  hud.style.display = 'none';\n  document.getElementById('btn-foo').classList.remove('is-on');\n  document.getElementById('btn-foo').setAttribute('aria-pressed', 'false');\n});\n```\n\n```css\nbutton.is-on,\nbutton[aria-pressed=\"true\"] {\n  background: rgba(0, 184, 177, 0.22);    /* brand teal — visible */\n  color: #00e6dc;\n  border-color: rgba(0, 184, 177, 0.60);\n  box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.35);\n}\n```\n\n**User feedback that drove this section (2026-04-28):** *\"if something\nis a toggle button, you better treat it as a toggle button. add that\nto skill too and audit yourself cuz the provenance is a toggling of a\nhud and you don't make the button act that way.\"*\n\n---\n\n## 3. HUDs and floating panels\n\n### 3a. Every floating HUD must be draggable\n\nA HUD that sits in a fixed spot steals screen real estate the user\ncan't reclaim. Every HUD that opens over the main canvas MUST be\ndraggable — the user grabs a visible handle (a grip strip, a header\nbar, the title) and moves the HUD anywhere. No exceptions.\n\nImplementation: use a shared `makeDraggable(element, handleEl)` helper.\nAccount for the offset-parent's viewport position — a naïve implementation\ngives a ~42px \"cursor jump\" when the HUD is inside a panel below a tab strip\n(`clientY` is viewport-relative but `style.top` is offset-parent-relative).\nFull `makeDraggable` implementation:\nsee [ui-implementation-reference.md](ui-implementation-reference.md) § 3a.\n\n### 3b. Every HUD must be collapsible\n\nUsers need to keep the HUD OPEN but get it out of the way during\nother work (orbiting the view, clicking the canvas). Provide a\nminimise (`−`) button in the header that collapses the HUD to just\nthe header strip, preserving its position and all state. Clicking\nthe minimise again expands.\n\n### 3bb. Double-clicking the drag handle toggles collapse\n\nEvery draggable HUD header should ALSO respond to `dblclick` by\ntoggling the same collapsed/expanded state that the `\\u2212` / `+` icon\ncontrols. Reason: the minimise icon is a tiny target, and users who\nalready know \"drag this bar to move\" naturally try double-clicking\nit to get it out of their way (Fusion 360, Blender, Figma, most IDE\ndockable panels behave this way). Don't make them aim.\n\nWire it on the handle element, not the whole HUD, so double-clicking\ninside the scrolling body doesn't trigger. Ignore `dblclick` on child\nicons that have their own action (`−`, `×`, dropdown carets). Call\n`e.preventDefault()` to avoid text selection. Full snippet:\nsee [ui-implementation-reference.md](ui-implementation-reference.md) § 3bb.\n\n### 3c. Every HUD must be dismissible, but re-openable from the main toolbar\n\nWhen the user hits the `×` to close a HUD, don't leave an orphan\n\"show me again\" floating button lying around the canvas. Instead,\nre-expose the HUD via a toggle button on the MAIN toolbar. That\nway, close = HUD disappears, toolbar remains; click the toolbar\nbutton to bring it back. Orphan hamburger buttons are clutter.\n\n### 3d. HUDs: auto-fit CONTENT to the container, but never force-clamp the USER's drag\n\nThere are two different concerns inside the same element, and they\nhave OPPOSITE correct behaviours:\n\n**(a) Auto-fit the HUD's own content growth** — yes, constrain. If\nthe user picks two chips and a measurement HUD would grow to 1050px\ntall in a 900px pane, that HUD must cap its own height and give the\ngrowable middle section its own scrollbar. This is the HUD taking\nresponsibility for its intrinsic size.\n\n**(b) The user's drag position** — NO, leave it alone. If the user\nchooses to park a HUD with its right half off the canvas because\nthey want max board visibility, that's a valid choice. \"Snapping\nthe HUD back on-screen because half of it overlapped the edge\" is\npaternalistic and infuriating — the user deliberately moved it\nthere. A later resize should NOT re-snap it either.\n\nRules, non-negotiable:\n\n1. **Cap max dimensions to the container, not the viewport.** Use\n   `max-height: calc(100% - <margin>px)` on the HUD against the\n   positioned parent (e.g. the 3D viewer wrap), NOT `calc(100vh -\n   …)`. The parent itself may be smaller than the viewport (tabs\n   bar, toolbar, IDE chrome).\n2. **Internal scrolling for the variable-height section.** A HUD\n   typically has fixed top chrome (header, filter buttons) and\n   fixed bottom chrome (footer, action buttons). The MIDDLE — the\n   selections list, the rows of measurements, the list of items —\n   must be the flex child that gets `overflow-y: auto`,\n   `min-height: 0`, and `flex: 1 1 auto`. Header + footer stay\n   pinned; middle scrolls. Never let the whole HUD scroll — users\n   lose the action buttons.\n3. **Do NOT clamp-to-container on drag.** The drag handler\n   faithfully follows the cursor — if the user drags the HUD\n   partially off the container, respect it. The user may be\n   deliberately parking it to get a wider look at the canvas.\n4. **Do NOT re-snap on window resize.** Once the user has placed a\n   HUD, their position sticks. If the container shrinks and the\n   HUD is now mostly off-screen, that's still the user's last\n   intent — they can drag it back in three seconds. Snapping \"for\n   their own good\" punishes the deliberate user.\n5. **Reconsider content density when max-height is regularly hit.**\n   If a HUD regularly hits its cap even with scrolling, the fix is\n   often less chrome, not more scroll — collapse attribute groups\n   by default, truncate labels with `…` on hover, or drop\n   decorative rows when a selection doesn't need them.\n6. **Minimum width** still applies: the HUD should have a\n   `min-width` so its own labels don't squeeze into two-letter\n   columns as the container shrinks. If the container narrows\n   below `min-width`, the HUD overflows horizontally — that's\n   fine, the user can drag it.\n\nReal failure cases that motivate this split:\n\n- **Overflow from content growth (must cap):** measure HUD on\n  adom-tsci grew to 1050px tall after a user picked two chips. IDE\n  pane was 900px. Selection 2's X/Y/Z rows were off-screen,\n  un-scrollable. Fix: `max-height: calc(100% - 80px)` on the HUD,\n  `overflow-y: auto` on `.mb-selections`.\n- **Over-constrained drag (must NOT clamp):** after fixing the\n  content cap, a first-pass added a clamp-to-container on drag and\n  on resize. User feedback: \"huds should be allowed to drag off\n  the edge of the webview. you are constraining them too much\n  now.\" Fix: drop the drag clamp and the resize re-clamp; only the\n  HUD's own size-fit is responsive.\n\n### 3f. Guided walkthroughs: interruptible, pausable, AI-drivable\n\nFor any board / scene / document tour (a \"🎬 Walk me through this\"\nfeature), the user must feel in control at every moment. Four\nnon-negotiable rules:\n\n1. **Interruptible camera animations.** The camera fly-to-next-step\n   must yield the moment the user orbits/pans/zooms. Hook\n   `pointerdown` on the canvas → stop the running Babylon/Three\n   animation and auto-pause the tour's step timer. Never force the\n   user to wait for an animation to complete.\n\n2. **Explicit pause state with visible indicator.** Pause has a\n   button AND a keyboard shortcut (Space). When paused, show a badge\n   (\"⏸ Paused\") on the narration HUD and recolour the progress bar\n   so the user can see the tour stopped. Resume re-flies the camera\n   (since the user moved the view) and resumes the step timer with\n   the REMAINING duration, not from scratch.\n\n3. **Reading-speed-aware auto-advance.** Step duration =\n   `max(minMs, text.length * 45ms + 2500ms + sentenceCount * 300ms)`.\n   45 ms / char ≈ 220 wpm (comfortable) plus a 2.5 s floor so slow\n   readers don't get rushed off the step before the camera lands.\n   Don't use fixed \"5 seconds per step.\"\n\n4. **AI-drivability as a first-class concern.** Every walkthrough\n   action (start / pause / resume / next / prev / close) must also\n   be HTTP + CLI reachable so the tour can be ralph-loop tested. A\n   `GET /api/walkthrough` status endpoint reports\n   `{active, step, total, paused, currentStepId, title}` so the\n   ralph script knows which step it's on for shotlog captions.\n   Without AI drivability you can't regression-test the tour when\n   the underlying board changes.\n\nScript format (WALKTHROUGH data array, `focus` kinds, `highlight`, `minMs`),\nordering rules (biggest/most-important → smallest/background for PCBs), HUD\nlayout details, and real failure history:\nsee [ui-implementation-reference.md](ui-implementation-reference.md) § 3f.\n\n### 3e. Group long lists with collapsible master toggles\n\nWhen a panel contains many toggleable items that naturally cluster\n(by kind, layer, net, priority), expose group headers with:\n\n- A caret (`▾`/`▸`) that collapses/expands just that group\n- A count (`<visible>/<total>`) showing group state at a glance\n- A master toggle icon (`●` all visible / `○` all hidden /\n  `◐` mixed) that flips every item in the group at once\n\nKeep per-item toggles inside each group — never replace them. The\n\"hide every testpoint so I can see traces\" operation is miserable\none-at-a-time; the \"hide THIS specific chip\" operation needs the\nper-item row.\n\nDefault state: everything COLLAPSED. First-time users see a compact\noverview of groups and expand only what they care about.\n\n---\n\n## 4. Multi-unit displays\n\nWhen a value has a cross-cultural unit convention, provide a second\nunit readout in parentheses. For PCB work: millimetres primary,\ninches or mils secondary. For audio: dB primary, numeric ratio\nsecondary. For temperature: °C primary, °F secondary.\n\nExpose this as a \"Secondary Units\" dropdown (None / Inches / mils /\netc.) rather than hardcoding a second unit, so users who don't need\nit aren't distracted by it.\n\nChange precision via a dropdown too — don't hardcode three decimals\nwhen some users want to see 0.1234 mm and others just 0.1 mm.\n\n---\n\n## 4b. Icons — always monochrome, always custom-drawn\n\n**Full rule lives in `gallia/skills/brand/SKILL.md` → Icons.** Summary\nof the parts UI designers forget most:\n\n- **Never use Unicode emoji** (`📏 🔍 🎬 ⎚ 🔧 …`) as UI icons. Emoji\n  render as multi-color by design (Twemoji / Apple Color / Noto) and\n  mix miserably next to any hand-drawn SVG. This applies even to\n  \"quick prototype\" toolbars — emoji end up shipping.\n- **Monochrome only.** `#e6edf3` on dark backgrounds or `currentColor`\n  so it inherits the surrounding text color. No gradients, no\n  shadows, no brand-palette accents on icons.\n- **Custom-drawn is the default, not the fallback.** Every icon\n  should be AI-drawn SVG that depicts the specific concept —\n  calipers for Measure, a magnifier with a data dot for Inspect, a\n  clapperboard for Walkthrough, a board-with-pins silhouette for\n  the Components panel. Hand-drawing per-feature is how the toolbar\n  actually teaches users what each button does.\n- **MDI is the fallback**, only for fully-generic concepts (chip /\n  eye / cog) where nothing you'd draw is more specific. Still apply\n  the monochrome rule.\n- `viewBox=\"0 0 24 24\"` everywhere for consistent sizing; pick one\n  style (stroked vs filled, 1.5–2 px stroke) per surface and stick\n  with it.\n\nIf the toolbar/HUD/tooltip you're writing contains a single emoji\ncharacter pretending to be an icon, you still have work to do.\n\n---\n\n## 5. Match existing well-loved tools\n\nIf the user's workflow involves a tool they already know well (Fusion\n360, Photoshop, KiCad, Figma, …), **copy their UX for comparable\nfeatures before inventing your own**. Users have muscle memory for\nthese tools; reinventing the button layout or mode picker adds\nfriction for zero benefit.\n\nFor measurements specifically, Fusion 360 is the reference:\n- Vertical label / control grid layout (Selection Filter, Precision,\n  Secondary Units, Clear Selection, Show Snap Points, Close)\n- Selection filter with three icons (Point, Edge, Body)\n- `1` / `2` tags placed over the 3D view at each selection point\n- Floating distance label at the midpoint between two selections\n- Persistent highlighted edges/bodies after selection so the user\n  can see what they picked\n\nIf you're about to invent something different, first ask yourself\nwhether the user will have to re-learn muscle memory they already\nhave.\n\n---\n\n## 5b. File and folder paths displayed in UI are CLICKABLE — reveal in VS Code\n\nWhenever 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.\n\nThe 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.\n\n### How to wire it\n\nThe 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:\n\n```js\n// HTML side — turn the path into a button (NOT an anchor; nothing to navigate to).\n<button class=\"path-link\" data-tooltip=\"Reveal this file in the VS Code Explorer\">\n  /tmp/r0402.glb\n</button>\n\n// click handler — POSTs to the app's own backend\nasync function reveal(path) {\n  const r = await fetch(\"api/reveal\", { method: \"POST\",\n    body: JSON.stringify({ path }) });\n  const j = await r.json();\n  showToast(j.ok ? \"Revealed \" + path : \"Reveal failed: \" + j.error,\n            j.ok ? \"ok\" : \"error\");\n}\n```\n\n```rust\n// server side — the app shells to adom-vscode\n(Method::Post, \"/api/reveal\") => {\n    let out = std::process::Command::new(\"adom-vscode\")\n        .args([\"reveal\"]).arg(&path).output();\n    // ... return {ok, path} or {ok: false, error}\n}\n```\n\n### Required behaviour\n\n- **Tooltip** every path link with `data-tooltip=\"Reveal this <thing> in VS Code Explorer\"` (per §1a).\n- **Toast** on click with the result (`Revealed /tmp/r0402.glb` or `Reveal failed: <reason>`) per §6.\n- **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.\n- **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.\n- **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.\n\n### ⚠ The workspace-boundary footgun (caught the hard way 2026-04-28)\n\n**`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.\n\nThis is a footgun for \"I'll just download to /tmp and reveal\" workflows (URL-source quicklooks, conversion intermediates, etc.).\n\n**Server-side pre-check before invoking `adom-vscode reveal`:**\n\n```rust\n// On Adom containers the workspace root is $HOME/project/. Some users\n// add more roots via VS Code's \"Add Folder to Workspace\"; until\n// adom-vscode exposes a /workspace endpoint, $HOME/project is the\n// safe baseline.\nfn vscode_workspace_roots() -> Vec<PathBuf> {\n    let mut out = Vec::new();\n    if let Ok(home) = std::env::var(\"HOME\") {\n        let p = PathBuf::from(home).join(\"project\");\n        if p.exists() {\n            if let Ok(c) = std::fs::canonicalize(&p) { out.push(c); }\n        }\n    }\n    out\n}\nfn is_in_vscode_workspace(p: &Path) -> bool {\n    let roots = vscode_workspace_roots();\n    if roots.is_empty() { return true; } // no info → trust adom-vscode\n    roots.iter().any(|r| p.starts_with(r))\n}\n```\n\n**Response when outside:**\n\n```json\n{\n  \"ok\": false,\n  \"outside_workspace\": true,\n  \"path\": \"/tmp/r0402.glb\",\n  \"workspace_roots\": \"/home/adom/project\",\n  \"error\": \"File is outside the VS Code workspace (workspace: /home/adom/project). Move or copy it into the workspace to make it revealable.\"\n}\n```\n\n**JS branches the toast** so the user sees something useful instead of a fake success:\n\n```js\nif (j && j.outside_workspace) {\n  showToast(\n    \"Outside VS Code workspace: \" + j.path +\n    \". Move/copy into \" + j.workspace_roots + \" to reveal.\",\n    \"error\"\n  );\n}\n```\n\nIf 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.\n\n### When the local server is dead\n\nIf 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'\")`:\n\n```js\nconst text = await r.text();\nlet j = null;\ntry { j = JSON.parse(text); } catch (_) {}\nif (j && j.ok) { ... }\nelse if (j && j.error) { showToast(\"Reveal failed: \" + j.error, \"error\"); }\nelse if (!r.ok) { /* 5xx — proxy or server */ }\nelse {\n  showToast(\"Server unreachable — restart \" + APP_NAME, \"error\");\n}\n```\n\n### Why this matters\n\nUsers who see paths in your UI and *can't click them* go through:\n1. Select with mouse (often interrupting other selection state).\n2. Cmd/Ctrl-C.\n3. Switch to terminal.\n4. Type `code <paste>` or `ls <paste>`.\n5. Realize they wanted Explorer, not editor; click into the sidebar.\n6. Navigate to the file manually.\n\nSix 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.\n\n## 6. Feedback for every action\n\nEvery click, drag, toggle, or keyboard shortcut needs IMMEDIATE\nfeedback:\n\n- A toast at the bottom-centre for stateless actions (\"view → top\",\n  \"measure: on\")\n- A changed button `.active` class for toggles\n- A visible preview / selection marker / highlight for spatial\n  actions\n- A status pill in the header for long-running operations\n  (\"Building… 12s\", \"GLB: 10:28:39 AM\")\n\nSilent success is indistinguishable from failure. If a user clicks\nand nothing visually changes, they assume the click didn't register\nand click again, firing the action twice. Every \"I clicked but\nnothing happened\" bug is a feedback-latency bug.\n\n---\n\n## 7. AI-drivability is a feature, not an afterthought\n\nEvery user action in the UI SHOULD have a corresponding CLI or HTTP\nendpoint so Claude (or the user's own scripts) can drive the app\nwithout clicking. See the `app-creator` skill's §7 for the full\nHTTP pattern. Practical effect: a measure HUD's `Close` button,\na toolbar's `Wireframe` toggle, a component panel's per-row\nvisibility — every one of these should be reachable via\n`adom-<app> <subcommand>`.\n\nWhen you extend a HUD with a new control, add the matching CLI\nsubcommand in the same change. Otherwise the AI-driven ralph loop\ncan't verify your addition works.\n\n---\n\n## Checklist — review every UI change against these\n\n- [ ] Every new button/label/control has a `data-tooltip`?\n- [ ] Every tooltip is multi-line and written for a newbie?\n- [ ] Tooltips have `z-index: 99999` with `text-transform: none`?\n- [ ] No tooltip or label text is written in ALL CAPS (shouting)?\n- [ ] Tooltips auto-flip when near viewport bottom/right?\n- [ ] Every irreversible-feeling click has a live preview on hover?\n- [ ] Preview meshes/elements are `isPickable = false` so they\n      don't interfere with subsequent picks?\n- [ ] Every floating HUD is draggable via a visible grip?\n- [ ] The drag handler accounts for offset-parent viewport position?\n- [ ] Every HUD has `max-height: calc(100% - <margin>)` against its\n      positioned container so growth cannot overflow?\n- [ ] The variable-height section uses internal `overflow-y: auto`\n      while header + footer stay pinned?\n- [ ] The drag handler does NOT clamp to container edges\n      (parking-off-screen is a valid user choice)?\n- [ ] Resize does NOT re-snap the HUD (user's drag position is\n      sacrosanct)?\n- [ ] Every HUD has a minimise button that collapses to header?\n- [ ] Double-clicking the drag handle ALSO toggles collapse (don't\n      force users to aim at the tiny minimise icon)?\n- [ ] Every HUD has a close button + a toolbar button to re-open?\n- [ ] Long toggle lists are grouped with master-toggle headers?\n- [ ] Default state for groups = collapsed, user expands to drill in?\n- [ ] Every action has immediate visible feedback (toast, class, preview)?\n- [ ] Every UI action has a matching CLI / HTTP endpoint?\n- [ ] Does this match the UX of a well-loved tool the user already\n      knows (Fusion / KiCad / Figma / …)?\n\nIf any of these are unchecked, the UI isn't done yet.\n\n## Provenance captions on every shown artifact\n\nWhenever 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*.\n\nCaption 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.\n\nFull conventions (markdown, interactive UI, iframe):\nsee [ui-implementation-reference.md](ui-implementation-reference.md) § Provenance captions.\n\n## Hero images: one-glance identity\n\nAny 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.\n\nKey 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.\n\nFull rules (what to pick per object type, where to place it, delivery convention):\nsee [ui-implementation-reference.md](ui-implementation-reference.md) § Hero images.\n",
  "author": {
    "name": "Kyle Bergstedt",
    "email": "kyle@adom.inc"
  },
  "visibility": {
    "public": true
  },
  "hero": null,
  "discovery_triggers": [],
  "discovery_pitch": null,
  "metadata": {},
  "created_at": "2026-05-28T05:29:42.417Z",
  "updated_at": "2026-05-28T05:29:42.417Z",
  "sub_skills": [],
  "parent_app": null,
  "org": "adom"
}