Toast feedback Controller
Stack host + auto-dismiss notifications — title, body, optional actions; hover/focus pauses the timer (htmx OOB or client bridge).
Layer: L2 host · Recipe: chrome-presentation — presentation / chrome. Curriculum: AGENTS.md; pick matrix: docs/agent/pick-a-surface.md; blast radius: CONSUMER_MAP.md.
Copy this
<div id="toast" class="toast-stack" aria-live="polite" data-toast-cap="8">
<div class="toast toast-enter" data-toast-level="success" data-remove-after="8s" role="status">
<span class="toast__icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg></span>
<div class="toast__body">
<div class="toast__title">Saved</div>
<div class="toast__message">Your changes are live.</div>
<div class="toast__actions"><a class="toast__action" href="#toast">View record</a><button type="button" class="toast__action" data-toast-dismiss>Dismiss</button></div>
</div>
<button type="button" class="toast__close" data-toast-dismiss aria-label="Dismiss"></button>
</div>
</div>Server exchange
This Hyperpart has no server exchange — it is presentation or client chrome only. htmx does not issue a request on this part's behalf. If you put an affordance (hx-*) on a control that uses this markup, that action's exchange belongs to the action, not this part. See Swap contract for host-owned envelopes.
Swap contract
Agent-visible HTMX topology (ADR-0054 / decision 0012). exchange envelope = what the response may re-emit relative to the persistent slot (body_only | outer | none | host_owned | document). dual-lock validates part markup only — not this envelope. Stem: stems/morph-safe-hypermedia.md; decision: docs/decisions/0012-swap-identity-contract.md.
No host HTMX exchange on this part — presentation or client chrome only. exchange envelope: n/a.
If a host wraps this markup in hx-*, that host owns the swap contract (sole identity + envelope). Prefer innerMorph / outerMorph for stable slots; replacement for flash; body-only responses under inner swaps.
Envelope response examples
This part has no owned exchange (envelope n/a). If a host adds hx-*, that host’s envelope applies — typically body_only:
<!-- Host wraps this presentation part with hx-* (host owns envelope) -->
<!-- Prefer: hx-swap="innerMorph" hx-target="#panel-body" -->
<!-- Server returns body_only interior for #panel-body -->
<div class="dz-stack">content…</div>
Do not re-own the slot:
<!-- WRONG: server returns the presentation root with a new id every poll -->
<div id="toast-root" data-dz-region>…</div>
How to use it
Seams
- stack host #toast.toast-stack receives OOB afterbegin
- data-remove-after + data-toast-level on each .toast
- optional .toast__title / __message / __actions slots
- person: data-toast-composition=person + __avatar / __actor
- data-toast-dismiss removes the nearest .toast
- showToast CustomEvent or window.dz.toast for client path
- host injects .toast__progress TTL bar (pauses with timer)
- level icon .toast__icon (inline SVG; host ensures if missing)
- swipe toward stack edge dismisses (same leave path)
- data-toast-sound + page dzCue opt-in for enter cue
Do / Don't
| Do | Don't |
|---|---|
| emit structured toasts (title + message + actions) from the exchange response via with_toast | build a client notification SPA store or copy third-party Alpine toast idioms |
| let hover/focus pause auto-dismiss so users can read or activate actions | force-dismiss while the pointer is over the toast |
| use actor_name for person composition — not a new severity colour | add data-toast-level=message as a fake tone |
Pitfalls
- do not morph the toast stack — replace/afterbegin only
- do not invent Alpine notify stacks — use OOB + this host
- message text must be textContent (never innerHTML from detail)
- gallery fire buttons are demo chrome — production uses with_toast or HX-Trigger showToast
- never enable sound by default on product shells
Keyboard / AT
- role=status (or alert for error); aria-live on the stack
- dismiss control is keyboard-activatable
- focus inside the stack pauses auto-dismiss
- sound/haptic cues are opt-in (meta or data-cue-sound)
Related parts
DOM contract
What the emitted HTML must satisfy — the table is the required surface; Python under contracts/ is the package-internal dual-lock CI runs (tests/test_contracts.py), not an app route. Standalone HTMX4: implement the API so responses match this markup. Dazzle: the agent emits SSR that already satisfies it. Do not invent attrs outside these tables. For request/response wiring see Server exchange.
contracts/toast.py
Required in the DOM: root [data-toast-level] (part toast). Emit only these attributes — inventing extras is fine only if controllers ignore them; omitting required ones fails CI (tests/test_contracts.py).
| Node | Attr | Constraint |
|---|---|---|
[data-toast-level] | data-toast-level | one of ['info', 'success', 'warning', 'error'] |
[data-toast-level] | data-remove-after | present (any value) |
Module source
Import path is monorepo/package-local (from contracts._kit import …). Source-token form often uses data-*; gallery demos above are unprefixed. Do not copy this into app routes.
"""HYPERPART: toast — stack host + auto-dismiss notification unit.
Dual-lock unit is the toast root (``[data-dz-toast-level]`` on ``.dz-toast``)
plus the stack host (``#dz-toast.dz-toast-stack``). Level, auto-dismiss delay,
title, message, and optional action row are host-owned.
Page chrome (decision 0011 / stem ``page-chrome-toast``): viewport stack,
default TTL 8s (10s error), pause on hover/focus, leave before remove. TTL
progress bar is host-injected (not a contract-required node). Optional person
composition (``data-dz-toast-composition=person``) and sound attr are optional
slots (ssr-client-slot-parity); host swipe-dismiss is pure behaviour.
Server emit: ``dazzle.http.runtime.response_helpers.with_toast``.
Client emit: ``showToast`` / stack ``toast`` events (``controllers/dz-toast.js``).
Contract selectors use ``[data-dz-*]`` only — the kit has no CSS class engine.
"""
from __future__ import annotations
from contracts._kit import DomContract, Node, OneOf, Present
DOM_CONTRACT = DomContract(
part="toast",
root="[data-dz-toast-level]",
nodes=(
Node(
"[data-dz-toast-level]",
attrs={
"data-dz-toast-level": OneOf("info", "success", "warning", "error"),
"data-dz-remove-after": Present(),
},
),
),
)
__all__ = ["DOM_CONTRACT"]
Notes
.toast + stack #toast.toast-stack. Decision 0011: viewport host, 8s/10s TTL, pause, leave, TTL progress, icons, person composition (actor_name), swipe-dismiss, opt-in sound via dzCue. Server: with_toast(..., title=…, actions=…, actor_name=…). Not Alpine notify.Source files
One logical Hyperpart, 9 code items (CSS layered, JS bundled). Bound by HYPERPART: toast — python tools/hyperpart.py toast lists them.
site/registry.py · contracts/toast.py · components/fragments.css:31 · components/fragments.css:585 · components/sitespec.css:1703 · components/toast.css:2 · base/design-system.css:610 · controllers/dz-toast.js · controllers/dz-cue.js