@johnmorrisdotca/kazoku-ui 0.9.0 → 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 +23 -0
- package/README.md +6 -6
- package/dist/testing/index.d.ts +5 -0
- package/dist/testing/index.js +5 -0
- package/dist/view-controls/controller.d.ts +2 -2
- package/dist/view-controls/controller.js +6 -1
- package/dist/view-controls/element.d.ts +1 -1
- package/dist/view-controls/element.js +79 -71
- package/dist/view-controls/levels.d.ts +2 -2
- package/dist/view-controls/levels.js +2 -2
- package/dist/view-controls/sight.d.ts +8 -0
- package/dist/view-controls/sight.js +51 -0
- package/dist/view-controls/styles.d.ts +1 -1
- package/dist/view-controls/styles.js +7 -1
- package/dist/view-controls/words.d.ts +1 -0
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
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
|
+
|
|
18
|
+
## [0.9.1] - 2026-11-02
|
|
19
|
+
|
|
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.
|
|
21
|
+
|
|
22
|
+
- **`SOFTWARE_GL_ARGS`** in `@johnmorrisdotca/kazoku-ui/testing`: the Chromium launch arguments that draw WebGL in software (ANGLE on SwiftShader), for every package's Playwright config. This repository's own config uses them for both Chromium projects, and its WebKit projects skip `renderer.demo.mjs` off CI (WebKit has no software switch).
|
|
23
|
+
- **`freeze-command`** input of `reusable-release.yml`: a package with live 3D passes its freeze test (`pnpm test:freeze`), which runs in headless Chromium before anything is published.
|
|
24
|
+
- The rule is written in `docs/renderer.md` ("The rule: no 3D release without a freeze test") and in `docs/release-workflow.md`.
|
|
25
|
+
|
|
3
26
|
## [0.9.0] - 2026-11-02
|
|
4
27
|
|
|
5
28
|
The sixth and seventh parts: the shared 3D renderer, at `@johnmorrisdotca/kazoku-ui/renderer`, and the freeze checks for a 3D package's e2e, at `@johnmorrisdotca/kazoku-ui/testing`.
|
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,
|
|
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
|
|
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,
|
|
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
|
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The launch arguments that make headless Chromium draw WebGL in software (ANGLE on SwiftShader), never on the machine's graphics card. Every package's Playwright
|
|
3
|
+
* config gives them to its Chromium projects: `use: { launchOptions: { args: [...SOFTWARE_GL_ARGS] } }`. A test run on a Mac's own GPU once panicked its graphics driver.
|
|
4
|
+
*/
|
|
5
|
+
export declare const SOFTWARE_GL_ARGS: readonly string[];
|
|
1
6
|
/** The GL object kinds counted. */
|
|
2
7
|
export declare const GPU_KINDS: readonly ["Buffer", "Texture", "VertexArray", "Program", "Framebuffer", "Renderbuffer"];
|
|
3
8
|
export type GpuKind = (typeof GPU_KINDS)[number];
|
package/dist/testing/index.js
CHANGED
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
// expect(after.contexts).toBe(1);
|
|
11
11
|
// expect(after.draws - before.draws).toBe(0); // at rest
|
|
12
12
|
// expect(gpuObjects(after)).toBe(gpuObjects(before)); // nothing leaked
|
|
13
|
+
/**
|
|
14
|
+
* The launch arguments that make headless Chromium draw WebGL in software (ANGLE on SwiftShader), never on the machine's graphics card. Every package's Playwright
|
|
15
|
+
* config gives them to its Chromium projects: `use: { launchOptions: { args: [...SOFTWARE_GL_ARGS] } }`. A test run on a Mac's own GPU once panicked its graphics driver.
|
|
16
|
+
*/
|
|
17
|
+
export const SOFTWARE_GL_ARGS = ["--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--ignore-gpu-blocklist"];
|
|
13
18
|
/** The GL object kinds counted. */
|
|
14
19
|
export const GPU_KINDS = ["Buffer", "Texture", "VertexArray", "Program", "Framebuffer", "Renderbuffer"];
|
|
15
20
|
/** Install the counters in a page, before its own scripts run. A string, so `page.addInitScript(COUNT_SCRIPT)` takes it as it is. */
|
|
@@ -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
|
|
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
|
-
#
|
|
78
|
-
#
|
|
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
|
-
|
|
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
|
|
185
|
-
this.#
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
292
|
-
on("
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
on("
|
|
309
|
-
on("
|
|
310
|
-
on("
|
|
311
|
-
on("
|
|
312
|
-
on("
|
|
313
|
-
on("
|
|
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
|
|
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.#
|
|
368
|
-
this.#
|
|
369
|
-
this.#
|
|
370
|
-
|
|
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
|
-
|
|
435
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
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",
|
|
@@ -162,6 +162,7 @@
|
|
|
162
162
|
"dev": "pnpm build && node scripts/dev.mjs",
|
|
163
163
|
"site": "pnpm build && node scripts/site.mjs",
|
|
164
164
|
"test:demo": "pnpm site && playwright test",
|
|
165
|
+
"test:freeze": "pnpm site && playwright test e2e/renderer.demo.mjs --project=chromium-desk",
|
|
165
166
|
"screenshots": "pnpm site && node scripts/screenshots.mjs"
|
|
166
167
|
},
|
|
167
168
|
"devDependencies": {
|