diff --git a/skills/busker/SKILL.md b/skills/busker/SKILL.md index 508a2c2..7488411 100644 --- a/skills/busker/SKILL.md +++ b/skills/busker/SKILL.md @@ -9,8 +9,8 @@ disable-model-invocation: true The [README](https://raw.githubusercontent.com/logfoxai/busker/main/README.md) indexes the guides (§ Contents). Read the ones you need before changing a routine. 1. **Do not time page changes.** If a click causes it, use `steps` and let the real click do it. A scene that changes on a timer will drift the first time a duration changes. -2. **`routes` is the only list of clickable things.** Adding a route wires the scene change *and* the pointer affordance. Never hand-maintain a parallel CSS list of `cursor: pointer` selectors. -3. **Routes are only for scenes.** Modals, filters, and toggles belong in your own click handlers — busker clicked the element, your code does the rest. +2. **`routes` is the only list of clickable things.** Adding a route wires the pointer affordance (and a scene change when `scene` is set). Never hand-maintain a parallel CSS list of `cursor: pointer` selectors. +3. **Filters and modals still use your handlers.** Put them in `routes` without `scene` so they look clickable and do not trip the miss hint; busker clicks the element, your code does the rest. 4. **Put time in `wait`, not `moveFor`.** `wait` is reading time for whatever the last press opened. A slow glide reads as lag. 5. **`press` in `moves` does not click.** Hand-timed mode animates the press only. If you want a real click, it is a `step`. 6. **Never use `event.isTrusted` to tell busker's clicks from a visitor's.** Scripted clicks from any source are untrusted. Busker already tracks its own. diff --git a/src/busk.spec.ts b/src/busk.spec.ts index 45bb078..4d57fb9 100644 --- a/src/busk.spec.ts +++ b/src/busk.spec.ts @@ -231,6 +231,68 @@ test('a visitor click on nothing lights up what is clickable', (assert) => { }); +test('a route without a scene is still clickable and does not light the miss hint', (assert) => { + + document.body.innerHTML = `
+ +
+

home

+ +
+
+ +
`; + + const root = document.getElementById('root'); + + 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; + + const now = 0; + const queued: FrameRequestCallback[] = []; + + globalThis.IntersectionObserver = FakeObserver; + globalThis.requestAnimationFrame = (cb: FrameRequestCallback): number => queued.push(cb); + globalThis.cancelAnimationFrame = (): void => {}; + performance.now = (): number => now; + + busk(root, { + initialScene: 'home', + steps: [{click: '[data-nav-item="alerts"]', moveFor: 100, dwell: 0}], + routes: [ + {click: '[data-nav-item="home"]', scene: 'home'}, + {click: '[data-nav-item="alerts"]', scene: 'list'}, + {click: '[data-filter]'}, + ], + }); + + observers[0]?.fire(); + + assert.equal(root.querySelector('[data-filter]')?.classList.contains('is-interactive'), true); + + root.querySelector('[data-filter]')?.click(); + + assert.equal(root.classList.contains('is-aside'), true); + assert.equal(root.querySelector('[data-scene="home"]')?.classList.contains('is-active'), true); + assert.equal(root.querySelector('[data-nav-item="alerts"]')?.classList.contains('is-hint'), false); + +}); + test('every clickable thing looks clickable', (assert) => { const {root} = stage(routine); diff --git a/src/busk.ts b/src/busk.ts index f30efc3..99ac1a6 100644 --- a/src/busk.ts +++ b/src/busk.ts @@ -267,7 +267,7 @@ export function busk(root: HTMLElement, routine: Routine): Busker { return el !== null && root.contains(el); }); - if (hit) activate(hit.scene); + if (hit?.scene) activate(hit.scene); // The show's own clicks route the mock but must not take it away from itself. if (clickingItself) return; diff --git a/src/content/docs/api-reference.md b/src/content/docs/api-reference.md index 68a2417..b74533e 100644 --- a/src/content/docs/api-reference.md +++ b/src/content/docs/api-reference.md @@ -62,9 +62,9 @@ Beats run back to back: a step sets off `wait` after the last one finished. | Field | Type | What it does | |---|---|---| | `click` | `string` | Selector of the clickable element. | -| `scene` | `string` | `data-scene` to show when it is clicked. | +| `scene` | `string` | Optional. `data-scene` to show when it is clicked. | -Every route target also gets `is-interactive`, which is what makes it look clickable. +Every route target gets `is-interactive`, which is what makes it look clickable. Omit `scene` for filters and other controls your own handlers drive — they still count as a hit, so a visitor click does not light the miss hint. ## `Move` diff --git a/src/content/docs/routines.md b/src/content/docs/routines.md index ee2815a..ace1301 100644 --- a/src/content/docs/routines.md +++ b/src/content/docs/routines.md @@ -52,20 +52,21 @@ Because the beats are relative, you can drop a step into the middle of a routine ## Routes -A route says what a click does: +A route says a click is live. When it also names a scene, that scene comes up: ```typescript routes: [ {click: '[data-nav-item="alerts"]', scene: 'alerts'}, + {click: '[data-filter]'}, ] ``` Routes do two jobs, which is the reason they are one list: -1. **They switch scenes** — for the cursor's clicks and for a visitor's, identically. -2. **They mark what is clickable.** Every route target gets `is-interactive`, which is what gives it a pointer cursor. So the things that look clickable are exactly the things that are, with no CSS list to maintain alongside. +1. **They mark what is clickable.** Every route target gets `is-interactive`, which is what gives it a pointer cursor. So the things that look clickable are exactly the things that are, with no CSS list to maintain alongside. +2. **They switch scenes** when `scene` is set — for the cursor's clicks and for a visitor's, identically. -Anything your own handlers do — opening a modal, filtering a table, toggling a row — needs no route at all. Busker clicked the element; your code took it from there. Routes are only for the part busker owns, which is which scene is up. +Filters, modals, and toggles still belong in your own handlers. Put them in `routes` *without* a `scene` so they get the pointer and do not trip the miss hint; busker clicked the element, your code took it from there. ## Where the cursor starts diff --git a/src/content/docs/taking-over.md b/src/content/docs/taking-over.md index 40e9345..8474dcd 100644 --- a/src/content/docs/taking-over.md +++ b/src/content/docs/taking-over.md @@ -4,7 +4,7 @@ A busker plays to whoever is passing. When someone actually walks up, they stop ## What happens on a click -1. If the click hit a [route](./routines.md#routes) target, that scene comes up — the same code path the cursor's own clicks use. +1. If the click hit a [route](./routines.md#routes) target with a `scene`, that scene comes up — the same code path the cursor's own clicks use. A route without a `scene` still counts as a hit (no miss hint); your handlers do the rest. 2. The loop stops for good and the root gets `is-aside`, which hides the cursor. 3. If the click hit nothing clickable, every route target gets `is-hint` for 1.5s, so they can see what is live. diff --git a/src/types.ts b/src/types.ts index 25662e7..5ad7283 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,12 +29,16 @@ export type Step = dwell?: never; }; -/** A click on `click` shows scene `scene`. */ +/** A click on `click` is interactive; when `scene` is set, it also shows that scene. */ export interface Route { /** Selector of the clickable element. */ click: string; - /** `data-scene` value to show. */ - scene: string; + /** + * `data-scene` value to show. Omit for filters, modals, and other controls + * that your own handlers drive — the target still gets the pointer and + * counts as a hit, so a visitor click does not light the miss hint. + */ + scene?: string; } /** A cursor glide on a hand-set timeline. */