|
| 1 | +# The client reports failures and confirmations as toasts |
| 2 | + |
| 3 | +**Status:** Accepted. |
| 4 | +**Date:** 2026-11-03. |
| 5 | + |
| 6 | +ADR 0028 established one shared presentation layer, and its feedback family |
| 7 | +(`client/ui/feedback.tsx`) owns the states a surface can be in: empty, error, |
| 8 | +skeleton, progress, busy, announcement. That family draws a state *in place*, |
| 9 | +because a state is a fact about the surface it belongs to. This decision adds the |
| 10 | +other kind of message: one that has already happened, is said once, and is gone |
| 11 | +again — a failure of an action, a confirmation that a write landed, a read that |
| 12 | +did not come back. Those are events, not states, and the client was drawing them |
| 13 | +as states. |
| 14 | + |
| 15 | +## What changed |
| 16 | + |
| 17 | +Before this decision the client wrote those events into the page. A failed save |
| 18 | +put a red paragraph under the form and left it there until the next attempt. A |
| 19 | +successful save put a green paragraph under the field and left it there until the |
| 20 | +user edited the field again. A failed read replaced the panel's content with a red |
| 21 | +paragraph. Every one of them spent layout on a moment that was already over: the |
| 22 | +page grew, the content below the notice moved, and the notice outlived the thing |
| 23 | +it was about. |
| 24 | + |
| 25 | +The shared toast (`client/ui/toast.tsx`) is now the one way the client says |
| 26 | +something that has already happened. The notice is drawn against the viewport, |
| 27 | +over the page, and takes no room from it. |
| 28 | + |
| 29 | +## What a toast is |
| 30 | + |
| 31 | +A toast is a card with a tone, a message, an optional description, an optional |
| 32 | +action, and a lifetime. It arrives, it is read, and it leaves. The client says |
| 33 | +through it: |
| 34 | + |
| 35 | +- an action that failed — "保存失败", "复制失败"; |
| 36 | +- a write that landed — "已保存", "已复制", "远程连接已保存"; |
| 37 | +- a read that did not come back — "读取定时任务失败。". |
| 38 | + |
| 39 | +`client/ui/toast-store.ts` owns the decisions and is free of React: the |
| 40 | +vocabulary of a notice, the lifetime each tone is worth, the rule that says which |
| 41 | +two notices are the same notice, the cap on a visible stack, and the queue's |
| 42 | +transitions. `client/ui/toast.tsx` owns the card, the timers, the announcement |
| 43 | +and the host. |
| 44 | + |
| 45 | +## What a toast is not |
| 46 | + |
| 47 | +Two kinds of error stay where they are, and this decision does not move them. |
| 48 | + |
| 49 | +**A field's own error.** A validation message belongs to the control it |
| 50 | +describes: `WebuiField` connects the message to the control with `aria-describedby` |
| 51 | +and marks the control `aria-invalid`. A toast is not addressable by a control and |
| 52 | +disappears on its own, so a field error moved into one would be lost to the user |
| 53 | +who is still typing in the field it is about. |
| 54 | + |
| 55 | +**A state that stays.** A quota banner, a goal banner, the notice about a |
| 56 | +session deleted on another device, a warning inside a message body: these state a |
| 57 | +fact that is true until the fact changes. They are states, they belong to the |
| 58 | +surface that owns them, and they are drawn in place. |
| 59 | + |
| 60 | +**A read that failed** is different from both, and this decision moves it. When |
| 61 | +the content a surface was going to show could not be read, the surface says so |
| 62 | +through a toast and then shows what it can — usually its content path with |
| 63 | +nothing in it, which is the same thing the user sees before the read began. The |
| 64 | +notice is transient because the condition is: a read that fails once and succeeds |
| 65 | +on the next attempt has no business owning a permanent block of layout. The |
| 66 | +surface's own empty state, where it has one, still describes what is absent. |
| 67 | + |
| 68 | +## The queue's rules |
| 69 | + |
| 70 | +- **Tone.** `info`, `success`, `warning`, `error`. The tone decides the colour, |
| 71 | + the glyph and how loudly the notice is announced. An error interrupts; the |
| 72 | + others wait their turn. |
| 73 | +- **Lifetime.** A confirmation is read at a glance and goes quickly; a failure |
| 74 | + carries a sentence the user may read twice and stays longer. A notice that |
| 75 | + offers an action waits for the answer — it does not time out — because a retry |
| 76 | + the user has not reached yet must not disappear. |
| 77 | +- **Repetition.** A notice that is said twice is said once: the second saying |
| 78 | + refreshes the first in place and restarts its countdown. Two notices are the |
| 79 | + same notice when their tone and their words are the same. |
| 80 | +- **The stack.** A handful of toasts are on screen at once, and the oldest leaves |
| 81 | + when the stack grows past that. A toast that stacks without a bound is a second |
| 82 | + page of content, and the user ends up reading the oldest message while the |
| 83 | + newest one is the only one that matters. |
| 84 | + |
| 85 | +## Announcement |
| 86 | + |
| 87 | +The card that carries a notice announces it: an error is an `alert`, the rest |
| 88 | +are a `status`. There is one announcer per notice, and the migration removed the |
| 89 | +inline announcer that used to sit next to it — a notice said twice is a notice |
| 90 | +heard twice. |
| 91 | + |
| 92 | +## The queue and the surfaces that raise notices |
| 93 | + |
| 94 | +The client mounts one queue per document, at the composition root, and the host |
| 95 | +that draws it is a sibling of the application. A surface rendered on its own — |
| 96 | +a server render, an isolated preview — has no queue to raise a notice into. |
| 97 | +That is a wiring mistake, and it is reported: the notice is raised into a |
| 98 | +remembered record and the client is told once that there was nowhere to draw |
| 99 | +it. It is not the reason a surface fails to render. |
| 100 | + |
| 101 | +## Overlay status |
| 102 | + |
| 103 | +A toast is not a managed overlay. It is not dismissed by Escape, it never takes |
| 104 | +focus on its own, and it does not register with the overlay coordinator. A layer that |
| 105 | +answered a dismissal would be a layer the user had to dismiss, and a toast the |
| 106 | +user has to dismiss is a dialog. |
| 107 | + |
| 108 | +## The notice crosses a modal |
| 109 | + |
| 110 | +A modal makes the page behind it inert, so that a keyboard reader and a screen |
| 111 | +reader stay inside it. The notice channel is not that page: it is where the |
| 112 | +failure a modal reports is said. Left inside the modal's exclusive zone, the |
| 113 | +notice would be silenced exactly when it mattered — an inert subtree is also |
| 114 | +gone from the accessibility tree, so the `alert` would never be heard — and |
| 115 | +the action it carries would be unreachable. |
| 116 | + |
| 117 | +The client's answer is a marker: `data-webui-live-surface`. A surface that |
| 118 | +carries it stays live across a modal. The platform layer keeps such a surface |
| 119 | +out of the modal's exclusive zone: it is not made inert, focus may rest on it, |
| 120 | +and it joins the modal's tab ring. The overlay listener treats a press on it as |
| 121 | +a press on the notice rather than a press on the page behind, so answering a |
| 122 | +notice's retry does not close the dialog that reported the failure. |
| 123 | + |
| 124 | +The toast host carries the marker. It is the only surface that does today; the |
| 125 | +marker is a contract rather than a toast feature, so the next surface that has |
| 126 | +to cross a modal can carry it too. |
| 127 | + |
| 128 | +## Adoption |
| 129 | + |
| 130 | +The contract is adopted by rendering it: `useWebuiToastError` and |
| 131 | +`useWebuiToastSuccess` say a message once, when it appears, and leave the layout |
| 132 | +alone. A caller keeps the state it holds — the state is what a retry and a |
| 133 | +re-render need — and stops drawing it. |
| 134 | + |
| 135 | +Completion is not proved by a gate. The presentation inventory records that the |
| 136 | +contract exists and that the surfaces using it were reviewed; the coverage of |
| 137 | +the migration is a source review, and the appearance is a browser check. |
0 commit comments