Colophon defines the colors and the type. Built for Agents defines the API surface. This chapter is the missing layer — the widget conventions that turn brand and engine into one product. Two tools side-by-side should feel like the same product, not two tools we both happen to host.
Why this chapter exists: in the May overnight builds, ten parallel Bob agents shipped ten tools — and each Bob made local UX choices. Five different mode-switch patterns, four different Copy-button styles, three different error treatments. The brand-palette test caught color violations; nothing catches UX-shape violations. This chapter is what catches them. Bob reads it. Reviewers cite it. Drift lands here as a fix, not a debate.
1. The shape of a widget
Every Microapp widget follows the same vertical flow. Don't invent a new layout per tool.
- Input section — the fields the user fills. One column on mobile; up to two columns on desktop only when the fields are clearly paired (e.g. width + height).
- Primary action — one button, or auto-compute on input change (preferred for fast operations). Never both.
- Output section — the answer. The main result is visually dominant; secondary stats sit underneath.
- Secondary actions — Copy, Reset, Clear, Download. Below the output, smaller weight than the primary action.
- Context — formula line, worked example, FAQ. After the output, optional, never above it.
Tier 1 vs Tier 2 — when to pick which
Tier 1 is an engine config rendered by <ToolEngine> (see Tool Engines). Tier 2 is a custom React widget at src/components/tools/<Name>.tsx. The split decides itself:
Default to Tier 1 when the math is one line. Going Tier 2 means inheriting all the conventions in the rest of this chapter — the engine config gets them for free via <ToolEngine>.
2. Inputs
- Every input has a real
<label htmlFor>. Never a styled<div>pretending to be a label. Screen readers need the association; Ben's a11y audit blocks PRs that skip it. - Mobile tap targets ≥44pt. That's
min-height: 2.75remat the default 16px root. Inputs, buttons, chips — anything tappable. - Numeric inputs use
type="number"withinputMode="decimal"when fractions are expected. Mobile numeric keyboards depend on it. - Pre-fill an example when the input shape is non-obvious. Median calculator lands with 3 sample numbers so the textarea isn't a blank wall. Word counter lands empty (the input shape is self-evident). Picking right is judgment; the test is "does an unfamiliar user know what to type?"
- Units are dropdowns, never modes. "Convert pixels at DPI 96" is one input shape with a unit picker; "DC vs AC three-phase" is a mode change because the inputs are different. Use the mode-switching rules below for the latter.
3. Mode switching — one pattern, not five
Five tools shipped in one week with five different ways to switch modes. That stops here.
hashRoute4. Outputs
Numeric precision — one rule
- Default: 4 significant figures. Not 6, not 8, not "as many as JavaScript gives us." Four is enough precision for engineering, finance, and stats homework; more is just visual noise.
- Money: 2 decimal places. Always.
$1,234.56, never$1234.5601. UsefmtCurrency(n)from the shared util. - Percentages: 2 decimals or 4 sig figs, whichever is fewer.
16.67%, not16.66666...%. - Per-tool overrides need justification. If your spec calls for 6 decimals on something, write the reason in the spec. Kai locks it; Ben checks it.
Layout
- The main result is the largest text in the widget. Bigger than the H1, bigger than the inputs. The user came for this number — show it.
- Secondary stats sit under the main result in a label-value table or grid. Same row → same precision and same unit.
- The formula line is optional — include it when the result invites "wait, how did you get that?". Set it small and monospace, under the result. Educational tools (chemistry, stats) should include it; one-shot converters (hex-to-decimal) shouldn't.
- Loss/edge states surface honestly. If profit is negative, show
-$10.00in coral — don't hide it. If divide-by-zero, show—— neverNaN, neverInfinity.
Copy buttons
Every result that someone might copy into another tool needs a Copy button. Standardized.
- Position: directly to the right of the result, vertically centered. Not at the top of the widget. Not in a kebab menu.
- Label: just "Copy" — short. No icon-only buttons (accessibility); icon-plus-label is fine.
- Feedback: sonner toast. Use the
toast.success("Copied")fromsonner— already a dep. No alerts, no inline "Copied!" text-swap (it shifts layout). - Multiple results = multiple Copy buttons. One per result. Don't make the user click "Copy" and then choose which thing.
5. Errors and empty states
- Voice: AmEx, never airline. §6 voice rule 10 again. "That's frustrating. Try this." Not "Error: invalid input." Not "Please be advised that..."
- Visual:
<ToolError>primitive. Coral background,role="alert", optional icon. Never a generic browser alert. Never a thrown exception bubbling up. - Position: under the input that caused it (when scoped to one field) or in the output area (when the whole calculation can't run). Never at the top of the widget — that's where the title is, not where errors live.
- Empty states get a sentence, not silence. "Paste some numbers above — comma, space, or newline separated — to see the median." Tells the user what shape of input you want.
6. Action buttons
- Primary = one button per widget, green (
--color-brand-green). The main action: "Calculate", "Generate", "Convert", "Compress". Never multiple primaries — if the widget has two primary actions, it's two widgets. - Secondary = cream/border for "Reset", "Clear", "Use example", "Add row". Visual weight is lower than primary.
- Destructive = coral with a modal. "Reset to zero", "Clear history". Long-press is banned — it's invisible to first-timers and not keyboard-equivalent. A modal with explicit confirm + cancel is the only acceptable pattern.
- Position: under the input section, right-aligned on desktop, full-width on mobile. Don't put the primary action above the inputs (the visual flow is input-then-act, not act-then-input).
7. Persistence
Some tools are their persistence — a tally counter loses its point if reload zeros it. Others are one-shot — a percentage calculator doesn't need to remember last week's input. The default is no persistence; persistence is opt-in.
- Persist when the tool is stateful by nature — counters, preference toggles, multi-row editors where the rows themselves are the work. Don't persist for one-shot calculators.
- Use
localStorage, never IndexedDB or cookies. Keep it cheap, keep it readable, keep it client-only. - Pattern: hydration guard + ref + useEffect. Don't read
localStorageduring render — Astro SSR will mismatch. SeeClickerCounter.tsxfor the reference implementation. - localStorage disabled = service-voice notice. Some browsers (private mode, certain device managers) block it. "Your browser isn't saving the count. It'll reset when you close the tab." Let the tool keep working in-memory.
8. The 30-second test
From the Simplicity Pledge: a first-time visitor must be able to use the tool in under 30 seconds, with zero documentation. That rule has UX consequences worth spelling out:
- No onboarding modals. If the widget needs to teach the user how to use it, the input is wrong. Redesign the input.
- No "Get started" buttons. The page IS the tool. The user gets started by typing.
- No tutorials, walkthroughs, or coachmarks. If you reach for these, your widget needs simplifying — not annotation.
- No welcome screens, splash screens, or interstitials. The result is the welcome.
- No "Sign in to continue" before the first calculation. Members get clean pages; non-members get ads. Both get the result. Covenant Item 02.
The test for whether you've passed: open the tool in a fresh incognito tab, set a 30-second timer, see if you can get an answer. If yes, ship. If no, simplify.
9. The shared primitives
Bob imports these by name. New tools use them. Existing tools get retrofitted when touched. This is the bridge from the constitution to the implementation.
role="alert". Renders only when message is non-empty. Use for input validation errors and divide-by-zero states.
import { ToolError } from "@/components/ui/ToolError" value prop (string) and an optional label for screen readers. Standardized everywhere a result can be copied.
import { CopyButton } from "@/components/ui/CopyButton" hashRoute persists state in the URL. Use for ≥3 modes; for ≤3 same-input modes, use <SegmentedControl> instead.
import { ModeTabs } from "@/components/ui/ModeTabs" import { SegmentedControl } from "@/components/ui/SegmentedControl" import { ResultCard } from "@/components/ui/ResultCard" value, format ("sigfig" | "money" | "percent" | "integer"), and optional unit. Handles the precision rules in §4 so every Bob doesn't reinvent them.
import { NumberOutput } from "@/components/ui/NumberOutput" import { PrimaryButton } from "@/components/ui/Button" import { SecondaryButton } from "@/components/ui/Button" import { DestructiveButton, ConfirmModal } from "@/components/ui/Button" src/components/ui/ with a documented signature, (2) add it to this chapter, (3) only then use it in your tool. The chapter is the contract.
10. Drift — what currently violates this chapter
Honest section. The conventions above were written after ten tools had already shipped, each with its own UX. The drift is documented here so this chapter doesn't read as aspirational. Each row is queued for retrofit; the order is opportunistic (when we touch the widget for another reason, we update it).
| Tool | What drifts | Fix on next touch |
|---|---|---|
ClickerCounter | Gear-icon settings drawer; mode-switch via gear (not tabs) | Move sound toggle to a labeled <Toggle>; multi-counter switch via <ModeTabs> |
OvertimeCalculator | Jurisdiction picker is a dropdown (should be <ModeTabs> — input shape changes) | Swap dropdown for tabs; "Federal" / "California" become top-level mode labels |
WattsToAmps | Segmented mode is fine; PF chip styling is bespoke | Move PF chips to a shared <ChipGroup> primitive once we extract one |
InvisibleText | Copy button is full-width hero (not the §4 standard placement) | Move Copy button to right-of-result; the hero "Copy cursed text" pattern was inherited from a single agent's choice |
MedianCalculator | "Copy summary" with sonner toast is correct; secondary stats use ad-hoc number formatting | Route stats through <NumberOutput format="sigfig"> |
ImageCropper | Inline errorMsg state; doesn't use <ToolError> | Swap to the primitive once extracted |
| Tier 1 engine tools (40+) | Render via <ToolEngine>; that component pre-dates this chapter | Update ToolEngine internals to use the same primitives — that one change retrofits all 40 in one PR |
When this table gets to zero rows, the site is consistent. Until then, the chapter is the goal and the table is the gap.
For Bob and Ben
Bob: read this chapter before generating any widget. The checklist.md in your skill folder links here as required reading. Import primitives by name; don't reinvent.
Ben: add a "Tool UX check" pass to your audit. For each violation, cite the section number ("§3 mode-switching", "§4 numeric precision"). Block the merge until the violation is either fixed or this chapter is updated to permit it.