diff --git a/README.md b/README.md
index db3b774..26fde46 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@ import {busk} from '@logfox/busker';
import '@logfox/busker/busker.css';
busk(document.querySelector('.app'), {
- scene: 'home',
+ initialScene: 'home',
steps: [
{click: '[data-nav-item="alerts"]', wait: 900},
{click: '[data-row="p0"]', wait: 1500},
diff --git a/busker.css b/busker.css
index 7bf3519..20ed53d 100644
--- a/busker.css
+++ b/busker.css
@@ -12,6 +12,8 @@
--busker-cursor-size: 0.95rem;
--busker-cursor-fill: rgb(0 0 0 / 0.42);
--busker-cursor-edge: #fff;
+ --busker-cursor-shadow: rgb(0 0 0 / 0.3);
+ --busker-scene-ms: 0.4s;
/* The cursor is positioned against the root. */
position: relative;
@@ -27,7 +29,7 @@
background: var(--busker-cursor-fill);
border: 2px solid var(--busker-cursor-edge);
border-radius: 999px;
- box-shadow: 0 1px 6px rgb(0 0 0 / 0.3);
+ box-shadow: 0 1px 6px var(--busker-cursor-shadow);
display: block;
height: var(--busker-cursor-size);
opacity: 0;
@@ -103,13 +105,37 @@
display: none;
}
-/* Scenes: one at a time. */
+/* Scenes are a stack, so the one going out can fade under the one coming in
+ rather than popping. They are out of flow, so whatever element holds them
+ needs a height of its own — from a parent, a grid track, or its own rule. */
+.busker :has(> [data-scene]) {
+ position: relative;
+}
+
.busker [data-scene] {
- display: none;
+ inset: 0;
+ opacity: 0;
+ pointer-events: none;
+ position: absolute;
+ translate: 0 4px;
+ visibility: hidden;
+
+ /* Visibility waits out the fade on the way out and switches at once on the
+ way in, so a scene nobody can see is not read out or tabbed into. */
+ transition:
+ opacity var(--busker-scene-ms) ease,
+ translate var(--busker-scene-ms) ease,
+ visibility 0s linear var(--busker-scene-ms);
}
.busker [data-scene].is-active {
- display: block;
+ opacity: 1;
+ pointer-events: auto;
+ translate: none;
+ visibility: visible;
+ transition:
+ opacity var(--busker-scene-ms) ease,
+ translate var(--busker-scene-ms) ease;
}
@media (prefers-reduced-motion: reduce) {
@@ -120,4 +146,9 @@
.busker .is-hint {
animation: none;
}
+
+ .busker [data-scene],
+ .busker [data-scene].is-active {
+ transition: none;
+ }
}
diff --git a/package-lock.json b/package-lock.json
index 2c10f04..40c95b6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,6 +19,7 @@
"happy-dom": "^20.13.2",
"kizu": "^5.1.1",
"knip": "^6.34.0",
+ "tsx": "^4.23.13",
"typescript": "^6.0.3",
"typescript-eslint": "^8.69.0"
},
@@ -9632,7 +9633,6 @@
"integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "~0.28.0"
},
diff --git a/package.json b/package.json
index b720039..3da1ba9 100644
--- a/package.json
+++ b/package.json
@@ -25,6 +25,7 @@
"publishConfig": {
"access": "public"
},
+ "type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
@@ -52,7 +53,7 @@
"build": "tsc",
"lint": "eslint . --fix",
"lint:check": "eslint .",
- "test": "c8 kizu -f 'src/**/*.spec.ts' && c8 report -r text -r html",
+ "test": "NODE_OPTIONS='--import tsx' c8 kizu -f 'src/**/*.spec.ts' && c8 report -r text -r html",
"deadcode:check": "knip",
"sync:fonts": "node scripts/sync-fonts.mjs",
"astro:sync": "astro sync",
@@ -71,6 +72,7 @@
"happy-dom": "^20.13.2",
"kizu": "^5.1.1",
"knip": "^6.34.0",
+ "tsx": "^4.23.13",
"typescript": "^6.0.3",
"typescript-eslint": "^8.69.0"
}
diff --git a/src/busk.spec.ts b/src/busk.spec.ts
index e6afe6d..45bb078 100644
--- a/src/busk.spec.ts
+++ b/src/busk.spec.ts
@@ -1,7 +1,7 @@
import {test} from 'kizu';
import {GlobalRegistrator} from '@happy-dom/global-registrator';
-import {busk} from './busk';
-import type {Busker, Routine} from './types';
+import {busk} from './busk.ts';
+import type {Busker, Routine} from './types.ts';
GlobalRegistrator.register({url: 'https://busker.test', width: 1024, height: 768});
@@ -34,7 +34,10 @@ class FakeObserver implements IntersectionObserver {
readonly rootMargin = '';
readonly thresholds: readonly number[] = [];
- constructor(private readonly callback: IntersectionObserverCallback) {
+ private readonly callback: IntersectionObserverCallback;
+
+ constructor(callback: IntersectionObserverCallback) {
+ this.callback = callback;
observers.push(this);
}
@@ -53,8 +56,8 @@ class FakeObserver implements IntersectionObserver {
/**
* happy-dom does no layout, so every rect is zero and the show would think it
- * is off screen. Give the root a size; nothing here depends on where the
- * cursor lands (that is covered in timeline.spec.ts).
+ * is off screen. Give the root a size, and give everything inside it a box
+ * that goes away when its scene is hidden, the way a real one does.
*/
function stage(routine: Routine): Stage {
document.body.innerHTML = `
${MOCK}
`;
@@ -64,6 +67,15 @@ function stage(routine: Routine): Stage {
if (!root) throw new Error('no root');
root.getBoundingClientRect = (): DOMRect => new DOMRect(0, 0, 800, 600);
+ root.querySelectorAll('*').forEach((el) => {
+ el.getBoundingClientRect = (): DOMRect => {
+ const scene = el.closest('[data-scene]');
+
+ return scene && !scene.classList.contains('is-active')
+ ? new DOMRect(0, 0, 0, 0)
+ : new DOMRect(100, 50, 80, 20);
+ };
+ });
observers.length = 0;
@@ -94,7 +106,7 @@ function stage(routine: Routine): Stage {
}
const routine: Routine = {
- scene: 'home',
+ initialScene: 'home',
steps: [
{click: '[data-nav-item="alerts"]', moveFor: 100, dwell: 0},
{click: '[data-row="p0"]', wait: 100, moveFor: 100, dwell: 0},
@@ -149,6 +161,38 @@ test('a routine that ends on a click still lands it', (assert) => {
});
+test('the cursor holds its place when its own click takes the target away', (assert) => {
+
+ const {root, startShow, tick} = stage({
+ initialScene: 'list',
+ steps: [{click: '[data-row="p0"]', moveFor: 100, dwell: 0}],
+ routes: [{click: '[data-row="p0"]', scene: 'home'}],
+ });
+
+ const cursor = root.querySelector('[data-cursor]');
+
+ // Arrived on the row and pressing it.
+ tick(0);
+ startShow();
+ tick(100);
+
+ const onTheRow = cursor?.style.left;
+
+ assert.equal(cursor?.classList.contains('is-pressing'), true);
+
+ // The click has landed and taken the row out of layout with it. The ring
+ // outlives the press on purpose, so on every frame that is left it has to
+ // keep running where the row was — not wherever an unresolvable target
+ // works out to.
+ tick(210);
+ tick(16);
+
+ assert.equal(root.querySelector('[data-scene="home"]')?.classList.contains('is-active'), true);
+ assert.equal(cursor?.classList.contains('is-ringing'), true);
+ assert.equal(cursor?.style.left, onTheRow);
+
+});
+
test('the show does not mistake its own click for a visitor taking over', (assert) => {
const {root, startShow, tick} = stage(routine);
diff --git a/src/busk.ts b/src/busk.ts
index de400f8..f30efc3 100644
--- a/src/busk.ts
+++ b/src/busk.ts
@@ -7,8 +7,8 @@ import {
moveIndexAt,
positionAt,
typedText,
-} from './timeline';
-import type {Busker, Move, Point, Routine} from './types';
+} from './timeline.ts';
+import type {Busker, Move, Point, Routine} from './types.ts';
const DEFAULT_START: Point = [0.5, 0.5];
/** IntersectionObserver ratios are floating point; 1 is rarely exactly 1. */
@@ -48,10 +48,10 @@ export function busk(root: HTMLElement, routine: Routine): Busker {
navItems.set(el.dataset.navItem as string, el);
});
- // Script mode presses for real; timeline mode only animates the press.
+ // A script presses for real; a hand-timed routine only animates the press.
const script = routine.steps ? compile(routine.steps) : null;
const moves = script?.moves ?? routine.moves ?? [];
- const duration = routine.duration ?? script?.duration ?? 0;
+ const duration = script?.duration ?? routine.duration ?? 0;
const start = routine.start ?? DEFAULT_START;
const routes = routine.routes ?? [];
const visibility = routine.visibility ?? 1;
@@ -68,21 +68,29 @@ export function busk(root: HTMLElement, routine: Routine): Busker {
const typings = found(routine.typing);
const countdowns = found(routine.countdowns);
+ /** Where each selector was last seen, in case it stops being anywhere. */
+ const lastSeen = new Map();
+
/** Where a move target sits, in px relative to the root's top-left. */
function resolve(target: string | Point): Point | null {
if (Array.isArray(target)) return [target[0] * root.clientWidth, target[1] * root.clientHeight];
- const el = root.querySelector(target);
+ const rect = root.querySelector(target)?.getBoundingClientRect();
- if (!el) return null;
+ // A press takes its own target away whenever the click changes the
+ // scene, and the ring outlives the press. With no box left to aim at,
+ // stay where the target was rather than sliding off to the corner.
+ if (!rect || (rect.width === 0 && rect.height === 0)) return lastSeen.get(target) ?? null;
const rootRect = root.getBoundingClientRect();
- const rect = el.getBoundingClientRect();
-
- return [
+ const at: Point = [
rect.left - rootRect.left + rect.width / 2,
rect.top - rootRect.top + rect.height / 2,
];
+
+ lastSeen.set(target, at);
+
+ return at;
}
let elapsed = 0;
@@ -213,7 +221,7 @@ export function busk(root: HTMLElement, routine: Routine): Busker {
// drop the start of the routine or fire a burst of catch-up clicks.
if (next >= duration) {
pressed.clear();
- activate(routine.scene ?? null);
+ activate(routine.initialScene ?? null);
elapsed = 0;
} else {
elapsed = next;
@@ -307,7 +315,7 @@ export function busk(root: HTMLElement, routine: Routine): Busker {
root.addEventListener('click', onClick);
}
- activate(routine.scene ?? null);
+ activate(routine.initialScene ?? null);
const observer = reducedMotion
? undefined
diff --git a/src/components/codeBlockTitles.spec.ts b/src/components/codeBlockTitles.spec.ts
index e82d433..ff1a0dc 100644
--- a/src/components/codeBlockTitles.spec.ts
+++ b/src/components/codeBlockTitles.spec.ts
@@ -9,7 +9,7 @@ import {
isFilePathTitle,
kindFromTitle,
tryCopyText,
-} from './codeBlockTitles';
+} from './codeBlockTitles.ts';
test('copyButtonContent idle and copied labels', (assert) => {
assert.equal(copyButtonContent(false), {label: 'Copy', state: 'idle'});
diff --git a/src/components/docsSearchQuery.spec.ts b/src/components/docsSearchQuery.spec.ts
index 2370f4c..5199490 100644
--- a/src/components/docsSearchQuery.spec.ts
+++ b/src/components/docsSearchQuery.spec.ts
@@ -1,5 +1,5 @@
import {test} from 'kizu';
-import {DOCS_SEARCH_MIN_QUERY_LENGTH, DOCS_SEARCH_PAGE_SIZE, isDocsSearchQueryReady} from './docsSearchQuery.js';
+import {DOCS_SEARCH_MIN_QUERY_LENGTH, DOCS_SEARCH_PAGE_SIZE, isDocsSearchQueryReady} from './docsSearchQuery.ts';
test('isDocsSearchQueryReady requires at least DOCS_SEARCH_MIN_QUERY_LENGTH characters', (assert) => {
diff --git a/src/components/isUsefulPagefindMatch.spec.ts b/src/components/isUsefulPagefindMatch.spec.ts
index 284a338..b47f676 100644
--- a/src/components/isUsefulPagefindMatch.spec.ts
+++ b/src/components/isUsefulPagefindMatch.spec.ts
@@ -1,5 +1,5 @@
import {test} from 'kizu';
-import {isUsefulMark, isUsefulPagefindMatch} from './isUsefulPagefindMatch.js';
+import {isUsefulMark, isUsefulPagefindMatch} from './isUsefulPagefindMatch.ts';
test('rejects inverted short-prefix garbage (adsf → a, asdfasdf → as)', (assert) => {
assert.equal(isUsefulMark('adsf', 'a'), false);
diff --git a/src/components/pagefindMount.spec.ts b/src/components/pagefindMount.spec.ts
index 8f0130e..fdc8e44 100644
--- a/src/components/pagefindMount.spec.ts
+++ b/src/components/pagefindMount.spec.ts
@@ -1,5 +1,5 @@
import {test} from 'kizu';
-import {bindPagefindMount, type PagefindMountEl} from './pagefindMount.js';
+import {bindPagefindMount, type PagefindMountEl} from './pagefindMount.ts';
function hostWith(mount: PagefindMountEl | null): {querySelector(selector: string): PagefindMountEl | null} {
return {
diff --git a/src/components/searchClearRefocus.spec.ts b/src/components/searchClearRefocus.spec.ts
index cf904ca..6d527cb 100644
--- a/src/components/searchClearRefocus.spec.ts
+++ b/src/components/searchClearRefocus.spec.ts
@@ -1,5 +1,5 @@
import {test} from 'kizu';
-import {bindSearchClearRefocus} from './searchClearRefocus.js';
+import {bindSearchClearRefocus} from './searchClearRefocus.ts';
function stubRoot(input: StubInput, clear: StubClear): Element {
return {
diff --git a/src/components/searchDialog.spec.ts b/src/components/searchDialog.spec.ts
index cb23c5e..70c40b4 100644
--- a/src/components/searchDialog.spec.ts
+++ b/src/components/searchDialog.spec.ts
@@ -5,7 +5,7 @@ import {
shouldCloseOnOutsideClick,
shouldCloseSearchOnEscape,
shouldHandleGlobalSlash,
-} from './searchDialog.js';
+} from './searchDialog.ts';
test('shouldHandleGlobalSlash opens only when idle and not typing in a field', (assert) => {
const base = {
diff --git a/src/components/searchIdleState.spec.ts b/src/components/searchIdleState.spec.ts
index c5960a4..d31da0b 100644
--- a/src/components/searchIdleState.spec.ts
+++ b/src/components/searchIdleState.spec.ts
@@ -1,5 +1,5 @@
import {test} from 'kizu';
-import {isSearchIdle} from './searchIdleState.js';
+import {isSearchIdle} from './searchIdleState.ts';
test('idle when search is ready and query is empty or shorter than 3 characters', (assert) => {
assert.equal(isSearchIdle(true, true, ''), true);
diff --git a/src/components/searchIdleState.ts b/src/components/searchIdleState.ts
index 1408b74..2b9890f 100644
--- a/src/components/searchIdleState.ts
+++ b/src/components/searchIdleState.ts
@@ -7,7 +7,7 @@
* skeleton before Pagefind mounts. Queries shorter than 3 characters stay
* idle — Pagefind does not search until then.
*/
-import {DOCS_SEARCH_MIN_QUERY_LENGTH} from './docsSearchQuery.js';
+import {DOCS_SEARCH_MIN_QUERY_LENGTH} from './docsSearchQuery.ts';
export function isSearchIdle(
searchReady: boolean,
diff --git a/src/components/searchKeyboardNav.spec.ts b/src/components/searchKeyboardNav.spec.ts
index 1ae8b40..7a94215 100644
--- a/src/components/searchKeyboardNav.spec.ts
+++ b/src/components/searchKeyboardNav.spec.ts
@@ -13,7 +13,7 @@ import {
scrollSearchSelectionIntoView,
shouldHandleSearchListKeyboard,
type KeyboardNavState,
-} from './searchKeyboardNav.js';
+} from './searchKeyboardNav.ts';
test('resetSearchNavSession clears highlight and scroll for a new dialog open', (assert) => {
const selected = stubEl();
diff --git a/src/components/searchPendingDelay.spec.ts b/src/components/searchPendingDelay.spec.ts
index 1f6f483..2fb6f24 100644
--- a/src/components/searchPendingDelay.spec.ts
+++ b/src/components/searchPendingDelay.spec.ts
@@ -3,7 +3,7 @@ import {
INITIAL_SEARCH_PENDING_DELAY_STATE,
resolveSearchPendingDelayState,
shouldShowSearchPending,
-} from './searchPendingDelay.js';
+} from './searchPendingDelay.ts';
test('shouldShowSearchPending only while searching with no settled results', (assert) => {
assert.equal(shouldShowSearchPending('searching', false, false), true);
diff --git a/src/components/searchStaleResults.spec.ts b/src/components/searchStaleResults.spec.ts
index 4bfc843..c299f7c 100644
--- a/src/components/searchStaleResults.spec.ts
+++ b/src/components/searchStaleResults.spec.ts
@@ -5,7 +5,7 @@ import {
resolveStaleResultsHold,
shouldDiscardStaleResults,
shouldShowStaleResults,
-} from './searchStaleResults.js';
+} from './searchStaleResults.ts';
test('shouldShowStaleResults only while holding a snapshot and live results are gone', (assert) => {
assert.equal(shouldShowStaleResults(true, false, true, true, false, false), true);
diff --git a/src/components/wrapPagefindSearch.spec.ts b/src/components/wrapPagefindSearch.spec.ts
index f664380..690b3ff 100644
--- a/src/components/wrapPagefindSearch.spec.ts
+++ b/src/components/wrapPagefindSearch.spec.ts
@@ -1,5 +1,5 @@
import {test} from 'kizu';
-import {wrapPagefindSearch} from './wrapPagefindSearch.js';
+import {wrapPagefindSearch} from './wrapPagefindSearch.ts';
test('wrapPagefindSearch drops inverted short-prefix hits', async (assert) => {
const rawSearch = async (_term: string): Promise<{
diff --git a/src/components/wrapPagefindSearch.ts b/src/components/wrapPagefindSearch.ts
index b69cea7..dfc816f 100644
--- a/src/components/wrapPagefindSearch.ts
+++ b/src/components/wrapPagefindSearch.ts
@@ -1,5 +1,5 @@
-import {isUsefulPagefindMatch, type PagefindMatchData} from './isUsefulPagefindMatch.js';
-import {isDocsSearchQueryReady} from './docsSearchQuery.js';
+import {isUsefulPagefindMatch, type PagefindMatchData} from './isUsefulPagefindMatch.ts';
+import {isDocsSearchQueryReady} from './docsSearchQuery.ts';
type PagefindSearchResult = {
data: () => Promise;
diff --git a/src/content/docs/api-reference.md b/src/content/docs/api-reference.md
index e890890..68a2417 100644
--- a/src/content/docs/api-reference.md
+++ b/src/content/docs/api-reference.md
@@ -12,14 +12,24 @@ Puts on a show inside `root`, an `HTMLElement`. Returns a [`Busker`](#busker). S
## `Routine`
+A routine is one of two things, never a mix. A `ScriptRoutine` has `steps` and
+gets its loop length from them; a `TimedRoutine` has a `duration` you set
+yourself. Mixing the two is a type error, so a hand-set `duration` can never
+quietly cut a script short.
+
+| Field | Type | Default | What it does |
+|---|---|---|---|
+| `steps` | [`Step[]`](#step) | — | A click-driven routine. Required in a `ScriptRoutine`. |
+| `duration` | `number` | — | Loop length in ms. Required in a `TimedRoutine`. |
+| `moves` | [`Move[]`](#move) | none | Hand-timed cursor glides. `TimedRoutine` only. |
+
+Everything else is shared:
+
| Field | Type | Default | What it does |
|---|---|---|---|
-| `scene` | `string` | none | Scene shown at the top of every loop. |
+| `initialScene` | `string` | none | Scene shown at the top of every loop. |
| `start` | `[number, number]` | `[0.5, 0.5]` | Where the cursor rests, as a fraction of the root's size. |
-| `steps` | [`Step[]`](#step) | none | A click-driven routine. The loop length comes from it. |
| `routes` | [`Route[]`](#route) | none | What a click — the cursor's or a visitor's — does. |
-| `duration` | `number` | from `steps` | Loop length in ms. Required if there are no `steps`. |
-| `moves` | [`Move[]`](#move) | none | Hand-timed cursor glides. |
| `toggles` | [`Toggle[]`](#toggle) | none | Classes held for a slice of the loop. |
| `typing` | [`Typing[]`](#typing) | none | Text that types itself. |
| `countdowns` | [`Countdown[]`](#countdown) | none | `m:ss` clocks. |
@@ -91,13 +101,13 @@ A hand-timed glide. See [Hand-timed routines](./timeline.md#moves).
| Field | Type | What it does |
|---|---|---|
| `target` | `string` | Selector. Busker writes its `textContent`. |
-| `seconds` | `number` | Value at the top of the loop. Stops at zero. |
+| `startSeconds` | `number` | Value at the top of every loop. Counts down to zero and stops. |
## `Busker`
| Member | What it does |
|---|---|
-| `duration` | Loop length in ms, derived from `steps` if you did not give one. |
+| `duration` | Loop length in ms: what you set, or what the `steps` add up to. |
| `play()` | Start or resume. A no-op once a visitor has taken over. |
| `pause()` | Hold where it is. |
| `stepAside()` | Hand the mock to the visitor: stop for good, hide the cursor. |
diff --git a/src/content/docs/getting-started.md b/src/content/docs/getting-started.md
index b136bcf..c66874c 100644
--- a/src/content/docs/getting-started.md
+++ b/src/content/docs/getting-started.md
@@ -6,7 +6,7 @@
npm i @logfox/busker
```
-Busker has no dependencies and runs in the browser. It ships types, and works with any framework or none — it only ever touches the element you hand it.
+Busker has no dependencies and runs in the browser. It ships types and ES modules, and works with any framework or none — it only ever touches the element you hand it.
## 2. Write the mock
@@ -19,13 +19,15 @@ A mock is ordinary markup. Busker needs three things from it, all `data-` attrib
-
-
Nothing is on fire.
-
+
+
+
Nothing is on fire.
+
-
-
Two things are on fire.
-
+
+
Two things are on fire.
+
+
@@ -33,6 +35,14 @@ A mock is ordinary markup. Busker needs three things from it, all `data-` attrib
`[data-scene]` marks each page of the mock, `[data-nav-item]` marks the nav, and `[data-cursor]` is the pointer busker moves. [Markup](./markup.md) has the full contract.
+Scenes are stacked on top of each other so they can cross-fade, which means they take no space of their own. Give the element that holds them a height, and the mock will keep it no matter which scene is up:
+
+```css
+.app__screen {
+ height: 20rem;
+}
+```
+
## 3. Put on a show
```typescript title="main.ts"
@@ -43,7 +53,7 @@ const root = document.querySelector('.app');
if (root) {
busk(root, {
- scene: 'home',
+ initialScene: 'home',
steps: [
{click: '[data-nav-item="alerts"]', wait: 1200},
{click: '[data-nav-item="home"]', wait: 2000},
diff --git a/src/content/docs/markup.md b/src/content/docs/markup.md
index 8199ce5..38baab9 100644
--- a/src/content/docs/markup.md
+++ b/src/content/docs/markup.md
@@ -37,14 +37,7 @@ The root is the element you pass to `busk()`. Two things follow from that:
## Scenes
-A scene is one page of the mock. Busker shows one at a time by putting `is-active` on it, and `busker.css` handles the display:
-
-```css
-.busker [data-scene] { display: none; }
-.busker [data-scene].is-active { display: block; }
-```
-
-Override those two rules if you want scenes to cross-fade, slide, or stack.
+A scene is one page of the mock. Busker shows one at a time by putting `is-active` on it, and `busker.css` does the rest: scenes are stacked and cross-fade into each other, so the mock never changes height and nothing pops. The element holding them needs a height of its own — see [Styling](./styling.md#scenes).
Scenes never change on a timer. They change because something was clicked — by the cursor or by a visitor — and a [route](./routines.md#routes) said so. That is the whole point: there is one cause, so there is nothing to synchronise.
diff --git a/src/content/docs/routines.md b/src/content/docs/routines.md
index c74efe8..ee2815a 100644
--- a/src/content/docs/routines.md
+++ b/src/content/docs/routines.md
@@ -4,7 +4,7 @@ A routine is a list of places the cursor goes. At each stop it presses the eleme
```typescript
busk(root, {
- scene: 'home',
+ initialScene: 'home',
start: [0.55, 0.25],
steps: [
{click: '[data-nav-item="alerts"]', wait: 900, moveFor: 550},
diff --git a/src/content/docs/styling.md b/src/content/docs/styling.md
index 2471eaf..bce7b96 100644
--- a/src/content/docs/styling.md
+++ b/src/content/docs/styling.md
@@ -18,6 +18,8 @@ Set these on the root, or anywhere above it:
| `--busker-cursor-size` | `0.95rem` | Diameter of the dot. |
| `--busker-cursor-fill` | `rgb(0 0 0 / 0.42)` | The dot at rest. |
| `--busker-cursor-edge` | `#fff` | The ring around the dot that keeps it visible on dark UI. |
+| `--busker-cursor-shadow` | `rgb(0 0 0 / 0.3)` | The dot's drop shadow. |
+| `--busker-scene-ms` | `0.4s` | How long one scene takes to cross-fade into the next. |
```css
.my-mock {
@@ -48,29 +50,21 @@ Busker only sets `left`, `top`, and the `is-*` classes. Everything else is yours
## Scenes
-Two rules handle scene switching:
+Scenes are stacked on top of each other and cross-fade, so the one going out
+fades under the one coming in instead of popping. `visibility` waits out the
+fade on the way out, which keeps a scene nobody can see from being read aloud or
+tabbed into.
-```css
-.busker [data-scene] { display: none; }
-.busker [data-scene].is-active { display: block; }
-```
+Because they are stacked, they are out of flow, and **the element holding them
+needs a height of its own** — from a parent, a grid track, or its own
+rule. Without one it collapses and the mock looks empty. In exchange the mock
+never changes height when the scene changes.
-Override them for a cross-fade — but keep the inactive scene out of the accessibility tree and out of layout, or the mock will be twice as tall as it looks:
+Set `--busker-scene-ms` to retime the fade, or turn it into a cut:
```css
-.busker [data-scene] {
- display: grid;
- grid-area: 1 / 1;
- opacity: 0;
- pointer-events: none;
- transition: opacity 0.2s ease;
- visibility: hidden;
-}
-
-.busker [data-scene].is-active {
- opacity: 1;
- pointer-events: auto;
- visibility: visible;
+.my-mock {
+ --busker-scene-ms: 0s;
}
```
diff --git a/src/content/docs/timeline.md b/src/content/docs/timeline.md
index 01991dc..86130ac 100644
--- a/src/content/docs/timeline.md
+++ b/src/content/docs/timeline.md
@@ -12,7 +12,7 @@ busk(root, {
{target: '[data-input]', text: 'why did checkout fail?', from: 4000, until: 6200, clearAt: 7000},
],
countdowns: [
- {target: '[data-clock]', seconds: 90},
+ {target: '[data-clock]', startSeconds: 90},
],
moves: [
{to: '[data-send]', from: 6400, until: 7000, press: 7100},
@@ -31,7 +31,7 @@ Pick by asking what makes the thing on screen change.
| something was clicked | [`steps`](./routines.md) |
| time passed | `duration` and the fields below |
-`steps` and `duration` are the two ways in. Give busker `steps` and the loop length comes from the routine; give it `duration` and you are timing everything yourself. A demo that is mostly clicks with one timed flourish is usually better as clicks plus a CSS animation on the element than as a hand-timed routine.
+`steps` and `duration` are the two ways in, and a routine is one or the other — TypeScript will not let you give both. Give busker `steps` and the loop length comes from the routine; give it `duration` and you are timing everything yourself. A demo that is mostly clicks with one timed flourish is usually better as clicks plus a CSS animation on the element than as a hand-timed routine.
## toggles
@@ -60,7 +60,7 @@ Busker sets `textContent`, so point it at a `` inside your fake input rath
Ticks a `m:ss` clock down over the loop and stops at zero.
```typescript
-{target: '[data-clock]', seconds: 90}
+{target: '[data-clock]', startSeconds: 90}
```
## moves
diff --git a/src/cs-pagefind/pagefind.ts b/src/cs-pagefind/pagefind.ts
index e41299f..4f5836e 100644
--- a/src/cs-pagefind/pagefind.ts
+++ b/src/cs-pagefind/pagefind.ts
@@ -9,7 +9,7 @@
* loads the Starlight-generated index next to this shim.
*/
import * as engine from '../pagefind/pagefind.js';
-import {wrapPagefindSearch} from '../components/wrapPagefindSearch.js';
+import {wrapPagefindSearch} from '../components/wrapPagefindSearch.ts';
let warmPromise: Promise | null = null;
diff --git a/src/index.ts b/src/index.ts
index 4d3fda7..71e8bb1 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,4 +1,4 @@
-export {busk} from './busk';
+export {busk} from './busk.ts';
export type {
Busker,
Countdown,
@@ -6,7 +6,9 @@ export type {
Point,
Route,
Routine,
+ ScriptRoutine,
Step,
+ TimedRoutine,
Toggle,
Typing,
-} from './types';
+} from './types.ts';
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 223640a..11e38ee 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -5,7 +5,7 @@ import '../../busker.css';
import '../styles/splash.css';
const routineSource = `busk(root, {
- scene: 'beans',
+ initialScene: 'beans',
start: [0.55, 0.3],
steps: [
{click: '[data-cta]', wait: 900},
@@ -199,7 +199,7 @@ const routineSource = `busk(root, {
});
busk(root, {
- scene: 'beans',
+ initialScene: 'beans',
start: [0.55, 0.3],
steps: [
{click: '[data-cta]', wait: 900},
@@ -236,19 +236,28 @@ const routineSource = `busk(root, {
text-align: center;
}
+ /* Headline and lead are set the way callspec sets them, so the two docs
+ sites read as one family. */
.splash__hero h1 {
- font-size: clamp(2rem, 5vw, 3.1rem);
- line-height: 1.1;
+ font-family: var(--cs-font-heading);
+ font-size: clamp(2.35rem, 6.2vw, 4rem);
+ font-weight: 600;
+ letter-spacing: -0.045em;
+ line-height: 1.18;
margin: 0 0 1rem;
text-wrap: balance;
}
.splash__lead {
color: var(--sl-color-gray-2);
- font-size: 1.05rem;
- line-height: 1.65;
+ font-family: var(--cs-font-heading);
+ font-size: clamp(1.05rem, 2.1vw, 1.35rem);
+ font-weight: 550;
+ letter-spacing: -0.025em;
+ line-height: 1.5;
margin-inline: auto;
- max-width: 42rem;
+ max-width: 48rem;
+ text-wrap: balance;
}
.splash__actions {
@@ -421,16 +430,13 @@ const routineSource = `busk(root, {
.mock__main {
overflow: hidden;
- padding: 1.4rem 1.4rem 1.6rem;
position: relative;
}
+ /* Scenes stack and cross-fade — that comes from busker.css. This is only
+ the padding, which has to live on the scene now that it fills the main. */
.mock__scene {
- display: none;
- }
-
- .mock__scene.is-active {
- display: block;
+ padding: 1.4rem 1.4rem 1.6rem;
}
/* hero */
diff --git a/src/timeline.spec.ts b/src/timeline.spec.ts
index 709cb2b..10e7058 100644
--- a/src/timeline.spec.ts
+++ b/src/timeline.spec.ts
@@ -7,7 +7,7 @@ import {
moveIndexAt,
positionAt,
typedText,
-} from './timeline';
+} from './timeline.ts';
test('compile: beats run back to back, so nothing has to be timed by hand', (assert) => {
@@ -124,7 +124,7 @@ test('typedText: clearAt wipes it, e.g. the message was sent', (assert) => {
test('countdownText: m:ss, zero padded, and it stops at zero', (assert) => {
- const countdown = {target: '#a', seconds: 125};
+ const countdown = {target: '#a', startSeconds: 125};
assert.equal(countdownText(countdown, 0), '2:05');
assert.equal(countdownText(countdown, 60_000), '1:05');
diff --git a/src/timeline.ts b/src/timeline.ts
index cc77152..516cd2d 100644
--- a/src/timeline.ts
+++ b/src/timeline.ts
@@ -1,4 +1,4 @@
-import type {Countdown, Move, Point, Step, Toggle, Typing} from './types';
+import type {Countdown, Move, Point, Step, Toggle, Typing} from './types.ts';
/** How long a glide takes when a step does not say. */
const DEFAULT_MOVE_MS = 600;
@@ -21,16 +21,22 @@ export function compile(steps: Step[]): {moves: Move[]; duration: number} {
let t = 0;
const moves = steps.map((step): Move => {
- const presses = 'click' in step;
const from = t + (step.wait ?? 0);
const until = from + (step.moveFor ?? DEFAULT_MOVE_MS);
- const press = presses ? until + (step.dwell ?? DEFAULT_DWELL_MS) : undefined;
+
+ if (step.click === undefined) {
+ t = until;
+
+ return {to: step.to, from, until};
+ }
+
+ const press = until + (step.dwell ?? DEFAULT_DWELL_MS);
// A beat runs to the click, which lands as the press lifts — not to the
// press itself. Otherwise the next beat starts mid-stroke.
- t = press === undefined ? until : press + PRESS_MS;
+ t = press + PRESS_MS;
- return {to: presses ? step.click : step.to, from, until, press};
+ return {to: step.click, from, until, press};
});
const last = moves[moves.length - 1];
@@ -74,7 +80,7 @@ export function typedText(typing: Typing, t: number): string {
/** The clock at `t`, as `m:ss`. Stops at zero rather than going negative. */
export function countdownText(countdown: Countdown, t: number): string {
- const left = Math.max(0, countdown.seconds - Math.floor(t / 1000));
+ const left = Math.max(0, countdown.startSeconds - Math.floor(t / 1000));
return `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
}
diff --git a/src/types.ts b/src/types.ts
index 30db2a2..25662e7 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -16,12 +16,17 @@ export type Step =
moveFor?: number;
/** How long the cursor hovers before pressing. Default 250. */
dwell?: number;
+ /** A press goes to the thing it clicks. */
+ to?: never;
}
| {
/** Where to glide, with no press at the end. */
to: string | Point;
wait?: number;
moveFor?: number;
+ click?: never;
+ /** Nothing is pressed, so there is no hover to hold before it. */
+ dwell?: never;
};
/** A click on `click` shows scene `scene`. */
@@ -41,9 +46,8 @@ export interface Move {
/** When the cursor arrives. */
until: number;
/**
- * Optional moment the cursor goes down. In a click-driven show the real
- * click lands as the stroke lifts, a press later. Timeline moves never
- * really click, so for them this is the animation and nothing more.
+ * Optional moment to animate a press. A `TimedRoutine` never really clicks
+ * — whatever the press appears to do, drive it with a `Toggle`.
*/
press?: number;
}
@@ -74,26 +78,18 @@ export interface Typing {
export interface Countdown {
/** Selector of the element whose text content is written. */
target: string;
- /** Value at the top of the loop. */
- seconds: number;
+ /** Value at the top of every loop. */
+ startSeconds: number;
}
-/**
- * A routine. Give it `steps` for a click-driven show, or `duration` plus any of
- * `moves` / `toggles` / `typing` / `countdowns` for a hand-timed one.
- */
-export interface Routine {
+/** What every routine has, however the cursor is driven. */
+interface CommonRoutine {
/** Scene shown at the top of every loop. */
- scene?: string;
+ initialScene?: string;
/** Where the cursor rests before the first beat. Default `[0.5, 0.5]`. */
start?: Point;
- /** Click-driven show. The loop length is derived from these. */
- steps?: Step[];
/** How clicks — the cursor's and the visitor's — change the scene. */
routes?: Route[];
- /** Loop length in ms. Required unless `steps` is given. */
- duration?: number;
- moves?: Move[];
toggles?: Toggle[];
typing?: Typing[];
countdowns?: Countdown[];
@@ -106,6 +102,30 @@ export interface Routine {
freezeAt?: number;
}
+/** A click-driven show. The loop is as long as the steps add up to. */
+export interface ScriptRoutine extends CommonRoutine {
+ steps: Step[];
+ /** The steps set the loop length. */
+ duration?: never;
+ /** The steps say where the cursor goes. */
+ moves?: never;
+}
+
+/** A hand-timed show. Nothing is really clicked; a press is animation only. */
+export interface TimedRoutine extends CommonRoutine {
+ /** Loop length in ms. */
+ duration: number;
+ moves?: Move[];
+ /** `duration` sets the loop length, so there are no steps to add up. */
+ steps?: never;
+}
+
+/**
+ * A routine is one of two things, never a mix: `steps` for a click-driven show,
+ * or `duration` for a hand-timed one.
+ */
+export type Routine = ScriptRoutine | TimedRoutine;
+
export interface Busker {
/** Loop length in ms. */
readonly duration: number;
diff --git a/tsconfig.json b/tsconfig.json
index ef29141..f9543a4 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -4,6 +4,8 @@
"moduleResolution": "node16",
"module": "Node16",
"declaration": true,
+ "allowImportingTsExtensions": true,
+ "rewriteRelativeImportExtensions": true,
"lib": ["ES2023", "DOM"],
"outDir": "dist",
"rootDir": "src",