From 0644d6d4d84362caed1e4c6003e0831054458317 Mon Sep 17 00:00:00 2001 From: logfox-agent Date: Wed, 2 Sep 2026 22:16:43 -0400 Subject: [PATCH 1/5] feat: ship ES modules and make routine shapes mutually exclusive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit busker published as CommonJS, so it could not be imported as a module in the browser without a bundler. It now builds and ships ESM. The Routine type also let you write shapes that cannot work — steps with a duration, or moves with no duration at all — and silently picked one. It is now a union of ScriptRoutine and TimedRoutine, so the compiler rejects the mix instead of the runtime guessing. Step is tightened the same way. Two fields go back to names that say what they hold: scene -> initialScene and Countdown.seconds -> startSeconds. --- README.md | 2 +- package.json | 1 + src/busk.spec.ts | 11 +++-- src/busk.ts | 12 ++--- src/components/codeBlockTitles.spec.ts | 2 +- src/components/docsSearchQuery.spec.ts | 2 +- src/components/isUsefulPagefindMatch.spec.ts | 2 +- src/components/pagefindMount.spec.ts | 2 +- src/components/searchClearRefocus.spec.ts | 2 +- src/components/searchDialog.spec.ts | 2 +- src/components/searchIdleState.spec.ts | 2 +- src/components/searchIdleState.ts | 2 +- src/components/searchKeyboardNav.spec.ts | 2 +- src/components/searchPendingDelay.spec.ts | 2 +- src/components/searchStaleResults.spec.ts | 2 +- src/components/wrapPagefindSearch.spec.ts | 2 +- src/components/wrapPagefindSearch.ts | 4 +- src/content/docs/api-reference.md | 22 ++++++--- src/content/docs/getting-started.md | 4 +- src/content/docs/routines.md | 2 +- src/content/docs/timeline.md | 6 +-- src/cs-pagefind/pagefind.ts | 2 +- src/index.ts | 6 ++- src/pages/index.astro | 4 +- src/timeline.spec.ts | 4 +- src/timeline.ts | 18 ++++--- src/types.ts | 52 ++++++++++++++------ tsconfig.json | 2 + 28 files changed, 110 insertions(+), 66 deletions(-) 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/package.json b/package.json index b720039..1dccf1e 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": { diff --git a/src/busk.spec.ts b/src/busk.spec.ts index e6afe6d..dddbd59 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); } @@ -94,7 +97,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}, diff --git a/src/busk.ts b/src/busk.ts index de400f8..725ac75 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; @@ -213,7 +213,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 +307,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..95b4452 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 @@ -43,7 +43,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/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/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..445b10e 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}, 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", From edeb6e6d9e78e36dea06c4e19620aaf7f6c82c27 Mon Sep 17 00:00:00 2001 From: logfox-agent Date: Wed, 2 Sep 2026 22:21:14 -0400 Subject: [PATCH 2/5] fix: run the test suite through the tsx ESM loader kizu hooks TypeScript in with `-r tsx/cjs`, which an ESM package never reaches, so Node was left to handle the spec files itself. Node 22 and 24 strip types natively and passed; Node 20 does not and could not load a single spec. Loading tsx with `--import` covers all three. --- package-lock.json | 2 +- package.json | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) 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 1dccf1e..3da1ba9 100644 --- a/package.json +++ b/package.json @@ -53,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", @@ -72,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" } From dff9f621aa4fa3b00f2954ec08865cbb7c9d69a3 Mon Sep 17 00:00:00 2001 From: logfox-agent Date: Wed, 2 Sep 2026 22:34:58 -0400 Subject: [PATCH 3/5] fix: keep the click ring on the button the cursor just pressed A press that changes the scene takes its own target out of layout, and the ring outlives the press on purpose. With no box left to measure, the target resolved to the root's top-left corner, so the cursor teleported off the mock and the click animation finished where nobody was looking. Selectors now remember where they last had a box, so the ring plays out on the spot that was pressed. --- src/busk.spec.ts | 45 +++++++++++++++++++++++++++++++++++++++++++-- src/busk.ts | 18 +++++++++++++----- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/src/busk.spec.ts b/src/busk.spec.ts index dddbd59..45bb078 100644 --- a/src/busk.spec.ts +++ b/src/busk.spec.ts @@ -56,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}
`; @@ -67,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; @@ -152,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 725ac75..f30efc3 100644 --- a/src/busk.ts +++ b/src/busk.ts @@ -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; From 8a00a7c3f6e0b4978d9ab7f82ca9746b224fa3cd Mon Sep 17 00:00:00 2001 From: logfox-agent Date: Wed, 2 Sep 2026 22:55:07 -0400 Subject: [PATCH 4/5] feat: cross-fade scene changes in busker.css Scene switching was display: none / block, so every consumer that wanted a mock to change screens without popping had to reimplement stacking and fading in its own stylesheet. Logfox did exactly that. Move it into busker.css: scenes stack, cross-fade over --busker-scene-ms, and drop out of the accessibility tree once the fade finishes. Because stacked scenes take no space, the element holding them needs a height of its own. In exchange the mock never changes height mid-demo. --- busker.css | 39 ++++++++++++++++++++++++++--- src/content/docs/getting-started.md | 22 +++++++++++----- src/content/docs/markup.md | 9 +------ src/content/docs/styling.md | 32 ++++++++++------------- src/pages/index.astro | 9 +++---- 5 files changed, 68 insertions(+), 43 deletions(-) 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/src/content/docs/getting-started.md b/src/content/docs/getting-started.md index 95b4452..c66874c 100644 --- a/src/content/docs/getting-started.md +++ b/src/content/docs/getting-started.md @@ -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" 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/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/pages/index.astro b/src/pages/index.astro index 445b10e..b734d5b 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -421,16 +421,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 */ From 27644c7de45d8911b7b2d003b206c8939e99a244 Mon Sep 17 00:00:00 2001 From: logfox-agent Date: Wed, 2 Sep 2026 23:01:57 -0400 Subject: [PATCH 5/5] docs: set the splash headline and lead the way callspec does Both sites already loaded the same two typefaces, but the busker splash never set the display treatment, so the headline fell back to Starlight's default weight and tracking and the lead read as body copy. Borrow callspec's scale, weight 600 headline with -0.045em tracking and a weight 550 lead, so the two docs sites look like one family. --- src/pages/index.astro | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/pages/index.astro b/src/pages/index.astro index b734d5b..11e38ee 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -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 {