From fe3e85efc70640d1c3394e35f268ac71f501c0b5 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 07:32:49 +0100 Subject: [PATCH 01/18] Add a reader-local order model for decision decks Co-Authored-By: Claude Opus 5.5 --- packages/editor/src/deck-order.test.ts | 62 +++++++++++++++++++++++++ packages/editor/src/deck-order.ts | 64 ++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 packages/editor/src/deck-order.test.ts create mode 100644 packages/editor/src/deck-order.ts diff --git a/packages/editor/src/deck-order.test.ts b/packages/editor/src/deck-order.test.ts new file mode 100644 index 000000000..cb90a7be0 --- /dev/null +++ b/packages/editor/src/deck-order.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "bun:test"; + +import { arrange, bringForward, cycle, position, settle, slot } from "./deck-order"; + +let open = (id: string) => ({ id, settled: false }); +let done = (id: string) => ({ id, settled: true }); + +describe("deck order", () => { + it("starts with open cards in document order and settled cards behind", () => { + expect(arrange([], [done("a"), open("b"), open("c")])).toEqual(["b", "c", "a"]); + }); + + it("keeps a reader's order when items re-render unchanged", () => { + expect(arrange(["c", "b", "a"], [done("a"), open("b"), open("c")])) + .toEqual(["c", "b", "a"]); + }); + + it("drops removed cards and slots a new open card before the settled ones", () => { + expect(arrange(["c", "b", "a"], [done("a"), open("c"), open("d")])) + .toEqual(["c", "d", "a"]); + }); + + it("appends a new settled card at the back", () => { + expect(arrange(["b"], [open("b"), done("z")])).toEqual(["b", "z"]); + }); + + it("does not move a card that has just been answered until settle runs", () => { + expect(arrange(["b", "c"], [done("b"), open("c")])).toEqual(["b", "c"]); + expect(settle(["b", "c"], [done("b"), open("c")])).toEqual(["c", "b"]); + }); + + it("settles stably, keeping relative order inside each group", () => { + expect(settle(["a", "b", "c", "d"], [done("a"), open("b"), done("c"), open("d")])) + .toEqual(["b", "d", "a", "c"]); + }); + + it("cycles through every card in both directions", () => { + expect(cycle(["a", "b", "c"], 1)).toEqual(["b", "c", "a"]); + expect(cycle(["a", "b", "c"], -1)).toEqual(["c", "a", "b"]); + expect(cycle([], 1)).toEqual([]); + }); + + it("brings one card to the top without disturbing the rest", () => { + expect(bringForward(["a", "b", "c"], "c")).toEqual(["c", "a", "b"]); + expect(bringForward(["a", "b"], "missing")).toEqual(["a", "b"]); + }); + + it("reports the top card's place in document order", () => { + expect(position([open("a"), open("b"), open("c")], "c")).toBe(3); + expect(position([open("a")], undefined)).toBe(0); + }); + + it("peeks two cards and hides deeper ones", () => { + expect(slot(0)).toEqual({ transform: "translateY(0rem) scale(1)", zIndex: 100, opacity: 1 }); + expect(slot(2).transform).toBe("translateY(-3.75rem) scale(0.92)"); + expect(slot(3)).toEqual({ + transform: "translateY(-3.75rem) scale(0.92)", + zIndex: 97, + opacity: 0, + }); + }); +}); diff --git a/packages/editor/src/deck-order.ts b/packages/editor/src/deck-order.ts new file mode 100644 index 000000000..f87d45425 --- /dev/null +++ b/packages/editor/src/deck-order.ts @@ -0,0 +1,64 @@ +/** + * Which card of a deck is on top, and where the rest sit. + * + * Order is one reader's working position, not a fact about the document, so it + * lives here and in React state and never reaches a record or a collaborator. + */ + +export type DeckEntry = { id: string; settled: boolean }; + +/** How many cards behind the top show a header edge. */ +export const PEEKS = 2; +const PEEK_REM = 1.875; +const SHRINK = 0.04; + +/** Reconcile a reader's order with the current members, moving nothing that survived. */ +export function arrange(previous: readonly string[], items: readonly DeckEntry[]): string[] { + let present = new Map(items.map(item => [item.id, item])); + let kept = previous.filter(id => present.has(id)); + let known = new Set(kept); + for (let item of items) { + if (known.has(item.id)) continue; + known.add(item.id); + if (item.settled) { + kept.push(item.id); + continue; + } + let back = kept.findIndex(id => present.get(id)!.settled); + if (back === -1) kept.push(item.id); + else kept.splice(back, 0, item.id); + } + return kept; +} + +/** Open cards in front, settled cards behind, each group in its current order. */ +export function settle(order: readonly string[], items: readonly DeckEntry[]): string[] { + let settled = new Set(items.filter(item => item.settled).map(item => item.id)); + return [...order.filter(id => !settled.has(id)), ...order.filter(id => settled.has(id))]; +} + +export function cycle(order: readonly string[], direction: 1 | -1): string[] { + if (order.length < 2) return [...order]; + return direction === 1 + ? [...order.slice(1), order[0]!] + : [order.at(-1)!, ...order.slice(0, -1)]; +} + +export function bringForward(order: readonly string[], id: string): string[] { + if (!order.includes(id)) return [...order]; + return [id, ...order.filter(other => other !== id)]; +} + +export function position(items: readonly DeckEntry[], id: string | undefined): number { + return id === undefined ? 0 : items.findIndex(item => item.id === id) + 1; +} + +export function slot(depth: number): { transform: string; zIndex: number; opacity: number } { + let shown = Math.min(depth, PEEKS); + let scale = Math.round((1 - shown * SHRINK) * 100) / 100; + return { + transform: `translateY(${-shown * PEEK_REM}rem) scale(${scale})`, + zIndex: 100 - depth, + opacity: depth > PEEKS ? 0 : 1, + }; +} From 70c008ca6acd2d1b17fb938b6080130fb7f9cd45 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 07:35:05 +0100 Subject: [PATCH 02/18] Publish runs of adjacent decisions from the document Co-Authored-By: Claude Sonnet 5.5 --- e2e/source/room-source.native.ts | 5 +- .../editor/src/decisions-metadata.test.tsx | 2 +- packages/editor/src/decisions.test.tsx | 2 +- packages/editor/src/deck-runs.test.ts | 70 +++++++++++++++++++ packages/editor/src/deck-runs.ts | 36 ++++++++++ packages/editor/src/questionnaires.test.ts | 14 +++- packages/editor/src/questionnaires.ts | 16 +++-- 7 files changed, 135 insertions(+), 10 deletions(-) create mode 100644 packages/editor/src/deck-runs.test.ts create mode 100644 packages/editor/src/deck-runs.ts diff --git a/e2e/source/room-source.native.ts b/e2e/source/room-source.native.ts index 138eb8045..e972ed1de 100644 --- a/e2e/source/room-source.native.ts +++ b/e2e/source/room-source.native.ts @@ -64,6 +64,7 @@ test("actual host evidence requires current metadata and sources enter actual Ch await page.evaluate(thread => { window.roomSourceProbe.setQuestions({ hasPlanContent: true, + runs: [], entries: [{ id: "card-1", value: { @@ -189,6 +190,7 @@ test("a conversation-linked card-only document stays in Document", async ({ page await page.evaluate(() => window.roomSourceProbe.setQuestions({ hasPlanContent: false, + runs: [], entries: [{ id: "conversation-card", value: { @@ -218,6 +220,7 @@ test("a Planner-only card opening still moves to Decisions", async ({ page }) => await page.evaluate(() => window.roomSourceProbe.setQuestions({ hasPlanContent: false, + runs: [], entries: [{ id: "planner-card", value: { @@ -256,7 +259,7 @@ test("opening Decisions focuses reopened work ahead of settled history and later }], }, })); - window.roomSourceProbe.setQuestions({ hasPlanContent: true, entries }); + window.roomSourceProbe.setQuestions({ hasPlanContent: true, runs: [], entries }); window.roomSourceProbe.receive({ kind: "question:meta", id: "settled", diff --git a/packages/editor/src/decisions-metadata.test.tsx b/packages/editor/src/decisions-metadata.test.tsx index 959c747e6..f90c4e9be 100644 --- a/packages/editor/src/decisions-metadata.test.tsx +++ b/packages/editor/src/decisions-metadata.test.tsx @@ -21,7 +21,7 @@ function markup(meta?: Question.CardMeta, canEdit?: boolean) { value: { getItem: () => null, setItem() {} }, }); let store = new QuestionnaireStore(); - store.set({ entries: [{ id: DECIDED.id, value: DECIDED }], hasPlanContent: true }); + store.set({ entries: [{ id: DECIDED.id, value: DECIDED }], hasPlanContent: true, runs: [] }); let cardMeta = new CardMetaStore(() => {}); let handlers = new Map void>(); let wire: Transport = { diff --git a/packages/editor/src/decisions.test.tsx b/packages/editor/src/decisions.test.tsx index e76aa2c0e..bae05d025 100644 --- a/packages/editor/src/decisions.test.tsx +++ b/packages/editor/src/decisions.test.tsx @@ -42,7 +42,7 @@ function markup(stored?: string, entries = [RESOLVED], failed = false): string { }); let store = new QuestionnaireStore(); - store.set({ entries, hasPlanContent: true }); + store.set({ entries, hasPlanContent: true, runs: [] }); if (failed) store.failOpen(); let motion: MotionDisclosureContract = { className: "motion-collapse", diff --git a/packages/editor/src/deck-runs.test.ts b/packages/editor/src/deck-runs.test.ts new file mode 100644 index 000000000..b3baf9e2f --- /dev/null +++ b/packages/editor/src/deck-runs.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "bun:test"; +import { createHeadlessEditor } from "@lexical/headless"; + +import { importPlan, registry } from "@chopin/dialect"; + +import { $collectDecisionRuns, deckLayout } from "./deck-runs"; + +const REGISTRY = registry(); + +function card(n: number) { + return `\n` + + `\n` + + `\n\n`; +} +let id = (n: number) => `01K0N4TR8K7JGM4R1J7PW4R8Y${n}`; + +function runs(source: string): string[][] { + let editor = createHeadlessEditor({ + nodes: REGISTRY.nodes, + onError: err => { + throw err; + }, + }); + importPlan(editor, source, { registry: REGISTRY }); + let value: string[][] = []; + editor.getEditorState().read(() => { + value = $collectDecisionRuns(); + }); + return value; +} + +describe("decision runs", () => { + it("groups consecutive cards", () => { + expect(runs(`Intro.\n\n${card(1)}\n${card(2)}\n${card(3)}\nOutro.\n`)) + .toEqual([[id(1), id(2), id(3)]]); + }); + + it("does not group cards separated by prose", () => { + expect(runs(`${card(1)}\nBetween.\n\n${card(2)}`)).toEqual([]); + }); + + it("finds separate runs", () => { + expect(runs(`${card(1)}\n${card(2)}\nBetween.\n\n${card(3)}\n${card(4)}`)) + .toEqual([[id(1), id(2)], [id(3), id(4)]]); + }); + + it("ignores a lone card", () => { + expect(runs(`Intro.\n\n${card(1)}`)).toEqual([]); + }); +}); + +describe("deck layout", () => { + it("needs two visible members and one open member", () => { + expect(deckLayout([{ id: "a", state: "open" }, { id: "b", state: "hidden" }])) + .toBeUndefined(); + expect(deckLayout([{ id: "a", state: "settled" }, { id: "b", state: "settled" }])) + .toBeUndefined(); + expect(deckLayout([{ id: "a", state: "settled" }, { id: "b", state: "open" }])) + .toEqual({ lead: "a", members: ["a", "b"] }); + }); + + it("leads with the first visible member", () => { + expect(deckLayout([ + { id: "a", state: "hidden" }, + { id: "b", state: "open" }, + { id: "c", state: "open" }, + ])).toEqual({ lead: "b", members: ["b", "c"] }); + }); +}); diff --git a/packages/editor/src/deck-runs.ts b/packages/editor/src/deck-runs.ts new file mode 100644 index 000000000..20662babc --- /dev/null +++ b/packages/editor/src/deck-runs.ts @@ -0,0 +1,36 @@ +/** + * Which decisions sit together closely enough to be worked through as a deck. + * + * "Related" is adjacency only: cards with nothing but empty caret paragraphs + * between them. One Planner ask anchored to one block inserts its cards this + * way, so a batch becomes a deck without any new record. + */ + +import { $getRoot, $isParagraphNode } from "lexical"; + +import { $isQuestionnaireNode } from "@chopin/dialect"; + +export type MemberState = "open" | "settled" | "hidden"; + +export function $collectDecisionRuns(): string[][] { + let runs: string[][] = []; + let current: string[] = []; + let close = () => { + if (current.length > 1) runs.push(current); + current = []; + }; + for (let node of $getRoot().getChildren()) { + if ($isQuestionnaireNode(node)) current.push(node.getId()); + else if (!($isParagraphNode(node) && node.getChildrenSize() === 0)) close(); + } + close(); + return runs; +} + +export function deckLayout( + members: ReadonlyArray<{ id: string; state: MemberState }>, +): { lead: string; members: string[] } | undefined { + let visible = members.filter(member => member.state !== "hidden"); + if (visible.length < 2 || !visible.some(member => member.state === "open")) return undefined; + return { lead: visible[0]!.id, members: visible.map(member => member.id) }; +} diff --git a/packages/editor/src/questionnaires.test.ts b/packages/editor/src/questionnaires.test.ts index 47a047f09..eb818e004 100644 --- a/packages/editor/src/questionnaires.test.ts +++ b/packages/editor/src/questionnaires.test.ts @@ -47,14 +47,22 @@ function read(editor: LexicalEditor): PlanQuestionnaireState { } describe("plan questionnaire state", () => { + it("publishes adjacent questionnaires as a run", () => { + let second = QUESTIONNAIRE + .replace("01K0N4TR8K7JGM4R1J7PW4R8YJ", "01K0N4TR8K7JGM4R1J7PW4R8YK") + .replace("01K0N4V4E7Y6P4MJ5WD8XZF3B2", "01K0N4V4E7Y6P4MJ5WD8XZF3B3"); + expect(state(`${QUESTIONNAIRE}\n${second}`).runs) + .toEqual([["01K0N4TR8K7JGM4R1J7PW4R8YJ", "01K0N4TR8K7JGM4R1J7PW4R8YK"]]); + }); + it("keeps a questionnaire-only document out of plan content", () => { - expect(state("")).toEqual({ entries: [], hasPlanContent: false }); + expect(state("")).toEqual({ entries: [], hasPlanContent: false, runs: [] }); let emptyParagraph = open(""); emptyParagraph.update(() => { $getRoot().append($createParagraphNode()); }, { discrete: true }); - expect(read(emptyParagraph)).toEqual({ entries: [], hasPlanContent: false }); + expect(read(emptyParagraph)).toEqual({ entries: [], hasPlanContent: false, runs: [] }); expect(state(QUESTIONNAIRE)).toMatchObject({ entries: [{ id: "01K0N4TR8K7JGM4R1J7PW4R8YJ" }], @@ -71,7 +79,7 @@ describe("plan questionnaire state", () => { "```ts\nlet answer = 42;\n```\n", ] ) { - expect(state(source)).toEqual({ entries: [], hasPlanContent: true }); + expect(state(source)).toEqual({ entries: [], hasPlanContent: true, runs: [] }); } }); diff --git a/packages/editor/src/questionnaires.ts b/packages/editor/src/questionnaires.ts index 6da8a23e8..10809374a 100644 --- a/packages/editor/src/questionnaires.ts +++ b/packages/editor/src/questionnaires.ts @@ -17,6 +17,7 @@ import { useEffect } from "react"; import { $isDecisionNode, $isQuestionnaireNode, QuestionnaireNode } from "@chopin/dialect"; import { counts, relate, relations, resolve } from "./anchors"; +import { $collectDecisionRuns } from "./deck-runs"; import { holds, paint, pin, unpin } from "./marks"; import { $blockPoints } from "./passage"; import { scrollToKey } from "./scroll"; @@ -37,6 +38,8 @@ export type QuestionnaireEntry = { export type PlanQuestionnaireState = { entries: QuestionnaireEntry[]; hasPlanContent: boolean; + /** Adjacent questionnaires, two or more per run, in document order. */ + runs: string[][]; }; export type DecisionsReadiness = "loading" | "ready" | "unavailable"; @@ -57,11 +60,11 @@ export function collectPlanState(): PlanQuestionnaireState { if ($isParagraphNode(node) && node.getChildrenSize() === 0) return false; return true; }); - return { entries, hasPlanContent }; + return { entries, hasPlanContent, runs: $collectDecisionRuns() }; } export class QuestionnaireStore { - #state: PlanQuestionnaireState = { entries: [], hasPlanContent: false }; + #state: PlanQuestionnaireState = { entries: [], hasPlanContent: false, runs: [] }; #readiness: DecisionsReadiness = "loading"; #synced = false; #readRevision = 0; @@ -89,6 +92,11 @@ export class QuestionnaireStore { }; snapshot = (): QuestionnaireEntry[] => this.#state.entries; + /** The run a questionnaire belongs to, if it sits beside another. */ + runOf(id: string): readonly string[] | undefined { + return this.#state.runs.find(run => run.includes(id)); + } + contentSnapshot = (): boolean => this.#state.hasPlanContent; readinessSnapshot = (): DecisionsReadiness => this.#readiness; @@ -126,8 +134,8 @@ export class QuestionnaireStore { this.#synced = false; this.#readRevision++; let changed = this.#state.entries.length > 0 || this.#state.hasPlanContent - || this.#readiness !== "loading"; - this.#state = { entries: [], hasPlanContent: false }; + || this.#state.runs.length > 0 || this.#readiness !== "loading"; + this.#state = { entries: [], hasPlanContent: false, runs: [] }; this.#readiness = "loading"; if (!changed) return; for (let listener of this.#listeners) listener(); From 83ed8b6655eba816b886ba38af8850aef05da345 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 07:38:38 +0100 Subject: [PATCH 03/18] Add a decision deck with keyboard cycling and announcements Co-Authored-By: Claude Sonnet 5.5 --- packages/editor/src/deck.css | 69 ++++++++++ packages/editor/src/deck.test.tsx | 64 ++++++++++ packages/editor/src/deck.tsx | 203 ++++++++++++++++++++++++++++++ packages/editor/src/index.ts | 2 + 4 files changed, 338 insertions(+) create mode 100644 packages/editor/src/deck.css create mode 100644 packages/editor/src/deck.test.tsx create mode 100644 packages/editor/src/deck.tsx diff --git a/packages/editor/src/deck.css b/packages/editor/src/deck.css new file mode 100644 index 000000000..b31aebb57 --- /dev/null +++ b/packages/editor/src/deck.css @@ -0,0 +1,69 @@ +/* packages/editor/src/deck.css */ +/* + * A decision deck. The top card sits in flow and sets the height; the cards + * behind are pinned to its box and lifted so only their header edges show. + */ +.decision-deck { + --deck-peek: 1.875rem; + display: grid; + gap: 0.625rem; +} + +.decision-deck-stage { + position: relative; + display: grid; + padding-block-start: calc(var(--deck-peek) * var(--deck-depth, 0)); +} + +.decision-deck-slot { + border-radius: var(--radius-lg); + transform-origin: top center; + outline: none; +} + +.decision-deck-slot[data-deck-top] { + position: relative; +} + +.decision-deck-slot:not([data-deck-top]) { + position: absolute; + inset: calc(var(--deck-peek) * var(--deck-depth, 0)) 0 0; + overflow: hidden; + pointer-events: none; +} + +/* Back cards read as paper behind the live one. */ +.decision-deck-slot:not([data-deck-top]) > * { + background: var(--color-inset); +} + +/* A settled line has no card of its own; the slot gives it one. */ +.decision-deck-slot[data-deck-settled] { + padding: 0.625rem 0.75rem; + background: var(--color-inset); + box-shadow: var(--shadow-resting); +} + +.decision-deck-slot[data-deck-top]:focus-visible { + box-shadow: 0 0 0 2px var(--color-brand); +} + +.decision-deck-nav { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.decision-deck-count { + margin-inline-end: auto; + font-size: var(--text-xs); + font-variant-numeric: tabular-nums; + color: var(--color-text-secondary); +} + +@media (prefers-reduced-motion: reduce) { + .decision-deck-slot, + .decision-deck-stage { + transition: none; + } +} diff --git a/packages/editor/src/deck.test.tsx b/packages/editor/src/deck.test.tsx new file mode 100644 index 000000000..254e003ef --- /dev/null +++ b/packages/editor/src/deck.test.tsx @@ -0,0 +1,64 @@ +// packages/editor/src/deck.test.tsx +import { describe, expect, it } from "bun:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { Deck, deckAnnouncement, deckKey } from "./deck"; + +function markup(settled: string[] = []) { + return renderToStaticMarkup(createElement(Deck, { + label: "Decisions", + reduced: () => true, + items: ["a", "b", "c"].map(id => ({ + id, + settled: settled.includes(id), + label: `Header ${id}`, + card: createElement("article", { "aria-label": "Decision" }, `Card ${id}`), + })), + })); +} + +describe("deck semantics", () => { + it("names itself as a carousel of decisions", () => { + let html = markup(); + expect(html).toContain('aria-roledescription="carousel"'); + expect(html).toContain('aria-label="Decisions"'); + expect(html).toContain('aria-roledescription="decision"'); + expect(html).toContain('aria-label="1 of 3"'); + }); + + it("leaves only the top card interactive", () => { + let html = markup(); + expect(html.match(/inert=""/g)?.length).toBe(2); + expect(html.match(/data-deck-top=""/g)?.length).toBe(1); + }); + + it("offers previous and next controls and a count", () => { + let html = markup(["a"]); + expect(html).toContain('aria-label="Previous decision"'); + expect(html).toContain('aria-label="Next decision"'); + expect(html).toContain("2 of 3"); + expect(html).toContain("1 decided"); + }); + + it("starts with open cards in front", () => { + expect(markup(["a"])).toMatch(/data-deck-slot="b" data-deck-top=""/); + }); +}); + +describe("deck keyboard", () => { + it("cycles only from the deck's own controls and slots", () => { + expect(deckKey("ArrowRight", "control")).toBe(1); + expect(deckKey("ArrowLeft", "slot")).toBe(-1); + expect(deckKey("ArrowRight", "other")).toBeUndefined(); + expect(deckKey("Enter", "control")).toBeUndefined(); + }); +}); + +describe("deck announcements", () => { + it("says where the reader is", () => { + expect(deckAnnouncement("cycle", 2, 3, "Scope")).toBe("Decision 2 of 3: Scope"); + expect(deckAnnouncement("settle", 2, 3, "Scope")) + .toBe("Decided. Now showing 2 of 3: Scope"); + }); +}); diff --git a/packages/editor/src/deck.tsx b/packages/editor/src/deck.tsx new file mode 100644 index 000000000..066ef1457 --- /dev/null +++ b/packages/editor/src/deck.tsx @@ -0,0 +1,203 @@ +// packages/editor/src/deck.tsx +/** + * Several decisions worked through one at a time. + * + * The top card is the only live one; the cards behind show a header edge and + * are inert. Order is the reader's own and never leaves this component. + * + * Keys are handled with a native listener because Lexical listens on the + * content-editable root natively, above React's root, and would otherwise move + * the caret before a synthetic handler could stop it. + */ + +import { useEffect, useId, useRef, useState } from "react"; +import { flushSync } from "react-dom"; +import { ArrowLeftIcon, ArrowRightIcon } from "@chopin/icons"; + +import { arrange, bringForward, cycle, PEEKS, position, settle, slot } from "./deck-order"; + +import type { CSSProperties, ReactNode } from "react"; + +import "./deck.css"; + +export type DeckItem = { + id: string; + /** Answered, discarded or expired: kept behind the open cards. */ + settled: boolean; + /** The decision's header, for the slide name and announcements. */ + label: string; + card: ReactNode; +}; + +export type DeckProps = { + items: DeckItem[]; + label: string; + /** Whether to skip the shuffle. Defaults to the reduced-motion preference. */ + reduced?: () => boolean; + className?: string; +}; + +export const DECK_FRONT_EVENT = "chopin:deck-front"; + +/** Bring the deck member that contains `element` to the top, synchronously. */ +export function revealDeckItem(element: Element): void { + let slotElement = element.closest("[data-deck-slot]"); + slotElement?.dispatchEvent( + new CustomEvent(DECK_FRONT_EVENT, { bubbles: true, detail: slotElement.dataset.deckSlot }), + ); +} + +export function deckKey(key: string, target: "control" | "slot" | "other"): 1 | -1 | undefined { + if (target === "other") return undefined; + if (key === "ArrowRight") return 1; + if (key === "ArrowLeft") return -1; + return undefined; +} + +export function deckAnnouncement( + kind: "cycle" | "settle", + index: number, + total: number, + label: string, +): string { + return kind === "cycle" + ? `Decision ${index} of ${total}: ${label}` + : `Decided. Now showing ${index} of ${total}: ${label}`; +} + +/** Used by the animated shuffle in the next change. */ +export function prefersReduced(): boolean { + return typeof matchMedia === "function" + && matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +export function Deck({ className, items, label }: DeckProps) { + let [order, setOrder] = useState(() => settle(arrange([], items), items)); + let current = arrange(order, items); + let [announcement, setAnnouncement] = useState(""); + let root = useRef(null); + let slots = useRef(new Map()); + let stageId = useId(); + let byId = new Map(items.map(item => [item.id, item])); + let top = current[0]; + let decided = items.filter(item => item.settled).length; + + let reorder = (next: string[], kind: "cycle" | "settle") => { + setOrder(next); + let first = byId.get(next[0] ?? ""); + if (first) { + setAnnouncement(deckAnnouncement(kind, position(items, first.id), items.length, first.label)); + } + }; + + // Task 4 replaces this effect's body with the timed, animated settle. + useEffect(() => { + let settled = settle(current, items); + if (settled.join() !== current.join()) reorder(settled, "settle"); + }); + + useEffect(() => { + let element = root.current; + if (!element) return; + let keydown = (event: KeyboardEvent) => { + let target = event.target as HTMLElement; + let kind: "control" | "slot" | "other" = target.closest("[data-deck-control]") + ? "control" + : target.matches("[data-deck-slot]") + ? "slot" + : "other"; + let direction = deckKey(event.key, kind); + if (!direction) return; + event.preventDefault(); + event.stopPropagation(); + let next = cycle(arrange(order, items), direction); + reorder(next, "cycle"); + if (kind === "slot") requestAnimationFrame(() => slots.current.get(next[0]!)?.focus()); + }; + let front = (event: Event) => { + let id = (event as CustomEvent).detail; + flushSync(() => setOrder(previous => bringForward(arrange(previous, items), id))); + }; + element.addEventListener("keydown", keydown); + element.addEventListener(DECK_FRONT_EVENT, front); + return () => { + element.removeEventListener("keydown", keydown); + element.removeEventListener(DECK_FRONT_EVENT, front); + }; + }); + + let depth = Math.min(current.length - 1, PEEKS); + return ( +
+
+ {items.map(item => { + let at = current.indexOf(item.id); + let place = slot(at); + let isTop = at === 0; + return ( +
{ + if (element) slots.current.set(item.id, element); + else slots.current.delete(item.id); + }} + role="group" + style={{ transform: place.transform, zIndex: place.zIndex, opacity: place.opacity }} + tabIndex={isTop ? -1 : undefined} + > + {item.card} +
+ ); + })} +
+
+ + {position(items, top)} of {items.length} + {decided > 0 && · {decided} decided} + + + +
+

+ {announcement} +

+
+ ); +} diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index df1c7d762..eba856cb1 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -22,6 +22,8 @@ export { export type { DecisionView, DecisionViewState, OpeningPhase } from "./decision-state"; export { Decisions } from "./decisions"; export type { DecisionsProps } from "./decisions"; +export { Deck, DECK_FRONT_EVENT, revealDeckItem } from "./deck"; +export type { DeckItem, DeckProps } from "./deck"; export { disclosureAccessibility, MotionDisclosure, From 69f0db76ee77de7e41f50b6587e7c9acc1755c2b Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 07:40:26 +0100 Subject: [PATCH 04/18] Ignore modified arrows in the deck and fix the count spacing Co-Authored-By: Claude Sonnet 5.5 --- packages/editor/src/deck.test.tsx | 11 ++++++++--- packages/editor/src/deck.tsx | 19 +++++++++++++++---- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/packages/editor/src/deck.test.tsx b/packages/editor/src/deck.test.tsx index 254e003ef..921b73264 100644 --- a/packages/editor/src/deck.test.tsx +++ b/packages/editor/src/deck.test.tsx @@ -24,7 +24,7 @@ describe("deck semantics", () => { expect(html).toContain('aria-roledescription="carousel"'); expect(html).toContain('aria-label="Decisions"'); expect(html).toContain('aria-roledescription="decision"'); - expect(html).toContain('aria-label="1 of 3"'); + expect(html).toContain('aria-label="1 of 3: Header a"'); }); it("leaves only the top card interactive", () => { @@ -37,8 +37,7 @@ describe("deck semantics", () => { let html = markup(["a"]); expect(html).toContain('aria-label="Previous decision"'); expect(html).toContain('aria-label="Next decision"'); - expect(html).toContain("2 of 3"); - expect(html).toContain("1 decided"); + expect(html.replace(/<[^>]*>|/g, "")).toContain("2 of 3 · 1 decided"); }); it("starts with open cards in front", () => { @@ -53,6 +52,12 @@ describe("deck keyboard", () => { expect(deckKey("ArrowRight", "other")).toBeUndefined(); expect(deckKey("Enter", "control")).toBeUndefined(); }); + + it("ignores modified arrows", () => { + for (let modifier of ["alt", "ctrl", "meta", "shift"]) { + expect(deckKey("ArrowRight", "control", { [modifier]: true })).toBeUndefined(); + } + }); }); describe("deck announcements", () => { diff --git a/packages/editor/src/deck.tsx b/packages/editor/src/deck.tsx index 066ef1457..c7bc761ad 100644 --- a/packages/editor/src/deck.tsx +++ b/packages/editor/src/deck.tsx @@ -47,7 +47,12 @@ export function revealDeckItem(element: Element): void { ); } -export function deckKey(key: string, target: "control" | "slot" | "other"): 1 | -1 | undefined { +export function deckKey( + key: string, + target: "control" | "slot" | "other", + modifiers: { alt?: boolean; ctrl?: boolean; meta?: boolean; shift?: boolean } = {}, +): 1 | -1 | undefined { + if (modifiers.alt || modifiers.ctrl || modifiers.meta || modifiers.shift) return undefined; if (target === "other") return undefined; if (key === "ArrowRight") return 1; if (key === "ArrowLeft") return -1; @@ -106,7 +111,13 @@ export function Deck({ className, items, label }: DeckProps) { : target.matches("[data-deck-slot]") ? "slot" : "other"; - let direction = deckKey(event.key, kind); + if (event.defaultPrevented) return; + let direction = deckKey(event.key, kind, { + alt: event.altKey, + ctrl: event.ctrlKey, + meta: event.metaKey, + shift: event.shiftKey, + }); if (!direction) return; event.preventDefault(); event.stopPropagation(); @@ -148,7 +159,7 @@ export function Deck({ className, items, label }: DeckProps) { return (
{position(items, top)} of {items.length} - {decided > 0 && · {decided} decided} + {decided > 0 && {" · "}{decided} decided}