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",