@johnmorrisdotca/kazoku-ui 0.13.0 → 0.14.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
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.14.0] - 2026-11-03
6
+
7
+ One speed control for the whole family: the Auto-turn speeds are 0.5×, 1× and 2×, and the same buttons serve a solar system's days a second and a replay bar.
8
+
9
+ - **`<kazoku-speed-buttons>`** (KAZOKU-UI-46, John 2026-11-03: "the same controls throughout all apps"). One button for each speed, no menu: `steps="0.5 1 2"` by default, any numbers (`1 10 100` for days a second, `0.25 0.5 1 2 4` for a replay, `0 1 3600 86400 -3600` for a clock that can stop and run back), `unit` (`×`, or none), `label` (the group's name, shown and read), `no-label`, `address` and `address-key` (the choice kept in the address, the start left off), `lang` and `theme`. The chosen speed is the `value`, an attribute that follows the reader's choice, and the property; `steps` and `step(1)` (the next speed, round at the ends) are properties too; the page hears `kazoku-speedchange` with `{ value, steps, reason }`. It is the Auto-turn control in the view controls and a part of its own: `@johnmorrisdotca/kazoku-ui/speed-buttons`, `/element` and `/define`, with the pure `parseSteps`, `speedText`, `nearestStep`, `startStep`, `stepFrom`, `parseSpeed` and `SPEED_ADDRESS_KEY`. Reduced motion changes nothing in it. New demo page `speed.html`, a guide in English and Japanese, `e2e/speed-buttons.demo.mjs`, `test/speed-buttons.test.ts`.
10
+ - **The view controls' speeds are 0.5×, 1× and 2×**, not Slow, Medium and Fast. The new `pace` attribute is the speed as a multiple of `speed` (1 by default) and follows the buttons; `paces` names other buttons (`paces="1 4 8"`). The address key is now `pace` (`?pace=2`), no longer `speed`, which is a page's own (a clock's, a replay's) and is never read or written by the controls; `?pace=slow` and `?pace=fast` of 0.12 and 0.13 still read as 0.5 and 2. `DEFAULT_KEYS.speed` is `"pace"`, `AddressChoices.pace` a number. `PACES`, `PACE_FACTOR`, `parsePace` and the type `Pace` remain, for the three words. The small speed button of the basic level while the view turns shows `1×` and steps through the speeds.
11
+
12
+ ### Documentation
13
+
14
+ - A guide, `speed-buttons.md`, with its Japanese page; the view controls guide, README, getting started, architecture, labels and upgrading pages say `pace`, `paces` and the numbers; the API reference lists the new part; a picture of the buttons three ways (`docs/images/speed-buttons-*.webp`).
15
+
16
+ ## [0.13.1] - 2026-11-03
17
+
18
+ The layouts hold at a phone's width and in any narrow panel.
19
+
20
+ - **Every layout works at a phone's width and at a desk's** (KAZOKU-UI-42, from the senshu-jerseys 0.36.0 rollout). `horizontal` in a panel as narrow as a phone's (336 px at a 390 px screen, with the page's margins) put the zoom slider's marks over each other and over the minus button, and squashed the bar; now, under 440 px (a phone, or a face wider than the system's), the slider has a row of its own and the buttons sit under it (Centre beside them). `compact` with More open at 300 px let the speeds and Fit reach past the bar on a wide face; the speeds wrap there now. The one-row form of the horizontal bar began at 900 px, where the angle slider was squeezed to 60 px and met Auto-turn and the speed button; it now begins at 1040 px, and the two rows hold between. `classic` on a phone in a panel under 330 px put the speed button over the minus button; it takes the row under them there.
21
+ - `e2e/phone-layouts.demo.mjs`: each layout at both levels (`controls`), still, turning, zoomed and with More pressed, at the phone's width and at forced panel widths from 280 px to 1100 px: no two controls overlap, none is outside the controls, a button is at least 47.5 px tall, a slider at least 60 px wide, and the page never scrolls sideways. `advanced` with `compact` holds in all of them; the two senshu tests that broke on it could not be reproduced here, so if one comes back, send its page and width.
22
+
5
23
  ## [0.13.0] - 2026-11-03
6
24
 
7
25
  The wheel over the view zooms it, in every family package that uses the view controls.
package/README.md CHANGED
@@ -11,7 +11,7 @@ The family's shared interface parts, as their own versioned package: one part pe
11
11
  The first part is the **view controls** for any 3D or zoomable view. They never draw the scene: they tell your page what the reader chose, and your canvas, WebGL,
12
12
  SVG or CSS 3D applies it.
13
13
 
14
- [Demo](https://johnmorrisdotca.github.io/kazoku-ui/) · [Layouts demo](https://johnmorrisdotca.github.io/kazoku-ui/layouts.html) · [Strict-CSP demo](https://johnmorrisdotca.github.io/kazoku-ui/csp.html) · [Theme switch](docs/theme-switch.md) · [Language picker](docs/language-picker.md) · [Address state](docs/address-state.md) · [Save image](docs/save-image.md) · [Shared 3D renderer](docs/renderer.md) · [The drag rule](docs/drag-rule.md) · [Content-Security-Policy](docs/csp.md) · [Labels, English and 日本語](docs/labels.md) · [Changelog](CHANGELOG.md) · [Builder](https://johnmorrisdotca.github.io/kazoku-ui/builder.html) · [API reference](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/API.md) · [Guides](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/guides)
14
+ [Demo](https://johnmorrisdotca.github.io/kazoku-ui/) · [Layouts demo](https://johnmorrisdotca.github.io/kazoku-ui/layouts.html) · [Strict-CSP demo](https://johnmorrisdotca.github.io/kazoku-ui/csp.html) · [Theme switch](docs/theme-switch.md) · [Language picker](docs/language-picker.md) · [Address state](docs/address-state.md) · [Save image](docs/save-image.md) · [Speed buttons](docs/guides/speed-buttons.md) · [Shared 3D renderer](docs/renderer.md) · [The drag rule](docs/drag-rule.md) · [Content-Security-Policy](docs/csp.md) · [Labels, English and 日本語](docs/labels.md) · [Changelog](CHANGELOG.md) · [Builder](https://johnmorrisdotca.github.io/kazoku-ui/builder.html) · [API reference](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/API.md) · [Guides](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/guides)
15
15
 
16
16
  ## In 30 seconds
17
17
 
@@ -57,6 +57,7 @@ console.log(zoomText(2.44)); // 2.4×
57
57
  - **View controls** in four layouts, with a zoom slider, a readout, Auto-turn, Centre, keys, touch and the address.
58
58
  - **Theme switch** (Light, Dark, Auto) and **language picker** (each language in its own name) that write `data-theme` and `lang` and tell the page.
59
59
  - **Save image** for a canvas, an SVG or any element, as PNG or SVG.
60
+ - **Speed buttons**: one row of 0.5×, 1×, 2× (or the steps you name: a solar system's days a second, a replay's speeds) for the whole family, kept in an attribute and the address.
60
61
  - **Address state**, with no element: choices kept in the link with `replaceState`.
61
62
  - **A shared 3D renderer** and its **freeze checks**: one WebGL context, shared assets, stills, a pixel budget.
62
63
  - **Labels in English and Japanese**, light and dark, 48 px touch targets, reduced motion respected.
@@ -156,15 +157,18 @@ Each its own subpath; later ones: picture-tile pickers, Help, GitHub and npm pil
156
157
  | `@johnmorrisdotca/kazoku-ui/save-image` | The formats, the file name, the size and the functions that render and download a canvas, an SVG or an element as PNG or SVG. Imports on a server. |
157
158
  | `@johnmorrisdotca/kazoku-ui/save-image/element` | The `SaveImageElement` class (needs a browser). |
158
159
  | `@johnmorrisdotca/kazoku-ui/save-image/define` | Registers `<kazoku-save-image>` (once, and only if the tag is free). |
160
+ | `@johnmorrisdotca/kazoku-ui/speed-buttons` | The steps, how a speed reads, the nearest step and the address key of the speed buttons. Imports on a server. |
161
+ | `@johnmorrisdotca/kazoku-ui/speed-buttons/element` | The `SpeedButtonsElement` class (needs a browser). |
162
+ | `@johnmorrisdotca/kazoku-ui/speed-buttons/define` | Registers `<kazoku-speed-buttons>` (once, and only if the tag is free). |
159
163
  | `@johnmorrisdotca/kazoku-ui/renderer` | The shared 3D renderer: one WebGL context for the focused view, shared assets, stills, drawing on demand, a pixel budget. three.js is an optional peer, loaded by your `import("three")`. Imports on a server. |
160
164
  | `@johnmorrisdotca/kazoku-ui/testing` | The freeze checks for a 3D package's e2e: count contexts, GPU objects, draws and frames; the canvas size against the budget. No test framework named. |
161
165
  | `@johnmorrisdotca/kazoku-ui/address-state` | Choices kept in the address: read, write and subscribe to URL parameters with `replaceState`. No element; imports on a server. |
162
166
 
163
167
  ## View controls
164
168
 
165
- - **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.
169
+ - **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 `1×`, and a press steps to the next speed). More (or `controls="advanced"`) adds the tilt slider, the speed buttons (0.5×, 1×, 2×), 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.
166
170
  - 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.
167
- - 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`.
171
+ - Auto-turn with play and pause; its speed is chosen with the family's [speed buttons](docs/guides/speed-buttons.md): 0.5× (half the `speed` attribute), 1× (the `speed` attribute, 30 degrees a second) or 2× (twice it), kept in the `pace` attribute and in the address as `?pace=0.5` or `?pace=2`.
168
172
  - **The wheel zooms.** The wheel over the view zooms it about the pointer, like a map; a trackpad's pinch (and Ctrl or Command with the wheel) zooms too. Shift and the wheel, or a sideways swipe, turns it. Tipping is a drag, the arrow keys or the tilt slider. With the pointer anywhere else the wheel scrolls the page, and a wheel that starts while the zoom is already at its end that way (turned down at Fit) scrolls the page too, so the page is never trapped.
169
173
  - Touch: one finger turns at 1x and moves the view when zoomed in; two fingers pinch.
170
174
  - Keys on the view: arrows turn and tip it, `+` and `-` zoom, `0` or Home is Front and Fit.
@@ -186,8 +190,8 @@ The `layout` attribute says where the controls sit; `controls` (`basic` or `adva
186
190
 
187
191
  | `layout` | What it looks like | Basic shows | More adds |
188
192
  |---|---|---|---|
189
- | `classic` (the default) | The bar of 0.10 and before, unchanged: one wrapping row at a desk, two rows on a phone. No zoom slider. | Angle, Auto-turn, − readout +, Fit, More | Tilt, Slow, Medium, Fast, Front; the pan pad once zoomed in |
190
- | `horizontal` | A tidy bar under the view: the turn on one row (Angle, Auto-turn, More), the **zoom slider** on the next (Zoom, − readout +, Fit). One row when the bar is 900 px or wider. Under 560 px the labels are read by a screen reader only, and the readout is Fit. Centre, once the view is zoomed or moved, comes in at the end of the zoom row (an icon in the two rows, so Auto-turn and More never move). | Angle, Auto-turn, the zoom slider, − readout +, Fit, More | Tilt and Front, then the speeds, between the two rows; the pan pad last |
193
+ | `classic` (the default) | The bar of 0.10 and before, unchanged: one wrapping row at a desk, two rows on a phone. No zoom slider. | Angle, Auto-turn, − readout +, Fit, More | Tilt, 0.5×, 1×, 2×, Front; the pan pad once zoomed in |
194
+ | `horizontal` | A tidy bar under the view: the turn on one row (Angle, Auto-turn, More), the **zoom slider** on the next (Zoom, − readout +, Fit). One row when the bar is 1040 px or wider. Under 560 px the labels are read by a screen reader only, and the readout is Fit; under 440 px the zoom slider takes a row of its own, with the buttons under it, so its marks never meet them. Centre, once the view is zoomed or moved, comes in at the end of the zoom row (an icon in the two rows, so Auto-turn and More never move). | Angle, Auto-turn, the zoom slider, − readout +, Fit, More | Tilt and Front, then the speeds, between the two rows; the pan pad last |
191
195
  | `vertical` | A column beside the view, `--kazoku-column-width` wide (16rem). Auto-turn and More at the top, so More does not move when it opens; then the Angle slider, the zoom slider, and − readout + and Fit. On a narrow screen put it under the view and give it `width: auto`. | Auto-turn, More, Angle, the zoom slider, − readout +, Fit | Tilt, the speeds, Front, the pan pad, in the column |
192
196
  | `compact` | The least room, for a card or a phone: **one row** of Auto-turn, −, the readout, + and More (icons, named for a screen reader). It never takes a second row until More is pressed; while it turns, the speed is in More, and Centre is there with Fit. | Auto-turn, − readout +, More | Everything else, one press away, under the row: the zoom slider and Fit, Angle, Tilt, the speeds, Front, the pan pad |
193
197
 
@@ -355,13 +359,15 @@ An `init` event is sent once, a moment after the element connects, with the star
355
359
  | `grab-radius` | Exact grab, for a globe or any turnable piece: its on-screen radius in CSS pixels at Fit, or `fit` for half the smaller side of the view. A drag then turns it by the angle that keeps the touched point under the pointer (a drag of *d* pixels moves the ground at the middle by *d* pixels), and the radius grows with the zoom. Left out, a drag turns by a fixed angle across the view, as before. See [docs/drag-rule.md](docs/drag-rule.md). |
356
360
  | `autoplay` | Turn by itself from the start, unless the reader prefers reduced motion. |
357
361
  | `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. |
358
- | `speed` | Degrees a second at Medium (30); Slow is half and Fast twice. |
362
+ | `speed` | Degrees a second at a pace of 1 (30). |
363
+ | `pace` | The Auto-turn speed as a multiple of `speed`: 1 by default, 0.5 and 2 on the default buttons. The reader's choice is written here, and kept in the address as `pace`. |
364
+ | `paces` | The speeds the buttons stand for, `0.5 1 2` by default (any numbers: `paces="1 4 8"`). |
359
365
  | `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. |
360
366
  | `layout` | `classic` (the default), `horizontal`, `vertical` or `compact`: where the controls sit (see [Layouts](#layouts)). Can change at any time. Anything else is `classic`. The host carries `data-layout` with the layout in use. |
361
367
  | `storage` | The `localStorage` key the level is remembered under (`kazoku-view-controls`). |
362
368
  | `flat` | The view cannot turn: no angle, tilt, Auto-turn or Front, and a drag moves it. |
363
369
  | `tilt-max`, `zoom-min`, `zoom-max` | The limits (30, 1, 5). |
364
- | `address` | Keep the choices in the page's address (`?yaw=40&tilt=10&zoom=2&drag=turn&speed=fast`). |
370
+ | `address` | Keep the choices in the page's address (`?yaw=40&tilt=10&zoom=2&drag=turn&pace=2`). |
365
371
  | `address-keys` | Other keys: `yaw:a,zoom:z`. |
366
372
  | `theme` | `light` or `dark`; otherwise the page's colour scheme. |
367
373
  | `overlay` | Sit over the bottom of a positioned parent. With `layout="compact"` the row floats 8 px in from the bottom start corner instead of spanning the bottom. |
@@ -371,7 +377,7 @@ Colours are custom properties: `--kazoku-ink`, `--kazoku-surface`, `--kazoku-lin
371
377
 
372
378
  ### The address
373
379
 
374
- Only what a reader chose is kept: angle, tilt, zoom, the drag choice and the auto-turn speed (`speed=slow` or `speed=fast`; Medium leaves the address, and another value of `speed`, a page's own, is left alone). The pan is in pixels of one screen and the turning is of a moment, so neither is; while the view turns by itself the angle is left out. A value at its start (Fit, or the `zoom` the page opens at) leaves the address, and other parameters are kept. The address is written at most four times a second (Safari stops a page that changes its address more than 100 times in 30 seconds). Without the element: `parseAddress(search, keys?)` and `formatAddress(search, choices, keys?, leaveYaw?, startZoom?)`.
380
+ Only what a reader chose is kept: angle, tilt, zoom, the drag choice and the auto-turn speed (`pace=0.5` or `pace=2`; 1 leaves the address; a page's own `speed` parameter is never touched, and `?pace=slow` or `fast` of 0.12 and 0.13 still read as 0.5 and 2). The pan is in pixels of one screen and the turning is of a moment, so neither is; while the view turns by itself the angle is left out. A value at its start (Fit, or the `zoom` the page opens at) leaves the address, and other parameters are kept. The address is written at most four times a second (Safari stops a page that changes its address more than 100 times in 30 seconds). Without the element: `parseAddress(search, keys?)` and `formatAddress(search, choices, keys?, leaveYaw?, startZoom?)`.
375
381
 
376
382
  ### Why the tag is only defined when it is free
377
383
 
@@ -424,6 +430,17 @@ See [docs/address-state.md](docs/address-state.md); the demo's `address.html` sh
424
430
 
425
431
  What a picture of an element cannot carry (web fonts, `::before`, cross-origin images), the element-free `renderImage` and `saveImage`, and the one `img-src data:` a strict policy needs are in [docs/save-image.md](docs/save-image.md); the demo's `save-image.html` saves all three kinds.
426
432
 
433
+ ## Speed buttons
434
+
435
+ `<kazoku-speed-buttons>` is one button for each speed (0.5×, 1×, 2× by default, no menu): the same element for Auto-turn in the view controls, a solar system's days a second (`steps="1 10 100"`) and a replay bar. The chosen speed is the `value` attribute, can be kept in the address, and is announced as `kazoku-speedchange`.
436
+
437
+ ```html
438
+ <kazoku-speed-buttons steps="0.5 1 2" value="1" address></kazoku-speed-buttons>
439
+ <script type="module">import "@johnmorrisdotca/kazoku-ui/speed-buttons/define";</script>
440
+ ```
441
+
442
+ See [the guide](docs/guides/speed-buttons.md) for the attributes, the event and the functions without the element.
443
+
427
444
  ## Shared 3D renderer
428
445
 
429
446
  A page with many 3D views gets one WebGL context, handed to the focused view; the others are stills made once from the same renderer, kept in a budgeted list. Shared geometries,
@@ -515,7 +532,7 @@ One part, one subpath; the pure code, the element and the registration are three
515
532
 
516
533
  ## Demos
517
534
 
518
- [The demo](https://johnmorrisdotca.github.io/kazoku-ui/) has the view controls, the language picker and the theme switch in its header. Beside it: [layouts](https://johnmorrisdotca.github.io/kazoku-ui/layouts.html), [the builder](https://johnmorrisdotca.github.io/kazoku-ui/builder.html), [address state](https://johnmorrisdotca.github.io/kazoku-ui/address.html), [save image](https://johnmorrisdotca.github.io/kazoku-ui/save-image.html), [the shared renderer](https://johnmorrisdotca.github.io/kazoku-ui/renderer.html), [exact grab](https://johnmorrisdotca.github.io/kazoku-ui/grab.html) and [a strict Content-Security-Policy](https://johnmorrisdotca.github.io/kazoku-ui/csp.html).
535
+ [The demo](https://johnmorrisdotca.github.io/kazoku-ui/) has the view controls, the language picker and the theme switch in its header. Beside it: [speed buttons](https://johnmorrisdotca.github.io/kazoku-ui/speed.html), [layouts](https://johnmorrisdotca.github.io/kazoku-ui/layouts.html), [the builder](https://johnmorrisdotca.github.io/kazoku-ui/builder.html), [address state](https://johnmorrisdotca.github.io/kazoku-ui/address.html), [save image](https://johnmorrisdotca.github.io/kazoku-ui/save-image.html), [the shared renderer](https://johnmorrisdotca.github.io/kazoku-ui/renderer.html), [exact grab](https://johnmorrisdotca.github.io/kazoku-ui/grab.html) and [a strict Content-Security-Policy](https://johnmorrisdotca.github.io/kazoku-ui/csp.html).
519
536
 
520
537
  <picture>
521
538
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/kazoku-ui/main/docs/images/builder-dark.webp">
@@ -534,6 +551,7 @@ One part, one subpath; the pure code, the element and the registration are three
534
551
  | [Language picker](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/guides/language-picker.md) | Each language in its own name |
535
552
  | [Address state](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/guides/address-state.md) | Choices kept in the link |
536
553
  | [Save image](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/guides/save-image.md) | A canvas, an SVG or an element as a picture |
554
+ | [Speed buttons](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/guides/speed-buttons.md) | 0.5×, 1×, 2× or your own steps, kept in an attribute and the address |
537
555
  | [The shared renderer](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/guides/renderer.md) | One WebGL context, and the freeze checks |
538
556
  | [Frameworks and CSP](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/guides/frameworks-and-csp.md) | React, Vue, Svelte, Angular, servers, strict policies |
539
557
  | [Accessibility and labels](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/guides/accessibility-and-labels.md) | The rules and the words |
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  /** The subpaths of the parts, in the order they were added. */
2
- export declare const PARTS: readonly ["view-controls", "theme-switch", "language-picker", "address-state", "save-image", "renderer", "testing"];
2
+ export declare const PARTS: readonly ["view-controls", "theme-switch", "language-picker", "address-state", "save-image", "speed-buttons", "renderer", "testing"];
3
3
  export type Part = (typeof PARTS)[number];
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kazoku UI 家族: the family's shared interface parts, each its own subpath so a page loads only what it uses.
2
2
  /** The subpaths of the parts, in the order they were added. */
3
- export const PARTS = ["view-controls", "theme-switch", "language-picker", "address-state", "save-image", "renderer", "testing"];
3
+ export const PARTS = ["view-controls", "theme-switch", "language-picker", "address-state", "save-image", "speed-buttons", "renderer", "testing"];
@@ -0,0 +1 @@
1
+ export declare function defineSpeedButtons(registry?: CustomElementRegistry): boolean;
@@ -0,0 +1,9 @@
1
+ // Importing this registers <kazoku-speed-buttons>, unless the tag is already taken (the first definition wins; see view-controls/define.ts for why a taken tag is left alone).
2
+ import { SpeedButtonsElement, TAG } from "./element.js";
3
+ export function defineSpeedButtons(registry = customElements) {
4
+ if (registry.get(TAG) !== undefined)
5
+ return false;
6
+ registry.define(TAG, SpeedButtonsElement);
7
+ return true;
8
+ }
9
+ defineSpeedButtons();
@@ -0,0 +1,34 @@
1
+ import { EVENT, TAG } from "./index.ts";
2
+ import { type SpeedWords } from "./words.ts";
3
+ export { EVENT, TAG };
4
+ /** What the `kazoku-speedchange` event carries in `detail`. */
5
+ export interface SpeedChangeDetail {
6
+ /** The speed now: one of `steps`. */
7
+ value: number;
8
+ steps: readonly number[];
9
+ /** `init` (once, when it comes up), `user` (a button or `step()`) or `set` (the `value` property or attribute). */
10
+ reason: "init" | "user" | "set";
11
+ }
12
+ /**
13
+ * Attributes: `steps` (the speeds, `0.5 1 2` by default; any numbers, `1 10 100`, `0 1 3600 86400 -3600`), `value` (the one that is on; the start, and it follows the reader's choice),
14
+ * `unit` (the word after each number, `×` by default), `label` (the group's name, shown before the buttons and read by a screen reader; "Speed" or 速さ by default), `no-label`
15
+ * (hide the name from the eye), `address` (keep the choice in the address under `address-key`, `speed` by default; the start is left off it), `lang` (`en` or `ja`), `theme` (`light` or `dark`).
16
+ */
17
+ export declare class SpeedButtonsElement extends HTMLElement {
18
+ #private;
19
+ static observedAttributes: string[];
20
+ constructor();
21
+ /** The speed now. Setting it to a speed that is not a step takes the nearest step. */
22
+ get value(): number;
23
+ set value(next: number | string);
24
+ /** The speeds the buttons stand for. Setting a list (or a text such as `1 10 100`) draws them again, keeping the nearest to the speed now. */
25
+ get steps(): readonly number[];
26
+ set steps(list: readonly number[] | string);
27
+ /** Your own label over the language's, for example `{ group: "Time" }`. */
28
+ set words(words: Partial<SpeedWords>);
29
+ /** One press of a single button that cycles: the next speed (`direction` 1) or the one before (-1), going round at the ends. */
30
+ step(direction?: 1 | -1): void;
31
+ connectedCallback(): void;
32
+ disconnectedCallback(): void;
33
+ attributeChangedCallback(name: string, _old: string | null, value: string | null): void;
34
+ }
@@ -0,0 +1,178 @@
1
+ // <kazoku-speed-buttons>: one button for each speed (0.5×, 1×, 2× by default), no menu. It is the one speed control of the family: the view controls use it for Auto-turn,
2
+ // and a solar system, a replay bar or a race takes it with the steps it needs. The choice is the `value` (an attribute, a property and, if asked, the address) and the
3
+ // page hears every change as a `kazoku-speedchange` event. It moves nothing itself: the page applies the number.
4
+ import { applyParams, createAddressWriter, readParam } from "../address-state/address.js";
5
+ import { adoptStyles } from "../shared/adopt.js";
6
+ import { watchScheme } from "../shared/scheme.js";
7
+ import { EVENT, TAG } from "./index.js";
8
+ import { DEFAULT_STEPS, DEFAULT_UNIT, nearestStep, parseSpeed, parseSteps, SPEED_ADDRESS_KEY, speedText, startStep, stepFrom } from "./steps.js";
9
+ import { STYLES } from "./styles.js";
10
+ import { languageOf, wordsFor } from "./words.js";
11
+ export { EVENT, TAG };
12
+ /**
13
+ * Attributes: `steps` (the speeds, `0.5 1 2` by default; any numbers, `1 10 100`, `0 1 3600 86400 -3600`), `value` (the one that is on; the start, and it follows the reader's choice),
14
+ * `unit` (the word after each number, `×` by default), `label` (the group's name, shown before the buttons and read by a screen reader; "Speed" or 速さ by default), `no-label`
15
+ * (hide the name from the eye), `address` (keep the choice in the address under `address-key`, `speed` by default; the start is left off it), `lang` (`en` or `ja`), `theme` (`light` or `dark`).
16
+ */
17
+ export class SpeedButtonsElement extends HTMLElement {
18
+ static observedAttributes = ["steps", "value", "unit", "label", "lang"];
19
+ #root;
20
+ #steps = [...DEFAULT_STEPS];
21
+ #value = 1;
22
+ #start = 1;
23
+ #ready = false;
24
+ #styled = false;
25
+ #words = {};
26
+ #writer = createAddressWriter({ delay: 0 });
27
+ #stop;
28
+ #reflecting = false;
29
+ constructor() {
30
+ super();
31
+ this.#root = this.attachShadow({ mode: "open" });
32
+ }
33
+ /** The speed now. Setting it to a speed that is not a step takes the nearest step. */
34
+ get value() {
35
+ return this.#value;
36
+ }
37
+ set value(next) {
38
+ const value = typeof next === "number" ? next : Number(next);
39
+ if (Number.isFinite(value))
40
+ this.#choose(nearestStep(value, this.#steps) ?? this.#value, "set");
41
+ }
42
+ /** The speeds the buttons stand for. Setting a list (or a text such as `1 10 100`) draws them again, keeping the nearest to the speed now. */
43
+ get steps() {
44
+ return this.#steps;
45
+ }
46
+ set steps(list) {
47
+ this.#aim(typeof list === "string" ? parseSteps(list) : parseSteps(list.join(" ")));
48
+ }
49
+ /** Your own label over the language's, for example `{ group: "Time" }`. */
50
+ set words(words) {
51
+ this.#words = words;
52
+ this.#render();
53
+ }
54
+ /** One press of a single button that cycles: the next speed (`direction` 1) or the one before (-1), going round at the ends. */
55
+ step(direction = 1) {
56
+ this.#choose(stepFrom(this.#value, this.#steps, direction), "user");
57
+ }
58
+ connectedCallback() {
59
+ if (!this.#styled) {
60
+ // Inside another part's shadow root (the view controls) it carries no nonce of its own: the part it sits in has the page's.
61
+ const outer = this.getRootNode();
62
+ adoptStyles(this.#root, STYLES, this.nonce || (outer instanceof ShadowRoot ? outer.host.nonce : "") || "");
63
+ this.#styled = true;
64
+ }
65
+ this.#stop = watchScheme(this);
66
+ this.#steps = parseSteps(this.getAttribute("steps"));
67
+ this.#start = parseSpeed(this.getAttribute("value"), this.#steps) ?? startStep(this.#steps);
68
+ const kept = this.hasAttribute("address") ? parseSpeed(readParam(this.#key(), globalThis.location?.search ?? ""), this.#steps) : undefined;
69
+ this.#value = kept ?? this.#start;
70
+ this.#render();
71
+ this.#ready = true;
72
+ // Said once the page has had its turn to listen; `value` can be read at any time.
73
+ queueMicrotask(() => {
74
+ if (this.isConnected)
75
+ this.#dispatch("init");
76
+ });
77
+ }
78
+ disconnectedCallback() {
79
+ this.#stop?.();
80
+ this.#stop = undefined;
81
+ this.#writer.cancel();
82
+ this.#ready = false;
83
+ }
84
+ attributeChangedCallback(name, _old, value) {
85
+ if (!this.#ready || this.#reflecting)
86
+ return;
87
+ if (name === "steps")
88
+ this.#aim(parseSteps(value));
89
+ else if (name === "value") {
90
+ const next = parseSpeed(value, this.#steps);
91
+ if (next !== undefined)
92
+ this.#choose(next, "set");
93
+ }
94
+ else
95
+ this.#render();
96
+ }
97
+ #key() {
98
+ return this.getAttribute("address-key") || SPEED_ADDRESS_KEY;
99
+ }
100
+ #aim(steps) {
101
+ this.#steps = steps;
102
+ this.#start = nearestStep(this.#start, steps) ?? startStep(steps);
103
+ const kept = nearestStep(this.#value, steps) ?? this.#start;
104
+ if (kept !== this.#value)
105
+ this.#value = kept;
106
+ this.#render();
107
+ if (kept !== this.#value)
108
+ this.#dispatch("set");
109
+ }
110
+ #choose(next, reason) {
111
+ if (next === this.#value)
112
+ return;
113
+ this.#value = next;
114
+ this.#render();
115
+ this.#reflect();
116
+ if (this.hasAttribute("address")) {
117
+ const key = this.#key();
118
+ this.#writer.write((search) => applyParams(search, { [key]: next }, { [key]: String(this.#start) }));
119
+ }
120
+ this.#dispatch(reason);
121
+ }
122
+ /** The `value` attribute follows the reader's choice, so the page and a framework that watches attributes see it. */
123
+ #reflect() {
124
+ if (this.getAttribute("value") === String(this.#value))
125
+ return;
126
+ this.#reflecting = true;
127
+ this.setAttribute("value", String(this.#value));
128
+ this.#reflecting = false;
129
+ }
130
+ #render() {
131
+ const language = languageOf(this.getAttribute("lang") ?? this.closest("[lang]")?.getAttribute("lang") ?? this.ownerDocument.documentElement.lang);
132
+ const group = this.getAttribute("label") || wordsFor(language, this.#words).group;
133
+ const unit = this.getAttribute("unit") ?? DEFAULT_UNIT;
134
+ const doc = this.ownerDocument;
135
+ let box = this.#root.querySelector(".group");
136
+ if (box === null) {
137
+ const label = doc.createElement("span");
138
+ label.className = "lbl";
139
+ label.setAttribute("part", "label");
140
+ label.setAttribute("aria-hidden", "true");
141
+ box = doc.createElement("div");
142
+ box.className = "group";
143
+ box.setAttribute("part", "group");
144
+ box.setAttribute("role", "group");
145
+ this.#root.append(label, box);
146
+ box.addEventListener("click", (event) => {
147
+ const button = event.target.closest("button[data-v]");
148
+ if (button !== null)
149
+ this.#choose(Number(button.dataset["v"]), "user");
150
+ });
151
+ }
152
+ const label = this.#root.querySelector(".lbl");
153
+ if (label !== null && label.textContent !== group)
154
+ label.textContent = group;
155
+ box.setAttribute("aria-label", group);
156
+ const stale = [...box.querySelectorAll("button")];
157
+ const same = stale.length === this.#steps.length && stale.every((button, i) => button.dataset["v"] === String(this.#steps[i]));
158
+ if (!same) {
159
+ box.replaceChildren(...this.#steps.map((step) => {
160
+ const button = doc.createElement("button");
161
+ button.type = "button";
162
+ button.dataset["v"] = String(step);
163
+ button.setAttribute("part", "button");
164
+ return button;
165
+ }));
166
+ }
167
+ for (const button of box.querySelectorAll("button")) {
168
+ const step = Number(button.dataset["v"]);
169
+ const text = speedText(step, unit);
170
+ if (button.textContent !== text)
171
+ button.textContent = text;
172
+ button.setAttribute("aria-pressed", String(step === this.#value));
173
+ }
174
+ }
175
+ #dispatch(reason) {
176
+ this.dispatchEvent(new CustomEvent(EVENT, { detail: { value: this.#value, steps: this.#steps, reason }, bubbles: true, composed: true }));
177
+ }
178
+ }
@@ -0,0 +1,4 @@
1
+ export { DEFAULT_STEPS, DEFAULT_UNIT, nearestStep, parseSpeed, parseSteps, SPEED_ADDRESS_KEY, speedText, startStep, stepFrom } from "./steps.ts";
2
+ export { languageOf, WORDS, wordsFor, type Language, type SpeedWords } from "./words.ts";
3
+ export declare const EVENT = "kazoku-speedchange";
4
+ export declare const TAG = "kazoku-speed-buttons";
@@ -0,0 +1,5 @@
1
+ // The speed buttons without the element: the steps, how a speed reads and how one is chosen. Nothing here touches the DOM, so it imports on a server.
2
+ export { DEFAULT_STEPS, DEFAULT_UNIT, nearestStep, parseSpeed, parseSteps, SPEED_ADDRESS_KEY, speedText, startStep, stepFrom } from "./steps.js";
3
+ export { languageOf, WORDS, wordsFor } from "./words.js";
4
+ export const EVENT = "kazoku-speedchange";
5
+ export const TAG = "kazoku-speed-buttons";
@@ -0,0 +1,18 @@
1
+ /** The steps when a page names none: half speed, normal and double. */
2
+ export declare const DEFAULT_STEPS: readonly number[];
3
+ /** The word on each step's button after its number, when the page names none. */
4
+ export declare const DEFAULT_UNIT = "\u00D7";
5
+ /** The address key the buttons keep their choice under, unless `address-key` names another. */
6
+ export declare const SPEED_ADDRESS_KEY = "speed";
7
+ /** The steps in a list such as `0.5 1 2` or `1, 10, 100`: finite numbers in the order given, each once. `fallback` when the list holds none. A step may be 0 (Paused) or negative (Back). */
8
+ export declare function parseSteps(text: string | null | undefined, fallback?: readonly number[]): number[];
9
+ /** The number as a button shows it: `0.5×`, `1×`, `100×`, `−1×` (a real minus sign). At most four significant figures, so 0.333333 reads `0.333×`. */
10
+ export declare function speedText(value: number, unit?: string): string;
11
+ /** The step nearest `value`: by ratio where both are above zero (so 2.5 is nearer 2 than 4), else by plain distance. `undefined` when there are no steps. */
12
+ export declare function nearestStep(value: number, steps: readonly number[]): number | undefined;
13
+ /** The step the buttons start on: 1 where the steps have it (normal speed), else the first. */
14
+ export declare function startStep(steps: readonly number[]): number;
15
+ /** The step after (`direction` 1) or before (-1) `value`, going round at the ends: one press of a single button that cycles through the speeds. */
16
+ export declare function stepFrom(value: number, steps: readonly number[], direction?: 1 | -1): number;
17
+ /** What an address or attribute says: a number that is (or is nearest to) one of the steps. `undefined` when it is not a number at all. The words `slow`, `medium` and `fast` of the first view controls mean 0.5, 1 and 2. */
18
+ export declare function parseSpeed(text: string | null | undefined, steps: readonly number[]): number | undefined;
@@ -0,0 +1,57 @@
1
+ // The speed buttons' arithmetic: pure, so a server or a test can use it. A speed is a plain number (a multiple of the normal pace, or a rate such as days a second);
2
+ // the buttons are the steps the page chose, and one of them is on.
3
+ /** The steps when a page names none: half speed, normal and double. */
4
+ export const DEFAULT_STEPS = [0.5, 1, 2];
5
+ /** The word on each step's button after its number, when the page names none. */
6
+ export const DEFAULT_UNIT = "×";
7
+ /** The address key the buttons keep their choice under, unless `address-key` names another. */
8
+ export const SPEED_ADDRESS_KEY = "speed";
9
+ const number = (text) => (text.trim() === "" ? Number.NaN : Number(text));
10
+ /** The steps in a list such as `0.5 1 2` or `1, 10, 100`: finite numbers in the order given, each once. `fallback` when the list holds none. A step may be 0 (Paused) or negative (Back). */
11
+ export function parseSteps(text, fallback = DEFAULT_STEPS) {
12
+ const found = [];
13
+ for (const part of (text ?? "").split(/[\s,]+/)) {
14
+ const value = number(part);
15
+ if (Number.isFinite(value) && !found.includes(value))
16
+ found.push(value);
17
+ }
18
+ return found.length > 0 ? found : [...fallback];
19
+ }
20
+ /** The number as a button shows it: `0.5×`, `1×`, `100×`, `−1×` (a real minus sign). At most four significant figures, so 0.333333 reads `0.333×`. */
21
+ export function speedText(value, unit = DEFAULT_UNIT) {
22
+ const text = String(Number(value.toPrecision(4)));
23
+ return `${text.startsWith("-") ? `−${text.slice(1)}` : text}${unit}`;
24
+ }
25
+ /** The step nearest `value`: by ratio where both are above zero (so 2.5 is nearer 2 than 4), else by plain distance. `undefined` when there are no steps. */
26
+ export function nearestStep(value, steps) {
27
+ let best;
28
+ let gap = Number.POSITIVE_INFINITY;
29
+ for (const step of steps) {
30
+ const far = value > 0 && step > 0 ? Math.abs(Math.log(value / step)) : Math.abs(value - step);
31
+ if (far < gap) {
32
+ gap = far;
33
+ best = step;
34
+ }
35
+ }
36
+ return best;
37
+ }
38
+ /** The step the buttons start on: 1 where the steps have it (normal speed), else the first. */
39
+ export function startStep(steps) {
40
+ return steps.includes(1) ? 1 : (steps[0] ?? 1);
41
+ }
42
+ /** The step after (`direction` 1) or before (-1) `value`, going round at the ends: one press of a single button that cycles through the speeds. */
43
+ export function stepFrom(value, steps, direction = 1) {
44
+ if (steps.length === 0)
45
+ return value;
46
+ const at = steps.findIndex((step) => step === value);
47
+ const from = at >= 0 ? at : steps.indexOf(nearestStep(value, steps) ?? value);
48
+ return steps[(Math.max(from, 0) + direction + steps.length) % steps.length];
49
+ }
50
+ /** What an address or attribute says: a number that is (or is nearest to) one of the steps. `undefined` when it is not a number at all. The words `slow`, `medium` and `fast` of the first view controls mean 0.5, 1 and 2. */
51
+ export function parseSpeed(text, steps) {
52
+ if (text === null || text === undefined)
53
+ return undefined;
54
+ const word = { slow: 0.5, medium: 1, fast: 2 }[text.trim().toLowerCase()];
55
+ const value = word ?? number(text);
56
+ return Number.isFinite(value) ? nearestStep(value, steps) : undefined;
57
+ }
@@ -0,0 +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: inline-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\"]):not([data-scheme=\"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\"]), :host([data-scheme=\"dark\"]: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 color-scheme: dark;\n}\n:host([theme=\"light\"]) { color-scheme: light; }\n[hidden] { display: none !important; }\n.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n.group { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 4px; }\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:disabled { opacity: 0.4; cursor: default; }\nsvg { width: 18px; height: 18px; fill: currentColor; flex: none; }\n\n:host([hidden]) { display: none; }\n:host { display: inline-flex; align-items: center; gap: 4px; vertical-align: middle; }\n.lbl { margin-inline-end: 4px; }\nbutton { padding: 0 12px; min-width: 48px; font-variant-numeric: tabular-nums; }\n:host([no-label]) .lbl { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n";
@@ -0,0 +1,9 @@
1
+ import { BASE_STYLES } from "../shared/base.js";
2
+ // One button for each speed, side by side; the one that is on is filled. The name comes first and is hidden (for the eye) by `::part(label)` where a page is short of room.
3
+ export const STYLES = `${BASE_STYLES}
4
+ :host([hidden]) { display: none; }
5
+ :host { display: inline-flex; align-items: center; gap: 4px; vertical-align: middle; }
6
+ .lbl { margin-inline-end: 4px; }
7
+ button { padding: 0 12px; min-width: 48px; font-variant-numeric: tabular-nums; }
8
+ :host([no-label]) .lbl { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
9
+ `;
@@ -0,0 +1,9 @@
1
+ export interface SpeedWords {
2
+ /** The name of the group of buttons, said by a screen reader and shown before them. */
3
+ group: string;
4
+ }
5
+ export type Language = "en" | "ja";
6
+ export declare const WORDS: Record<Language, SpeedWords>;
7
+ /** The language to use for a `lang` value such as "ja", "ja-JP" or "en-CA": Japanese for ja, English for anything else. */
8
+ export declare const languageOf: (lang: string | null | undefined) => Language;
9
+ export declare const wordsFor: (language: Language, overrides?: Partial<SpeedWords>) => SpeedWords;
@@ -0,0 +1,7 @@
1
+ export const WORDS = {
2
+ en: { group: "Speed" },
3
+ ja: { group: "速さ" },
4
+ };
5
+ /** The language to use for a `lang` value such as "ja", "ja-JP" or "en-CA": Japanese for ja, English for anything else. */
6
+ export const languageOf = (lang) => (/^ja\b/i.test(lang ?? "") ? "ja" : "en");
7
+ export const wordsFor = (language, overrides = {}) => ({ ...WORDS[language], ...overrides });
@@ -1,4 +1,3 @@
1
- import { type Pace } from "./levels.ts";
2
1
  import { type Limits } from "./math.ts";
3
2
  export type DragMode = "move" | "turn";
4
3
  export interface AddressKeys {
@@ -6,7 +5,7 @@ export interface AddressKeys {
6
5
  tilt: string;
7
6
  zoom: string;
8
7
  drag: string;
9
- /** The auto-turn speed (`slow` or `fast`; Medium is the start and leaves the address). */
8
+ /** The auto-turn speed, a number (`0.5`, `2`; 1 is the start and leaves the address). `pace` by default, because a page's own `speed` (a clock's, a replay's) is its own. */
10
9
  speed?: string;
11
10
  }
12
11
  export declare const DEFAULT_KEYS: AddressKeys;
@@ -15,7 +14,8 @@ export interface AddressChoices {
15
14
  tilt: number;
16
15
  zoom: number;
17
16
  drag: DragMode;
18
- pace?: Pace;
17
+ /** The auto-turn speed as a multiple of the element's `speed`: the number of the button that is on. */
18
+ pace?: number;
19
19
  }
20
20
  /** Read the choices out of a query string (`?yaw=40`, with or without the `?`). Anything missing or not a number is left out of the result. */
21
21
  export declare function parseAddress(search: string, keys?: AddressKeys, limits?: Limits): Partial<AddressChoices>;
@@ -1,8 +1,8 @@
1
1
  // The choices kept in the page's address: pure string functions, so a server or a test can use them. Only what a person chose is kept
2
2
  // (angle, tilt, zoom, what a drag does); the pan is in pixels of one screen and the turning of a moment, so neither is.
3
- import { parsePace } from "./levels.js";
3
+ import { PACE_FACTOR, parsePace } from "./levels.js";
4
4
  import { clamp, DEFAULT_LIMITS, wrapYaw } from "./math.js";
5
- export const DEFAULT_KEYS = { yaw: "yaw", tilt: "tilt", zoom: "zoom", drag: "drag", speed: "speed" };
5
+ export const DEFAULT_KEYS = { yaw: "yaw", tilt: "tilt", zoom: "zoom", drag: "drag", speed: "pace" };
6
6
  const rounded = (value, places) => Number(value.toFixed(places));
7
7
  /** Read the choices out of a query string (`?yaw=40`, with or without the `?`). Anything missing or not a number is left out of the result. */
8
8
  export function parseAddress(search, keys = DEFAULT_KEYS, limits = DEFAULT_LIMITS) {
@@ -27,9 +27,12 @@ export function parseAddress(search, keys = DEFAULT_KEYS, limits = DEFAULT_LIMIT
27
27
  const drag = params.get(keys.drag);
28
28
  if (drag === "turn" || drag === "move")
29
29
  out.drag = drag;
30
- const pace = parsePace(params.get(keys.speed ?? "speed"));
31
- if (pace !== undefined)
32
- out.pace = pace;
30
+ const said = params.get(keys.speed ?? "pace");
31
+ // A number is the pace; so are the words of 0.12 and 0.13 (`slow`, `fast`), which a link made then still carries.
32
+ const named = parsePace(said);
33
+ const value = named !== undefined ? PACE_FACTOR[named] : said === null || said.trim() === "" ? Number.NaN : Number(said);
34
+ if (Number.isFinite(value))
35
+ out.pace = value;
33
36
  return out;
34
37
  }
35
38
  /**
@@ -49,10 +52,10 @@ export function formatAddress(search, choices, keys = DEFAULT_KEYS, leaveYaw = f
49
52
  set(keys.tilt, Math.round(choices.tilt) === 0 ? undefined : String(rounded(choices.tilt, 0)));
50
53
  set(keys.zoom, Math.abs(choices.zoom - startZoom) < 0.005 ? undefined : String(rounded(choices.zoom, 2)));
51
54
  set(keys.drag, choices.drag === "turn" ? "turn" : undefined);
52
- // The speed key is touched only when the caller has a pace, and a value that is not a pace word (a page's own `speed`) is left alone.
53
- const speedKey = keys.speed ?? "speed";
54
- if (choices.pace !== undefined && (choices.pace !== "medium" || parsePace(params.get(speedKey)) !== undefined))
55
- set(speedKey, choices.pace === "medium" ? undefined : choices.pace);
55
+ // The pace key is touched only when the caller has a pace; the start (1) leaves it off the address.
56
+ const paceKey = keys.speed ?? "pace";
57
+ if (choices.pace !== undefined)
58
+ set(paceKey, choices.pace === 1 ? undefined : String(rounded(choices.pace, 3)));
56
59
  const text = params.toString();
57
60
  return text === "" ? "" : `?${text}`;
58
61
  }
@@ -1,6 +1,7 @@
1
1
  import { type ViewController, type ViewState } from "./controller.ts";
2
2
  import { EVENT, TAG } from "./index.ts";
3
3
  import { type Point } from "./math.ts";
4
+ import "../speed-buttons/define.ts";
4
5
  import { type ViewWords } from "./words.ts";
5
6
  export { EVENT, TAG };
6
7
  /** What the `kazoku-viewchange` event carries in `detail`. */
@@ -11,7 +12,7 @@ export interface ViewChangeDetail extends ViewState {
11
12
  /**
12
13
  * Attributes: `for` (the id of the view to bind), `lang` (en or ja; else the page's), `yaw`, `tilt` (the starting view), `zoom` (`fit`, the default, or a scale where 1
13
14
  * 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
- * 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
+ * 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 at a pace of 1), `pace` (the auto-turn speed as a multiple of `speed`, 1 by default; the reader's choice of the buttons is written here and kept in the address as `pace`), `paces` (the speeds the buttons stand for, `0.5 1 2` by default),
15
16
  * `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; the wheel itself zooms),
16
17
  * `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
18
  * `layout` (`classic`, the default, `horizontal`, `vertical` or `compact`: where the controls sit; the horizontal, vertical and compact layouts have the zoom slider),
@@ -6,9 +6,12 @@ import { localStore, remember } from "../shared/store.js";
6
6
  import { DEFAULT_KEYS, formatAddress, parseAddress, parseKeys } from "./address.js";
7
7
  import { createViewController, DEFAULT_IGNORE } from "./controller.js";
8
8
  import { EVENT, TAG } from "./index.js";
9
- import { LEVEL_STORAGE_KEY, PACE_FACTOR, PACES, parseLayout, parseLevel, zoomText } from "./levels.js";
9
+ import { LEVEL_STORAGE_KEY, parseLayout, parseLevel, zoomText } from "./levels.js";
10
10
  import { ZOOM_STEP, ZOOMED } from "./math.js";
11
11
  import { turnOnlyInSight } from "./sight.js";
12
+ import "../speed-buttons/define.js";
13
+ import { EVENT as SPEED_EVENT } from "../speed-buttons/index.js";
14
+ import { nearestStep, parseSpeed, parseSteps, speedText } from "../speed-buttons/steps.js";
12
15
  import { STYLES } from "./styles.js";
13
16
  import { languageOf, wordsFor } from "./words.js";
14
17
  import { SNAP_PX, zoomAtPosition, zoomMarks, zoomPosition, zoomSnap, zoomStep, zoomToMark } from "./zoomScale.js";
@@ -35,11 +38,7 @@ const HTML = `
35
38
  <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>
36
39
  <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>
37
40
  <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>
38
- <div class="group pace adv" role="group" data-g="pace"><span class="lbl" data-w="speed" aria-hidden="true"></span>
39
- <button type="button" data-b="slow" aria-pressed="false" part="button"><span data-w="slow"></span></button>
40
- <button type="button" data-b="medium" aria-pressed="true" part="button"><span data-w="medium"></span></button>
41
- <button type="button" data-b="fast" aria-pressed="false" part="button"><span data-w="fast"></span></button>
42
- </div>
41
+ <kazoku-speed-buttons class="pace adv" data-g="pace"></kazoku-speed-buttons>
43
42
  <button type="button" class="front adv" data-b="front" part="button"><span data-w="front"></span></button>
44
43
  <button type="button" class="centre" data-b="centre" part="button" hidden>${ICON.centre}<span class="text" data-w="centre"></span></button>
45
44
  <div class="group zoom" role="group" data-g="zoom">
@@ -65,7 +64,7 @@ const HTML = `
65
64
  /**
66
65
  * Attributes: `for` (the id of the view to bind), `lang` (en or ja; else the page's), `yaw`, `tilt` (the starting view), `zoom` (`fit`, the default, or a scale where 1
67
66
  * 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;
68
- * 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),
67
+ * 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 at a pace of 1), `pace` (the auto-turn speed as a multiple of `speed`, 1 by default; the reader's choice of the buttons is written here and kept in the address as `pace`), `paces` (the speeds the buttons stand for, `0.5 1 2` by default),
69
68
  * `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; the wheel itself zooms),
70
69
  * `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),
71
70
  * `layout` (`classic`, the default, `horizontal`, `vertical` or `compact`: where the controls sit; the horizontal, vertical and compact layouts have the zoom slider),
@@ -73,7 +72,7 @@ const HTML = `
73
72
  * or dark), `overlay` (sit over the bottom of a positioned parent).
74
73
  */
75
74
  export class ViewControlsElement extends HTMLElement {
76
- static observedAttributes = ["for", "lang", "yaw", "tilt", "zoom", "fit-scale", "ignore", "anchor", "key-tilt", "stop-turn", "tilt-step", "grab-radius", "object-size", "speed", "controls", "layout", "flat", "tilt-max", "zoom-min", "zoom-max", "address-keys"];
75
+ static observedAttributes = ["for", "lang", "yaw", "tilt", "zoom", "fit-scale", "ignore", "anchor", "key-tilt", "stop-turn", "tilt-step", "grab-radius", "object-size", "speed", "pace", "paces", "controls", "layout", "flat", "tilt-max", "zoom-min", "zoom-max", "address-keys"];
77
76
  #root;
78
77
  #controller;
79
78
  #view;
@@ -91,7 +90,7 @@ export class ViewControlsElement extends HTMLElement {
91
90
  #marksFor = "";
92
91
  /** The zoom the slider shows now; NaN until it has shown one, and again when the marks are redrawn for new limits. */
93
92
  #zoomShown = Number.NaN;
94
- #pace = "medium";
93
+ #pace = 1;
95
94
  #labels = wordsFor("en");
96
95
  #said = "";
97
96
  #sayTimer;
@@ -172,7 +171,7 @@ export class ViewControlsElement extends HTMLElement {
172
171
  Object.assign(start, chosen);
173
172
  c.turnable = !this.hasAttribute("flat");
174
173
  this.#level = parseLevel(this.#remembered()) ?? parseLevel(this.getAttribute("controls")) ?? "basic";
175
- this.#pace = pace ?? "medium";
174
+ this.#pace = this.#nearestPace(pace ?? parseSpeed(this.getAttribute("pace"), this.#paces()) ?? 1);
176
175
  this.#speed();
177
176
  c.ignore = this.getAttribute("ignore") ?? DEFAULT_IGNORE;
178
177
  for (const name of ["key-tilt", "stop-turn", "tilt-step", "grab-radius", "object-size"])
@@ -238,6 +237,11 @@ export class ViewControlsElement extends HTMLElement {
238
237
  this.#option(name);
239
238
  else if (name === "speed")
240
239
  this.#speed();
240
+ else if (name === "pace" || name === "paces") {
241
+ this.#pace = this.#nearestPace(parseSpeed(this.getAttribute("pace"), this.#paces()) ?? this.#pace);
242
+ this.#speed();
243
+ this.#show();
244
+ }
241
245
  else if (name === "layout") {
242
246
  this.#arrange();
243
247
  this.#show();
@@ -343,16 +347,16 @@ export class ViewControlsElement extends HTMLElement {
343
347
  remember(this.#storageKey(), this.#level);
344
348
  this.#show();
345
349
  });
346
- const choosePace = (pace) => {
347
- this.#pace = pace;
348
- this.#speed();
349
- this.#show();
350
- if (this.hasAttribute("address"))
351
- this.#save();
352
- };
353
- for (const pace of PACES)
354
- on(pace, () => choosePace(pace));
355
- on("pace-now", () => choosePace(PACES[(PACES.indexOf(this.#pace) + 1) % PACES.length]));
350
+ // The speed buttons are the family's own element; its choice is the pace, kept in the attribute and the address.
351
+ const speeds = q("kazoku-speed-buttons");
352
+ speeds.addEventListener(SPEED_EVENT, (event) => {
353
+ // The element's own, not one for the page to hear: the page hears `kazoku-viewchange`.
354
+ event.stopPropagation();
355
+ const { value, reason } = event.detail;
356
+ if (reason === "user")
357
+ this.#choosePace(value);
358
+ });
359
+ on("pace-now", () => speeds.step(1));
356
360
  on("left", () => c().panBy(-1, 0));
357
361
  on("right", () => c().panBy(1, 0));
358
362
  on("up", () => c().panBy(0, -1));
@@ -441,9 +445,26 @@ export class ViewControlsElement extends HTMLElement {
441
445
  return null;
442
446
  }
443
447
  }
448
+ /** The speeds the buttons stand for: the `paces` attribute, `0.5 1 2` by default. */
449
+ #paces() {
450
+ return parseSteps(this.getAttribute("paces"));
451
+ }
452
+ #nearestPace(value) {
453
+ return nearestStep(value, this.#paces()) ?? 1;
454
+ }
455
+ /** A button was pressed: the pace is the number on it. It is written to the `pace` attribute and the address. */
456
+ #choosePace(pace) {
457
+ this.#pace = pace;
458
+ this.#speed();
459
+ if (this.getAttribute("pace") !== String(pace))
460
+ this.setAttribute("pace", String(pace));
461
+ this.#show();
462
+ if (this.hasAttribute("address"))
463
+ this.#save();
464
+ }
444
465
  /** The turn's degrees a second: the `speed` attribute (30) times the pace. */
445
466
  #speed() {
446
- this.#ensure().speed = (Number(this.getAttribute("speed")) || 30) * PACE_FACTOR[this.#pace];
467
+ this.#ensure().speed = (Number(this.getAttribute("speed")) || 30) * this.#pace;
447
468
  }
448
469
  #limits() {
449
470
  const c = this.#ensure();
@@ -502,7 +523,9 @@ export class ViewControlsElement extends HTMLElement {
502
523
  set('[data-g="zoom"]', words.zoom);
503
524
  set('[data-g="pan"]', words.pan);
504
525
  set('[data-g="drag"]', words.drag);
505
- set('[data-g="pace"]', words.speed);
526
+ const speeds = this.#root.querySelector("kazoku-speed-buttons");
527
+ speeds?.setAttribute("lang", language);
528
+ speeds?.setAttribute("label", words.speed);
506
529
  set('[data-b="left"]', words.panLeft);
507
530
  set('[data-b="right"]', words.panRight);
508
531
  set('[data-b="up"]', words.panUp);
@@ -553,10 +576,16 @@ export class ViewControlsElement extends HTMLElement {
553
576
  }
554
577
  say(q('[data-b="level"]'), `${words.zoomLevel} ${zoom}, ${words.zoomReset}`);
555
578
  this.#sayZoom(`${words.zoomLevel} ${zoom}`);
556
- for (const pace of PACES)
557
- q(`[data-b="${pace}"]`).setAttribute("aria-pressed", String(this.#pace === pace));
558
- write(q('[data-o="pace"]'), words[this.#pace]);
559
- say(q('[data-b="pace-now"]'), `${words.speed}: ${words[this.#pace]}`);
579
+ const speeds = q("kazoku-speed-buttons");
580
+ const paces = this.#paces();
581
+ if (speeds.getAttribute("steps") !== paces.join(" "))
582
+ speeds.setAttribute("steps", paces.join(" "));
583
+ this.#pace = this.#nearestPace(this.#pace);
584
+ if (speeds.value !== this.#pace)
585
+ speeds.value = this.#pace;
586
+ const pace = speedText(this.#pace);
587
+ write(q('[data-o="pace"]'), pace);
588
+ say(q('[data-b="pace-now"]'), `${words.speed}: ${pace}`);
560
589
  const more = q('[data-b="more"]');
561
590
  more.setAttribute("aria-expanded", String(advanced));
562
591
  this.dataset.level = this.#level;
@@ -3,7 +3,7 @@ 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. */
5
5
  export declare const LEVEL_STORAGE_KEY = "kazoku-view-controls";
6
- /** Auto-turn speed: Medium is the element's `speed`, Slow half of it and Fast twice it. */
6
+ /** The three speeds of 0.12 and 0.13 by name: Medium is the element's `speed`, Slow half of it and Fast twice it. A pace is now any number (the `pace` attribute, the `paces` it chooses from); these words still read as 0.5, 1 and 2. */
7
7
  export type Pace = "slow" | "medium" | "fast";
8
8
  export declare const PACES: readonly Pace[];
9
9
  export declare const PACE_FACTOR: Record<Pace, number>;
@@ -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.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/* The classic 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 :host([data-layout=\"classic\"]) .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 :host([data-layout=\"classic\"]) .field.angle { grid-area: angle; }\n :host([data-layout=\"classic\"]) .field.tilt { grid-area: tilt; }\n :host([data-layout=\"classic\"]) .field label, :host([data-layout=\"classic\"]) .field output, :host([data-layout=\"classic\"]) .play .text, :host([data-layout=\"classic\"]) .more .text, :host([data-layout=\"classic\"]) .pace .lbl { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }\n :host([data-layout=\"classic\"]) .play, :host([data-layout=\"classic\"]) .more { padding: 0; }\n :host([data-layout=\"classic\"]) .play { grid-area: play; }\n :host([data-layout=\"classic\"]) .more { grid-area: more; }\n :host([data-layout=\"classic\"]) .front { grid-area: front; padding: 0 12px; }\n :host([data-layout=\"classic\"]) .pace { grid-area: pace; justify-self: end; flex-wrap: nowrap; }\n :host([data-layout=\"classic\"]) .pace button { padding: 0 12px; }\n :host([data-layout=\"classic\"]) .zoom { grid-area: zoom; justify-self: end; flex-wrap: nowrap; }\n :host([data-layout=\"classic\"]) .zoom button { padding: 0 8px; }\n :host([data-layout=\"classic\"]) .zoom button.level { padding: 0 6px; }\n /* In the free room left of the zoom buttons, so showing it adds no row. */\n :host([data-layout=\"classic\"]) .pace-now { grid-area: zoom; justify-self: start; }\n :host([data-layout=\"classic\"]) .pan { grid-area: pan; justify-content: center; }\n :host([data-layout=\"classic\"]) .drag { margin-inline-start: 4px; }\n}\n\n/* The zoom slider (horizontal, vertical and compact): a logarithmic track with ticks at its marks, the labels under it, and the part from the least zoom to the zoom now filled. */\n.zslide { display: none; }\n:host([data-layout=\"horizontal\"]) .zslide, :host([data-layout=\"vertical\"]) .zslide, :host([data-layout=\"compact\"]) .zslide { display: flex; }\n.zbox { position: relative; flex: 1 1 auto; min-width: 96px; height: 52px; }\n.zbox input { position: absolute; inset: 0 0 auto; z-index: 1; min-width: 0; }\n.zbox input::-webkit-slider-runnable-track { background: transparent; }\n.zbox input::-moz-range-track { background: transparent; }\n.ztrack { position: absolute; inset-inline: 14px; top: 21px; height: 6px; border-radius: 3px; background: var(--_line); }\n.zfill { position: absolute; inset-block: 0; left: 0; width: calc(var(--pos, 0) * 100%); border-radius: 3px; background: var(--_accent); }\n.ztick { position: absolute; top: 50%; left: calc(var(--at) * 100%); width: 2px; height: 14px; border-radius: 1px; background: var(--_ink); opacity: 0.5; transform: translate(-50%, -50%); }\n.zmarks { position: absolute; inset-inline: 14px; top: 36px; height: 16px; pointer-events: none; }\n.zmark { position: absolute; left: calc(var(--at) * 100%); transform: translateX(-50%); font-size: 0.72rem; font-weight: 500; line-height: 16px; opacity: 0.85; white-space: nowrap; font-variant-numeric: tabular-nums; }\n.zmark[data-end=\"start\"] { transform: translateX(-35%); }\n.zmark[data-end=\"end\"] { transform: translateX(-65%); }\n\n/* Horizontal: a tidy bar under the view. Two rows (the turn, then the zoom with its slider); one row when its own width allows. More adds its rows between them. */\n:host([data-layout=\"horizontal\"]) { container-type: inline-size; }\n:host([data-layout=\"horizontal\"]) .bar, :host([data-layout=\"vertical\"]) .bar, :host([data-layout=\"compact\"]) .bar { display: grid; row-gap: 0; align-items: center; }\n:host([data-layout=\"horizontal\"]) .bar > *, :host([data-layout=\"vertical\"]) .bar > *, :host([data-layout=\"compact\"]) .zoom > * { margin-block: 2px; }\n:host([data-layout=\"horizontal\"]) .bar { grid-template-columns: minmax(0, 1fr) auto auto auto; grid-template-areas: \"angle speed play more\" \"tilt tilt front front\" \"pace pace pace pace\" \"zoom zoom zoom centre\" \"pan pan pan pan\"; column-gap: 8px; padding: 4px 8px; }\n:host([data-layout=\"horizontal\"][data-flat]) .bar { grid-template-columns: minmax(0, 1fr) auto auto; grid-template-areas: \"zoom centre more\" \"pan pan pan\"; }\n:host([data-layout=\"horizontal\"]) .angle, :host([data-layout=\"vertical\"]) .angle, :host([data-layout=\"compact\"]) .angle { grid-area: angle; }\n:host([data-layout=\"horizontal\"]) .tilt, :host([data-layout=\"vertical\"]) .tilt, :host([data-layout=\"compact\"]) .tilt { grid-area: tilt; }\n:host([data-layout=\"horizontal\"]) .pace-now, :host([data-layout=\"vertical\"]) .pace-now { grid-area: speed; }\n:host([data-layout=\"horizontal\"]) .play, :host([data-layout=\"vertical\"]) .play, :host([data-layout=\"compact\"]) .play { grid-area: play; }\n:host([data-layout=\"horizontal\"]) .pace, :host([data-layout=\"vertical\"]) .pace, :host([data-layout=\"compact\"]) .pace { grid-area: pace; flex-wrap: nowrap; }\n:host([data-layout=\"horizontal\"]) .front, :host([data-layout=\"vertical\"]) .front, :host([data-layout=\"compact\"]) .front { grid-area: front; justify-self: start; }\n:host([data-layout=\"horizontal\"]) .zoom, :host([data-layout=\"vertical\"]) .zoom { grid-area: zoom; }\n:host([data-layout=\"horizontal\"]) .pan, :host([data-layout=\"vertical\"]) .pan, :host([data-layout=\"compact\"]) .pan { grid-area: pan; justify-self: start; }\n:host([data-layout=\"horizontal\"]) .more, :host([data-layout=\"vertical\"]) .more, :host([data-layout=\"compact\"]) .more { grid-area: more; margin-inline-start: 0; }\n/* Centre, shown once the view is zoomed or moved: beside the zoom, never in the turn's row, so Auto-turn and More stay where they are. */\n:host([data-layout=\"horizontal\"]) .centre, :host([data-layout=\"vertical\"]) .centre, :host([data-layout=\"compact\"]) .centre { grid-area: centre; justify-self: end; }\n/* In the two rows it shares More's column, so it is an icon (its name is read) and never widens that column, which would move Auto-turn. */\n@container (max-width: 899px) {\n :host([data-layout=\"horizontal\"]) .centre .text { position: absolute; width: 1px; min-width: 0; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n :host([data-layout=\"horizontal\"]) .centre { padding: 0; }\n}\n:host([data-layout=\"horizontal\"]) .zoom { display: flex; flex-wrap: nowrap; gap: 4px; min-width: 0; }\n:host([data-layout=\"horizontal\"]) .zslide { flex: 1 1 auto; min-width: 0; }\n:host([data-layout=\"horizontal\"]) .zoom button, :host([data-layout=\"vertical\"]) .zoom button, :host([data-layout=\"compact\"]) .zoom button { padding: 0 10px; }\n:host([data-layout=\"horizontal\"]) .zoom button.level, :host([data-layout=\"vertical\"]) .zoom button.level, :host([data-layout=\"compact\"]) .zoom button.level { padding: 0 6px; }\n@container (max-width: 559px) {\n :host([data-layout=\"horizontal\"]) .field label, :host([data-layout=\"horizontal\"]) .field output, :host([data-layout=\"horizontal\"]) .play .text, :host([data-layout=\"horizontal\"]) .more .text, :host([data-layout=\"horizontal\"]) .pace .lbl { position: absolute; width: 1px; min-width: 0; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n :host([data-layout=\"horizontal\"]) .play, :host([data-layout=\"horizontal\"]) .more { padding: 0; }\n :host([data-layout=\"horizontal\"]) .zbox { min-width: 72px; }\n /* The readout is Fit here (its name says so), which leaves the slider the room for its marks. */\n :host([data-layout=\"horizontal\"]) [data-b=\"fit\"] { display: none; }\n}\n@container (min-width: 900px) {\n :host([data-layout=\"horizontal\"]) .bar { grid-template-columns: minmax(0, 1fr) auto auto auto auto auto; grid-template-areas: \"angle speed play zoom centre more\" \"tilt tilt tilt pace front front\" \"pan pan pan pan pan pan\"; column-gap: 12px; }\n :host([data-layout=\"horizontal\"][data-flat]) .bar { grid-template-columns: minmax(0, 1fr) auto auto; grid-template-areas: \"zoom centre more\" \"pan pan pan\"; }\n :host([data-layout=\"horizontal\"]) .zslide { flex: 0 0 15rem; }\n :host([data-layout=\"horizontal\"]) .pan { justify-self: end; }\n}\n\n/* Vertical: a column beside the view, its width --kazoku-column-width (16rem). Auto-turn and More at the top, so More stays put when it opens; then the turn, the zoom and its slider. */\n:host([data-layout=\"vertical\"]) { width: var(--kazoku-column-width, 16rem); max-width: 100%; }\n:host([data-layout=\"vertical\"]) .bar { grid-template-columns: auto auto minmax(0, 1fr) auto; grid-template-areas: \"play speed . more\" \"angle angle angle angle\" \"tilt tilt tilt tilt\" \"pace pace pace pace\" \"front front front front\" \"zoom zoom zoom zoom\" \"centre centre centre centre\" \"pan pan pan pan\"; column-gap: 8px; padding: 6px 8px; }\n:host([data-layout=\"vertical\"]) .field { flex: none; display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: \"label value\" \"slider slider\"; gap: 0 8px; }\n:host([data-layout=\"vertical\"]) .field label { grid-area: label; padding-top: 4px; }\n:host([data-layout=\"vertical\"]) .field output { grid-area: value; padding-top: 4px; }\n:host([data-layout=\"vertical\"]) .field input, :host([data-layout=\"vertical\"]) .zbox { grid-area: slider; }\n:host([data-layout=\"vertical\"]) .zoom { display: grid; grid-template-columns: auto auto auto minmax(0, 1fr); grid-template-areas: \"zs zs zs zs\" \"out level in fit\"; gap: 0 4px; }\n:host([data-layout=\"vertical\"]) .zslide { grid-area: zs; }\n:host([data-layout=\"vertical\"]) [data-b=\"out\"] { grid-area: out; }\n:host([data-layout=\"vertical\"]) [data-b=\"level\"] { grid-area: level; }\n:host([data-layout=\"vertical\"]) [data-b=\"in\"] { grid-area: in; }\n:host([data-layout=\"vertical\"]) [data-b=\"fit\"] { grid-area: fit; justify-self: start; }\n:host([data-layout=\"vertical\"]) .pace .lbl { position: absolute; width: 1px; min-width: 0; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n:host([data-layout=\"vertical\"]) .pace button { padding: 0 10px; }\n:host([data-layout=\"vertical\"]) .pan { flex-wrap: wrap; }\n:host([data-layout=\"vertical\"]) .drag { margin-inline-start: 0; }\n\n/* Compact: one short row (Auto-turn, zoom out, the zoom now, zoom in, More) for a card or a phone. More opens the rest under it: the zoom slider and Fit, the angle, and the advanced controls. */\n:host([data-layout=\"compact\"]) { display: block; }\n:host([data-layout=\"compact\"][overlay]) { inset-inline: 8px auto; bottom: 8px; }\n/* Over a card, an open More never reaches past the card's top: the controls scroll inside themselves. */\n:host([data-layout=\"compact\"][overlay]) { display: flex; flex-direction: column; max-height: calc(100% - 16px); }\n:host([data-layout=\"compact\"][overlay]) .bar { min-height: 0; overflow-y: auto; }\n:host([data-layout=\"compact\"]) .bar { width: fit-content; max-width: 100%; grid-template-columns: repeat(5, auto); grid-template-areas: \"play out level in more\" \"zs zs zs zs fit\" \"centre centre centre centre centre\" \"angle angle angle angle angle\" \"tilt tilt tilt tilt tilt\" \"pace pace pace pace pace\" \"front front front front front\" \"pan pan pan pan pan\"; column-gap: 2px; padding: 2px 4px; border: 1px solid var(--_line); border-radius: 28px; background: var(--_surface); }\n:host([data-layout=\"compact\"][data-level=\"advanced\"]) .bar { border-radius: 20px; padding: 2px 8px 6px; column-gap: 4px; }\n:host([data-layout=\"compact\"][data-flat]) .bar { grid-template-areas: \"out level in more .\" \"zs zs zs fit fit\" \"centre centre centre centre centre\" \"pan pan pan pan pan\"; }\n:host([data-layout=\"compact\"]) .zoom { display: contents; }\n:host([data-layout=\"compact\"]) .zslide { grid-area: zs; }\n:host([data-layout=\"compact\"]) [data-b=\"out\"] { grid-area: out; }\n:host([data-layout=\"compact\"]) [data-b=\"level\"] { grid-area: level; }\n:host([data-layout=\"compact\"]) [data-b=\"in\"] { grid-area: in; }\n:host([data-layout=\"compact\"]) [data-b=\"fit\"] { grid-area: fit; }\n:host([data-layout=\"compact\"]) .play, :host([data-layout=\"compact\"]) .more, :host([data-layout=\"compact\"]) [data-b=\"out\"], :host([data-layout=\"compact\"]) [data-b=\"level\"], :host([data-layout=\"compact\"]) [data-b=\"in\"] { border-color: transparent; }\n:host([data-layout=\"compact\"]) .play:hover:not(:disabled), :host([data-layout=\"compact\"]) .more:hover:not(:disabled), :host([data-layout=\"compact\"]) .zoom button:hover:not(:disabled) { border-color: var(--_ink); }\n:host([data-layout=\"compact\"]) .play .text, :host([data-layout=\"compact\"]) .more .text { position: absolute; width: 1px; min-width: 0; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n:host([data-layout=\"compact\"]) .play, :host([data-layout=\"compact\"]) .more { padding: 0; }\n:host([data-layout=\"compact\"]) .pace-now { display: none; }\n:host([data-layout=\"vertical\"]) .centre, :host([data-layout=\"compact\"]) .centre { justify-self: start; }\n/* The row stays one row: Centre is with Fit, one press of More away. */\n:host([data-layout=\"compact\"][data-level=\"basic\"]) .centre { display: none; }\n:host([data-layout=\"compact\"][data-level=\"basic\"]) .angle, :host([data-layout=\"compact\"][data-level=\"basic\"]) .zslide, :host([data-layout=\"compact\"][data-level=\"basic\"]) [data-b=\"fit\"] { display: none; }\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/* The speed buttons are an element of their own: they are given the colours this bar has, set on the bar so that the buttons' own variables never read themselves. */\n.bar { --_bar-ink: var(--_ink); --_bar-surface: var(--_surface); --_bar-line: var(--_line); --_bar-accent: var(--_accent); }\n.pace { --kazoku-ink: var(--_bar-ink); --kazoku-surface: var(--_bar-surface); --kazoku-line: var(--_bar-line); --kazoku-accent: var(--_bar-accent); }\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/* The classic 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 :host([data-layout=\"classic\"]) .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 :host([data-layout=\"classic\"]) .field.angle { grid-area: angle; }\n :host([data-layout=\"classic\"]) .field.tilt { grid-area: tilt; }\n :host([data-layout=\"classic\"]) .field label, :host([data-layout=\"classic\"]) .field output, :host([data-layout=\"classic\"]) .play .text, :host([data-layout=\"classic\"]) .more .text, :host([data-layout=\"classic\"]) .pace::part(label) { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }\n :host([data-layout=\"classic\"]) .play, :host([data-layout=\"classic\"]) .more { padding: 0; }\n :host([data-layout=\"classic\"]) .play { grid-area: play; }\n :host([data-layout=\"classic\"]) .more { grid-area: more; }\n :host([data-layout=\"classic\"]) .front { grid-area: front; padding: 0 12px; }\n :host([data-layout=\"classic\"]) .pace { grid-area: pace; justify-self: end; flex-wrap: nowrap; }\n :host([data-layout=\"classic\"]) .zoom { grid-area: zoom; justify-self: end; flex-wrap: nowrap; }\n :host([data-layout=\"classic\"]) .zoom button { padding: 0 8px; }\n :host([data-layout=\"classic\"]) .zoom button.level { padding: 0 6px; }\n /* In the free room left of the zoom buttons, so showing it adds no row. */\n :host([data-layout=\"classic\"]) .pace-now { grid-area: zoom; justify-self: start; }\n :host([data-layout=\"classic\"]) .pan { grid-area: pan; justify-content: center; }\n :host([data-layout=\"classic\"]) .drag { margin-inline-start: 4px; }\n}\n/* In a panel narrower than the buttons and the speed need side by side, the speed takes the row under them. */\n:host([data-layout=\"classic\"]) { container-type: inline-size; }\n@container (max-width: 329px) {\n @media (max-width: 640px) {\n :host([data-layout=\"classic\"]) .pace-now { grid-area: pace; justify-self: end; }\n }\n}\n\n/* The zoom slider (horizontal, vertical and compact): a logarithmic track with ticks at its marks, the labels under it, and the part from the least zoom to the zoom now filled. */\n.zslide { display: none; }\n:host([data-layout=\"horizontal\"]) .zslide, :host([data-layout=\"vertical\"]) .zslide, :host([data-layout=\"compact\"]) .zslide { display: flex; }\n.zbox { position: relative; flex: 1 1 auto; min-width: 96px; height: 52px; }\n.zbox input { position: absolute; inset: 0 0 auto; z-index: 1; min-width: 0; }\n.zbox input::-webkit-slider-runnable-track { background: transparent; }\n.zbox input::-moz-range-track { background: transparent; }\n.ztrack { position: absolute; inset-inline: 14px; top: 21px; height: 6px; border-radius: 3px; background: var(--_line); }\n.zfill { position: absolute; inset-block: 0; left: 0; width: calc(var(--pos, 0) * 100%); border-radius: 3px; background: var(--_accent); }\n.ztick { position: absolute; top: 50%; left: calc(var(--at) * 100%); width: 2px; height: 14px; border-radius: 1px; background: var(--_ink); opacity: 0.5; transform: translate(-50%, -50%); }\n.zmarks { position: absolute; inset-inline: 14px; top: 36px; height: 16px; pointer-events: none; }\n.zmark { position: absolute; left: calc(var(--at) * 100%); transform: translateX(-50%); font-size: 0.72rem; font-weight: 500; line-height: 16px; opacity: 0.85; white-space: nowrap; font-variant-numeric: tabular-nums; }\n.zmark[data-end=\"start\"] { transform: translateX(-35%); }\n.zmark[data-end=\"end\"] { transform: translateX(-65%); }\n\n/* Horizontal: a tidy bar under the view. Two rows (the turn, then the zoom with its slider); one row when its own width allows. More adds its rows between them. */\n:host([data-layout=\"horizontal\"]) { container-type: inline-size; }\n:host([data-layout=\"horizontal\"]) .bar, :host([data-layout=\"vertical\"]) .bar, :host([data-layout=\"compact\"]) .bar { display: grid; row-gap: 0; align-items: center; }\n:host([data-layout=\"horizontal\"]) .bar > *, :host([data-layout=\"vertical\"]) .bar > *, :host([data-layout=\"compact\"]) .zoom > * { margin-block: 2px; }\n:host([data-layout=\"horizontal\"]) .bar { grid-template-columns: minmax(0, 1fr) auto auto auto; grid-template-areas: \"angle speed play more\" \"tilt tilt front front\" \"pace pace pace pace\" \"zoom zoom zoom centre\" \"pan pan pan pan\"; column-gap: 8px; padding: 4px 8px; }\n:host([data-layout=\"horizontal\"][data-flat]) .bar { grid-template-columns: minmax(0, 1fr) auto auto; grid-template-areas: \"zoom centre more\" \"pan pan pan\"; }\n:host([data-layout=\"horizontal\"]) .angle, :host([data-layout=\"vertical\"]) .angle, :host([data-layout=\"compact\"]) .angle { grid-area: angle; }\n:host([data-layout=\"horizontal\"]) .tilt, :host([data-layout=\"vertical\"]) .tilt, :host([data-layout=\"compact\"]) .tilt { grid-area: tilt; }\n:host([data-layout=\"horizontal\"]) .pace-now, :host([data-layout=\"vertical\"]) .pace-now { grid-area: speed; }\n:host([data-layout=\"horizontal\"]) .play, :host([data-layout=\"vertical\"]) .play, :host([data-layout=\"compact\"]) .play { grid-area: play; }\n:host([data-layout=\"horizontal\"]) .pace, :host([data-layout=\"vertical\"]) .pace, :host([data-layout=\"compact\"]) .pace { grid-area: pace; flex-wrap: nowrap; }\n:host([data-layout=\"horizontal\"]) .front, :host([data-layout=\"vertical\"]) .front, :host([data-layout=\"compact\"]) .front { grid-area: front; justify-self: start; }\n:host([data-layout=\"horizontal\"]) .zoom, :host([data-layout=\"vertical\"]) .zoom { grid-area: zoom; }\n:host([data-layout=\"horizontal\"]) .pan, :host([data-layout=\"vertical\"]) .pan, :host([data-layout=\"compact\"]) .pan { grid-area: pan; justify-self: start; }\n:host([data-layout=\"horizontal\"]) .more, :host([data-layout=\"vertical\"]) .more, :host([data-layout=\"compact\"]) .more { grid-area: more; margin-inline-start: 0; }\n/* Centre, shown once the view is zoomed or moved: beside the zoom, never in the turn's row, so Auto-turn and More stay where they are. */\n:host([data-layout=\"horizontal\"]) .centre, :host([data-layout=\"vertical\"]) .centre, :host([data-layout=\"compact\"]) .centre { grid-area: centre; justify-self: end; }\n/* In the two rows it shares More's column, so it is an icon (its name is read) and never widens that column, which would move Auto-turn. */\n@container (max-width: 1039px) {\n :host([data-layout=\"horizontal\"]) .centre .text { position: absolute; width: 1px; min-width: 0; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n :host([data-layout=\"horizontal\"]) .centre { padding: 0; }\n}\n:host([data-layout=\"horizontal\"]) .zoom { display: flex; flex-wrap: nowrap; gap: 4px; min-width: 0; }\n:host([data-layout=\"horizontal\"]) .zslide { flex: 1 1 auto; min-width: 0; }\n:host([data-layout=\"horizontal\"]) .zoom button, :host([data-layout=\"vertical\"]) .zoom button, :host([data-layout=\"compact\"]) .zoom button { padding: 0 10px; }\n:host([data-layout=\"horizontal\"]) .zoom button.level, :host([data-layout=\"vertical\"]) .zoom button.level, :host([data-layout=\"compact\"]) .zoom button.level { padding: 0 6px; }\n@container (max-width: 559px) {\n :host([data-layout=\"horizontal\"]) .field label, :host([data-layout=\"horizontal\"]) .field output, :host([data-layout=\"horizontal\"]) .play .text, :host([data-layout=\"horizontal\"]) .more .text, :host([data-layout=\"horizontal\"]) .pace::part(label) { position: absolute; width: 1px; min-width: 0; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n :host([data-layout=\"horizontal\"]) .play, :host([data-layout=\"horizontal\"]) .more { padding: 0; }\n :host([data-layout=\"horizontal\"]) .zbox { min-width: 72px; }\n /* The readout is Fit here (its name says so), which leaves the slider the room for its marks. */\n :host([data-layout=\"horizontal\"]) [data-b=\"fit\"] { display: none; }\n}\n/* A phone-safe width (a phone's screen less the page's margins, and a face wider than the system's): the zoom slider takes a row of its own, so its marks never meet the buttons, and the buttons sit under it. */\n@container (max-width: 439px) {\n :host([data-layout=\"horizontal\"]) .zoom { flex-wrap: wrap; }\n :host([data-layout=\"horizontal\"]) .zslide { flex: 1 0 100%; }\n :host([data-layout=\"horizontal\"]) .centre { align-self: end; }\n :host([data-layout=\"horizontal\"]) .zbox { min-width: 0; }\n}\n/* Wide enough for the turn, Auto-turn, the zoom with its slider, Centre and More on one row, with the angle slider still a slider. */\n@container (min-width: 1040px) {\n :host([data-layout=\"horizontal\"]) .bar { grid-template-columns: minmax(0, 1fr) auto auto auto auto auto; grid-template-areas: \"angle speed play zoom centre more\" \"tilt tilt tilt pace front front\" \"pan pan pan pan pan pan\"; column-gap: 12px; }\n :host([data-layout=\"horizontal\"][data-flat]) .bar { grid-template-columns: minmax(0, 1fr) auto auto; grid-template-areas: \"zoom centre more\" \"pan pan pan\"; }\n :host([data-layout=\"horizontal\"]) .zslide { width: 15rem; }\n :host([data-layout=\"horizontal\"]) .zoom { display: grid; grid-auto-flow: column; grid-auto-columns: auto; }\n :host([data-layout=\"horizontal\"]) .pan { justify-self: end; }\n}\n\n/* Vertical: a column beside the view, its width --kazoku-column-width (16rem). Auto-turn and More at the top, so More stays put when it opens; then the turn, the zoom and its slider. */\n:host([data-layout=\"vertical\"]) { width: var(--kazoku-column-width, 16rem); max-width: 100%; }\n:host([data-layout=\"vertical\"]) .bar { grid-template-columns: auto auto minmax(0, 1fr) auto; grid-template-areas: \"play speed . more\" \"angle angle angle angle\" \"tilt tilt tilt tilt\" \"pace pace pace pace\" \"front front front front\" \"zoom zoom zoom zoom\" \"centre centre centre centre\" \"pan pan pan pan\"; column-gap: 8px; padding: 6px 8px; }\n:host([data-layout=\"vertical\"]) .field { flex: none; display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: \"label value\" \"slider slider\"; gap: 0 8px; }\n:host([data-layout=\"vertical\"]) .field label { grid-area: label; padding-top: 4px; }\n:host([data-layout=\"vertical\"]) .field output { grid-area: value; padding-top: 4px; }\n:host([data-layout=\"vertical\"]) .field input, :host([data-layout=\"vertical\"]) .zbox { grid-area: slider; }\n:host([data-layout=\"vertical\"]) .zoom { display: grid; grid-template-columns: auto auto auto minmax(0, 1fr); grid-template-areas: \"zs zs zs zs\" \"out level in fit\"; gap: 0 4px; }\n:host([data-layout=\"vertical\"]) .zslide { grid-area: zs; }\n:host([data-layout=\"vertical\"]) [data-b=\"out\"] { grid-area: out; }\n:host([data-layout=\"vertical\"]) [data-b=\"level\"] { grid-area: level; }\n:host([data-layout=\"vertical\"]) [data-b=\"in\"] { grid-area: in; }\n:host([data-layout=\"vertical\"]) [data-b=\"fit\"] { grid-area: fit; justify-self: start; }\n:host([data-layout=\"vertical\"]) .pace::part(label) { position: absolute; width: 1px; min-width: 0; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n:host([data-layout=\"vertical\"]) .pan { flex-wrap: wrap; }\n:host([data-layout=\"vertical\"]) .drag { margin-inline-start: 0; }\n\n/* Compact: one short row (Auto-turn, zoom out, the zoom now, zoom in, More) for a card or a phone. More opens the rest under it: the zoom slider and Fit, the angle, and the advanced controls. */\n:host([data-layout=\"compact\"]) { display: block; }\n:host([data-layout=\"compact\"][overlay]) { inset-inline: 8px auto; bottom: 8px; }\n/* Over a card, an open More never reaches past the card's top: the controls scroll inside themselves. */\n:host([data-layout=\"compact\"][overlay]) { display: flex; flex-direction: column; max-height: calc(100% - 16px); }\n:host([data-layout=\"compact\"][overlay]) .bar { min-height: 0; overflow-y: auto; }\n:host([data-layout=\"compact\"]) .bar { width: fit-content; max-width: 100%; grid-template-columns: repeat(5, auto); grid-template-areas: \"play out level in more\" \"zs zs zs zs fit\" \"centre centre centre centre centre\" \"angle angle angle angle angle\" \"tilt tilt tilt tilt tilt\" \"pace pace pace pace pace\" \"front front front front front\" \"pan pan pan pan pan\"; column-gap: 2px; padding: 2px 4px; border: 1px solid var(--_line); border-radius: 28px; background: var(--_surface); }\n:host([data-layout=\"compact\"][data-level=\"advanced\"]) .bar { border-radius: 20px; padding: 2px 8px 6px; column-gap: 4px; }\n:host([data-layout=\"compact\"][data-flat]) .bar { grid-template-areas: \"out level in more .\" \"zs zs zs fit fit\" \"centre centre centre centre centre\" \"pan pan pan pan pan\"; }\n:host([data-layout=\"compact\"]) .zoom { display: contents; }\n:host([data-layout=\"compact\"]) .zslide { grid-area: zs; }\n:host([data-layout=\"compact\"]) [data-b=\"out\"] { grid-area: out; }\n:host([data-layout=\"compact\"]) [data-b=\"level\"] { grid-area: level; }\n:host([data-layout=\"compact\"]) [data-b=\"in\"] { grid-area: in; }\n:host([data-layout=\"compact\"]) [data-b=\"fit\"] { grid-area: fit; }\n:host([data-layout=\"compact\"]) .play, :host([data-layout=\"compact\"]) .more, :host([data-layout=\"compact\"]) [data-b=\"out\"], :host([data-layout=\"compact\"]) [data-b=\"level\"], :host([data-layout=\"compact\"]) [data-b=\"in\"] { border-color: transparent; }\n:host([data-layout=\"compact\"]) .play:hover:not(:disabled), :host([data-layout=\"compact\"]) .more:hover:not(:disabled), :host([data-layout=\"compact\"]) .zoom button:hover:not(:disabled) { border-color: var(--_ink); }\n:host([data-layout=\"compact\"]) .play .text, :host([data-layout=\"compact\"]) .more .text { position: absolute; width: 1px; min-width: 0; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n:host([data-layout=\"compact\"]) .play, :host([data-layout=\"compact\"]) .more { padding: 0; }\n:host([data-layout=\"compact\"]) .pace-now { display: none; }\n:host([data-layout=\"compact\"]) .pace { flex-wrap: wrap; }\n:host([data-layout=\"vertical\"]) .centre, :host([data-layout=\"compact\"]) .centre { justify-self: start; }\n/* The row stays one row: Centre is with Fit, one press of More away. */\n:host([data-layout=\"compact\"][data-level=\"basic\"]) .centre { display: none; }\n:host([data-layout=\"compact\"][data-level=\"basic\"]) .angle, :host([data-layout=\"compact\"][data-level=\"basic\"]) .zslide, :host([data-layout=\"compact\"][data-level=\"basic\"]) [data-b=\"fit\"] { display: none; }\n";
@@ -59,7 +59,9 @@ button:disabled { opacity: 0.4; cursor: default; }
59
59
  svg { width: 18px; height: 18px; fill: currentColor; flex: none; }
60
60
  button[aria-pressed="true"] .i-play, button[aria-pressed="false"] .i-pause { display: none; }
61
61
  .zoom button.level { min-width: 64px; padding: 0 8px; font-variant-numeric: tabular-nums; }
62
- .pace .lbl { margin-inline-end: 4px; }
62
+ /* The speed buttons are an element of their own: they are given the colours this bar has, set on the bar so that the buttons' own variables never read themselves. */
63
+ .bar { --_bar-ink: var(--_ink); --_bar-surface: var(--_surface); --_bar-line: var(--_line); --_bar-accent: var(--_accent); }
64
+ .pace { --kazoku-ink: var(--_bar-ink); --kazoku-surface: var(--_bar-surface); --kazoku-line: var(--_bar-line); --kazoku-accent: var(--_bar-accent); }
63
65
  /* 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. */
64
66
  .pace-now { flex-direction: column; gap: 0; padding: 0 10px; line-height: 1.1; }
65
67
  .pace-now .lbl { font-size: 0.7em; font-weight: 400; }
@@ -72,13 +74,12 @@ button[aria-pressed="true"] .i-play, button[aria-pressed="false"] .i-pause { dis
72
74
  ${CLASSIC} .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; }
73
75
  ${CLASSIC} .field.angle { grid-area: angle; }
74
76
  ${CLASSIC} .field.tilt { grid-area: tilt; }
75
- ${CLASSIC} .field label, ${CLASSIC} .field output, ${CLASSIC} .play .text, ${CLASSIC} .more .text, ${CLASSIC} .pace .lbl { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
77
+ ${CLASSIC} .field label, ${CLASSIC} .field output, ${CLASSIC} .play .text, ${CLASSIC} .more .text, ${CLASSIC} .pace::part(label) { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
76
78
  ${CLASSIC} .play, ${CLASSIC} .more { padding: 0; }
77
79
  ${CLASSIC} .play { grid-area: play; }
78
80
  ${CLASSIC} .more { grid-area: more; }
79
81
  ${CLASSIC} .front { grid-area: front; padding: 0 12px; }
80
82
  ${CLASSIC} .pace { grid-area: pace; justify-self: end; flex-wrap: nowrap; }
81
- ${CLASSIC} .pace button { padding: 0 12px; }
82
83
  ${CLASSIC} .zoom { grid-area: zoom; justify-self: end; flex-wrap: nowrap; }
83
84
  ${CLASSIC} .zoom button { padding: 0 8px; }
84
85
  ${CLASSIC} .zoom button.level { padding: 0 6px; }
@@ -87,6 +88,13 @@ button[aria-pressed="true"] .i-play, button[aria-pressed="false"] .i-pause { dis
87
88
  ${CLASSIC} .pan { grid-area: pan; justify-content: center; }
88
89
  ${CLASSIC} .drag { margin-inline-start: 4px; }
89
90
  }
91
+ /* In a panel narrower than the buttons and the speed need side by side, the speed takes the row under them. */
92
+ ${CLASSIC} { container-type: inline-size; }
93
+ @container (max-width: 329px) {
94
+ @media (max-width: 640px) {
95
+ ${CLASSIC} .pace-now { grid-area: pace; justify-self: end; }
96
+ }
97
+ }
90
98
 
91
99
  /* The zoom slider (horizontal, vertical and compact): a logarithmic track with ticks at its marks, the labels under it, and the part from the least zoom to the zoom now filled. */
92
100
  .zslide { display: none; }
@@ -121,7 +129,7 @@ ${H} .more, ${V} .more, ${C} .more { grid-area: more; margin-inline-start: 0; }
121
129
  /* Centre, shown once the view is zoomed or moved: beside the zoom, never in the turn's row, so Auto-turn and More stay where they are. */
122
130
  ${H} .centre, ${V} .centre, ${C} .centre { grid-area: centre; justify-self: end; }
123
131
  /* In the two rows it shares More's column, so it is an icon (its name is read) and never widens that column, which would move Auto-turn. */
124
- @container (max-width: 899px) {
132
+ @container (max-width: 1039px) {
125
133
  ${H} .centre .text { ${UNSEEN} }
126
134
  ${H} .centre { padding: 0; }
127
135
  }
@@ -130,16 +138,25 @@ ${H} .zslide { flex: 1 1 auto; min-width: 0; }
130
138
  ${H} .zoom button, ${V} .zoom button, ${C} .zoom button { padding: 0 10px; }
131
139
  ${H} .zoom button.level, ${V} .zoom button.level, ${C} .zoom button.level { padding: 0 6px; }
132
140
  @container (max-width: 559px) {
133
- ${H} .field label, ${H} .field output, ${H} .play .text, ${H} .more .text, ${H} .pace .lbl { ${UNSEEN} }
141
+ ${H} .field label, ${H} .field output, ${H} .play .text, ${H} .more .text, ${H} .pace::part(label) { ${UNSEEN} }
134
142
  ${H} .play, ${H} .more { padding: 0; }
135
143
  ${H} .zbox { min-width: 72px; }
136
144
  /* The readout is Fit here (its name says so), which leaves the slider the room for its marks. */
137
145
  ${H} [data-b="fit"] { display: none; }
138
146
  }
139
- @container (min-width: 900px) {
147
+ /* A phone-safe width (a phone's screen less the page's margins, and a face wider than the system's): the zoom slider takes a row of its own, so its marks never meet the buttons, and the buttons sit under it. */
148
+ @container (max-width: 439px) {
149
+ ${H} .zoom { flex-wrap: wrap; }
150
+ ${H} .zslide { flex: 1 0 100%; }
151
+ ${H} .centre { align-self: end; }
152
+ ${H} .zbox { min-width: 0; }
153
+ }
154
+ /* Wide enough for the turn, Auto-turn, the zoom with its slider, Centre and More on one row, with the angle slider still a slider. */
155
+ @container (min-width: 1040px) {
140
156
  ${H} .bar { grid-template-columns: minmax(0, 1fr) auto auto auto auto auto; grid-template-areas: "angle speed play zoom centre more" "tilt tilt tilt pace front front" "pan pan pan pan pan pan"; column-gap: 12px; }
141
157
  :host([data-layout="horizontal"][data-flat]) .bar { grid-template-columns: minmax(0, 1fr) auto auto; grid-template-areas: "zoom centre more" "pan pan pan"; }
142
- ${H} .zslide { flex: 0 0 15rem; }
158
+ ${H} .zslide { width: 15rem; }
159
+ ${H} .zoom { display: grid; grid-auto-flow: column; grid-auto-columns: auto; }
143
160
  ${H} .pan { justify-self: end; }
144
161
  }
145
162
 
@@ -156,8 +173,7 @@ ${V} [data-b="out"] { grid-area: out; }
156
173
  ${V} [data-b="level"] { grid-area: level; }
157
174
  ${V} [data-b="in"] { grid-area: in; }
158
175
  ${V} [data-b="fit"] { grid-area: fit; justify-self: start; }
159
- ${V} .pace .lbl { ${UNSEEN} }
160
- ${V} .pace button { padding: 0 10px; }
176
+ ${V} .pace::part(label) { ${UNSEEN} }
161
177
  ${V} .pan { flex-wrap: wrap; }
162
178
  ${V} .drag { margin-inline-start: 0; }
163
179
 
@@ -181,6 +197,7 @@ ${C} .play:hover:not(:disabled), ${C} .more:hover:not(:disabled), ${C} .zoom but
181
197
  ${C} .play .text, ${C} .more .text { ${UNSEEN} }
182
198
  ${C} .play, ${C} .more { padding: 0; }
183
199
  ${C} .pace-now { display: none; }
200
+ ${C} .pace { flex-wrap: wrap; }
184
201
  ${V} .centre, ${C} .centre { justify-self: start; }
185
202
  /* The row stays one row: Centre is with Fit, one press of More away. */
186
203
  :host([data-layout="compact"][data-level="basic"]) .centre { display: none; }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@johnmorrisdotca/kazoku-ui",
3
- "version": "0.13.0",
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.",
3
+ "version": "0.14.0",
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, a save-as-image button and one set of speed buttons (0.5×, 1×, 2× or your own steps), 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",
7
7
  "3d",
@@ -30,7 +30,10 @@
30
30
  "webgl",
31
31
  "three",
32
32
  "three.js",
33
- "renderer"
33
+ "renderer",
34
+ "speed-buttons",
35
+ "playback-speed",
36
+ "time-speed"
34
37
  ],
35
38
  "homepage": "https://johnmorrisdotca.github.io/kazoku-ui/",
36
39
  "repository": {
@@ -47,7 +50,8 @@
47
50
  "./dist/view-controls/define.js",
48
51
  "./dist/theme-switch/define.js",
49
52
  "./dist/language-picker/define.js",
50
- "./dist/save-image/define.js"
53
+ "./dist/save-image/define.js",
54
+ "./dist/speed-buttons/define.js"
51
55
  ],
52
56
  "main": "./dist/index.js",
53
57
  "module": "./dist/index.js",
@@ -123,6 +127,21 @@
123
127
  "import": "./dist/save-image/define.js",
124
128
  "default": "./dist/save-image/define.js"
125
129
  },
130
+ "./speed-buttons": {
131
+ "types": "./dist/speed-buttons/index.d.ts",
132
+ "import": "./dist/speed-buttons/index.js",
133
+ "default": "./dist/speed-buttons/index.js"
134
+ },
135
+ "./speed-buttons/element": {
136
+ "types": "./dist/speed-buttons/element.d.ts",
137
+ "import": "./dist/speed-buttons/element.js",
138
+ "default": "./dist/speed-buttons/element.js"
139
+ },
140
+ "./speed-buttons/define": {
141
+ "types": "./dist/speed-buttons/define.d.ts",
142
+ "import": "./dist/speed-buttons/define.js",
143
+ "default": "./dist/speed-buttons/define.js"
144
+ },
126
145
  "./renderer": {
127
146
  "types": "./dist/renderer/index.d.ts",
128
147
  "import": "./dist/renderer/index.js",