AhaSlides Design
v0.52.0React · Vue · Lit

Patterns · composition guide

Canvas pattern

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.

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

The canvas is the visual surface a slide type renders into — one iframe shared by the editor preview and the live presenting/casting screen. It drifts fastest because each slide is its own cross-origin bundle that can't see the host theme. This pattern ships NO new component: it documents the CONVENTIONS that make every slide read on light, dark, and image themes, stay legible from the back of a hall, and reuse the host control bar and the component library rather than hand-rolling. The narrative 'why', the worked BAD/GOOD examples, and the full §1–§9 rationale live in the aha-design-canvas skill; this artifact carries only what the repo can enforce.

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-canvas judge aha-design:aha-design-canvas-judge

Choose the surface

SurfaceUse forExample
Framed (setting.enableFullScreen: false)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 resta poll answer area, a quiz choice grid
Full-canvas (setting.enableFullScreen: true)a bespoke layout that owns the whole stage; the host hides its chrome and you render the title (slideProps.title) yourselfThis or That, a custom game board
Presenter control bar (NCB, outside the iframe)slide-specific actions (Next / Summarise / Previous) — declared in the plugin manifest, painted by the host as <aha-antd-button>, never rendered inside the canvasIdea board's Previous / Next: vote / Summarise

Composed of

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

ReusesKindForIn DS?
buttoncomponentany 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>available
iconcomponenton-canvas glyphs — the ✓/✗ verdict mark, trophy, status indicators — a design-system glyph, never an inline <svg> or a foreign icon setavailable
--aha-colorSuccesstokenthe ✓ / correct-answer / validation-passed mark — the semantic function token (#16C49A) bundled into the plugin build, never a lookalike brand accentavailable
--aha-colorErrortokenthe ✗ / incorrect-answer / destructive-action mark — the semantic function token (#F5222D), bundled, never hard-codedavailable
--aha-colorWarningtokenthe caution / 'are you sure?' indicator mark — bundled semantic function tokenavailable
--aha-colorInfotokenthe informational badge mark — bundled semantic function tokenavailable
--aha-borderRadiustokenthe 8px radius cap on rectangular content containers (cards, answer boxes, option tiles, panels) — rounded-lg, never rounded-2xl/3xlavailable
aha-antd-buttoncomponentthe 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 directlymissing
kbd-chipcomponentthe in-canvas keyboard-shortcut indicator — a single-character key renders as a SQUARE (equal box, radius 4), multi-char keys grow wider; shared across slidesmissing
tooltipcomponentan icon-only in-canvas button's name + shortcut tooltip (e.g. 'Mute music (M)') — shared tooltip, not a hand-rolled title-only hoveravailable
focus-trapcomponenta 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 closemissing
use-deck-fontcomponentthe 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 Sansmissing
⚠︎ 4 referenced components not yet in the DS — aha-antd-button, kbd-chip, focus-trap, use-deck-font. 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

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

RuleSkill assertion
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).§1 C9
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.§1 C9
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.§2 C1
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.§2 C1 C5
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.§2 C14
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.§2 C5 C15
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.§2a C2
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.§2a C2 C4
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.§2a C6
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.§2b C7 C8
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.§2c C2
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.§3 C10 C11
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.§3 C10 C11
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.§3 C12
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.)§3 C12
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.§3 C13
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.§3 C6
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.§3 C2
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.§3 C10
Don't depend on fast or subtle motion to convey meaning.§3 C10
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.§4 C3
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).§4 C3
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.§4 C16
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).§4 C3 C16
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.§5 C3
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.§5 C3
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.§5 C3
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.§5 C3
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.§5 C3 C4
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.§6 C13
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).§6 C13
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).§6 C13
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.§6 C13
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.§7 C1
Hand the right-panel settings form off to aha-design-settings — the canvas pattern covers only the visual surface, not the settings form.§4 §8

Composition code

Doc-only — this pattern ships no wrapper from this repo. Doc-only pattern — ships no composition wrapper from this repo. A slide type's canvas is a cross-origin iframe app (AhaSlides renders each slide plugin in its own iframe, reading host state via Zoid xprops on window.xprops), so the real composition lives in the slide-type-creator / slide plugin codebase, not here. The canonical correct pattern is the shared fixture aha-design-canvas-judge/evals/fixtures/good-fill-in-the-blanks/ (the judge's good-control) — diff a new canvas against it. If a shared canvas helper (useDeckFont, a focus-trap, a kbd-chip) graduates into this DS, add a reuse block and it is gated like a composite.

Guide

Canvas — composition guide

Distilled from aha-design-canvas. The skill holds the full rationale, worked BAD/GOOD examples, the §1–§9 sections, and the canonical good-fill-in-the-blanks fixture; the design system now OWNS this build ruleset (this guide is its single source of truth). The judge, its criteria (C1..C16), and the eval harness stay in the skill. When the two ever disagree, the skill wins and this file is regenerated.

The canvas is the visual surface of a slide type — what the audience and presenter see. A slide type is an embedded iframe app that renders into one canvas in two states — the editor preview and the live presenting/casting screen — sharing a single iframe and differing only by the presentation.presenting flag. Design for both at once. This pattern adds no new control; it reuses the shared Icon, the component-library Button, and the semantic --aha-color* tokens, and defines the conventions that keep every slide reading as one product on every theme.

Out of scope: the right-panel settings form (→ aha-design-settings), and the technical wiring behind the visuals (how height is reported, how actions are delivered, how setting flags are declared).

Pick the container first

The choice lives in the plugin manifest (plugin-manifest.json) under setting.enableFullScreen. Make it explicit — an absent setting reads as "we forgot", not "we chose".

ContainerUse for
Framed (enableFullScreen: false)A "question + answer area" slide that should look like every built-in type. The host renders the title, description, and question image; your iframe fills the rest.
Full-canvas (enableFullScreen: true)A bespoke layout that owns the whole stage. The host hides its chrome (xprops.fullCanvas = true); you render the title/header yourself.
Presenter control bar (NCB, outside the iframe)Slide-specific actions (Next / Summarise / Previous). Declared in the manifest, painted by the host — never rendered in-canvas.
  • Never enableFullScreen: true + enableQuestionTitle: true together. Full-canvas

hides the host title bar, so the enableQuestionTitle value renders nowhere and vanishes. Pick framed (enableFullScreen off, enableQuestionTitle on) so the host paints the title, OR full-canvas (enableFullScreen on, render slideProps.title yourself, enableQuestionTitle off).

  • A framed slide's root stays transparent — it must not render its own title (the host

draws it) nor paint a slide-wide background/backdrop/scrim; let the deck theme show through. The only opaque fill it may paint is a surface bounded to its content (a card, a chip, a caption bar).

  • A full-canvas slide MUST render slideProps.title (slideProps.value?.title ?? '')

— the host hides its title bar, so rendering only per-item prompts/labels silently drops the presenter's title.

The stage is a 16:9 box

Slides render inside a 16:9 stage (1280×720 reference) that the host transform: scale()s as one unit to fit any screen. Design against the proportions, not pixels.

  • Responsive, nothing clipped. Use responsive layout so content holds up scaled large

(projector) or small (preview) and never overflows.

  • The iframe clips everything past its box — box-shadows and glows included. Keep a

shadowed element's shadow *inside* the stage padding: size it 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. 0 34px 90px -26px (~98px) on a card 40px from the edge WILL clip; 0 8px 32px -14px (~30px) inside ~44px of padding does not.

Colours & fonts come from xprops — never hard-code

The plugin runs in its own iframe (separate document, separate bundle) and reads host state via Zoid xprops on window.xprops. Host CSS variables do not cross the iframe boundary, and AntD's theme object isn't reachable — so "read the theme" means "read xprops".

xprops pathWhat it is
xprops.slide.baseColourSlide background colour
xprops.slide.textColourPrimary text colour
xprops.slide.backgroundImageBackground image URL (largest variant), already composited with the host's overlay
xprops.presentation.fontFamilyDeck's font (NAME only)
xprops.presentationColorPaletteAccent palette — CSS colour strings, padded to 30
xprops.presentationLighterColorPaletteLighter-shade accent palette
  • Never hard-code hex colours, Tailwind colour utilities, or font families. Must look

right on light, dark, and image backgrounds.

  • Use the palette for accents so the slide feels part of the deck — but **never pair two

palette entries as fill-and-text. The palette is brand accents, not a fill/foreground pair, and has no guaranteed contrast (an all-blue deck makes both go blue and the label vanishes). When text sits on a coloured fill, derive the ink FROM the fill (isLightColour(fill) ? darkToken : lightToken) or fill with the item's own accent and contrast against that. The palette is safe for a mark (bar, dot), never for palette text on a palette fill**.

  • Loading the font NAME is only half — you must LOAD the face.

xprops.presentation.fontFamily is only a name; the cross-origin iframe doesn't inherit the host's @font-face rules, so font-family: <deck name> alone falls back to serif (Times). Call the shared useDeckFont(fontFamily) helper (adds the Google-Fonts <link>, idempotent, no-ops for bundled Plus Jakarta Sans) in every role that renders text — the presenter Canvas AND the Audience / report iframes are separate documents.

  • Fallback when unset: baseColour #ffffff, textColour #313131. The textColour

fallback fires on the common case (default themes leave it unset). On the deck background the flat #313131 is fine — but when the ink sits on a bounded filled surface whose polarity can vary (a disabled-grey box, a state-tinted card, a coloured chip), a *fixed* fallback is invisible on the opposite polarity. Derive it from that surface's own background with a luminance flip: slide.textColour ?? readableInkOn(fill), not a constant. Bind every copy of the derived ink to one computed source.

  • The host's visibility (overlay opacity) is not forwarded — it's already composited

into the backgroundImage you receive.

Two colour jobs — keep them separate

JobLayerColour source
Job 1 — chart / visualizationbar fills, ring segments, plot dots, area shadingthe accent palette (presentationColorPalette + lighter sibling). Charts never carry state meaning.
Job 2 — state indicatorthe ✓/✗ icon, status badge, border accent, label chip that announces correctnesssemantic function tokens bundled into the plugin's own build
TokenWhere it goes
colorSuccess (#16C49A)✓ icon, correct-answer badge, validation-passed border
colorError (#F5222D)✗ icon, incorrect-answer badge, destructive-action chip
colorWarningcaution icon, "are you sure?" prompt
colorInfoinformational badge
  • Bundle the semantic tokens into the plugin's build (import from the DS package, or

plugin-local constants matching it) — AntD's theme.useToken() isn't available in this Vue stack, --aha-* vars don't cross the iframe, and xprops doesn't forward them.

  • Use the FUNCTION token, not a lookalike brand accent. A bright brand accent that

merely *looks* green (Bright Teal #20E8B5 as a "correct" tick) is wrong — it drifts per brand and usually fails contrast (~1.58:1 on white).

  • The #1 mistake: applying state colours to the chart. Painting the "correct" bar

green-success fights the deck's brand and conveys state *through* the chart. Move the green to a ✓ icon next to the bar; leave the bar in palette colours.

  • Job 2 ≠ any emphasis. Poll-leader, leaderboard-winner, featured item are accents

*without* a right/wrong axis — they stay in palette colours (bar AND icon). Don't tint

a poll-leader bar with colorSuccess; it falsely implies "correct".

  • A ✓/✗ glyph is a verdict, never decorative. Use it only where there's 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 (hide the countdown chip, a plain "done", or nothing).

Per-option metrics — attach, don't float

When a datum belongs to one option (a vote count, a tally, a share), put it on that option's card — a bounded corner responder-count badge (a darker tint of the option's own accent, e.g. color-mix(in srgb, <accent> 50%, <space>), with white ink). A bounded surface is contrast-safe by construction. Don't float a detached legend keyed by colour, and don't hand-draw a second redundant rendering (a colour-split "progress" bar) of a share the cards already state.

Four "AI-generated" tells to avoid

  • No pastel wash under same-hue ink. A card at color-mix(<accent> 14%, white) with

<accent>-coloured text reads monotone, low-contrast, "AI". Let the surface be deck-owned (transparent, ink from slide.textColour) or pair a tint with genuinely high-contrast ink — not one hue at two brightnesses.

  • Corner radius tops out at 8px. Rectangular content containers (cards, answer boxes,

option tiles, panels, buttons) use ≤ 8px (rounded-lg); rounded-2xl/3xl/20px reads toy-like. Intentionally circular elements (pills, chips, badges, avatars, dots, progress tracks/fills) stay fully rounded (999px).

  • No extra-bold weight. Display text is 400 or 600 only; 700/800/900 reads heavy and

"AI" (a count number at 800 is the classic tell).

  • No decorative brand watermark or meaningless chrome. The host owns branding — never

paint a faux ahaslides.com badge, a logo chip, a "Powered by…" tag, or an ornamental pill. Every on-canvas element must carry a real datum; if it says nothing, delete it.

Accessibility — readable from across the room (WCAG)

A slide is read from a distance, often on a glare-washed projector. Design for the worst seat — the same bar applies to the small editor preview.

  • Contrast: WCAG AA 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.

  • A slide-painted surface re-anchors the check against that composited surface. Two

traps: 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, a 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, below the 3:1 mark floor. Put it on a bounded fill (a colorSuccess circle with a white ✓) or use it on a dark surface.

  • Type scale — fixed logical px. The stage transform scales everything as one unit, so a

fixed logical px scales proportionally on every screen. Use the preset roles:

roleclasspx @720
caption (floor)text-base16
body (default)text-lg18
subheadtext-2xl24
titletext-5xl48
herotext-7xl72
  • Primary content (title / question / answer) sits at body (18px) or larger; only

secondary meta may touch the 16px floor. Text below 16px is illegible on a projector.

  • Never a vh/vw font-sizevh resolves against the framed iframe viewport

(~498px, not 720), so text-[2.3vh] renders ~11px, and its basis wobbles by framing.

  • No clamp() px floor — a px floor pins an absolute size while siblings scale,

breaking proportion on the transform-scaled canvas. A plain fixed px needs no floor.

  • Never text-xs/text-sm (12/14px) and no arbitrary text-[…] font-size — pick a

role. (em is fine — a relative multiplier of a role-sized parent.)

  • 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 (the stacked-"OR" pill is the classic miss). If a divider must be compact, keep the word horizontal at a smaller in-scale role (never below 16px) or let it wrap.

  • Never colour alone — pair colour with a non-colour cue. Prefer an icon (trophy,

✓/✗) over a spelled-out chip; use words only when no glyph conveys the meaning ("Closed", "Beta"), and almost never stack icon + word.

  • Chart geometry stays 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 it as a positioned overlay, or the decorated row's track is narrower and the chart lies.

  • 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 the scrim, not with a darker border. Pick the palette shade (saturated on light themes, the lighter sibling on dark themes) that actually clears the floor on the current background.

  • Motion: don't depend on fast or subtle motion to convey meaning.

The presenter control bar (NCB)

Slide-specific actions (Idea board's Previous / Next: vote / Summarise) render in the presenter control bar (PresenterControlBarNew.vue, centre region ncb-center, via NcbPluginActions.vue) — outside the iframe, and also on the projector during presenting. You declare the action data in the manifest; the host paints each as <aha-antd-button>. The reason is consistency (theming, shortcut chips, sound), not audience visibility. Declare, don't render.

Levers per declared action:

LeverTypeUse it for
idstringRequired, for action callbacks.
labelstring (optional)Short verb-first text ("Summarise"); omit for an icon-only square button. Raw text, not a function.
icon (+ iconViewBox)string (optional)A design-system glyph; set iconViewBox for non-square icons.
variant`'primary' \'secondary' (default 'secondary'`)At most one primary = the obvious next step; the rest secondary.
disabled / loadingboolean (optional)Reflect state. A boolean, never a function.
shortcut`'Enter' \'Shift+Enter'` (optional)Keyboard hint chip.
  • secondary is a solid WHITE button, never a ghost/outline — white fill, subtle

border, dark label. The correct resting style for almost every slide action (Show correct answer, Group by themes, Hide votes). Declare variant: 'secondary' (or omit it) and the host renders the white button; never hand-render a transparent/border-only button inside the canvas.

  • A repeated per-round stepper is secondary, not primary. Reserve the one primary

for a genuinely singular climax (reveal final result, finish activity). Workhorse navigation clicked over and over ("Next pair", per-question "Next") is secondary, or no primary at all.

  • Footgun — disabled/loading are NOT function-typed. The host coerces with

Boolean(action.disabled); a function literal is always truthy, so disabled: () => votes === 0 makes the button permanently disabled. Pass a reactive boolean.

  • Feature-flag caveat. 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.

  • Reuse the library component — never hand-roll a control. Any control you render

(the in-canvas fallback, or any button/input/select/toggle/tooltip/modal) comes from the project's component library (this app ships Ant Design Vue, themed via its ConfigProvider tokens), not a bespoke <button>/<div>/<input> with hand-written fill/border/radius CSS. Reproduce the host's white secondary with your own Ant <Button> under ConfigProvider, placed bottom-centre so it reads as a slide action.

``ts // AFTER — the per-round stepper is secondary (This or That "Next pair") actions: [ { id: 'previous', icon: 'arrow-left', variant: 'secondary', shortcut: 'Shift+Enter' }, { id: 'next', label: 'thisOrThat.nextPair', variant: 'secondary', shortcut: 'Enter' }, ]; // BEFORE — WRONG: shipped as a primary CTA, so every pair shouts the accent // { id: 'next', label: 'thisOrThat.nextPair', variant: 'primary' } // should be secondary ``

Keyboard-shortcut affordances — for controls you render in-canvas

§5's host-painted actions get shortcut chips, focus order, and sound for free. A slide that renders its own interactive controls (a bespoke overlay, a play/pause, a mute button, a settings popover) owns those affordances — skipping any is a defect.

  • a. A modal/overlay/popover traps focus. Move focus to an element inside it on

open, keep Tab/Shift+Tab cycling within it (the presenter can't tab out to the page behind), and return focus to the trigger on close. An overlay that opens but leaves focus behind is invisible to keyboard and screen-reader users.

  • b. Every actionable button shows its shortcut; a single character is a SQUARE. Render

the key in a small <kbd> chip. A one-character key is a square (equal width and height, small rounded corners ~4px), never a wide pill — a pill reads as a tag, a square reads as a key; multi-char keys grow wider.

  • c. A labelled button shows its shortcut INLINE next to the label, always visible —

never hidden in a hover-only title="Press X" tooltip (which makes the presenter hunt and hides it from touch).

  • d. An icon-only button carries a name + shortcut tooltip. Its tooltip (and

aria-label) is the button's name plus the shortcut in parenthesesMute music (M). A bare icon, or a tooltip that drops the name or (shortcut), leaves the control undiscoverable.

(The buttons above still come from the component library per §5 — the affordance rules are additive, not a licence to hand-roll the element.)

Language

Display in the presentation's language, not a fixed one. The plugin runs its own i18n instance and does not inherit the host's locale. Initialise the locale from xprops.presentation.language (or xprops.currentUser.presenterLanguage) at startup and update it when the host emits a change. No hard-coded English; use Intl.NumberFormat(locale) for percentages and counts, not .toFixed() with an assumed .-as-decimal separator.

Right panel

The right-panel settings form is out of scope — hand it off to aha-design-settings.


*Full detail, worked BAD/GOOD examples, and the canonical good-fill-in-the-blanks fixture

live in the aha-design-canvas skill. Self-check any built canvas with aha-design-canvas-judge — it emits a binary PASS/FAIL across the same contracts (theme tokens, semantic state colours, NCB control bar, WCAG, non-colour cues, framed-vs-full-canvas, the full-canvas host title, the on-canvas size floor, i18n, keyboard-shortcut affordances) — and fix every FAIL before declaring the work OK TO SHIP.*