@kubex/zinc 1.1.92 → 1.1.95

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.
Files changed (53) hide show
  1. package/dist/custom-elements.json +2880 -453
  2. package/dist/vscode.html-custom-data.json +209 -22
  3. package/dist/web-types.json +452 -39
  4. package/dist/zn.d.ts +633 -61
  5. package/dist/zn.min.css +1 -1
  6. package/dist/zn.min.js +865 -560
  7. package/docs/pages/components/page-builder.md +143 -19
  8. package/docs/pages/components/schedule-builder.md +345 -0
  9. package/docs/pages/components/slash-menu.md +132 -6
  10. package/docs/pages/components/textarea.md +16 -0
  11. package/package.json +1 -1
  12. package/scss/_root.scss +7 -1
  13. package/src/components/alert/alert.scss +9 -13
  14. package/src/components/button/button.scss +5 -2
  15. package/src/components/chip/chip.scss +1 -1
  16. package/src/components/icon-picker/icon-picker.component.ts +1 -1
  17. package/src/components/inline-edit/inline-edit.component.ts +6 -1
  18. package/src/components/input/input.component.ts +12 -2
  19. package/src/components/linked-select/linked-select.component.ts +22 -5
  20. package/src/components/page/page.scss +20 -9
  21. package/src/components/page-builder/modules/page-section-card/page-section-card.component.ts +16 -9
  22. package/src/components/page-builder/modules/page-section-card/page-section-card.scss +13 -0
  23. package/src/components/page-builder/modules/page-section-card/page-section-card.test.ts +9 -0
  24. package/src/components/page-builder/page-builder.component.ts +535 -232
  25. package/src/components/page-builder/page-builder.scss +230 -19
  26. package/src/components/page-builder/page-builder.test.ts +790 -110
  27. package/src/components/page-builder/page-tree.test.ts +483 -0
  28. package/src/components/page-builder/page-tree.ts +329 -0
  29. package/src/components/page-builder/page.types.ts +98 -10
  30. package/src/components/page-nav/page-nav.scss +9 -1
  31. package/src/components/panel/panel.component.ts +5 -1
  32. package/src/components/priority-list/priority-list.component.ts +1 -0
  33. package/src/components/priority-list/priority-list.scss +2 -1
  34. package/src/components/remarkd-editor/remarkd-editor.component.ts +198 -9
  35. package/src/components/remarkd-editor/remarkd-editor.scss +81 -0
  36. package/src/components/remarkd-editor/remarkd-editor.test.ts +179 -0
  37. package/src/components/schedule-builder/index.ts +12 -0
  38. package/src/components/schedule-builder/schedule-builder.component.ts +1543 -0
  39. package/src/components/schedule-builder/schedule-builder.scss +448 -0
  40. package/src/components/schedule-builder/schedule-builder.test.ts +344 -0
  41. package/src/components/settings-container/settings-container.scss +2 -1
  42. package/src/components/slash-item/slash-item.component.ts +1 -1
  43. package/src/components/slash-menu/slash-menu-items.ts +48 -0
  44. package/src/components/slash-menu/slash-menu.component.ts +134 -27
  45. package/src/components/slash-menu/slash-menu.scss +90 -12
  46. package/src/components/slash-menu/slash-menu.test.ts +107 -0
  47. package/src/components/textarea/textarea.component.ts +12 -2
  48. package/src/components/textarea/textarea.test.ts +2 -2
  49. package/src/components/toggle/toggle.component.ts +2 -1
  50. package/src/components/translations/translations.component.ts +5 -1
  51. package/src/zinc.ts +1 -0
  52. package/docs/superpowers/plans/2026-08-03-theme-editor.md +0 -1536
  53. package/docs/superpowers/specs/2026-08-03-theme-editor-design.md +0 -327
@@ -27,6 +27,10 @@ are bound by their `name` attribute: values prefill from the section's data and
27
27
  description="Large banner with optional search">
28
28
  <zn-input name="title" label="Title"></zn-input>
29
29
  <zn-input name="subtitle" label="Subtitle"></zn-input>
30
+ <zn-input name="gradient" label="Background gradient" placeholder="linear-gradient(135deg, #00aaff, #aa00ff)"
31
+ help-text="CSS gradient painted behind the hero. Leave empty for the theme default."></zn-input>
32
+ <zn-toggle name="showSearch" label="Show search"
33
+ description="Draws the site search box in the banner."></zn-toggle>
30
34
  </template>
31
35
  <template type="article-list" slot="config" label="Article List" icon="list" category="Content"
32
36
  description="A list of KB articles">
@@ -40,13 +44,42 @@ Types can also be registered programmatically via `sectionTypes` /
40
44
  `registerSectionTypes()`, including a `renderConfig(section, update)` callback for
41
45
  inspector bodies that need real logic.
42
46
 
47
+ ## The inspector
48
+
49
+ Selecting a section opens the inspector, which heads the panel with the section type's
50
+ icon and colour, the section's name, its type (shown only where a renamed section makes
51
+ it useful), and a close button that clears the selection. Below that, a **Section name**
52
+ field renames the section (the card's label), followed by the type's stamped config form.
53
+ A type whose template has no content gets a hint in place of the form rather than an
54
+ empty panel.
55
+
56
+ Because the panel is narrow, the inspector sets its own form-label and help-text
57
+ typography rather than inheriting your app's full-width form styling, and its `gap` is
58
+ the single source of spacing between fields — a control's own outer margins are zeroed
59
+ so they can't stack with it. Stamped `<zn-toggle>`s are laid out as a row, with the
60
+ label and description left and the switch right; set `label-position` yourself on a
61
+ toggle to opt out.
62
+
63
+ Style the panel through `inspector`, `inspector-header` and `inspector-body` parts.
64
+
65
+ A container section additionally gets a **Layout** group at the top of the inspector: a column
66
+ count, a weight per column, a "Keep adding rows" toggle, and — when growing is off — a row count.
67
+ Every edit here is lossless: changing the column count re-chunks the same ordered list of
68
+ stacks into the new shape, changing the row count only pads or trims trailing empty rows
69
+ (clamping at the last row that still holds content), and changing a column's weight leaves
70
+ every cell's contents untouched. All of it is undoable like any other edit.
71
+
43
72
  ## JavaScript API
44
73
 
45
74
  - `state` — get/set the current `PageState`. The getter returns a deep copy; the setter
46
75
  replaces the state wholesale (like the `config` attribute, it does not emit `zn-page-change`).
47
76
  - `addSection(type, index?)` — insert a new section of a registered type (default: at the end).
48
- - `addSectionToSlot(type, containerId, slotIndex)` — insert into a container's empty slot,
49
- honouring its `accepts` list. Returns the new section, or `null` if not allowed.
77
+ - `addSectionToCell(type, containerId, cellIndex, insertIndex?)` — insert a new section of a
78
+ registered type into a container's cell. `cellIndex` is the position in the container's flat,
79
+ row-major `cells` list; `insertIndex` (default `0`, the top of the stack) is where in that
80
+ cell's stack it lands. Returns the new section, or `null` if the drop isn't allowed — because
81
+ the type isn't in the container's `accepts` list, or because it would exceed the two-level
82
+ nesting cap.
50
83
  - `undo()` / `redo()` — step through edit history (bounded at 50 entries). There is no built-in
51
84
  toolbar: wire these to your own header buttons or keyboard shortcuts.
52
85
  - `registerSectionType(type)` / `registerSectionTypes(types)` — programmatic registration,
@@ -92,8 +125,8 @@ or call `restoreAutoSave()` yourself.
92
125
 
93
126
  Every edit emits `zn-page-change` with the full page state (`event.detail.state`, also
94
127
  readable via the `state` property) — plain JSON the host persists and later feeds back in
95
- through the `config` attribute. Sections appear in page order; container sections carry a
96
- `children` array sized to their slot count, with `null` for empty slots:
128
+ through the `config` attribute. Sections appear in page order; a container section additionally
129
+ carries `layout` and `cells`, per the Containers section below:
97
130
 
98
131
  ```json
99
132
  {
@@ -108,10 +141,11 @@ through the `config` attribute. Sections appear in page order; container section
108
141
  "type": "article-grid",
109
142
  "label": "Popular articles",
110
143
  "data": {"title": "Popular"},
111
- "children": [
112
- {"id": "s-mc42h-2", "type": "article-tile", "data": {"article": "art_42"}},
113
- {"id": "s-mc42p-3", "type": "article-tile", "data": {"article": "art_7"}},
114
- null, null, null, null
144
+ "layout": {"widths": [1, 1, 1], "grow": false},
145
+ "cells": [
146
+ [{"id": "s-mc42h-2", "type": "article-tile", "data": {"article": "art_42"}}],
147
+ [{"id": "s-mc42p-3", "type": "article-tile", "data": {"article": "art_7"}}],
148
+ []
115
149
  ]
116
150
  },
117
151
  {
@@ -123,29 +157,119 @@ through the `config` attribute. Sections appear in page order; container section
123
157
  }
124
158
  ```
125
159
 
126
- ## Container tiles
160
+ A host loading a config saved before this model — the old flat, null-padded `children` array —
161
+ still has it accepted and migrated on load; see "Legacy `slots`" further down for what that
162
+ older shape looked like and how it's converted.
127
163
 
128
- A section type with a `slots` attribute becomes a full-row container: its card renders a
129
- 3-column grid of that many child slots beneath it. Drag sections from the palette into empty
130
- cells, drag children **between cells to reorder**, or out onto the page. `accepts` restricts
131
- which types the slots take. Containers can't be placed inside other containers, and slot
132
- contents persist as `children` on the section (empty slots are `null`).
164
+ ## A required first section
165
+
166
+ Pages that must always open with a particular section a hero banner, a masthead — set
167
+ `required-first` to that section type. The builder hoists an existing section of the type to
168
+ the top of the page, or inserts an empty one when there is none, and pins it there: it has no
169
+ remove action, ignores <kbd>Delete</kbd>, can't be dragged or moved into a container slot, and
170
+ nothing can be dropped above it. Its content stays fully editable in the inspector, and the
171
+ type stays in the palette, so further sections of it can still be added below.
172
+
173
+ Which section is pinned is derived from the state — `sections[0]` when its type matches — so
174
+ nothing about the lock is written into the persisted config.
175
+
176
+ ```html:preview
177
+ <zn-page-builder heading="KB Homepage" required-first="hero" style="height: 420px"
178
+ config='{"sections":[{"id":"t1","type":"rich-text","data":{"content":"Welcome"}}]}'>
179
+ <template type="hero" slot="config" label="Hero" icon="star" category="Headers"
180
+ description="Banner with a heading and optional search">
181
+ <zn-input name="title" label="Title"></zn-input>
182
+ <zn-toggle name="showSearch" label="Show search"></zn-toggle>
183
+ </template>
184
+ <template type="rich-text" slot="config" label="Rich Text" icon="notes" category="Content"
185
+ description="A block of markdown content">
186
+ <zn-input name="content" label="Content"></zn-input>
187
+ </template>
188
+ </zn-page-builder>
189
+ ```
190
+
191
+ The config above declares only a rich-text section, so the hero is inserted above it — loading a
192
+ page that lacks the required section normalises it rather than rejecting it. Two things follow
193
+ from that. The inserted section's `data` is empty, so a host that wants the pinned section
194
+ prefilled should put it into the `config` it hands over rather than rely on the insert. And the
195
+ guard is client-side, so a host that persists the config should enforce the same rule on save.
196
+
197
+ ## Containers
198
+
199
+ A section type with the `container` attribute becomes a full-row container: its card renders a
200
+ grid of **cells** beneath it, and each cell holds an ordered **stack** of sections. The type
201
+ author only declares that it's a container and what it starts as; the editor reshapes it after
202
+ placing it, from the inspector's Layout group.
203
+
204
+ | Attribute | Meaning |
205
+ |---|---|
206
+ | `container` | Marks the type a container. Required. |
207
+ | `columns="4"` | Seeds a new instance with 4 equal columns. |
208
+ | `widths="1 2 1"` | Seeds the column weights directly (comma- or whitespace-separated). Wins over `columns`. |
209
+ | `grow` | Seeds the instance growable — it always offers a further empty row. |
210
+ | `accepts="a,b"` | Restricts which types the cells take. Omit to allow any type, within the nesting cap below. |
211
+
212
+ If `widths` is present but unparsable — non-numeric tokens are discarded rather than kept as
213
+ columns — the container falls back to `columns`, and if that's absent too, to three equal
214
+ columns (`[1, 1, 1]`).
215
+
216
+ Drag sections from the palette into a cell, stack several sections in one cell, or drag them
217
+ between cells or out onto the page. Containers may nest **two levels deep** — a container inside
218
+ a cell, itself holding another container — and a drop that would nest a third level is refused.
219
+ That cap holds even when a container's `accepts` list names another container type: `accepts`
220
+ can't be used to bypass it.
133
221
 
134
222
  ```html:preview
135
223
  <zn-page-builder heading="KB Homepage" style="height: 560px"
136
- config='{"sections":[{"id":"g1","type":"article-grid","data":{}}]}'>
137
- <template type="article-grid" slot="config" label="Article Grid" icon="grid_view"
138
- category="Layout" description="A 3x2 grid of article tiles"
139
- slots="6" accepts="article-tile">
224
+ config='{"sections":[{"id":"g1","type":"row","data":{},"layout":{"widths":[1,2,1],"grow":false},"cells":[[],[],[]]}]}'>
225
+ <template type="row" slot="config" label="Row" icon="view_column" category="Layout"
226
+ description="A row of columns you can weight" container widths="1 2 1">
227
+ <zn-input name="title" label="Row title"></zn-input>
228
+ </template>
229
+ <template type="grid" slot="config" label="Tile Grid" icon="grid_view" category="Layout"
230
+ description="Keeps adding rows as you fill it" container columns="3" grow>
140
231
  <zn-input name="title" label="Grid title"></zn-input>
141
232
  </template>
142
- <template type="article-tile" slot="config" label="Article" icon="article" category="Content"
233
+ <template type="article" slot="config" label="Article" icon="article" category="Content"
143
234
  description="A single article tile">
144
235
  <zn-input name="article" label="Article id"></zn-input>
145
236
  </template>
146
237
  </zn-page-builder>
147
238
  ```
148
239
 
240
+ ### The container config
241
+
242
+ A container persists its `layout` and its `cells` — a flat, row-major list of stacks whose
243
+ length is always a whole multiple of `layout.widths.length`. Rows are implicit
244
+ (`cells.length / layout.widths.length`), so there's no row count to keep in sync:
245
+
246
+ ```json
247
+ {
248
+ "id": "s-mc42a-1",
249
+ "type": "row",
250
+ "layout": { "widths": [1, 2, 1], "grow": false },
251
+ "cells": [
252
+ [ { "id": "s-1", "type": "nav", "data": {} },
253
+ { "id": "s-2", "type": "links", "data": {} } ],
254
+ [ { "id": "s-3", "type": "hero", "data": {} } ],
255
+ []
256
+ ]
257
+ }
258
+ ```
259
+
260
+ Render it by mapping each weight to a grid track and each cell to a stack. A **growable**
261
+ container never persists a trailing all-empty row — the builder adds that row itself at render
262
+ time, so don't expect it in the JSON. A **fixed** container's trailing empty row, by contrast, is
263
+ part of its layout and does persist.
264
+
265
+ ### Legacy `slots`
266
+
267
+ `slots="6"` still declares a container, and pages persisted with the old flat, null-padded
268
+ `children` array still load: they're migrated to `layout` + `cells` on load and re-saved in the
269
+ new shape — `children` is never written back. A `slots`-declared container that has no explicit
270
+ `accepts` also keeps its older, stricter rule of refusing container types in its cells (rather
271
+ than allowing anything up to the nesting cap). Prefer `container` going forward.
272
+
149
273
  ## List sections
150
274
 
151
275
  Sections that show a set of existing items (categories, articles, …) reference them by id:
@@ -0,0 +1,345 @@
1
+ ---
2
+ meta:
3
+ title: Schedule Builder
4
+ description: Builds a weekly opening-hours schedule as a drag-to-paint calendar or a compact list of time ranges, and posts the result as JSON.
5
+ layout: component
6
+ ---
7
+
8
+ ```html:preview
9
+ <zn-schedule-builder
10
+ name="opening-hours"
11
+ value='{
12
+ "timezone": "Europe/London",
13
+ "days": {
14
+ "mon": ["08:00-18:00"],
15
+ "tue": ["08:00-18:00"],
16
+ "wed": ["08:00-18:00"],
17
+ "thu": ["08:30-18:00"],
18
+ "fri": ["08:00-18:00"],
19
+ "sat": ["09:00-13:00"],
20
+ "sun": []
21
+ },
22
+ "exceptions": [
23
+ {"label": "All-hands offsite", "date": "2026-08-28", "ranges": ["08:00-13:00"]},
24
+ {"label": "Systems maintenance", "date": "2026-09-14", "ranges": ["10:00-18:00"]},
25
+ {"label": "Christmas Eve — early close", "date": "2026-12-24", "ranges": ["08:00-13:00"]}
26
+ ]
27
+ }'>
28
+ </zn-schedule-builder>
29
+ ```
30
+
31
+ :::tip
32
+ This component works with standard `<form>` elements. Please refer to the section
33
+ on [form controls](/getting-started/form-controls) to learn more about form submission and client-side validation.
34
+ :::
35
+
36
+ ## The Value
37
+
38
+ The schedule is serialised to JSON and exposed three ways:
39
+
40
+ - as the `value` attribute/property — a JSON string, which is what gets submitted with the form;
41
+ - as the `schedule` property — the same data as a plain object;
42
+ - as the `zn-change` event, emitted whenever the schedule changes.
43
+
44
+ ```json
45
+ {
46
+ "timezone": "UTC",
47
+ "days": {
48
+ "mon": [{ "start": "08:00", "end": "18:00" }],
49
+ "tue": [{ "start": "08:00", "end": "12:00" }, { "start": "13:30", "end": "18:00" }],
50
+ "wed": [], "thu": [], "fri": [], "sat": [], "sun": []
51
+ },
52
+ "exceptions": [
53
+ { "label": "Christmas Eve — early close", "date": "2026-12-24", "ranges": [{ "start": "08:00", "end": "13:00" }] }
54
+ ]
55
+ }
56
+ ```
57
+
58
+ Ranges are always sorted, and overlapping or touching ranges are merged, so `08:00–12:00` plus
59
+ `12:00–18:00` becomes a single `08:00–18:00`. Times are 24 hour `HH:MM` regardless of the
60
+ `time-format` used for display, and `timezone` names the timezone they are expressed in — see
61
+ [Timezones](#timezones).
62
+
63
+ When you write the value yourself you can use the `"08:00-18:00"` shorthand instead of
64
+ `{"start": "08:00", "end": "18:00"}`, and you can pass a bare day map without the `days` wrapper.
65
+ Both are normalised on the way in.
66
+
67
+ ## Examples
68
+
69
+ ### Views
70
+
71
+ The builder has two views. `calendar` is a drag-to-paint week grid; `form` is a compact list of time
72
+ ranges that fits inside a normal settings form. Use the `view` attribute to choose the starting
73
+ view, and `no-toggle` to remove the switcher and lock the component to one of them.
74
+
75
+ ```html:preview
76
+ <zn-schedule-builder
77
+ view="form"
78
+ value='{"mon":["08:00-18:00"],"tue":["08:00-18:00"],"wed":["08:00-12:00","13:30-18:00"],"thu":["08:00-18:00"],"fri":["08:00-16:00"],"sat":["09:00-13:00"],"sun":[]}'>
79
+ </zn-schedule-builder>
80
+ ```
81
+
82
+ In the form view, click a range to edit it, and use **Add range** to split a day into multiple
83
+ periods. In the calendar view, drag across the grid to open hours and drag over open hours to close
84
+ them — a drag can span several days at once.
85
+
86
+ ### Timezones
87
+
88
+ A schedule has two timezones: the one it is **stored** in, and the one it is **shown** in.
89
+
90
+ - `save-timezone` is the timezone the value is written in. It defaults to `UTC` as soon as the
91
+ schedule is timezone-aware, so hours land in your database in one canonical zone.
92
+ - `display-timezone` is the timezone the grid and the list are drawn in. It defaults to
93
+ `save-timezone`, and accepts `auto` for the viewer's own timezone.
94
+ - `show-timezone` adds a picker so the viewer can read the same schedule in any timezone.
95
+
96
+ Switching the display timezone never changes the value — the same hours are simply labelled in
97
+ another zone, and `zn-change` doesn't fire. Edits work the other way around: you drag or type in the
98
+ zone you are looking at, and the component converts back to `save-timezone` before storing.
99
+
100
+ ```html:preview
101
+ <zn-schedule-builder
102
+ id="tz-demo"
103
+ show-timezone
104
+ display-timezone="Europe/London"
105
+ start-hour="0"
106
+ end-hour="24"
107
+ hide-summary
108
+ value='{"timezone":"UTC","days":{"mon":["08:00-18:00"],"tue":["08:00-18:00"],"wed":["08:00-18:00"],"thu":["08:00-18:00"],"fri":["08:00-16:00"]}}'>
109
+ </zn-schedule-builder>
110
+
111
+ <pre id="tz-demo-output" style="margin-top: 1rem"></pre>
112
+
113
+ <script>
114
+ const tzBuilder = document.querySelector('#tz-demo');
115
+ const tzOutput = document.querySelector('#tz-demo-output');
116
+
117
+ const printTz = () => (tzOutput.textContent = `stored (UTC):\n${JSON.stringify(tzBuilder.schedule.days, null, 2)}`);
118
+
119
+ tzBuilder.addEventListener('zn-change', printTz);
120
+ printTz();
121
+ </script>
122
+ ```
123
+
124
+ Pick a timezone far from UTC and the blocks slide, wrapping onto the next day — or onto Monday, from
125
+ the end of Sunday — while the stored hours below stay put.
126
+
127
+ #### Which Timezones the Picker Offers
128
+
129
+ `timezones` takes IANA names, or one of three named sets:
130
+
131
+ | Set | Zones | What it is |
132
+ | --- | --- | --- |
133
+ | `en` | 6 | US Eastern, Central, Mountain and Pacific, the UK, and Australia — listed under those names rather than their IANA ones. For an English-speaking audience who would rather not read `America/Los_Angeles`. |
134
+ | `offsets` | ~42 | One zone per UTC offset. The default — enough to read a schedule from anywhere without a long list, though the city standing in for each offset is arbitrary. |
135
+ | `common` | ~90 | Every offset in use, plus the business and population centres that share one — so Berlin, Paris, Madrid and Rome all appear rather than one standing for the rest. Includes the half and quarter-hour zones (India, Iran, Nepal, Newfoundland, central Australia, Chatham). |
136
+ | `all` | ~420 | Everything `Intl.supportedValuesOf('timeZone')` reports. Complete, but full of aliases and zones nobody selects. |
137
+
138
+ `common` is the one to reach for whenever real users pick their own timezone; `offsets` suits an
139
+ internal tool where the offset is all that matters; `en` suits a product whose customers are all in
140
+ the US, the UK or Australia.
141
+
142
+ ```html:preview
143
+ <zn-schedule-builder
144
+ show-timezone
145
+ timezones="en"
146
+ display-timezone="Europe/London"
147
+ view="form"
148
+ value='{"timezone":"UTC","days":{"mon":["13:00-21:00"],"tue":["13:00-21:00"]}}'>
149
+ </zn-schedule-builder>
150
+ ```
151
+
152
+ ```html:preview
153
+ <zn-schedule-builder
154
+ show-timezone
155
+ timezones="common"
156
+ display-timezone="auto"
157
+ view="form"
158
+ value='{"timezone":"UTC","days":{"mon":["13:00-21:00"],"tue":["13:00-21:00"]}}'>
159
+ </zn-schedule-builder>
160
+ ```
161
+
162
+ Sets and explicit names can be mixed — `timezones="en Asia/Tokyo"` adds Tokyo to the six above, and a
163
+ set's friendly name wins over a later plain listing of the same zone. The viewer's own timezone plus
164
+ both configured zones are always added, so the current selection is never missing from the list.
165
+
166
+ ```html:preview
167
+ <zn-schedule-builder
168
+ show-timezone
169
+ display-timezone="America/New_York"
170
+ timezones="UTC Europe/London America/New_York Asia/Tokyo"
171
+ view="form"
172
+ value='{"timezone":"UTC","days":{"mon":["13:00-21:00"],"tue":["13:00-21:00"]}}'>
173
+ </zn-schedule-builder>
174
+ ```
175
+
176
+ Reading the hours in code follows the same split: `schedule`, `value` and `getDay()` are in the save
177
+ timezone, while `displayedDays` and `setDisplayDay()` are in the display timezone.
178
+
179
+ :::tip
180
+ A weekly pattern has no date of its own, so there is no way to know whether daylight saving applies
181
+ to it. Offsets are resolved against today by default; set `reference-date` to a `YYYY-MM-DD` date to
182
+ pin them to a specific point in the year.
183
+ :::
184
+
185
+ ### Open and Closed Labels
186
+
187
+ `open-label` and `closed-label` name the two states. They appear in the calendar legend, against
188
+ days with no hours, and in the exception annotations, so use whatever wording fits the thing being
189
+ scheduled — availability, cover, staffing, opening hours.
190
+
191
+ ```html:preview
192
+ <zn-schedule-builder
193
+ open-label="On call"
194
+ closed-label="Off"
195
+ start-hour="8"
196
+ end-hour="20"
197
+ hide-summary
198
+ value='{"mon":["09:00-17:00"],"tue":["09:00-17:00"],"wed":["09:00-17:00"],"thu":["09:00-17:00"],"fri":["09:00-13:00"]}'>
199
+ </zn-schedule-builder>
200
+ ```
201
+
202
+ ### Labels and Help Text
203
+
204
+ ```html:preview
205
+ <zn-schedule-builder
206
+ label="Opening hours"
207
+ help-text="Customers can only book appointments during these hours."
208
+ view="form"
209
+ value='{"mon":["09:00-17:00"],"tue":["09:00-17:00"],"wed":["09:00-17:00"],"thu":["09:00-17:00"],"fri":["09:00-17:00"]}'>
210
+ </zn-schedule-builder>
211
+ ```
212
+
213
+ ### Grid Range and Granularity
214
+
215
+ `start-hour` and `end-hour` set the window the calendar draws, and `interval` sets both the grid
216
+ granularity and the step of the time inputs in the form view. The interval must be one of `5`, `10`,
217
+ `15`, `20`, `30` or `60` minutes.
218
+
219
+ ```html:preview
220
+ <zn-schedule-builder
221
+ start-hour="7"
222
+ end-hour="13"
223
+ interval="15"
224
+ hide-summary
225
+ value='{"mon":["08:00-12:00"],"tue":["08:15-11:45"],"wed":["08:00-12:00"],"thu":["08:00-12:00"],"fri":["08:00-11:00"]}'>
226
+ </zn-schedule-builder>
227
+ ```
228
+
229
+ ### Exceptions
230
+
231
+ Exceptions are dated deviations from the weekly pattern. The builder never edits them — it displays
232
+ them, and carries them through the value untouched — so the surrounding application stays in charge
233
+ of how they are created.
234
+
235
+ Hours an exception removes are drawn in a lighter shade in the calendar, and annotated against the
236
+ day in the form view. An exception can be a single `date`, or a `from`/`to` window, and can either
237
+ close the day outright (`closed`) or replace its hours (`ranges`).
238
+
239
+ Only exceptions that change the repeating weekly pattern are drawn against it: ones that name their
240
+ `days`, and ones whose date window covers at least a full week. A one-off date is a single
241
+ occurrence rather than a pattern, so it is carried through the value untouched but leaves the grid
242
+ alone.
243
+
244
+ ```html:preview
245
+ <zn-schedule-builder
246
+ view="form"
247
+ value='{
248
+ "days": {
249
+ "mon": ["08:00-18:00"],
250
+ "tue": ["08:00-18:00"],
251
+ "wed": ["08:00-18:00"],
252
+ "thu": ["08:00-18:00"],
253
+ "fri": ["08:00-16:00"],
254
+ "sat": ["09:00-13:00"],
255
+ "sun": []
256
+ },
257
+ "exceptions": [
258
+ {"label": "Summer Fridays", "from": "2026-06-01", "to": "2026-08-29", "days": ["fri"], "ranges": ["08:00-16:00"]},
259
+ {"label": "Bank holiday", "date": "2026-08-31", "closed": true}
260
+ ]
261
+ }'>
262
+ </zn-schedule-builder>
263
+ ```
264
+
265
+ ### Week Start and Time Format
266
+
267
+ ```html:preview
268
+ <zn-schedule-builder
269
+ week-start="sun"
270
+ time-format="12"
271
+ view="form"
272
+ value='{"mon":["08:00-18:00"],"sat":["09:00-13:00"],"sun":["10:00-16:00"]}'>
273
+ </zn-schedule-builder>
274
+ ```
275
+
276
+ ### Readonly and Disabled
277
+
278
+ Use `readonly` to display a schedule without any editing affordances, and `disabled` to grey it out
279
+ and take it out of form submission.
280
+
281
+ ```html:preview
282
+ <zn-schedule-builder
283
+ readonly
284
+ view="form"
285
+ value='{"mon":["08:00-18:00"],"tue":["08:00-18:00"],"wed":["08:00-18:00"],"thu":["08:00-18:00"],"fri":["08:00-18:00"],"sat":["09:00-13:00"]}'>
286
+ </zn-schedule-builder>
287
+ ```
288
+
289
+ ### Reading the Value from JavaScript
290
+
291
+ ```html:preview
292
+ <zn-schedule-builder
293
+ id="schedule-demo"
294
+ view="form"
295
+ value='{"mon":["09:00-17:00"],"tue":["09:00-17:00"]}'>
296
+ </zn-schedule-builder>
297
+
298
+ <pre id="schedule-demo-output" style="margin-top: 1rem"></pre>
299
+
300
+ <script>
301
+ const builder = document.querySelector('#schedule-demo');
302
+ const output = document.querySelector('#schedule-demo-output');
303
+
304
+ const print = () => (output.textContent = JSON.stringify(builder.schedule, null, 2));
305
+
306
+ builder.addEventListener('zn-change', print);
307
+ print();
308
+ </script>
309
+ ```
310
+
311
+ ### In a Form
312
+
313
+ The schedule posts as a single JSON field named after the control.
314
+
315
+ ```html:preview
316
+ <form class="schedule-form">
317
+ <zn-schedule-builder
318
+ name="opening-hours"
319
+ label="Opening hours"
320
+ required
321
+ view="form"
322
+ value='{"mon":["09:00-17:00"],"tue":["09:00-17:00"],"wed":["09:00-17:00"],"thu":["09:00-17:00"],"fri":["09:00-17:00"]}'>
323
+ </zn-schedule-builder>
324
+
325
+ <br />
326
+ <zn-button type="submit">Submit</zn-button>
327
+ </form>
328
+
329
+ <script>
330
+ const form = document.querySelector('.schedule-form');
331
+
332
+ form.addEventListener('submit', event => {
333
+ event.preventDefault();
334
+ const data = new FormData(form);
335
+ alert(data.get('opening-hours'));
336
+ });
337
+ </script>
338
+ ```
339
+
340
+ ## Accessibility
341
+
342
+ The form view is fully keyboard operable: ranges are buttons, editing a range gives you two native
343
+ time inputs, and <kbd>Enter</kbd> or <kbd>Escape</kbd> closes the editor. The calendar view is a
344
+ pointer-driven surface, so offer the form view (the default toggle does) wherever keyboard-only
345
+ users need to edit a schedule.