format: one dprint pass over the carried files
commit beb8a3a130857eb1ff202c248c3458e540d80e56 · parent 56b0416af1
· browse this tree
Ximianth, 2026-10-10 05:16:51 UTC
No semantic change intended; unit tests (348) and the four e2e scenarios report the same counts as before. This is the commit that makes the pre-commit hook pass on the full tree.
diff --git a/packages/e2e/harness.ts b/packages/e2e/harness.ts index 2a50241..abf1dfc 100644 --- a/packages/e2e/harness.ts +++ b/packages/e2e/harness.ts @@ -107,8 +107,8 @@ export class Harness { accessSync(CHROME_BIN, constants.X_OK); } catch { throw new Error( - `chrome binary missing at ${CHROME_BIN}; ` + - "install headless-shell there, or point YUKEY_E2E_CHROME at a chromium binary", + `chrome binary missing at ${CHROME_BIN}; ` + + "install headless-shell there, or point YUKEY_E2E_CHROME at a chromium binary", ); } diff --git a/packages/e2e/scenarios/ip-drive.ts b/packages/e2e/scenarios/ip-drive.ts index e3e93db..cfb64b0 100644 --- a/packages/e2e/scenarios/ip-drive.ts +++ b/packages/e2e/scenarios/ip-drive.ts @@ -72,8 +72,7 @@ async function run(ctx: ScenarioContext): Promise<void> { return JSON.parse(box as string); }; - const COL = - "document.querySelector('sw-gallery').shadowRoot.querySelector('swg-infopop-column').shadowRoot"; + const COL = "document.querySelector('sw-gallery').shadowRoot.querySelector('swg-infopop-column').shadowRoot"; await openGallery( ctx, @@ -272,8 +271,7 @@ async function run(ctx: ScenarioContext): Promise<void> { check("pointerdown-closes", s.mode === "closed", JSON.stringify(s.mode)); // --- edges: clamping and the flip --- - const EDGES = - "document.querySelector('sw-gallery').shadowRoot.querySelector('swg-infopop-edges').shadowRoot"; + const EDGES = "document.querySelector('sw-gallery').shadowRoot.querySelector('swg-infopop-edges').shadowRoot"; pt = await centerOf(`${EDGES}.querySelectorAll('sw-button')[1]`); // far right await cdp.mouseMove(pt.x, pt.y); await sleep(500); diff --git a/packages/e2e/scenarios/lc-drive.ts b/packages/e2e/scenarios/lc-drive.ts index fc4cbf4..1cd0c1c 100644 --- a/packages/e2e/scenarios/lc-drive.ts +++ b/packages/e2e/scenarios/lc-drive.ts @@ -118,8 +118,7 @@ async function run(ctx: ScenarioContext): Promise<void> { // Determinism: stop both demos' own churn timers; the scenario churns by hand. await cdp.js(`clearInterval(${demo(0)}.timer); clearInterval(${demo(1)}.timer)`); - const st = async (i = 0): Promise<LcState> => - JSON.parse((await cdp.js(stateJs(demo(i)))) as string); + const st = async (i = 0): Promise<LcState> => JSON.parse((await cdp.js(stateJs(demo(i)))) as string); const row = (s: LcState, key: string): LcRow | null => s.rows.find((r) => r.key === key) ?? null; // Data-side churn, like the real queue does between renders. @@ -795,10 +794,10 @@ async function run(ctx: ScenarioContext): Promise<void> { s1 = await st(1); check( "commit-flows-after-refusal", - !s1.textMode && - s1.inputValue === "" && - s1.dataIds.length === countBefore10 + 1 && - s1.log.includes("rejected draft appended"), + !s1.textMode + && s1.inputValue === "" + && s1.dataIds.length === countBefore10 + 1 + && s1.log.includes("rejected draft appended"), JSON.stringify([s1.textMode, s1.inputValue, s1.dataIds, s1.log]), ); } diff --git a/packages/e2e/scenarios/mc-drive.ts b/packages/e2e/scenarios/mc-drive.ts index 2776940..c89aa70 100644 --- a/packages/e2e/scenarios/mc-drive.ts +++ b/packages/e2e/scenarios/mc-drive.ts @@ -335,7 +335,7 @@ async function run(ctx: ScenarioContext): Promise<void> { // Click the beacon row (a plain activatable row: earlier Enter and Space // left its counter at 2, so a working click reads 3). - let pt = await portCenter('[data-mc-row="beacon"]'); + let pt = await portCenter("[data-mc-row=\"beacon\"]"); await cdp.click(pt.x, pt.y); s = await st(); port = s.columns[0] as McColumn; @@ -344,7 +344,7 @@ async function run(ctx: ScenarioContext): Promise<void> { // Click the surviving crate's aux ×: the synthesis carries the aux position, // so the click removes exactly as Enter-on-aux does. - pt = await portCenter('[data-mc-row="crate-cells"] [data-mc-aux]'); + pt = await portCenter("[data-mc-row=\"crate-cells\"] [data-mc-aux]"); await cdp.click(pt.x, pt.y); s = await st(); port = s.columns[0] as McColumn; diff --git a/packages/e2e/scenarios/sw-drive.ts b/packages/e2e/scenarios/sw-drive.ts index 0471c0f..8ede259 100644 --- a/packages/e2e/scenarios/sw-drive.ts +++ b/packages/e2e/scenarios/sw-drive.ts @@ -157,8 +157,7 @@ async function run(ctx: ScenarioContext): Promise<void> { ); // Mouse click on the dock button (trusted click via CDP). - const SA_ROOT = - 'document.querySelector("sw-gallery").shadowRoot.querySelector("swg-button-standalone")'; + const SA_ROOT = "document.querySelector(\"sw-gallery\").shadowRoot.querySelector(\"swg-button-standalone\")"; let pt = await centerOf( `[...${SA_ROOT}.shadowRoot.querySelectorAll("sw-button")].find(x => x.label === "Dock at station")`, ); @@ -223,8 +222,7 @@ async function run(ctx: ScenarioContext): Promise<void> { await cdp.key("ArrowDown"); s = await st(); check("disabled-row-focusable", ops(s).rowFocused === "purge", JSON.stringify(ops(s))); - const MAIN_SCROLL = - "document.querySelector('sw-gallery').shadowRoot.querySelector('main').scrollTop"; + const MAIN_SCROLL = "document.querySelector('sw-gallery').shadowRoot.querySelector('main').scrollTop"; const before = await cdp.js(MAIN_SCROLL); await cdp.key(" "); s = await st(); @@ -447,8 +445,9 @@ async function run(ctx: ScenarioContext): Promise<void> { }; const flashCleared = async (): Promise<boolean> => { for (let i = 0; i < 30; i++) { - if ((await cdp.js("!window.__flash.el.classList.contains('sw-pressed')")) === true) + if ((await cdp.js("!window.__flash.el.classList.contains('sw-pressed')")) === true) { return true; + } await sleep(20); } return false; @@ -488,8 +487,9 @@ async function run(ctx: ScenarioContext): Promise<void> { for (let i = 0; i < 30; i++) { if ( (await cdp.js(`(${SA_BUTTON("Dock at station")}).classList.contains('sw-pressed')`)) !== true - ) + ) { break; + } await sleep(20); } @@ -544,8 +544,9 @@ async function run(ctx: ScenarioContext): Promise<void> { (await cdp.js( "document.readyState === 'complete' && !!document.querySelector('sw-gallery')?.shadowRoot?.querySelector('swg-megacolumn-demo')", )) === true - ) + ) { break; + } await sleep(100); } landed = false; diff --git a/packages/yukey/src/adopt-into-root.ts b/packages/yukey/src/adopt-into-root.ts index 2d05745..2ad1ec9 100644 --- a/packages/yukey/src/adopt-into-root.ts +++ b/packages/yukey/src/adopt-into-root.ts @@ -13,12 +13,14 @@ import type { CSSResult } from "lit"; export function adoptIntoRoot(root: Node, styles: CSSResult, marker: string): void { - if (!(root instanceof ShadowRoot || root instanceof Document)) + if (!(root instanceof ShadowRoot || root instanceof Document)) { throw new Error(`adopt-into-root (${marker}): not inside any document or shadow root`); + } const sheet = styles.styleSheet; if (sheet !== undefined) { - if (!root.adoptedStyleSheets.includes(sheet)) + if (!root.adoptedStyleSheets.includes(sheet)) { root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet]; + } return; } const holder = root instanceof Document ? root.head : root; diff --git a/packages/yukey/src/focus-stack.ts b/packages/yukey/src/focus-stack.ts index 5f2fcc2..e768222 100644 --- a/packages/yukey/src/focus-stack.ts +++ b/packages/yukey/src/focus-stack.ts @@ -119,8 +119,9 @@ export class FocusStack { } register(surface: FocusSurface): void { - if (this.#surfaces.has(surface.id)) + if (this.#surfaces.has(surface.id)) { throw new Error(`focus-stack: surface "${surface.id}" is already registered`); + } this.#surfaces.set(surface.id, surface); } @@ -137,8 +138,9 @@ export class FocusStack { const containing: { id: string; root: HTMLElement }[] = []; for (const surface of this.#surfaces.values()) { const root = this.#liveRoot(surface.id); - if (root !== null && composedContains(root, active)) + if (root !== null && composedContains(root, active)) { containing.push({ id: surface.id, root }); + } } // Surfaces containing one focus point form a containment chain, so this // pairwise comparator is a total order on them. @@ -149,8 +151,9 @@ export class FocusStack { // A user-opened surface is about to seat focus: record where focus came from. // Call while focus still sits at the origin (before the seat moves it). entered(id: string): void { - if (!this.#surfaces.has(id)) + if (!this.#surfaces.has(id)) { throw new Error(`focus-stack: entered("${id}") but no such surface is registered`); + } const top = this.#entries[this.#entries.length - 1]; if (top?.entered === id) return; // replace-not-push: the journey origin holds this.#entries = this.#entries.filter((e) => e.entered !== id); // one entry per id @@ -186,10 +189,11 @@ export class FocusStack { if (this.#focusSurface(candidate)) return; } const fallback = this.#fallback?.() ?? null; - if (fallback === null) + if (fallback === null) { throw new Error( `focus-stack: "${id}" closed holding focus with no live origin and no fallback`, ); + } fallback.focus(); } diff --git a/packages/yukey/src/gallery/sw-gallery.ts b/packages/yukey/src/gallery/sw-gallery.ts index af62b8a..8e3bbb8 100644 --- a/packages/yukey/src/gallery/sw-gallery.ts +++ b/packages/yukey/src/gallery/sw-gallery.ts @@ -89,7 +89,8 @@ export class SwGallery extends LitElement { } `; - @state() private currentId = ""; + @state() + private currentId = ""; // Hash -> selection. Bound once (constructor, not connectedCallback) because the // shell lives for the page's whole life; there's nothing to unbind. Tab is @@ -126,11 +127,14 @@ export class SwGallery extends LitElement { return html` <nav> <div class="head">starweave gallery</div> - ${entries.map( - (e) => html`<a href=${`#${e.id}`} class=${e.id === this.currentId ? "current" : ""} + ${ + entries.map( + (e) => + html`<a href=${`#${e.id}`} class=${e.id === this.currentId ? "current" : ""} >${e.title}</a >`, - )} + ) + } </nav> <main>${current ? this.renderEntry(current) : this.renderIndex()}</main> <sw-infopop></sw-infopop> @@ -141,12 +145,15 @@ export class SwGallery extends LitElement { return html` <h1>${entry.title}</h1> <div class="description">${entry.description}</div> - ${entry.variants.map( - (v) => html`<div class="variant"> + ${ + entry.variants.map( + (v) => + html`<div class="variant"> <div class="caption">${v.caption}</div> <div class="stage ${v.wide ? "wide" : ""}">${v.render()}</div> </div>`, - )} + ) + } `; } diff --git a/packages/yukey/src/infopop-place.test.ts b/packages/yukey/src/infopop-place.test.ts index 51ad68c..c574383 100644 --- a/packages/yukey/src/infopop-place.test.ts +++ b/packages/yukey/src/infopop-place.test.ts @@ -1,14 +1,7 @@ // Tests for the pure placement rule (infopop-place.ts). Plain node env: geometry in, geometry out. import { describe, expect, it } from "vitest"; -import { - ANCHOR_GAP, - type Placement, - placeInfopop, - type Rect, - type Size, - VIEWPORT_MARGIN, -} from "./infopop-place.js"; +import { ANCHOR_GAP, placeInfopop, type Placement, type Rect, type Size, VIEWPORT_MARGIN } from "./infopop-place.js"; const viewport: Size = { w: 1000, h: 800 }; const popup: Size = { w: 200, h: 60 }; @@ -19,10 +12,10 @@ function anchorAt(x: number, y: number, w = 100, h = 24): Rect { function insideViewport(p: Placement): boolean { return ( - p.x >= VIEWPORT_MARGIN && - p.y >= VIEWPORT_MARGIN && - p.x + popup.w <= viewport.w - VIEWPORT_MARGIN && - p.y + popup.h <= viewport.h - VIEWPORT_MARGIN + p.x >= VIEWPORT_MARGIN + && p.y >= VIEWPORT_MARGIN + && p.x + popup.w <= viewport.w - VIEWPORT_MARGIN + && p.y + popup.h <= viewport.h - VIEWPORT_MARGIN ); } diff --git a/packages/yukey/src/megacolumn-controller.test.ts b/packages/yukey/src/megacolumn-controller.test.ts index d8155d0..86c1853 100644 --- a/packages/yukey/src/megacolumn-controller.test.ts +++ b/packages/yukey/src/megacolumn-controller.test.ts @@ -144,7 +144,7 @@ describe("closed rows (aria-hidden)", () => { expect(ctl.nav.focusedId).toBe("c"); }); - it('aria-hidden="false" does NOT close a row: matched by value, never presence', () => { + it("aria-hidden=\"false\" does NOT close a row: matched by value, never presence", () => { // aria-hidden="false" is known to behave inconsistently in browsers; a // presence check would silently read it as hidden and make a visible row // unreachable. The scan matches the value "true" only. @@ -538,8 +538,7 @@ describe("scrolling the focused row into view", () => { afterEach(() => { scrolls.mockRestore(); }); - const scrolledRows = (): unknown[] => - scrolls.mock.contexts.map((el) => (el as HTMLElement).dataset?.mcRow); + const scrolledRows = (): unknown[] => scrolls.mock.contexts.map((el) => (el as HTMLElement).dataset?.mcRow); it("scrolls the newly focused row on arrow movement, nearest-edge and instant", () => { const { key } = makeRig(threeRows); @@ -868,7 +867,7 @@ describe("the megacolumn-focus-request event (programmatic focus moves)", () => it("moves logical focus to the named row and renders the move", () => { const { ctl, root, container } = makeRig(threeRows); - const rowC = root.querySelector<HTMLElement>('[data-mc-row="c"]'); + const rowC = root.querySelector<HTMLElement>("[data-mc-row=\"c\"]"); if (rowC === null) throw new Error("unreachable: rig just rendered row c"); const e = request(rowC, "c"); expect(ctl.nav.focusedId).toBe("c"); @@ -1277,13 +1276,13 @@ describe("column-scoped hotkey markings (data-mc-hotkey)", () => { makeRig(` <div data-mc-row="a" data-mc-hotkey="d">a</div> <div data-mc-row="b" data-mc-hotkey="d">b</div> - `), + `) ).toThrow(/duplicate hotkey marking "d".*"a".*"b"/); expect(() => makeRig(` <div data-mc-row="a" data-mc-hotkey="d">a</div> <div data-mc-row="b" data-mc-hotkey="D">b</div> - `), + `) ).toThrow(/duplicate hotkey marking/); }); @@ -1297,16 +1296,16 @@ describe("column-scoped hotkey markings (data-mc-hotkey)", () => { }); it("throws on a marking outside any row", () => { - expect(() => - makeRig(`<span data-mc-hotkey="d">stray</span><div data-mc-row="a">a</div>`), - ).toThrow(/outside any row/); + expect(() => makeRig(`<span data-mc-hotkey="d">stray</span><div data-mc-row="a">a</div>`)).toThrow( + /outside any row/, + ); }); it("throws on a marking on an aux control (x/Delete are the aux norms)", () => { expect(() => makeRig(` <div data-mc-row="a"><span data-mc-main>m</span><span data-mc-aux data-mc-hotkey="j">x</span></div> - `), + `) ).toThrow(/aux/); }); }); diff --git a/packages/yukey/src/megacolumn-controller.ts b/packages/yukey/src/megacolumn-controller.ts index 6b578cb..0b7ee16 100644 --- a/packages/yukey/src/megacolumn-controller.ts +++ b/packages/yukey/src/megacolumn-controller.ts @@ -201,7 +201,7 @@ type Host = ReactiveControllerHost & { renderRoot: DocumentFragment | HTMLElemen // - Match by VALUE, never by attribute presence: aria-hidden="false" behaves // inconsistently in browsers and must never read as hidden here. function closedToSight(el: HTMLElement): boolean { - return el.closest('[aria-hidden="true"]') !== null; + return el.closest("[aria-hidden=\"true\"]") !== null; } export class MegacolumnController implements ReactiveController { @@ -275,14 +275,16 @@ export class MegacolumnController implements ReactiveController { // The container must stay the only tab stop: a stray natively-focusable // element inside a row would fracture the focus model. Widgets that truly // need real focus must be marked data-mc-input (text entry) instead. - for (const candidate of el.querySelectorAll( - "button, select, textarea, input, a[href], [tabindex]", - )) { + for ( + const candidate of el.querySelectorAll( + "button, select, textarea, input, a[href], [tabindex]", + ) + ) { if (candidate.matches("[data-mc-input]")) continue; if (candidate instanceof HTMLElement && candidate.tabIndex === -1) continue; throw new Error( - `megacolumn: row "${id}" contains a native tab stop (${candidate.tagName.toLowerCase()}); ` + - "give it tabindex=-1 or mark it data-mc-input", + `megacolumn: row "${id}" contains a native tab stop (${candidate.tagName.toLowerCase()}); ` + + "give it tabindex=-1 or mark it data-mc-input", ); } // aria-activedescendant needs a referencable id; derive one from the row id. @@ -349,25 +351,28 @@ export class MegacolumnController implements ReactiveController { for (const el of container.querySelectorAll<HTMLElement>("[data-mc-hotkey]")) { if (closedToSight(el)) continue; const marking = el.dataset.mcHotkey ?? ""; - if (!/^[a-z0-9]$/i.test(marking)) + if (!/^[a-z0-9]$/i.test(marking)) { throw new Error( `megacolumn: hotkey marking "${marking}" must be a single [a-z0-9] character`, ); + } const char = marking.toLowerCase(); const row = el.closest<HTMLElement>("[data-mc-row]"); - if (row === null) + if (row === null) { throw new Error(`megacolumn: hotkey marking "${marking}" is outside any row`); - if (el.closest("[data-mc-aux]") !== null) + } + if (el.closest("[data-mc-aux]") !== null) { throw new Error( - `megacolumn: row "${row.dataset.mcRow}" marks its aux control with "${marking}"; ` + - "aux controls answer to x/Delete already", + `megacolumn: row "${row.dataset.mcRow}" marks its aux control with "${marking}"; ` + + "aux controls answer to x/Delete already", ); + } const prior = this.#hotkeys.get(char); if (prior !== undefined) { const priorRow = prior.closest<HTMLElement>("[data-mc-row]"); throw new Error( - `megacolumn: duplicate hotkey marking "${char}" on rows ` + - `"${priorRow?.dataset.mcRow}" and "${row.dataset.mcRow}"`, + `megacolumn: duplicate hotkey marking "${char}" on rows ` + + `"${priorRow?.dataset.mcRow}" and "${row.dataset.mcRow}"`, ); } this.#hotkeys.set(char, el); @@ -673,8 +678,9 @@ export class MegacolumnController implements ReactiveController { private controlElement(row: HTMLElement, position: RowPosition): HTMLElement { if (position === "aux") { const aux = row.querySelector<HTMLElement>("[data-mc-aux]"); - if (aux === null) + if (aux === null) { throw new Error(`megacolumn: row "${row.dataset.mcRow}" lost its [data-mc-aux]`); + } return aux; } return row.querySelector<HTMLElement>("[data-mc-main]") ?? row; diff --git a/packages/yukey/src/megacolumn-nav.ts b/packages/yukey/src/megacolumn-nav.ts index 0fc7dfd..ed4805f 100644 --- a/packages/yukey/src/megacolumn-nav.ts +++ b/packages/yukey/src/megacolumn-nav.ts @@ -130,8 +130,9 @@ export class MegacolumnNav { const row = this.rowById(id); if (row === undefined) throw new Error(`focusRow: unknown row id: ${id}`); if (!isFocusable(row)) throw new Error(`focusRow: row is not focusable: ${id}`); - if (position === "aux" && row.hasAux !== true) + if (position === "aux" && row.hasAux !== true) { throw new Error(`focusRow: row has no aux control: ${id}`); + } this.#focusedId = id; this.#position = position; this.#textMode = false; @@ -177,13 +178,15 @@ export class MegacolumnNav { // and letting them scroll the page would be surprising -- but nothing moves. // Other keys pass: there is no row widget to forward to. if (this.#focusedId === null) { - if (key === "ArrowDown" || key === "ArrowUp" || key === "Home" || key === "End") + if (key === "ArrowDown" || key === "ArrowUp" || key === "Home" || key === "End") { return { kind: "nav" }; + } return { kind: "pass" }; } const row = this.rowById(this.#focusedId); - if (row === undefined) + if (row === undefined) { throw new Error(`focused row vanished without setRows: ${this.#focusedId}`); + } switch (key) { case "ArrowDown": diff --git a/packages/yukey/src/megacolumn.gallery.ts b/packages/yukey/src/megacolumn.gallery.ts index 506631d..4db74d3 100644 --- a/packages/yukey/src/megacolumn.gallery.ts +++ b/packages/yukey/src/megacolumn.gallery.ts @@ -8,11 +8,7 @@ import { css, html, LitElement, type TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import type { GalleryEntry } from "./gallery/entry.js"; -import { - MegacolumnController, - type MegacolumnKeyDetail, - megacolumnStyles, -} from "./megacolumn-controller.js"; +import { MegacolumnController, type MegacolumnKeyDetail, megacolumnStyles } from "./megacolumn-controller.js"; import { flashPress } from "./press-flash.js"; interface CargoItem { @@ -141,20 +137,27 @@ export class SwgMegacolumnDemo extends LitElement { // biome-ignore lint/correctness/noUnusedPrivateClassMembers: wires itself via addController private mc = new MegacolumnController(this); - @property() label = "megacolumn"; + @property() + label = "megacolumn"; // The churn demo only runs when asked: two identical always-churning columns // side by side would be visual noise. - @property({ type: Boolean }) churn = false; + @property({ type: Boolean }) + churn = false; - @state() private beacons = 0; - @state() private power = 40; - @state() private cargo: CargoItem[] = [ + @state() + private beacons = 0; + @state() + private power = 40; + @state() + private cargo: CargoItem[] = [ { id: "crate-ore", label: "ore crate" }, { id: "crate-struts", label: "strut bundle" }, { id: "crate-cells", label: "fuel cells" }, ]; - @state() private tasks: TaskItem[] = []; - @state() private lastAction = ""; + @state() + private tasks: TaskItem[] = []; + @state() + private lastAction = ""; private taskSeq = 0; private timer: number | undefined; @@ -225,8 +228,7 @@ export class SwgMegacolumnDemo extends LitElement { // The two-control norm: x/Delete remove from anywhere in the row (the // hotkey guess the guidelines reward); Enter/Space only remove from the // aux control -- on main they inspect (activation must not be destructive). - const remove = - detail.key === "x" || detail.key === "Delete" || (activate && detail.position === "aux"); + const remove = detail.key === "x" || detail.key === "Delete" || (activate && detail.position === "aux"); if (remove) { this.cargo = this.cargo.filter((c) => c.id !== cargoItem.id); this.lastAction = `${cargoItem.label} jettisoned`; @@ -291,17 +293,20 @@ export class SwgMegacolumnDemo extends LitElement { </div> <div class="section">cargo manifest</div> - ${this.cargo.map( - // Cargo rows carry no hotkey markings on purpose: they churn, and a - // letter that vanishes with its row is a broken affordance. Letters - // mark landmarks; lists are for arrows. - (item) => html` + ${ + this.cargo.map( + // Cargo rows carry no hotkey markings on purpose: they churn, and a + // letter that vanishes with its row is a broken affordance. Letters + // mark landmarks; lists are for arrows. + (item) => + html` <div data-mc-row=${item.id} data-info="a crate in the hold; Enter inspects it"> <span class="grow" data-mc-main>${item.label}</span> <span data-mc-aux title="jettison" data-info="jettisons this crate (x or Delete from anywhere in the row)">×</span> </div> `, - )} + ) + } <div class="section">comms</div> <div data-mc-row="hail" data-mc-hotkey="h"> @@ -327,15 +332,17 @@ export class SwgMegacolumnDemo extends LitElement { </div> ${ - this.churn - ? html` + this.churn + ? html` <div class="section">task queue (live)</div> - ${this.tasks.map( - (t) => html`<div data-mc-row=${t.id}><span class="grow">${t.label}</span></div>`, - )} - ` - : "" + ${ + this.tasks.map( + (t) => html`<div data-mc-row=${t.id}><span class="grow">${t.label}</span></div>`, + ) } + ` + : "" + } </div> <div class="log">${this.lastAction}</div> `; @@ -423,7 +430,8 @@ export class SwgMegacolumnScrollDemo extends LitElement { // biome-ignore lint/correctness/noUnusedPrivateClassMembers: wires itself via addController private mc = new MegacolumnController(this); - @state() private lastAction = ""; + @state() + private lastAction = ""; private onMegacolumnKey(e: Event): void { const { detail } = e as CustomEvent<MegacolumnKeyDetail>; @@ -441,8 +449,10 @@ export class SwgMegacolumnScrollDemo extends LitElement { aria-label="relay bank" @megacolumn-key=${this.onMegacolumnKey} > - ${relayIds.map( - (nn) => html` + ${ + relayIds.map( + (nn) => + html` <div data-mc-row=${`relay-${nn}`} data-mc-hotkey=${ifDefined(relayHotkeys.get(nn))} @@ -451,7 +461,8 @@ export class SwgMegacolumnScrollDemo extends LitElement { <span>${relayLabel(nn)}</span> </div> `, - )} + ) + } </div> <div class="log">${this.lastAction}</div> `; @@ -461,28 +472,27 @@ export class SwgMegacolumnScrollDemo extends LitElement { const entry: GalleryEntry = { id: "megacolumn", title: "megacolumn focus model", - description: - "The keyboard navigation machinery every megacolumn screen builds on: " + - "one real focusable per column, row focus as logical state (MegacolumnNav), " + - "rendered via aria-activedescendant and the focus-ring tokens (MegacolumnController). " + - "This demo is the reference implementation of the data-mc-* DOM contract.", + description: "The keyboard navigation machinery every megacolumn screen builds on: " + + "one real focusable per column, row focus as logical state (MegacolumnNav), " + + "rendered via aria-activedescendant and the focus-ring tokens (MegacolumnController). " + + "This demo is the reference implementation of the data-mc-* DOM contract.", variants: [ { - caption: - "Two independent columns: Tab moves between them (each column is ONE tab stop); " + - "up/down move the row focus inside, jumping the section gaps. " + - "The gold letters are hotkey jumps: b/d/r/h/e/s/v/a/g move focus straight to their row " + - "(from anywhere in the column), then Enter/Space commits -- try g from the top, or b from the bottom. " + - "Also try: Enter/Space on the action rows; left/right on the reactor slider; " + - "right-arrow onto a crate's × then Enter (or just x/Delete from anywhere in the row -- " + - "crates churn, so they get no letters: letters mark landmarks, arrows work lists); " + - "Enter on the hail row to type -- every key goes to the input, hotkeys included -- then Enter sends, Escape abandons; " + - "Home/End for the column edges. Tab away and back: the row focus is where you left it. " + - "Rows carry data-info, so i (or a lingering hover) explains any of them -- " + - "including the offline row, which stays a nav stop and a jump target (e lands there; " + - "i is how you ask why it's off) even though activation is dead there.", + caption: "Two independent columns: Tab moves between them (each column is ONE tab stop); " + + "up/down move the row focus inside, jumping the section gaps. " + + "The gold letters are hotkey jumps: b/d/r/h/e/s/v/a/g move focus straight to their row " + + "(from anywhere in the column), then Enter/Space commits -- try g from the top, or b from the bottom. " + + "Also try: Enter/Space on the action rows; left/right on the reactor slider; " + + "right-arrow onto a crate's × then Enter (or just x/Delete from anywhere in the row -- " + + "crates churn, so they get no letters: letters mark landmarks, arrows work lists); " + + "Enter on the hail row to type -- every key goes to the input, hotkeys included -- then Enter sends, Escape abandons; " + + "Home/End for the column edges. Tab away and back: the row focus is where you left it. " + + "Rows carry data-info, so i (or a lingering hover) explains any of them -- " + + "including the offline row, which stays a nav stop and a jump target (e lands there; " + + "i is how you ask why it's off) even though activation is dead there.", wide: true, - render: () => html` + render: () => + html` <div style="display:flex; gap:24px; align-items:flex-start;"> <swg-megacolumn-demo style="flex:1" label="port console"></swg-megacolumn-demo> <swg-megacolumn-demo style="flex:1" label="starboard console"></swg-megacolumn-demo> @@ -490,20 +500,19 @@ const entry: GalleryEntry = { `, }, { - caption: - "Focus memory under churn: the task queue loses its head and grows a tail every few seconds. " + - "Focus a task row and watch focus fall to the nearest surviving neighbor when the row vanishes; " + - "the rest of the column is unbothered.", + caption: "Focus memory under churn: the task queue loses its head and grows a tail every few seconds. " + + "Focus a task row and watch focus fall to the nearest surviving neighbor when the row vanishes; " + + "the rest of the column is unbothered.", render: () => html`<swg-megacolumn-demo label="ops console" churn></swg-megacolumn-demo>`, }, { caption: - "Scroll containment: 28 static relays in a 240px box -- the thing to WATCH is that the page never scrolls, only the box. " + - "Home/End slam between the edges; arrows creep across the boundary rows and the box gives way one row at a time " + - "(nearest-edge scrolling: no centering, no glide); " + - "the gold digits jump -- 1/0/2/8 land on relays 01/10/20/28, crossing most of the scroller in one keystroke, " + - "the ring arriving in view every time. " + - "Enter pings a relay; a few rows carry data-info, so i works mid-scroll.", + "Scroll containment: 28 static relays in a 240px box -- the thing to WATCH is that the page never scrolls, only the box. " + + "Home/End slam between the edges; arrows creep across the boundary rows and the box gives way one row at a time " + + "(nearest-edge scrolling: no centering, no glide); " + + "the gold digits jump -- 1/0/2/8 land on relays 01/10/20/28, crossing most of the scroller in one keystroke, " + + "the ring arriving in view every time. " + + "Enter pings a relay; a few rows carry data-info, so i works mid-scroll.", render: () => html`<swg-megacolumn-scroll-demo></swg-megacolumn-scroll-demo>`, }, ], diff --git a/packages/yukey/src/press-flash.ts b/packages/yukey/src/press-flash.ts index 08e1fd8..7d84403 100644 --- a/packages/yukey/src/press-flash.ts +++ b/packages/yukey/src/press-flash.ts @@ -20,11 +20,12 @@ export const PRESSED_CLASS = "sw-pressed"; export function pressMs(el: Element): number { const raw = getComputedStyle(el).getPropertyValue("--sw-press-ms").trim(); const m = /^(\d+(?:\.\d+)?)(m?s)$/.exec(raw); - if (m === null) + if (m === null) { throw new Error( - `press-flash: --sw-press-ms did not resolve on <${el.tagName.toLowerCase()}> ` + - `(got "${raw}"); are the design tokens installed?`, + `press-flash: --sw-press-ms did not resolve on <${el.tagName.toLowerCase()}> ` + + `(got "${raw}"); are the design tokens installed?`, ); + } const n = Number(m[1]); return m[2] === "s" ? n * 1000 : n; } diff --git a/packages/yukey/src/sw-button.gallery.ts b/packages/yukey/src/sw-button.gallery.ts index d03fd7a..fae6f8a 100644 --- a/packages/yukey/src/sw-button.gallery.ts +++ b/packages/yukey/src/sw-button.gallery.ts @@ -5,11 +5,7 @@ import { css, html, LitElement, type TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import type { GalleryEntry } from "./gallery/entry.js"; -import { - MegacolumnController, - type MegacolumnKeyDetail, - megacolumnStyles, -} from "./megacolumn-controller.js"; +import { MegacolumnController, type MegacolumnKeyDetail, megacolumnStyles } from "./megacolumn-controller.js"; import "./sw-button.js"; import { flashPress } from "./press-flash.js"; import type { SwInfoDetail } from "./sw-info.js"; @@ -52,8 +48,10 @@ export class SwgButtonStandalone extends LitElement { `, ]; - @state() private last = ""; - @state() private lastWasInfo = false; + @state() + private last = ""; + @state() + private lastWasInfo = false; private act(what: string): void { this.last = what; @@ -137,15 +135,21 @@ export class SwgButtonColumn extends LitElement { // biome-ignore lint/correctness/noUnusedPrivateClassMembers: wires itself via addController private mc = new MegacolumnController(this); - @property() label = "console"; + @property() + label = "console"; // Hotkeys are per-instance so the scoping demo can give two columns // overlapping letters without tripping the per-column duplicate throw. - @property() dockKey = "d"; - @property() beaconKey = "b"; + @property() + dockKey = "d"; + @property() + beaconKey = "b"; - @state() private cargo = ["ore crate", "strut bundle"]; - @state() private last = ""; - @state() private lastWasInfo = false; + @state() + private cargo = ["ore crate", "strut bundle"]; + @state() + private last = ""; + @state() + private lastWasInfo = false; private act(what: string): void { this.last = what; @@ -205,8 +209,10 @@ export class SwgButtonColumn extends LitElement { @click=${() => this.act("purged (should never happen)")} ></sw-button> </div> - ${this.cargo.map( - (item) => html` + ${ + this.cargo.map( + (item) => + html` <div data-mc-row=${item}> <span class="grow" data-mc-main>${item}</span> <span data-mc-aux> @@ -214,14 +220,15 @@ export class SwgButtonColumn extends LitElement { label="×" info="jettisons this crate (x or Delete from anywhere in the row)" @click=${() => { - this.cargo = this.cargo.filter((c) => c !== item); - this.act(`${item} jettisoned`); - }} + this.cargo = this.cargo.filter((c) => c !== item); + this.act(`${item} jettisoned`); + }} ></sw-button> </span> </div> `, - )} + ) + } </div> <div class="log ${this.lastWasInfo ? "info" : ""}">${this.last}</div> `; @@ -231,40 +238,37 @@ export class SwgButtonColumn extends LitElement { const entry: GalleryEntry = { id: "sw-button", title: "sw-button", - description: - "The button that makes the keyboard norms structural: hotkey marking " + - "(letter-in-label in --sw-hotkey-accent, else the 70%-offset slot, an icon for " + - "complex buttons), the i info hook (sw-info events, rendered by the shell's " + - "sw-infopop -- press i on anything here and watch), token hit targets, and " + - "dual-context behavior -- a native focusable standalone, a megacolumn-key " + - "consumer inside rows.", + description: "The button that makes the keyboard norms structural: hotkey marking " + + "(letter-in-label in --sw-hotkey-accent, else the 70%-offset slot, an icon for " + + "complex buttons), the i info hook (sw-info events, rendered by the shell's " + + "sw-infopop -- press i on anything here and watch), token hit targets, and " + + "dual-context behavior -- a native focusable standalone, a megacolumn-key " + + "consumer inside rows.", variants: [ { - caption: - "Standalone: real tab stops with native Enter/Space. All marking forms: " + - "letter-in-label (Dock's D), the 70% slot (units' 7), the complex-button icon " + - "(transfer), the active in-effect option (cruise: washed, aria-current, still " + - "clickable), and disabled (dimmed, unclickable, but i still answers while focused). " + - "A button's hotkey and i work while it holds focus; watch the log.", + caption: "Standalone: real tab stops with native Enter/Space. All marking forms: " + + "letter-in-label (Dock's D), the 70% slot (units' 7), the complex-button icon " + + "(transfer), the active in-effect option (cruise: washed, aria-current, still " + + "clickable), and disabled (dimmed, unclickable, but i still answers while focused). " + + "A button's hotkey and i work while it holds focus; watch the log.", render: () => html`<swg-button-standalone></swg-button-standalone>`, }, { - caption: - "Inside a megacolumn: one tab stop for the whole column, buttons as rows. " + - "The marked letters are jumps: d or b from any row moves focus to that button " + - "(no arrowing needed), and Enter/Space then activates -- you see where you are " + - "before committing. Try: d, then Enter; i for info on the focused control; " + - "on a crate row, x or Delete jettisons via the aux ×, or right-arrow onto it and Enter. " + - "The disabled row still focuses and answers i, but nothing activates it.", + caption: "Inside a megacolumn: one tab stop for the whole column, buttons as rows. " + + "The marked letters are jumps: d or b from any row moves focus to that button " + + "(no arrowing needed), and Enter/Space then activates -- you see where you are " + + "before committing. Try: d, then Enter; i for info on the focused control; " + + "on a crate row, x or Delete jettisons via the aux ×, or right-arrow onto it and Enter. " + + "The disabled row still focuses and answers i, but nothing activates it.", render: () => html`<swg-button-column label="ops console"></swg-button-column>`, }, { - caption: - "Hotkey scope IS the focused column: both columns mark d for dock, and d jumps only " + - "in the column that holds focus. Tab between them and try d then Enter in each; " + - "the other column never hears it.", + caption: "Hotkey scope IS the focused column: both columns mark d for dock, and d jumps only " + + "in the column that holds focus. Tab between them and try d then Enter in each; " + + "the other column never hears it.", wide: true, - render: () => html` + render: () => + html` <div style="display:flex; gap:24px; align-items:flex-start;"> <swg-button-column style="flex:1" label="port console" dockKey="d" beaconKey="b"></swg-button-column> <swg-button-column style="flex:1" label="starboard console" dockKey="d" beaconKey="c"></swg-button-column> diff --git a/packages/yukey/src/sw-button.ts b/packages/yukey/src/sw-button.ts index 75da923..54142c5 100644 --- a/packages/yukey/src/sw-button.ts +++ b/packages/yukey/src/sw-button.ts @@ -161,10 +161,14 @@ export class SwButton extends LitElement { } `; - @property() label = ""; - @property() icon = ""; - @property({ type: Boolean, reflect: true }) disabled = false; - @property({ type: Boolean }) active = false; + @property() + label = ""; + @property() + icon = ""; + @property({ type: Boolean, reflect: true }) + disabled = false; + @property({ type: Boolean }) + active = false; // The info content, mirrored synchronously onto data-info: that attribute is // the carrier contract sw-infopop's hover trigger finds elements by @@ -193,8 +197,9 @@ export class SwButton extends LitElement { return this.#hotkey; } set hotkey(v: string) { - if (v !== "" && !/^[a-z0-9]$/i.test(v)) + if (v !== "" && !/^[a-z0-9]$/i.test(v)) { throw new Error(`sw-button: hotkey "${v}" must be a single [a-z0-9] character`); + } const norm = v.toLowerCase(); this.#hotkey = norm; if (norm === "") delete this.dataset.mcHotkey; @@ -226,11 +231,12 @@ export class SwButton extends LitElement { connectedCallback(): void { super.connectedCallback(); this.#row = this.closest("[data-mc-row]"); - if (this.#row === null && this.closest("[data-mc-container]") !== null) + if (this.#row === null && this.closest("[data-mc-container]") !== null) { throw new Error( - "sw-button: inside a megacolumn container but outside any row; " + - "a loose tab stop would fracture the single-tab-stop model", + "sw-button: inside a megacolumn container but outside any row; " + + "a loose tab stop would fracture the single-tab-stop model", ); + } if (this.#row !== null) { this.#row.addEventListener("megacolumn-key", this.#onMegacolumnKey); } else { @@ -383,7 +389,9 @@ export class SwButton extends LitElement { #content(marked: number): TemplateResult { if (marked === -1) return html`${this.label}`; - return html`${this.label.slice(0, marked)}<span class="hk">${this.label[marked]}</span>${this.label.slice(marked + 1)}`; + return html`${this.label.slice(0, marked)}<span class="hk">${this.label[marked]}</span>${ + this.label.slice(marked + 1) + }`; } } diff --git a/packages/yukey/src/sw-infopop.gallery.ts b/packages/yukey/src/sw-infopop.gallery.ts index 93efd76..15e5a42 100644 --- a/packages/yukey/src/sw-infopop.gallery.ts +++ b/packages/yukey/src/sw-infopop.gallery.ts @@ -6,11 +6,7 @@ import { css, html, LitElement, type TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import type { GalleryEntry } from "./gallery/entry.js"; -import { - MegacolumnController, - type MegacolumnKeyDetail, - megacolumnStyles, -} from "./megacolumn-controller.js"; +import { MegacolumnController, type MegacolumnKeyDetail, megacolumnStyles } from "./megacolumn-controller.js"; import "./sw-button.js"; // A megacolumn where every row flavor carries info: sw-buttons answer i @@ -77,10 +73,13 @@ export class SwgInfopopColumn extends LitElement { // biome-ignore lint/correctness/noUnusedPrivateClassMembers: wires itself via addController private mc = new MegacolumnController(this); - @property() label = "info everywhere"; + @property() + label = "info everywhere"; - @state() private power = 40; - @state() private last = ""; + @state() + private power = 40; + @state() + private last = ""; private onMegacolumnKey(e: Event): void { const { key, rowId } = (e as CustomEvent<MegacolumnKeyDetail>).detail; @@ -111,8 +110,8 @@ export class SwgInfopopColumn extends LitElement { hotkey="d" info="an sw-button answers i itself; hover reads the same data-info mirror" @click=${() => { - this.last = "docked"; - }} + this.last = "docked"; + }} ></sw-button> </div> <div data-mc-row="survey" data-info="a plain text row: the megacolumn answers i for its data-info"> @@ -166,29 +165,26 @@ export class SwgInfopopEdges extends LitElement { const entry: GalleryEntry = { id: "sw-infopop", title: "sw-infopop", - description: - "The info norm's popup: keyboard i and mouse hover raise the SAME authored " + - "content (one data-info attribute per element, two triggers). Keyboard-raised " + - "popups pin until any key or click; hover popups follow the pointer and never " + - "displace a pinned one. The instance shown is the gallery shell's own popup: " + - "one per app, listening at its root.", + description: "The info norm's popup: keyboard i and mouse hover raise the SAME authored " + + "content (one data-info attribute per element, two triggers). Keyboard-raised " + + "popups pin until any key or click; hover popups follow the pointer and never " + + "displace a pinned one. The instance shown is the gallery shell's own popup: " + + "one per app, listening at its root.", variants: [ { - caption: - "Every row flavor carries info. Keyboard: focus the column, press i on each row -- " + - "the sw-button answers itself, the plain rows are answered by the megacolumn's data-info " + - "fallback, and on the probe row, right-arrow onto the × for its own story. " + - "i again on the same row dismisses (a toggle); i after moving to another row follows. " + - "Mouse: hover the same places and linger; the identical text appears. " + - "Precedence: press i, then wave the mouse over other rows -- the pinned popup holds " + - "until a key or click dismisses it. Navigation keys close the popup AND still navigate.", + caption: "Every row flavor carries info. Keyboard: focus the column, press i on each row -- " + + "the sw-button answers itself, the plain rows are answered by the megacolumn's data-info " + + "fallback, and on the probe row, right-arrow onto the × for its own story. " + + "i again on the same row dismisses (a toggle); i after moving to another row follows. " + + "Mouse: hover the same places and linger; the identical text appears. " + + "Precedence: press i, then wave the mouse over other rows -- the pinned popup holds " + + "until a key or click dismisses it. Navigation keys close the popup AND still navigate.", render: () => html`<swg-infopop-column></swg-infopop-column>`, }, { - caption: - "Edge behavior: these buttons hug the stage's sides, so the below-centered placement " + - "clamps horizontally to stay inside the viewport. Hover or focus-and-i near a viewport " + - "edge (shrink the window height to see the bottom flip: the popup jumps above the anchor).", + caption: "Edge behavior: these buttons hug the stage's sides, so the below-centered placement " + + "clamps horizontally to stay inside the viewport. Hover or focus-and-i near a viewport " + + "edge (shrink the window height to see the bottom flip: the popup jumps above the anchor).", wide: true, render: () => html`<swg-infopop-edges></swg-infopop-edges>`, }, diff --git a/packages/yukey/src/sw-infopop.ts b/packages/yukey/src/sw-infopop.ts index 8734822..d879d7d 100644 --- a/packages/yukey/src/sw-infopop.ts +++ b/packages/yukey/src/sw-infopop.ts @@ -80,7 +80,8 @@ export class SwInfopop extends LitElement { } `; - @state() private content = ""; + @state() + private content = ""; #mode: Mode = "closed"; #anchor: HTMLElement | null = null; // The [data-info] carrier currently under the pointer (tracked in every mode, diff --git a/packages/yukey/src/sw-list.gallery.ts b/packages/yukey/src/sw-list.gallery.ts index 1e587c8..69b0b29 100644 --- a/packages/yukey/src/sw-list.gallery.ts +++ b/packages/yukey/src/sw-list.gallery.ts @@ -12,11 +12,7 @@ import { css, html, LitElement, nothing, type TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { classMap } from "lit/directives/class-map.js"; import type { GalleryEntry } from "./gallery/entry.js"; -import { - MegacolumnController, - type MegacolumnKeyDetail, - megacolumnStyles, -} from "./megacolumn-controller.js"; +import { MegacolumnController, type MegacolumnKeyDetail, megacolumnStyles } from "./megacolumn-controller.js"; import { flashPress } from "./press-flash.js"; import type { SwListAddDetail } from "./sw-list.js"; import type { LiveListPhase } from "./sw-live-list.js"; @@ -156,13 +152,17 @@ export class SwgLivecolumnDemo extends LitElement { // biome-ignore lint/correctness/noUnusedPrivateClassMembers: wires itself via addController private mc = new MegacolumnController(this); - @property() label = "ops queue"; + @property() + label = "ops queue"; // Refill keeps the queue steady (a completed head is replaced); without it the // queue drains to the empty state and only player prepends fill it. - @property({ type: Boolean }) refill = false; + @property({ type: Boolean }) + refill = false; - @state() private tasks: QueueTask[] = []; - @state() private lastAction = ""; + @state() + private tasks: QueueTask[] = []; + @state() + private lastAction = ""; private seq = 0; private timer: number | undefined; @@ -186,10 +186,9 @@ export class SwgLivecolumnDemo extends LitElement { private newTask(origin: "player" | "assignment" = "assignment"): QueueTask { this.seq += 1; - const chores = - origin === "player" - ? ["survey the anomaly", "escort the hauler", "scout the far gate"] - : ["move to waypoint", "load ore", "unload struts", "dock", "refuel"]; + const chores = origin === "player" + ? ["survey the anomaly", "escort the hauler", "scout the far gate"] + : ["move to waypoint", "load ore", "unload struts", "dock", "refuel"]; return { id: `t${this.seq}`, label: `${chores[this.seq % chores.length]} (#${this.seq})`, @@ -231,16 +230,16 @@ export class SwgLivecolumnDemo extends LitElement { // own prepend/status rows decline everything here (the add-row's typing // never reaches this handler; text entry dominates). if ( - detail.rowId === "prepend" || - detail.rowId === "queue-header" || - detail.rowId === "queue-add" || - detail.rowId === "status" - ) + detail.rowId === "prepend" + || detail.rowId === "queue-header" + || detail.rowId === "queue-add" + || detail.rowId === "status" + ) { return; + } const task = this.tasks.find((t) => t.id === detail.rowId); const activate = detail.key === "Enter" || detail.key === " "; - const remove = - detail.key === "x" || detail.key === "Delete" || (activate && detail.position === "aux"); + const remove = detail.key === "x" || detail.key === "Delete" || (activate && detail.position === "aux"); if (remove) { // A ghost's task is already out of the data: removing again is a no-op, // and saying so quietly beats pretending something happened. @@ -249,10 +248,9 @@ export class SwgLivecolumnDemo extends LitElement { // Acknowledge on the aux x itself -- the control whose activation removal // IS -- in its own danger-tinted pressed style. The glyph element survives // the phase flip to ✓ (see renderTask), so the flash paints through it. - const aux = - e.target instanceof Element - ? e.target.closest("[data-mc-row]")?.querySelector("[data-mc-aux]") - : null; + const aux = e.target instanceof Element + ? e.target.closest("[data-mc-row]")?.querySelector("[data-mc-aux]") + : null; if (aux instanceof HTMLElement) flashPress(aux); this.tasks = this.tasks.filter((t) => t.id !== task.id); } @@ -260,8 +258,7 @@ export class SwgLivecolumnDemo extends LitElement { return; } if (activate) { - this.lastAction = - task === undefined ? "inspected a completed task" : `${task.label} inspected`; + this.lastAction = task === undefined ? "inspected a completed task" : `${task.label} inspected`; e.preventDefault(); // Acknowledge on the row's main control (inside the focused row). if (e.target instanceof HTMLElement) flashPress(e.target); @@ -283,10 +280,10 @@ export class SwgLivecolumnDemo extends LitElement { return html`<div class=${classMap({ task: true, committed: done })}> <span class="origin ${t.origin}">${t.origin}</span> <span class="grow" data-mc-main data-info=${ - done - ? "a completed task, held briefly; Enter still inspects it" - : "a queued task; Enter inspects it" - }>${t.label}</span> + done + ? "a completed task, held briefly; Enter still inspects it" + : "a queued task; Enter inspects it" + }>${t.label}</span> <span class=${classMap({ glyph: true, gone: done })} data-mc-aux=${done ? nothing : ""} title=${done ? nothing : "remove"} @@ -295,8 +292,7 @@ export class SwgLivecolumnDemo extends LitElement { </div>`; }; - private renderQueueEmpty = (): TemplateResult => - html`<div class="idle">queue empty (prepend something)</div>`; + private renderQueueEmpty = (): TemplateResult => html`<div class="idle">queue empty (prepend something)</div>`; render(): TemplateResult { return html` @@ -356,48 +352,45 @@ export class SwgLivecolumnDemo extends LitElement { const entry: GalleryEntry = { id: "sw-list", title: "sw-list: the live megacolumn's list region", - description: - "The list region component, composed here into a megacolumn console: header row, live rows " + - "(sw-live-list inside), ghosted add-row, and the t/n list-scoped jumps -- the list norms as " + - "things the component does. The queue churns and stays fully keyboard-drivable: ghost rows " + - "stay navigable while they hold (a stationary ghost is safe for the keyboard exactly as it " + - "is for the mouse); collapsing rows leave navigation and focus falls to a neighbor under a " + - "brief decisive-key lockout; key activity pins the FOCUSED ghost's geometry (the keyboard " + - "aims by identity, so only the aimed-at row needs holding still). The mouse side is the " + - "default click-guard: motion proceeds under the pointer, and only a click on a row that is " + - "mid-displacement dies quietly. This is the rehearsal for the infocard's task queue.", + description: "The list region component, composed here into a megacolumn console: header row, live rows " + + "(sw-live-list inside), ghosted add-row, and the t/n list-scoped jumps -- the list norms as " + + "things the component does. The queue churns and stays fully keyboard-drivable: ghost rows " + + "stay navigable while they hold (a stationary ghost is safe for the keyboard exactly as it " + + "is for the mouse); collapsing rows leave navigation and focus falls to a neighbor under a " + + "brief decisive-key lockout; key activity pins the FOCUSED ghost's geometry (the keyboard " + + "aims by identity, so only the aimed-at row needs holding still). The mouse side is the " + + "default click-guard: motion proceeds under the pointer, and only a click on a row that is " + + "mid-displacement dies quietly. This is the rehearsal for the infocard's task queue.", variants: [ { - caption: - "A steady queue: the head completes every few seconds (ghosts ✓, holds, collapses) and new " + - "assignment work arrives. Try: arrow onto a task and just WORK it -- the ghost under your " + - "focus holds while you keep pressing keys (the key-activity pin), and collapses moments after " + - "you pause; ghosts you are NOT on collapse on their own schedule, each at its own moment. " + - "Removals flash the × itself, danger-tinted, before the row ghosts (x/Delete removes a task from anywhere in its row; " + - "right-arrow reaches the ×; Enter on a ghost still inspects it), and if focus was on a ghost " + - "when it collapsed, focus falls to the next row under a brief decisive-key lockout -- hammer " + - "Enter across the collapse and note nothing mis-fires. The LIST NORMS are sw-list's own " + - "behavior now: a header row (arrow onto it and the " + - "whole region washes -- group identity without a nested container; i there describes the list as " + - "a list) and the add-row at the tail. Two ways to add, both earning the direct marking under " + - "the easy-undo doctrine: the prepend " + - "button is hotkey-direct -- the exception to letters-move-focus: p acts immediately from any row " + - "(focus following to the orders row), justified because a prepend is cheaply undone -- x the new " + - "head and it is gone. The add-row's a dives straight into typing (direct on a text row means " + - "what Enter means there), justified because nothing touches the queue until the Enter commit " + - "(the owner hears a cancelable sw-list-add and appends; watch the task animate in) and Escape " + - "abandons the draft. While focus is anywhere " + - "in the list -- header, tasks, add-row -- t jumps back up to the header and n jumps past the " + - "list to the next stop after it: list-scoped keys sw-list wires itself, deliberately not " + - "gold-marked (they mean the same thing in every list).", + caption: "A steady queue: the head completes every few seconds (ghosts ✓, holds, collapses) and new " + + "assignment work arrives. Try: arrow onto a task and just WORK it -- the ghost under your " + + "focus holds while you keep pressing keys (the key-activity pin), and collapses moments after " + + "you pause; ghosts you are NOT on collapse on their own schedule, each at its own moment. " + + "Removals flash the × itself, danger-tinted, before the row ghosts (x/Delete removes a task from anywhere in its row; " + + "right-arrow reaches the ×; Enter on a ghost still inspects it), and if focus was on a ghost " + + "when it collapsed, focus falls to the next row under a brief decisive-key lockout -- hammer " + + "Enter across the collapse and note nothing mis-fires. The LIST NORMS are sw-list's own " + + "behavior now: a header row (arrow onto it and the " + + "whole region washes -- group identity without a nested container; i there describes the list as " + + "a list) and the add-row at the tail. Two ways to add, both earning the direct marking under " + + "the easy-undo doctrine: the prepend " + + "button is hotkey-direct -- the exception to letters-move-focus: p acts immediately from any row " + + "(focus following to the orders row), justified because a prepend is cheaply undone -- x the new " + + "head and it is gone. The add-row's a dives straight into typing (direct on a text row means " + + "what Enter means there), justified because nothing touches the queue until the Enter commit " + + "(the owner hears a cancelable sw-list-add and appends; watch the task animate in) and Escape " + + "abandons the draft. While focus is anywhere " + + "in the list -- header, tasks, add-row -- t jumps back up to the header and n jumps past the " + + "list to the next stop after it: list-scoped keys sw-list wires itself, deliberately not " + + "gold-marked (they mean the same thing in every list).", render: () => html`<swg-livecolumn-demo refill></swg-livecolumn-demo>`, }, { - caption: - "Drain to empty: nothing refills this queue. Prepend a few tasks (p), watch them complete and " + - "drain away; the empty state is the owner's renderEmpty render-prop (mega mode has no slots). " + - "Focus stays sane through the last row's death: the column itself keeps focus and the arrows " + - "revive it the moment a row exists again.", + caption: "Drain to empty: nothing refills this queue. Prepend a few tasks (p), watch them complete and " + + "drain away; the empty state is the owner's renderEmpty render-prop (mega mode has no slots). " + + "Focus stays sane through the last row's death: the column itself keeps focus and the arrows " + + "revive it the moment a row exists again.", render: () => html`<swg-livecolumn-demo label="drain queue"></swg-livecolumn-demo>`, }, ], diff --git a/packages/yukey/src/sw-list.test.ts b/packages/yukey/src/sw-list.test.ts index c49f0e2..d63c578 100644 --- a/packages/yukey/src/sw-list.test.ts +++ b/packages/yukey/src/sw-list.test.ts @@ -42,11 +42,16 @@ class SwtListOwner extends LitElement { static styles = [megacolumnStyles]; // biome-ignore lint/correctness/noUnusedPrivateClassMembers: wires itself via addController private mc = new MegacolumnController(this); - @property({ attribute: false }) items: Item[] = []; - @property() header = "the test list"; - @property({ attribute: false }) renderHeader?: () => TemplateResult; - @property({ type: Boolean }) addable = true; - @property({ type: Boolean }) withAfter = true; + @property({ attribute: false }) + items: Item[] = []; + @property() + header = "the test list"; + @property({ attribute: false }) + renderHeader?: () => TemplateResult; + @property({ type: Boolean }) + addable = true; + @property({ type: Boolean }) + withAfter = true; render(): TemplateResult { return html`<div data-mc-container aria-label="test column"> @@ -60,8 +65,7 @@ class SwtListOwner extends LitElement { add-info="appends a test item" .items=${this.items} .keyOf=${(it: Item) => it.id} - .renderItem=${(it: Item, _phase: LiveListPhase) => - html`<div class="row-content">${it.label}</div>`} + .renderItem=${(it: Item, _phase: LiveListPhase) => html`<div class="row-content">${it.label}</div>`} ></sw-list> ${this.withAfter ? html`<div data-mc-row="after">after</div>` : nothing} </div>`; @@ -415,7 +419,7 @@ describe("the list-scoped keys (t and n)", () => { o.items = items("a"); }); await rig.settle(); - rig.root.querySelector('[data-mc-row="before"]')?.setAttribute("data-mc-hotkey", "t"); + rig.root.querySelector("[data-mc-row=\"before\"]")?.setAttribute("data-mc-hotkey", "t"); rig.container.dispatchEvent(new CustomEvent("megacolumn-rescan", { bubbles: true })); await focusItem(rig, "a"); rig.key("t"); diff --git a/packages/yukey/src/sw-list.ts b/packages/yukey/src/sw-list.ts index 3f4ce61..02a56b7 100644 --- a/packages/yukey/src/sw-list.ts +++ b/packages/yukey/src/sw-list.ts @@ -172,19 +172,32 @@ const swListStyles = css` @customElement("sw-list") export class SwList<T = unknown> extends LitElement { - @property() name = "list"; - @property() header = ""; - @property({ attribute: "header-info" }) headerInfo = ""; - @property({ attribute: false }) renderHeader?: () => TemplateResult; - @property({ type: Boolean }) addable = false; - @property({ attribute: "add-label" }) addLabel = "add…"; - @property({ attribute: "add-hotkey" }) addHotkey = "a"; - @property({ attribute: "add-info" }) addInfo = ""; - @property({ attribute: false }) items: T[] = []; - @property({ attribute: false }) keyOf!: (item: T) => string; - @property({ attribute: false }) renderItem!: (item: T, phase: LiveListPhase) => TemplateResult; - @property({ attribute: false }) renderEmpty?: () => TemplateResult; - @property() resetKey = ""; + @property() + name = "list"; + @property() + header = ""; + @property({ attribute: "header-info" }) + headerInfo = ""; + @property({ attribute: false }) + renderHeader?: () => TemplateResult; + @property({ type: Boolean }) + addable = false; + @property({ attribute: "add-label" }) + addLabel = "add…"; + @property({ attribute: "add-hotkey" }) + addHotkey = "a"; + @property({ attribute: "add-info" }) + addInfo = ""; + @property({ attribute: false }) + items: T[] = []; + @property({ attribute: false }) + keyOf!: (item: T) => string; + @property({ attribute: false }) + renderItem!: (item: T, phase: LiveListPhase) => TemplateResult; + @property({ attribute: false }) + renderEmpty?: () => TemplateResult; + @property() + resetKey = ""; // The column this list belongs to; n walks its rows. Set at connect (which // fails closed when there is none). @@ -201,10 +214,11 @@ export class SwList<T = unknown> extends LitElement { // Light DOM, no slots (see the header). Styles adopt into the owner's root. protected override createRenderRoot(): HTMLElement { - if (this.children.length > 0) + if (this.children.length > 0) { throw new Error( "sw-list: renders into the light DOM and has no slots; rows come from renderItem", ); + } adoptIntoRoot(this.getRootNode(), swListStyles, "data-sw-list"); return this; } @@ -222,15 +236,17 @@ export class SwList<T = unknown> extends LitElement { } override willUpdate(): void { - if (!/^[a-z0-9-]+$/i.test(this.name)) + if (!/^[a-z0-9-]+$/i.test(this.name)) { throw new Error( - `sw-list: name "${this.name}" must be a non-empty [a-z0-9-]+ token ` + - "(it namespaces the structural row ids)", + `sw-list: name "${this.name}" must be a non-empty [a-z0-9-]+ token ` + + "(it namespaces the structural row ids)", ); - if (this.addable && !/^[a-z0-9]$/i.test(this.addHotkey)) + } + if (this.addable && !/^[a-z0-9]$/i.test(this.addHotkey)) { throw new Error( `sw-list: add-hotkey "${this.addHotkey}" must be a single [a-z0-9] character`, ); + } } protected override updated(): void { @@ -327,14 +343,14 @@ export class SwList<T = unknown> extends LitElement { override render(): TemplateResult { return html` ${ - this.header === "" - ? nothing - : html`<div + this.header === "" + ? nothing + : html`<div data-mc-row="${this.name}-header" class="sw-list-header" data-info=${this.#composedHeaderInfo()} >${this.renderHeader?.() ?? this.header}</div>` - } + } <sw-live-list megacolumn .items=${this.items} @@ -344,8 +360,8 @@ export class SwList<T = unknown> extends LitElement { .resetKey=${this.resetKey} ></sw-live-list> ${ - this.addable - ? html`<div + this.addable + ? html`<div data-mc-row="${this.name}-add" class="sw-list-add" data-info=${this.#composedAddInfo()} @@ -353,8 +369,8 @@ export class SwList<T = unknown> extends LitElement { <span data-mc-hotkey=${this.addHotkey} data-mc-hotkey-direct>+ ${this.#markedAddLabel()}</span> <input data-mc-input type="text" @keydown=${this.#onAddKeydown} /> </div>` - : nothing - } + : nothing + } `; } @@ -369,9 +385,8 @@ export class SwList<T = unknown> extends LitElement { #composedAddInfo(): string { const top = this.header === "" ? "the list's top" : "the list's header"; - const norms = - `${this.addHotkey} or Enter dives into typing, Enter commits, Escape abandons; ` + - `t jumps back to ${top}`; + const norms = `${this.addHotkey} or Enter dives into typing, Enter commits, Escape abandons; ` + + `t jumps back to ${top}`; return this.addInfo === "" ? norms : `${this.addInfo}; ${norms}`; } @@ -381,7 +396,9 @@ export class SwList<T = unknown> extends LitElement { #markedAddLabel(): TemplateResult { const idx = this.addLabel.toLowerCase().indexOf(this.addHotkey.toLowerCase()); if (idx === -1) return html`${this.addLabel} <span class="sw-list-hk">${this.addHotkey}</span>`; - return html`${this.addLabel.slice(0, idx)}<span class="sw-list-hk">${this.addLabel[idx]}</span>${this.addLabel.slice(idx + 1)}`; + return html`${this.addLabel.slice(0, idx)}<span class="sw-list-hk">${this.addLabel[idx]}</span>${ + this.addLabel.slice(idx + 1) + }`; } } diff --git a/packages/yukey/src/sw-live-list.gallery.ts b/packages/yukey/src/sw-live-list.gallery.ts index 3e6178f..3bac560 100644 --- a/packages/yukey/src/sw-live-list.gallery.ts +++ b/packages/yukey/src/sw-live-list.gallery.ts @@ -49,8 +49,10 @@ const keyOf = (item: DemoRow): string => item.id; // one enters at the tail; the interplay with a hovering pointer is the demo. @customElement("swg-live-list-churn") export class SwgLiveListChurn extends LitElement { - @property() pointerSafety: "click-guard" | "freeze" = "click-guard"; - @state() private items: DemoRow[] = []; + @property() + pointerSafety: "click-guard" | "freeze" = "click-guard"; + @state() + private items: DemoRow[] = []; private seq = 0; private timer: number | undefined; @@ -89,35 +91,33 @@ export class SwgLiveListChurn extends LitElement { const entry: GalleryEntry = { id: "sw-live-list", title: "sw-live-list", - description: - "A keyed live list whose rows vanish safely under a pointer. " + - "The owner supplies keying and rendering; this component owns row lifecycle: " + - "departed rows ghost and hold before collapsing, insertions grow in. " + - "Click safety is target-scoped by default (pointerSafety: click-guard): motion proceeds " + - "under the pointer, and a click is swallowed only while its target row is mid-displacement " + - "(or was, within the last 50ms) -- stale clicks die, everything else stays live. " + - "Freeze mode is the opt-in alternative: geometry holds still under the pointer instead.", + description: "A keyed live list whose rows vanish safely under a pointer. " + + "The owner supplies keying and rendering; this component owns row lifecycle: " + + "departed rows ghost and hold before collapsing, insertions grow in. " + + "Click safety is target-scoped by default (pointerSafety: click-guard): motion proceeds " + + "under the pointer, and a click is swallowed only while its target row is mid-displacement " + + "(or was, within the last 50ms) -- stale clicks die, everything else stays live. " + + "Freeze mode is the opt-in alternative: geometry holds still under the pointer instead.", variants: [ { - caption: - "Self-churning queue, default click safety (click-guard): the head row completes every few " + - "seconds and new work enters at the tail. Watch a row ghost (✓, dimmed), hold, then collapse -- " + - "with the pointer resting inside, nothing waits for it. Try clicking a row the instant one above " + - "it collapses: the click on the still-moving row dies quietly (no surprise target), while rows " + - "above the motion answer clicks throughout.", + caption: "Self-churning queue, default click safety (click-guard): the head row completes every few " + + "seconds and new work enters at the tail. Watch a row ghost (✓, dimmed), hold, then collapse -- " + + "with the pointer resting inside, nothing waits for it. Try clicking a row the instant one above " + + "it collapses: the click on the still-moving row dies quietly (no surprise target), while rows " + + "above the motion answer clicks throughout.", render: () => html`<swg-live-list-churn></swg-live-list-churn>`, }, { - caption: - "The same churn in freeze mode (pointerSafety: freeze, the opt-in): rest the pointer inside " + - "the list and geometry freezes -- ghosts hold and insertions wait invisibly until the pointer " + - "leaves, and the deferred motion plays out one beat apart. For surfaces that want quiescence " + - "under the pointer; the price is a world that visibly waits for the mouse.", + caption: "The same churn in freeze mode (pointerSafety: freeze, the opt-in): rest the pointer inside " + + "the list and geometry freezes -- ghosts hold and insertions wait invisibly until the pointer " + + "leaves, and the deferred motion plays out one beat apart. For surfaces that want quiescence " + + "under the pointer; the price is a world that visibly waits for the mouse.", render: () => html`<swg-live-list-churn pointerSafety="freeze"></swg-live-list-churn>`, }, { caption: "Empty state: the owner provides the empty slot's content.", - render: () => html`<sw-live-list + render: () => + html`<sw-live-list .items=${[] as DemoRow[]} .keyOf=${keyOf} .renderItem=${renderRow} diff --git a/packages/yukey/src/sw-live-list.test.ts b/packages/yukey/src/sw-live-list.test.ts index cdc8a52..07334ab 100644 --- a/packages/yukey/src/sw-live-list.test.ts +++ b/packages/yukey/src/sw-live-list.test.ts @@ -101,7 +101,7 @@ const fire = (el: Element, type: "click" | "mousedown"): void => { el.dispatchEvent(new MouseEvent(type, { bubbles: true, composed: true, cancelable: true })); }; -const countItemClicks = (list: SwLiveList<Item>): (() => number) => { +const countItemClicks = (list: SwLiveList<Item>): () => number => { let n = 0; list.addEventListener("item-click", () => { n += 1; @@ -455,8 +455,7 @@ describe("click-guard (the default pointerSafety): motion proceeds, stale clicks vi.advanceTimersByTime(list.holdMs); // b collapsing await list.updateComplete; vi.advanceTimersByTime(100); - const slot = (key: string): Element | null => - list.shadowRoot?.querySelector(`[data-key="${key}"]`) ?? null; + const slot = (key: string): Element | null => list.shadowRoot?.querySelector(`[data-key="${key}"]`) ?? null; const c = slot("c"); const a = slot("a"); if (c === null || a === null) throw new Error("rows missing"); diff --git a/packages/yukey/src/sw-live-list.ts b/packages/yukey/src/sw-live-list.ts index 2a702be..a71a99e 100644 --- a/packages/yukey/src/sw-live-list.ts +++ b/packages/yukey/src/sw-live-list.ts @@ -228,39 +228,50 @@ export class SwLiveList<T = unknown> extends LitElement { lifecycleStyles, ]; - @property({ attribute: false }) items: T[] = []; - @property({ attribute: false }) keyOf!: (item: T) => string; - @property({ attribute: false }) renderItem!: (item: T, phase: LiveListPhase) => TemplateResult; + @property({ attribute: false }) + items: T[] = []; + @property({ attribute: false }) + keyOf!: (item: T) => string; + @property({ attribute: false }) + renderItem!: (item: T, phase: LiveListPhase) => TemplateResult; // The empty state, rendered when no row is visible. In shadow mode the default // slot alternative still works; mega mode has no shadow root, so this render-prop // is the only way there. - @property({ attribute: false }) renderEmpty?: () => TemplateResult; + @property({ attribute: false }) + renderEmpty?: () => TemplateResult; // Owner-supplied styles for rendered items, adopted into this shadow root // (the owner's own stylesheet can't reach in here). Shadow mode only: // mega-mode rows live in the owner's tree, already inside its stylesheet's reach. - @property({ attribute: false }) itemStyles?: CSSResult; + @property({ attribute: false }) + itemStyles?: CSSResult; // Identity of the data source. When it changes, all row state drops and the list // renders fresh -- no exit animations for the old identity's rows. - @property() resetKey = ""; + @property() + resetKey = ""; // Which strategy answers the click-safety goal (see the header). "click-guard" // (the default): motion proceeds and stale clicks on moving-target rows are // swallowed. "freeze": geometry defers while the pointer is inside the host. // Runtime-togglable: a host may raise "freeze" as a quasi-lock and drop it again; // leaving freeze releases whatever the hover pin was deferring. - @property({ attribute: "pointer-safety" }) pointerSafety: "click-guard" | "freeze" = - "click-guard"; + @property({ attribute: "pointer-safety" }) + pointerSafety: "click-guard" | "freeze" = "click-guard"; // Timing knobs, defaulted per the vanishing-rows study. // (graceMs is freeze-mode machinery: how long the pointer-events lockout // outlives the last motion. Click-guard's tail is CLICK_GUARD_MS, a constant: // it is a click-race window, not a feel knob.) - @property({ type: Number }) holdMs = 1200; - @property({ type: Number }) collapseMs = 300; - @property({ type: Number }) graceMs = 200; - @property({ type: Number }) enterMs = 300; + @property({ type: Number }) + holdMs = 1200; + @property({ type: Number }) + collapseMs = 300; + @property({ type: Number }) + graceMs = 200; + @property({ type: Number }) + enterMs = 300; // How long the key-activity pin outlives the last keydown (mega mode; see header). // Short: it bridges the gaps inside one burst of typing, and the focused row's // protection should die almost as soon as the fingers stop. - @property({ type: Number }) keyPinMs = 200; + @property({ type: Number }) + keyPinMs = 200; // Row state, insertion-ordered (= display order). Deliberately not @state: // mutations call requestUpdate explicitly, and items changes re-render anyway. @@ -283,7 +294,8 @@ export class SwLiveList<T = unknown> extends LitElement { private keyPinned = false; private keyPinTimer: number | undefined; - @query(".sw-ll-items") private itemsEl?: HTMLElement; + @query(".sw-ll-items") + private itemsEl?: HTMLElement; constructor() { super(); @@ -309,11 +321,12 @@ export class SwLiveList<T = unknown> extends LitElement { protected override createRenderRoot(): HTMLElement | DocumentFragment { if (!this.hasAttribute("megacolumn")) return super.createRenderRoot(); this.mega = true; - if (this.children.length > 0) + if (this.children.length > 0) { throw new Error( - "sw-live-list: megacolumn mode renders into the light DOM and has no slots; " + - "use the renderEmpty render-prop", + "sw-live-list: megacolumn mode renders into the light DOM and has no slots; " + + "use the renderEmpty render-prop", ); + } adoptIntoRoot(this.getRootNode(), megaModeStyles, "data-sw-live-list"); return this; } @@ -324,8 +337,9 @@ export class SwLiveList<T = unknown> extends LitElement { // The key-activity pin listens on the column's one real focusable: every // keydown a user aims anywhere in the column lands there first. const container = this.closest<HTMLElement>("[data-mc-container]"); - if (container === null) + if (container === null) { throw new Error("sw-live-list: megacolumn mode outside any [data-mc-container]"); + } this.megaContainer = container; container.addEventListener("keydown", this.onKeyActivity); } @@ -343,16 +357,18 @@ export class SwLiveList<T = unknown> extends LitElement { } override willUpdate(changed: PropertyValues<this>): void { - if (this.mega && this.itemStyles !== undefined) + if (this.mega && this.itemStyles !== undefined) { throw new Error( - "sw-live-list: itemStyles has no effect in megacolumn mode; " + - "style rows from the owner's own stylesheet", + "sw-live-list: itemStyles has no effect in megacolumn mode; " + + "style rows from the owner's own stylesheet", ); - if (this.pointerSafety !== "click-guard" && this.pointerSafety !== "freeze") + } + if (this.pointerSafety !== "click-guard" && this.pointerSafety !== "freeze") { throw new Error( - `sw-live-list: unknown pointerSafety "${this.pointerSafety}" ` + - '(the modes are "click-guard" and "freeze")', + `sw-live-list: unknown pointerSafety "${this.pointerSafety}" ` + + "(the modes are \"click-guard\" and \"freeze\")", ); + } // Leaving freeze mode releases whatever the hover pin deferred (the pointer // may never leave); entering it starts deferring from here on. releaseDeferred // re-checks every deferral condition itself, so one unconditional call covers @@ -667,10 +683,10 @@ export class SwLiveList<T = unknown> extends LitElement { this.beginEnter(row); changed = true; } else if ( - row.phase === "committed" && - row.holdExpired && - row.timer === undefined && // not already scheduled by an earlier release - !this.collapseDeferred(row) + row.phase === "committed" + && row.holdExpired + && row.timer === undefined // not already scheduled by an earlier release + && !this.collapseDeferred(row) ) { backlog.push(row); } @@ -755,11 +771,13 @@ export class SwLiveList<T = unknown> extends LitElement { style=${vars} tabindex="-1" > - ${repeat( - rowList, - (r) => r.key, - (r) => this.renderRow(r), - )} + ${ + repeat( + rowList, + (r) => r.key, + (r) => this.renderRow(r), + ) + } </div> ${empty} `; @@ -770,8 +788,7 @@ export class SwLiveList<T = unknown> extends LitElement { // and (mega mode) is also what the controller's scan skips: one attribute, // closed to sight and closed to navigation together. const closed = row.phase === "pending" || row.phase === "collapsing"; - const phase: LiveListPhase = - row.phase === "committed" || row.phase === "collapsing" ? "committed" : "live"; + const phase: LiveListPhase = row.phase === "committed" || row.phase === "collapsing" ? "committed" : "live"; // Mega mode stamps row identity for the megacolumn scan (see the header). // classMap (not string interpolation) because the megacolumn controller stamps // its focus classes on these elements imperatively, and an interpolated class diff --git a/packages/yukey/src/tab-cycle.test.ts b/packages/yukey/src/tab-cycle.test.ts index a8f8cef..e610f51 100644 --- a/packages/yukey/src/tab-cycle.test.ts +++ b/packages/yukey/src/tab-cycle.test.ts @@ -6,12 +6,7 @@ // attribute-based (see the header of tab-cycle.ts). import { beforeEach, describe, expect, it } from "vitest"; -import { - containTab, - deepActiveElement, - deepTabbables, - installTabContainment, -} from "./tab-cycle.js"; +import { containTab, deepActiveElement, deepTabbables, installTabContainment } from "./tab-cycle.js"; function host(innerHtml: string): HTMLElement { const el = document.createElement("div"); diff --git a/packages/yukey/src/tokens.gallery.ts b/packages/yukey/src/tokens.gallery.ts index f9aaecf..ec6c03e 100644 --- a/packages/yukey/src/tokens.gallery.ts +++ b/packages/yukey/src/tokens.gallery.ts @@ -137,7 +137,8 @@ export class SwgTokens extends LitElement { } `; - @property() face: "palette" | "spacing" | "type" | "focus" = "palette"; + @property() + face: "palette" | "spacing" | "type" | "focus" = "palette"; render(): TemplateResult { switch (this.face) { @@ -165,13 +166,16 @@ export class SwgTokens extends LitElement { const colorGroups = tokenGroups .map((g) => ({ ...g, tokens: g.tokens.filter((t) => t.kind === "color") })) .filter((g) => g.tokens.length > 0); - return html`${colorGroups.map( - (g) => html`<div class="group"> + return html`${ + colorGroups.map( + (g) => + html`<div class="group"> <div class="group-title">${g.title}</div> <div class="group-about">${g.about}</div> ${g.tokens.map((t) => SwgTokens.paletteRow(t))} </div>`, - )}`; + ) + }`; } private renderSpacing(): TemplateResult { @@ -182,14 +186,17 @@ export class SwgTokens extends LitElement { <div class="group"> <div class="group-title">${spacing.title}</div> <div class="group-about">${spacing.about}</div> - ${spacing.tokens.map( - (t) => html`<div class="trow"> + ${ + spacing.tokens.map( + (t) => + html`<div class="trow"> <div><div class="bar" style="width: var(${t.name});"></div></div> <span class="tname">${t.name}</span> <span class="tvalue">${t.value}</span> <span class="tabout">${t.about}</span> </div>`, - )} + ) + } </div> <div class="group"> <div class="group-title">${hit.title}</div> @@ -216,23 +223,29 @@ export class SwgTokens extends LitElement { return html`<div class="group"> <div class="group-title">${type.title}</div> <div class="group-about">${type.about}</div> - ${sizes.map( - (t) => html`<div class="trow"> + ${ + sizes.map( + (t) => + html`<div class="trow"> <span class="tvalue">${t.value}</span> <span class="tname">${t.name}</span> <span class="specimen" style="font-size: var(${t.name});">Kestrel Reach 74/74</span> <span class="tabout">${t.about}</span> </div>`, - )} - ${rest.map( - (t) => html`<div class="trow"> + ) + } + ${ + rest.map( + (t) => + html`<div class="trow"> <span class="tvalue">${t.value}</span> <span class="tname">${t.name}</span> <span class="specimen" style="letter-spacing: var(${t.name}); text-transform: uppercase;" >Kestrel Reach</span> <span class="tabout">${t.about}</span> </div>`, - )} + ) + } </div>`; } @@ -269,13 +282,16 @@ export class SwgTokens extends LitElement { <div class="group"> <div class="group-title">${motion.title}</div> <div class="group-about">${motion.about}</div> - ${motion.tokens.map( - (t) => html`<div class="trow"> + ${ + motion.tokens.map( + (t) => + html`<div class="trow"> <span class="tvalue">${t.value}</span> <span class="tname">${t.name}</span> <span class="tabout">${t.about}</span> </div>`, - )} + ) + } <div class="demo-row"> <sw-button label="press me" hotkey="p"></sw-button> <span class="note"> @@ -292,16 +308,14 @@ export class SwgTokens extends LitElement { const entry: GalleryEntry = { id: "tokens", title: "design tokens", - description: - "The visual vocabulary everything else is built from: --sw-* custom properties, " + - "rendered live from their definitions in tokens.ts. Names are semantic roles and are " + - "the durable part; values are the iteration surface -- edit tokens.ts, rebuild, " + - "and this page shows the new truth. Intent notes live in docs/precepts-aesthetics.md.", + description: "The visual vocabulary everything else is built from: --sw-* custom properties, " + + "rendered live from their definitions in tokens.ts. Names are semantic roles and are " + + "the durable part; values are the iteration surface -- edit tokens.ts, rebuild, " + + "and this page shows the new truth. Intent notes live in docs/precepts-aesthetics.md.", variants: [ { - caption: - "The palette, by role. Swatches sit on a checkerboard so translucent washes read as " + - "translucent; wash values derive from their base color, so retinting a base retints its washes.", + caption: "The palette, by role. Swatches sit on a checkerboard so translucent washes read as " + + "translucent; wash values derive from their base color, so retinting a base retints its washes.", wide: true, render: () => html`<swg-tokens face="palette"></swg-tokens>`, }, @@ -316,9 +330,8 @@ const entry: GalleryEntry = { render: () => html`<swg-tokens face="type"></swg-tokens>`, }, { - caption: - "Keyboard visibility: the focus ring (try tabbing through), the two hotkey-marking forms, " + - "and the activation flash (press things; watch them acknowledge).", + caption: "Keyboard visibility: the focus ring (try tabbing through), the two hotkey-marking forms, " + + "and the activation flash (press things; watch them acknowledge).", wide: true, render: () => html`<swg-tokens face="focus"></swg-tokens>`, }, diff --git a/packages/yukey/src/tokens.ts b/packages/yukey/src/tokens.ts index 10d4cb4..42ecbce 100644 --- a/packages/yukey/src/tokens.ts +++ b/packages/yukey/src/tokens.ts @@ -53,8 +53,7 @@ export const tokenGroups: TokenGroup[] = [ name: "--sw-bg-panel-solid", value: "#0a1220", kind: "color", - about: - "opaque panel surface: cards, sticky headers, toasts -- anywhere bleed-through would muddy", + about: "opaque panel surface: cards, sticky headers, toasts -- anywhere bleed-through would muddy", }, { name: "--sw-bg-panel", @@ -141,9 +140,8 @@ export const tokenGroups: TokenGroup[] = [ { id: "accent", title: "accent", - about: - "The interactive blue family: selected and hovered things, meters, active chrome. " + - "Washes derive from the accent, so retinting it retints them.", + about: "The interactive blue family: selected and hovered things, meters, active chrome. " + + "Washes derive from the accent, so retinting it retints them.", tokens: [ { name: "--sw-accent", @@ -161,10 +159,9 @@ export const tokenGroups: TokenGroup[] = [ name: "--sw-wash-pressed", value: "hsl(from var(--sw-accent) h calc(s * 1.5) calc(l * 1.2) / 0.5)", kind: "color", - about: - "background wash for a control being activated: held under a pointer, " + - "or flashing for --sw-press-ms after triggering. Deliberately more saturated " + - "and brighter than the accent it derives from: activation must outrank selection at a glance", + about: "background wash for a control being activated: held under a pointer, " + + "or flashing for --sw-press-ms after triggering. Deliberately more saturated " + + "and brighter than the accent it derives from: activation must outrank selection at a glance", }, { name: "--sw-wash-selected", @@ -195,8 +192,7 @@ export const tokenGroups: TokenGroup[] = [ name: "--sw-good", value: "#8fd4c0", kind: "color", - about: - "the working-economy teal: producing, positive flow, and the map's route system (promises of movement)", + about: "the working-economy teal: producing, positive flow, and the map's route system (promises of movement)", }, { name: "--sw-warn", @@ -245,17 +241,15 @@ export const tokenGroups: TokenGroup[] = [ { id: "focus", title: "keyboard & focus", - about: - "The guidelines demand focus be visible and hotkeys be marked; these tokens are how. " + - "The focus-model and button stages consume them heavily.", + about: "The guidelines demand focus be visible and hotkeys be marked; these tokens are how. " + + "The focus-model and button stages consume them heavily.", tokens: [ { name: "--sw-hotkey-accent", value: "#e8c86a", kind: "color", - about: - "the significant color for hotkey marking: the letter-in-label tint, the 70%-offset hotkey slot. " + - "Deliberately the only gold in the palette: hotkeys must pop", + about: "the significant color for hotkey marking: the letter-in-label tint, the 70%-offset hotkey slot. " + + "Deliberately the only gold in the palette: hotkeys must pop", }, { name: "--sw-selection", @@ -291,37 +285,33 @@ export const tokenGroups: TokenGroup[] = [ name: "--sw-scroll-peek", value: "28px", kind: "size", - about: - "how much neighbor stays visible past the focused row when navigation scrolls: " + - "about one row, so the next row is always in view and a scroller's edge is " + - "only flush when the list truly ends there", + about: "how much neighbor stays visible past the focused row when navigation scrolls: " + + "about one row, so the next row is always in view and a scroller's edge is " + + "only flush when the list truly ends there", }, ], }, { id: "motion", title: "motion", - about: - "Interaction-feedback durations. System parameters, not per-element choices: " + - "slowing one here slows it everywhere, which is what an accessibility mode needs.", + about: "Interaction-feedback durations. System parameters, not per-element choices: " + + "slowing one here slows it everywhere, which is what an accessibility mode needs.", tokens: [ { name: "--sw-press-ms", value: "140ms", kind: "duration", - about: - "how long the activation flash lingers on a triggered control. " + - "Below ~100ms a flash risks being missed outright; 180ms already reads " + - "as sluggish. Tuned by hand at the 2026-08-23 playtest", + about: "how long the activation flash lingers on a triggered control. " + + "Below ~100ms a flash risks being missed outright; 180ms already reads " + + "as sluggish. Tuned by hand at the 2026-08-23 playtest", }, ], }, { id: "spacing", title: "spacing", - about: - "The gap ramp. Gaps carry meaning: xs binds a cluster, sm relates neighbors, " + - "lg separates groups, xl+ separates sections (the gaps megacolumn focus jumps across).", + about: "The gap ramp. Gaps carry meaning: xs binds a cluster, sm relates neighbors, " + + "lg separates groups, xl+ separates sections (the gaps megacolumn focus jumps across).", tokens: [ { name: "--sw-space-xs",