AhaSlides Design
v0.52.0React · Vue · Lit

Patterns · composition guide

Audience iframe pattern

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.

◆ generated from guidelines/audience.json + parts/audience.guide.md — do not edit by hand

The audience iframe is the phone-side counterpart to the presenter canvas: a separate entry, loaded at its own route, rendered mobile-first in its own cross-origin document. It ships NO new component — it reuses Button, Checkbox and Icon, paints with the deck palette / textColour / fontFamily the host forwards via window.xprops, and bundles semantic state colours from the design-system package because no --aha-* vars cross the iframe boundary. The narrative 'why', the worked BAD/GOOD examples and the C1..C22 judge criteria live in the aha-design-audience skill; this artifact carries only the enforceable construction rules.

Based on

The rationale, worked examples, and the full assertion set live in the design skill — this pattern distils the enforceable subset and links each rule back to it.

build aha-design:aha-design-audience judge aha-design:aha-design-audience-judge

Choose the surface

SurfaceUse forExample
Framed (default)most slide types — host paints the question image, title, description, audio button and countdown above your iframe; your iframe owns the answer area onlypoll options, quiz answers, scale slider, word-cloud input
Full-canvas (enableFullScreen: true)a slide needing a bespoke header the host chrome can't give — you render the whole surface including the titleintro slide, branded reveal, fullscreen quote

Composed of

What a compliant audience iframe surface reuses from this design system — the pattern's link into the component graph.

ReusesKindForIn DS?
buttoncomponentthe 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)available
checkboxcomponentmulti-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)available
iconcomponentthe 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)available
--aha-colorSuccesstokenthe correct / submitted / success function colour — bundled into the plugin build, never derived from the deck palette or a lookalike accentmissing
--aha-colorErrortokenthe incorrect / validation-error / destructive function colour — bundled into the plugin buildmissing
--aha-colorWarningtokencaution / are-you-sure prompt body — bundled into the plugin buildmissing
--aha-colorInfotokeninformational note — bundled into the plugin buildmissing
radiocomponentsingle-select option rows — the checked disc takes the deck accent, the selected-row ring takes textColourmissing
slidercomponentscale / bid input — rail flush with min/max labels, filled portion carries the deck-ink hairline the rail hasmissing
textareacomponentopen-ended / idea text input — font-size 16px to dodge the iOS auto-zoom-on-focusmissing
toastcomponentthe 'Submitted' announcement via the host's showToastSuccess — never an in-iframe banner (host paints it outside the iframe)missing
modalcomponentany modal interaction via the host's openPluginModal bottom-sheet — host paints the chrome, you fill the bodymissing
skeletoncomponentthe neutral cold-start placeholder shown behind a themeReady guard while window.xprops populatesmissing
instructioncomponentthe ONE shared micro-instruction component reused across every slide so the hint never drifts in size/weight/colourmissing
⚠︎ 11 referenced components not yet in the DS — --aha-colorSuccess, --aha-colorError, --aha-colorWarning, --aha-colorInfo, radio, slider, textarea, toast, modal, skeleton, instruction. 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

The shippable checklist — each rule traces to an assertion in aha-design:aha-design-audience.

RuleSkill assertion
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.C1 §intro
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).C4 §1
Be fluid up to the host's max-width cap (~710–840px framed); don't set or fight the width.§1
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.C5 §1
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.§1 §2
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).C19 §1
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°).C8 §1a
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).C9 §1a
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.§1b
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.§1b
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.C2 §2
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.C3 §2
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).C10 §2
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.C11 §2
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.C12 §2 §7
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'.C16 §2
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.C17 §2
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.C17 §2
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.C18 §2
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.C20 §2
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.C21 §2
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.C2 §2a
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.C6 §2a
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'.C6 §2a
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.§2b
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.§2b
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.C19 §2b
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).C22 §3
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).C13 §4
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.§4
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.C14 §4 §6
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.C15 §5
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.C15 §5
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.C15 §5
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.C15 §5
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.C15 §5
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.§5
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.§5 §6
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.§5
Label every control via t(...) — never hard-coded 'Submit'/'Send'/'Vote'. All user-visible text translates per xprops.presentation.language.§5 §9
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.C20-util §6
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.§6
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.§6
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.§6
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.§7
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).§7 §9
Touch targets are roughly finger-sized (~44–48px, iOS 44pt / Android 48dp) as a recommended range; leave ≥8px between tappables to avoid mis-taps.§8
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).§8
Keep a visible focus ring — keyboard users on tablets/laptops still join; don't remove the default ring without replacing it.§8
Don't use motion alone to convey meaning — it's lost on bad connections and for prefers-reduced-motion users.§8
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.§8 §4
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.§8
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.§9
Format numbers / percentages with Intl.NumberFormat(locale), not .toFixed(...) + '%' (some locales use ',' as the decimal separator).§9

Composition code

Doc-only — this pattern ships no wrapper from this repo. Doc-only pattern — ships no composition wrapper from this repo. The audience-iframe building blocks (submit button, option rows, instruction line, skeleton, the xprops theme binding and useDeckFont helper) live in the slide-type-plugin repos and are painted from window.xprops per plugin; they are not importable across the cross-origin iframe boundary, and semantic state colours are bundled per-plugin from the design-system package. If a shared audience wrapper ever graduates into this DS, add a reuse block and it is gated like a composite.

Guide

Audience iframe — composition guide

Distilled from aha-design-audience. The skill holds the full rationale, worked BAD/GOOD examples, the AHAM-xxx bug references, and the C1..C22 judge criteria plus the pre-ship checklist; this guide is the shippable construction checklist. When the two ever disagree, the skill wins and this file is regenerated. Evals (the binary PASS/FAIL verdict) stay in the skill and its sister judge aha-design-audience-judge.

The audience iframe is what a participant sees in their own browser after joining via app.ahaslides.com/join/<code> — the phone-side counterpart to the presenter canvas. It ships no new component: it reuses Button, Checkbox and Icon, paints with the deck palette / textColour / fontFamily the host forwards via window.xprops, and bundles semantic state colours from the design-system package because no --aha-* vars cross the iframe boundary. This pattern carries the conventions that keep every slide type's audience view reading as one product.

Pick the surface first

A plugin declares three separate URLs in its manifest — canvasUrl, editorUrl, audienceUrl — and each is its own entry page. Build the audienceUrl entry only. Same plugin repo, separate entry files; never branch one shared component with if (audience) mobile else canvas — the host loads completely separate documents.

Then pick the mode via setting.enableFullScreen in the manifest:

SurfaceUse for
Framed (default)Most slide types. Host paints the question image, title, description, audio button and countdown progress *above* your iframe; your iframe owns the answer area only.
Full-canvas (enableFullScreen: true)A slide needing a bespoke header the host chrome can't give — an intro slide, a branded reveal, a fullscreen quote. You render the whole surface, including the title.

Make the choice explicit in the manifest. Mixed mode (framed + your own title) duplicates the host's title and looks broken.

How the host talks to the iframe

The iframe runs in its own cross-origin document via the same Zoid model as the canvas, but the host forwards a smaller subset through window.xprops.

*Forwarded (the raw materials you paint with):*

window.xprops pathWhat it is
xprops.slide.textColourPrimary text colour
xprops.presentation.fontFamilyDeck's font (NAME only — see "Load the deck font")
xprops.presentation.languageDeck's locale code
xprops.presentationColorPaletteAccent palette — array of CSS colour strings
xprops.presentationLighterColorPaletteLighter-shade palette sibling
xprops.currentUser.presenterLanguageFallback locale

NOT forwarded — don't expect them: baseColour (you choose your own background),

backgroundImage, any AntD semantic token, any host CSS variable. --aha-colorSuccess and friends do not exist inside this iframe.

Host UI utilities (host renders them outside the iframe — use them, don't reinvent):

showToastInfo / showToastSuccess / showToastError, openPluginModal / closePluginModal, onSubmitButtonHeightChange(h), the timeLimit countdown, scrollTo(y) / getWindowHeight(), and the required onHeightChange(h).

There is no shared UI kit injected into the iframe. You build your own input components with your stack — but paint them with the palette / textColour / fontFamily the host hands you so they look native to AhaSlides.

Shape — mobile-first, single column, height-by-content

  • One vertical 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).

  • Fluid width. Host enforces a max-width (~710–840px framed); be fluid up to the cap,

don't set or fight it.

  • Height by content. Report 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. Wrong reports cause a scrollbar inside the iframe or empty padding around it.

  • Cold-start. 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.

*Native type scale — match it exactly (not "at least"):*

RoleValue
Body / option / button label14px
Help text / captions / secondary12px
Question / header22px bold (host-painted in framed mode)
Form input (<input> / <textarea>)16px — the one exception, dodges iOS auto-zoom-on-focus
Body line-height1.5

Spacing follows a multiple-of-8 rhythm (8 / 16 / 24).

Transforms and bounded scrolling stay inside the reported box

onHeightChange reports your resting layout box; the host paints nothing outside it.

  • A transform that paints outside the box (swipe tilt, drag ghost, hover pop, settle

bounce) gets clipped. Budget a little headroom and keep the transform small enough that its most-extreme frame's bounding box still fits the reported height. Prefer a cheap cap (an 8° tilt over 12°) over a bigger transform that forces a taller reported box.

  • A card that scrolls internally. Default is no inner scroll — let the host shrink-wrap.

The one sanctioned exception is a *bounded* card (a fixed-size swipe / flash / reveal card that must stay one screen): it MAY scroll its own overflow with no page-level scroll, and MUST show a fade cue on the still-hidden edge(s), dropped when it all fits. Fade with a CSS mask-image (fades to true transparency, works on any deck background) — never a coloured gradient overlay (there's no baseColour to match, so a coloured fade shows as a grey smear).

Swipe / drag gestures must stay smooth (60 fps)

  • Drop expensive continuous effects while moving. A backdrop-filter: blur(), a large

box-shadow, or any filter on the dragged element is re-sampled every frame — the single biggest cause of a janky swipe. Track a moving flag (dragging || flinging || springing-back) and null the effect out while it's true; restore on settle. Animate only compositor propertiestransform and opacity, never width / height / top / left / margin. Set will-change: transform.

  • Commit on velocity, not distance alone. Track pointer velocity (px/ms across

pointermoves) and commit when the gesture passes either the distance threshold or a flick-velocity threshold — so a fast flick flings instead of springing back.

Theme — palette for chart, bundle semantic for state

The same two-jobs split as the canvas, with less theme forwarded.

  • Job 1 — chart / visualization layer. Bar fills, ring segments, plot dots, selection

highlights carry magnitude/proportion — pull from xprops.presentationColorPalette (or presentationLighterColorPalette); pick the shade that contrasts your chosen background.

  • Job 2 — state indicator layer. Submitted ✓, error ✗, correct / incorrect, warning,

info must not depend on the deck palette and must not come from --aha-* vars. Bundle the function tokens into the plugin build from the design-system package, so the audience sees the same success-green / error-red as the presenter canvas.

TokenWhere it goes
colorSuccess✓ icon, "submitted" badge, correct-answer indicator
colorError✗ icon, validation error, incorrect-answer indicator, destructive
colorWarningcaution, "are you sure?" prompt body
colorInfoinformational note
  • Use the FUNCTION token, not a lookalike accent. colorSuccess (#16C49A), colorError

(#F5222D), colorWarning, colorInfo are the semantic functions. A bright brand accent that merely *looks* green/red (Bright Teal #20E8B5 as a tick) is wrong and fails contrast (~1.58:1 on white). Even correct colorSuccess on white is only ~2.23:1 — below 3:1 — so put a light success/error glyph on a bounded fill (a filled badge with a white ✓), not straight onto the light surface.

  • textColour is for text, not borders. Bind body text to xprops.slide.textColour.

Derive hairlines from color-mix(in srgb, currentColor 10%, transparent), never from textColour directly.

  • Choose your own background. No baseColour is forwarded — default to white / very

light (works on light and dark decks), or derive a surface from the luminance of textColour.

  • Load the deck font, don't just name it. 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 browser serif fallback (the AHAM-642 bug). Call useDeckFont(fontFamily) in this iframe — its own call, separate from the canvas — with 'Plus Jakarta Sans' as the fallback.

Selection controls track the deck (AHAM-701):

  • A checked Radio / Checkbox takes the deck ACCENT (presentationColorPalette[0], via a

custom property like --aha-control-accent), never Ant's fixed colorPrimary purple. A purple disc on a green deck is the FAIL.

  • The selected-answer ring / edge takes the deck's textColour (not a near-black or

a deepened accent) so it stays visible on a dark deck; the accent still marks the pick on the control and in label weight — the ring's only job is to stay visible.

  • Publishing the accent var is not optional. 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 — never publishing it — ships purple on every deck.

*Sliders:*

  • A filled slider / progress track carries 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.

  • A horizontal slider's rail sits flush with its min/max end labels — zero out Ant's

default slider margin (~margin: 10px 6px) so the rail runs edge-to-edge under the captions.

Section rhythm: distinct labelled sections sit ≥24px apart — wrap each section (its

label + its control) in its own tight flex 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.

Three tells that read as "AI-generated" — avoid all three

  • No pastel wash under same-hue ink. Don't pair a pastel/tinted background with icon +

text in the *same* hue. 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.

  • Corner radius ≤ 8px on rectangular content containers (option tiles, cards, panels, the

swipe card, the submit button) — including the OUTER wrapper, not just the tiles inside. 12 / 16 / 24px read toy-like. Intentionally circular elements (pills, chips, badges, avatars, dots, progress tracks) stay fully rounded.

  • No extra-bold weight. Text uses weight 400 or 600 only (the standard submit button is

600). No 700 / 800 / 900.

Text economy — say it once, in plain body text

  • Don't label a control with text the control already states. Two labelled True / False

buttons don't need a "True or False" header or an "OR" divider — the buttons *are* the prompt. Delete any label, header or divider that merely repeats an adjacent control; a word earns its place only if it says something the controls don't.

  • Micro-instructions are the shortest possible phrase, in the DEFAULT body. A hint like

"Tap or swipe to answer" is one line of 16px, regular (400), the deck's textColour — never a full-width pill, tinted card, shadowed chip or button-shaped box, and never semibold or muted-grey. Use ONE shared instruction component across every slide so the line never drifts in size / weight / colour between slides.

  • Primary copy stays at 16px, never text-sm. text-sm (14px) is legitimate ONLY on

secondary metadata beside a label — a vote-count chip, a percentage, a helper caption — never on the answer-option label or the question restatement.

Plugin self-renders submitted / waiting / correct-incorrect

For plugins the host shows no generic "Waiting…" screen — your iframe stays mounted and you render every post-submission state:

  • Just submitted — call xprops.showToastSuccess(t('audience.submitted')) for the

announcement (host paints a consistent toast outside the iframe); don't re-implement a "Submitted" banner inside the iframe. After the toast, transition to results bars / quiz feedback / waiting.

  • Waiting for the next slide — a calm holding state on the same surface; gentle motion if

any, no noisy spinners.

  • Quiz correct / incorrect — pair the semantic colour with a non-colour cue (✓ / ✗ plus the

answer text). Bar fills stay in palette colour; the indicator carries the state.

  • Pre-submission empty state — show the input affordance with placeholder copy ("Tap an

option to vote"), never a giant "0" or empty chart (reads as broken).

Transitions must be smooth — re-report height via onHeightChange when a state change legitimately changes content height, but avoid layout jumps >16px between adjacent states.

The standard submit button — match the spec, don't import the component

Every built-in audience slide type uses ONE submit-button look (the host's aha-antd-button in the primary-alt variant). That component does not cross the iframe boundary, so you rebuild your own to match the spec:

*Visual spec*

  • Full-width block, fills the answer area.
  • Height ~44–48px (the touch-target range).
  • Soft rounded corners ≤ 8px (rounded-lg) — no sharp 0px, no pill unless that's the deck style.
  • Background = the deck's first palette colour xprops.presentationColorPalette[0] (or a

presentationLighterColorPalette shade). Never hard-code a brand colour, never leave a bare type="primary" on the fixed purple (AHAM-701).

  • Text = a readable ink chosen against the fill by luminance — white on a saturated/dark

shade, dark ink on a light shade. Don't hard-code white (it disappears on a light shade). Semi-bold (600).

  • Edge = a hairline at ~10% of textColour, so a purple button on a purple deck holds its shape.
  • Position = end of the answer-area block, ~16px below (host may paint a sticky "Scroll to

submit" pill above when you opt in).

*Interaction states — the part that breaks if skipped*

1. Invalid inputdisabled as 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. 2. Submitting → an inline spinner inside the button — it does not disappear, change height, or move (no layout jump); lock it to prevent double-submit. 3. Host stopSubmission → a locked state: lock icon + i18n "submission closed" label replacing the submit label; button stays present (no reflow) but uninteractive. 4. After submit completes — scored quiz → a circular badge (green ✓ correct / red ✗ incorrect, using the bundled semantic colours); non-scored → fire showToastSuccess(...) and transition to the plugin-rendered submitted / waiting state.

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.

*Wiring you must include*

  • onSubmitButtonHeightChange(h) — the submit button's y-offset, so the host can paint the

floating "Scroll to submit" pill when it sits below the fold.

  • data-testid on every interactive element, kebab-case, scoped to the slide type

(audience-<slidetype>-submit-button, audience-<slidetype>-option-3).

  • Label via t(...) — never hard-coded "Submit" / "Send" / "Vote".

Host utilities — use them, don't reinvent

  • Toast (showToast*) for any transient confirmation or lightweight error — if you need a

toast, use the host's, never an in-iframe banner. Recommended for the "Submitted" notice so every slide type confirms the same way.

  • Bottom-sheet modal (openPluginModal / closePluginModal) for any modal — host paints the

chrome, you fill the body. Don't build a custom overlay inside the iframe (wrong layer).

  • Sticky "Scroll to submit" via onSubmitButtonHeightChange(h).
  • Countdown (timeLimit): in framed mode the host already paints the countdown progress bar

above the iframe — don't duplicate it; use xprops.timeLimit as logic only (e.g. lock submit when timeLimit <= 0). The host owns the expiry notice ("Time's up") — don't draw your own deadline card (it double-announces and drifts from the host clock). In the rare full-canvas case, render the countdown bound to the live xprops.timeLimit, never static text or your own setInterval. Never write static time text ("30 seconds", "1 phút", "đếm ngược 30s") anywhere.

  • Layout helpersscrollTo(y), getWindowHeight().

Out of reach — don't reinvent or assume

Owned by the host; don't render them and don't assume they're broken if you can't reach them:

  • Edge states — lost connection, poll closed, kicked, last slide, slide skipped: the host

overlays its own screen. Don't draw your own.

  • Logo / watermark / branding / plan-gating — host layers outside the iframe.
  • Locale sourcexprops.presentation.language (presenter's choice), not the audience

browser. Don't override; just translate.

  • RTL flip — the host does not flip layout by language; if your slide type needs RTL, handle

it yourself (dir="rtl" on the root, logical CSS).

  • BackgroundbaseColour and backgroundImage don't reach the iframe.

Touch targets + accessibility

  • Touch targets roughly finger-sized (~44–48px, iOS 44pt / Android 48dp) — a recommended

range. ≥8px between tappables to avoid mis-taps.

  • WCAG AA against your chosen background — 4.5:1 text, 3:1 large text and meaningful shapes —

verified on both light and dark decks.

  • Focus visible — keyboard users on tablets / laptops still join; don't remove the ring

without replacing it.

  • No motion to convey meaning — lost on bad connections and for prefers-reduced-motion.
  • Non-colour cue paired with every colour-bearing signal — prefer an icon (✓ / ✗) over a

verbose word chip; compact and language-neutral.

  • Honest chart geometry — reserve a fixed-width slot on every row for a sometimes-present

indicator (e.g. a leader trophy) so all bar tracks share width; a 55% bar must occupy 55%.

Language — initialise from xprops, never browser

  • The iframe has its own i18n instance (not inherited). Initialise locale from

xprops.presentation.language with xprops.currentUser.presenterLanguage as fallback, and subscribe to host language changes mid-session.

  • Format numbers / percentages with Intl.NumberFormat(locale) — not .toFixed(...) + '%'

(some locales use , as the decimal separator).

  • All user-visible text via t(...) — no hard-coded English in templates, buttons,

placeholders, aria-labels, or tooltips.


*Full detail, screenshots, worked BAD/GOOD examples and the AHAM-xxx bug context live in the

aha-design-audience skill. Self-check any built audience surface with aha-design-audience-judge — it emits a binary PASS/FAIL across the same contracts (C1..C22) with a Where / Evidence / Fix block per failure — and fix every FAIL before shipping.*