@johnmorrisdotca/kazoku-ui 0.9.1 → 0.10.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.10.0] - 2026-11-02
4
+
5
+ The bar has one zoom number, and it is always the true one; the auto-turn speed is in sight whenever the view turns; and the automatic turn asks for a frame only while someone can see the view, in every way an element can be used.
6
+
7
+ - **The `2×` button is gone.** It was never a readout: it was a preset, "Zoom to 2 times" (`data-b="two"`), and in 0.7 and before it sat between zoom out and zoom in, where it read like one: at the least zoom, with zoom out greyed, the bar said `2×`. Since 0.8 the readout had taken that place, but the preset stayed in advanced, so a bar could show `1×` and `2×` together. A double press on the view still goes to 2 times. The label `zoomTwo` stays for a page's own `words`; a test that presses `[data-b="two"]` has to change.
8
+ - **The readout cannot go stale.** It now follows a lowered `zoom-max` or raised `zoom-min` (the view moves inside the new limit and the buttons grey to match; before, the view stayed outside the limit). After an element was moved, the zoom buttons drove the old controller, so the page drew one zoom and the readout said another; that is the move fixed below. Below 1 it keeps two figures, so a small zoom never reads `0×`. A test goes through the buttons, the wheel, a pinch, a double press, Fit, the keys, the address, `set`, the limits, the `zoom` attribute and the fit scale.
9
+ - **The speed in basic.** While the view turns, basic shows one small button beside Auto-turn: the word Speed over Slow, Medium or Fast, and a press steps to the next (kept in the address as before). It comes in on Auto-turn's left, so Auto-turn does not move out from under the pointer that pressed it. It adds no row: on a phone it sits in the free room left of the zoom buttons, and on a wider bar the angle's running number makes the room while the view turns (a screen reader still has it). A page with `autoplay` shows it from the start. Advanced keeps the three buttons.
10
+ - **A moved element no longer leaves a second loop running.** An element taken off the page and put back (a framework reordering its children, `append` to another parent) got a new controller while its buttons kept driving the old one: Auto-turn then ran a loop nothing suspended, on and off screen, and with `autoplay` a move in the same task started two. The controls now drive the controller in use, a destroyed controller never turns again, and only the controller in use speaks for the element.
11
+ - **`element.play()` on an element that is on no page** (never added, or taken off) asks for no frame until it is put on one. It used to run for ever.
12
+ - **A plain controller** stops too: `view.bind(element)` holds the turn while that element is off screen or the tab is hidden. It used to take the page's own `suspendTurn()`.
13
+ - **A view that opens off screen** asks for no frame until the observer has said it is seen; before, the first frame could be asked for ahead of that answer.
14
+ - `e2e/autoplay-loop.demo.mjs` counts at `requestAnimationFrame` itself: 0 callbacks, 0 changes and 0 waiting while out of sight or hidden, at most one callback, one change and one waiting request a page frame otherwise.
15
+ - The tests' Chromium runs headless and draws in software (`--use-gl=angle --use-angle=swiftshader --enable-unsafe-swiftshader`, in `playwright.config.mjs` and the picture script).
16
+ - **The must-have list as a check**: `templates/check-standard.mjs` (copied unchanged into each package as `scripts/check-standard.mjs`) fails when a package lacks its demo on Pages, an embed page, a documentation site with Japanese twins, the family files or tested usage examples (script tag, ES module, bundlers, React, Vue, Svelte, Angular, iframe, TypeScript). An item that cannot apply is named in `package.json` with a reason. [docs/package-standard.md](docs/package-standard.md); the rule is in spx-stats `docs/package-work.md`.
17
+
3
18
  ## [0.9.1] - 2026-11-02
4
19
 
5
20
  No 3D release without a freeze test (John, 2026-11-02, after a graphics-driver kernel panic during a test run). Test kit and release workflow; the parts themselves are unchanged.
package/README.md CHANGED
@@ -37,8 +37,8 @@ Parts (each its own subpath; later ones: picture-tile pickers, Help, GitHub and
37
37
 
38
38
  ## View controls
39
39
 
40
- - **Two levels, so the bar stays simple.** The default, `controls="basic"`, shows the angle slider, Auto-turn, zoom out and in with a **zoom readout** (`2.4×`; tap it to go back to Fit), Fit, and a small More button. More (or `controls="advanced"`) adds the tilt slider, the auto-turn **speed** (Slow, Medium, Fast), Front, 2 times, and, once zoomed in, the four-arrow pan pad and the Move or Turn choice for what a drag does. The reader's choice with More is remembered in `localStorage` and beats the attribute.
41
- - The zoom readout is the view's `zoom` (1× is Fit). A screen reader is told "Zoom level 2.4×" once the zoom has settled for a moment, not for every step of a pinch.
40
+ - **Two levels, so the bar stays simple.** The default, `controls="basic"`, shows the angle slider, Auto-turn, zoom out and in with a **zoom readout** (`2.4×`; tap it to go back to Fit), Fit, and a small More button; while the view turns, one small **speed** button beside Auto-turn (it shows the word Speed over `Medium`, and a press steps to the next speed). More (or `controls="advanced"`) adds the tilt slider, all three speeds (Slow, Medium, Fast), Front, and, once zoomed in, the four-arrow pan pad and the Move or Turn choice for what a drag does. The reader's choice with More is remembered in `localStorage` and beats the attribute.
41
+ - The zoom readout is the view's `zoom` (1× is Fit, whatever `fit-scale` is), one decimal, the one zoom number on the bar: it follows the buttons, the wheel, a pinch, a double press, Fit, the keys, the address, `set` and a changed limit. A screen reader is told "Zoom level 2.4×" once the zoom has settled for a moment, not for every step of a pinch.
42
42
  - Auto-turn with play and pause; its speed is Slow (half the `speed` attribute), Medium (the `speed` attribute, 30 degrees a second) or Fast (twice it), kept in the address as `?speed=slow` or `?speed=fast`.
43
43
  - Zoom is about the pointer (and Ctrl or Command with the wheel, or a trackpad's pinch).
44
44
  - Touch: one finger turns at 1x and moves the view when zoomed in; two fingers pinch.
@@ -111,7 +111,7 @@ myPlayButton.onclick = () => view.toggle();
111
111
  myZoomIn.onclick = () => view.zoomBy(1.4);
112
112
  ```
113
113
 
114
- 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.
114
+ 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 view that opens off screen asks for no frame until it is seen, and an element on no page asks for none. One frame is asked for at a time, and at most one `auto` change is sent a page frame. A page does not need to pause it for these. Without the element, `view.bind(element)` watches the bound element the same way; `view.suspendTurn()` and `view.resumeTurn()` do the same for your own visibility test (they nest, on top of what `bind` does), and `view.play(seconds)` stops the turn by itself after that many seconds of turning.
115
115
 
116
116
  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.
117
117
 
@@ -182,7 +182,7 @@ An `init` event is sent once, a moment after the element connects, with the star
182
182
  | `autoplay` | Turn by itself from the start, unless the reader prefers reduced motion. |
183
183
  | `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. |
184
184
  | `speed` | Degrees a second at Medium (30); Slow is half and Fast twice. |
185
- | `controls` | `basic` (the default) or `advanced`: which controls are shown (see above). Written in the page's markup it is where the reader starts; what the reader chose with More is remembered under `kazoku-view-controls` in `localStorage` (or the key `storage` names) and wins. Setting the attribute later changes the level at once. The host carries `data-level` with the level now. |
185
+ | `controls` | `basic` (the default) or `advanced`: which controls are shown (see above; the zoom readout in both, the speed in both while the view turns). Written in the page's markup it is where the reader starts; what the reader chose with More is remembered under `kazoku-view-controls` in `localStorage` (or the key `storage` names) and wins. Setting the attribute later changes the level at once. The host carries `data-level` with the level now. |
186
186
  | `storage` | The `localStorage` key the level is remembered under (`kazoku-view-controls`). |
187
187
  | `flat` | The view cannot turn: no angle, tilt, Auto-turn or Front, and a drag moves it. |
188
188
  | `tilt-max`, `zoom-min`, `zoom-max` | The limits (30, 1, 5). |
@@ -285,11 +285,11 @@ The styles are adopted as a constructable stylesheet (`adoptedStyleSheets`), so
285
285
 
286
286
  ## Size
287
287
 
288
- Gzipped, unbundled. View controls: the element and its definition about 6.9 KB, the controller 4.4 KB, the maths 1.4 KB, the address 1.4 KB, the levels 0.4 KB, the labels 0.9 KB and the styles 1.7 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 3D renderer: 8.9 KB (the renderer 4.5, the scheduler 1.2, the assets 1.4, the stills 0.6, the pixel budget 0.8, the index 0.4), the test kit 1.6 KB; three.js is the page's own and is not counted. 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.
288
+ Gzipped, unbundled. View controls: the element and its definition about 7.3 KB, the controller 4.6 KB, the sight watch 0.6 KB, the maths 1.4 KB, the address 1.4 KB, the levels 0.4 KB, the labels 0.9 KB and the styles 2.0 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 3D renderer: 8.9 KB (the renderer 4.5, the scheduler 1.2, the assets 1.4, the stills 0.6, the pixel budget 0.8, the index 0.4), the test kit 1.6 KB; three.js is the page's own and is not counted. 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.
289
289
 
290
290
  ## Checks
291
291
 
292
- `pnpm check` (lint, types, 99 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`, and the shared 3D renderer on `demo/renderer.html`: one WebGL context over fifty focus changes, nothing drawn or requested at rest, the GPU objects and heap flat, the canvas within budget at 5K, context loss, out of sight, the stills' byte budget, and three.js fetched only when a view goes live).
292
+ `pnpm check` (lint, types, 104 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, the frame loop counted at `requestAnimationFrame` (nothing out of sight or hidden, one callback a page frame otherwise, a moved element too), the zoom readout against the state after every way of zooming, the speed button adding no row, 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`, and the shared 3D renderer on `demo/renderer.html`: one WebGL context over fifty focus changes, nothing drawn or requested at rest, the GPU objects and heap flat, the canvas within budget at 5K, context loss, out of sight, the stills' byte budget, and three.js fetched only when a view goes live).
293
293
 
294
294
  ## Not yet
295
295
 
@@ -68,7 +68,7 @@ export interface ViewController {
68
68
  /** Start at "fit" or at a scale relative to natural size, and go there now. */
69
69
  setStartZoom(zoom: "fit" | number): void;
70
70
  set(next: Partial<ViewState>, final?: boolean, reason?: string): void;
71
- /** Turn by itself. With `seconds`, stop by itself (as `pause`) after that many seconds of turning; time spent suspended does not count. `toggle` and a bare `play()` turn without a limit. */
71
+ /** Turn by itself (never after `destroy`). With `seconds`, stop by itself (as `pause`) after that many seconds of turning; time spent suspended does not count. `toggle` and a bare `play()` turn without a limit. */
72
72
  play(seconds?: number): void;
73
73
  pause(): void;
74
74
  toggle(): void;
@@ -87,7 +87,7 @@ export interface ViewController {
87
87
  /** Move the view by whole fractions of its size: `panBy(1, 0)` is a quarter of its width to the right. */
88
88
  panBy(stepsX: number, stepsY: number): void;
89
89
  setDrag(drag: DragMode): void;
90
- /** Bind the pointer, pinch, wheel and key hands to `view`. Returns a function that unbinds them. */
90
+ /** Bind the pointer, pinch, wheel and key hands to `view`, and hold the automatic turn while `view` is off screen or the tab is hidden. Returns a function that unbinds them. */
91
91
  bind(view: HTMLElement): () => void;
92
92
  destroy(): void;
93
93
  }
@@ -1,3 +1,4 @@
1
+ import { turnOnlyInSight } from "./sight.js";
1
2
  import { clamp, clampPan, DEFAULT_LIMITS, dragTurn, PAN_STEP, pinchTo, wheelZoomFactor, wrapYaw, ZOOMED, zoomAbout } from "./math.js";
2
3
  /** What `bind` leaves alone by default: the page's own controls inside the view. */
3
4
  export const DEFAULT_IGNORE = "button, a, input, select, textarea, label, [data-kazoku-ignore]";
@@ -15,6 +16,7 @@ export function createViewController(options = {}) {
15
16
  let suspends = 0;
16
17
  let limit = 0;
17
18
  let turned = 0;
19
+ let destroyed = false;
18
20
  let fitScale = positive(options.fitScale) ?? 1;
19
21
  let start = positive(options.zoom) ?? "fit";
20
22
  let atStart = true;
@@ -132,7 +134,7 @@ export function createViewController(options = {}) {
132
134
  emit(final, reason);
133
135
  },
134
136
  play(seconds) {
135
- if (state.turning || !controller.turnable)
137
+ if (destroyed || state.turning || !controller.turnable)
136
138
  return;
137
139
  state.turning = true;
138
140
  last = 0;
@@ -206,6 +208,7 @@ export function createViewController(options = {}) {
206
208
  return bindHands(view);
207
209
  },
208
210
  destroy() {
211
+ destroyed = true;
209
212
  clock.cancel(frame);
210
213
  state.turning = false;
211
214
  unbind?.();
@@ -415,7 +418,9 @@ export function createViewController(options = {}) {
415
418
  // A right press is a pan, not the browser's menu.
416
419
  view.addEventListener("contextmenu", onMenu);
417
420
  view.style.touchAction = state.zoom > ZOOMED ? "none" : "pan-y";
421
+ const unwatch = turnOnlyInSight(controller, view);
418
422
  const off = () => {
423
+ unwatch();
419
424
  view.removeEventListener("pointerdown", onDown);
420
425
  view.removeEventListener("pointermove", onMove);
421
426
  view.removeEventListener("pointerup", onUp);
@@ -13,7 +13,7 @@ export interface ViewChangeDetail extends ViewState {
13
13
  * is the scene's natural size), `fit-scale` (the page's scale at Fit: what zoom 1 means), `ignore` (a selector for what inside the view a pointer press leaves alone;
14
14
  * buttons, links and form controls by default), `autoplay` (turn by itself from the start, unless the reader prefers reduced motion), `autoplay-for` (stop that turn by itself after this many seconds of turning), `speed` (degrees a second),
15
15
  * `anchor` (`x,y`: the point the zoom buttons and keys zoom about, in pixels from the view's middle at Fit), `key-tilt` (`drag`, the default, or `wheel`: which way Arrow Up tips),
16
- * `controls` (`basic`, the default, or `advanced`: which controls are shown; the reader's own choice with the More button is remembered in localStorage, under `kazoku-view-controls` or the key `storage` names, and wins),
16
+ * `controls` (`basic`, the default, or `advanced`: which controls are shown, the zoom readout in both, and the speed in both while the view turns; the reader's own choice with the More button is remembered in localStorage, under `kazoku-view-controls` or the key `storage` names, and wins),
17
17
  * `stop-turn` (a press, the wheel or a key on the view stops the automatic turn), `tilt-step` (round a drag's tilt to a multiple of this many degrees), `flat` (the view cannot turn), `tilt-max`, `zoom-min`, `zoom-max`, `address` (keep the choices in the page's address), `address-keys` (`yaw:a,tilt:b`), `theme` (light
18
18
  * or dark), `overlay` (sit over the bottom of a positioned parent).
19
19
  */
@@ -8,6 +8,7 @@ import { createViewController, DEFAULT_IGNORE } from "./controller.js";
8
8
  import { EVENT, TAG } from "./index.js";
9
9
  import { LEVEL_STORAGE_KEY, PACE_FACTOR, PACES, parseLevel, zoomText } from "./levels.js";
10
10
  import { ZOOM_STEP, ZOOMED } from "./math.js";
11
+ import { turnOnlyInSight } from "./sight.js";
11
12
  import { STYLES } from "./styles.js";
12
13
  import { languageOf, wordsFor } from "./words.js";
13
14
  export { EVENT, TAG };
@@ -27,6 +28,7 @@ const HTML = `
27
28
  <div class="bar" part="bar" role="group">
28
29
  <div class="field angle" part="field angle"><label data-w="angle" for="yaw"></label><input id="yaw" type="range" min="-180" max="180" step="1" value="0" part="slider" /><output for="yaw" data-o="yaw">0°</output></div>
29
30
  <div class="field tilt adv" part="field tilt"><label data-w="tilt" for="tilt"></label><input id="tilt" type="range" min="-30" max="30" step="1" value="0" part="slider" /><output for="tilt" data-o="tilt">0°</output></div>
31
+ <button type="button" class="pace-now" data-b="pace-now" part="button" hidden><span class="lbl" data-w="speed"></span><span data-o="pace"></span></button>
30
32
  <button type="button" class="play" data-b="play" aria-pressed="false" part="button">${ICON.play}${ICON.pause}<span class="text" data-w="autoTurn"></span></button>
31
33
  <div class="group pace adv" role="group" data-g="pace"><span class="lbl" data-w="speed" aria-hidden="true"></span>
32
34
  <button type="button" data-b="slow" aria-pressed="false" part="button"><span data-w="slow"></span></button>
@@ -38,7 +40,6 @@ const HTML = `
38
40
  <button type="button" data-b="out" part="button">${ICON.minus}</button>
39
41
  <button type="button" class="level" data-b="level" part="button"><span data-o="zoom">1×</span></button>
40
42
  <button type="button" data-b="in" part="button">${ICON.plus}</button>
41
- <button type="button" class="two adv" data-b="two" part="button">2×</button>
42
43
  <button type="button" data-b="fit" part="button"><span data-w="fit"></span></button>
43
44
  </div>
44
45
  <div class="group pan adv" role="group" data-g="pan" hidden>
@@ -59,7 +60,7 @@ const HTML = `
59
60
  * is the scene's natural size), `fit-scale` (the page's scale at Fit: what zoom 1 means), `ignore` (a selector for what inside the view a pointer press leaves alone;
60
61
  * buttons, links and form controls by default), `autoplay` (turn by itself from the start, unless the reader prefers reduced motion), `autoplay-for` (stop that turn by itself after this many seconds of turning), `speed` (degrees a second),
61
62
  * `anchor` (`x,y`: the point the zoom buttons and keys zoom about, in pixels from the view's middle at Fit), `key-tilt` (`drag`, the default, or `wheel`: which way Arrow Up tips),
62
- * `controls` (`basic`, the default, or `advanced`: which controls are shown; the reader's own choice with the More button is remembered in localStorage, under `kazoku-view-controls` or the key `storage` names, and wins),
63
+ * `controls` (`basic`, the default, or `advanced`: which controls are shown, the zoom readout in both, and the speed in both while the view turns; the reader's own choice with the More button is remembered in localStorage, under `kazoku-view-controls` or the key `storage` names, and wins),
63
64
  * `stop-turn` (a press, the wheel or a key on the view stops the automatic turn), `tilt-step` (round a drag's tilt to a multiple of this many degrees), `flat` (the view cannot turn), `tilt-max`, `zoom-min`, `zoom-max`, `address` (keep the choices in the page's address), `address-keys` (`yaw:a,tilt:b`), `theme` (light
64
65
  * or dark), `overlay` (sit over the bottom of a positioned parent).
65
66
  */
@@ -74,9 +75,8 @@ export class ViewControlsElement extends HTMLElement {
74
75
  #sliderHeld = 0;
75
76
  #writer = createAddressWriter({ delay: 250 });
76
77
  #keys = DEFAULT_KEYS;
77
- #seen;
78
- #onScreen = true;
79
- #suspended = false;
78
+ #unwatch;
79
+ #offPage = false;
80
80
  #level = "basic";
81
81
  #pace = "medium";
82
82
  #labels = wordsFor("en");
@@ -137,6 +137,9 @@ export class ViewControlsElement extends HTMLElement {
137
137
  }
138
138
  connectedCallback() {
139
139
  const c = this.#ensure();
140
+ if (this.#offPage)
141
+ c.resumeTurn();
142
+ this.#offPage = false;
140
143
  if (this.#root.querySelector(".bar") === null)
141
144
  this.#build();
142
145
  this.#keys = parseKeys(this.getAttribute("address-keys"));
@@ -169,11 +172,11 @@ export class ViewControlsElement extends HTMLElement {
169
172
  this.ownerDocument.addEventListener("DOMContentLoaded", () => this.#resolveView(), { once: true });
170
173
  this.#ready = true;
171
174
  this.#show();
172
- this.ownerDocument.addEventListener("visibilitychange", this.#sight);
173
175
  this.#watch();
174
176
  // Said once the page has had its turn to listen; a page can also read `state` at any time.
175
177
  queueMicrotask(() => {
176
- if (!this.isConnected)
178
+ // Taken off the page already, perhaps put back: then this controller is gone and a newer microtask speaks for the new one.
179
+ if (this.#controller !== c)
177
180
  return;
178
181
  this.#dispatch(c.state, { final: true, reason: "init" });
179
182
  if (this.hasAttribute("autoplay") && !globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches)
@@ -181,17 +184,16 @@ export class ViewControlsElement extends HTMLElement {
181
184
  });
182
185
  }
183
186
  disconnectedCallback() {
184
- this.ownerDocument.removeEventListener("visibilitychange", this.#sight);
185
- this.#seen?.disconnect();
186
- this.#seen = undefined;
187
- this.#suspended = false;
188
- this.#onScreen = true;
187
+ this.#unwatch?.();
188
+ this.#unwatch = undefined;
189
189
  this.#unbind?.();
190
190
  this.#unbind = undefined;
191
191
  this.#controller?.destroy();
192
192
  this.#controller = undefined;
193
+ this.#offPage = false;
193
194
  this.#view = undefined;
194
195
  this.#ready = false;
196
+ this.#sliderHeld = 0;
195
197
  this.#writer.cancel();
196
198
  clearTimeout(this.#sayTimer);
197
199
  this.#said = "";
@@ -228,8 +230,11 @@ export class ViewControlsElement extends HTMLElement {
228
230
  }
229
231
  else if (name === "address-keys")
230
232
  this.#keys = parseKeys(value);
231
- else
233
+ else {
232
234
  this.#limits();
235
+ // A limit that moved past the view takes the view with it, and the readout and the buttons say so.
236
+ c.set({});
237
+ }
233
238
  }
234
239
  #option(name) {
235
240
  const c = this.#ensure();
@@ -251,7 +256,19 @@ export class ViewControlsElement extends HTMLElement {
251
256
  }
252
257
  #ensure() {
253
258
  if (this.#controller === undefined) {
254
- this.#controller = createViewController({ onChange: (state, info) => this.#changed(state, info) });
259
+ const made = createViewController({
260
+ onChange: (state, info) => {
261
+ // A page may hold on to an old `controller` after a move; only the one in use speaks for the element.
262
+ if (this.#controller === made)
263
+ this.#changed(state, info);
264
+ },
265
+ });
266
+ this.#controller = made;
267
+ // Nothing can see an element that is on no page: it asks for no frame until it is put on one.
268
+ if (!this.isConnected) {
269
+ made.suspendTurn();
270
+ this.#offPage = true;
271
+ }
255
272
  }
256
273
  return this.#controller;
257
274
  }
@@ -259,58 +276,59 @@ export class ViewControlsElement extends HTMLElement {
259
276
  this.#root.innerHTML = HTML;
260
277
  adoptStyles(this.#root, STYLES, this.nonce);
261
278
  const q = (selector) => this.#root.querySelector(selector);
262
- const c = this.#ensure();
279
+ // The controller in use when a control is pressed: the element keeps these controls when it is moved, and gets a new controller.
280
+ const c = () => this.#ensure();
263
281
  const slider = (id) => {
264
282
  const input = q(`#${id}`);
265
283
  // A hand on the angle slider holds the automatic turn still while it is down; the turn then goes on from where the hand left it.
266
284
  const hold = () => {
267
285
  this.#sliderHeld += 1;
268
286
  if (this.#sliderHeld === 1 && id === "yaw")
269
- c.holdTurn();
287
+ c().holdTurn();
270
288
  };
271
289
  const free = () => {
272
290
  if (this.#sliderHeld === 0)
273
291
  return;
274
292
  this.#sliderHeld = 0;
275
293
  if (id === "yaw")
276
- c.releaseTurn();
294
+ c().releaseTurn();
277
295
  };
278
296
  input.addEventListener("pointerdown", () => hold());
279
297
  input.addEventListener("pointerup", free);
280
298
  input.addEventListener("pointercancel", free);
281
- input.addEventListener("input", () => c.set({ [id]: Number(input.value) }, false, "slider"));
282
- input.addEventListener("change", () => c.set({ [id]: Number(input.value) }, true, "slider"));
299
+ input.addEventListener("input", () => c().set({ [id]: Number(input.value) }, false, "slider"));
300
+ input.addEventListener("change", () => c().set({ [id]: Number(input.value) }, true, "slider"));
283
301
  };
284
302
  slider("yaw");
285
303
  slider("tilt");
286
304
  const on = (b, run) => q(`[data-b="${b}"]`).addEventListener("click", run);
287
- on("play", () => c.toggle());
288
- on("front", () => c.front());
289
- on("out", () => c.zoomBy(1 / ZOOM_STEP));
290
- on("in", () => c.zoomBy(ZOOM_STEP));
291
- on("two", () => c.zoomAt(2));
292
- on("fit", () => c.fit());
293
- on("level", () => c.fit());
305
+ on("play", () => c().toggle());
306
+ on("front", () => c().front());
307
+ on("out", () => c().zoomBy(1 / ZOOM_STEP));
308
+ on("in", () => c().zoomBy(ZOOM_STEP));
309
+ on("fit", () => c().fit());
310
+ on("level", () => c().fit());
294
311
  on("more", () => {
295
312
  this.#level = this.#level === "basic" ? "advanced" : "basic";
296
313
  remember(this.#storageKey(), this.#level);
297
314
  this.#show();
298
315
  });
299
- for (const pace of PACES) {
300
- on(pace, () => {
301
- this.#pace = pace;
302
- this.#speed();
303
- this.#show();
304
- if (this.hasAttribute("address"))
305
- this.#save();
306
- });
307
- }
308
- on("left", () => c.panBy(-1, 0));
309
- on("right", () => c.panBy(1, 0));
310
- on("up", () => c.panBy(0, -1));
311
- on("down", () => c.panBy(0, 1));
312
- on("move", () => c.setDrag("move"));
313
- on("turn", () => c.setDrag("turn"));
316
+ const choosePace = (pace) => {
317
+ this.#pace = pace;
318
+ this.#speed();
319
+ this.#show();
320
+ if (this.hasAttribute("address"))
321
+ this.#save();
322
+ };
323
+ for (const pace of PACES)
324
+ on(pace, () => choosePace(pace));
325
+ on("pace-now", () => choosePace(PACES[(PACES.indexOf(this.#pace) + 1) % PACES.length]));
326
+ on("left", () => c().panBy(-1, 0));
327
+ on("right", () => c().panBy(1, 0));
328
+ on("up", () => c().panBy(0, -1));
329
+ on("down", () => c().panBy(0, 1));
330
+ on("move", () => c().setDrag("move"));
331
+ on("turn", () => c().setDrag("turn"));
314
332
  }
315
333
  #storageKey() {
316
334
  return this.getAttribute("storage") || LEVEL_STORAGE_KEY;
@@ -362,35 +380,13 @@ export class ViewControlsElement extends HTMLElement {
362
380
  this.#unbind = this.#ensure().bind(element);
363
381
  this.#watch();
364
382
  }
365
- /** The automatic turn runs only while the view (else this element) is on screen in a visible tab; otherwise its frame loop is stopped. */
383
+ /** The controller holds its turn while the view it is bound to cannot be seen; with no view yet, this element stands in for it. */
366
384
  #watch() {
367
- this.#seen?.disconnect();
368
- this.#seen = undefined;
369
- this.#onScreen = true;
370
- if (typeof IntersectionObserver === "function" && this.isConnected) {
371
- this.#seen = new IntersectionObserver((entries) => {
372
- const last = entries[entries.length - 1];
373
- if (last === undefined)
374
- return;
375
- this.#onScreen = last.isIntersecting;
376
- this.#sight();
377
- });
378
- this.#seen.observe(this.#view ?? this);
379
- }
380
- this.#sight();
385
+ this.#unwatch?.();
386
+ this.#unwatch = undefined;
387
+ if (this.#view === undefined && this.isConnected)
388
+ this.#unwatch = turnOnlyInSight(this.#ensure(), this);
381
389
  }
382
- #sight = () => {
383
- if (this.#controller === undefined)
384
- return;
385
- const idle = !this.#onScreen || this.ownerDocument.visibilityState === "hidden";
386
- if (idle === this.#suspended)
387
- return;
388
- this.#suspended = idle;
389
- if (idle)
390
- this.#controller.suspendTurn();
391
- else
392
- this.#controller.resumeTurn();
393
- };
394
390
  #label() {
395
391
  const language = languageOf(this.getAttribute("lang") ?? this.closest("[lang]")?.getAttribute("lang") ?? this.ownerDocument.documentElement.lang);
396
392
  const words = wordsFor(language, this.#words);
@@ -401,7 +397,6 @@ export class ViewControlsElement extends HTMLElement {
401
397
  set(".bar", words.group);
402
398
  set('[data-b="play"]', words.autoTurn);
403
399
  set('[data-b="out"]', words.zoomOut);
404
- set('[data-b="two"]', words.zoomTwo);
405
400
  set('[data-b="in"]', words.zoomIn);
406
401
  set('[data-g="zoom"]', words.zoom);
407
402
  set('[data-g="pan"]', words.pan);
@@ -431,11 +426,22 @@ export class ViewControlsElement extends HTMLElement {
431
426
  const advanced = this.#level === "advanced";
432
427
  const words = this.#labels;
433
428
  const zoom = zoomText(s.zoom);
434
- q('[data-o="zoom"]').textContent = zoom;
435
- q('[data-b="level"]').setAttribute("aria-label", `${words.zoomLevel} ${zoom}, ${words.zoomReset}`);
429
+ // A frame of the automatic turn comes through here: what has not changed is not written.
430
+ const write = (node, text) => {
431
+ if (node.textContent !== text)
432
+ node.textContent = text;
433
+ };
434
+ const say = (node, label) => {
435
+ if (node.getAttribute("aria-label") !== label)
436
+ node.setAttribute("aria-label", label);
437
+ };
438
+ write(q('[data-o="zoom"]'), zoom);
439
+ say(q('[data-b="level"]'), `${words.zoomLevel} ${zoom}, ${words.zoomReset}`);
436
440
  this.#sayZoom(`${words.zoomLevel} ${zoom}`);
437
441
  for (const pace of PACES)
438
442
  q(`[data-b="${pace}"]`).setAttribute("aria-pressed", String(this.#pace === pace));
443
+ write(q('[data-o="pace"]'), words[this.#pace]);
444
+ say(q('[data-b="pace-now"]'), `${words.speed}: ${words[this.#pace]}`);
439
445
  const more = q('[data-b="more"]');
440
446
  more.setAttribute("aria-expanded", String(advanced));
441
447
  this.dataset.level = this.#level;
@@ -450,6 +456,8 @@ export class ViewControlsElement extends HTMLElement {
450
456
  q(selector).hidden = flat;
451
457
  for (const selector of [".tilt", ".front", '[data-g="drag"]', '[data-g="pace"]'])
452
458
  q(selector).hidden = flat || !advanced;
459
+ // The speed is always in sight while the view turns: one small button in basic, which steps through the three; advanced always has the three.
460
+ q(".pace-now").hidden = flat || advanced || !s.turning;
453
461
  this.toggleAttribute("data-turning", s.turning);
454
462
  }
455
463
  /** Tells a screen reader the zoom once it has settled for a moment (a pinch or a wheel makes many steps). */
@@ -1,4 +1,4 @@
1
- /** `basic` is the default: turn, zoom, Fit and Auto-turn. `advanced` adds tilt, speed, Front, 2 times and the pan pad. */
1
+ /** `basic` is the default: turn, zoom with its readout, Fit and Auto-turn, and the speed while it turns. `advanced` adds tilt, the three speeds, Front and the pan pad. */
2
2
  export type Level = "basic" | "advanced";
3
3
  export declare const parseLevel: (text: string | null | undefined) => Level | undefined;
4
4
  /** The localStorage key the level is remembered under, unless `storage` names another. */
@@ -8,5 +8,5 @@ export type Pace = "slow" | "medium" | "fast";
8
8
  export declare const PACES: readonly Pace[];
9
9
  export declare const PACE_FACTOR: Record<Pace, number>;
10
10
  export declare const parsePace: (text: string | null | undefined) => Pace | undefined;
11
- /** The zoom as the readout shows it: `1×`, `2.4×`. */
11
+ /** The zoom as the readout shows it: `1×`, `2.4×`; below 1, two figures, so a small zoom never reads `0×`. */
12
12
  export declare const zoomText: (zoom: number) => string;
@@ -5,5 +5,5 @@ export const LEVEL_STORAGE_KEY = "kazoku-view-controls";
5
5
  export const PACES = ["slow", "medium", "fast"];
6
6
  export const PACE_FACTOR = { slow: 0.5, medium: 1, fast: 2 };
7
7
  export const parsePace = (text) => PACES.find((pace) => pace === text);
8
- /** The zoom as the readout shows it: `1×`, `2.4×`. */
9
- export const zoomText = (zoom) => `${Number(zoom.toFixed(1))}×`;
8
+ /** The zoom as the readout shows it: `1×`, `2.4×`; below 1, two figures, so a small zoom never reads `0×`. */
9
+ export const zoomText = (zoom) => `${Number(zoom < 1 ? zoom.toPrecision(2) : zoom.toFixed(1))}×`;
@@ -0,0 +1,8 @@
1
+ import type { ViewController } from "./controller.ts";
2
+ /**
3
+ * Calls `tell` with whether `target` can be seen, at once and on every change. Until the observer has answered the view counts as not seen, so a view that opens off screen
4
+ * never asks for a frame. Without `IntersectionObserver` it counts as on screen. Returns a function that stops watching.
5
+ */
6
+ export declare function watchSight(target: Element, tell: (seen: boolean) => void): () => void;
7
+ /** Stops the controller's frame loop while `target` cannot be seen, and lets it go on when it can. Returns a function that stops watching and takes its hold off. */
8
+ export declare function turnOnlyInSight(controller: Pick<ViewController, "suspendTurn" | "resumeTurn">, target: Element): () => void;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Calls `tell` with whether `target` can be seen, at once and on every change. Until the observer has answered the view counts as not seen, so a view that opens off screen
3
+ * never asks for a frame. Without `IntersectionObserver` it counts as on screen. Returns a function that stops watching.
4
+ */
5
+ export function watchSight(target, tell) {
6
+ const doc = target.ownerDocument;
7
+ let onScreen = typeof IntersectionObserver !== "function";
8
+ let told;
9
+ const check = () => {
10
+ const seen = onScreen && doc?.visibilityState !== "hidden";
11
+ if (seen === told)
12
+ return;
13
+ told = seen;
14
+ tell(seen);
15
+ };
16
+ const observer = onScreen
17
+ ? undefined
18
+ : new IntersectionObserver((entries) => {
19
+ const latest = entries[entries.length - 1];
20
+ if (latest === undefined)
21
+ return;
22
+ onScreen = latest.isIntersecting;
23
+ check();
24
+ });
25
+ observer?.observe(target);
26
+ doc?.addEventListener?.("visibilitychange", check);
27
+ check();
28
+ return () => {
29
+ observer?.disconnect();
30
+ doc?.removeEventListener?.("visibilitychange", check);
31
+ };
32
+ }
33
+ /** Stops the controller's frame loop while `target` cannot be seen, and lets it go on when it can. Returns a function that stops watching and takes its hold off. */
34
+ export function turnOnlyInSight(controller, target) {
35
+ let held = false;
36
+ const stop = watchSight(target, (seen) => {
37
+ if (held === !seen)
38
+ return;
39
+ held = !seen;
40
+ if (held)
41
+ controller.suspendTurn();
42
+ else
43
+ controller.resumeTurn();
44
+ });
45
+ return () => {
46
+ stop();
47
+ if (held)
48
+ controller.resumeTurn();
49
+ held = false;
50
+ };
51
+ }
@@ -1 +1 @@
1
- export declare const STYLES = "\n:host {\n --_ink: var(--kazoku-ink, #1b1b1f);\n --_surface: var(--kazoku-surface, #ffffff);\n --_line: var(--kazoku-line, #c9ccd3);\n --_accent: var(--kazoku-accent, #2457d6);\n display: block;\n box-sizing: border-box;\n color: var(--_ink);\n font: 600 0.9rem/1.2 system-ui, -apple-system, \"Hiragino Sans\", \"Noto Sans JP\", \"Segoe UI\", sans-serif;\n color-scheme: light dark;\n}\n@media (prefers-color-scheme: dark) {\n :host(:not([theme=\"light\"])) {\n --_ink: var(--kazoku-ink, #f1f2f6);\n --_surface: var(--kazoku-surface, #1c1d22);\n --_line: var(--kazoku-line, #4a4d58);\n --_accent: var(--kazoku-accent, #8fb0ff);\n }\n}\n:host([theme=\"dark\"]) {\n --_ink: var(--kazoku-ink, #f1f2f6);\n --_surface: var(--kazoku-surface, #1c1d22);\n --_line: var(--kazoku-line, #4a4d58);\n --_accent: var(--kazoku-accent, #8fb0ff);\n color-scheme: dark;\n}\n:host([theme=\"light\"]) { color-scheme: light; }\n:host([overlay]) { position: absolute; inset-inline: 0; bottom: 0; z-index: 1; }\n:host([overlay]) .bar { background: color-mix(in srgb, var(--_surface) 88%, transparent); }\n[hidden] { display: none !important; }\n.bar { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; padding: 4px 8px; box-sizing: border-box; }\n.field { display: flex; align-items: center; gap: 8px; flex: 1 1 200px; min-width: 0; }\n.field.tilt { flex-basis: 150px; }\n.field label { flex: none; }\n.field output { flex: none; min-width: 3.2em; text-align: end; font-variant-numeric: tabular-nums; }\n.group { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 4px; }\n.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\ninput[type=\"range\"] { -webkit-appearance: none; appearance: none; flex: 1 1 auto; width: 100%; min-width: 60px; height: 48px; margin: 0; padding: 0; background: transparent; cursor: pointer; }\ninput[type=\"range\"]::-webkit-slider-runnable-track { height: 6px; border-radius: 3px; background: var(--_line); }\ninput[type=\"range\"]::-moz-range-track { height: 6px; border-radius: 3px; background: var(--_line); }\ninput[type=\"range\"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 28px; height: 28px; margin-top: -11px; border-radius: 50%; background: var(--_accent); border: 3px solid var(--_surface); box-shadow: 0 0 0 1px var(--_ink); }\ninput[type=\"range\"]::-moz-range-thumb { width: 22px; height: 22px; border-radius: 50%; background: var(--_accent); border: 3px solid var(--_surface); box-shadow: 0 0 0 1px var(--_ink); }\ninput[type=\"range\"]:focus-visible { outline: 2px solid var(--_ink); outline-offset: -4px; border-radius: 8px; }\nbutton { -webkit-appearance: none; appearance: none; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 48px; min-height: 48px; margin: 0; padding: 0 14px; border: 1px solid var(--_line); border-radius: 24px; background: var(--_surface); color: var(--_ink); font: inherit; cursor: pointer; white-space: nowrap; }\nbutton:hover:not(:disabled) { border-color: var(--_ink); }\nbutton:focus-visible { outline: 2px solid var(--_ink); outline-offset: 2px; }\nbutton[aria-pressed=\"true\"] { background: var(--_ink); color: var(--_surface); border-color: var(--_ink); }\nbutton[aria-expanded=\"true\"] { border-color: var(--_ink); box-shadow: inset 0 0 0 1px var(--_ink); }\nbutton:disabled { opacity: 0.4; cursor: default; }\nsvg { width: 18px; height: 18px; fill: currentColor; flex: none; }\nbutton[aria-pressed=\"true\"] .i-play, button[aria-pressed=\"false\"] .i-pause { display: none; }\n.zoom button.two { min-width: 56px; }\n.zoom button.level { min-width: 64px; padding: 0 8px; font-variant-numeric: tabular-nums; }\n.pace .lbl { margin-inline-end: 4px; }\n.drag { margin-inline-start: 8px; }\n.more { margin-inline-start: auto; }\n/* A compact bar on a phone: two rows to start with (angle, Auto-turn and More; the zoom), more rows when the reader asks for more; labels read only by a screen reader. */\n@media (max-width: 640px) {\n .bar { display: grid; grid-template-columns: minmax(0, 1fr) 48px 48px; grid-template-areas: \"angle play more\" \"tilt tilt front\" \"pace pace pace\" \"zoom zoom zoom\" \"pan pan pan\"; gap: 0 8px; padding: 2px 8px; }\n .field.angle { grid-area: angle; }\n .field.tilt { grid-area: tilt; }\n .field label, .field output, .play .text, .more .text, .pace .lbl { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }\n .play, .more { padding: 0; }\n .play { grid-area: play; }\n .more { grid-area: more; }\n .front { grid-area: front; padding: 0 12px; }\n .pace { grid-area: pace; justify-self: end; flex-wrap: nowrap; }\n .pace button { padding: 0 12px; }\n .zoom { grid-area: zoom; justify-self: end; flex-wrap: nowrap; }\n .zoom button { padding: 0 8px; }\n .zoom button.level { padding: 0 6px; }\n .pan { grid-area: pan; justify-content: center; }\n .drag { margin-inline-start: 4px; }\n}\n";
1
+ export declare const STYLES = "\n:host {\n --_ink: var(--kazoku-ink, #1b1b1f);\n --_surface: var(--kazoku-surface, #ffffff);\n --_line: var(--kazoku-line, #c9ccd3);\n --_accent: var(--kazoku-accent, #2457d6);\n display: block;\n box-sizing: border-box;\n color: var(--_ink);\n font: 600 0.9rem/1.2 system-ui, -apple-system, \"Hiragino Sans\", \"Noto Sans JP\", \"Segoe UI\", sans-serif;\n color-scheme: light dark;\n}\n@media (prefers-color-scheme: dark) {\n :host(:not([theme=\"light\"])) {\n --_ink: var(--kazoku-ink, #f1f2f6);\n --_surface: var(--kazoku-surface, #1c1d22);\n --_line: var(--kazoku-line, #4a4d58);\n --_accent: var(--kazoku-accent, #8fb0ff);\n }\n}\n:host([theme=\"dark\"]) {\n --_ink: var(--kazoku-ink, #f1f2f6);\n --_surface: var(--kazoku-surface, #1c1d22);\n --_line: var(--kazoku-line, #4a4d58);\n --_accent: var(--kazoku-accent, #8fb0ff);\n color-scheme: dark;\n}\n:host([theme=\"light\"]) { color-scheme: light; }\n:host([overlay]) { position: absolute; inset-inline: 0; bottom: 0; z-index: 1; }\n:host([overlay]) .bar { background: color-mix(in srgb, var(--_surface) 88%, transparent); }\n[hidden] { display: none !important; }\n.bar { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; padding: 4px 8px; box-sizing: border-box; }\n.field { display: flex; align-items: center; gap: 8px; flex: 1 1 200px; min-width: 0; }\n.field.tilt { flex-basis: 150px; }\n.field label { flex: none; }\n.field output { flex: none; min-width: 3.2em; text-align: end; font-variant-numeric: tabular-nums; }\n.group { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 4px; }\n.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\ninput[type=\"range\"] { -webkit-appearance: none; appearance: none; flex: 1 1 auto; width: 100%; min-width: 60px; height: 48px; margin: 0; padding: 0; background: transparent; cursor: pointer; }\ninput[type=\"range\"]::-webkit-slider-runnable-track { height: 6px; border-radius: 3px; background: var(--_line); }\ninput[type=\"range\"]::-moz-range-track { height: 6px; border-radius: 3px; background: var(--_line); }\ninput[type=\"range\"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 28px; height: 28px; margin-top: -11px; border-radius: 50%; background: var(--_accent); border: 3px solid var(--_surface); box-shadow: 0 0 0 1px var(--_ink); }\ninput[type=\"range\"]::-moz-range-thumb { width: 22px; height: 22px; border-radius: 50%; background: var(--_accent); border: 3px solid var(--_surface); box-shadow: 0 0 0 1px var(--_ink); }\ninput[type=\"range\"]:focus-visible { outline: 2px solid var(--_ink); outline-offset: -4px; border-radius: 8px; }\nbutton { -webkit-appearance: none; appearance: none; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 48px; min-height: 48px; margin: 0; padding: 0 14px; border: 1px solid var(--_line); border-radius: 24px; background: var(--_surface); color: var(--_ink); font: inherit; cursor: pointer; white-space: nowrap; }\nbutton:hover:not(:disabled) { border-color: var(--_ink); }\nbutton:focus-visible { outline: 2px solid var(--_ink); outline-offset: 2px; }\nbutton[aria-pressed=\"true\"] { background: var(--_ink); color: var(--_surface); border-color: var(--_ink); }\nbutton[aria-expanded=\"true\"] { border-color: var(--_ink); box-shadow: inset 0 0 0 1px var(--_ink); }\nbutton:disabled { opacity: 0.4; cursor: default; }\nsvg { width: 18px; height: 18px; fill: currentColor; flex: none; }\nbutton[aria-pressed=\"true\"] .i-play, button[aria-pressed=\"false\"] .i-pause { display: none; }\n.zoom button.level { min-width: 64px; padding: 0 8px; font-variant-numeric: tabular-nums; }\n.pace .lbl { margin-inline-end: 4px; }\n/* One small button: the word over the speed, so it takes little room. It comes in on Auto-turn's left, so Auto-turn stays under the pointer that just pressed it. The angle's running number makes the room, so the turning bar is never wider than the still one and adds no row; it stays for a screen reader. */\n.pace-now { flex-direction: column; gap: 0; padding: 0 10px; line-height: 1.1; }\n.pace-now .lbl { font-size: 0.7em; font-weight: 400; }\n:host([data-turning][data-level=\"basic\"]) .field.angle { flex-basis: 110px; }\n:host([data-turning][data-level=\"basic\"]) .field.angle output { position: absolute; width: 1px; min-width: 0; height: 1px; overflow: hidden; clip-path: inset(50%); }\n.drag { margin-inline-start: 8px; }\n.more { margin-inline-start: auto; }\n/* A compact bar on a phone: two rows to start with (angle, Auto-turn and More; the zoom), more rows when the reader asks for more; labels read only by a screen reader. */\n@media (max-width: 640px) {\n .bar { display: grid; grid-template-columns: minmax(0, 1fr) 48px 48px; grid-template-areas: \"angle play more\" \"tilt tilt front\" \"pace pace pace\" \"zoom zoom zoom\" \"pan pan pan\"; gap: 0 8px; padding: 2px 8px; }\n .field.angle { grid-area: angle; }\n .field.tilt { grid-area: tilt; }\n .field label, .field output, .play .text, .more .text, .pace .lbl { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }\n .play, .more { padding: 0; }\n .play { grid-area: play; }\n .more { grid-area: more; }\n .front { grid-area: front; padding: 0 12px; }\n .pace { grid-area: pace; justify-self: end; flex-wrap: nowrap; }\n .pace button { padding: 0 12px; }\n .zoom { grid-area: zoom; justify-self: end; flex-wrap: nowrap; }\n .zoom button { padding: 0 8px; }\n .zoom button.level { padding: 0 6px; }\n /* In the free room left of the zoom buttons, so showing it adds no row. */\n .pace-now { grid-area: zoom; justify-self: start; }\n .pan { grid-area: pan; justify-content: center; }\n .drag { margin-inline-start: 4px; }\n}\n";
@@ -52,9 +52,13 @@ button[aria-expanded="true"] { border-color: var(--_ink); box-shadow: inset 0 0
52
52
  button:disabled { opacity: 0.4; cursor: default; }
53
53
  svg { width: 18px; height: 18px; fill: currentColor; flex: none; }
54
54
  button[aria-pressed="true"] .i-play, button[aria-pressed="false"] .i-pause { display: none; }
55
- .zoom button.two { min-width: 56px; }
56
55
  .zoom button.level { min-width: 64px; padding: 0 8px; font-variant-numeric: tabular-nums; }
57
56
  .pace .lbl { margin-inline-end: 4px; }
57
+ /* One small button: the word over the speed, so it takes little room. It comes in on Auto-turn's left, so Auto-turn stays under the pointer that just pressed it. The angle's running number makes the room, so the turning bar is never wider than the still one and adds no row; it stays for a screen reader. */
58
+ .pace-now { flex-direction: column; gap: 0; padding: 0 10px; line-height: 1.1; }
59
+ .pace-now .lbl { font-size: 0.7em; font-weight: 400; }
60
+ :host([data-turning][data-level="basic"]) .field.angle { flex-basis: 110px; }
61
+ :host([data-turning][data-level="basic"]) .field.angle output { position: absolute; width: 1px; min-width: 0; height: 1px; overflow: hidden; clip-path: inset(50%); }
58
62
  .drag { margin-inline-start: 8px; }
59
63
  .more { margin-inline-start: auto; }
60
64
  /* A compact bar on a phone: two rows to start with (angle, Auto-turn and More; the zoom), more rows when the reader asks for more; labels read only by a screen reader. */
@@ -72,6 +76,8 @@ button[aria-pressed="true"] .i-play, button[aria-pressed="false"] .i-pause { dis
72
76
  .zoom { grid-area: zoom; justify-self: end; flex-wrap: nowrap; }
73
77
  .zoom button { padding: 0 8px; }
74
78
  .zoom button.level { padding: 0 6px; }
79
+ /* In the free room left of the zoom buttons, so showing it adds no row. */
80
+ .pace-now { grid-area: zoom; justify-self: start; }
75
81
  .pan { grid-area: pan; justify-content: center; }
76
82
  .drag { margin-inline-start: 4px; }
77
83
  }
@@ -6,6 +6,7 @@ export interface ViewWords {
6
6
  front: string;
7
7
  zoom: string;
8
8
  zoomOut: string;
9
+ /** Not shown any more: the bar has no "2×" button since the readout came, as it read like one. Kept so a page's own `words` still type-check. */
9
10
  zoomTwo: string;
10
11
  zoomIn: string;
11
12
  fit: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnmorrisdotca/kazoku-ui",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "Kazoku UI 家族, the family's shared interface parts as their own versioned package: view controls for any 3D or zoomable view (angle, tilt, auto-turn, zoom about the pointer, pan, touch, address state) a light, dark and auto theme switch, a language picker, choices kept in the address and a save-as-image button, each a framework-free custom element and a plain controller. Zero dependencies; the shared 3D renderer takes three.js as an optional peer.",
5
5
  "keywords": [
6
6
  "view-controls",