From f987546af944a1d3744492434fe4d70b2027c4ff Mon Sep 17 00:00:00 2001 From: logfox-agent Date: Sun, 6 Sep 2026 17:32:21 -0400 Subject: [PATCH] feat: allow routes without a scene for filters and modals Clickable controls that your own handlers drive still need the pointer and must not trip the miss hint. Make Route.scene optional so those targets can live in routes without forcing a fake scene switch. --- skills/busker/SKILL.md | 4 +- src/busk.spec.ts | 62 +++++++++++++++++++++++++++++++ src/busk.ts | 2 +- src/content/docs/api-reference.md | 4 +- src/content/docs/routines.md | 9 +++-- src/content/docs/taking-over.md | 2 +- src/types.ts | 10 +++-- 7 files changed, 80 insertions(+), 13 deletions(-) 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. */