@synopackageland/ui 0.23.9 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/callout.md CHANGED
@@ -103,9 +103,9 @@ halfway across. A Callout drops it: it stays on the page, where DSM draws its
103
103
  notes with no decoration.
104
104
 
105
105
  The title reads in the text colour at 600, and the message under it in the
106
- secondary text colour. Corners are 12px (`--ui-callout-radius`), rounder than
107
- a card on purpose: a Callout floats above the settings rather than being one
108
- of them. Buttons in `#actions` and a toast's action are capsules. A Callout
106
+ secondary text colour. Corners follow `--ui-radius-callout` (10px in a DSM window, 12px on a full
107
+ page and mobile; `--ui-callout-radius` overrides it), rounder than a card on
108
+ purpose: a Callout floats above the settings rather than being one of them. Buttons in `#actions` and a toast's action are capsules. A Callout
109
109
  fades in and rises 6px when it appears, and a toast slides in; with reduced
110
110
  motion both appear at once.
111
111
 
@@ -30,7 +30,7 @@ These rules keep pages built from `@synopackageland/ui` consistent. The componen
30
30
  - In a dialog or a side `Sheet`, keep the default `direction="auto"`: as in DSM, the labels sit in a left column whenever the form is at least 400px wide (a 420–480px sheet) and stack above their controls only below that (a phone). Size the column with `Form label-width` (about 120px for short labels); a long label wraps inside it and stays level with its control's first line, whether that is a field, a select, a switch, a toggle group or a segment control.
31
31
  - Size a field by its content: number fields are 128px; text fields use `fieldWidth`; in a narrow row that stacks its label (a phone), `md` and `lg` fill the column. Put two short fields side by side rather than stretching one across the page.
32
32
  - A checkbox's text sits on the same line as its box. Do not apply `display: grid` to `label` in App CSS; `Checkbox` renders its own label.
33
- - For 2–4 mutually exclusive settings, use `RadioGroup` (vertical by default, `orientation="horizontal"` when labels fit). In a wizard step, `variant="card"` draws the options as choice cards with an optional `icon` each. A checkbox enables an independent option; a switch turns a feature on or off immediately. Keep the circular radio with a dot, square checkbox with a tick, and pill switch with a moving thumb.
33
+ - For 2–4 mutually exclusive settings, use `RadioGroup` (vertical by default, `orientation="horizontal"` when labels fit). In a wizard step, `variant="card"` draws the options as choice cards with an optional `icon` each. The 2–4 count is a guide, not a limit: the cards wrap, but five or more short options (or longer ones, even three) read better as a vertical radio list. A checkbox enables an independent option; a switch turns a feature on or off immediately. Keep the circular radio with a dot, square checkbox with a tick, and pill switch with a moving thumb.
34
34
  - Reserve `SegmentControl` for toolbar view switching: its selected item is a primary-filled capsule. `variant="sheet"` and `SectionTabs` are navigation tabs. Do not use either as a form value selector.
35
35
  - Choices side by side are 34px apart (`--ui-choice-inline-gap`, DSM's Task Scheduler) and share one top whether checked or not. For a set of checkboxes use `CheckboxGroup` (`orientation="horizontal"` for a row, `v-model` an array of values), as `RadioGroup` for one choice; Checkboxes written one after another get the same gap. Do not add margins between them.
36
36
  - A password field has a show/hide toggle; `Input type="password"` adds it.
@@ -22,6 +22,37 @@ App. Each line links to its details below.
22
22
  | No end space under docked bars | The room under the last row is constant (`--ui-page-end-space`, plus `--ui-bars-room` under docked bars); a fill page's list runs under the FAB and bars to the screen's bottom with no blank band, also when that list is an App's own element nested in the page. |
23
23
  | Gesture velocity | One estimator and one set of thresholds for every gesture decision the UI makes. See [Gesture velocity](#gesture-velocity). |
24
24
 
25
+ ## Phone look
26
+
27
+ Since 0.25 a touch phone (narrow and a coarse pointer, the phone tokens'
28
+ gate) draws softer than a DSM window: a pale page with white cards on it and
29
+ no frames, round corners, more air and bigger text. It is all in
30
+ `src/theme/phone.css`, inside `@media (pointer: coarse)` and scoped to the
31
+ phone presentation; a desktop, a DSM window (also one narrowed with a mouse)
32
+ and a touch tablet are unchanged. Tap boxes, bar heights, hide on scroll and
33
+ the commit footer behave as before.
34
+
35
+ | Token or part | Before (phone) | Phone look |
36
+ | --- | --- | --- |
37
+ | Page under cards (`--ui-page-canvas`) | `#fafbf9` | `#f1f3f0` (dark: the dark page, `#121513`) |
38
+ | Card | white, 1px frame, card shadow, 6px corner | white, no frame, 1px soft shadow (none in dark), `--ui-radius-card` 20px |
39
+ | Section list (SectionTabs' phone list) | framed group, 6px corner, 44px rows, full-width rules | inset grouped list: white group, 20px corner, 52px rows, hairlines inset 16px, on the mist |
40
+ | A list of ListItems on the page (`role="list"`, `ul`, `ol`, outside a card, sheet or dialog) | full-bleed rows, full-width rules | the same inset grouped list: one white group per list, rows inset 16px, hairlines from the text's edge, the heading just above it inset to the text |
41
+ | Page background | the mist only on a page of cards | the mist on every page (not a data-grid page or `surface="none"`); a sticky row and `--ui-app-web-surface` follow it |
42
+ | `--ui-radius-md` / `-lg` (and the 0.23.9 roles on them) | 4 / 6 | 12 / 14: md rounds the control, container and popover roles (fields, tool buttons, inset blocks, menus), lg the panel and nav roles |
43
+ | `--ui-radius-dialog` / `-window` / `-sheet` | 8 / 8 / 12 | 24 |
44
+ | Primary, danger and secondary buttons | capsule (action) or 4px (tool) | capsule; a primary's fill is accent step 10 (`#0075db`, 4.6:1 with white) |
45
+ | Segmented control | accent pill on a framed track, equal columns | ink pill (`--ui-phone-ink`, `#1f2421`; dark `#e3e8e4`) on a frameless mist track; 6px sides; up to three segments stay equal columns but one whose label is wider than its share grows to it, so a label is not cut (four ellipsize as before) (`check:phone-layout` checks it) |
46
+ | Phone bar | white with a hairline | the page's colour, no hairline, 17px bold title, round pressed tint |
47
+ | Tab bar | full-width white row with a top rule | a floating white pill inset 16px, 8px above the bottom (`--ui-tab-bar-float`); the current tab is ink on a round pad |
48
+ | `--ui-text-muted` | 40% grey (2:1) | 80% (4.8:1 on the mist; dark 62%) |
49
+ | Separators | `--ui-border-subtle` | `--ui-phone-separator` |
50
+
51
+ An App that sets `--ui-mobile-bar-bg`, `--ui-mobile-bar-border` or
52
+ `--ui-tab-bar-bg` keeps its colour. The tab bar's box keeps the full width and
53
+ adds the float gap below the pill, so the room AppLayout measures for the
54
+ bottom bars, the FAB's offset and the end space follow it.
55
+
25
56
  ## Width containment and the release gate
26
57
 
27
58
  Typography tables scroll horizontally within their parent, including a flex
@@ -75,12 +106,12 @@ control and grid sizes; it does not change the 16px field rule.
75
106
 
76
107
  | Token | Desktop | Touch tablet | Phone | Used for |
77
108
  |---|---|---|---|---|
78
- | `--ui-font-size` | 13 | 13 | **14** | primary text: list and record titles, field values, body copy, menu items |
79
- | `--ui-font-size-sm` | 12 | 12 | **12** | secondary text: labels, meta, captions, helper text, badges |
109
+ | `--ui-font-size` | 13 | 13 | **15** | primary text: list and record titles, field values, body copy, menu items |
110
+ | `--ui-font-size-sm` | 12 | 12 | **13** | secondary text: labels, meta, captions, helper text, badges |
80
111
  | `--ui-font-size-xs` (new) | 12 | 12 | 12 | the smallest text anywhere |
81
- | `--ui-line-height` | 20 | 20 | **20** | body line |
82
- | `--ui-font-size-lg` | 14 | 14 | **15** | card and section titles |
83
- | `--ui-font-size-title` | 16 | 16 | **16** | header and dialog titles |
112
+ | `--ui-line-height` | 20 | 20 | **22** | body line |
113
+ | `--ui-font-size-lg` | 14 | 14 | **17** | card and section titles |
114
+ | `--ui-font-size-title` | 16 | 16 | **17** | header and dialog titles |
84
115
  | `--ui-grid-font-size` (new) | 13 | 13 | **14** | grid cell text (not yet read by the canvas grid; see below) |
85
116
  | `--ui-font-size-field` (new) | 16 | 16 | 16 | every field on a coarse pointer, to stop iOS focus zoom |
86
117
  | `--ui-control-md` | 28 | **44** | **44** | fields, selects, form and sheet buttons |
@@ -91,15 +122,15 @@ control and grid sizes; it does not change the 16px field rule.
91
122
  | `--ui-grid-row-height` / `-header-height` | 28 | **36** | **32** | canvas grid rows (docs/ux-grid.md) |
92
123
  | `--ui-list-row-height` | 32 | **44** | **44** | list rows, a DisclosureRow |
93
124
  | `--ui-menu-row-height` | 32 | **44** | **44** | menu, select and sheet list rows |
94
- | `--ui-page-padding-x` / `-y` | 16 (narrow layout 12) | 16 | **12** | page gutter |
95
- | `--ui-section-gap` | 12 | 12 | **12** | space between sections |
96
- | `--ui-card-padding` | 20 (Card default) | 20 | **12** | card inset |
125
+ | `--ui-page-padding-x` / `-y` | 16 (narrow layout 12) | 16 | **16 / 12** | page gutter |
126
+ | `--ui-section-gap` | 12 | 12 | **16** | space between sections |
127
+ | `--ui-card-padding` | 20 (Card default) | 20 | **16** | card inset |
97
128
  | `--ui-chrome-height` | 44 (web 48, narrow layout 44) | – | **44** | the phone bar (plus its 1px rule) |
98
129
  | `--ui-tab-bar-height` | – | – | **48** | the tab bar's row, above `--ui-safe-bottom` |
99
130
  | `--ui-section-tab-height` | 44 | 44 | **40** | the drawn height of a phone's tabs strip (its tap box stays 44) |
100
131
  | `--ui-sticky-top-offset` | 0 | 0 | measured | what sticks at the top of a PageContent (see below) |
101
132
  | `--ui-page-end-space` | 24 | 24 | 24 (constant: hiding the bars never changes it) | room after a page's last row |
102
- | `--ui-radius-sheet` (new) | 12 | 12 | 12 | top corners of a bottom sheet |
133
+ | `--ui-radius-sheet` (new) | 12 | 12 | **24** | top corners of a bottom sheet |
103
134
  | `--ui-safe-top/right/bottom/left` (new) | `env(safe-area-inset-*, 0px)` | same | same | notch and home indicator; non-zero only once the viewport meta has `viewport-fit=cover` |
104
135
  | `--ui-pressed` (new) | 8% of the text colour | same | same | the pressed tint |
105
136
 
@@ -159,6 +190,17 @@ component rule overrides them.
159
190
  IconButton draw their own pressed state (`data-variant`), so they're
160
191
  skipped. A component that sets its own shadow or `active:` style also keeps
161
192
  its own.
193
+ - Press feedback on a phone (`phone.css`, a coarse pointer in the mobile
194
+ presentation): a Button or an IconButton takes the `--ui-state-pressed`
195
+ layer over its own active colour and settles to `scale(0.98)` in 100ms
196
+ (`cubic-bezier(0.24, 1, 0.4, 1)`), springing back on release. The scale is
197
+ a transform, so nothing around the control moves. A ghost Button or
198
+ IconButton already fills with the layer and a link button fades, so neither
199
+ takes a second layer. A list row, a tab and a menu item keep the layer
200
+ alone: a row that shrank would pull away from its hairlines. A disabled
201
+ control takes nothing. Reduced motion and `data-ui-motion="off"` keep the
202
+ layer and drop the scale. A DSM window, a desktop page and a touch tablet
203
+ are unchanged.
162
204
  - Hover: every hand-written `:hover` rule in the theme sits inside
163
205
  `@media (hover: hover)`, so a tap leaves no stuck hover look. Tailwind's
164
206
  `hover:` utilities already do this.
@@ -437,7 +479,11 @@ contain`, a pinned footer and safe-area padding. The top corners use
437
479
  overrides that.
438
480
 
439
481
  `Dialog` on a phone (an app 640px or narrower) renders as a `BottomSheet`:
440
- `auto`, or `full` for `fullscreen` and for a form with more than 3 fields. The
482
+ `auto`, or `full` for `fullscreen` and for a form with more than 3 fields.
483
+ `presentation="modal"` keeps a centred window instead, with the phone's
484
+ dialog corner and dim scrim, as tall as its content up to its `height` inside
485
+ the safe area (only a longer body scrolls); `WizardDialog` uses it by default, as a DSM
486
+ wizard opens, and `presentation="adaptive"` gives it the bottom sheet. The
441
487
  CSS-only dialog-as-sheet in `adaptive.css` is gone. `Sheet side="bottom"` is
442
488
  a `BottomSheet` everywhere; without `size` it fits its content. Left and right
443
489
  sheets are unchanged, and their footer keeps clear of the home indicator.
@@ -8,7 +8,7 @@ checks through the optional build-isolation wrapper.
8
8
 
9
9
  | Entry point | Default | Explicit full |
10
10
  | --- | --- | --- |
11
- | `npm run gates`, `scripts/gates.mjs` | Internal-content checks, build, lint, typecheck, unit tests, notices, isolated pack and tarball content audit | Adds Chromium and WebKit installation, responsive/adaptive/date-picker/JSON/host/switch/layout/InfoTip/motion/short-viewport/phone-layout/phone-bars (Chromium and WebKit)/focus-restore browser checks, Histoire build, exact-tarball registry consumer smoke |
11
+ | `npm run gates`, `scripts/gates.mjs` | Internal-content checks, build, lint, typecheck, unit tests, notices, isolated pack and tarball content audit | Adds Chromium and WebKit installation, responsive/adaptive/date-picker/JSON/host/switch/layout/InfoTip/motion/short-viewport/phone-layout/phone-bars (Chromium and WebKit)/press/focus-restore browser checks, Histoire build, exact-tarball registry consumer smoke |
12
12
  | `pnpm check`, `check:fast` | Basic, with optional Docker host build isolation | `SPL_FULL_GATE=1` selects the same full plan |
13
13
  | `fast-ci.yml`, composite verify action | Basic | No automatic extended checks |
14
14
  | Disabled CI/release workflow templates | Basic, version/tag/ancestry checks, stage, pack and actual archive audit | No implicit browser or registry consumer smoke |
@@ -182,7 +182,7 @@ before the token existed. Set them on the app root or any ancestor.
182
182
  | `--ui-mobile-bar-fg` | the phone bar's text, menu button and brand | `var(--ui-text-secondary)` (text), `var(--ui-text)` (button, brand) |
183
183
  | `--ui-mobile-bar-border` | the phone bar's bottom rule | `var(--ui-border-subtle)` |
184
184
  | `--ui-callout-max-width` | caps how wide a `Callout` grows; it spans its container by default | none |
185
- | `--ui-callout-radius` | a `Callout`'s and a `Toast`'s corners | 12px |
185
+ | `--ui-callout-radius` | a `Callout`'s and a `Toast`'s corners | `--ui-radius-callout` (window 10px, web and mobile 12px) |
186
186
  | `--ui-callout-glow` | how strong the tone's glow is behind a `Callout`'s or `Toast`'s icon; a dark theme raises it (workbench: 14%) | 9% |
187
187
  | `--ui-callout-shadow` | the shadow under a `Callout` and a `Toast` (workbench dark: `0 10px 28px -12px rgba(0, 0, 0, 0.6)`) | `0 8px 24px -12px rgba(15, 23, 42, 0.18)` |
188
188
 
@@ -193,24 +193,34 @@ The colour, radius, spacing and web tokens are listed in the
193
193
  ## Corner radius
194
194
 
195
195
  Components round their corners with a role, not a step, so a theme can round
196
- one kind of surface without the others:
197
-
198
- | Token | Rounds | Window | Web and phone |
199
- | --- | --- | --- | --- |
200
- | `--ui-radius-inner` | a part inside a control or a popover: a menu row, a tag, a tooltip, a checkbox box, a small hit area | 3px | 3px |
201
- | `--ui-radius-control` | a button, a field, a select, a segment | 4px | 4px |
202
- | `--ui-radius-container` | a flat block: a card's surface, an inset area, a thumbnail frame | 4px | 4px |
203
- | `--ui-radius-popover` | a menu, a select or combobox list, a popover, a picker | 4px | 4px |
204
- | `--ui-radius-panel` | a raised block: a choice card, a framed region, a drop zone, a side drawer, a grid card | 5px | 6px |
205
- | `--ui-radius-nav` | a navigation row in the sidebar or the top bar | 5px | 6px |
206
-
207
- Each role defaults to a step of the scale (`--ui-radius-sm`, `-md`, `-lg`),
208
- and the steps stay public. Override a role to change only that kind of
209
- surface; override a step on `:root` to move every role on it. The roles are
210
- declared on `:root` and again on the web and phone presentation roots
211
- (`adaptive.css`), so a step overridden there still moves them. A step
212
- overridden on any other element does not reach the roles below it: override
213
- the role on that element instead.
196
+ one kind of surface without the others. Since 0.25 a DSM window rounds a step
197
+ softer than DSM's own 3-5px and a full page softer still; the mobile
198
+ presentation keeps the steps (a phone rounds them itself, `phone.css`):
199
+
200
+ | Token | Rounds | Window | Web | Mobile | Touch phone |
201
+ | --- | --- | --- | --- | --- | --- |
202
+ | `--ui-radius-inner` | a part inside a control or a popover: a menu row, a tag, a tooltip, a checkbox box, a small hit area | 4px | 6px | `sm` (3px) | 3px |
203
+ | `--ui-radius-control` | a button, a field, a select, a segment | 6px | 8px | `md` (4px) | 12px |
204
+ | `--ui-radius-container` | a flat block: a card's surface, an inset area, a thumbnail frame | 8px | 10px | `md` (4px) | 12px |
205
+ | `--ui-radius-popover` | a menu, a select or combobox list, a popover, a picker | 8px | 10px | `md` (4px) | 12px |
206
+ | `--ui-radius-panel` | a raised block: a choice card, a framed region, a drop zone, a side drawer, a grid card | 8px | 12px | `lg` (6px) | 14px |
207
+ | `--ui-radius-nav` | a navigation row in the sidebar or the top bar | 6px | 8px | `lg` (6px) | 14px |
208
+ | `--ui-radius-window` | a dialog with a title bar | 10px | 14px | 8px | 24px |
209
+ | `--ui-radius-dialog` | a message box | 10px | 14px | 8px | 24px |
210
+ | `--ui-radius-callout` | a `Callout` and a `Toast` (`--ui-callout-radius` still wins) | 10px | 12px | 12px | 12px |
211
+
212
+ A part inside a popover stays concentric with it: a menu row (`inner`, 4px) sits
213
+ 4px inside its menu (`popover`, 8px). What floats highest rounds most.
214
+
215
+ The steps `--ui-radius-sm`, `-md` and `-lg` stay public at DSM's 3, 4 and 5px
216
+ for an App's own parts. On the mobile presentation the roles follow the steps
217
+ (declared again on its root in `adaptive.css`), so a step overridden there
218
+ moves them. On a touch phone (docs/mobile-native.md, "Phone look") the phone
219
+ scope sets the steps `--ui-radius-md` to 12px and `-lg` to 14px, so the roles
220
+ follow them there; a card on a page of cards and a grouped list take
221
+ `--ui-radius-card` (20px), and a sheet `--ui-radius-sheet` (24px).
222
+
223
+ Override a role to change only that kind of surface:
214
224
 
215
225
  ```css
216
226
  :root {
@@ -219,10 +229,31 @@ the role on that element instead.
219
229
  }
220
230
  ```
221
231
 
232
+ To keep the pre-0.25 corners in a DSM window, set the roles back to the steps:
233
+ `inner` 3px, `control`, `container` and `popover` 4px, `panel` and `nav` 5px,
234
+ `window` 3px, `dialog` 6px, `callout` 12px.
235
+
236
+ ## Focus, hover and the scrim
237
+
238
+ | Token | What | Value |
239
+ | --- | --- | --- |
240
+ | `--ui-focus-ring` | the keyboard focus colour | the accent (`--ui-accent-9`), solid |
241
+ | `--ui-shadow-focus` | keyboard focus on a control: a 2px gap in the surface colour, then a 2px ring | `0 0 0 2px surface, 0 0 0 4px ring` |
242
+ | `--ui-shadow-focus-inset` | keyboard focus inside a row, a cell or a tab strip | `inset 0 0 0 2px ring` |
243
+ | `--ui-shadow-focus-field` | a focused field, whose own border turns primary: a soft halo | `0 0 0 3px` `--ui-focus-halo` (`--ui-accent-a4`) |
244
+ | `--ui-state-hover` | the pointer on a ghost button or a menu row | 6% of the text colour |
245
+ | `--ui-state-pressed` | pressed (a ghost button, a touch control while the finger is down) | 8% of the text colour; `--ui-pressed` is a deprecated alias |
246
+ | `--ui-surface-hover` | the pointer on a list or table row (DSM's pale blue) | `--ui-accent-a2` |
247
+ | `--ui-overlay` | the scrim behind a modal | window: white 55%; web: `--ui-overlay-page`; mobile: `--ui-overlay-dim` (black 40%, as before) |
248
+ | `--ui-overlay-page` | the scrim on a full page | `rgba(15, 23, 32, 0.32)`; dark `rgba(0, 0, 0, 0.5)` |
249
+
250
+ A component never writes a ring out (`shadow-[0_0_0_3px_…]`); it uses one of
251
+ the focus tokens (`tests/css/focus-ring.test.ts`).
252
+
222
253
  ## Motion
223
254
 
224
255
  The components move a little by default: dialogs and their masks fade in and
225
- settle from 97 %, menus, popovers and select lists drop 4px from their trigger,
256
+ settle from 97 %, menus, popovers and select lists drop 6px from their trigger (settling from 96 %),
226
257
  a notification slides 8px in, expandable bodies (`Card`, `SettingsSection`,
227
258
  `ExpandableItem`, `Callout` details, `TimelineRow`, `FacetPanel`, `Accordion`,
228
259
  `Fieldset`) open their height, a row the user just added fades in, and a tab
package/docs/wizard.md CHANGED
@@ -57,15 +57,20 @@ const open = ref(false)
57
57
  does not stack its children with a gap. Several blocks go in one `Form`
58
58
  (fields one field gap apart) or a `<Space direction="column">`; a lone
59
59
  Callout, paragraph or card grid needs no wrapper.
60
- - **Choices.** A step's 2–4 options are choice cards:
60
+ - **Choices.** Options that each need an icon and a line are choice cards:
61
61
  `<RadioGroup variant="card" orientation="horizontal" :options>`. Each card
62
62
  shows the option's `icon` in a tile, its label and one line, and keeps the
63
63
  radio dot; the whole card picks it and the chosen card takes the primary
64
64
  border and a soft fill. The cards share the row (180px at least) and wrap
65
- in a narrow window.
65
+ in a narrow window. Cards usually suit 2–4 options; that is a guide, not a
66
+ limit, and nothing cuts a fifth. Five short options (symptoms, say) read
67
+ better as a vertical `RadioGroup` list, and long options do too even when
68
+ there are three. Pick by how the options read, not by their count.
66
69
  - **Types.** `welcome` offers only Start; `done` drops Back and Cancel, fills
67
- the progress and turns the main action into Finish. The last step without
68
- `next-step-key` turns it into Apply.
70
+ the progress and turns the main action into Finish; `result` ends the flow
71
+ with an outcome the app sets ([Result](#result)); `progress` shows work the
72
+ app runs ([Progress](#progress)). The last step without `next-step-key`
73
+ turns the main action into Apply.
69
74
  - **Main action.** `next-label`, `next-disabled` and `:show-next="false"` (a step
70
75
  that moves on by itself or through its own choices, such as a row of cards)
71
76
  change it for one step. `:show-back="false"` hides Back.
@@ -98,9 +103,12 @@ const open = ref(false)
98
103
  The `#footer` slot replaces the footer's buttons and receives `nextStep`,
99
104
  `previousStep`, `finish`, `cancel`, `canGoPrevious`, `isLastStep`, `busy` and
100
105
  `stepKey`. Events: `next-step`, `previous-step` (`{ from, to }`), `finished`,
101
- `cancel`. A step guard (`pre-enter-next-step`, `pre-enter-previous-step`)
102
- returning `false` keeps the step; the main action shows a spinner while it
103
- runs. The step list does not navigate, so a guard cannot be skipped.
106
+ `cancel` and `navigation-error` ([Guards](#guards)). The step list does not
107
+ navigate, so a guard cannot be skipped.
108
+
109
+ `finished` means the user took Finish (or Apply); it does not mean the work
110
+ succeeded. Read the outcome from your own state, and do not run the work again
111
+ in a `finished` callback.
104
112
 
105
113
  A wizard fills the height its container gives it (`WizardDialog` and
106
114
  `<Dialog flush height="…">` give it one); on a page, set a height on its
@@ -117,6 +125,220 @@ there is no Back and nothing shifts. A welcome step offers Start alone; a done
117
125
  step offers Finish (and its own `#actions`). There is no Close in the footer:
118
126
  a `WizardDialog` closes from its header.
119
127
 
128
+ ## Result
129
+
130
+ A `type="result"` step ends the flow with an outcome: `result-tone` is
131
+ `success`, `warning`, `danger` or `neutral` (the default, which claims neither
132
+ success nor failure). A partial import, a failed connection test or a scan
133
+ that finished with warnings is a result, never a `done` step: `done` always
134
+ shows the success mark and its burst.
135
+
136
+ ```vue
137
+ <WizardStep
138
+ step-key="result"
139
+ type="result"
140
+ :result-tone="tone"
141
+ headline="Some items were not imported"
142
+ description="The imported items are kept; check why the others failed and try again"
143
+ >
144
+ <FindingList :items="findings" />
145
+ <template #actions>
146
+ <Button variant="ghost" kind="tool" @click="startAgain">Check again</Button>
147
+ </template>
148
+ </WizardStep>
149
+ ```
150
+
151
+ - The step shows its tone's mark (a tick, a warning sign, a cross or an info
152
+ mark) on its colour, read as "Succeeded", "Warning", "Failed" or "Done". It
153
+ pops in once, with no ring and no burst for any tone.
154
+ - Finish is the one main action, blue for every tone: a red mark does not
155
+ make the button red. Back and Cancel are gone; the header's close button and
156
+ the step's `#actions` stay. `next-label` renames Finish.
157
+ - The guide marks the step with the outcome: a warning or a failure is not
158
+ drawn or read as completed. The steps before it read as completed: that says
159
+ the user passed them, not that the backend did each part.
160
+ - A result step never moves on: `next-step-key` is ignored (with a warning
161
+ in development).
162
+ - Map the outcome in the app: a rollback that worked can be `neutral`, a
163
+ partial or unknown rollback `warning`, a failed one `danger`.
164
+
165
+ ## Progress
166
+
167
+ A `type="progress"` step shows work the app runs: a progress bar and a status
168
+ line. The wizard only draws them; the app starts, watches and ends the work,
169
+ and moves the wizard on (`v-model:active-step-key`) when the backend says the
170
+ work ended.
171
+
172
+ ```vue
173
+ <WizardStep
174
+ step-key="scanning"
175
+ type="progress"
176
+ headline="Checking"
177
+ :progress="total > 0
178
+ ? { mode: 'determinate', label: 'Items checked', value: checked, max: total, status: statusText }
179
+ : { mode: 'indeterminate', label: 'Getting status', status: statusText }"
180
+ stop-label="Stop watching"
181
+ @stop="stopWatching"
182
+ />
183
+ ```
184
+
185
+ - `progress` is `{ mode: 'indeterminate', label, status? }` while the total is
186
+ unknown, or `{ mode: 'determinate', label, value, max, valueLabel?, status? }`.
187
+ Without it the bar is indeterminate and named by the headline. `status` is
188
+ read politely when it changes.
189
+ - The bar is on its own tinted track (never grey) and has no stripes or
190
+ pattern; reduced motion stills it.
191
+ - Next and Back are hidden unless the step sets `:show-next="true"` or
192
+ `:show-back="true"` (for work that only reads). Back never undoes work that
193
+ changed data.
194
+ - `stop-label` adds a secondary button that emits `stop`; `stop-disabled`
195
+ disables it while the app waits for the backend. "Stop watching" only stops
196
+ the app looking; "Cancel task" is for a backend that can cancel, and the app
197
+ shows a cancelled result only once the backend confirms it.
198
+ - There is no timeout, retry count or auto-advance: time passing is not a
199
+ failure. `lazy` only mounts the step's content; it starts and stops nothing.
200
+
201
+ ## Guards
202
+
203
+ `pre-enter-next-step` and `pre-enter-previous-step` run before a move. A guard
204
+ gets `{ from, direction, signal }` and returns:
205
+
206
+ | Return | Effect |
207
+ | --- | --- |
208
+ | nothing | the move goes on as the step says |
209
+ | `false` | the step stays |
210
+ | `{ goTo: 'key' }` | the move goes to that step instead, this time only |
211
+
212
+ ```ts
213
+ async function checkAgain({ signal }: WizardGuardContext): Promise<WizardGuardResult> {
214
+ const status = await api.status({ signal })
215
+ if (status.needsAccess) return { goTo: 'access' }
216
+ return status.ready ? undefined : false
217
+ }
218
+ ```
219
+
220
+ - The main action shows a spinner while a guard runs; presses meanwhile are
221
+ ignored, so the guard runs once.
222
+ - A `goTo` is checked once the app's update has rendered, so a step the
223
+ guard mounts with `v-if` can be its target. It moves through the usual
224
+ `next-step` or `previous-step` event, `update:active-step-key` and focus.
225
+ The target's own guard does not run.
226
+ - A `goTo` that names no registered step (`unknown-step`) or the step itself
227
+ (`same-step`) keeps the step and emits `navigation-error`. A guard that
228
+ throws or rejects keeps the step and emits `navigation-error` with
229
+ `guard-rejected`, without the error's text: show your own message.
230
+ - `signal` aborts when the move no longer matters: the wizard left the step,
231
+ the app reset `active-step-key`, the `WizardDialog` closed (not while "Keep
232
+ editing" is asked) or the wizard unmounted. The spinner stops at once, and
233
+ a reply that comes later moves nothing and emits nothing. Pass `signal` to
234
+ your requests so they stop too.
235
+ - A step key is not a permission. The target step and the backend still check
236
+ what the user may do.
237
+
238
+ A guard written as `() => false | void` still works as before.
239
+
240
+ ## Recipes
241
+
242
+ These use the API above; none needs anything new.
243
+
244
+ ### Branches the backend decides
245
+
246
+ Steps need not all be known up front. A guard loads the branch, and a
247
+ `next-step-key` function reads it; the wizard reads `next-step-key` after the
248
+ guard. A step only some branches need can be mounted with `v-if`; it takes its
249
+ place in document order.
250
+
251
+ ```vue
252
+ <WizardStep step-key="inspect" headline="Check the target"
253
+ :pre-enter-next-step="loadBranch"
254
+ :next-step-key="() => branch.needsAccess ? 'access' : 'preview'" />
255
+ <WizardStep v-if="branch.needsAccess" step-key="access" headline="Allow access" next-step-key="preview" />
256
+ <WizardStep step-key="preview" headline="Confirm" />
257
+ ```
258
+
259
+ Map each backend kind to a step key in the app, and treat an unknown kind as
260
+ "cannot continue": never use a backend string as a key. The progress counts
261
+ the mounted steps, so mount the path the user takes, not every branch; when
262
+ the total is unknown, `progress-text` can say so.
263
+
264
+ ### Two equal ways on
265
+
266
+ Keep or roll back, replace or keep: put the choices in one `RadioGroup` (cards
267
+ when they need an icon and a line) and let one main action confirm the chosen
268
+ one. Its `next-label` follows the choice; a choice never acts by itself, so
269
+ moving through the options with the arrow keys does nothing else. The footer
270
+ keeps one blue button. A choice that destroys data asks in a confirmation
271
+ dialog first, whose confirm button is the only red one.
272
+
273
+ ```vue
274
+ <WizardStep step-key="decision" headline="Choose what to do"
275
+ :show-back="false" :next-disabled="!decision"
276
+ :next-label="decision === 'rollback' ? 'Roll back' : 'Keep changes'"
277
+ :next-step-key="() => decision === 'rollback' ? 'working' : 'result'"
278
+ :pre-enter-next-step="confirmDecision">
279
+ <RadioGroup v-model="decision" variant="card" orientation="horizontal" :options="allowedDecisions" />
280
+ </WizardStep>
281
+ ```
282
+
283
+ Offer only the choices the backend allows: an interrupted run has no "keep".
284
+
285
+ ### Handing over to another wizard
286
+
287
+ The parent opens one wizard at a time. To hand over (a diagnosis opening a
288
+ fix): check what the source passes, stop the source's pending work, close the
289
+ source, then set the target's props and open it. Do not rely on two
290
+ `v-model:open` flips in one tick; mount the two exclusively (`v-if` on one
291
+ state) so only one dialog holds the focus. A handover is not the source's
292
+ Finish and not a rollback, and secrets do not travel in its props.
293
+
294
+ ```ts
295
+ type WizardRequest = { kind: 'diagnose' } | { kind: 'setup'; intent: 'send' | 'receive' } | undefined
296
+ ```
297
+
298
+ ### Watching work
299
+
300
+ For a [progress](#progress) step the app watches the backend:
301
+
302
+ 1. Watch only while the wizard is open on that step. Leaving, closing,
303
+ unmounting or reopening ends the round: clear its timer and abort its
304
+ request.
305
+ 2. One request at a time: schedule the next when the last ends, not with
306
+ `setInterval`. Follow the backend's retry-after; on errors back off slowly
307
+ (from about a second to 15 or 30 seconds). These are a schedule, not a
308
+ deadline.
309
+ 3. After every `await`, check that the round, the open state and the step are
310
+ still the same before writing anything.
311
+ 4. A network error shows "Status not up to date" with a way to check again and
312
+ keeps the last progress. Only the backend's own failed or interrupted state
313
+ leads to a failure result.
314
+ 5. Reopening reads the run from the backend and resumes; it does not apply
315
+ again.
316
+
317
+ ### Opening another DSM app
318
+
319
+ When a step needs the user to finish something in another app, open it with
320
+ the platform SDK's app launcher (it knows the app's verified ID; a package
321
+ name is not an app ID), and always show the path in words as well, for a host
322
+ that cannot open it. A secondary button opens it; the step's main action is
323
+ "Check again", whose guard reads the state again. Coming back to the wizard
324
+ only triggers a check: it is never a success by itself.
325
+
326
+ ```vue
327
+ <WizardStep step-key="external" headline="Finish in the other app"
328
+ next-label="Check again" :pre-enter-next-step="checkAgain">
329
+ <p>When you are done there, come back and check again</p>
330
+ <Button variant="secondary" kind="tool" @click="openApp">Open the app</Button>
331
+ <p v-if="launchUnavailable">Open it from the DSM main menu</p>
332
+ </WizardStep>
333
+ ```
334
+
335
+ ### Not provided
336
+
337
+ The wizard has no backend step schema or runner, no timeout or retry settings
338
+ for work, no two-primary footer, no card count option, no cross-wizard
339
+ navigation and no launcher of its own. These stay with the app (or the
340
+ platform SDK) on purpose.
341
+
120
342
  ## Modal
121
343
 
122
344
  `WizardDialog` opens like a DSM modal window, in the app (app-local, see
@@ -131,8 +353,16 @@ a `WizardDialog` closes from its header.
131
353
  step title) shows a ring only after a key, never after a click.
132
354
 
133
355
  These are `Dialog` props (`scrim="light"`, `:close-on-outside="false"`,
134
- `:header-divider="false"`), so a dialog of your own around a `Wizard` can
135
- open the same way.
356
+ `:header-divider="false"`, `presentation="modal"`), so a dialog of your own
357
+ around a `Wizard` can open the same way.
358
+
359
+ On a phone `WizardDialog` stays a centred window, as a DSM wizard does: the
360
+ phone's dialog corner, the phone's dim scrim, the same fixed header and
361
+ footer. It is as tall as its step, up to the wizard's height inside the
362
+ phone's safe area; only a longer step scrolls, between the header and the
363
+ footer. `presentation="adaptive"` keeps the bottom sheet instead, for an app
364
+ that wants its earlier phone look; other dialogs are bottom sheets on a phone
365
+ as before. The "Discard changes?" question is always a centred window.
136
366
 
137
367
  ## Leaving with input
138
368
 
@@ -164,6 +394,7 @@ in every built-in locale.
164
394
  - **Motion.** A step fades in from 10px on the side it comes from and its
165
395
  title fades in; the progress segments ease to their new state; the done mark
166
396
  pops with a ring and a short burst. Reduced motion turns all of it off.
167
- - **Size.** In a phone-width window the dialog is a bottom sheet.
397
+ - **Size.** In a phone-width window the dialog stays a centred window with a
398
+ margin around it; `presentation="adaptive"` makes it a bottom sheet.
168
399
  - **Theme.** Colours are the theme's roles, so the wizard follows the accent
169
400
  and the dark theme.