@johnmorrisdotca/kazoku-ui 0.3.0 → 0.7.2

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 (61) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +81 -5
  3. package/dist/address-state/address.d.ts +76 -0
  4. package/dist/address-state/address.js +171 -0
  5. package/dist/address-state/index.d.ts +1 -0
  6. package/dist/address-state/index.js +2 -0
  7. package/dist/index.d.ts +1 -1
  8. package/dist/index.js +1 -1
  9. package/dist/language-picker/define.d.ts +1 -0
  10. package/dist/language-picker/define.js +9 -0
  11. package/dist/language-picker/element.d.ts +39 -0
  12. package/dist/language-picker/element.js +194 -0
  13. package/dist/language-picker/index.d.ts +4 -0
  14. package/dist/language-picker/index.js +5 -0
  15. package/dist/language-picker/languages.d.ts +38 -0
  16. package/dist/language-picker/languages.js +100 -0
  17. package/dist/language-picker/styles.d.ts +1 -0
  18. package/dist/language-picker/styles.js +3 -0
  19. package/dist/language-picker/words.d.ts +8 -0
  20. package/dist/language-picker/words.js +7 -0
  21. package/dist/save-image/define.d.ts +1 -0
  22. package/dist/save-image/define.js +9 -0
  23. package/dist/save-image/element.d.ts +39 -0
  24. package/dist/save-image/element.js +186 -0
  25. package/dist/save-image/format.d.ts +20 -0
  26. package/dist/save-image/format.js +37 -0
  27. package/dist/save-image/index.d.ts +6 -0
  28. package/dist/save-image/index.js +8 -0
  29. package/dist/save-image/render.d.ts +55 -0
  30. package/dist/save-image/render.js +188 -0
  31. package/dist/save-image/styles.d.ts +1 -0
  32. package/dist/save-image/styles.js +9 -0
  33. package/dist/save-image/words.d.ts +12 -0
  34. package/dist/save-image/words.js +7 -0
  35. package/dist/shared/adopt.d.ts +4 -0
  36. package/dist/shared/adopt.js +34 -0
  37. package/dist/shared/base.d.ts +1 -0
  38. package/dist/shared/base.js +40 -0
  39. package/dist/shared/scheme.d.ts +1 -0
  40. package/dist/shared/scheme.js +19 -0
  41. package/dist/shared/store.d.ts +2 -0
  42. package/dist/shared/store.js +17 -0
  43. package/dist/shared/target.d.ts +2 -0
  44. package/dist/shared/target.js +11 -0
  45. package/dist/theme-switch/define.d.ts +1 -0
  46. package/dist/theme-switch/define.js +9 -0
  47. package/dist/theme-switch/element.d.ts +39 -0
  48. package/dist/theme-switch/element.js +203 -0
  49. package/dist/theme-switch/index.d.ts +4 -0
  50. package/dist/theme-switch/index.js +5 -0
  51. package/dist/theme-switch/styles.d.ts +1 -0
  52. package/dist/theme-switch/styles.js +8 -0
  53. package/dist/theme-switch/theme.d.ts +29 -0
  54. package/dist/theme-switch/theme.js +43 -0
  55. package/dist/theme-switch/words.d.ts +11 -0
  56. package/dist/theme-switch/words.js +7 -0
  57. package/dist/view-controls/controller.d.ts +6 -1
  58. package/dist/view-controls/controller.js +26 -3
  59. package/dist/view-controls/element.d.ts +1 -1
  60. package/dist/view-controls/element.js +54 -24
  61. package/package.json +67 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,69 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.2] - 2026-11-02
4
+
5
+ One release workflow for all the family's packages.
6
+
7
+ - `.github/workflows/reusable-release.yml` (`workflow_call`): from main, the tag equals `package.json`'s version, the repo's check script runs, a version already on npm is skipped, the `v<version>` tag is made if missing, then `npm publish --provenance` by trusted publishing. Inputs: `node-version`, `install-command`, `check-command`, `github-release`.
8
+ - This repo's `release.yml` now only calls it (same file name, so its npmjs.com trusted-publisher entry is unchanged) and still makes the GitHub release with the tarball.
9
+ - `docs/release-workflow.md`: npm trusted publishing accepts a called workflow; the publisher entry names the calling file; how to move another repo over.
10
+
11
+ ## [0.7.1] - 2026-11-02
12
+
13
+ The automatic turn no longer runs when nothing can see the view.
14
+
15
+ - While the `for` element (else the controls) is scrolled out of sight, or the tab is hidden, the frame loop stops: no animation frame is requested and no `kazoku-viewchange` with reason `auto` is sent. `turning` stays true and the turn resumes where it was. It uses `IntersectionObserver` and `visibilitychange`; without `IntersectionObserver` the view counts as on screen.
16
+ - New attribute `autoplay-for` (seconds): the `autoplay` turn stops by itself after that long, counting only time spent turning. Left out, nothing changes.
17
+ - The controller gains `suspendTurn()`, `resumeTurn()` (nesting) and `play(seconds)`. A bare `play()` and the Auto-turn button are unchanged.
18
+
19
+ ## [0.7.0] - 2026-11-02
20
+
21
+ The fifth part: save as an image, at `@johnmorrisdotca/kazoku-ui/save-image` (and `/save-image/element`, `/save-image/define`).
22
+
23
+ - `<kazoku-save-image for="id">`: Save PNG and Save SVG buttons (48 px, light and dark, English and Japanese) for a canvas, an SVG or any element. A canvas is saved as a PNG at its own pixel size. An SVG is saved as a stand-alone SVG (namespaces, size, `viewBox` and the styles it is drawn in written into it) or as a PNG; any other element as a PNG drawn through a `foreignObject` with its computed styles, a canvas inside it drawn in as a picture.
24
+ - Attributes `formats`, `name`, `scale` (2 by default, held to 8192 pixels a side and 36 million in all), `background` (the colour behind a PNG), `manual` (make the picture and the event, download nothing), `no-inline-styles`, `lang`, `theme`. `element.save(format)` and `element.target`.
25
+ - Events `kazoku-save` (`{ blob, format, width, height, filename, reason }`) and `kazoku-saveerror` (`{ format, error, reason }`), and a status line for a screen reader.
26
+ - The element-free functions (`renderImage`, `saveImage`, `downloadBlob`, `serializeSvg`, `serializeElement`, `svgToPng`, `filenameFor`, `parseFormats`, `pixelSize`) import on a server. Docs: `docs/save-image.md`, with what a picture of an element cannot carry (web fonts, `::before`, cross-origin images).
27
+ - Under a strict policy it needs `img-src data:` for an SVG or element PNG (a canvas needs nothing); `demo/csp.html` now has `img-src data:` and a save button.
28
+ - The demo has `save-image.html`: a canvas, an SVG and a piece of the page, each saved and shown back. Existing elements are unchanged.
29
+
30
+ ## [0.6.0] - 2026-11-02
31
+
32
+ The fourth part: choices kept in the address, at `@johnmorrisdotca/kazoku-ui/address-state`. No element.
33
+
34
+ - `createAddressState({ defaults, delay, host })`: `get`, `has`, `all`, `set`, `setMany`, `remove`, `subscribe(listener, keys?)`, `flush`, `destroy`. It writes with `replaceState` (no history entry; the path, hash and other parameters kept; a value equal to its default leaves the address), as one write for the changes inside `delay` (250 ms by default; 0 writes at once). A subscriber hears each change made through any state on the page, with `{ key, value, previous, source }`, and the reader going back or forward (`source: "navigation"`). A page that cannot change its address still works.
35
+ - The smaller pieces: `readParams`, `readParam`, `applyParams(search, changes, defaults?)` and `createAddressWriter({ delay, host })` (`write(compute)`, `flush()`, `cancel()`). All of it imports on a server.
36
+ - The view controls now write their address through `createAddressWriter`, the theme switch and the language picker through `createAddressState`: the same addresses, the same quarter-second write for the view controls, nothing changes for a page.
37
+ - Docs: `docs/address-state.md`. The demo's `address.html` keeps a name, a size and dots in the address beside the theme switch and the language picker.
38
+
39
+ ## [0.5.0] - 2026-11-02
40
+
41
+ The third part: the language picker, at `@johnmorrisdotca/kazoku-ui/language-picker` (and `/language-picker/element`, `/language-picker/define`).
42
+
43
+ - `<kazoku-language-picker languages="en ja">`: a button for each language the page lists, each in its own name (English, 日本語, Français; any other code is named by `Intl.DisplayNames` in its own language). It writes the choice as `lang` on a `target` (the page's `<html>` by default) and sends `kazoku-languagechange` with `{ lang, previous, reason, target }` (reasons `init`, `user`, `set`, `external`). Each button carries its own `lang`.
44
+ - It starts in the language of the address (`address`, `?lang=`, `address-key`), else the store (`storage`, in a try/catch), else `value`, else the reader's browser languages (`ja-JP` is `ja`), else what the target's `lang` says, else the first code. Another writer of the target's `lang` moves it with it.
45
+ - The pure entry (`parseLanguages`, `matchLanguage`, `languageName`, `startingLanguage`, `applyLanguage`, the words) imports on a server. Docs: `docs/language-picker.md`.
46
+ - Under a strict `style-src`, like the others. The demo's English and 日本語 buttons are now a picker, and the view controls and theme switch answer to it.
47
+ - The theme switch and the view controls are unchanged (the theme switch shares its target and store code with the picker; same behaviour).
48
+
49
+ ## [0.4.0] - 2026-11-01
50
+
51
+ The second part: the theme switch, at `@johnmorrisdotca/kazoku-ui/theme-switch` (and `/theme-switch/element`, `/theme-switch/define`).
52
+
53
+ - `<kazoku-theme-switch>`: Light, Dark and Auto as 48 px buttons. It writes the choice as `data-theme` on a `target` (the page's `<html>` by default; Auto removes the attribute so the device decides), and sends `kazoku-themechange` with `{ choice, scheme, reason, target }` (reasons `init`, `user`, `set`, `system`, `external`).
54
+ - It remembers the choice in the address (`address`, `address-key`) and in `localStorage` (`storage`, in a try/catch: a browser that keeps nothing still switches). Read in the order address, store, `value`, the target's own `data-theme`.
55
+ - Another writer of the target's `data-theme` moves the switch with it, and a change of the device's colours is an event while on Auto. `modes="light dark"` leaves Auto out. `target` is found in the switch's own tree first, so it works inside a shadow root.
56
+ - The element is light or dark with the page's `data-theme`, not only the device's, so it is dark in a page forced dark on a light device. Labels in English and Japanese. Under a strict `style-src`, like the view controls.
57
+ - The pure entry (`readTheme`, `applyTheme`, `resolveTheme`, `themeOf`, `parseModes`, `parseTheme`, the words) imports on a server. Docs: `docs/theme-switch.md`. The demo has a switch in its header and tells the view controls.
58
+ - The view controls are unchanged.
59
+
60
+ ## [0.3.1] - 2026-11-01
61
+
62
+ - The styles now pass a strict Content-Security-Policy. `<kazoku-view-controls>` adopts its styles as a constructable stylesheet (`adoptedStyleSheets`), which `style-src 'self'` allows with no `'unsafe-inline'` and no hash; before, it wrote a `<style>` into its shadow root and a strict `style-src` blocked it. Elements with the same styles share one sheet.
63
+ - A browser without constructable stylesheets gets the `<style>` as before, now carrying the element's `nonce` (`<kazoku-view-controls nonce="...">` or `element.nonce`), so a page with `style-src 'nonce-...'` allows it.
64
+ - What a page needs is in `docs/csp.md`. The demo has `csp.html` (`style-src 'self'`) and `csp-nonce.html`, both with `?fallback` to force the `<style>` route, and `e2e/csp.demo.mjs` plays them in Chromium and WebKit.
65
+ - Defaults and the look are unchanged.
66
+
3
67
  ## [0.3.0] - 2026-11-01
4
68
 
5
69
  - Zoom about a point of your own: the `anchor` attribute (`x,y`), the `element.anchor` setter and the controller's `anchor` member and option take a point in CSS pixels from the view's middle at Fit. The + and - buttons, the 2x button and the + and - keys zoom about it, and it stays fixed on screen within a pixel; wheel, pinch and double tap still zoom about the pointer. With no anchor they zoom about the middle, as before. `zoomAt` and `zoomBy` with no point use it.
package/README.md CHANGED
@@ -2,26 +2,36 @@
2
2
 
3
3
  <picture>
4
4
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/kazoku-ui/main/docs/images/hero-dark.webp">
5
- <img alt="The demo: a white card with a blue circle and a black triangle, turned a little and tipped, in a frame with a dotted floor. Under it the view controls: Angle and Tilt sliders, Auto-turn and Front buttons, zoom out, 2 times, zoom in and Fit, four arrows to move the zoomed view, and the Move and Turn choice for a drag." src="https://raw.githubusercontent.com/johnmorrisdotca/kazoku-ui/main/docs/images/hero-light.webp" width="720">
5
+ <img alt="The demo: in its header the English and 日本語 choice and the Light, Dark and Auto switch; below, a white card with a blue circle and a black triangle, turned a little and tipped, in a frame with a dotted floor. Under it the view controls: Angle and Tilt sliders, Auto-turn and Front buttons, zoom out, 2 times, zoom in and Fit, four arrows to move the zoomed view, and the Move and Turn choice for a drag." src="https://raw.githubusercontent.com/johnmorrisdotca/kazoku-ui/main/docs/images/hero-light.webp" width="720">
6
6
  </picture>
7
7
 
8
8
  The family's shared interface parts, as their own versioned package: one part per subpath, so a page loads only what it uses. No runtime dependencies, MIT.
9
9
  The first part is the **view controls** for any 3D or zoomable view. They never draw the scene: they tell your page what the reader chose, and your canvas, WebGL,
10
10
  SVG or CSS 3D applies it.
11
11
 
12
- [Demo](https://johnmorrisdotca.github.io/kazoku-ui/) · [Labels, English and 日本語](docs/labels.md) · [Changelog](CHANGELOG.md)
12
+ [Demo](https://johnmorrisdotca.github.io/kazoku-ui/) · [Strict-CSP demo](https://johnmorrisdotca.github.io/kazoku-ui/csp.html) · [Theme switch](docs/theme-switch.md) · [Language picker](docs/language-picker.md) · [Address state](docs/address-state.md) · [Save image](docs/save-image.md) · [Content-Security-Policy](docs/csp.md) · [Labels, English and 日本語](docs/labels.md) · [Changelog](CHANGELOG.md)
13
13
 
14
14
  ```sh
15
15
  npm install @johnmorrisdotca/kazoku-ui
16
16
  ```
17
17
 
18
- Parts (each its own subpath; later ones: picture-tile pickers, theme swatches with light and dark, the English and 日本語 switch, Help, GitHub and npm pills, Save SVG and PNG, the family's backgrounds):
18
+ Parts (each its own subpath; later ones: picture-tile pickers, Help, GitHub and npm pills, the family's backgrounds):
19
19
 
20
20
  | Subpath | What it is |
21
21
  |---|---|
22
22
  | `@johnmorrisdotca/kazoku-ui/view-controls` | The controller, the arithmetic, the address and the labels. Imports on a server. |
23
23
  | `@johnmorrisdotca/kazoku-ui/view-controls/element` | The `ViewControlsElement` class (needs a browser). |
24
24
  | `@johnmorrisdotca/kazoku-ui/view-controls/define` | Registers `<kazoku-view-controls>` (once, and only if the tag is free). |
25
+ | `@johnmorrisdotca/kazoku-ui/theme-switch` | The theme choices, how one is applied and remembered. Imports on a server. |
26
+ | `@johnmorrisdotca/kazoku-ui/theme-switch/element` | The `ThemeSwitchElement` class (needs a browser). |
27
+ | `@johnmorrisdotca/kazoku-ui/theme-switch/define` | Registers `<kazoku-theme-switch>` (once, and only if the tag is free). |
28
+ | `@johnmorrisdotca/kazoku-ui/language-picker` | The language codes, each language's own name, which one to start in, how one is applied. Imports on a server. |
29
+ | `@johnmorrisdotca/kazoku-ui/language-picker/element` | The `LanguagePickerElement` class (needs a browser). |
30
+ | `@johnmorrisdotca/kazoku-ui/language-picker/define` | Registers `<kazoku-language-picker>` (once, and only if the tag is free). |
31
+ | `@johnmorrisdotca/kazoku-ui/save-image` | The formats, the file name, the size and the functions that render and download a canvas, an SVG or an element as PNG or SVG. Imports on a server. |
32
+ | `@johnmorrisdotca/kazoku-ui/save-image/element` | The `SaveImageElement` class (needs a browser). |
33
+ | `@johnmorrisdotca/kazoku-ui/save-image/define` | Registers `<kazoku-save-image>` (once, and only if the tag is free). |
34
+ | `@johnmorrisdotca/kazoku-ui/address-state` | Choices kept in the address: read, write and subscribe to URL parameters with `replaceState`. No element; imports on a server. |
25
35
 
26
36
  ## View controls
27
37
 
@@ -98,6 +108,8 @@ myPlayButton.onclick = () => view.toggle();
98
108
  myZoomIn.onclick = () => view.zoomBy(1.4);
99
109
  ```
100
110
 
111
+ The automatic turn is idle whenever nothing can see the view: while the `for` element (else the controls) is scrolled out of sight, and while the tab is hidden. Its frame loop is stopped, so no animation frame is asked for and no `kazoku-viewchange` event with reason `auto` is sent; `state.turning` stays true, and the turn goes on from where it was when the view is back (the first frame back only restarts the clock). A page does not need to pause it for these. Without the element, `view.suspendTurn()` and `view.resumeTurn()` do the same for your own visibility test (they nest), and `view.play(seconds)` stops the turn by itself after that many seconds of turning.
112
+
101
113
  Options of `createViewController` that match: `zoom` (`"fit"` or a number), `fitScale`, `ignore`, `anchor`, `keyTilt`, `stopTurn`, `tiltStep`. `anchor`, `keyTilt`, `stopTurn` and `tiltStep` are also members you can change at any time. The controller also has `ignore` (changeable), `fitScale`, `startZoom` (the `zoom` the view opens at) and `setFitScale(n)`, `setStartZoom(zoom)`; `DEFAULT_IGNORE` is the default selector.
102
114
 
103
115
  ### Buttons and links inside the view
@@ -165,6 +177,7 @@ An `init` event is sent once, a moment after the element connects, with the star
165
177
  | `stop-turn` | A press on the view, the wheel over it or a key it handles stops the automatic turn. Off by default, so the turn carries on. Buttons and sliders never stop it. |
166
178
  | `tilt-step` | Round the tilt of a drag to a multiple of this many degrees (`1` gives whole degrees). 0 by default: not rounded. |
167
179
  | `autoplay` | Turn by itself from the start, unless the reader prefers reduced motion. |
180
+ | `autoplay-for` | Seconds of turning after which the `autoplay` turn stops by itself (as Auto-turn pressed). Left out, it never stops. Time spent paused off screen or in a hidden tab is not counted, and a reader's own Auto-turn press has no limit. |
168
181
  | `speed` | Degrees a second (30). |
169
182
  | `flat` | The view cannot turn: no angle, tilt, Auto-turn or Front, and a drag moves it. |
170
183
  | `tilt-max`, `zoom-min`, `zoom-max` | The limits (30, 1, 5). |
@@ -184,13 +197,76 @@ Only what a reader chose is kept: angle, tilt, zoom and the drag choice. The pan
184
197
 
185
198
  `customElements.define` throws when a name is taken, and a page can meet that without any fault: two copies of this package (an app and a widget that each bundle one, or two versions), the script loaded twice, or a page that registered the tag to supply its own element. A throw would stop the importing module and the page's script with it. So `define` calls `customElements.get` first and the first definition wins; `defineViewControls(registry?)` returns whether it defined the tag.
186
199
 
200
+ ## Theme switch
201
+
202
+ `<kazoku-theme-switch>` is Light, Dark and Auto as one row of 48 px buttons. It writes `data-theme` on the page's `<html>` (or a `target` you name; Auto takes it off, so the device decides), keeps the choice in the address (`address`) and optionally in `localStorage` (`storage`), and sends `kazoku-themechange`.
203
+
204
+ ```html
205
+ <kazoku-theme-switch address storage></kazoku-theme-switch>
206
+ <script type="module">import "@johnmorrisdotca/kazoku-ui/theme-switch/define";</script>
207
+ ```
208
+
209
+ The attributes, the event, the early-apply function for a server or a head script and the CSS to write are in [docs/theme-switch.md](docs/theme-switch.md). The demo has one in its header.
210
+
211
+ ## Language picker
212
+
213
+ `<kazoku-language-picker languages="en ja">` is a button for each language the page lists, each in its own name (English, 日本語). It writes `lang` on the page's `<html>` (or a `target`), keeps the choice in the address (`address`, `?lang=ja`) and optionally in `localStorage` (`storage`), and sends `kazoku-languagechange` with `{ lang, previous, reason, target }`. It starts in the address' language, then the remembered one, then the page's `value`, then the reader's browser languages, then the first code. It translates nothing; the page answers the event.
214
+
215
+ ```html
216
+ <kazoku-language-picker languages="en ja" address storage></kazoku-language-picker>
217
+ <script type="module">import "@johnmorrisdotca/kazoku-ui/language-picker/define";</script>
218
+ ```
219
+
220
+ See [docs/language-picker.md](docs/language-picker.md). The demo's own English and 日本語 choice is one.
221
+
222
+ ## Address state
223
+
224
+ Choices kept in the address, without an element: `createAddressState({ defaults })` with `get`, `set`, `remove`, `subscribe` and `flush`, writing with `replaceState` (no history entry, one write for the changes of a quarter of a second, a value at its default leaves the address, other parameters and the hash kept), and hearing back and forward. The theme switch, the language picker and the view controls keep their choices through it.
225
+
226
+ ```js
227
+ import { createAddressState } from "@johnmorrisdotca/kazoku-ui/address-state";
228
+
229
+ const state = createAddressState({ defaults: { size: "m" } });
230
+ state.set("size", "l"); // ?size=l
231
+ state.subscribe(({ key, value }) => redraw(key, value));
232
+ ```
233
+
234
+ See [docs/address-state.md](docs/address-state.md); the demo's `address.html` shows it.
235
+
236
+ ## Save image
237
+
238
+ `<kazoku-save-image for="art">` is Save PNG and Save SVG for a canvas, an SVG or any element: a canvas at its own pixel size; an SVG as a stand-alone SVG (the styles it is drawn in written into it) or a PNG at `scale` (2 by default); any other element as a PNG, drawn with its computed styles. `name` is the file name, `background` the colour behind a PNG, `manual` makes the picture and the `kazoku-save` event (`{ blob, format, width, height, filename, reason }`) but downloads nothing. `kazoku-saveerror` says why it could not.
239
+
240
+ ```html
241
+ <kazoku-save-image for="jersey" name="senshu-jersey"></kazoku-save-image>
242
+ <script type="module">import "@johnmorrisdotca/kazoku-ui/save-image/define";</script>
243
+ ```
244
+
245
+ What a picture of an element cannot carry (web fonts, `::before`, cross-origin images), the element-free `renderImage` and `saveImage`, and the one `img-src data:` a strict policy needs are in [docs/save-image.md](docs/save-image.md); the demo's `save-image.html` saves all three kinds.
246
+
247
+ ## One release workflow for the family's packages
248
+
249
+ `.github/workflows/reusable-release.yml` is the release written once: from main, the tag must equal `package.json`'s version, the repo's check script runs, a version already on npm is skipped, the `v<version>` tag is made if missing, and `npm publish --provenance` runs by trusted publishing (no token). A package repo's `release.yml` calls it:
250
+
251
+ ```yaml
252
+ jobs:
253
+ release:
254
+ uses: johnmorrisdotca/kazoku-ui/.github/workflows/reusable-release.yml@v0.7.2
255
+ ```
256
+
257
+ npm accepts a called workflow: the trusted-publisher entry on npmjs.com names the **calling** file (`release.yml`), and `id-token: write` goes on both. To move a repo over, keep its file name `release.yml`, replace the body with the call, and pin to a released tag. The inputs, the sources and the steps are in [docs/release-workflow.md](docs/release-workflow.md).
258
+
259
+ ## Content-Security-Policy
260
+
261
+ The styles are adopted as a constructable stylesheet (`adoptedStyleSheets`), so a page with `style-src 'self'` and no `'unsafe-inline'` needs no hash and no allowance. A browser without it gets a `<style>` carrying the element's `nonce`. What a page needs is in [docs/csp.md](docs/csp.md), and the demo's `csp.html` runs under a strict policy.
262
+
187
263
  ## Size
188
264
 
189
- Gzipped, unbundled: the element and its definition about 5.0 KB, the controller 4.3 KB, the maths 1.4 KB, the address 1.2 KB, the labels 0.7 KB and the styles 1.6 KB (`pnpm test:package` prints them). Nothing is loaded from the network.
265
+ Gzipped, unbundled. View controls: the element and its definition about 5.0 KB, the controller 4.3 KB, the maths 1.4 KB, the address 1.2 KB, the labels 0.7 KB and the styles 1.6 KB. Theme switch: the element and its definition 3.3 KB, the choices 1.0 KB, the labels 0.3 KB and the styles 0.3 KB. Language picker: the element and its definition 2.8 KB, the codes and names 1.7 KB, the labels 0.3 KB. Save image: the element and its definition 3.0 KB, the rendering 3.6 KB, the formats and file names 1.0 KB, the labels 0.4 KB. Address state: 2.1 KB. Shared by parts: the style adoption 0.8 KB, the base styles 1.0 KB, the page's colours 0.5 KB and the target and the store 0.6 KB. `pnpm test:package` prints them. Nothing is loaded from the network.
190
266
 
191
267
  ## Checks
192
268
 
193
- `pnpm check` (lint, types, 36 unit tests of the maths, the address, the controller and the package, the build and the packed package), and `pnpm test:demo` (the demo in Chromium and WebKit at a phone's width and a desk's: the controls in the first screen, a toggle during Auto-turn leaves the angle advancing, a button or link inside the view still clicks while a drag turns it, the opening zoom and fit scale, zoom about the pointer, zoom about an anchor (`?anchor=pin`), Arrow Up either way (`?key-tilt=wheel`), `?stop-turn`, `?tilt-step=1`, pan when zoomed, touch, the address, the labels, reduced motion).
269
+ `pnpm check` (lint, types, 75 unit tests of the maths, the address, the controller and the package, the build and the packed package), and `pnpm test:demo` (the demo in Chromium and WebKit at a phone's width and a desk's: the controls in the first screen, a toggle during Auto-turn leaves the angle advancing, a button or link inside the view still clicks while a drag turns it, the opening zoom and fit scale, zoom about the pointer, zoom about an anchor (`?anchor=pin`), Arrow Up either way (`?key-tilt=wheel`), `?stop-turn`, `?tilt-step=1`, pan when zoomed, touch, the address, the labels, reduced motion, the controls styled under a strict `style-src` with no violation, `demo/csp.html`, the theme switch and the language picker in each browser and size, the address state, back and forward and no history added, on `demo/address.html`, and the save buttons: each file read back from disk, on `demo/save-image.html`).
194
270
 
195
271
  ## Not yet
196
272
 
@@ -0,0 +1,76 @@
1
+ /** What a parameter may be set to. `null` and `undefined` take it off the address. */
2
+ export type AddressValue = string | number | null | undefined;
3
+ /** The part of `window` the address needs: where we are, and how to change it without a history entry. */
4
+ export interface AddressHost {
5
+ location: {
6
+ search: string;
7
+ pathname: string;
8
+ hash: string;
9
+ };
10
+ history: {
11
+ state: unknown;
12
+ replaceState(state: unknown, unused: string, url: string): void;
13
+ };
14
+ addEventListener?(type: "popstate", listener: () => void): void;
15
+ removeEventListener?(type: "popstate", listener: () => void): void;
16
+ }
17
+ /** The parameters of an address' search string. A repeated one keeps its first value. */
18
+ export declare function readParams(search?: string): Record<string, string>;
19
+ /** One parameter of an address' search string, or `null`. */
20
+ export declare function readParam(key: string, search?: string): string | null;
21
+ /**
22
+ * A search string with `changes` made to it: a value sets the parameter, `null` or `undefined` takes it off, and a value equal to that key's entry in `defaults` takes it off too
23
+ * (a choice at its start leaves the address plain). Other parameters are kept in their order. The result is `?a=1` or an empty string.
24
+ */
25
+ export declare function applyParams(search: string, changes: Readonly<Record<string, AddressValue>>, defaults?: Readonly<Record<string, string>>): string;
26
+ export interface AddressWriterOptions {
27
+ /** Milliseconds a write waits so that many changes become one: Safari stops a page that changes its address more than a hundred times in thirty seconds. 250 by default; 0 writes at once. */
28
+ delay?: number;
29
+ host?: AddressHost;
30
+ }
31
+ export interface AddressWriter {
32
+ /** Asks for the address to become `compute(its search string now)`. Calls inside the delay are one write, and `compute` is the latest, run when the time is up. */
33
+ write(compute: (search: string) => string): void;
34
+ /** Writes now what is waiting. */
35
+ flush(): void;
36
+ /** Drops what is waiting. */
37
+ cancel(): void;
38
+ }
39
+ /** Writes the address in place (`replaceState`), the pathname and hash kept. A page that cannot change its address (a sandboxed frame) is left as it is: nothing throws. */
40
+ export declare function createAddressWriter(options?: AddressWriterOptions): AddressWriter;
41
+ /** What a subscriber is told. */
42
+ export interface AddressChange {
43
+ key: string;
44
+ /** What the address holds for the key now: the string, or `null` when it is not there. */
45
+ value: string | null;
46
+ /** What it held before; `null` when it was not there. */
47
+ previous: string | null;
48
+ /** `set` (a `set`, `setMany` or `remove` on a state of this page) or `navigation` (the reader went back or forward to another address). */
49
+ source: "set" | "navigation";
50
+ }
51
+ export interface AddressStateOptions {
52
+ /** A key's start: a value equal to it is left off the address, and `get` gives it when the address has none. */
53
+ defaults?: Readonly<Record<string, string>>;
54
+ /** As for `createAddressWriter`. */
55
+ delay?: number;
56
+ host?: AddressHost;
57
+ }
58
+ export interface AddressState {
59
+ /** The key's value as the page will have it (a change still waiting included), else its default, else `null`. */
60
+ get(key: string): string | null;
61
+ /** Whether the address (or a change waiting) holds the key. */
62
+ has(key: string): boolean;
63
+ /** Every parameter, as `get` sees them. */
64
+ all(): Record<string, string>;
65
+ /** Sets a key; `null`, `undefined` or the key's default takes it off. Subscribers hear it now; the address is written after the delay. */
66
+ set(key: string, value: AddressValue): void;
67
+ setMany(values: Readonly<Record<string, AddressValue>>): void;
68
+ remove(key: string): void;
69
+ /** Hears every change to the address made through a state of this page, and the reader going back or forward. `keys` limits it to those keys. Returns the way to stop. */
70
+ subscribe(listener: (change: AddressChange) => void, keys?: readonly string[]): () => void;
71
+ /** Writes what is waiting. */
72
+ flush(): void;
73
+ /** Writes what is waiting and stops listening for back and forward. */
74
+ destroy(): void;
75
+ }
76
+ export declare function createAddressState(options?: AddressStateOptions): AddressState;
@@ -0,0 +1,171 @@
1
+ // Choices kept in the page's address, without an element: read, write and watch the parameters of `?a=1&b=2` with `replaceState` (no history entry for a choice).
2
+ // Every function that touches the page takes a `host` (the `window` by default), so a test can give it a stand-in. Nothing here touches the DOM until it is called.
3
+ /** The parameters of an address' search string. A repeated one keeps its first value. */
4
+ export function readParams(search = globalThis.location?.search ?? "") {
5
+ const found = {};
6
+ for (const [key, value] of new URLSearchParams(search))
7
+ if (!(key in found))
8
+ found[key] = value;
9
+ return found;
10
+ }
11
+ /** One parameter of an address' search string, or `null`. */
12
+ export function readParam(key, search = globalThis.location?.search ?? "") {
13
+ return new URLSearchParams(search).get(key);
14
+ }
15
+ /**
16
+ * A search string with `changes` made to it: a value sets the parameter, `null` or `undefined` takes it off, and a value equal to that key's entry in `defaults` takes it off too
17
+ * (a choice at its start leaves the address plain). Other parameters are kept in their order. The result is `?a=1` or an empty string.
18
+ */
19
+ export function applyParams(search, changes, defaults = {}) {
20
+ const query = new URLSearchParams(search);
21
+ for (const [key, value] of Object.entries(changes)) {
22
+ if (value === null || value === undefined || String(value) === defaults[key])
23
+ query.delete(key);
24
+ else
25
+ query.set(key, String(value));
26
+ }
27
+ return query.size > 0 ? `?${query}` : "";
28
+ }
29
+ /** Writes the address in place (`replaceState`), the pathname and hash kept. A page that cannot change its address (a sandboxed frame) is left as it is: nothing throws. */
30
+ export function createAddressWriter(options = {}) {
31
+ const delay = options.delay ?? 250;
32
+ let timer;
33
+ let latest;
34
+ const run = () => {
35
+ const compute = latest;
36
+ latest = undefined;
37
+ if (compute === undefined)
38
+ return;
39
+ try {
40
+ const host = options.host ?? globalThis;
41
+ const next = compute(host.location.search);
42
+ if (next !== host.location.search)
43
+ host.history.replaceState(host.history.state, "", `${host.location.pathname}${next}${host.location.hash}`);
44
+ }
45
+ catch {
46
+ /* an address that cannot be changed: the choices still work */
47
+ }
48
+ };
49
+ const clear = () => {
50
+ if (timer !== undefined)
51
+ globalThis.clearTimeout(timer);
52
+ timer = undefined;
53
+ };
54
+ return {
55
+ write(compute) {
56
+ latest = compute;
57
+ if (delay <= 0)
58
+ return run();
59
+ if (timer !== undefined)
60
+ return;
61
+ timer = globalThis.setTimeout(() => {
62
+ timer = undefined;
63
+ run();
64
+ }, delay);
65
+ },
66
+ flush() {
67
+ clear();
68
+ run();
69
+ },
70
+ cancel() {
71
+ clear();
72
+ latest = undefined;
73
+ },
74
+ };
75
+ }
76
+ const rooms = new WeakMap();
77
+ function roomOf(host) {
78
+ let room = rooms.get(host);
79
+ if (room === undefined) {
80
+ const made = {
81
+ listeners: new Set(),
82
+ seen: readParams(host.location.search),
83
+ attached: false,
84
+ onNavigation: () => {
85
+ const now = readParams(host.location.search);
86
+ const before = made.seen;
87
+ made.seen = now;
88
+ for (const key of new Set([...Object.keys(before), ...Object.keys(now)])) {
89
+ if (before[key] !== now[key])
90
+ tell(made, { key, value: now[key] ?? null, previous: before[key] ?? null, source: "navigation" });
91
+ }
92
+ },
93
+ };
94
+ rooms.set(host, (room = made));
95
+ }
96
+ return room;
97
+ }
98
+ function tell(room, change) {
99
+ for (const listener of [...room.listeners])
100
+ if (listener.keys === undefined || listener.keys.includes(change.key))
101
+ listener.run(change);
102
+ }
103
+ export function createAddressState(options = {}) {
104
+ const host = options.host ?? globalThis;
105
+ const defaults = options.defaults ?? {};
106
+ const writer = createAddressWriter({ ...(options.delay !== undefined ? { delay: options.delay } : {}), host });
107
+ const room = roomOf(host);
108
+ /** What was asked and has not been written yet: a key and its value, `null` for off. */
109
+ const queue = new Map();
110
+ const mine = new Set();
111
+ const held = (key) => (queue.has(key) ? queue.get(key) : (readParams(host.location.search)[key] ?? null));
112
+ const detach = (listener) => {
113
+ mine.delete(listener);
114
+ room.listeners.delete(listener);
115
+ if (room.listeners.size === 0 && room.attached) {
116
+ host.removeEventListener?.("popstate", room.onNavigation);
117
+ room.attached = false;
118
+ }
119
+ };
120
+ const state = {
121
+ get: (key) => held(key) ?? defaults[key] ?? null,
122
+ has: (key) => held(key) !== null,
123
+ all() {
124
+ const now = { ...readParams(host.location.search) };
125
+ for (const [key, value] of queue) {
126
+ if (value === null)
127
+ delete now[key];
128
+ else
129
+ now[key] = value;
130
+ }
131
+ return now;
132
+ },
133
+ set: (key, value) => state.setMany({ [key]: value }),
134
+ setMany(values) {
135
+ const changes = [];
136
+ for (const [key, raw] of Object.entries(values)) {
137
+ const value = raw === null || raw === undefined || String(raw) === defaults[key] ? null : String(raw);
138
+ const previous = held(key);
139
+ queue.set(key, value);
140
+ if (value !== previous)
141
+ changes.push({ key, value, previous, source: "set" });
142
+ }
143
+ writer.write((search) => {
144
+ const next = applyParams(search, Object.fromEntries(queue), defaults);
145
+ queue.clear();
146
+ room.seen = readParams(next);
147
+ return next;
148
+ });
149
+ for (const change of changes)
150
+ tell(room, change);
151
+ },
152
+ remove: (key) => state.set(key, null),
153
+ subscribe(run, keys) {
154
+ const listener = { run, keys };
155
+ mine.add(listener);
156
+ room.listeners.add(listener);
157
+ if (!room.attached) {
158
+ room.attached = true;
159
+ host.addEventListener?.("popstate", room.onNavigation);
160
+ }
161
+ return () => detach(listener);
162
+ },
163
+ flush: () => writer.flush(),
164
+ destroy() {
165
+ writer.flush();
166
+ for (const listener of [...mine])
167
+ detach(listener);
168
+ },
169
+ };
170
+ return state;
171
+ }
@@ -0,0 +1 @@
1
+ export { applyParams, createAddressState, createAddressWriter, readParam, readParams, type AddressChange, type AddressHost, type AddressState, type AddressStateOptions, type AddressValue, type AddressWriter, type AddressWriterOptions } from "./address.ts";
@@ -0,0 +1,2 @@
1
+ // Choices kept in the address: read, write and watch URL parameters with `replaceState`. No element and no DOM at import, so it imports on a server.
2
+ export { applyParams, createAddressState, createAddressWriter, readParam, readParams } from "./address.js";
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  /** The subpaths of the parts, in the order they were added. */
2
- export declare const PARTS: readonly ["view-controls"];
2
+ export declare const PARTS: readonly ["view-controls", "theme-switch", "language-picker", "address-state", "save-image"];
3
3
  export type Part = (typeof PARTS)[number];
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kazoku UI 家族: the family's shared interface parts, each its own subpath so a page loads only what it uses.
2
2
  /** The subpaths of the parts, in the order they were added. */
3
- export const PARTS = ["view-controls"];
3
+ export const PARTS = ["view-controls", "theme-switch", "language-picker", "address-state", "save-image"];
@@ -0,0 +1 @@
1
+ export declare function defineLanguagePicker(registry?: CustomElementRegistry): boolean;
@@ -0,0 +1,9 @@
1
+ // Importing this registers <kazoku-language-picker>, unless the tag is already taken (the first definition wins; see view-controls/define.ts for why a taken tag is left alone).
2
+ import { LanguagePickerElement, TAG } from "./element.js";
3
+ export function defineLanguagePicker(registry = customElements) {
4
+ if (registry.get(TAG) !== undefined)
5
+ return false;
6
+ registry.define(TAG, LanguagePickerElement);
7
+ return true;
8
+ }
9
+ defineLanguagePicker();
@@ -0,0 +1,39 @@
1
+ import { EVENT, TAG } from "./index.ts";
2
+ import { type PickerWords } from "./words.ts";
3
+ export { EVENT, TAG };
4
+ /** What the `kazoku-languagechange` event carries in `detail`. */
5
+ export interface LanguageChangeDetail {
6
+ /** The language now chosen: one of the codes the page gave, lower case ("en", "ja"). */
7
+ lang: string;
8
+ /** The one before; `undefined` at `init`. */
9
+ previous: string | undefined;
10
+ /** `init` (once, a moment after it connects), `user` (a button), `set` (the `value` property or attribute) or `external` (something else wrote the target's `lang`). */
11
+ reason: "init" | "user" | "set" | "external";
12
+ /** The element the choice is written on. */
13
+ target: Element;
14
+ }
15
+ /**
16
+ * Attributes: `languages` (the codes, `en ja` by default), `value` (the language to start in), `target` (a CSS selector for the element that gets `lang`; the page's <html>
17
+ * by default), `address` (keep the choice in `?lang=`, or the key `address-key` names), `storage` (also remember it in localStorage under that key; a bare `storage` uses
18
+ * `kazoku-lang`), `theme` (`light` or `dark`: its own look). The start is read, in this order, from the address, the store, `value`, the reader's languages
19
+ * (`navigator.languages`), the target's own `lang`, then the first code.
20
+ */
21
+ export declare class LanguagePickerElement extends HTMLElement {
22
+ #private;
23
+ static observedAttributes: string[];
24
+ constructor();
25
+ /** The language now chosen. Setting it applies it (event reason `set`); a code that is not in `languages` is ignored. */
26
+ get value(): string;
27
+ set value(code: string);
28
+ /** The codes the picker offers. */
29
+ get languages(): readonly string[];
30
+ get target(): Element | undefined;
31
+ set target(element: Element | undefined);
32
+ /** Your own names over the family's, for example `{ fr: "Français (Canada)" }`. */
33
+ set names(names: Record<string, string>);
34
+ /** Your own words over the language's, for example `{ group: "Choose a language" }`. */
35
+ set words(words: Partial<PickerWords>);
36
+ connectedCallback(): void;
37
+ disconnectedCallback(): void;
38
+ attributeChangedCallback(name: string, _old: string | null, value: string | null): void;
39
+ }