Agent feeds · raw
guidelines.agent.json raw feed
Machine feed: every pattern with its composedOf reuse graph, rules (each ref’d to a skill assertion), and whether it ships code.
◆ generated — the exact file served to agents at /guidelines.agent.json · do not edit by hand
guidelines.agent.json
[
{
"generatedFrom": "guidelines/app-shell.json",
"kind": "guideline",
"pattern": "App shell",
"slug": "app-shell",
"summary": "How an AhaSlides product SCREEN is assembled from DS components — the left nav rail (real brand mark + nav items + one primary CTA), the top bar (context left, actions/avatar right), the content region, and where relevant a canvas/stage — spaced by --aha-* tokens, with a real brand identity and no dead placeholders. Closes the composition gap that turns a component-correct screen into generic-admin-template slop.",
"skillRef": {
"build": "ahaslides-design:app-shell",
"judge": "ahaslides-design:app-shell",
"note": "Authored in the DS — there is no aha-design plugin skill for the app shell. The DS OWNS this surface's criteria in anti-slop/criteria.json (surfaces.app-shell, C1..C5). Backfilling a matching aha-design build+judge skill pair is a follow-up, not a dependency."
},
"surfaces": [
"dashboard",
"editor",
"presenter"
],
"surfaceChoice": null,
"composedOf": [
{
"ref": "button",
"as": "component",
"use": "the primary CTA in the nav rail (e.g. New presentation), top-bar actions, and per-row actions — one primary per region, sized via the size prop, never inline height/padding/radius",
"status": "available"
},
{
"ref": "icon",
"as": "component",
"use": "the brand mark glyph, every nav-item glyph, and top-bar action glyphs — an <aha-icon name> from the library, never an inline <svg> or a foreign icon set",
"status": "available"
},
{
"ref": "avatar",
"as": "component",
"use": "the top-bar account avatar and any per-row user avatar",
"status": "available"
},
{
"ref": "badge",
"as": "component",
"use": "the 'New' marker on a nav item or menu row",
"status": "available"
},
{
"ref": "divider",
"as": "component",
"use": "top-bar and rail section separators",
"status": "available"
},
{
"ref": "--aha-motion-mid",
"as": "token",
"use": "the active/selected nav-item transition duration — toggled on a persistent node, never a bare literal",
"status": "available"
},
{
"ref": "--aha-borderRadius",
"as": "token",
"use": "the radius on rail/top-bar/content surfaces — from the 4/6/8/12/16 scale",
"status": "available"
}
],
"componentBacklog": "No missing components — the shell composes entirely from shipped DS leaves plus tokens. The gap this pattern closes is COMPOSITION knowledge, not a missing primitive.",
"rules": [
{
"rule": "Use the real AhaSlides brand mark + wordmark at the top of the nav rail — the shipped logo / an <aha-icon> brand glyph — never a coloured letter-tile or initials square standing in for a logo.",
"ref": [
"C1"
]
},
{
"rule": "Never ship a placeholder as a final state: a QR/thumbnail/region renders a real asset or an explicit, styled empty state — never a permanent 'Loading QR…' label, spinner, or grey box.",
"ref": [
"C2"
]
},
{
"rule": "Give each region one primary action and a scannable type hierarchy from the scale; do not render uniform grey-on-white equal-weight cards with evenly-spaced everything (the admin-template tell).",
"ref": [
"C3"
]
},
{
"rule": "Fill every shell slot with a DS component (aha-button, aha-icon, aha-avatar, aha-badge, DataTable) and take all spacing/radius/colour from --aha-* tokens — never a hand-rolled <button>/<div> with inline hex/px, never a raw <table>.",
"ref": [
"C4"
]
},
{
"rule": "Animate the active/selected nav state by toggling an attribute/class on a persistent node via the motion tokens (--aha-motion-* + --aha-ease-*); never snap, never a bare duration literal, never rebuild the subtree on selection.",
"ref": [
"C5"
]
}
],
"shipsCode": false,
"reuse": null
},
{
"generatedFrom": "guidelines/audience.json",
"kind": "guideline",
"pattern": "Audience iframe",
"slug": "audience",
"summary": "How to build the audience-facing iframe of a slide-type plugin — mobile-first single-column layout, theme-from-xprops, self-rendered submit/waiting/correct-incorrect states, the standard submit-button spec, and host-utility integration — composed from existing components.",
"skillRef": {
"build": "aha-design:aha-design-audience",
"judge": "aha-design:aha-design-audience-judge",
"note": "The skill is the source of truth for rationale, worked BAD/GOOD examples, the AHAM-xxx bug references, and the full C1..C22 judge criteria plus the pre-ship checklist. This pattern distils the enforceable construction subset and links each rule back by ref (the judge's C-criteria, or a section label where the skill states a rule the judge doesn't separately enumerate)."
},
"surfaces": [
"audience"
],
"surfaceChoice": [
{
"surface": "Framed (default)",
"useFor": "most slide types — host paints the question image, title, description, audio button and countdown above your iframe; your iframe owns the answer area only",
"example": "poll options, quiz answers, scale slider, word-cloud input"
},
{
"surface": "Full-canvas (enableFullScreen: true)",
"useFor": "a slide needing a bespoke header the host chrome can't give — you render the whole surface including the title",
"example": "intro slide, branded reveal, fullscreen quote"
}
],
"composedOf": [
{
"ref": "button",
"as": "component",
"use": "the standard submit button spec — full-width, ~44–48px tall, deck-palette fill, readable ink, disabled/submitting/locked/post-submit states (you rebuild it in-iframe; the real aha-antd-button primary-alt does not cross the boundary)",
"status": "available"
},
{
"ref": "checkbox",
"as": "component",
"use": "multi-select option rows — the checked accent takes the deck accent (presentationColorPalette[0]), never Ant's fixed purple (you rebuild it in-iframe; the host AhaCheckbox does not cross the boundary)",
"status": "available"
},
{
"ref": "icon",
"as": "component",
"use": "the non-colour state cue paired with every colour signal — ✓ on submit, ✗ incorrect, lock icon on closed, leader trophy (glyph choice / size / stroke per aha-design-icons)",
"status": "available"
},
{
"ref": "--aha-colorSuccess",
"as": "token",
"use": "the correct / submitted / success function colour — bundled into the plugin build, never derived from the deck palette or a lookalike accent",
"status": "missing"
},
{
"ref": "--aha-colorError",
"as": "token",
"use": "the incorrect / validation-error / destructive function colour — bundled into the plugin build",
"status": "missing"
},
{
"ref": "--aha-colorWarning",
"as": "token",
"use": "caution / are-you-sure prompt body — bundled into the plugin build",
"status": "missing"
},
{
"ref": "--aha-colorInfo",
"as": "token",
"use": "informational note — bundled into the plugin build",
"status": "missing"
},
{
"ref": "radio",
"as": "component",
"use": "single-select option rows — the checked disc takes the deck accent, the selected-row ring takes textColour",
"status": "missing"
},
{
"ref": "slider",
"as": "component",
"use": "scale / bid input — rail flush with min/max labels, filled portion carries the deck-ink hairline the rail has",
"status": "missing"
},
{
"ref": "textarea",
"as": "component",
"use": "open-ended / idea text input — font-size 16px to dodge the iOS auto-zoom-on-focus",
"status": "missing"
},
{
"ref": "toast",
"as": "component",
"use": "the 'Submitted' announcement via the host's showToastSuccess — never an in-iframe banner (host paints it outside the iframe)",
"status": "missing"
},
{
"ref": "modal",
"as": "component",
"use": "any modal interaction via the host's openPluginModal bottom-sheet — host paints the chrome, you fill the body",
"status": "missing"
},
{
"ref": "skeleton",
"as": "component",
"use": "the neutral cold-start placeholder shown behind a themeReady guard while window.xprops populates",
"status": "missing"
},
{
"ref": "instruction",
"as": "component",
"use": "the ONE shared micro-instruction component reused across every slide so the hint never drifts in size/weight/colour",
"status": "missing"
}
],
"componentBacklog": "composedOf marks 10 items this pattern leans on that the DS does not yet ship — the four semantic-state tokens (colorSuccess/Error/Warning/Info, which plugins bundle from the design-system package rather than read as --aha-* vars because no host CSS var crosses the iframe), plus radio, slider, textarea, toast, modal, skeleton and a shared instruction component. Per the charter, a pattern leaning on a missing component is a signal to add it HERE — this is the pattern surfacing the component roadmap.",
"rules": [
{
"id": "separate-entry",
"rule": "Build the audienceUrl entry only — its own entry file, loaded at its own route, a separate document from the canvasUrl and editorUrl. Never branch one shared component with `if (audience) mobile else canvas`.",
"ref": [
"C1",
"§intro"
]
},
{
"id": "single-column-mobile",
"rule": "Lay out one vertical mobile-first column — question on top, answer area beneath, feedback after. No side-by-side panels. No horizontal scroll at any width; fit the narrowest phone (~320px).",
"ref": [
"C4",
"§1"
]
},
{
"id": "fluid-width",
"rule": "Be fluid up to the host's max-width cap (~710–840px framed); don't set or fight the width.",
"ref": [
"§1"
]
},
{
"id": "height-by-content",
"rule": "Report content height via onHeightChange(h) on every size change so the host shrink-wraps the iframe. Use min-height for affordance and let content drive total height — no fixed pixel heights that assume a device.",
"ref": [
"C5",
"§1"
]
},
{
"id": "cold-start-skeleton",
"rule": "Guard colour-bearing UI behind a themeReady check (!!textColour && palette.length > 0) and render a neutral color-mix skeleton while window.xprops loads — never flash hex-fallback colours that then switch to the real palette. The only sanctioned literals are #FFFFFF for the surface and 'Plus Jakarta Sans' for the font fallback.",
"ref": [
"§1",
"§2"
]
},
{
"id": "native-type-scale",
"rule": "Match the built-in type scale exactly: body/option/button label 14px, help/caption/secondary 12px, question/header 22px bold (host-painted in framed mode), form input 16px (dodges iOS auto-zoom), body line-height 1.5. Spacing on a multiple-of-8 rhythm (8/16/24).",
"ref": [
"C19",
"§1"
]
},
{
"id": "transform-inside-box",
"rule": "onHeightChange reports the RESTING box. Keep any transform (swipe tilt, drag ghost, hover pop, settle bounce) small enough that its most-extreme frame's bounding box still fits the reported height; budget a little headroom rather than inflating the reported box. Prefer a cheap cap (e.g. 8° tilt over 12°).",
"ref": [
"C8",
"§1a"
]
},
{
"id": "bounded-scroll-fade",
"rule": "Default is no inner scroll (let the host shrink-wrap). The one exception — a fixed-size bounded card (swipe/flash/reveal) — MAY scroll its own overflow with no page-level scroll, and MUST show a mask-image fade on the still-hidden edge(s), dropped when it all fits. Fade with CSS mask-image, never a coloured gradient overlay (no baseColour to match).",
"ref": [
"C9",
"§1a"
]
},
{
"id": "swipe-60fps",
"rule": "Keep a dragged/flung card at 60fps: drop expensive continuous effects (backdrop-filter, large box-shadow, any filter) while a `moving` flag is true and restore on settle; animate only transform/opacity (never width/height/top/left/margin); set will-change: transform.",
"ref": [
"§1b"
]
},
{
"id": "commit-on-velocity",
"rule": "Commit a swipe on EITHER the distance threshold OR a flick-velocity threshold (px/ms across pointermoves), so a fast flick flings instead of springing back.",
"ref": [
"§1b"
]
},
{
"id": "palette-for-chart",
"rule": "Pull chart / visualization fills (bars, ring segments, plot dots, selection highlights) from xprops.presentationColorPalette (or presentationLighterColorPalette); pick the shade that contrasts against your chosen background.",
"ref": [
"C2",
"§2"
]
},
{
"id": "bundle-semantic-state",
"rule": "State colours (submitted ✓, error ✗, correct/incorrect, warning, info) must NOT depend on the deck palette and must NOT come from --aha-* vars (they don't exist in this iframe). Bundle colorSuccess/colorError/colorWarning/colorInfo into the plugin build from the design-system package, so the audience sees the same success-green / error-red as the presenter canvas.",
"ref": [
"C3",
"§2"
]
},
{
"id": "function-token-not-lookalike",
"rule": "Use the actual function token (colorSuccess #16C49A, colorError #F5222D, colorWarning, colorInfo) for state marks — never a lookalike brand accent (Bright Teal #20E8B5 as a tick is wrong and fails contrast at ~1.58:1). A light success/error glyph goes on a bounded fill (filled badge, white ✓), not straight onto the light surface (colorSuccess on white is only ~2.23:1).",
"ref": [
"C10",
"§2"
]
},
{
"id": "textcolour-for-text",
"rule": "Bind body text to xprops.slide.textColour. Do NOT use textColour as a border/divider/hairline source; derive hairlines from color-mix(in srgb, currentColor 10%, transparent) so they stay subtle and theme-aware.",
"ref": [
"C11",
"§2"
]
},
{
"id": "choose-own-background",
"rule": "No baseColour or backgroundImage is forwarded — choose your own background: a white / very light surface by default (works on light and dark decks), or derive a surface from the luminance of textColour.",
"ref": [
"C12",
"§2",
"§7"
]
},
{
"id": "load-deck-font",
"rule": "fontFamily is only the font's NAME; this cross-origin iframe does not inherit the host's @font-face, so naming the family without loading the face renders all text in the serif fallback. Call useDeckFont(fontFamily) in THIS iframe (its own call — loading on the canvas does nothing here); defaults to 'Plus Jakarta Sans'.",
"ref": [
"C16",
"§2"
]
},
{
"id": "controls-track-deck",
"rule": "A checked Radio/Checkbox takes the deck ACCENT (presentationColorPalette[0] via a custom property like --aha-control-accent), never Ant's fixed colorPrimary purple. The selected-answer ring/edge takes the deck's textColour (not a near-black or a deepened accent) so it stays visible on any deck; the accent still marks the pick on the control and in label weight.",
"ref": [
"C17",
"§2"
]
},
{
"id": "publish-accent-var",
"rule": "Publishing the accent var is mandatory — something in the render tree must actually SET --aha-control-accent (inline style/:style on the control or an ancestor, from presentationColorPalette[0]). Referencing the var with only a purple fallback, without ever publishing it, ships purple on every deck.",
"ref": [
"C17",
"§2"
]
},
{
"id": "slider-fill-hairline",
"rule": "Give a filled slider/progress track the same deck-ink hairline its empty rail has (color-mix(in srgb, currentColor 20%, transparent)) so a coloured fill doesn't sink into a same-hue deck — a hairline matching the rail, not a heavy ink ring around the control.",
"ref": [
"C18",
"§2"
]
},
{
"id": "slider-rail-flush",
"rule": "Zero out Ant's default slider margin (~margin: 10px 6px) so a horizontal slider's rail runs edge-to-edge, flush under the row holding its min/max end labels.",
"ref": [
"C20",
"§2"
]
},
{
"id": "section-rhythm",
"rule": "Distinct labelled sections sit ≥24px apart: wrap each section (label + its control) in its own tight flex-col gap-2 and space whole sections on the outer column with gap-6. The 8px gap is for rows INSIDE one group; don't stand a per-label mt-2/mt-3 in for a real section break.",
"ref": [
"C21",
"§2"
]
},
{
"id": "no-pastel-wash",
"rule": "Don't pair a pastel/tinted background with icon + text in the same hue (monotone 'AI' look). Bind ink to textColour (which tracks and contrasts), or pair a tint with genuinely high-contrast ink and clear hierarchy — not one hue at two brightnesses.",
"ref": [
"C2",
"§2a"
]
},
{
"id": "radius-cap-8",
"rule": "Rectangular content containers (option tiles, cards, panels, swipe card, submit button) cap at ≤8px radius — rounded-xl/2xl/3xl (12/16/24) read toy-like, and the cap applies to the OUTER wrapper too. Intentionally circular elements (pills, chips, badges, avatars, dots, progress tracks) stay fully rounded.",
"ref": [
"C6",
"§2a"
]
},
{
"id": "weight-400-600",
"rule": "Text weight is 400 or 600 only (the standard submit button is 600). No 700/800/900 (font-bold/extrabold/black) — heavy weight reads 'AI'.",
"ref": [
"C6",
"§2a"
]
},
{
"id": "no-redundant-label",
"rule": "Delete any label, header or divider that merely repeats an adjacent control — two labelled True/False buttons don't need a 'True or False' header or an 'OR' divider; the buttons are the prompt. A word earns its place only if it says something the controls don't.",
"ref": [
"§2b"
]
},
{
"id": "plain-micro-instruction",
"rule": "A micro-instruction is the shortest possible phrase in the DEFAULT body (16px, regular 400, the deck's textColour) — never a pill, tinted card, shadowed chip or button-shaped box, and never semibold or muted-grey. Use ONE shared instruction component across every slide so it never drifts between slides.",
"ref": [
"§2b"
]
},
{
"id": "primary-copy-16px",
"rule": "Primary body copy and option labels stay at the audience default 16px, never text-sm. text-sm (14px) is legitimate ONLY on secondary metadata beside a label (vote-count chip, percentage, helper caption) — never on the option label or question restatement.",
"ref": [
"C19",
"§2b"
]
},
{
"id": "declare-fullscreen",
"rule": "Declare setting.enableFullScreen explicitly in the manifest — framed (default) for most types (host paints its chrome above the iframe), full-canvas only for a bespoke header. Never mix framed mode with your own title (it duplicates the host's).",
"ref": [
"C22",
"§3"
]
},
{
"id": "self-render-states",
"rule": "The plugin self-renders every post-submission state inside the iframe (host shows no generic 'Waiting…' screen for plugins): just-submitted, waiting-for-next-slide (calm, no noisy spinner), quiz correct/incorrect (semantic colour + non-colour ✓/✗ cue + answer text, bar fills stay palette), and a pre-submission empty state (show the input affordance with placeholder copy, never a giant '0' or empty chart).",
"ref": [
"C13",
"§4"
]
},
{
"id": "smooth-state-transitions",
"rule": "Keep state transitions smooth — re-report height via onHeightChange when a state change legitimately changes content height, but avoid layout jumps >16px between adjacent states.",
"ref": [
"§4"
]
},
{
"id": "submit-via-toast",
"rule": "Announce submission with xprops.showToastSuccess(t('audience.submitted')) — the host paints a consistent toast outside the iframe. Never re-implement a 'Submitted' banner inside the iframe; after the toast, transition content to results / quiz feedback / waiting.",
"ref": [
"C14",
"§4",
"§6"
]
},
{
"id": "submit-button-spec",
"rule": "Rebuild the standard submit button in-iframe (the aha-antd-button primary-alt doesn't cross the boundary): full-width block, ~44–48px tall, soft rounded corners (≤8px), background from xprops.presentationColorPalette[0] (never a hard-coded brand colour, never a bare type=\"primary\" fixed purple), readable ink chosen by fill luminance (white on dark shade, dark ink on light — never hard-coded white), semi-bold, a ~10%-textColour edge hairline, positioned at the end of the answer area with ~16px below.",
"ref": [
"C15",
"§5"
]
},
{
"id": "submit-disabled-neutral",
"rule": "When input is invalid the submit button is a distinct NEUTRAL GREY surface (grey fill + grey text), not the coloured fill at reduced opacity (a dimmed purple still reads enabled). No pointer cursor; aria-disabled=\"true\"; block submit until the user has picked/typed.",
"ref": [
"C15",
"§5"
]
},
{
"id": "submit-submitting",
"rule": "While submitting, show an inline spinner INSIDE the button — the button doesn't disappear, change height or move (no layout jump) — and lock it to prevent double-submit.",
"ref": [
"C15",
"§5"
]
},
{
"id": "submit-locked",
"rule": "On host stopSubmission, swap to a locked state: lock icon + i18n 'submission closed' label replacing the submit label; the button stays present (no reflow) but uninteractive.",
"ref": [
"C15",
"§5"
]
},
{
"id": "submit-post-complete",
"rule": "After submit completes: a scored quiz swaps to a circular badge — green ✓ correct / red ✗ incorrect using the bundled semantic colours; a non-scored slide fires showToastSuccess and transitions to the plugin-rendered submitted/waiting state.",
"ref": [
"C15",
"§5"
]
},
{
"id": "secondary-actions",
"rule": "Secondary actions (Skip, Start voting) use a lower-contrast variant (surface + border, or a desaturated palette shade), same height, same i18n discipline — the hierarchy must read clearly as primary vs side option. Never paint two visually equal buttons.",
"ref": [
"§5"
]
},
{
"id": "submit-height-change",
"rule": "Call onSubmitButtonHeightChange(h) with the submit button's y-offset so the host can paint the floating 'Scroll to submit' pill when the button sits below the fold.",
"ref": [
"§5",
"§6"
]
},
{
"id": "testid-kebab",
"rule": "Put a kebab-case data-testid scoped to the slide type on every interactive element (e.g. audience-<slidetype>-submit-button, audience-<slidetype>-option-3) so QA can target it across slide types and releases.",
"ref": [
"§5"
]
},
{
"id": "i18n-labels",
"rule": "Label every control via t(...) — never hard-coded 'Submit'/'Send'/'Vote'. All user-visible text translates per xprops.presentation.language.",
"ref": [
"§5",
"§9"
]
},
{
"id": "use-host-utilities",
"rule": "Use the host utilities rather than reinventing them in-iframe: showToast* for transient feedback/errors, openPluginModal/closePluginModal for modals (host paints chrome, you fill the body), onSubmitButtonHeightChange for the sticky pill, scrollTo/getWindowHeight for positioning. Lean toward the utility so it stays consistent and host-evolvable.",
"ref": [
"C20-util",
"§6"
]
},
{
"id": "countdown-logic-not-ui",
"rule": "In framed mode the host already paints the countdown progress bar above the iframe — do NOT duplicate it with your own pill/widget. Use xprops.timeLimit as LOGIC only (e.g. lock submit when timeLimit <= 0). In the rare full-canvas case, render the countdown bound to the live xprops.timeLimit, never static text or your own setInterval.",
"ref": [
"§6"
]
},
{
"id": "host-owns-expiry",
"rule": "The host owns the countdown EXPIRY notice — it announces 'Time's up' in its chrome. Don't draw your own 'Time's up' / deadline card in the iframe (it double-announces and drifts from the host clock); let the round-end state you DO own carry the meaning.",
"ref": [
"§6"
]
},
{
"id": "no-static-time-text",
"rule": "Never write static time text ('30 seconds', '1 phút', 'đếm ngược 30s') anywhere — description, label or helper — it drifts from reality the moment the timer ticks.",
"ref": [
"§6"
]
},
{
"id": "out-of-reach",
"rule": "Don't render or assume-broken host-owned layers: edge states (lost connection, poll closed, kicked, last slide, slide skipped) overlay from the host — don't draw your own; logo / watermark / branding / plan-gating are host layers; baseColour / backgroundImage never reach the iframe.",
"ref": [
"§7"
]
},
{
"id": "locale-from-host",
"rule": "Locale is xprops.presentation.language (presenter's choice), not the audience browser — don't override, just translate. The host does not flip RTL by language; if your slide type needs RTL (Arabic, Hebrew) handle it yourself (dir=\"rtl\" on the root, logical CSS).",
"ref": [
"§7",
"§9"
]
},
{
"id": "touch-targets",
"rule": "Touch targets are roughly finger-sized (~44–48px, iOS 44pt / Android 48dp) as a recommended range; leave ≥8px between tappables to avoid mis-taps.",
"ref": [
"§8"
]
},
{
"id": "wcag-aa",
"rule": "Meet WCAG AA against your CHOSEN background — 4.5:1 text, 3:1 large text and meaningful shapes — verified on both light and dark decks (since baseColour isn't forwarded, you make and defend the background choice).",
"ref": [
"§8"
]
},
{
"id": "focus-visible",
"rule": "Keep a visible focus ring — keyboard users on tablets/laptops still join; don't remove the default ring without replacing it.",
"ref": [
"§8"
]
},
{
"id": "no-motion-meaning",
"rule": "Don't use motion alone to convey meaning — it's lost on bad connections and for prefers-reduced-motion users.",
"ref": [
"§8"
]
},
{
"id": "non-colour-cue",
"rule": "Pair a non-colour cue with every colour-bearing signal (state, error, correct/incorrect, leader) — prefer an icon (✓/✗) over a verbose word chip; it's compact and language-neutral.",
"ref": [
"§8",
"§4"
]
},
{
"id": "honest-chart-geometry",
"rule": "Keep chart geometry honest — if a row has an indicator only sometimes (e.g. a leader trophy), reserve a fixed-width slot on every row so all bar tracks share width; a 55% bar must occupy 55%, not 48% because the chip ate the difference.",
"ref": [
"§8"
]
},
{
"id": "i18n-from-xprops",
"rule": "The iframe has its own i18n instance (not inherited from host): initialise locale from xprops.presentation.language with xprops.currentUser.presenterLanguage as fallback, and subscribe to host language changes mid-session.",
"ref": [
"§9"
]
},
{
"id": "intl-number-format",
"rule": "Format numbers / percentages with Intl.NumberFormat(locale), not .toFixed(...) + '%' (some locales use ',' as the decimal separator).",
"ref": [
"§9"
]
}
],
"shipsCode": false,
"reuse": null
},
{
"generatedFrom": "guidelines/canvas.json",
"kind": "guideline",
"pattern": "Canvas",
"slug": "canvas",
"summary": "How a slide type LOOKS on the canvas — the 16:9 stage, theme/colour/fonts from xprops, semantic state colours, WCAG readability from across the room, framed-vs-full-canvas, the presenter control bar, in-canvas keyboard affordances, and i18n — built from existing components.",
"skillRef": {
"build": "aha-design:aha-design-canvas",
"judge": "aha-design:aha-design-canvas-judge",
"note": "The skill is the source of truth for rationale, worked BAD/GOOD examples, the §1–§9 sections, the pre-ship checklist (§8), and the canonical good-fill-in-the-blanks fixture. This pattern distils the enforceable subset; each rule links back by ref to the judge criteria (C1..C16) and/or the skill section it comes from. The judge (aha-design-canvas-judge) emits a binary PASS/FAIL across the same contracts."
},
"surfaces": [
"editor",
"presenter",
"audience"
],
"surfaceChoice": [
{
"surface": "Framed (setting.enableFullScreen: false)",
"useFor": "a 'question + answer area' slide that should look like every built-in AhaSlides slide; the host renders title/description/question-image and your iframe fills the rest",
"example": "a poll answer area, a quiz choice grid"
},
{
"surface": "Full-canvas (setting.enableFullScreen: true)",
"useFor": "a bespoke layout that owns the whole stage; the host hides its chrome and you render the title (slideProps.title) yourself",
"example": "This or That, a custom game board"
},
{
"surface": "Presenter control bar (NCB, outside the iframe)",
"useFor": "slide-specific actions (Next / Summarise / Previous) — declared in the plugin manifest, painted by the host as <aha-antd-button>, never rendered inside the canvas",
"example": "Idea board's Previous / Next: vote / Summarise"
}
],
"composedOf": [
{
"ref": "button",
"as": "component",
"use": "any in-canvas control fallback when the plugin-action feature flag is off — reproduce the host's white secondary <aha-antd-button> with your own library Button, never a raw <button>",
"status": "available"
},
{
"ref": "icon",
"as": "component",
"use": "on-canvas glyphs — the ✓/✗ verdict mark, trophy, status indicators — a design-system glyph, never an inline <svg> or a foreign icon set",
"status": "available"
},
{
"ref": "--aha-colorSuccess",
"as": "token",
"use": "the ✓ / correct-answer / validation-passed mark — the semantic function token (#16C49A) bundled into the plugin build, never a lookalike brand accent",
"status": "available"
},
{
"ref": "--aha-colorError",
"as": "token",
"use": "the ✗ / incorrect-answer / destructive-action mark — the semantic function token (#F5222D), bundled, never hard-coded",
"status": "available"
},
{
"ref": "--aha-colorWarning",
"as": "token",
"use": "the caution / 'are you sure?' indicator mark — bundled semantic function token",
"status": "available"
},
{
"ref": "--aha-colorInfo",
"as": "token",
"use": "the informational badge mark — bundled semantic function token",
"status": "available"
},
{
"ref": "--aha-borderRadius",
"as": "token",
"use": "the 8px radius cap on rectangular content containers (cards, answer boxes, option tiles, panels) — rounded-lg, never rounded-2xl/3xl",
"status": "available"
},
{
"ref": "aha-antd-button",
"as": "component",
"use": "the host-painted presenter control-bar action button (NCB) — you declare the action data in the manifest, the host renders this wrapper; the iframe cannot call it directly",
"status": "missing"
},
{
"ref": "kbd-chip",
"as": "component",
"use": "the in-canvas keyboard-shortcut indicator — a single-character key renders as a SQUARE (equal box, radius 4), multi-char keys grow wider; shared across slides",
"status": "missing"
},
{
"ref": "tooltip",
"as": "component",
"use": "an icon-only in-canvas button's name + shortcut tooltip (e.g. 'Mute music (M)') — shared tooltip, not a hand-rolled title-only hover",
"status": "available"
},
{
"ref": "focus-trap",
"as": "component",
"use": "a shared focus-trap wrapper for an in-canvas modal/overlay/popover — moves focus in on open, cycles Tab within, returns focus to the trigger on close",
"status": "missing"
},
{
"ref": "use-deck-font",
"as": "component",
"use": "the shared useDeckFont(fontFamily) helper — injects the deck font's @font-face <link> into the iframe so binding fontFamily doesn't fall back to serif; idempotent, no-ops for bundled Plus Jakarta Sans",
"status": "missing"
}
],
"componentBacklog": "composedOf marks 5 artifacts this pattern needs that the DS does not yet ship (aha-antd-button host wrapper, kbd-chip, tooltip, focus-trap, use-deck-font). Per the charter, a pattern that leans on a missing component is a signal to add it HERE — this is the pattern surfacing the component roadmap so the next slide type reuses it instead of hand-rolling.",
"rules": [
{
"rule": "Design against the 16:9 stage proportions (1280×720 reference), not a monitor's pixels; use responsive layout so nothing overflows or is clipped when scaled large (projector) or small (preview).",
"ref": [
"§1",
"C9"
]
},
{
"rule": "Keep a shadowed element's shadow INSIDE the stage padding — the iframe clips box-shadows/glows painted past its box. Size the shadow so its reach (offset + blur − spread) fits the margin, keep it near-symmetric, and don't let a scale-up animation grow the element into the padding the shadow needs.",
"ref": [
"§1",
"C9"
]
},
{
"rule": "Read colours and fonts from xprops (slide.baseColour, slide.textColour, slide.backgroundImage, presentation.fontFamily, presentationColorPalette, presentationLighterColorPalette) — never hard-code hex colours, Tailwind colour utilities, or font families. The slide must look right on light, dark, and image backgrounds.",
"ref": [
"§2",
"C1"
]
},
{
"rule": "Use the accent palette for chart/accent marks so the slide feels native to the deck — but NEVER pair two palette entries as fill-and-text; the palette has no guaranteed fill/foreground contrast. When text sits on a coloured fill, derive the ink FROM the fill (isLightColour(fill) ? dark : light) or fill with the item's own accent and contrast against that.",
"ref": [
"§2",
"C1",
"C5"
]
},
{
"rule": "Loading the font NAME is not enough — inject the deck face into the cross-origin iframe via the shared useDeckFont(fontFamily) helper in EVERY role that renders text (presenter Canvas AND Audience/report iframes are separate documents). Binding fontFamily without loading the face is the serif-fallback bug.",
"ref": [
"§2",
"C14"
]
},
{
"rule": "When textColour is unset (the common default deck) and the ink sits on a bounded filled surface whose polarity can vary, derive the fallback from that surface's own background with a luminance flip (readableInkOn(fill)) — never a fixed ?? '#fff' or ?? '#313131' constant that goes invisible on the opposite polarity. Bind every copy of the derived ink to one computed source.",
"ref": [
"§2",
"C5",
"C15"
]
},
{
"rule": "Keep the two colour jobs separate: the chart/visualization layer (bar fills, ring segments, dots) pulls from the accent palette and NEVER carries state meaning; the state-indicator layer (✓/✗, status badge, border accent) is where correctness lives. Don't paint the 'correct' bar green — move the green to a ✓ icon and leave the bar in palette colours.",
"ref": [
"§2a",
"C2"
]
},
{
"rule": "Semantic state colours (colorSuccess/colorError/colorWarning/colorInfo) must be the design-system FUNCTION tokens bundled into the plugin's own build (imported from the DS package or plugin-local constants matching it) — not host CSS vars (they don't cross the iframe), not xprops, and not a lookalike brand accent that merely reads green/red.",
"ref": [
"§2a",
"C2",
"C4"
]
},
{
"rule": "A ✓/✗ glyph is a correct/incorrect signal, never decoration. Use it only where the slide has a real verdict (quiz answer, validation pass/fail); don't drop a ✓ on an opinion poll / This-or-That / word cloud as a 'done' ornament — use a neutral affordance instead.",
"ref": [
"§2a",
"C6"
]
},
{
"rule": "Avoid the four 'AI-generated' tells: no pastel/tinted wash under same-hue icon+text (monotone, low-contrast); corner radius ≤ 8px on rectangular content containers (pills/chips/badges/avatars/dots/bar tracks stay fully rounded); display-text weight 400 or 600 only (no 700/800/900); no decorative brand watermark or meaningless chrome — every on-canvas element must carry a real datum.",
"ref": [
"§2b",
"C7",
"C8"
]
},
{
"rule": "Attach a per-option metric (vote count, tally, share) to that option's own card — a bounded corner responder-count badge (a darker tint of the option's accent with white ink), not a detached legend or a separate vote-count strip. Don't hand-draw a second redundant rendering (a colour-split progress bar) of a metric the option already states.",
"ref": [
"§2c",
"C2"
]
},
{
"rule": "Meet WCAG AA as the floor — 4.5:1 for text, 3:1 for large text and meaningful shapes — checked against the REAL composited background (baseColour blended with backgroundImage); add a scrim behind text on busy photos. Go higher when you can.",
"ref": [
"§3",
"C10",
"C11"
]
},
{
"rule": "A slide-painted surface re-anchors the contrast check against that composited surface — but a translucent tint is NOT a contrast guarantee (only an opaque surface is), and any fixed-colour mark on it (a semantic ✓/✗ token, a palette-accent border) must clear the floor on BOTH a light and a dark deck. A light semantic token on a light surface still fails (colorSuccess #16C49A on white ≈ 2.23:1) — put it on a bounded fill or a dark surface.",
"ref": [
"§3",
"C10",
"C11"
]
},
{
"rule": "Size every meaningful text with a fixed logical px from the Tailwind preset roles (text-base 16 / text-lg 18 / text-2xl 24 / text-5xl 48 / text-7xl 72). Primary content (title/question/answer) sits at body 18px or larger; only secondary meta may touch the 16px floor.",
"ref": [
"§3",
"C12"
]
},
{
"rule": "Never a vh/vw font-size on the canvas (vh resolves against the framed iframe viewport ~498px, not the 720 stage, so text-[2.3vh] ≈ 11px), no clamp() px floor (it breaks proportion on the transform-scaled stage), never text-xs/text-sm (12/14px), and no arbitrary text-[…] font-size — pick a role. (em is fine.)",
"ref": [
"§3",
"C12"
]
},
{
"rule": "Text is for reading, not decorating — a real word renders as normal running text; never stack its letters vertically, rotate it, or over-track it into a mark (e.g. 'OR' stacked O-over-R in a pill). If a divider must be compact, keep the word horizontal at a smaller in-scale role or let it wrap — never stack or rotate.",
"ref": [
"§3",
"C13"
]
},
{
"rule": "Never rely on colour alone — pair colour with a non-colour cue (icon shape, label, or position). Prefer an icon (trophy, ✓/✗) over a spelled-out chip; use words only when no glyph conveys the meaning (e.g. 'Closed', 'Beta'), and almost never stack icon + word together.",
"ref": [
"§3",
"C6"
]
},
{
"rule": "Keep chart geometry honest — a decorative indicator (leader chip, trophy) must not change a row's spatial accuracy. Reserve a fixed-width decoration slot on EVERY row (empty when undecorated) or render the decoration as a positioned overlay, so a chip doesn't narrow one row's track and make the chart lie.",
"ref": [
"§3",
"C2"
]
},
{
"rule": "Borders are ornament — never derive them directly from textColour. Use a subtle theme-aware hairline (color-mix(in srgb, currentColor 10%, transparent)); fix a marginal contrast check at the bar or scrim, not with a darker border. Pick the palette shade (saturated vs lighter) that actually clears the floor on the current composited background.",
"ref": [
"§3",
"C10"
]
},
{
"rule": "Don't depend on fast or subtle motion to convey meaning.",
"ref": [
"§3",
"C10"
]
},
{
"rule": "Declare the framed-vs-full-canvas choice EXPLICITLY in the plugin manifest (setting.enableFullScreen) — an absent setting reads as 'we forgot', not 'we chose'. Framed for a standard question+answer slide; full-canvas for a bespoke layout.",
"ref": [
"§4",
"C3"
]
},
{
"rule": "A FRAMED slide's root element must stay transparent in every display mode and let the deck theme (baseColour/backgroundImage the host composites behind it) show through — it must not render its own title (the host draws it) nor paint a slide-wide background/backdrop/scrim. The only opaque fill it may paint is a surface bounded to its content (a card, chip, caption bar).",
"ref": [
"§4",
"C3"
]
},
{
"rule": "In FULL-CANVAS mode you MUST render the presenter's title yourself from slideProps.title (slideProps.value?.title ?? '') — the host hides all its chrome including the title bar, so rendering only per-item prompts/labels silently drops the title the presenter typed.",
"ref": [
"§4",
"C16"
]
},
{
"rule": "Never set enableFullScreen: true and enableQuestionTitle: true together — full-canvas hides the host title bar, so an enableQuestionTitle value renders nowhere and vanishes. Pick framed (enableFullScreen off, enableQuestionTitle on) OR full-canvas (enableFullScreen on, render slideProps.title yourself, enableQuestionTitle off).",
"ref": [
"§4",
"C3",
"C16"
]
},
{
"rule": "Slide-specific actions (Next / Summarise / Previous) are DECLARED in the plugin manifest and painted by the host in the presenter control bar (NCB) as <aha-antd-button> — never rendered inside the canvas. You don't pick the button component, size, or styling; declare the action data, the host does the rest. This is for consistency (theming, shortcut chips, sound), not audience visibility.",
"ref": [
"§5",
"C3"
]
},
{
"rule": "A declared action's secondary variant is a SOLID WHITE button (white fill, subtle border, dark label) — the correct resting style for almost every slide action — never a transparent/ghost/outline button. Declare variant: 'secondary' (or omit it, the default); never hand-render a transparent/border-only button inside the canvas.",
"ref": [
"§5",
"C3"
]
},
{
"rule": "Reserve the one primary action for a genuinely singular climactic step (reveal final result, finish activity) — or use no primary at all. A repeated per-round advance stepper (This or That's 'Next pair', a per-question 'Next') is secondary, not primary, so the accent keeps signalling the one decisive action.",
"ref": [
"§5",
"C3"
]
},
{
"rule": "Pass a reactive boolean to a declared action's disabled / loading — NOT a function. The host coerces with Boolean(action.disabled), and a function literal is always truthy, so disabled: () => votes === 0 makes the button permanently disabled. label is likewise raw text ({{ action.label }}), not a function.",
"ref": [
"§5",
"C3"
]
},
{
"rule": "Plugin-declared action buttons are gated behind canUsePluginActionButtons; when the flag is off the host doesn't expose setActionButtons/onActionInvoke, so keep an in-canvas button fallback. Build that fallback from the component library (your own Ant <Button> under ConfigProvider), never a raw <button>/<div>/<input> with hand-written fill/border/radius CSS, and place it bottom-centre where the host bar would be.",
"ref": [
"§5",
"C3",
"C4"
]
},
{
"rule": "An in-canvas modal/overlay/popover traps focus — move keyboard focus to an element inside it on open, keep Tab/Shift+Tab cycling within it, and return focus to the trigger on close. An overlay that opens but leaves focus on the page behind is invisible to keyboard and screen-reader users.",
"ref": [
"§6",
"C13"
]
},
{
"rule": "Every actionable in-canvas button carries a keyboard-shortcut indicator, and a single-character key renders as a SQUARE (equal width/height, small rounded corners ~4px) — never a wide pill (a pill reads as a tag/label, a square reads as a key).",
"ref": [
"§6",
"C13"
]
},
{
"rule": "A labelled in-canvas button shows its shortcut chip INLINE next to the label, always visible — never hidden in a hover-only title=\"Press X\" tooltip (which makes the presenter hunt for it and hides it from touch).",
"ref": [
"§6",
"C13"
]
},
{
"rule": "An icon-only in-canvas button carries a tooltip (and aria-label) that is the button's NAME plus the shortcut in parentheses — e.g. 'Mute music (M)'. A bare icon with no tooltip, or one that drops the name or the (shortcut), leaves the control undiscoverable.",
"ref": [
"§6",
"C13"
]
},
{
"rule": "Display in the presentation's language — the plugin runs its own i18n instance and does NOT inherit the host's locale. Initialise locale from xprops.presentation.language (or xprops.currentUser.presenterLanguage) at startup and update on host change; no hard-coded English; use Intl.NumberFormat(locale) for percentages/counts, not .toFixed() with an assumed decimal separator.",
"ref": [
"§7",
"C1"
]
},
{
"rule": "Hand the right-panel settings form off to aha-design-settings — the canvas pattern covers only the visual surface, not the settings form.",
"ref": [
"§4",
"§8"
]
}
],
"shipsCode": false,
"reuse": null
},
{
"generatedFrom": "guidelines/feedback.json",
"kind": "guideline",
"pattern": "Feedback",
"slug": "feedback",
"summary": "How to build transient and post-action feedback — toasts, the DS V3 Alert inline banner, inline correct/incorrect result panels, and the CSAT thumbs widget — from existing components, plus when NOT to use a toast.",
"skillRef": {
"build": "aha-design:aha-design-feedback",
"judge": "aha-design:aha-design-feedback-judge",
"note": "The skill is the source of truth for rationale, the Alert references/alert.md spec (tokens, glyphs, size metrics, Figma node IDs), and the full FEEDBACK-xx assertions. The DS now OWNS this build ruleset; the plugin skill is generated FROM this. This pattern carries the enforceable rules and links each back by ref; judge/evals stay in the skill."
},
"surfaces": [
"editor",
"respondent",
"dashboard",
"presenter",
"audience"
],
"surfaceChoice": [
{
"surface": "Toast (fixed bottom-right pill)",
"useFor": "transient, non-actionable confirmation; auto-dismiss ~3s",
"example": "\"Thanks for your feedback\", \"Copied\""
},
{
"surface": "Alert (inline banner)",
"useFor": "persistent, in-context message the user may need to act on; stays in layout",
"example": "inline error/info/warning/branding banner"
},
{
"surface": "Inline result panel (correct/incorrect)",
"useFor": "in-place result feedback shown after an answer",
"example": "quiz correct/incorrect with explanation"
},
{
"surface": "CSAT thumbs widget",
"useFor": "a satisfaction (thumbs up/down) prompt with optional follow-up",
"example": "post-flow \"How was this?\" rating"
}
],
"composedOf": [
{
"ref": "icon",
"as": "component",
"use": "the DS system status glyph per Alert type (check-circle / x-circle / info-circle / warning-triangle / lightbulb), the × close glyph, the thumbs glyphs — functional icons only, stroke 1.5",
"status": "available"
},
{
"ref": "--aha-colorSuccess",
"as": "token",
"use": "inline correct-result border and Alert success border — never hardcode a hex",
"status": "available"
},
{
"ref": "--aha-colorSuccessBg",
"as": "token",
"use": "inline correct-result background surface",
"status": "available"
},
{
"ref": "--aha-colorError",
"as": "token",
"use": "inline incorrect-result border and Alert error border",
"status": "available"
},
{
"ref": "--aha-colorErrorBg",
"as": "token",
"use": "inline incorrect-result background surface",
"status": "available"
},
{
"ref": "alert",
"as": "component",
"use": "the DS V3 AhaAlert inline banner — 5 types (success/error/info/warning/branding) × regular/small sizes, DS surface/border/icon tokens, distinct glyph per type; NOT a bare AntD Alert",
"status": "missing"
},
{
"ref": "toast",
"as": "component",
"use": "the bespoke fixed bottom-right pill (role=status, ~3s auto-dismiss, portalled to document.body) for custom bottom-right content",
"status": "missing"
},
{
"ref": "message",
"as": "component",
"use": "AntD message/notification API for ordinary app-level success/info toasts",
"status": "missing"
},
{
"ref": "csat",
"as": "component",
"use": "the shared Csat thumbs widget — binary rating with optional thumbs-down follow-up, required source token, best-effort csat.* analytics",
"status": "missing"
}
],
"componentBacklog": "composedOf marks 4 components this pattern needs that the DS does not yet ship (alert, toast, message, csat). Per the charter, a pattern that leans on a missing component is a signal to add it HERE — this is the pattern surfacing the component roadmap.",
"rules": [
{
"id": "toast-is-transient",
"rule": "Use a toast only for a transient, non-actionable confirmation. Render it as a small fixed pill at the viewport bottom-right that rises from below so it never shifts surrounding layout, and auto-dismiss after ~3s, clearing the timer on unmount.",
"ref": [
"FEEDBACK-01"
]
},
{
"id": "toast-role-and-portal",
"rule": "A toast carries role=\"status\" so it is announced without stealing focus, and is portalled via createPortal(node, document.body) to escape any transformed/filtered/overflow ancestor that would clip a position:fixed element.",
"ref": [
"FEEDBACK-02"
]
},
{
"id": "standard-vs-bespoke-toast",
"rule": "For ordinary app-level success/info toasts, prefer AntD's message/notification APIs; reserve the bespoke fixed pill for when you need the specific bottom-right placement and custom content.",
"ref": [
"FEEDBACK-03"
]
},
{
"id": "alert-is-ds-v3",
"rule": "Build the DS V3 AhaAlert wrapper per references/alert.md — DS surface/border/icon tokens per type plus size (regular: 12/16 padding, 8px radius, 14px text; small: 8px padding, 4px radius, 12px text). Never ship a bare AntD <Alert>: it cannot express the branding type, the regular/small sizes, the DS surface/border tokens, or the DS system glyphs.",
"ref": [
"FEEDBACK-08"
]
},
{
"id": "alert-five-types-glyph",
"rule": "Support five types — success, error, info, warning, branding — each with its own DS bg-surface/border/icon token AND a distinct system glyph (check-circle / x-circle / info-circle / warning-triangle / lightbulb); colour is never the only signal.",
"ref": [
"FEEDBACK-09"
]
},
{
"id": "alert-font-weight",
"rule": "Alert text uses only Plus Jakarta Sans 400 (message and inline link) and 600 (title) — no 300/500/700; snap any other supplied weight to the nearest (title → 600, body → 400).",
"ref": [
"FEEDBACK-10"
]
},
{
"id": "alert-sentence-case",
"rule": "The Alert title/heading is sentence case — capitalize the first letter only (plus proper nouns); never Title Case, never ALL CAPS.",
"ref": [
"FEEDBACK-11"
]
},
{
"id": "alert-token-role-based",
"rule": "The Alert spec is token/role based so it applies unchanged to AntD, Vue, or plain CSS — wire the DS --p-color-* tokens onto whatever token layer the target app uses (e.g. the app's --aha-* / AntD theme tokens).",
"ref": [
"FEEDBACK-08"
]
},
{
"id": "alert-vs-toast",
"rule": "Use an Alert for a persistent, in-context message the user may need to act on; use a toast for a transient auto-dismissing confirmation.",
"ref": [
"FEEDBACK-08",
"FEEDBACK-01"
]
},
{
"id": "inline-result-semantics",
"rule": "Inline post-action (correct/incorrect) feedback uses role=\"status\" with aria-live=\"polite\" (informational, not urgent).",
"ref": [
"FEEDBACK-04"
]
},
{
"id": "inline-result-tokens",
"rule": "Colour the inline result container with semantic tokens — border var(--aha-colorSuccess)/var(--aha-colorError) and background var(--aha-colorSuccessBg)/var(--aha-colorErrorBg); never hardcode hex.",
"ref": [
"FEEDBACK-04"
]
},
{
"id": "inline-result-content",
"rule": "Lead the inline result with a strong correct/incorrect label; show the correct answer and any explanation below when incorrect.",
"ref": [
"FEEDBACK-04"
]
},
{
"id": "inline-result-hooks",
"rule": "Expose stable data-* hooks (data-correct, data-element-id) on the inline result for testing.",
"ref": [
"FEEDBACK-04"
]
},
{
"id": "csat-shared-widget",
"rule": "Use the shared Csat widget — a binary thumbs rating with an optional follow-up on thumbs-down. Do not build a bespoke rating control.",
"ref": [
"FEEDBACK-05"
]
},
{
"id": "csat-source-token",
"rule": "Pass a required source token from the closed CsatSource union (one per placement) so Mixpanel sees stable, typo-proof segmentation; add a union member when adding a placement.",
"ref": [
"FEEDBACK-05"
]
},
{
"id": "csat-best-effort-analytics",
"rule": "CSAT emits CSAT_SHOWN (once per mount), CSAT_RATED, and CSAT_FEEDBACK_SUBMITTED; tracking is best-effort — a throwing/uninitialised track() must never break rendering or block the thank-you toast.",
"ref": [
"FEEDBACK-06"
]
},
{
"id": "not-a-toast",
"rule": "Do NOT put these in a toast: an actionable error (inline Alert in context or an overlay Alert), a field validation error (FieldErrorDisplay / a11y FieldError, shared-components), a destructive confirmation (a modal, aha-design-overlays), or a full-page failure (ErrorPage, shared-components). A toast that auto-dismisses must never be the only place a required message lives.",
"ref": [
"FEEDBACK-07"
]
}
],
"shipsCode": false,
"reuse": null
},
{
"generatedFrom": "guidelines/overlays.json",
"kind": "guideline",
"pattern": "Overlays",
"slug": "overlays",
"summary": "How to build a modal, drawer, or popover — surface choice, safety-critical dismiss behaviour, state reset, drawer placement/width, the async-confirm pattern, inline error surfacing, and danger-styled confirms — built from existing components.",
"skillRef": {
"build": "aha-design:aha-design-overlays",
"judge": "aha-design:aha-design-overlays-judge",
"note": "The skill is the source of truth for rationale, the DeleteSurveyConfirmModal/RespondentReportDrawer references, and the full OVERLAY-01..08 assertions. This pattern distils the enforceable subset and links each rule back by ref; the judge closes the build→judge→fix loop."
},
"surfaces": [
"editor",
"dashboard",
"settings"
],
"surfaceChoice": [
{
"surface": "Modal",
"useFor": "a focused decision or short flow that should block the page",
"example": "confirmation, destructive action, one-time config"
},
{
"surface": "Drawer",
"useFor": "a detail view or medium-complexity panel that supplements the page without replacing it",
"example": "a record's report, distribution settings"
},
{
"surface": "Popover",
"useFor": "a small, anchored, contextual snippet — not multi-step flows",
"example": "quick info, a compact input, an upsell"
}
],
"composedOf": [
{
"ref": "button",
"as": "component",
"use": "the confirm/cancel CTAs; destructive confirm is the danger Button (okButtonProps danger + loading), danger on the button never the title/body",
"status": "available"
},
{
"ref": "icon",
"as": "component",
"use": "the X close glyph and footer action glyphs — functional icons only, via the shared Icon",
"status": "available"
},
{
"ref": "--aha-color-error",
"as": "token",
"use": "danger confirm colour — never hardcode a hex",
"status": "available"
},
{
"ref": "modal",
"as": "component",
"use": "the blocking-decision surface (confirmations, destructive actions) — AntD v6 Modal with mask/keyboard/destroyOnHidden props wired per these rules",
"status": "missing"
},
{
"ref": "drawer",
"as": "component",
"use": "the detail/panel surface — AntD v6 Drawer with placement=right, responsive width, footer actions, destroyOnClose",
"status": "missing"
},
{
"ref": "popover",
"as": "component",
"use": "the anchored contextual snippet surface — AntD v6 Popover",
"status": "missing"
},
{
"ref": "alert",
"as": "component",
"use": "the inline in-overlay error banner (AhaAlert / DS V3 Alert, owned by aha-design-feedback) — never a bare AntD Alert",
"status": "missing"
}
],
"componentBacklog": "composedOf marks 4 components this pattern needs that the DS does not yet ship (modal, drawer, popover, alert). Per the charter, a pattern that leans on a missing component is a signal to add it HERE — this is the pattern surfacing the component roadmap (the Alert overlaps aha-design-feedback's banner).",
"rules": [
{
"id": "surface-choice",
"rule": "Pick the surface by the job: Modal for a blocking decision or short flow, Drawer for a detail/medium-complexity panel that supplements the page, Popover for a small anchored snippet — never a multi-step flow in a popover.",
"ref": [
"OVERLAY-01"
]
},
{
"id": "destructive-no-mask-close",
"rule": "Destructive overlays MUST NOT dismiss on outside/mask click — set mask={{ closable: false }} on the Modal, leaving Esc (keyboard) and the X button as the only dismiss paths. Non-destructive overlays may allow mask-close.",
"ref": [
"OVERLAY-02"
]
},
{
"id": "no-dismiss-while-busy",
"rule": "While an async action is in flight (busy), disable cancel and suppress onCancel/onClose so the user cannot dismiss mid-operation.",
"ref": [
"OVERLAY-03"
]
},
{
"id": "state-reset",
"rule": "Always set destroyOnHidden (Modal) / destroyOnClose (Drawer) so transient state — form input, error text, busy flag — resets between opens. If the instance can stay mounted, also clear local error and busy on open; don't rely on unmount alone.",
"ref": [
"OVERLAY-04"
]
},
{
"id": "drawer-placement-width",
"rule": "Drawers default to placement=\"right\" with responsive width width={isMobile ? '100%' : 720} via useIsMobile() — full-bleed on mobile, fixed panel on desktop. The width is a prop, not gated through CSS.",
"ref": [
"OVERLAY-05"
]
},
{
"id": "drawer-footer-body",
"rule": "Put navigation/actions (prev/next, retry, position indicator) in the drawer footer, and use styles={{ body: { padding: 0 } }} when the body renders its own padded content.",
"ref": [
"OVERLAY-05"
]
},
{
"id": "confirm-title-question",
"rule": "For confirmations, the modal title is a question (\"Delete this survey permanently?\").",
"ref": [
"OVERLAY-06"
]
},
{
"id": "danger-confirm-button",
"rule": "A destructive confirm uses okButtonProps={{ danger: true, loading: busy }} — the danger styling lives on the action button, never the title or body — and cancel is disabled while busy (cancelButtonProps={{ disabled: busy }}).",
"ref": [
"OVERLAY-06"
]
},
{
"id": "async-confirm-pattern",
"rule": "onConfirm returns a promise: guard re-entry (if (busy) return), set busy, clear prior error, then await the action. On rejection stay open, surface the error inline, and clear busy; on success the caller closes. Never close a destructive overlay before its action resolves, and never fire-and-forget.",
"ref": [
"OVERLAY-07"
]
},
{
"id": "errors-inline",
"rule": "Surface failures inside the overlay with an inline DS V3 Alert (AhaAlert type=\"error\", styling owned by aha-design-feedback) — never a bare AntD Alert, never a nested modal stacked on top, never a downgrade to a transient toast. The overlay stays open on failure so the user can retry without re-opening.",
"ref": [
"OVERLAY-08"
]
}
],
"shipsCode": false,
"reuse": null
},
{
"generatedFrom": "guidelines/settings.json",
"kind": "guideline",
"pattern": "Settings",
"slug": "settings",
"summary": "How to organise a settings surface — group ordering, help-text vs tooltip, sub-settings, plan-gating, and the danger zone — built from existing components.",
"skillRef": {
"build": "aha-design:aha-design-settings",
"judge": "aha-design:aha-design-settings-judge",
"note": "The skill is the source of truth for rationale, worked BAD/GOOD examples, and the full 52 SETTINGS-xx assertions. This pattern distils the enforceable subset and links each rule back by ref."
},
"surfaces": [
"editor",
"settings",
"dashboard"
],
"surfaceChoice": [
{
"surface": "Inline panel (editor right pane)",
"useFor": "per-object settings, immediate effect on canvas",
"example": "block required toggle, choice count"
},
{
"surface": "Settings page (route)",
"useFor": "workspace/account-level, affects a whole scope",
"example": "workspace name, billing, permissions"
},
{
"surface": "Modal",
"useFor": "one-time action or short flow, not independently persisted",
"example": "export options, one-time import config"
},
{
"surface": "Drawer",
"useFor": "supplements a primary view, 3–8 settings",
"example": "distribution settings, results filter"
}
],
"composedOf": [
{
"ref": "checkbox",
"as": "component",
"use": "grouped options saved together; consent",
"status": "available"
},
{
"ref": "button",
"as": "component",
"use": "the danger-zone CTA (danger variant → colorError)",
"status": "available"
},
{
"ref": "icon",
"as": "component",
"use": "the ? help glyph, drag handle, per-option image, delete — functional icons only",
"status": "available"
},
{
"ref": "--aha-size*",
"as": "token",
"use": "the whole spacing hierarchy (4/8/16/32/48) — separation comes from spacing, never dividers",
"status": "available"
},
{
"ref": "--aha-color-error",
"as": "token",
"use": "danger CTA colour — never hardcode a hex",
"status": "available"
},
{
"ref": "switch",
"as": "component",
"use": "a single setting that takes effect immediately (toggle rows)",
"status": "available"
},
{
"ref": "select",
"as": "component",
"use": "duration/unit/mode dropdowns; recommended default pre-selected",
"status": "available"
},
{
"ref": "input",
"as": "component",
"use": "text/number fields; NumberWithUnit for a number + unit",
"status": "available"
},
{
"ref": "tooltip",
"as": "component",
"use": "the ? help tooltip (HelpTooltip) — shared dark-navy tooltip, arrow anchored to the ? icon",
"status": "available"
},
{
"ref": "settings-list",
"as": "component",
"use": "the reusable form of this pattern — SectionHeader (semibold group header) + SettingRow (label left / control right, or stacked) rendered from a schema over DS controls; spacing-only hierarchy",
"status": "available"
},
{
"ref": "dropdown",
"as": "component",
"use": "DropdownMenu — themed action menu (aha-dropdown + aha-menu) for image Change/Edit/Delete and Improve-style menus",
"status": "available"
},
{
"ref": "menu",
"as": "component",
"use": "the aha-menu inside DropdownMenu's action menu — neutral-grey item hover",
"status": "available"
},
{
"ref": "mode-field",
"as": "component",
"use": "ModeField — a labelled field whose input swaps with an inline exclusive mode control (outline radio group / segmented); only the active mode's body is live",
"status": "available"
},
{
"ref": "counted-input",
"as": "component",
"use": "CountedInput — single-line field with a focus-only char counter (counter is a persistent sibling revealed via :focus-within, never a remount-on-focus)",
"status": "available"
},
{
"ref": "counted-textarea",
"as": "component",
"use": "CountedTextarea — multi-line sibling of CountedInput; grows between minRows/maxRows, same focus-only counter",
"status": "available"
},
{
"ref": "card-select",
"as": "component",
"use": "CardSelect — single-select as an icon+label card grid; the one selection shape Ant has no built-in for",
"status": "available"
},
{
"ref": "number-with-unit",
"as": "component",
"use": "NumberWithUnit — digit input + hover stepper (clamped, maxDigits) + unit-in-full + optional error line, at an 8px corner; for durations/numbers-with-a-unit",
"status": "available"
},
{
"ref": "option-row",
"as": "component",
"use": "OptionRow — repeatable option/answer row: drag handle, optional correct toggle, borderless textarea with hover counter, optional image control, floating delete",
"status": "available"
},
{
"ref": "numbered-item",
"as": "component",
"use": "NumberedItem — repeatable numbered SECTION wrapper for composite items (chip + '<Label> N' header + hover delete + grey container); body is a slot",
"status": "available"
},
{
"ref": "question-list",
"as": "component",
"use": "QuestionList — editable list of collapsible questions (prompt field + OptionRow choices); composes NumberedItem/OptionRow",
"status": "available"
},
{
"ref": "image-dropzone",
"as": "component",
"use": "ImageDropzone — full-width settings-only image field (dashed card, empty/loading/filled) emitting intents; the host runs the upload modals, never a self-built uploader",
"status": "available"
},
{
"ref": "image-action-button",
"as": "component",
"use": "ImageActionButton — per-option image control with empty/loading/thumbnail states + Change/Edit/Delete menu; emits intents",
"status": "available"
},
{
"ref": "info-box",
"as": "component",
"use": "InfoBox — settings-only tinted callout (information/success/warning/error), optionally dismissible with persisted state; bespoke, not Ant Alert",
"status": "available"
}
],
"componentBacklog": "Cleared — every settings-lab composed control now ships in the DS as a first-class artifact (contract + lib + parts + conformance): CountedInput, CountedTextarea, NumberWithUnit, CardSelect, ModeField, InfoBox, NumberedItem, ImageActionButton, ImageDropzone, OptionRow and QuestionList, alongside the atomic controls (Switch, Select, Input, Tooltip=HelpTooltip, Dropdown=DropdownMenu) and <aha-settings-list> (SectionHeader + SettingRow + SubSettingGroup). Every composedOf entry now resolves to a real contract, so a fully-compliant settings surface is buildable purely by reuse. See the canonical settings-lab → DS mapping in parts/settings.guide.md.",
"rules": [
{
"id": "name-is-noun",
"rule": "Setting name is a short noun phrase — no leading verb (Enable/Allow/Show/Turn on). The control communicates the action; the label names what is acted on.",
"ref": [
"SETTINGS-01",
"SETTINGS-40"
]
},
{
"id": "toggle-vs-checkbox",
"rule": "Toggle when the change takes effect immediately; checkbox when options are saved together as a group or express consent. Never mix toggles and checkboxes in one group.",
"ref": [
"SETTINGS-11"
]
},
{
"id": "help-text-is-exception",
"rule": "Help text only for a must-see consequence that is hard to undo (≤90 chars, states the consequence). Default is ZERO help lines per panel; everything else is a ? tooltip or nothing. Never both channels on one setting.",
"ref": [
"SETTINGS-02",
"SETTINGS-28",
"SETTINGS-29"
]
},
{
"id": "help-glyph",
"rule": "The help trigger is a question-mark (?) glyph from the shared Icon — never an info circle (ⓘ). Every ? in a panel is the same glyph, size, and tooltip placement; the tooltip is the shared dark-navy tooltip with its arrow anchored to the icon.",
"ref": [
"SETTINGS-26",
"SETTINGS-30",
"SETTINGS-35"
]
},
{
"id": "spacing-not-dividers",
"rule": "Hierarchy and separation come from --aha-size* spacing tokens (4 name→help, 8 sub→parent, 16 sibling, 32 group, 48 danger zone) — never divider lines, never raw px.",
"ref": [
"SETTINGS-13"
]
},
{
"id": "no-container-around-plain-settings",
"rule": "Plain settings and groups sit directly on the panel surface, separated by spacing — never wrapped in a card or tinted/bordered container. Card styling is reserved for genuinely selectable items and the repeatable composite-item wrapper.",
"ref": [
"SETTINGS-15"
]
},
{
"id": "group-shape",
"rule": "A group has 2–6 settings. Header is a 1–3 word noun phrase, sentence case, no colon. Only the group header carries weight — semibold (600, the SectionHeader weight); member/single-control labels are regular weight (400).",
"ref": [
"SETTINGS-05",
"SETTINGS-06",
"SETTINGS-37"
]
},
{
"id": "map-composed-controls",
"rule": "Build a settings surface by reusing the canonical composed controls — SectionHeader, SettingRow, SubSettingGroup, HelpTooltip, ModeField, CountedInput/CountedTextarea, NumberWithUnit, CardSelect, OptionRow, NumberedItem, QuestionList, ImageDropzone/ImageActionButton, InfoBox, DropdownMenu — mapped to their DS form (parts/settings.guide.md). Never hand-roll a settings control that already exists in the library; a control the DS does not yet ship is a tracked backlog gap (composedOf), not licence to re-implement it privately.",
"ref": [
"SETTINGS-15",
"SETTINGS-33"
]
},
{
"id": "sub-settings",
"rule": "A dependent sub-setting is HIDDEN (not disabled) when its parent is off. When shown it reads as nested — indented and de-emphasised — never as a top-level peer and never bracketed by its own separators.",
"ref": [
"SETTINGS-07",
"SETTINGS-14"
]
},
{
"id": "plan-gated",
"rule": "A plan-gated setting stays visible but locked (crown badge) — never hidden. The badge opens the shared Paywall popover (owned by aha-design-paywall), not a bespoke one.",
"ref": [
"SETTINGS-08"
]
},
{
"id": "danger-zone",
"rule": "Dangerous/irreversible settings sit last, set apart by the largest gap (48). The CTA is the danger Button (colorError, no hardcoded hex); red never on the label. Any irreversible action requires a confirmation modal.",
"ref": [
"SETTINGS-09",
"SETTINGS-10",
"SETTINGS-12"
]
},
{
"id": "control-placement",
"rule": "A narrow control sits inline with its label (label left / control right); a wide control (textarea, wide select, radio group, image dropzone) drops below it. Never stack a narrow control or cram a wide one inline.",
"ref": [
"SETTINGS-47"
]
},
{
"id": "no-empty-height",
"rule": "Every object occupies only its real content height. A container that renders nothing visible is removed or collapsed to zero — never left holding space (the #1 source of editor mystery-gaps).",
"ref": [
"SETTINGS-19"
]
}
],
"shipsCode": false,
"reuse": null
},
{
"generatedFrom": "guidelines/status-badges.json",
"kind": "guideline",
"pattern": "Status badges",
"slug": "status-badges",
"summary": "How to build a status/state pill on a domain object — dot + localised label, the aha-status-pill class convention, the role=status + aria-label contract, i18n labels, and colour driven by the modifier class — from existing components.",
"skillRef": {
"build": "aha-design:aha-design-status-badges",
"judge": "aha-design:aha-design-status-badges-judge",
"note": "The DS now OWNS this build ruleset — it is the single source of truth and the plugin skill will be generated FROM this pattern. The skill remains the home for rationale, worked BAD/GOOD examples, the judge verdicts and evals; this pattern distils the enforceable construction rules and links each back by ref (STATUS-xx)."
},
"surfaces": [
"dashboard",
"admin",
"tools"
],
"surfaceChoice": [
{
"surface": "Dashboard list / card",
"useFor": "the lifecycle state of a survey, presentation, or collector row",
"example": "draft / published / closed / archived on a survey card"
},
{
"surface": "Admin / ops table",
"useFor": "an enum-driven state column on a domain object",
"example": "active / inactive on an account row"
},
{
"surface": "Detail header",
"useFor": "the current state of the object being viewed",
"example": "a published badge beside a presentation title"
}
],
"composedOf": [
{
"ref": "icon",
"as": "component",
"use": "any glyph rendered beside the status (owned by aha-design-icons) — the dot itself is decorative CSS, not a glyph",
"status": "available"
},
{
"ref": "--aha-color-success",
"as": "token",
"use": "the semantic colour a 'published'/'active' state maps to via its modifier class — never an inline hex",
"status": "available"
},
{
"ref": "--aha-color-error",
"as": "token",
"use": "the semantic colour a 'closed'/failed state maps to via its modifier class — never an inline hex",
"status": "available"
},
{
"ref": "status-pill",
"as": "component",
"use": "the StatusBadge wrapper itself (aha-status-pill + dot + label) — the canonical element this pattern describes",
"status": "missing"
},
{
"ref": "tag",
"as": "component",
"use": "the generic Tag for free-form labels this pattern explicitly is NOT (contrast target — a status badge is a fixed, semantic set, not a free-form Tag)",
"status": "available"
},
{
"ref": "badge",
"as": "component",
"use": "the generic count/notification Badge this pattern explicitly is NOT (contrast target — a status badge conveys state, not a count)",
"status": "available"
}
],
"componentBacklog": "composedOf marks 3 components this pattern references that the DS does not yet ship (status-pill, tag, badge). The status pill (StatusBadge / aha-status-pill family) is the primitive this pattern is built around and currently lives only in the product repo (features/dashboard/StatusBadge.tsx) — per the charter, a pattern that leans on a missing component is the signal to add it HERE. tag and badge are named as the primitives this pattern is NOT, so the roadmap keeps them distinct.",
"rules": [
{
"id": "enum-only",
"rule": "Render a status pill only for a value from a known status enum (e.g. SurveyStatus: draft / published / closed / archived). Never use it for free-form labels, counts, or notification dots — those are AntD Badge/Tag territory.",
"ref": [
"STATUS-01"
]
},
{
"id": "anatomy",
"rule": "A status pill is a single inline element containing exactly two parts: a dot span (aha-status-pill__dot) carrying the state colour, followed by the localised label text.",
"ref": [
"STATUS-02"
]
},
{
"id": "class-convention",
"rule": "Markup is the base class aha-status-pill plus a per-state modifier aha-status-pill--{status}. Use the class convention, not a one-off prop or inline style.",
"ref": [
"STATUS-02"
]
},
{
"id": "colour-by-modifier",
"rule": "Colour of the pill and its dot is driven by the --{status} modifier class, keeping every state's colour in one stylesheet and on semantic tokens. Never set the colour inline with a hardcoded hex.",
"ref": [
"STATUS-03"
]
},
{
"id": "label-from-i18n",
"rule": "The visible label is always sourced from i18n — t(`survey_status.${status}`) or the equivalent namespace for the object. Never hardcode the visible string.",
"ref": [
"STATUS-04"
]
},
{
"id": "new-state-two-parts",
"rule": "Adding a new state means adding BOTH a translation key AND a --{status} modifier class — neither alone is complete.",
"ref": [
"STATUS-03",
"STATUS-04"
]
},
{
"id": "a11y-contract",
"rule": "The element carries role=\"status\" and an aria-label equal to the visible label, so the state is announced and not conveyed by colour alone.",
"ref": [
"STATUS-05"
]
},
{
"id": "not-colour-alone",
"rule": "State is never conveyed by the dot colour alone — the text label is always present and is the source of truth; the dot is decorative.",
"ref": [
"STATUS-06"
]
},
{
"id": "test-id",
"rule": "Carry a data-testid of status-badge-${status} on the pill so the rendered state is assertable.",
"ref": [
"STATUS-02"
]
}
],
"shipsCode": false,
"reuse": null
},
{
"generatedFrom": "guidelines/ux-writing.json",
"kind": "guideline",
"pattern": "UX writing",
"slug": "ux-writing",
"summary": "The WORDS in every product surface — sentence-case casing, one canonical label, and specific / actionable / blameless error-empty-feedback microcopy — applied to the labels and copy the existing components render.",
"skillRef": {
"build": "aha-design:aha-design-ux-writing",
"judge": "aha-design:aha-design-ux-writing-judge",
"note": "The skill is the source of truth for rationale, the worked before/after library (references/patterns.md), and the full UXW-1..UXW-7 assertions. This pattern distils the enforceable subset and links each rule back by ref. The skill is a deliberately growing rulebook — casing and error/empty/feedback microcopy are written; action-label style, terminology glossary, tooltips, onboarding, and confirmation copy are stubbed there and land here as they're written."
},
"surfaces": [
"editor",
"dashboard",
"settings",
"audience"
],
"surfaceChoice": [
{
"surface": "Label / CTA / menu / tab",
"useFor": "the name of an action or destination — sentence case, one canonical form reused everywhere",
"example": "View report, Create presentation, Multiple choice"
},
{
"surface": "Heading / dialog title",
"useFor": "section and dialog titles — sentence case; a title carries the OUTCOME",
"example": "We couldn't open your presentation"
},
{
"surface": "Empty state",
"useFor": "success-but-no-data — encouraging copy + a primary create action, never an apology",
"example": "No presentations yet. Create your first one."
},
{
"surface": "Error / load-failure message",
"useFor": "a recoverable problem — name the object, give the next step, expose the recovery control",
"example": "Couldn't load some presentations. Check your connection and try again."
}
],
"composedOf": [
{
"ref": "button",
"as": "component",
"use": "action/CTA labels the rule cases (sentence case, canonical form) and the in-message recovery control (Retry/Reload/Log in again)",
"status": "available"
},
{
"ref": "icon",
"as": "component",
"use": "the glyph beside a message is functional only; the words, not the glyph, carry the outcome",
"status": "available"
},
{
"ref": "--aha-color-error",
"as": "token",
"use": "a destructive/data-loss or fatal failure message binds its emphasis to the error role — wording severity matches the colour, never a hardcoded red",
"status": "available"
},
{
"ref": "--aha-color-warning",
"as": "token",
"use": "a transient retryable hiccup reads as warning/info tone, not a blaring error — the word severity matches a calmer token",
"status": "available"
},
{
"ref": "alert",
"as": "component",
"use": "recovery-required copy lives in an inline Alert that survives, not an auto-dismissing toast — the shared DS V3 Alert, never a bare AntD Alert",
"status": "missing"
},
{
"ref": "toast",
"as": "component",
"use": "transient, no-recovery-needed confirmations only; recovery copy must not live here alone",
"status": "missing"
},
{
"ref": "empty-state",
"as": "component",
"use": "the success-no-data shell whose copy is encouraging + a primary create action, kept distinct from a failure",
"status": "missing"
},
{
"ref": "error-page",
"as": "component",
"use": "the full-page failure whose headline names the object + outcome and offers a next step",
"status": "missing"
}
],
"componentBacklog": "composedOf marks 4 components this pattern's copy lives inside that the DS does not yet ship (alert, toast, empty-state, error-page). Per the charter, a pattern that leans on a missing component is a signal to add it HERE — this is the pattern surfacing the component roadmap for the surfaces that host product copy.",
"rules": [
{
"id": "sentence-case-default",
"rule": "Sentence case is the house default for ALL product UI text — labels, CTAs, menu/tab/nav items, slide-type and interactive-component names, headings, tooltips, helper text, dialog titles, empty states, and messages. Capitalize the first letter only; keep proper nouns, brand names, and acronyms as-is. 'View report' not 'View Report'; 'Multiple choice' / 'Word cloud' / 'Open-ended' / 'Spinner wheel' / 'Q&A' not Title Case.",
"ref": [
"UXW-1"
]
},
{
"id": "one-canonical-label",
"rule": "Pick ONE canonical cased form per label and use it in every place it appears — button, menu, tooltip, analytics label. Resolve AI drift (card says 'View Report', menu says 'View report') to the sentence-case 'View report'; never ship both forms.",
"ref": [
"UXW-1"
]
},
{
"id": "caps-label-exception",
"rule": "The styled all-caps eyebrow / overline / chip label (the 'capitalized' typography role, owned by aha-design-typography) is intentionally uppercased — it is a deliberate style, not a casing mistake. Don't 'fix' it to sentence case, and don't cite it to justify Title Case elsewhere.",
"ref": [
"UXW-1"
]
},
{
"id": "message-anatomy",
"rule": "A message names the OUTCOME (what happened, always), adds the CAUSE only when it changes what the user does, always states the NEXT step for anything recoverable, and exposes the ACTION when a one-tap recovery exists. Title = the OUTCOME; body = the NEXT step — never inverted.",
"ref": [
"UXW-2",
"UXW-3",
"UXW-7"
]
},
{
"id": "be-specific",
"rule": "Name the specific object and what happened — a concrete count over 'some'. 'Couldn't load some presentations.' not 'Some items could not be loaded.' Never ship a standalone 'Something went wrong', 'An error occurred', 'Oops', 'Unexpected error', 'Some items could not be loaded', or bare 'Failed'.",
"ref": [
"UXW-2"
]
},
{
"id": "never-dead-end",
"rule": "Any message about a recoverable problem must tell the user what to do now AND expose the recovery as a real control when a one-tap recovery exists (Retry, Reload, Log in again, Go back, Contact support). A message with an outcome but no next step is a dead-end.",
"ref": [
"UXW-3"
]
},
{
"id": "human-blameless",
"rule": "Be blameless, jargon-free, and front-loaded. Don't blame the user ('That file type isn't supported — try a PNG or JPG', not 'You entered an invalid file'). Keep raw codes / stack traces / HTTP status out of the headline — tuck any code into secondary detail. Lead with the user's goal, not the system's failure.",
"ref": [
"UXW-4"
]
},
{
"id": "match-severity",
"rule": "Match wording severity to real impact. A transient, retryable hiccup is a calm info/warning with 'Try again', not a blaring red 'Error' — over-alarming trains users to ignore real errors. A destructive or data-loss outcome states the stakes plainly ('This permanently deletes 12 responses.') without crying wolf or burying the lede.",
"ref": [
"UXW-5"
]
},
{
"id": "empty-is-not-error",
"rule": "An empty state (success, no data) is encouraging with a primary next action ('No presentations yet. Create your first one.') — never an apology or a red alarm. A load failure follows be-specific + never-dead-end (name it, offer Retry) and is never disguised as emptiness, which would hide the Retry the user needs.",
"ref": [
"UXW-6"
]
},
{
"id": "copy-survives",
"rule": "Recovery-required copy must not live only in an auto-dismissing toast — it belongs in an inline Alert or on the page so the words survive. When a message has a title + body, the title carries the outcome and the body the next step (a shapeless single-string message isn't penalised on shape — its vagueness is a be-specific problem).",
"ref": [
"UXW-7"
]
}
],
"shipsCode": false,
"reuse": null
}
]