From fe39ef02b66708d73c9a5cb25995c3f9befb728e Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Sun, 8 Mar 2026 04:16:40 +1100 Subject: [PATCH 01/16] scss --- site/src/styles/globals.scss | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/site/src/styles/globals.scss b/site/src/styles/globals.scss index 6ac11ad..03f7dfd 100644 --- a/site/src/styles/globals.scss +++ b/site/src/styles/globals.scss @@ -1,4 +1,4 @@ -@import "modules/reset.scss"; -@import "modules/variables.scss"; -@import "modules/globals.scss"; -@import "modules/codeblock.scss"; +@use "modules/reset.scss"; +@use "modules/variables.scss"; +@use "modules/globals.scss"; +@use "modules/codeblock.scss"; From 1d1d5aeebbd49b0974a040d3cf220149fa4e91a9 Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Sun, 8 Mar 2026 04:20:14 +1100 Subject: [PATCH 02/16] add tests + fix animations --- packages/torph/package.json | 6 +- packages/torph/src/lib/text-morph/index.ts | 20 +- .../text-morph/utils/__tests__/diff.test.ts | 277 ++++++++ .../torph/src/lib/text-morph/utils/diff.ts | 231 +++++++ .../torph/src/lib/text-morph/utils/dom.ts | 25 + packages/torph/vitest.config.ts | 5 + pnpm-lock.yaml | 619 +++++++++++++++++- 7 files changed, 1178 insertions(+), 5 deletions(-) create mode 100644 packages/torph/src/lib/text-morph/utils/__tests__/diff.test.ts create mode 100644 packages/torph/src/lib/text-morph/utils/diff.ts create mode 100644 packages/torph/vitest.config.ts diff --git a/packages/torph/package.json b/packages/torph/package.json index a7dd8b5..b0633a7 100644 --- a/packages/torph/package.json +++ b/packages/torph/package.json @@ -50,6 +50,7 @@ "dev": "tsup --watch", "lint": "eslint -c .eslintrc.cjs ./src/**/*.{ts,tsx}", "lint:fix": "eslint --fix -c .eslintrc.cjs ./src/**/*.{ts,tsx}", + "test": "vitest run", "pre-commit": "lint-staged" }, "keywords": [ @@ -70,8 +71,8 @@ "peerDependencies": { "react": ">=18", "react-dom": ">=18", - "vue": ">=3", - "svelte": ">=5" + "svelte": ">=5", + "vue": ">=3" }, "peerDependenciesMeta": { "react": { @@ -106,6 +107,7 @@ "svelte": "^5.0.0", "tsup": "^8.5.0", "typescript": "^5.9.3", + "vitest": "^4.0.18", "vue": "^3.3.0" } } diff --git a/packages/torph/src/lib/text-morph/index.ts b/packages/torph/src/lib/text-morph/index.ts index 51a6575..a1ebd60 100644 --- a/packages/torph/src/lib/text-morph/index.ts +++ b/packages/torph/src/lib/text-morph/index.ts @@ -13,7 +13,8 @@ import { animateEnterOrPersist, transitionContainerSize, } from "./utils/animate"; -import { detachFromFlow, reconcileChildren } from "./utils/dom"; +import { detachFromFlow, splitWordSpans, reconcileChildren } from "./utils/dom"; +import { diffSegments } from "./utils/diff"; import { addStyles, removeStyles } from "./utils/styles"; import { ATTR_ROOT, @@ -49,6 +50,7 @@ export class TextMorph { private currentMeasures: Measures = {}; private prevMeasures: Measures = {}; + private previousSegments: Segment[] = []; private isInitialRender = true; private reducedMotion: ReducedMotionState | null = null; @@ -129,7 +131,19 @@ export class TextMorph { const oldWidth = element.offsetWidth; const oldHeight = element.offsetHeight; - const segments = segmentText(value, this.options.locale!); + let segments: Segment[]; + let splits: Map; + + if (this.previousSegments.length > 0) { + const result = diffSegments(this.previousSegments, value, this.options.locale!); + segments = result.segments; + splits = result.splits; + } else { + segments = segmentText(value, this.options.locale!); + splits = new Map(); + } + + splitWordSpans(element, splits); this.prevMeasures = measure(this.element); const oldChildren = Array.from(element.children) as HTMLElement[]; @@ -178,6 +192,8 @@ export class TextMorph { }); }); + this.previousSegments = segments; + if (this.isInitialRender) { this.isInitialRender = false; element.style.width = "auto"; diff --git a/packages/torph/src/lib/text-morph/utils/__tests__/diff.test.ts b/packages/torph/src/lib/text-morph/utils/__tests__/diff.test.ts new file mode 100644 index 0000000..4feb034 --- /dev/null +++ b/packages/torph/src/lib/text-morph/utils/__tests__/diff.test.ts @@ -0,0 +1,277 @@ +import { describe, it, expect } from "vitest"; +import { segmentText } from "../segment"; +import { diffSegments } from "../diff"; +import type { Segment } from "../segment"; + +function ids(segments: Segment[]): string[] { + return segments.map((s) => s.id); +} + +function strings(segments: Segment[]): string[] { + return segments.map((s) => s.string); +} + +function wordIds(segments: Segment[]): string[] { + return segments.filter((s) => s.string !== "\u00A0").map((s) => s.id); +} + +describe("diffSegments", () => { + describe("exact word matches", () => { + it("persists matching words with same IDs", () => { + const old = segmentText("Transaction Safe", "en"); + const { segments, splits } = diffSegments(old, "Transaction Safe", "en"); + + expect(splits.size).toBe(0); + // "Transaction" should keep the same ID + const txn = segments.find((s) => s.string === "Transaction"); + const oldTxn = old.find((s) => s.string === "Transaction"); + expect(txn?.id).toBe(oldTxn?.id); + }); + + it("handles word reordering — Transaction Safe → Processing Transaction", () => { + const old = segmentText("Transaction Safe", "en"); + const { segments, splits } = diffSegments( + old, + "Processing Transaction", + "en", + ); + + expect(splits.size).toBe(0); + + // "Transaction" persists with the same ID + const oldTxnId = old.find((s) => s.string === "Transaction")!.id; + const newTxn = segments.find((s) => s.string === "Transaction"); + expect(newTxn?.id).toBe(oldTxnId); + + // "Processing" is a new word (different ID from "Safe") + const processing = segments.find((s) => s.string === "Processing"); + expect(processing?.id).toBe("Processing"); + + // "Safe" is NOT in new segments + expect(segments.find((s) => s.string === "Safe")).toBeUndefined(); + }); + + it("handles reverse — Processing Transaction → Transaction Safe", () => { + const old = segmentText("Processing Transaction", "en"); + const { segments, splits } = diffSegments( + old, + "Transaction Safe", + "en", + ); + + expect(splits.size).toBe(0); + + const oldTxnId = old.find((s) => s.string === "Transaction")!.id; + const newTxn = segments.find((s) => s.string === "Transaction"); + expect(newTxn?.id).toBe(oldTxnId); + + expect(segments.find((s) => s.string === "Safe")).toBeDefined(); + expect(segments.find((s) => s.string === "Processing")).toBeUndefined(); + }); + }); + + describe("multi-cycle stability", () => { + it("Transaction Safe ↔ Processing Transaction — 4 cycles", () => { + let prev = segmentText("Processing Transaction", "en"); + const txnId = prev.find((s) => s.string === "Transaction")!.id; + + // Cycle 1: → Transaction Safe + let result = diffSegments(prev, "Transaction Safe", "en"); + expect(result.segments.find((s) => s.string === "Transaction")?.id).toBe( + txnId, + ); + prev = result.segments; + + // Cycle 2: → Processing Transaction + result = diffSegments(prev, "Processing Transaction", "en"); + expect(result.segments.find((s) => s.string === "Transaction")?.id).toBe( + txnId, + ); + prev = result.segments; + + // Cycle 3: → Transaction Safe (again) + result = diffSegments(prev, "Transaction Safe", "en"); + expect(result.segments.find((s) => s.string === "Transaction")?.id).toBe( + txnId, + ); + prev = result.segments; + + // Cycle 4: → Processing Transaction (again) + result = diffSegments(prev, "Processing Transaction", "en"); + expect(result.segments.find((s) => s.string === "Transaction")?.id).toBe( + txnId, + ); + }); + + it("IDs are consistent across repeated cycles", () => { + let prev = segmentText("Processing Transaction", "en"); + + const cycle1 = diffSegments(prev, "Transaction Safe", "en"); + const cycle2 = diffSegments( + cycle1.segments, + "Processing Transaction", + "en", + ); + const cycle3 = diffSegments( + cycle2.segments, + "Transaction Safe", + "en", + ); + const cycle4 = diffSegments( + cycle3.segments, + "Processing Transaction", + "en", + ); + + // Cycle 1 and 3 should produce identical segment IDs + expect(ids(cycle1.segments)).toEqual(ids(cycle3.segments)); + // Cycle 2 and 4 should produce identical segment IDs + expect(ids(cycle2.segments)).toEqual(ids(cycle4.segments)); + }); + + it("space IDs do not persist between different texts", () => { + const old = segmentText("Transaction Safe", "en"); + const { segments } = diffSegments( + old, + "Processing Transaction", + "en", + ); + + const oldSpaceIds = old + .filter((s) => s.string === "\u00A0") + .map((s) => s.id); + const newSpaceIds = segments + .filter((s) => s.string === "\u00A0") + .map((s) => s.id); + + // Space IDs should differ because the words have different lengths, + // so character offsets differ. This ensures exiting words anchor to + // word segments, not spaces. + for (const newId of newSpaceIds) { + expect(oldSpaceIds).not.toContain(newId); + } + }); + }); + + describe("character morphing", () => { + it("npm → pnpm — splits word into chars and morphs", () => { + const old = segmentText("npm i torph", "en"); + const { segments, splits } = diffSegments( + old, + "pnpm add torph", + "en", + ); + + // "npm" should be split into character spans + expect(splits.has("npm")).toBe(true); + const charSegs = splits.get("npm")!; + expect(charSegs).toHaveLength(3); + expect(strings(charSegs)).toEqual(["n", "p", "m"]); + + // New segments should have char-level entries for pnpm + // "p" is new, "n", "p", "m" persist from npm + const pnpmChars = segments.filter( + (s) => s.string !== "\u00A0" && s.string.length === 1, + ); + expect(strings(pnpmChars)).toEqual(["p", "n", "p", "m"]); + + // The persisting chars should have IDs matching the split + const nSeg = pnpmChars.find( + (s) => s.string === "n" && s.id === charSegs[0]!.id, + ); + expect(nSeg).toBeDefined(); + + // "torph" persists as a word + const torph = segments.find((s) => s.string === "torph"); + expect(torph?.id).toBe("torph"); + + // "add" enters as a new word (no morph with "i") + const add = segments.find((s) => s.string === "add"); + expect(add).toBeDefined(); + }); + + it("pnpm → npm — reverse morph", () => { + const old = segmentText("pnpm i torph", "en"); + const { segments, splits } = diffSegments(old, "npm i torph", "en"); + + // "pnpm" should be split + expect(splits.has("pnpm")).toBe(true); + + // n, p, m should persist from "pnpm" to "npm" + // Filter to chars that reference the old "pnpm" word (exclude "i" which is a whole word) + const morphedChars = segments.filter( + (s) => s.string.length === 1 && s.id.startsWith("pnpm:"), + ); + expect(strings(morphedChars)).toEqual(["n", "p", "m"]); + + // "i" should persist as a whole word + expect(segments.find((s) => s.string === "i" && s.id === "i")).toBeDefined(); + }); + + it("does NOT morph dissimilar words", () => { + const old = segmentText("cat and dog", "en"); + const { segments, splits } = diffSegments( + old, + "fish and bird", + "en", + ); + + // No splits — words are too different + expect(splits.size).toBe(0); + + // "and" persists + expect(segments.find((s) => s.string === "and")?.id).toBe("and"); + // "fish" and "bird" are whole words + expect(segments.find((s) => s.string === "fish")).toBeDefined(); + expect(segments.find((s) => s.string === "bird")).toBeDefined(); + }); + + it("multi-cycle morph: npm → pnpm → npm", () => { + const initial = segmentText("npm i torph", "en"); + + // npm → pnpm + const r1 = diffSegments(initial, "pnpm i torph", "en"); + expect(r1.splits.has("npm")).toBe(true); + + // pnpm → npm (reverse) + const r2 = diffSegments(r1.segments, "npm i torph", "en"); + + // The char-level segments from r1 should produce char-level output for npm + // n, p, m persist from old "pnpm" chars + const morphedChars = r2.segments.filter( + (s) => s.string.length === 1 && s.string !== "\u00A0" && s.id !== "i", + ); + expect(strings(morphedChars)).toEqual(["n", "p", "m"]); + + // "i" and "torph" should persist as whole words + expect(r2.segments.find((s) => s.string === "i")).toBeDefined(); + expect(r2.segments.find((s) => s.string === "torph")).toBeDefined(); + }); + }); + + describe("fallback to segmentText", () => { + it("uses segmentText when old has no spaces", () => { + const old = segmentText("hello", "en"); + const { segments, splits } = diffSegments(old, "hello world", "en"); + + expect(splits.size).toBe(0); + // Should produce standard word segments + expect(segments.find((s) => s.string === "hello")).toBeDefined(); + expect(segments.find((s) => s.string === "world")).toBeDefined(); + }); + + it("uses segmentText when new has no spaces", () => { + const old = segmentText("hello world", "en"); + const { segments, splits } = diffSegments(old, "hello", "en"); + + expect(splits.size).toBe(0); + }); + + it("uses segmentText when old segments are empty", () => { + const { segments, splits } = diffSegments([], "hello world", "en"); + + expect(splits.size).toBe(0); + expect(segments.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/packages/torph/src/lib/text-morph/utils/diff.ts b/packages/torph/src/lib/text-morph/utils/diff.ts new file mode 100644 index 0000000..96cabda --- /dev/null +++ b/packages/torph/src/lib/text-morph/utils/diff.ts @@ -0,0 +1,231 @@ +import type { Segment } from "./segment"; +import { segmentText } from "./segment"; + +export type DiffResult = { + segments: Segment[]; + splits: Map; +}; + +type WordGroup = { + word: string; + segments: Segment[]; +}; + +function groupIntoWords(segments: Segment[]): WordGroup[] { + const groups: WordGroup[] = []; + let current: Segment[] = []; + + for (const seg of segments) { + if (seg.string === "\u00A0") { + if (current.length > 0) { + groups.push({ + word: current.map((s) => s.string).join(""), + segments: [...current], + }); + current = []; + } + } else { + current.push(seg); + } + } + if (current.length > 0) { + groups.push({ + word: current.map((s) => s.string).join(""), + segments: [...current], + }); + } + + return groups; +} + +function lcsIndices(a: string[], b: string[]): [number[], number[]] { + const m = a.length; + const n = b.length; + const dp: number[][] = Array.from({ length: m + 1 }, () => + Array(n + 1).fill(0), + ); + + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i]![j] = + a[i - 1] === b[j - 1] + ? dp[i - 1]![j - 1]! + 1 + : Math.max(dp[i - 1]![j]!, dp[i]![j - 1]!); + } + } + + const ai: number[] = []; + const bi: number[] = []; + let i = m; + let j = n; + while (i > 0 && j > 0) { + if (a[i - 1] === b[j - 1]) { + ai.unshift(i - 1); + bi.unshift(j - 1); + i--; + j--; + } else if (dp[i - 1]![j]! >= dp[i]![j - 1]!) { + i--; + } else { + j--; + } + } + + return [ai, bi]; +} + +function charSimilarity(a: string, b: string): number { + if (a.length === 0 || b.length === 0) return 0; + const [matched] = lcsIndices(a.split(""), b.split("")); + return matched.length / Math.max(a.length, b.length); +} + +const MIN_SIMILARITY = 0.4; + +export function diffSegments( + oldSegments: Segment[], + newText: string, + locale: Intl.LocalesArgument, +): DiffResult { + const newHasSpaces = newText.includes(" "); + const oldWords = groupIntoWords(oldSegments); + + if (oldWords.length <= 1 || !newHasSpaces) { + return { segments: segmentText(newText, locale), splits: new Map() }; + } + + const newWordStrings = newText.split(" ").filter((w) => w.length > 0); + const oldWordStrings = oldWords.map((g) => g.word); + + // Word-level LCS + const [oldLcsIdx, newLcsIdx] = lcsIndices(oldWordStrings, newWordStrings); + const oldMatchedSet = new Set(oldLcsIdx); + const newMatchedSet = new Set(newLcsIdx); + + const newToOldWord = new Map(); + for (let k = 0; k < newLcsIdx.length; k++) { + newToOldWord.set(newLcsIdx[k]!, oldLcsIdx[k]!); + } + + // Pair unmatched words by similarity + const oldUnmatched = oldWordStrings + .map((_, i) => i) + .filter((i) => !oldMatchedSet.has(i)); + const newUnmatched = newWordStrings + .map((_, i) => i) + .filter((i) => !newMatchedSet.has(i)); + + const morphPairs = new Map(); + const usedOld = new Set(); + + for (const ni of newUnmatched) { + let bestOi = -1; + let bestSim = MIN_SIMILARITY; + + for (const oi of oldUnmatched) { + if (usedOld.has(oi)) continue; + const sim = charSimilarity(oldWordStrings[oi]!, newWordStrings[ni]!); + if (sim > bestSim) { + bestSim = sim; + bestOi = oi; + } + } + + if (bestOi >= 0) { + morphPairs.set(ni, bestOi); + usedOld.add(bestOi); + } + } + + const usedIds = new Set(); + function uniqueId(base: string): string { + if (!usedIds.has(base)) { + usedIds.add(base); + return base; + } + let i = 1; + while (usedIds.has(`${base}~${i}`)) i++; + const id = `${base}~${i}`; + usedIds.add(id); + return id; + } + + const segments: Segment[] = []; + const splits = new Map(); + let charOffset = 0; + + for (let ni = 0; ni < newWordStrings.length; ni++) { + if (ni > 0) { + // Use character-position-based space IDs (matching segmentText's scheme) + // so spaces don't persist between different texts — this keeps exit + // anchors pointing at words rather than spaces. + segments.push({ + id: `space-${charOffset}`, + string: "\u00A0", + }); + charOffset++; + } + + if (newToOldWord.has(ni)) { + // Exact word match — reuse old segments + const oi = newToOldWord.get(ni)!; + const oldGroup = oldWords[oi]!; + for (const seg of oldGroup.segments) { + usedIds.add(seg.id); + segments.push(seg); + } + } else if (morphPairs.has(ni)) { + // Character morph between similar words + const oi = morphPairs.get(ni)!; + const oldGroup = oldWords[oi]!; + const oldWord = oldGroup.word; + const newWord = newWordStrings[ni]!; + + // Get or create old char segments + let oldCharSegs: Segment[]; + if (oldGroup.segments.length === 1) { + const wordSeg = oldGroup.segments[0]!; + oldCharSegs = oldWord.split("").map((c, i) => ({ + id: `${wordSeg.id}:${i}`, + string: c, + })); + splits.set(wordSeg.id, oldCharSegs); + } else { + oldCharSegs = oldGroup.segments; + } + + // Character-level LCS + const oldChars = oldWord.split(""); + const newChars = newWord.split(""); + const [oldCharLcs, newCharLcs] = lcsIndices(oldChars, newChars); + + const newCharToOldSeg = new Map(); + for (let k = 0; k < newCharLcs.length; k++) { + newCharToOldSeg.set(newCharLcs[k]!, oldCharSegs[oldCharLcs[k]!]!); + } + + for (let ci = 0; ci < newChars.length; ci++) { + if (newCharToOldSeg.has(ci)) { + const oldSeg = newCharToOldSeg.get(ci)!; + usedIds.add(oldSeg.id); + segments.push({ id: oldSeg.id, string: newChars[ci]! }); + } else { + segments.push({ + id: uniqueId(`${newWord}~${ci}`), + string: newChars[ci]!, + }); + } + } + } else { + // No match — new word enters + segments.push({ + id: uniqueId(newWordStrings[ni]!), + string: newWordStrings[ni]!, + }); + } + + charOffset += newWordStrings[ni]!.length; + } + + return { segments, splits }; +} diff --git a/packages/torph/src/lib/text-morph/utils/dom.ts b/packages/torph/src/lib/text-morph/utils/dom.ts index a9808ca..6251b91 100644 --- a/packages/torph/src/lib/text-morph/utils/dom.ts +++ b/packages/torph/src/lib/text-morph/utils/dom.ts @@ -29,6 +29,31 @@ export function detachFromFlow(elements: HTMLElement[]) { }); } +export function splitWordSpans( + element: HTMLElement, + splits: Map, +) { + if (splits.size === 0) return; + + const children = Array.from(element.children) as HTMLElement[]; + for (const child of children) { + if (child.hasAttribute(ATTR_EXITING)) continue; + const id = child.getAttribute(ATTR_ID); + if (!id) continue; + const charSegs = splits.get(id); + if (!charSegs) continue; + + for (const seg of charSegs) { + const span = document.createElement("span"); + span.setAttribute(ATTR_ITEM, ""); + span.setAttribute(ATTR_ID, seg.id); + span.textContent = seg.string; + child.before(span); + } + child.remove(); + } +} + export function reconcileChildren( element: HTMLElement, oldChildren: HTMLElement[], diff --git a/packages/torph/vitest.config.ts b/packages/torph/vitest.config.ts new file mode 100644 index 0000000..77a73cf --- /dev/null +++ b/packages/torph/vitest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: {}, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f67d798..4a144d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -181,6 +181,9 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@20.5.1)(jsdom@28.1.0)(sass@1.93.0)(yaml@2.8.1) vue: specifier: ^3.3.0 version: 3.5.24(typescript@5.9.3) @@ -227,6 +230,19 @@ importers: packages: + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + + '@asamuzakjp/css-color@5.0.1': + resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@6.8.1': + resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.22.13': resolution: {integrity: sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==} engines: {node: '>=6.9.0'} @@ -339,6 +355,10 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@changesets/apply-release-plan@6.1.4': resolution: {integrity: sha512-FMpKF1fRlJyCZVYHr3CbinpZZ+6MwvOtWUuO8uo+svcATEoc1zRDcj23pAurJ2TZ/uVz1wFHH6K3NlACy0PLew==} @@ -464,6 +484,37 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.1.1': + resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.0.2': + resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.0': + resolution: {integrity: sha512-H4tuz2nhWgNKLt1inYpoVCfbJbMwX/lQKp3g69rrrIMIYlFD9+zTykOKhNR8uGrAmbS/kT9n6hTFkmDkxLgeTA==} + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -815,6 +866,15 @@ packages: resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1368,9 +1428,15 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1645,6 +1711,35 @@ packages: vite: ^4.0.0 || ^5.0.0 vue: ^3.2.25 + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.18': + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + + '@vitest/runner@4.0.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + + '@vitest/snapshot@4.0.18': + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + + '@vitest/spy@4.0.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@vue/compiler-core@3.5.24': resolution: {integrity: sha512-eDl5H57AOpNakGNAkFDH+y7kTqrQpJkZFXhWZQGyx/5Wh7B1uQYvcWkvZi11BDhscPgj8N7XV3oRwiPnx1Vrig==} @@ -1697,6 +1792,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -1805,6 +1904,10 @@ packages: resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==} engines: {node: '>=0.10.0'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} @@ -1839,6 +1942,9 @@ packages: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -1897,6 +2003,10 @@ packages: caniuse-lite@1.0.30001743: resolution: {integrity: sha512-e6Ojr7RV14Un7dz6ASD0aZDmQPT/A+eZU+nuTNfjqmRrmkmQlnTNWH0SKmqagx9PeW87UVqapSurtAXifmtdmw==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -2034,6 +2144,14 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssstyle@6.2.0: + resolution: {integrity: sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==} + engines: {node: '>=20'} + csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} @@ -2060,6 +2178,10 @@ packages: resolution: {integrity: sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg==} engines: {node: '>=8'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -2106,6 +2228,9 @@ packages: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -2184,6 +2309,10 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} @@ -2207,6 +2336,9 @@ packages: resolution: {integrity: sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -2441,6 +2573,9 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -2456,6 +2591,10 @@ packages: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} @@ -2741,6 +2880,18 @@ packages: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} engines: {node: '>=10'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-id@1.0.2: resolution: {integrity: sha512-UNopramDEhHJD+VR+ehk8rOslwSfByxPIZyJRfV739NDhN5LF1fa1MqnzKm2lGTQRjNrjK19Q5fhkgIfjlVUKw==} @@ -2911,6 +3062,9 @@ packages: resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==} engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-reference@3.0.3: resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} @@ -3020,6 +3174,15 @@ packages: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true + jsdom@28.1.0: + resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -3171,6 +3334,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.6: + resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} + engines: {node: 20 || >=22} + lru-cache@4.1.5: resolution: {integrity: sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==} @@ -3202,6 +3369,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + meow@6.1.1: resolution: {integrity: sha512-3YffViIt2QWgTy6Pale5QpopX/IvU3LPL03jOTqp6pGj3VjesdO/U8CuHMKpnQr4shCNCM5fd5XFFvIIl6JBHg==} engines: {node: '>=8'} @@ -3454,6 +3624,9 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -3573,6 +3746,10 @@ packages: resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==} engines: {node: '>=6'} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -3727,6 +3904,10 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -3812,6 +3993,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -3869,6 +4053,12 @@ packages: stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -3986,6 +4176,9 @@ packages: resolution: {integrity: sha512-uxck1KI7JWtlfP3H6HOWi/94soAl23jsGJkBzN2BAWcQng0+lTrRNhxActFqORgnO9BHVd1hKJhG+ljRuIUWfQ==} engines: {node: '>=18'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + synckit@0.11.11: resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} engines: {node: ^14.18.0 || >=16.0.0} @@ -4011,13 +4204,31 @@ packages: through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + + tldts-core@7.0.25: + resolution: {integrity: sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==} + + tldts@7.0.25: + resolution: {integrity: sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w==} + hasBin: true + tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -4030,9 +4241,17 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + tr46@1.0.1: resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -4177,6 +4396,10 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + undici@7.22.0: + resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} + engines: {node: '>=20.18.1'} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -4285,6 +4508,40 @@ packages: vite: optional: true + vitest@4.0.18: + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vue@3.5.24: resolution: {integrity: sha512-uTHDOpVQTMjcGgrqFPSb8iO2m1DUvo+WbGqoXQz8Y1CeBYQ0FXf2z1gLRaBtHjlRz7zZUBHxjVB5VTLzYkvftg==} peerDependencies: @@ -4293,6 +4550,10 @@ packages: typescript: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} @@ -4316,6 +4577,18 @@ packages: webidl-conversions@4.0.2: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + whatwg-url@7.1.0: resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} @@ -4358,6 +4631,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -4374,6 +4652,13 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@4.0.3: resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} @@ -4441,6 +4726,30 @@ packages: snapshots: + '@acemir/cssom@0.9.31': + optional: true + + '@asamuzakjp/css-color@5.0.1': + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.2.6 + optional: true + + '@asamuzakjp/dom-selector@6.8.1': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.6 + optional: true + + '@asamuzakjp/nwsapi@2.3.9': + optional: true + '@babel/code-frame@7.22.13': dependencies: '@babel/highlight': 7.22.20 @@ -4581,6 +4890,11 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + optional: true + '@changesets/apply-release-plan@6.1.4': dependencies: '@babel/runtime': 7.23.2 @@ -4850,6 +5164,34 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@6.0.2': + optional: true + + '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + optional: true + + '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + optional: true + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + optional: true + + '@csstools/css-syntax-patches-for-csstree@1.1.0': + optional: true + + '@csstools/css-tokenizer@4.0.0': + optional: true + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -5064,6 +5406,9 @@ snapshots: '@eslint/core': 0.15.2 levn: 0.4.1 + '@exodus/bytes@1.15.0': + optional: true + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -5518,8 +5863,15 @@ snapshots: dependencies: '@babel/types': 7.28.4 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/cookie@0.6.0': {} + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/is-ci@3.0.3': @@ -5814,6 +6166,45 @@ snapshots: vite: 5.4.21(@types/node@20.5.1)(sass@1.93.0) vue: 3.5.24(typescript@5.9.3) + '@vitest/expect@4.0.18': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.18(vite@6.4.1(@types/node@20.5.1)(sass@1.93.0)(yaml@2.8.1))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.1(@types/node@20.5.1)(sass@1.93.0)(yaml@2.8.1) + + '@vitest/pretty-format@4.0.18': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.18': + dependencies: + '@vitest/utils': 4.0.18 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.18': {} + + '@vitest/utils@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + tinyrainbow: 3.0.3 + '@vue/compiler-core@3.5.24': dependencies: '@babel/parser': 7.28.5 @@ -5883,6 +6274,9 @@ snapshots: acorn@8.15.0: {} + agent-base@7.1.4: + optional: true + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -6026,6 +6420,8 @@ snapshots: arrify@1.0.1: {} + assertion-error@2.0.1: {} + ast-types-flow@0.0.8: {} async-function@1.0.0: {} @@ -6048,6 +6444,11 @@ snapshots: dependencies: is-windows: 1.0.2 + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + optional: true + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -6114,6 +6515,8 @@ snapshots: caniuse-lite@1.0.30001743: {} + chai@6.2.2: {} + chalk@2.4.2: dependencies: ansi-styles: 3.2.1 @@ -6242,6 +6645,20 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + optional: true + + cssstyle@6.2.0: + dependencies: + '@asamuzakjp/css-color': 5.0.1 + '@csstools/css-syntax-patches-for-csstree': 1.1.0 + css-tree: 3.2.1 + lru-cache: 11.2.6 + optional: true + csstype@3.1.3: {} csstype@3.2.3: {} @@ -6263,6 +6680,14 @@ snapshots: dargs@7.0.0: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + optional: true + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -6300,6 +6725,9 @@ snapshots: decamelize@1.2.0: {} + decimal.js@10.6.0: + optional: true + deep-is@0.1.4: {} deepmerge@4.3.1: {} @@ -6371,6 +6799,9 @@ snapshots: entities@4.5.0: {} + entities@6.0.1: + optional: true + error-ex@1.3.2: dependencies: is-arrayish: 0.2.1 @@ -6497,6 +6928,8 @@ snapshots: iterator.prototype: 1.1.5 safe-array-concat: 1.1.3 + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -6893,6 +7326,10 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} eventemitter3@5.0.1: {} @@ -6921,6 +7358,8 @@ snapshots: signal-exit: 4.1.0 strip-final-newline: 3.0.0 + expect-type@1.3.0: {} + extendable-error@0.1.7: {} external-editor@3.1.0: @@ -7217,6 +7656,29 @@ snapshots: dependencies: lru-cache: 6.0.0 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.0 + transitivePeerDependencies: + - '@noble/hashes' + optional: true + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + optional: true + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + optional: true + human-id@1.0.2: {} human-signals@2.1.0: {} @@ -7376,6 +7838,9 @@ snapshots: is-plain-obj@1.1.0: {} + is-potential-custom-element-name@1.0.1: + optional: true + is-reference@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -7490,6 +7955,34 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@28.1.0: + dependencies: + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.8.1 + '@bramus/specificity': 2.4.2 + '@exodus/bytes': 1.15.0 + cssstyle: 6.2.0 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.0 + undici: 7.22.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + - supports-color + optional: true + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -7633,6 +8126,9 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.2.6: + optional: true + lru-cache@4.1.5: dependencies: pseudomap: 1.0.2 @@ -7662,6 +8158,9 @@ snapshots: math-intrinsics@1.1.0: {} + mdn-data@2.27.1: + optional: true + meow@6.1.1: dependencies: '@types/minimist': 1.2.4 @@ -7809,7 +8308,7 @@ snapshots: dependencies: hosted-git-info: 4.1.0 is-core-module: 2.13.0 - semver: 7.5.4 + semver: 7.7.4 validate-npm-package-license: 3.0.4 npm-run-path@4.0.1: @@ -7937,6 +8436,11 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse5@8.0.0: + dependencies: + entities: 6.0.1 + optional: true + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -8024,6 +8528,9 @@ snapshots: punycode@2.3.0: {} + punycode@2.3.1: + optional: true + queue-microtask@1.2.3: {} quick-lru@4.0.1: {} @@ -8224,6 +8731,11 @@ snapshots: '@parcel/watcher': 2.5.1 optional: true + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + optional: true + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -8350,6 +8862,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -8409,6 +8923,10 @@ snapshots: stable-hash@0.0.5: {} + stackback@0.0.2: {} + + std-env@3.10.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -8568,6 +9086,9 @@ snapshots: magic-string: 0.30.21 zimmerframe: 1.1.4 + symbol-tree@3.2.4: + optional: true + synckit@0.11.11: dependencies: '@pkgr/core': 0.2.9 @@ -8590,13 +9111,27 @@ snapshots: through@2.3.8: {} + tinybench@2.9.0: {} + tinyexec@0.3.2: {} + tinyexec@1.0.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.0.3: {} + + tldts-core@7.0.25: + optional: true + + tldts@7.0.25: + dependencies: + tldts-core: 7.0.25 + optional: true + tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 @@ -8607,10 +9142,20 @@ snapshots: totalist@3.0.1: {} + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.25 + optional: true + tr46@1.0.1: dependencies: punycode: 2.3.0 + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + optional: true + tree-kill@1.2.2: {} trim-newlines@3.0.1: {} @@ -8793,6 +9338,9 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + undici@7.22.0: + optional: true + universalify@0.1.2: {} universalify@2.0.0: {} @@ -8868,6 +9416,44 @@ snapshots: optionalDependencies: vite: 6.4.1(@types/node@20.5.1)(sass@1.93.0)(yaml@2.8.1) + vitest@4.0.18(@types/node@20.5.1)(jsdom@28.1.0)(sass@1.93.0)(yaml@2.8.1): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@6.4.1(@types/node@20.5.1)(sass@1.93.0)(yaml@2.8.1)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 6.4.1(@types/node@20.5.1)(sass@1.93.0)(yaml@2.8.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.5.1 + jsdom: 28.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + vue@3.5.24(typescript@5.9.3): dependencies: '@vue/compiler-dom': 3.5.24 @@ -8878,6 +9464,11 @@ snapshots: optionalDependencies: typescript: 5.9.3 + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + optional: true + wcwidth@1.0.1: dependencies: defaults: 1.0.4 @@ -8891,6 +9482,21 @@ snapshots: webidl-conversions@4.0.2: {} + webidl-conversions@8.0.1: + optional: true + + whatwg-mimetype@5.0.0: + optional: true + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + optional: true + whatwg-url@7.1.0: dependencies: lodash.sortby: 4.7.0 @@ -8969,6 +9575,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wrap-ansi@6.2.0: @@ -8989,6 +9600,12 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.0 + xml-name-validator@5.0.0: + optional: true + + xmlchars@2.2.0: + optional: true + y18n@4.0.3: {} y18n@5.0.8: {} From 1d2fdffac4afe3771624e495ae9ff69badb76081 Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Sun, 8 Mar 2026 04:59:50 +1100 Subject: [PATCH 03/16] tests --- packages/torph/src/index.ts | 4 + packages/torph/src/lib/text-morph/index.ts | 37 +- .../text-morph/utils/__tests__/diff.test.ts | 51 +- .../torph/src/lib/text-morph/utils/diff.ts | 23 +- site/src/app/playground/tests/page.tsx | 5 + site/src/surfaces/playground-tests/index.tsx | 512 ++++++++++++++++++ .../playground-tests/styles.module.scss | 157 ++++++ 7 files changed, 771 insertions(+), 18 deletions(-) create mode 100644 site/src/app/playground/tests/page.tsx create mode 100644 site/src/surfaces/playground-tests/index.tsx create mode 100644 site/src/surfaces/playground-tests/styles.module.scss diff --git a/packages/torph/src/index.ts b/packages/torph/src/index.ts index 9980bca..63f6c62 100644 --- a/packages/torph/src/index.ts +++ b/packages/torph/src/index.ts @@ -1,3 +1,7 @@ export { DEFAULT_AS, DEFAULT_TEXT_MORPH_OPTIONS, MorphController, TextMorph } from "./lib/text-morph"; export type { TextMorphOptions } from "./lib/text-morph/types"; export type { SpringParams } from "./lib/text-morph/utils/spring"; +export { segmentText } from "./lib/text-morph/utils/segment"; +export type { Segment } from "./lib/text-morph/utils/segment"; +export { diffSegments } from "./lib/text-morph/utils/diff"; +export type { DiffResult } from "./lib/text-morph/utils/diff"; diff --git a/packages/torph/src/lib/text-morph/index.ts b/packages/torph/src/lib/text-morph/index.ts index a1ebd60..f6735f0 100644 --- a/packages/torph/src/lib/text-morph/index.ts +++ b/packages/torph/src/lib/text-morph/index.ts @@ -46,13 +46,14 @@ export class TextMorph { private element: HTMLElement; private options: Omit & { ease?: string } = {}; - private data: HTMLElement | string; + private data: HTMLElement | string | null; private currentMeasures: Measures = {}; private prevMeasures: Measures = {}; private previousSegments: Segment[] = []; private isInitialRender = true; private reducedMotion: ReducedMotionState | null = null; + private _collapseTimer: ReturnType | null = null; constructor(options: TextMorphOptions) { @@ -85,13 +86,14 @@ export class TextMorph { if (options.debug) this.element.setAttribute(ATTR_DEBUG, ""); } - this.data = ""; + this.data = null; if (!this.isDisabled()) { addStyles(); } } destroy() { + if (this._collapseTimer) clearTimeout(this._collapseTimer); this.reducedMotion?.destroy(); this.element.getAnimations().forEach((anim) => anim.cancel()); this.element.removeAttribute(ATTR_ROOT); @@ -201,13 +203,30 @@ export class TextMorph { return; } - transitionContainerSize( - element, - oldWidth, - oldHeight, - this.options.duration!, - this.options.onAnimationComplete, - ); + if (segments.length === 0) { + // Keep container at old size while exits play, then collapse + element.style.width = `${oldWidth}px`; + element.style.height = `${oldHeight}px`; + const collapseTimer = setTimeout(() => { + element.style.width = "auto"; + element.style.height = "auto"; + this.options.onAnimationComplete?.(); + }, this.options.duration!); + // Store for cleanup if another update comes before exits finish + this._collapseTimer = collapseTimer; + } else { + if (this._collapseTimer) { + clearTimeout(this._collapseTimer); + this._collapseTimer = null; + } + transitionContainerSize( + element, + oldWidth, + oldHeight, + this.options.duration!, + this.options.onAnimationComplete, + ); + } } private updateStyles(segments: Segment[]) { diff --git a/packages/torph/src/lib/text-morph/utils/__tests__/diff.test.ts b/packages/torph/src/lib/text-morph/utils/__tests__/diff.test.ts index 4feb034..32920a9 100644 --- a/packages/torph/src/lib/text-morph/utils/__tests__/diff.test.ts +++ b/packages/torph/src/lib/text-morph/utils/__tests__/diff.test.ts @@ -249,22 +249,44 @@ describe("diffSegments", () => { }); }); - describe("fallback to segmentText", () => { - it("uses segmentText when old has no spaces", () => { + describe("granularity transitions", () => { + it("persists chars when going from single word to multi word", () => { const old = segmentText("hello", "en"); const { segments, splits } = diffSegments(old, "hello world", "en"); expect(splits.size).toBe(0); - // Should produce standard word segments - expect(segments.find((s) => s.string === "hello")).toBeDefined(); + // Old char segments should persist (grapheme IDs reused) + const oldCharIds = old.map((s) => s.id); + const newCharIds = segments + .filter((s) => s.string !== "\u00A0") + .filter((s) => s.string.length === 1) + .map((s) => s.id); + // All old char IDs should appear in new segments + for (const id of oldCharIds) { + expect(newCharIds).toContain(id); + } + // "world" should enter as a new word expect(segments.find((s) => s.string === "world")).toBeDefined(); }); - it("uses segmentText when new has no spaces", () => { + it("persists word when going from multi word to single word", () => { const old = segmentText("hello world", "en"); - const { segments, splits } = diffSegments(old, "hello", "en"); + const { segments } = diffSegments(old, "hello", "en"); + + // "hello" should persist with same ID + const oldHello = old.find((s) => s.string === "hello")!; + const newHello = segments.find((s) => s.string === "hello"); + expect(newHello?.id).toBe(oldHello.id); + // "world" should not be present + expect(segments.find((s) => s.string === "world")).toBeUndefined(); + }); + + it("uses segmentText when both old and new are single words", () => { + const old = segmentText("hello", "en"); + const { segments, splits } = diffSegments(old, "world", "en"); expect(splits.size).toBe(0); + expect(segments.length).toBeGreaterThan(0); }); it("uses segmentText when old segments are empty", () => { @@ -274,4 +296,21 @@ describe("diffSegments", () => { expect(segments.length).toBeGreaterThan(0); }); }); + + describe("word reordering beyond LCS", () => { + it("persists both words when swapped — hello world → world hello", () => { + const old = segmentText("hello world", "en"); + const { segments, splits } = diffSegments(old, "world hello", "en"); + + expect(splits.size).toBe(0); + + const oldHello = old.find((s) => s.string === "hello")!; + const oldWorld = old.find((s) => s.string === "world")!; + const newHello = segments.find((s) => s.string === "hello"); + const newWorld = segments.find((s) => s.string === "world"); + + expect(newHello?.id).toBe(oldHello.id); + expect(newWorld?.id).toBe(oldWorld.id); + }); + }); }); diff --git a/packages/torph/src/lib/text-morph/utils/diff.ts b/packages/torph/src/lib/text-morph/utils/diff.ts index 96cabda..e8ac3da 100644 --- a/packages/torph/src/lib/text-morph/utils/diff.ts +++ b/packages/torph/src/lib/text-morph/utils/diff.ts @@ -90,7 +90,7 @@ export function diffSegments( const newHasSpaces = newText.includes(" "); const oldWords = groupIntoWords(oldSegments); - if (oldWords.length <= 1 || !newHasSpaces) { + if (oldWords.length <= 1 && !newHasSpaces) { return { segments: segmentText(newText, locale), splits: new Map() }; } @@ -108,13 +108,30 @@ export function diffSegments( } // Pair unmatched words by similarity - const oldUnmatched = oldWordStrings + let oldUnmatched = oldWordStrings .map((_, i) => i) .filter((i) => !oldMatchedSet.has(i)); - const newUnmatched = newWordStrings + let newUnmatched = newWordStrings .map((_, i) => i) .filter((i) => !newMatchedSet.has(i)); + // Exact-match reordered words that LCS couldn't capture (order-preserving) + const exactUsed = new Set(); + for (const ni of newUnmatched) { + for (const oi of oldUnmatched) { + if (exactUsed.has(oi)) continue; + if (newWordStrings[ni] === oldWordStrings[oi]) { + newToOldWord.set(ni, oi); + exactUsed.add(oi); + break; + } + } + } + if (exactUsed.size > 0) { + oldUnmatched = oldUnmatched.filter((i) => !exactUsed.has(i)); + newUnmatched = newUnmatched.filter((i) => !newToOldWord.has(i)); + } + const morphPairs = new Map(); const usedOld = new Set(); diff --git a/site/src/app/playground/tests/page.tsx b/site/src/app/playground/tests/page.tsx new file mode 100644 index 0000000..b7e4882 --- /dev/null +++ b/site/src/app/playground/tests/page.tsx @@ -0,0 +1,5 @@ +import { PlaygroundTests } from "@/surfaces/playground-tests"; + +export default function Page() { + return ; +} diff --git a/site/src/surfaces/playground-tests/index.tsx b/site/src/surfaces/playground-tests/index.tsx new file mode 100644 index 0000000..11e0704 --- /dev/null +++ b/site/src/surfaces/playground-tests/index.tsx @@ -0,0 +1,512 @@ +"use client"; + +import styles from "./styles.module.scss"; +import React from "react"; +import { TextMorph } from "torph/react"; +import { segmentText, diffSegments } from "torph"; +import type { Segment } from "torph"; +import { Button } from "@/components/button"; + +type VerifyFn = () => { pass: boolean; detail: string }; + +type TestCase = { + label: string; + description: string; + tags: string[]; + values: string[]; + align?: React.CSSProperties["textAlign"]; + verify?: VerifyFn; +}; + +function verifyWordPersistence( + from: string, + to: string, + word: string, +): { pass: boolean; detail: string } { + const old = segmentText(from, "en"); + const { segments } = diffSegments(old, to, "en"); + const oldSeg = old.find((s: Segment) => s.string === word); + const newSeg = segments.find((s: Segment) => s.string === word); + if (!oldSeg || !newSeg) { + return { + pass: false, + detail: `"${word}" missing in ${!oldSeg ? "old" : "new"}`, + }; + } + const pass = oldSeg.id === newSeg.id; + return { + pass, + detail: pass + ? `"${word}" ID persists` + : `"${word}" ID changed: ${oldSeg.id} → ${newSeg.id}`, + }; +} + +function verifyCharMorph( + from: string, + to: string, + splitWord: string, +): { pass: boolean; detail: string } { + const old = segmentText(from, "en"); + const { splits } = diffSegments(old, to, "en"); + const pass = splits.has(splitWord); + return { + pass, + detail: pass + ? `"${splitWord}" split into chars` + : `"${splitWord}" was NOT split`, + }; +} + +function verifyNoMorph( + from: string, + to: string, +): { pass: boolean; detail: string } { + const old = segmentText(from, "en"); + const { splits } = diffSegments(old, to, "en"); + const pass = splits.size === 0; + return { + pass, + detail: pass + ? "No char splits (correct)" + : `Unexpected splits: ${[...splits.keys()].join(", ")}`, + }; +} + +function verifyCycleStability( + a: string, + b: string, + word: string, +): { pass: boolean; detail: string } { + let prev = segmentText(a, "en"); + const originalId = prev.find((s: Segment) => s.string === word)?.id; + if (!originalId) + return { pass: false, detail: `"${word}" not found in "${a}"` }; + + for (let i = 0; i < 4; i++) { + const target = i % 2 === 0 ? b : a; + const { segments } = diffSegments(prev, target, "en"); + const seg = segments.find((s: Segment) => s.string === word); + if (!seg || seg.id !== originalId) { + return { pass: false, detail: `"${word}" ID changed at cycle ${i + 1}` }; + } + prev = segments; + } + return { pass: true, detail: `"${word}" ID stable across 4 cycles` }; +} + +function verifyWordAbsent( + from: string, + to: string, + word: string, +): { pass: boolean; detail: string } { + const old = segmentText(from, "en"); + const { segments } = diffSegments(old, to, "en"); + const found = segments.find((s: Segment) => s.string === word); + const pass = !found; + return { + pass, + detail: pass + ? `"${word}" correctly absent` + : `"${word}" unexpectedly present`, + }; +} + +function verifyGraphemeMorph( + from: string, + to: string, + sharedChars: string[], +): { pass: boolean; detail: string } { + // Single-word texts use grapheme segmentation, so both old and new are char segments + const oldSegs = segmentText(from, "en"); + const newSegs = segmentText(to, "en"); + // Check that shared characters exist in both + const oldChars = oldSegs.map((s: Segment) => s.string); + const newChars = newSegs.map((s: Segment) => s.string); + const allShared = sharedChars.every( + (c) => oldChars.includes(c) && newChars.includes(c), + ); + return { + pass: allShared, + detail: allShared + ? `Shared chars [${sharedChars.join(",")}] present in both` + : `Some shared chars missing`, + }; +} + +function combineResults(...results: { pass: boolean; detail: string }[]) { + const pass = results.every((r) => r.pass); + return { pass, detail: results.map((r) => r.detail).join("; ") }; +} + +const TESTS: TestCase[] = [ + { + label: "Word reorder + exit", + description: + "Transaction should FLIP to its new position. Safe should exit, Processing should enter. On repeat cycles, exit direction should stay consistent.", + tags: ["flip", "exit direction"], + values: ["Transaction Safe", "Processing Transaction"], + verify: () => + verifyWordPersistence( + "Transaction Safe", + "Processing Transaction", + "Transaction", + ), + }, + { + label: "Character morph (add prefix)", + description: + 'The "p" should enter while "n", "p", "m" persist and FLIP into new positions. "i" and "torph" stay unchanged.', + tags: ["char morph", "split"], + values: ["npm i torph", "pnpm i torph"], + verify: () => verifyCharMorph("npm i torph", "pnpm i torph", "npm"), + }, + { + label: "Character morph + word swap", + description: + '"npm" morphs to "pnpm" at char level. "i" exits and "add" enters as whole words. "torph" persists.', + tags: ["char morph", "enter", "exit"], + values: ["npm i torph", "pnpm add torph"], + verify: () => + combineResults( + verifyCharMorph("npm i torph", "pnpm add torph", "npm"), + verifyWordPersistence("npm i torph", "pnpm add torph", "torph"), + ), + }, + { + label: "Reverse character morph", + description: + '"pnpm" splits into chars. "n", "p", "m" persist into "npm", the leading "p" exits.', + tags: ["char morph", "reverse"], + values: ["pnpm i torph", "npm i torph"], + verify: () => verifyCharMorph("pnpm i torph", "npm i torph", "pnpm"), + }, + { + label: "Dissimilar word replacement", + description: + '"cat" and "dog" exit as whole words (no char morph). "fish" and "bird" enter. "and" persists in place.', + tags: ["no morph", "enter", "exit"], + values: ["cat and dog", "fish and bird"], + verify: () => + combineResults( + verifyNoMorph("cat and dog", "fish and bird"), + verifyWordPersistence("cat and dog", "fish and bird", "and"), + ), + }, + { + label: "Same word, reversed order", + description: + 'Both "hello" and "world" FLIP to swap positions. No enter/exit — just movement.', + tags: ["flip"], + values: ["hello world", "world hello"], + verify: () => + combineResults( + verifyWordPersistence("hello world", "world hello", "hello"), + verifyWordPersistence("hello world", "world hello", "world"), + ), + }, + { + label: "Add word", + description: '"hello" persists in place. "world" enters with fade + scale.', + tags: ["enter"], + values: ["hello", "hello world"], + verify: () => { + // "hello" goes from grapheme segments to a word match — old char IDs should persist + const old = segmentText("hello", "en"); + const { segments } = diffSegments(old, "hello world", "en"); + const oldCharIds = old.map((s: Segment) => s.id); + const newCharIds = segments + .filter((s: Segment) => s.string !== "\u00A0" && s.string.length === 1) + .map((s: Segment) => s.id); + const allPersist = oldCharIds.every((id) => newCharIds.includes(id)); + const worldEnters = segments.some((s: Segment) => s.string === "world"); + return { + pass: allPersist && worldEnters, + detail: allPersist + ? worldEnters + ? "hello chars persist; world enters" + : "world missing" + : "Some hello char IDs lost", + }; + }, + }, + { + label: "Remove word", + description: '"hello" persists. "world" exits with fade out.', + tags: ["exit"], + values: ["hello world", "hello"], + verify: () => + combineResults( + verifyWordPersistence("hello world", "hello", "hello"), + verifyWordAbsent("hello world", "hello", "world"), + ), + }, + { + label: "Multi-word persist", + description: + '"the" and "brown" persist across the first two states. Changed words enter/exit smoothly.', + tags: ["flip", "enter", "exit"], + values: [ + "the quick brown fox", + "the slow brown dog", + "a quick brown fox jumps", + ], + verify: () => + combineResults( + verifyWordPersistence( + "the quick brown fox", + "the slow brown dog", + "brown", + ), + verifyWordPersistence( + "the quick brown fox", + "the slow brown dog", + "the", + ), + ), + }, + { + label: "Single character change", + description: + '"c", "a", "r" persist. "t" exits and "d" enters (or morphs if similar enough).', + tags: ["char morph"], + values: ["cart", "card"], + verify: () => verifyGraphemeMorph("cart", "card", ["c", "a", "r"]), + }, + { + label: "Numbers", + description: + "Shared digits and symbols ($, commas) persist. New digits enter.", + tags: ["char morph"], + values: ["$1,234", "$12,345,678", "$99"], + align: "right", + verify: () => verifyGraphemeMorph("$1,234", "$12,345,678", ["$", "1", ","]), + }, + { + label: "Duplicate words", + description: + 'Both "the" instances should persist with distinct IDs. "cat"/"dog" exit, "big"/"small" enter.', + tags: ["duplicates", "flip"], + values: ["the cat and the dog", "the big and the small"], + verify: () => { + const old = segmentText("the cat and the dog", "en"); + const { segments } = diffSegments(old, "the big and the small", "en"); + const oldThes = old.filter((s: Segment) => s.string === "the"); + const newThes = segments.filter((s: Segment) => s.string === "the"); + const bothPersist = + oldThes.length === 2 && + newThes.length === 2 && + oldThes[0]!.id === newThes[0]!.id && + oldThes[1]!.id === newThes[1]!.id; + const andPersists = + old.find((s: Segment) => s.string === "and")?.id === + segments.find((s: Segment) => s.string === "and")?.id; + return { + pass: bothPersist && andPersists, + detail: bothPersist + ? andPersists + ? 'Both "the" IDs persist; "and" persists' + : '"and" ID changed' + : 'Duplicate "the" IDs not preserved', + }; + }, + }, + { + label: "Empty to text", + description: + '"hello world" should enter with animation from empty. Morphing back to "" should fade all words out gracefully without layout jumps.', + tags: ["edge case"], + values: ["", "hello world", ""], + verify: () => { + // Empty old produces valid segments via the diff path + const { segments } = diffSegments([], "hello world", "en"); + // And "hello world" → "" produces empty segments + const old = segmentText("hello world", "en"); + const r2 = diffSegments(old, "", "en"); + const pass = + segments.length > 0 && + segments.some((s: Segment) => s.string === "hello") && + r2.segments.length === 0; + return { + pass, + detail: pass + ? "Empty → text produces segments; text → empty produces none" + : `segments=${segments.length}, reverse=${r2.segments.length}`, + }; + }, + }, + { + label: "Emoji", + description: + "Emoji grapheme clusters should be treated as single segments and persist correctly.", + tags: ["grapheme", "edge case"], + values: ["Hello 👋", "Goodbye 👋"], + verify: () => + verifyWordPersistence("Hello 👋", "Goodbye 👋", "👋"), + }, + { + label: "Long sentence overlap", + description: + '"the", "quick", "fox", "over" persist. Other words swap in/out.', + tags: ["flip", "enter", "exit"], + values: [ + "the quick brown fox jumps over the lazy dog", + "the quick red fox leaps over the happy cat", + ], + verify: () => + combineResults( + verifyWordPersistence( + "the quick brown fox jumps over the lazy dog", + "the quick red fox leaps over the happy cat", + "quick", + ), + verifyWordPersistence( + "the quick brown fox jumps over the lazy dog", + "the quick red fox leaps over the happy cat", + "fox", + ), + verifyWordPersistence( + "the quick brown fox jumps over the lazy dog", + "the quick red fox leaps over the happy cat", + "over", + ), + ), + }, + { + label: "Multi-cycle stability", + description: + '"Transaction" ID stays the same across 4+ cycles. Exit direction should never flip.', + tags: ["stability", "cycles"], + values: ["Transaction Safe", "Processing Transaction"], + verify: () => + verifyCycleStability( + "Transaction Safe", + "Processing Transaction", + "Transaction", + ), + }, + { + label: "Rapid spam (auto-cycle)", + description: + "Hit Auto to toggle every 150ms. Animations should queue gracefully without glitches or jumps.", + tags: ["spam", "resilience"], + values: ["Transaction Safe", "Processing Transaction"], + verify: () => + verifyCycleStability( + "Transaction Safe", + "Processing Transaction", + "Transaction", + ), + }, +]; + +const RESULTS = TESTS.map((test) => ({ + label: test.label, + result: test.verify?.() ?? null, +})); + +function TestCard({ + test, + result, +}: { + test: TestCase; + result: { pass: boolean; detail: string } | null; +}) { + const [index, setIndex] = React.useState(0); + const [auto, setAuto] = React.useState(false); + const intervalRef = React.useRef | null>(null); + + const advance = React.useCallback(() => { + setIndex((i) => (i + 1) % test.values.length); + }, [test.values.length]); + + React.useEffect(() => { + if (auto) { + intervalRef.current = setInterval(advance, 150); + } + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [auto, advance]); + + const isSpamTest = test.tags.includes("spam"); + + return ( +
+
+
+ {test.label} + {result && ( + + {result.pass ? "PASS" : "FAIL"} + + )} +
+
+ {test.tags.map((tag) => ( + + {tag} + + ))} +
+
+

{test.description}

+
+ {test.values[index]} +
+
+ + {isSpamTest && ( + + )} + + {index + 1} / {test.values.length} + +
+
+ ); +} + +export const PlaygroundTests = () => { + const passed = RESULTS.filter((r) => r.result?.pass).length; + const failed = RESULTS.filter((r) => r.result && !r.result.pass).length; + const total = RESULTS.filter((r) => r.result).length; + + return ( +
+
+ + {passed}/{total} passed + {failed > 0 && · {failed} failed} + +
+ {RESULTS.map((r) => ( + + ))} +
+
+ {TESTS.map((test, i) => ( + + ))} +
+ ); +}; diff --git a/site/src/surfaces/playground-tests/styles.module.scss b/site/src/surfaces/playground-tests/styles.module.scss new file mode 100644 index 0000000..8932f90 --- /dev/null +++ b/site/src/surfaces/playground-tests/styles.module.scss @@ -0,0 +1,157 @@ +.grid { + display: flex; + flex-direction: column; + gap: 2rem; +} + +.summary { + position: sticky; + top: 1rem; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 1.25rem; + border-radius: 0.75rem; + background: var(--body-light); + backdrop-filter: blur(12px); +} + +.summaryLabel { + font-size: 0.8rem; + font-weight: 600; + color: rgba(255, 255, 255, 0.5); + white-space: nowrap; +} + +.summaryFail { + color: rgb(248, 113, 113); +} + +.summaryDots { + display: flex; + gap: 0.3rem; + flex-wrap: wrap; + justify-content: flex-end; +} + +.dotPass, +.dotFail, +.dotSkip { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + cursor: default; +} + +.dotPass { + background: rgb(74, 222, 128); +} + +.dotFail { + background: rgb(248, 113, 113); +} + +.dotSkip { + background: rgba(255, 255, 255, 0.15); +} + +.card { + position: relative; + border-radius: 1rem; + background: var(--body-light); + overflow: hidden; +} + +.cardHeader { + padding: 1rem 1.25rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.headerLeft { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.label { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: rgba(255, 255, 255, 0.4); +} + +.badgePass, +.badgeFail { + font-size: 0.6rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.15rem 0.4rem; + border-radius: 0.25rem; + cursor: default; +} + +.badgePass { + background: rgba(74, 222, 128, 0.15); + color: rgb(74, 222, 128); +} + +.badgeFail { + background: rgba(248, 113, 113, 0.15); + color: rgb(248, 113, 113); +} + +.description { + padding: 0.75rem 1.25rem 0; + margin: 0; + font-size: 0.8rem; + line-height: 1.5; + color: rgba(255, 255, 255, 0.35); +} + +.tags { + display: flex; + gap: 0.375rem; + flex-shrink: 0; +} + +.tag { + font-size: 0.625rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.2rem 0.5rem; + border-radius: 0.375rem; + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.35); +} + +.cardBody { + padding: 3rem 1.25rem; + text-align: center; + font-family: var(--font-secondary); + font-size: 1.5rem; + font-weight: 500; + color: #ffffff; +} + +.cardFooter { + padding: 0.75rem 1.25rem; + display: flex; + align-items: center; + gap: 0.5rem; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.step { + font-size: 0.7rem; + color: rgba(255, 255, 255, 0.3); + margin-left: auto; +} From 35ef732511fd0c1600cf58d94c1e6cacdf691650 Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Sun, 8 Mar 2026 06:19:03 +1100 Subject: [PATCH 04/16] more tests --- package.json | 2 +- packages/torph/src/lib/text-morph/index.ts | 32 +- .../torph/src/lib/text-morph/utils/animate.ts | 6 +- .../torph/src/lib/text-morph/utils/diff.ts | 5 +- scripts/bundle-sizes.mjs | 56 ++ .../playground-tests/bundle-sizes.json | 23 + site/src/surfaces/playground-tests/index.tsx | 599 ++++++++++++++++-- .../playground-tests/styles.module.scss | 336 +++++++++- 8 files changed, 991 insertions(+), 68 deletions(-) create mode 100644 scripts/bundle-sizes.mjs create mode 100644 site/src/surfaces/playground-tests/bundle-sizes.json diff --git a/package.json b/package.json index 5f0b137..d89b87e 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "dev": "pnpm run watch & pnpm run site:dev", "build": "pnpm --filter=torph build", "site:dev": "pnpm --filter=site dev", - "site:build": "pnpm run build && pnpm --filter=site build", + "site:build": "pnpm run build && node scripts/bundle-sizes.mjs && pnpm --filter=site build", "example:react": "pnpm --filter=react-example dev", "example:svelte": "pnpm --filter=svelte-example dev", "example:svelte-ssr": "pnpm --filter=svelte-ssr-example dev", diff --git a/packages/torph/src/lib/text-morph/index.ts b/packages/torph/src/lib/text-morph/index.ts index f6735f0..8ae15d3 100644 --- a/packages/torph/src/lib/text-morph/index.ts +++ b/packages/torph/src/lib/text-morph/index.ts @@ -46,15 +46,13 @@ export class TextMorph { private element: HTMLElement; private options: Omit & { ease?: string } = {}; - private data: HTMLElement | string | null; + private data: HTMLElement | string; private currentMeasures: Measures = {}; private prevMeasures: Measures = {}; private previousSegments: Segment[] = []; private isInitialRender = true; private reducedMotion: ReducedMotionState | null = null; - private _collapseTimer: ReturnType | null = null; - constructor(options: TextMorphOptions) { const { ease: rawEase, ...rest } = { ...DEFAULT_TEXT_MORPH_OPTIONS, ...options }; @@ -86,14 +84,13 @@ export class TextMorph { if (options.debug) this.element.setAttribute(ATTR_DEBUG, ""); } - this.data = null; + this.data = ""; if (!this.isDisabled()) { addStyles(); } } destroy() { - if (this._collapseTimer) clearTimeout(this._collapseTimer); this.reducedMotion?.destroy(); this.element.getAnimations().forEach((anim) => anim.cancel()); this.element.removeAttribute(ATTR_ROOT); @@ -145,6 +142,13 @@ export class TextMorph { splits = new Map(); } + // Keep a zero-width space segment so the container always has in-flow + // content, preserving the line box height during exit animations. + const isEmptyTransition = segments.length === 0; + if (isEmptyTransition) { + segments = [{ id: "empty", string: "\u200B" }]; + } + splitWordSpans(element, splits); this.prevMeasures = measure(this.element); @@ -175,7 +179,7 @@ export class TextMorph { this.updateStyles(segments); exiting.forEach((child) => { - if (this.isInitialRender) { + if (this.isInitialRender || child.getAttribute(ATTR_ID) === "empty") { child.remove(); return; } @@ -203,22 +207,19 @@ export class TextMorph { return; } - if (segments.length === 0) { - // Keep container at old size while exits play, then collapse + if (isEmptyTransition) { + // Lock container at old size while exits play so the container + // doesn't reposition (e.g. under text-align: center). + element.style.transitionProperty = "none"; element.style.width = `${oldWidth}px`; element.style.height = `${oldHeight}px`; - const collapseTimer = setTimeout(() => { + setTimeout(() => { element.style.width = "auto"; element.style.height = "auto"; + element.style.transitionProperty = ""; this.options.onAnimationComplete?.(); }, this.options.duration!); - // Store for cleanup if another update comes before exits finish - this._collapseTimer = collapseTimer; } else { - if (this._collapseTimer) { - clearTimeout(this._collapseTimer); - this._collapseTimer = null; - } transitionContainerSize( element, oldWidth, @@ -242,6 +243,7 @@ export class TextMorph { children.forEach((child, index) => { if (child.hasAttribute(ATTR_EXITING)) return; const key = child.getAttribute(ATTR_ID) || `child-${index}`; + if (key === "empty") return; const isNew = !this.prevMeasures[key]; const deltaKey = isNew diff --git a/packages/torph/src/lib/text-morph/utils/animate.ts b/packages/torph/src/lib/text-morph/utils/animate.ts index d2241e6..f19b561 100644 --- a/packages/torph/src/lib/text-morph/utils/animate.ts +++ b/packages/torph/src/lib/text-morph/utils/animate.ts @@ -128,7 +128,11 @@ export function transitionContainerSize( pendingCleanup = null; } - if (oldWidth === 0 || oldHeight === 0) return; + if (oldWidth === 0 || oldHeight === 0) { + element.style.width = "auto"; + element.style.height = "auto"; + return; + } element.style.width = "auto"; element.style.height = "auto"; diff --git a/packages/torph/src/lib/text-morph/utils/diff.ts b/packages/torph/src/lib/text-morph/utils/diff.ts index e8ac3da..b4a729e 100644 --- a/packages/torph/src/lib/text-morph/utils/diff.ts +++ b/packages/torph/src/lib/text-morph/utils/diff.ts @@ -218,7 +218,10 @@ export function diffSegments( const newCharToOldSeg = new Map(); for (let k = 0; k < newCharLcs.length; k++) { - newCharToOldSeg.set(newCharLcs[k]!, oldCharSegs[oldCharLcs[k]!]!); + const oldSeg = oldCharSegs[oldCharLcs[k]!]; + if (oldSeg) { + newCharToOldSeg.set(newCharLcs[k]!, oldSeg); + } } for (let ci = 0; ci < newChars.length; ci++) { diff --git a/scripts/bundle-sizes.mjs b/scripts/bundle-sizes.mjs new file mode 100644 index 0000000..7b70200 --- /dev/null +++ b/scripts/bundle-sizes.mjs @@ -0,0 +1,56 @@ +import { readFileSync, writeFileSync, mkdtempSync, rmSync } from "fs"; +import { gzipSync } from "zlib"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; +import { execSync } from "child_process"; +import { tmpdir } from "os"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const dist = resolve(__dirname, "../packages/torph/dist"); + +const entries = [ + { name: "core", path: "index.mjs" }, + { name: "react", path: "react/index.mjs" }, + { name: "vue", path: "vue/index.mjs" }, +]; + +function measure(dir, entryList) { + return entryList.map(({ name, path }) => { + try { + const buf = readFileSync(resolve(dir, path)); + return { name, raw: buf.length, gzip: gzipSync(buf).length }; + } catch { + return { name, raw: 0, gzip: 0 }; + } + }); +} + +// Local sizes +const local = measure(dist, entries); + +// Published sizes from npm +let published = entries.map(({ name }) => ({ name, raw: 0, gzip: 0 })); +try { + const tmp = mkdtempSync(resolve(tmpdir(), "torph-npm-")); + execSync("npm pack torph --pack-destination .", { cwd: tmp, stdio: "pipe" }); + execSync("tar -xzf *.tgz", { cwd: tmp, stdio: "pipe" }); + published = measure(resolve(tmp, "package/dist"), entries); + rmSync(tmp, { recursive: true, force: true }); +} catch (e) { + console.warn("Could not fetch published package from npm:", e.message); +} + +const sizes = local.map((l, i) => { + const p = published[i]; + return { + name: l.name, + raw: l.raw, + gzip: l.gzip, + publishedRaw: p.raw, + publishedGzip: p.gzip, + }; +}); + +const out = resolve(__dirname, "../site/src/surfaces/playground-tests/bundle-sizes.json"); +writeFileSync(out, JSON.stringify(sizes, null, 2) + "\n"); +console.log("Wrote bundle sizes:", sizes); diff --git a/site/src/surfaces/playground-tests/bundle-sizes.json b/site/src/surfaces/playground-tests/bundle-sizes.json new file mode 100644 index 0000000..9a62790 --- /dev/null +++ b/site/src/surfaces/playground-tests/bundle-sizes.json @@ -0,0 +1,23 @@ +[ + { + "name": "core", + "raw": 12445, + "gzip": 4820, + "publishedRaw": 9062, + "publishedGzip": 3628 + }, + { + "name": "react", + "raw": 1095, + "gzip": 633, + "publishedRaw": 1010, + "publishedGzip": 575 + }, + { + "name": "vue", + "raw": 1474, + "gzip": 595, + "publishedRaw": 1455, + "publishedGzip": 587 + } +] diff --git a/site/src/surfaces/playground-tests/index.tsx b/site/src/surfaces/playground-tests/index.tsx index 11e0704..59c12b5 100644 --- a/site/src/surfaces/playground-tests/index.tsx +++ b/site/src/surfaces/playground-tests/index.tsx @@ -3,9 +3,11 @@ import styles from "./styles.module.scss"; import React from "react"; import { TextMorph } from "torph/react"; -import { segmentText, diffSegments } from "torph"; +import { segmentText, diffSegments, DEFAULT_TEXT_MORPH_OPTIONS } from "torph"; import type { Segment } from "torph"; import { Button } from "@/components/button"; +import bundleSizes from "./bundle-sizes.json"; +import pkg from "../../../../packages/torph/package.json"; type VerifyFn = () => { pass: boolean; detail: string }; @@ -117,10 +119,8 @@ function verifyGraphemeMorph( to: string, sharedChars: string[], ): { pass: boolean; detail: string } { - // Single-word texts use grapheme segmentation, so both old and new are char segments const oldSegs = segmentText(from, "en"); const newSegs = segmentText(to, "en"); - // Check that shared characters exist in both const oldChars = oldSegs.map((s: Segment) => s.string); const newChars = newSegs.map((s: Segment) => s.string); const allShared = sharedChars.every( @@ -139,6 +139,19 @@ function combineResults(...results: { pass: boolean; detail: string }[]) { return { pass, detail: results.map((r) => r.detail).join("; ") }; } +function measurePerf( + fn: () => { pass: boolean; detail: string }, + iterations = 100, +) { + const start = performance.now(); + let result: { pass: boolean; detail: string } = { pass: true, detail: "" }; + for (let i = 0; i < iterations; i++) { + result = fn(); + } + const elapsed = performance.now() - start; + return { ...result, timeMs: elapsed / iterations }; +} + const TESTS: TestCase[] = [ { label: "Word reorder + exit", @@ -211,7 +224,6 @@ const TESTS: TestCase[] = [ tags: ["enter"], values: ["hello", "hello world"], verify: () => { - // "hello" goes from grapheme segments to a word match — old char IDs should persist const old = segmentText("hello", "en"); const { segments } = diffSegments(old, "hello world", "en"); const oldCharIds = old.map((s: Segment) => s.id); @@ -318,9 +330,7 @@ const TESTS: TestCase[] = [ tags: ["edge case"], values: ["", "hello world", ""], verify: () => { - // Empty old produces valid segments via the diff path const { segments } = diffSegments([], "hello world", "en"); - // And "hello world" → "" produces empty segments const old = segmentText("hello world", "en"); const r2 = diffSegments(old, "", "en"); const pass = @@ -341,8 +351,7 @@ const TESTS: TestCase[] = [ "Emoji grapheme clusters should be treated as single segments and persist correctly.", tags: ["grapheme", "edge case"], values: ["Hello 👋", "Goodbye 👋"], - verify: () => - verifyWordPersistence("Hello 👋", "Goodbye 👋", "👋"), + verify: () => verifyWordPersistence("Hello 👋", "Goodbye 👋", "👋"), }, { label: "Long sentence overlap", @@ -398,28 +407,198 @@ const TESTS: TestCase[] = [ "Transaction", ), }, + { + label: "RTL text (Arabic)", + description: + "Arabic text should segment and diff correctly. Shared words should persist across transitions.", + tags: ["i18n", "edge case"], + values: ["مرحبا بالعالم", "مرحبا يا صديقي"], + verify: () => + verifyWordPersistence("مرحبا بالعالم", "مرحبا يا صديقي", "مرحبا"), + }, + { + label: "RTL text (Hebrew)", + description: "Hebrew text segmentation and persistence of shared words.", + tags: ["i18n", "edge case"], + values: ["שלום עולם", "שלום חברים"], + verify: () => verifyWordPersistence("שלום עולם", "שלום חברים", "שלום"), + }, + { + label: "Long paragraph", + description: + "Stress test with paragraph-length text. Common words should persist, unique words should enter/exit.", + tags: ["stress", "flip"], + values: [ + "The quick brown fox jumps over the lazy dog while the sun sets behind the distant mountains", + "The slow gray wolf runs under the bright moon while the rain falls across the nearby valleys", + ], + verify: () => + combineResults( + verifyWordPersistence( + "The quick brown fox jumps over the lazy dog while the sun sets behind the distant mountains", + "The slow gray wolf runs under the bright moon while the rain falls across the nearby valleys", + "while", + ), + verifyWordPersistence( + "The quick brown fox jumps over the lazy dog while the sun sets behind the distant mountains", + "The slow gray wolf runs under the bright moon while the rain falls across the nearby valleys", + "the", + ), + ), + }, + { + label: "Whitespace normalization", + description: + "Extra spaces should not cause unexpected segment splits or ID changes.", + tags: ["edge case"], + values: ["hello world", "hello world", "hello world"], + verify: () => verifyWordPersistence("hello world", "hello world", "hello"), + }, + { + label: "Unicode accents", + description: + "Accented characters (café → cafe) should handle gracefully. Shared base chars should persist.", + tags: ["grapheme", "edge case"], + values: ["café", "cafe"], + verify: () => verifyGraphemeMorph("café", "cafe", ["c", "a", "f"]), + }, + { + label: "Compound emoji", + description: + "Complex emoji (family, flag sequences) should be treated as single grapheme segments.", + tags: ["grapheme", "edge case"], + values: ["Hello 👨‍👩‍👧‍👦", "Goodbye 👨‍👩‍👧‍👦"], + verify: () => verifyWordPersistence("Hello 👨‍👩‍👧‍👦", "Goodbye 👨‍👩‍👧‍👦", "👨‍👩‍👧‍👦"), + }, ]; -const RESULTS = TESTS.map((test) => ({ - label: test.label, - result: test.verify?.() ?? null, -})); +const ALL_TAGS = [...new Set(TESTS.flatMap((t) => t.tags))].sort(); + +type TestResult = { + label: string; + result: { pass: boolean; detail: string } | null; + timeMs: number | null; +}; + +function computeResults(): TestResult[] { + return TESTS.map((test) => { + if (!test.verify) return { label: test.label, result: null, timeMs: null }; + const { timeMs, ...result } = measurePerf(test.verify); + return { label: test.label, result, timeMs }; + }); +} + +function useResults(): TestResult[] { + const empty = TESTS.map((t) => ({ + label: t.label, + result: null, + timeMs: null, + })); + const [results, setResults] = React.useState(empty); + React.useEffect(() => { + setResults(computeResults()); + }, []); + return results; +} + +const EASINGS = { + default: DEFAULT_TEXT_MORPH_OPTIONS.ease, + spring: { stiffness: 200, damping: 20, mass: 1 }, + linear: "linear", +} as const; +type EasingKey = keyof typeof EASINGS; + +function SegmentInspector({ from, to }: { from: string; to: string }) { + const oldSegs = segmentText(from, "en"); + const { segments: newSegs, splits } = diffSegments(oldSegs, to, "en"); + + return ( +
+
+ Old segments +
+ {oldSegs.map((s, i) => ( + + {s.string === "\u00A0" ? "·" : s.string} + {s.id.slice(0, 6)} + + ))} +
+
+
+ New segments +
+ {newSegs.map((s, i) => { + const persisted = oldSegs.some((o) => o.id === s.id); + return ( + + {s.string === "\u00A0" ? "·" : s.string} + {s.id.slice(0, 6)} + + ); + })} +
+
+ {splits.size > 0 && ( +
+ Splits +
+ {[...splits.entries()].map(([word, chars]) => ( + + {word} → {chars.map((c) => c.string).join("")} + + ))} +
+
+ )} +
+ ); +} function TestCard({ test, result, + timeMs, + morphAllSignal, + cardRef, }: { test: TestCase; result: { pass: boolean; detail: string } | null; + timeMs: number | null; + morphAllSignal: number; + cardRef?: React.Ref; }) { + const SPEEDS = { + default: DEFAULT_TEXT_MORPH_OPTIONS.duration, + slow: 3000, + fast: 150, + } as const; + type Speed = keyof typeof SPEEDS; + const [index, setIndex] = React.useState(0); const [auto, setAuto] = React.useState(false); + const [showInspector, setShowInspector] = React.useState(false); + const [speed, setSpeed] = React.useState("default"); + const [easing, setEasing] = React.useState("default"); + const progressRef = React.useRef(null); const intervalRef = React.useRef | null>(null); const advance = React.useCallback(() => { setIndex((i) => (i + 1) % test.values.length); }, [test.values.length]); + React.useEffect(() => { + if (morphAllSignal > 0) advance(); + }, [morphAllSignal, advance]); + React.useEffect(() => { if (auto) { intervalRef.current = setInterval(advance, 150); @@ -430,22 +609,29 @@ function TestCard({ }, [auto, advance]); const isSpamTest = test.tags.includes("spam"); + const prevIndex = (index - 1 + test.values.length) % test.values.length; return ( -
+
{test.label} {result && ( {result.pass ? "PASS" : "FAIL"} )} + {timeMs !== null && ( + + {timeMs < 0.01 ? "<0.01" : timeMs.toFixed(2)}ms + + )}
{test.tags.map((tag) => ( @@ -456,57 +642,376 @@ function TestCard({

{test.description}

-
- {test.values[index]} +

{result ? result.detail : "\u00A0"}

+
+ { + if (progressRef.current) { + const el = progressRef.current; + el.style.transition = "none"; + el.style.width = "0%"; + el.offsetHeight; // force reflow + el.style.transition = `width ${SPEEDS[speed]}ms linear`; + el.style.width = "100%"; + } + }} + onAnimationComplete={() => { + if (progressRef.current) { + progressRef.current.style.transition = "none"; + progressRef.current.style.width = "0%"; + } + }} + > + {test.values[index]} +
- {isSpamTest && ( )} - - {index + 1} / {test.values.length} - + +
+ {(Object.keys(SPEEDS) as Speed[]).map((s) => ( + + ))} +
+
+ {(Object.keys(EASINGS) as EasingKey[]).map((e) => ( + + ))} +
+
+ + {index + 1} / {test.values.length} + +
+
+
+
+ {showInspector && ( + + )}
); } +function SandboxCard() { + const [from, setFrom] = React.useState("hello world"); + const [to, setTo] = React.useState("world hello"); + const [current, setCurrent] = React.useState("hello world"); + const progressRef = React.useRef(null); + const duration = DEFAULT_TEXT_MORPH_OPTIONS.duration; + + const toggle = React.useCallback(() => { + setCurrent((c) => (c === from ? to : from)); + }, [from, to]); + + return ( +
+
+
+ Sandbox +
+
+ custom +
+
+

+ Type any text to test morphing behavior with custom inputs. +

+
+
+ + { + setFrom(e.target.value); + setCurrent(e.target.value); + }} + /> +
+
+ + setTo(e.target.value)} + /> +
+
+
+ { + if (progressRef.current) { + const el = progressRef.current; + el.style.transition = "none"; + el.style.width = "0%"; + el.offsetHeight; + el.style.transition = `width ${duration}ms linear`; + el.style.width = "100%"; + } + }} + onAnimationComplete={() => { + if (progressRef.current) { + progressRef.current.style.transition = "none"; + progressRef.current.style.width = "0%"; + } + }} + > + {current} + +
+
+
+
+
+
+
+
+
+ ); +} + +function copyResultsToClipboard(results: TestResult[]) { + const lines = [ + "# Torph Test Results", + "", + `| Test | Status | Time |`, + `|------|--------|------|`, + ...results.map((r) => { + const status = !r.result ? "Skip" : r.result.pass ? "Pass" : "Fail"; + const time = r.timeMs !== null ? `${r.timeMs.toFixed(2)}ms` : "-"; + return `| ${r.label} | ${status} | ${time} |`; + }), + "", + `Generated: ${new Date().toISOString()}`, + ]; + navigator.clipboard.writeText(lines.join("\n")); +} + export const PlaygroundTests = () => { - const passed = RESULTS.filter((r) => r.result?.pass).length; - const failed = RESULTS.filter((r) => r.result && !r.result.pass).length; - const total = RESULTS.filter((r) => r.result).length; + const [activeTag, setActiveTag] = React.useState(null); + const [failOnly, setFailOnly] = React.useState(false); + const [morphAllSignal, setMorphAllSignal] = React.useState(0); + const [copied, setCopied] = React.useState(false); + const results = useResults(); + const cardRefs = React.useRef<(HTMLDivElement | null)[]>([]); + + const filteredIndices = TESTS.map((_, i) => i).filter((i) => { + if (activeTag && !TESTS[i]!.tags.includes(activeTag)) return false; + if (failOnly && results[i]?.result?.pass !== false) return false; + return true; + }); + + const isDev = process.env.NODE_ENV !== "production"; + const passed = results.filter((r) => r.result?.pass).length; + const failed = results.filter((r) => r.result && !r.result.pass).length; + const total = results.filter((r) => r.result).length; + + React.useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement + ) + return; + + if (e.code === "Space" && e.shiftKey) { + e.preventDefault(); + setMorphAllSignal((s) => s + 1); + return; + } + + if (e.code === "Space" && !e.shiftKey) { + const focused = document.activeElement; + if ( + focused instanceof HTMLElement && + focused.closest(`.${styles.card}`) + ) { + e.preventDefault(); + const cardBody = focused.querySelector( + `.${styles.cardBody}`, + ) as HTMLElement | null; + if (cardBody) cardBody.click(); + } + } + } + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); + + const handleCopy = () => { + copyResultsToClipboard(results); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; return (
- - {passed}/{total} passed - {failed > 0 && · {failed} failed} - -
- {RESULTS.map((r) => ( - - ))} +
+
+ + {passed}/{total} passed + {failed > 0 && ( + · {failed} failed + )} + + + v{pkg.version} + {isDev && dev} + +
+
+ {results.map((r) => ( + + ))} +
+
+
+ {bundleSizes.map((entry) => { + const diff = entry.publishedGzip + ? entry.gzip - entry.publishedGzip + : 0; + const diffStr = diff > 0 ? `+${diff}` : `${diff}`; + return ( + + {entry.name}{" "} + {(entry.gzip / 1024).toFixed(1)}kB + {diff !== 0 && ( + 0 ? styles.bundleDiffUp : styles.bundleDiffDown + } + > + {diffStr}B + + )} + + ); + })} +
+
+ + +
+
+
+
+ + {ALL_TAGS.map((tag) => ( + + ))}
- {TESTS.map((test, i) => ( - + + {filteredIndices.map((i) => ( + { + cardRefs.current[i] = el; + }} + /> ))} +

+ Space morph focused card · Shift+Space morph all +

); }; diff --git a/site/src/surfaces/playground-tests/styles.module.scss b/site/src/surfaces/playground-tests/styles.module.scss index 8932f90..88314d1 100644 --- a/site/src/surfaces/playground-tests/styles.module.scss +++ b/site/src/surfaces/playground-tests/styles.module.scss @@ -9,15 +9,27 @@ top: 1rem; z-index: 10; display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; + flex-direction: column; + gap: 0.5rem; padding: 0.75rem 1.25rem; border-radius: 0.75rem; background: var(--body-light); backdrop-filter: blur(12px); } +.summaryRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.summaryLeft { + display: flex; + align-items: center; + gap: 0.75rem; +} + .summaryLabel { font-size: 0.8rem; font-weight: 600; @@ -29,6 +41,82 @@ color: rgb(248, 113, 113); } +.version { + font-size: 0.65rem; + font-weight: 500; + color: rgba(255, 255, 255, 0.25); + white-space: nowrap; + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.versionDev { + font-size: 0.55rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.1rem 0.3rem; + border-radius: 0.2rem; + background: rgba(250, 204, 21, 0.15); + color: rgb(250, 204, 21); +} + +.morphAllBtn { + font-size: 0.6rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.25rem 0.6rem; + border-radius: 0.3rem; + border: 1px solid rgba(255, 255, 255, 0.1); + background: transparent; + color: rgba(255, 255, 255, 0.4); + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; + + &:hover { + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.6); + } + + &:active { + transform: scale(0.96); + } +} + +.bundleSizes { + display: flex; + gap: 0.75rem; +} + +.bundleEntry { + font-size: 0.65rem; + color: rgba(255, 255, 255, 0.35); + white-space: nowrap; + display: inline-flex; + align-items: center; + gap: 0.25rem; + + strong { + color: rgba(255, 255, 255, 0.6); + font-weight: 600; + } +} + +.bundleDiffUp { + font-size: 0.55rem; + font-weight: 600; + color: rgb(248, 113, 113); +} + +.bundleDiffDown { + font-size: 0.55rem; + font-weight: 600; + color: rgb(74, 222, 128); +} + .summaryDots { display: flex; gap: 0.3rem; @@ -57,6 +145,35 @@ background: rgba(255, 255, 255, 0.15); } +.filterTags { + display: flex; + gap: 0.25rem; + flex-wrap: wrap; +} + +.filterTag { + font-size: 0.55rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 0.15rem 0.35rem; + border-radius: 0.25rem; + border: none; + background: transparent; + color: rgba(255, 255, 255, 0.2); + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + color: rgba(255, 255, 255, 0.45); + } +} + +.filterTagActive { + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.6); +} + .card { position: relative; border-radius: 1rem; @@ -108,6 +225,17 @@ color: rgb(248, 113, 113); } +.perfBadge { + font-size: 0.6rem; + font-weight: 500; + font-variant-numeric: tabular-nums; + padding: 0.15rem 0.4rem; + border-radius: 0.25rem; + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.35); + cursor: default; +} + .description { padding: 0.75rem 1.25rem 0; margin: 0; @@ -116,6 +244,15 @@ color: rgba(255, 255, 255, 0.35); } +.verifyDetail { + padding: 0.25rem 1.25rem 0; + margin: 0; + font-size: 0.7rem; + font-family: monospace; + line-height: 1.4; + color: rgba(255, 255, 255, 0.25); +} + .tags { display: flex; gap: 0.375rem; @@ -150,8 +287,201 @@ border-top: 1px solid rgba(255, 255, 255, 0.06); } +.speedToggle { + display: flex; + border-radius: 0.3rem; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.speedBtn { + font-size: 0.55rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 0.2rem 0.4rem; + border: none; + background: transparent; + color: rgba(255, 255, 255, 0.2); + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + color: rgba(255, 255, 255, 0.4); + } + + & + & { + border-left: 1px solid rgba(255, 255, 255, 0.08); + } +} + +.speedBtnActive { + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.6); +} + .step { font-size: 0.7rem; color: rgba(255, 255, 255, 0.3); +} + +.inspector { + padding: 0.75rem 1.25rem 1rem; + border-top: 1px solid rgba(255, 255, 255, 0.06); + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.inspectorSection { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.inspectorLabel { + font-size: 0.6rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: rgba(255, 255, 255, 0.25); +} + +.segmentList { + display: flex; + gap: 0.25rem; + flex-wrap: wrap; +} + +.segmentChip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.7rem; + font-family: monospace; + padding: 0.15rem 0.4rem; + border-radius: 0.25rem; + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.5); + cursor: default; +} + +.segmentPersisted { + background: rgba(74, 222, 128, 0.1); + color: rgb(74, 222, 128); +} + +.segmentNew { + background: rgba(96, 165, 250, 0.1); + color: rgb(96, 165, 250); +} + +.segmentId { + font-size: 0.55rem; + color: rgba(255, 255, 255, 0.2); +} + +.stepGroup { + display: flex; + align-items: center; + gap: 0.4rem; margin-left: auto; } + +.progressTrack { + width: 20px; + height: 3px; + border-radius: 1.5px; + background: rgba(255, 255, 255, 0.08); + overflow: hidden; +} + +.progressBar { + width: 0%; + height: 100%; + border-radius: 1.5px; + background: rgb(96, 165, 250); +} + +.iconBtn { + display: flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border-radius: 0.375rem; + border: 1px solid rgba(255, 255, 255, 0.08); + background: transparent; + color: rgba(255, 255, 255, 0.25); + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.45); + } + + svg { + display: block; + } +} + +.iconBtnActive { + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.6); +} + +.summaryActions { + display: flex; + gap: 0.375rem; +} + +.sandboxInputs { + display: flex; + gap: 0.75rem; + padding: 0.75rem 1.25rem; +} + +.sandboxField { + display: flex; + flex-direction: column; + gap: 0.25rem; + flex: 1; +} + +.sandboxInput { + font-size: 0.8rem; + font-family: monospace; + padding: 0.4rem 0.6rem; + border-radius: 0.375rem; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.8); + outline: none; + transition: border-color 0.15s ease; + + &:focus { + border-color: rgba(255, 255, 255, 0.25); + } + + &::placeholder { + color: rgba(255, 255, 255, 0.2); + } +} + +.keyboardHint { + text-align: center; + font-size: 0.65rem; + color: rgba(255, 255, 255, 0.2); + padding: 0.5rem 0; + + kbd { + font-family: monospace; + font-size: 0.6rem; + padding: 0.1rem 0.3rem; + border-radius: 0.2rem; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.35); + } +} From 8ebd9cddb8de72328fb2fbb3209700520abe4549 Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Sun, 8 Mar 2026 07:01:37 +1100 Subject: [PATCH 05/16] multiline support --- packages/torph/src/lib/text-morph/index.ts | 1 + .../torph/src/lib/text-morph/utils/diff.ts | 40 +++-- .../torph/src/lib/text-morph/utils/dom.ts | 38 +++- .../torph/src/lib/text-morph/utils/flip.ts | 1 + .../torph/src/lib/text-morph/utils/segment.ts | 26 ++- .../torph/src/lib/text-morph/utils/styles.ts | 4 + packages/torph/src/react/TextMorph.tsx | 2 +- site/src/surfaces/playground-tests/index.tsx | 166 +++++++++++++++--- .../playground-tests/styles.module.scss | 4 +- 9 files changed, 237 insertions(+), 45 deletions(-) diff --git a/packages/torph/src/lib/text-morph/index.ts b/packages/torph/src/lib/text-morph/index.ts index 8ae15d3..99bcd63 100644 --- a/packages/torph/src/lib/text-morph/index.ts +++ b/packages/torph/src/lib/text-morph/index.ts @@ -242,6 +242,7 @@ export class TextMorph { children.forEach((child, index) => { if (child.hasAttribute(ATTR_EXITING)) return; + if (child.tagName === "BR") return; const key = child.getAttribute(ATTR_ID) || `child-${index}`; if (key === "empty") return; const isNew = !this.prevMeasures[key]; diff --git a/packages/torph/src/lib/text-morph/utils/diff.ts b/packages/torph/src/lib/text-morph/utils/diff.ts index b4a729e..524a27f 100644 --- a/packages/torph/src/lib/text-morph/utils/diff.ts +++ b/packages/torph/src/lib/text-morph/utils/diff.ts @@ -16,7 +16,7 @@ function groupIntoWords(segments: Segment[]): WordGroup[] { let current: Segment[] = []; for (const seg of segments) { - if (seg.string === "\u00A0") { + if (seg.string === "\u00A0" || seg.string === "\n") { if (current.length > 0) { groups.push({ word: current.map((s) => s.string).join(""), @@ -88,13 +88,28 @@ export function diffSegments( locale: Intl.LocalesArgument, ): DiffResult { const newHasSpaces = newText.includes(" "); + const newHasNewlines = newText.includes("\n"); const oldWords = groupIntoWords(oldSegments); - if (oldWords.length <= 1 && !newHasSpaces) { + if (oldWords.length <= 1 && !newHasSpaces && !newHasNewlines) { return { segments: segmentText(newText, locale), splits: new Map() }; } - const newWordStrings = newText.split(" ").filter((w) => w.length > 0); + // Split new text into words and track separators (space vs newline) + const newWordStrings: string[] = []; + const newSeparators: string[] = []; // separator BEFORE each word (index 0 is empty) + const parts = newText.split(/( |\n)/); + let lastSep = ""; + for (const part of parts) { + if (part === " " || part === "\n") { + lastSep = part; + } else if (part.length > 0) { + newSeparators.push(lastSep); + newWordStrings.push(part); + lastSep = ""; + } + } + const oldWordStrings = oldWords.map((g) => g.word); // Word-level LCS @@ -173,13 +188,18 @@ export function diffSegments( for (let ni = 0; ni < newWordStrings.length; ni++) { if (ni > 0) { - // Use character-position-based space IDs (matching segmentText's scheme) - // so spaces don't persist between different texts — this keeps exit - // anchors pointing at words rather than spaces. - segments.push({ - id: `space-${charOffset}`, - string: "\u00A0", - }); + const sep = newSeparators[ni] || " "; + if (sep === "\n") { + segments.push({ + id: `newline-${charOffset}`, + string: "\n", + }); + } else { + segments.push({ + id: `space-${charOffset}`, + string: "\u00A0", + }); + } charOffset++; } diff --git a/packages/torph/src/lib/text-morph/utils/dom.ts b/packages/torph/src/lib/text-morph/utils/dom.ts index 6251b91..9939eac 100644 --- a/packages/torph/src/lib/text-morph/utils/dom.ts +++ b/packages/torph/src/lib/text-morph/utils/dom.ts @@ -3,21 +3,33 @@ import { ATTR_EXITING, ATTR_ID, ATTR_ITEM } from "./constants"; import { parseTranslate } from "./animate"; export function detachFromFlow(elements: HTMLElement[]) { - const snapshots = elements.map((child) => { + // Snapshot positions BEFORE removing BRs so layout hasn't shifted yet. + const snapshots = new Map(); + for (const child of elements) { + if (child.tagName === "BR") continue; const { tx, ty } = parseTranslate(child); const opacity = Number(getComputedStyle(child).opacity) || 1; child.getAnimations().forEach((a) => a.cancel()); - return { + snapshots.set(child, { left: child.offsetLeft + tx, top: child.offsetTop + ty, width: child.offsetWidth, height: child.offsetHeight, opacity, - }; - }); + }); + } - elements.forEach((child, i) => { - const snap = snapshots[i]!; + // Remove BR elements — they can't be animated and must leave the flow + // before reconciliation to prevent layout jumps. + for (let i = elements.length - 1; i >= 0; i--) { + if (elements[i]!.tagName === "BR") { + elements[i]!.remove(); + elements.splice(i, 1); + } + } + + elements.forEach((child) => { + const snap = snapshots.get(child)!; child.setAttribute(ATTR_EXITING, ""); child.style.position = "absolute"; child.style.pointerEvents = "none"; @@ -78,6 +90,20 @@ export function reconcileChildren( }); segments.forEach((segment) => { + if (segment.string === "\n") { + // Newline segments render as
elements + const existing = reusable.get(segment.id); + if (existing && existing.tagName === "BR") { + element.appendChild(existing); + } else { + const br = document.createElement("br"); + br.setAttribute(ATTR_ITEM, ""); + br.setAttribute(ATTR_ID, segment.id); + element.appendChild(br); + } + return; + } + const existing = reusable.get(segment.id); if (existing) { existing.textContent = segment.string; diff --git a/packages/torph/src/lib/text-morph/utils/flip.ts b/packages/torph/src/lib/text-morph/utils/flip.ts index 934e1f7..5cfb2f4 100644 --- a/packages/torph/src/lib/text-morph/utils/flip.ts +++ b/packages/torph/src/lib/text-morph/utils/flip.ts @@ -10,6 +10,7 @@ export function measure(element: HTMLElement): Measures { children.forEach((child, index) => { if (child.hasAttribute(ATTR_EXITING)) return; + if (child.tagName === "BR") return; const key = child.getAttribute(ATTR_ID) || `child-${index}`; measures[key] = { x: child.offsetLeft, diff --git a/packages/torph/src/lib/text-morph/utils/segment.ts b/packages/torph/src/lib/text-morph/utils/segment.ts index e7e7791..303527d 100644 --- a/packages/torph/src/lib/text-morph/utils/segment.ts +++ b/packages/torph/src/lib/text-morph/utils/segment.ts @@ -7,7 +7,31 @@ export function segmentText( value: string, locale: Intl.LocalesArgument, ): Segment[] { - const byWord = value.includes(" "); + const hasNewlines = value.includes("\n"); + const byWord = value.includes(" ") || hasNewlines; + + if (hasNewlines) { + // Split by newlines, segment each line at word level, join with newline segments + const lines = value.split("\n"); + const allSegments: Segment[] = []; + lines.forEach((line, lineIndex) => { + if (lineIndex > 0) { + allSegments.push({ id: `newline-${lineIndex}`, string: "\n" }); + } + if (line.length === 0) return; + let lineSegments: Segment[]; + if (typeof Intl.Segmenter !== "undefined") { + const segmenter = new Intl.Segmenter(locale, { + granularity: "word", + }); + lineSegments = segmentsFromIntl(segmenter.segment(line)[Symbol.iterator]()); + } else { + lineSegments = segmentsFallback(line, true); + } + allSegments.push(...lineSegments); + }); + return allSegments; + } if (typeof Intl.Segmenter !== "undefined") { const segmenter = new Intl.Segmenter(locale, { diff --git a/packages/torph/src/lib/text-morph/utils/styles.ts b/packages/torph/src/lib/text-morph/utils/styles.ts index 6462c32..a5df6ae 100644 --- a/packages/torph/src/lib/text-morph/utils/styles.ts +++ b/packages/torph/src/lib/text-morph/utils/styles.ts @@ -17,6 +17,10 @@ const TORPH_CSS = ` opacity: 1; } +br[${ATTR_ITEM}] { + display: inline; +} + [${ATTR_ROOT}][${ATTR_DEBUG}] { outline: 2px solid magenta; [${ATTR_ITEM}] { diff --git a/packages/torph/src/react/TextMorph.tsx b/packages/torph/src/react/TextMorph.tsx index 9536923..8d73bcd 100644 --- a/packages/torph/src/react/TextMorph.tsx +++ b/packages/torph/src/react/TextMorph.tsx @@ -36,7 +36,7 @@ export const TextMorph = ({ }: TextMorphProps) => { const { ref, update } = useTextMorph(props); const text = childrenToString(children); - const initialHTML = React.useRef({ __html: text }); + const initialHTML = React.useRef({ __html: text.replace(/\n/g, "
") }); React.useEffect(() => { update(text); diff --git a/site/src/surfaces/playground-tests/index.tsx b/site/src/surfaces/playground-tests/index.tsx index 59c12b5..1e32af2 100644 --- a/site/src/surfaces/playground-tests/index.tsx +++ b/site/src/surfaces/playground-tests/index.tsx @@ -470,6 +470,108 @@ const TESTS: TestCase[] = [ values: ["Hello 👨‍👩‍👧‍👦", "Goodbye 👨‍👩‍👧‍👦"], verify: () => verifyWordPersistence("Hello 👨‍👩‍👧‍👦", "Goodbye 👨‍👩‍👧‍👦", "👨‍👩‍👧‍👦"), }, + { + label: "Multiline basic", + description: + "Shared words should persist across line breaks. Newlines are treated as word boundaries.", + tags: ["multiline"], + values: ["hello\nworld", "hello\nuniverse"], + verify: () => + verifyWordPersistence("hello\nworld", "hello\nuniverse", "hello"), + }, + { + label: "Multiline add line", + description: + "Adding a new line should enter new words. Existing words should persist.", + tags: ["multiline", "enter"], + values: ["hello world\ngoodbye", "hello world\ngoodbye\nfarewell"], + verify: () => + combineResults( + verifyWordPersistence( + "hello world\ngoodbye", + "hello world\ngoodbye\nfarewell", + "hello", + ), + verifyWordPersistence( + "hello world\ngoodbye", + "hello world\ngoodbye\nfarewell", + "goodbye", + ), + ), + }, + { + label: "Multiline remove line", + description: + "Removing a line should exit those words. Remaining words should persist.", + tags: ["multiline", "exit"], + values: ["hello world\nfoo bar\ngoodbye moon", "hello world\ngoodbye moon"], + verify: () => + combineResults( + verifyWordPersistence( + "hello world\nfoo bar\ngoodbye moon", + "hello world\ngoodbye moon", + "hello", + ), + verifyWordPersistence( + "hello world\nfoo bar\ngoodbye moon", + "hello world\ngoodbye moon", + "goodbye", + ), + verifyWordAbsent( + "hello world\nfoo bar\ngoodbye moon", + "hello world\ngoodbye moon", + "foo", + ), + ), + }, + { + label: "Multiline reorder", + description: + "Swapping line order. Shared words should persist and FLIP to new positions.", + tags: ["multiline", "flip"], + values: ["alpha bravo\ncharlie delta", "charlie delta\nalpha bravo"], + verify: () => + combineResults( + verifyWordPersistence( + "alpha bravo\ncharlie delta", + "charlie delta\nalpha bravo", + "alpha", + ), + verifyWordPersistence( + "alpha bravo\ncharlie delta", + "charlie delta\nalpha bravo", + "charlie", + ), + ), + }, + { + label: "Multiline with edits", + description: + "Lines change content while shared words persist across the multiline transition.", + tags: ["multiline", "flip"], + values: [ + "the quick brown fox\njumps over the lazy dog", + "the slow red fox\nleaps over the happy cat", + ], + verify: () => + combineResults( + verifyWordPersistence( + "the quick brown fox\njumps over the lazy dog", + "the slow red fox\nleaps over the happy cat", + "the", + ), + verifyWordPersistence( + "the quick brown fox\njumps over the lazy dog", + "the slow red fox\nleaps over the happy cat", + "fox", + ), + verifyWordPersistence( + "the quick brown fox\njumps over the lazy dog", + "the slow red fox\nleaps over the happy cat", + "over", + ), + ), + }, ]; const ALL_TAGS = [...new Set(TESTS.flatMap((t) => t.tags))].sort(); @@ -677,27 +779,6 @@ function TestCard({ {auto ? "Stop" : "Auto"} )} -
{(Object.keys(SPEEDS) as Speed[]).map((s) => ( +
{showInspector && ( @@ -962,14 +1076,14 @@ export const PlaygroundTests = () => {

{test.description}

-

{result ? result.detail : "\u00A0"}

+

+ {result ? result.detail : "\u00A0"} + {domResult && !domResult.pass && ( + <> · DOM: {domResult.detail} + )} + {jumpResult && !jumpResult.pass && ( + <> · JUMP: {jumpResult.detail} + )} +

{ if (progressRef.current) { const el = progressRef.current; @@ -762,12 +1312,30 @@ function TestCard({ el.style.transition = `width ${SPEEDS[speed]}ms linear`; el.style.width = "100%"; } + // Snapshot positions before morph for jump detection + if (bodyRef.current) { + const torphRoot = bodyRef.current.querySelector("[torph-root]"); + if (torphRoot) { + preAnimSnap.current = takeJumpSnapshot(torphRoot); + requestAnimationFrame(() => { + if (preAnimSnap.current) { + onJumpResult?.(verifyNoJump(torphRoot, preAnimSnap.current)); + } + }); + } + } }} onAnimationComplete={() => { if (progressRef.current) { progressRef.current.style.transition = "none"; progressRef.current.style.width = "0%"; } + if (test.verifyDom && bodyRef.current) { + const torphRoot = bodyRef.current.querySelector("[torph-root]"); + if (torphRoot) { + onDomResult?.(test.verifyDom(torphRoot)); + } + } }} > {test.values[index]} @@ -779,30 +1347,6 @@ function TestCard({ {auto ? "Stop" : "Auto"} )} -
- {(Object.keys(SPEEDS) as Speed[]).map((s) => ( - - ))} -
-
- {(Object.keys(EASINGS) as EasingKey[]).map((e) => ( - - ))} -
@@ -939,16 +1483,33 @@ function SandboxCard() { ); } -function copyResultsToClipboard(results: TestResult[]) { +function copyResultsToClipboard( + results: TestResult[], + domResults: (({ pass: boolean; detail: string }) | null)[], + jumpResults: (({ pass: boolean; detail: string }) | null)[], +) { const lines = [ "# Torph Test Results", "", - `| Test | Status | Time |`, - `|------|--------|------|`, - ...results.map((r) => { + `| Test | Data | DOM | Jump | Jump Detail | Time |`, + `|------|------|-----|------|-------------|------|`, + ...results.map((r, i) => { const status = !r.result ? "Skip" : r.result.pass ? "Pass" : "Fail"; + const dom = domResults[i] + ? domResults[i]!.pass + ? "Pass" + : "Fail" + : "-"; + const jump = jumpResults[i] + ? jumpResults[i]!.pass + ? "Pass" + : "Fail" + : "-"; + const jumpDetail = jumpResults[i] + ? jumpResults[i]!.detail + : "-"; const time = r.timeMs !== null ? `${r.timeMs.toFixed(2)}ms` : "-"; - return `| ${r.label} | ${status} | ${time} |`; + return `| ${r.label} | ${status} | ${dom} | ${jump} | ${jumpDetail} | ${time} |`; }), "", `Generated: ${new Date().toISOString()}`, @@ -961,18 +1522,35 @@ export const PlaygroundTests = () => { const [failOnly, setFailOnly] = React.useState(false); const [morphAllSignal, setMorphAllSignal] = React.useState(0); const [copied, setCopied] = React.useState(false); + const [speed, setSpeed] = React.useState("default"); + const [easing, setEasing] = React.useState("default"); + const [align, setAlign] = React.useState("left"); + const [debug, setDebug] = React.useState(false); const results = useResults(); + const [domResults, setDomResults] = React.useState< + (({ pass: boolean; detail: string }) | null)[] + >(() => TESTS.map(() => null)); + const [jumpResults, setJumpResults] = React.useState< + (({ pass: boolean; detail: string }) | null)[] + >(() => TESTS.map(() => null)); const cardRefs = React.useRef<(HTMLDivElement | null)[]>([]); const filteredIndices = TESTS.map((_, i) => i).filter((i) => { if (activeTag && !TESTS[i]!.tags.includes(activeTag)) return false; - if (failOnly && results[i]?.result?.pass !== false) return false; + if (failOnly) { + const dataFail = results[i]?.result?.pass === false; + const domFail = domResults[i]?.pass === false; + const jumpFail = jumpResults[i]?.pass === false; + if (!dataFail && !domFail && !jumpFail) return false; + } return true; }); const isDev = process.env.NODE_ENV !== "production"; const passed = results.filter((r) => r.result?.pass).length; const failed = results.filter((r) => r.result && !r.result.pass).length; + const domFailed = domResults.filter((r) => r && !r.pass).length; + const jumpFailed = jumpResults.filter((r) => r && !r.pass).length; const total = results.filter((r) => r.result).length; React.useEffect(() => { @@ -1008,7 +1586,7 @@ export const PlaygroundTests = () => { }, []); const handleCopy = () => { - copyResultsToClipboard(results); + copyResultsToClipboard(results, domResults, jumpResults); setCopied(true); setTimeout(() => setCopied(false), 2000); }; @@ -1023,6 +1601,12 @@ export const PlaygroundTests = () => { {failed > 0 && ( · {failed} failed )} + {domFailed > 0 && ( + · {domFailed} DOM failed + )} + {jumpFailed > 0 && ( + · {jumpFailed} JUMP failed + )} v{pkg.version} @@ -1081,13 +1665,6 @@ export const PlaygroundTests = () => { > {copied ? "Copied!" : "Copy Results"} -
@@ -1118,6 +1695,26 @@ export const PlaygroundTests = () => { result={results[i]!.result} timeMs={results[i]!.timeMs} morphAllSignal={morphAllSignal} + domResult={domResults[i] ?? null} + jumpResult={jumpResults[i] ?? null} + speed={speed} + easing={easing} + align={align} + debug={debug} + onDomResult={(r) => + setDomResults((prev) => { + const next = [...prev]; + next[i] = r; + return next; + }) + } + onJumpResult={(r) => + setJumpResults((prev) => { + const next = [...prev]; + next[i] = r; + return next; + }) + } cardRef={(el) => { cardRefs.current[i] = el; }} @@ -1126,6 +1723,59 @@ export const PlaygroundTests = () => {

Space morph focused card · Shift+Space morph all

+
+
+ {(Object.keys(SPEEDS) as Speed[]).map((s) => ( + + ))} +
+
+ {(Object.keys(EASINGS) as EasingKey[]).map((e) => ( + + ))} +
+
+ {ALIGNS.map((a) => ( + + ))} +
+ + +
); }; diff --git a/site/src/surfaces/playground-tests/styles.module.scss b/site/src/surfaces/playground-tests/styles.module.scss index 37f7be1..4460e17 100644 --- a/site/src/surfaces/playground-tests/styles.module.scss +++ b/site/src/surfaces/playground-tests/styles.module.scss @@ -272,12 +272,13 @@ .cardBody { padding: 3rem 1.25rem; - text-align: center; + text-align: left; font-family: var(--font-secondary); font-size: 1.5rem; font-weight: 500; line-height: 1.5; color: #ffffff; + user-select: none; } .cardFooter { @@ -471,6 +472,20 @@ } } +.toolbar { + position: sticky; + bottom: 1rem; + z-index: 10; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-radius: 0.75rem; + background: var(--body-light); + backdrop-filter: blur(12px); +} + .keyboardHint { text-align: center; font-size: 0.65rem; From 683aa5a8020a6d6c3a9a8317468565dbc7c98400 Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Sun, 8 Mar 2026 12:51:30 +1100 Subject: [PATCH 07/16] fix --- packages/torph/src/lib/text-morph/index.ts | 14 +++++++++++++- site/src/surfaces/playground-tests/index.tsx | 9 ++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/torph/src/lib/text-morph/index.ts b/packages/torph/src/lib/text-morph/index.ts index 7ce8ad2..a89fa41 100644 --- a/packages/torph/src/lib/text-morph/index.ts +++ b/packages/torph/src/lib/text-morph/index.ts @@ -53,6 +53,7 @@ export class TextMorph { private previousSegments: Segment[] = []; private isInitialRender = true; private reducedMotion: ReducedMotionState | null = null; + private emptyTransitionTimer: ReturnType | null = null; constructor(options: TextMorphOptions) { const { ease: rawEase, ...rest } = { ...DEFAULT_TEXT_MORPH_OPTIONS, ...options }; @@ -88,6 +89,10 @@ export class TextMorph { } destroy() { + if (this.emptyTransitionTimer !== null) { + clearTimeout(this.emptyTransitionTimer); + this.emptyTransitionTimer = null; + } this.reducedMotion?.destroy(); this.element.getAnimations().forEach((anim) => anim.cancel()); this.element.removeAttribute(ATTR_ROOT); @@ -124,6 +129,12 @@ export class TextMorph { } private createTextGroup(value: string, element: HTMLElement) { + // Cancel any pending empty-transition timeout from a previous morph + if (this.emptyTransitionTimer !== null) { + clearTimeout(this.emptyTransitionTimer); + this.emptyTransitionTimer = null; + } + const oldWidth = element.offsetWidth; const oldHeight = element.offsetHeight; @@ -219,7 +230,8 @@ export class TextMorph { element.style.transitionProperty = "none"; element.style.width = `${oldWidth}px`; element.style.height = `${oldHeight}px`; - setTimeout(() => { + this.emptyTransitionTimer = setTimeout(() => { + this.emptyTransitionTimer = null; element.style.width = "auto"; element.style.height = "auto"; element.style.transitionProperty = ""; diff --git a/site/src/surfaces/playground-tests/index.tsx b/site/src/surfaces/playground-tests/index.tsx index 53dcce8..29f3aa9 100644 --- a/site/src/surfaces/playground-tests/index.tsx +++ b/site/src/surfaces/playground-tests/index.tsx @@ -1491,8 +1491,8 @@ function copyResultsToClipboard( const lines = [ "# Torph Test Results", "", - `| Test | Data | DOM | Jump | Jump Detail | Time |`, - `|------|------|-----|------|-------------|------|`, + `| Test | Data | DOM | DOM Detail | Jump | Jump Detail | Time |`, + `|------|------|-----|------------|------|-------------|------|`, ...results.map((r, i) => { const status = !r.result ? "Skip" : r.result.pass ? "Pass" : "Fail"; const dom = domResults[i] @@ -1500,6 +1500,9 @@ function copyResultsToClipboard( ? "Pass" : "Fail" : "-"; + const domDetail = domResults[i] + ? domResults[i]!.detail + : "-"; const jump = jumpResults[i] ? jumpResults[i]!.pass ? "Pass" @@ -1509,7 +1512,7 @@ function copyResultsToClipboard( ? jumpResults[i]!.detail : "-"; const time = r.timeMs !== null ? `${r.timeMs.toFixed(2)}ms` : "-"; - return `| ${r.label} | ${status} | ${dom} | ${jump} | ${jumpDetail} | ${time} |`; + return `| ${r.label} | ${status} | ${dom} | ${domDetail} | ${jump} | ${jumpDetail} | ${time} |`; }), "", `Generated: ${new Date().toISOString()}`, From e41552a9d3c0a6519c5c6f4d4ecc75911d052913 Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Sun, 8 Mar 2026 13:17:04 +1100 Subject: [PATCH 08/16] move tags --- site/src/components/tooltip/index.tsx | 63 +++ .../src/components/tooltip/styles.module.scss | 42 ++ site/src/surfaces/playground-tests/index.tsx | 430 +++++++++++++----- .../playground-tests/styles.module.scss | 13 +- 4 files changed, 434 insertions(+), 114 deletions(-) create mode 100644 site/src/components/tooltip/index.tsx create mode 100644 site/src/components/tooltip/styles.module.scss diff --git a/site/src/components/tooltip/index.tsx b/site/src/components/tooltip/index.tsx new file mode 100644 index 0000000..3abd29c --- /dev/null +++ b/site/src/components/tooltip/index.tsx @@ -0,0 +1,63 @@ +import { useState, useRef, useLayoutEffect } from "react"; +import styles from "./styles.module.scss"; + +export const Tooltip = ({ + children, + content, + position = "top", + delay = 300, +}: { + children: React.ReactNode; + content: React.ReactNode; + position?: "top" | "bottom"; + delay?: number; +}) => { + const [visible, setVisible] = useState(false); + const [nudge, setNudge] = useState(0); + const timeoutRef = useRef>(null); + const tipRef = useRef(null); + const wrapperRef = useRef(null); + + useLayoutEffect(() => { + if (!visible || !tipRef.current) return; + const rect = tipRef.current.getBoundingClientRect(); + const pad = 8; + if (rect.left < pad) setNudge(pad - rect.left); + else if (rect.right > window.innerWidth - pad) + setNudge(window.innerWidth - pad - rect.right); + else setNudge(0); + }, [visible]); + + const show = () => { + clearTimeout(timeoutRef.current!); + timeoutRef.current = setTimeout(() => setVisible(true), delay); + }; + const hide = () => { + clearTimeout(timeoutRef.current!); + setVisible(false); + setNudge(0); + }; + + return ( +
+ {children} + {visible && ( +
+ {content} +
+ )} +
+ ); +}; diff --git a/site/src/components/tooltip/styles.module.scss b/site/src/components/tooltip/styles.module.scss new file mode 100644 index 0000000..91e3e81 --- /dev/null +++ b/site/src/components/tooltip/styles.module.scss @@ -0,0 +1,42 @@ +.tooltip { + position: relative; + display: inline-flex; +} + +.tooltipContent { + position: absolute; + left: 50%; + z-index: 1000; + padding: 0.375rem 0.75rem; + border-radius: 0.5rem; + background: #181818; + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.08), + 0 0.25rem 1rem rgba(0, 0, 0, 0.5); + color: #e0e0e0; + font-family: var(--font-secondary); + font-size: 0.8125rem; + font-weight: 450; + white-space: nowrap; + pointer-events: none; + animation: fadeIn 120ms ease; + + &.top { + bottom: calc(100% + 6px); + } + + &.bottom { + top: calc(100% + 6px); + } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateX(-50%) scale(0.96); + } + to { + opacity: 1; + transform: translateX(-50%) scale(1); + } +} diff --git a/site/src/surfaces/playground-tests/index.tsx b/site/src/surfaces/playground-tests/index.tsx index 29f3aa9..662042f 100644 --- a/site/src/surfaces/playground-tests/index.tsx +++ b/site/src/surfaces/playground-tests/index.tsx @@ -6,6 +6,7 @@ import { TextMorph } from "torph/react"; import { segmentText, diffSegments, DEFAULT_TEXT_MORPH_OPTIONS } from "torph"; import type { Segment } from "torph"; import { Button } from "@/components/button"; +import { Tooltip } from "@/components/tooltip"; import bundleSizes from "./bundle-sizes.json"; import pkg from "../../../../packages/torph/package.json"; @@ -402,9 +403,13 @@ function verifyNoJump( // Root position shift (viewport coords) const rootDx = after.rootRect.left - before.rootRect.left; + const rootDy = after.rootRect.top - before.rootRect.top; if (Math.abs(rootDx) > 0.5) { context.push(`rootX: ${rootDx > 0 ? "+" : ""}${rootDx.toFixed(1)}`); } + if (Math.abs(rootDy) > 0.5) { + context.push(`rootY: ${rootDy > 0 ? "+" : ""}${rootDy.toFixed(1)}`); + } // Check each item that existed before after.items.forEach((cur, id) => { @@ -440,6 +445,98 @@ function verifyNoJump( return { pass: true, detail: `${header} no frame-0 jump` }; } +// ── Frame performance monitor ── + +type PerfResult = { + pass: boolean; + detail: string; + totalFrames: number; + droppedFrames: number; + longestFrame: number; + avgFrame: number; + morphTime: number; +}; + +class FrameMonitor { + private frames: number[] = []; + private rafId: number | null = null; + private lastTime = 0; + private running = false; + private startTime = 0; + private firstFrameTime = 0; + + start() { + this.stop(); + this.frames = []; + this.lastTime = 0; + this.running = true; + this.startTime = performance.now(); + this.firstFrameTime = 0; + this.rafId = requestAnimationFrame(this.tick); + } + + stop(): PerfResult { + this.running = false; + if (this.rafId !== null) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + + const frameTimes = this.frames; + // Time from start() to first rAF = sync morph work + first paint + const morphTime = this.firstFrameTime > 0 ? this.firstFrameTime - this.startTime : 0; + + if (frameTimes.length === 0) { + return { + pass: true, + detail: `no frames | morph=${morphTime.toFixed(1)}ms`, + totalFrames: 0, + droppedFrames: 0, + longestFrame: 0, + avgFrame: 0, + morphTime, + }; + } + + const longestFrame = Math.max(...frameTimes); + const avgFrame = frameTimes.reduce((a, b) => a + b, 0) / frameTimes.length; + // A "dropped" frame is one where the gap exceeds 1.5x of a 60fps frame (25ms) + const dropThreshold = 25; + const droppedFrames = frameTimes.filter((t) => t > dropThreshold).length; + const totalFrames = frameTimes.length; + + const issues: string[] = []; + if (droppedFrames > 0) { + issues.push(`${droppedFrames} dropped`); + } + if (longestFrame > 50) { + issues.push(`worst: ${longestFrame.toFixed(1)}ms`); + } + if (morphTime > 16) { + issues.push(`morph: ${morphTime.toFixed(1)}ms`); + } + + const pass = droppedFrames === 0 && longestFrame <= 50; + const detail = pass + ? `${totalFrames}f avg=${avgFrame.toFixed(1)}ms worst=${longestFrame.toFixed(1)}ms morph=${morphTime.toFixed(1)}ms` + : `${totalFrames}f ${issues.join(" | ")} avg=${avgFrame.toFixed(1)}ms morph=${morphTime.toFixed(1)}ms`; + + return { pass, detail, totalFrames, droppedFrames, longestFrame, avgFrame, morphTime }; + } + + private tick = () => { + if (!this.running) return; + const now = performance.now(); + if (this.lastTime > 0) { + this.frames.push(now - this.lastTime); + } else { + this.firstFrameTime = now; + } + this.lastTime = now; + this.rafId = requestAnimationFrame(this.tick); + }; +} + function verifyDomStandard(root: HTMLElement): { pass: boolean; detail: string } { const checks: [string, { pass: boolean; detail: string }][] = [ ["bounds", verifyItemsInBounds(root)], @@ -1119,10 +1216,12 @@ function SegmentInspector({ from, to }: { from: string; to: string }) { Old segments
{oldSegs.map((s, i) => ( - - {s.string === "\u00A0" ? "·" : s.string} - {s.id.slice(0, 6)} - + + + {s.string === "\u00A0" ? "·" : s.string} + {s.id.slice(0, 6)} + + ))}
@@ -1132,14 +1231,15 @@ function SegmentInspector({ from, to }: { from: string; to: string }) { {newSegs.map((s, i) => { const persisted = oldSegs.some((o) => o.id === s.id); return ( - - {s.string === "\u00A0" ? "·" : s.string} - {s.id.slice(0, 6)} - + + + {s.string === "\u00A0" ? "·" : s.string} + {s.id.slice(0, 6)} + + ); })}
@@ -1149,13 +1249,14 @@ function SegmentInspector({ from, to }: { from: string; to: string }) { Splits
{[...splits.entries()].map(([word, chars]) => ( - - {word} → {chars.map((c) => c.string).join("")} - + + + {word} → {chars.map((c) => c.string).join("")} + + ))}
@@ -1181,9 +1282,11 @@ function TestCard({ morphAllSignal, domResult, jumpResult, + perfResult, cardRef, onDomResult, onJumpResult, + onPerfResult, speed, easing, align: globalAlign, @@ -1195,9 +1298,11 @@ function TestCard({ morphAllSignal: number; domResult: { pass: boolean; detail: string } | null; jumpResult: { pass: boolean; detail: string } | null; + perfResult: PerfResult | null; cardRef?: React.Ref; onDomResult?: (result: { pass: boolean; detail: string } | null) => void; onJumpResult?: (result: { pass: boolean; detail: string } | null) => void; + onPerfResult?: (result: PerfResult | null) => void; speed: Speed; easing: EasingKey; align: Align; @@ -1211,6 +1316,7 @@ function TestCard({ const intervalRef = React.useRef | null>(null); const bodyRef = React.useRef(null); const preAnimSnap = React.useRef(null); + const frameMonitor = React.useRef(new FrameMonitor()); const advance = React.useCallback(() => { setIndex((i) => (i + 1) % test.values.length); @@ -1237,43 +1343,6 @@ function TestCard({
{test.label} - {result && ( - - {result.pass ? "PASS" : "FAIL"} - - )} - {domResult && ( - - DOM {domResult.pass ? "PASS" : "FAIL"} - - )} - {!domResult && test.verifyDom && ( - - DOM … - - )} - {jumpResult && ( - - JUMP {jumpResult.pass ? "OK" : "FAIL"} - - )} - {timeMs !== null && ( - - {timeMs < 0.01 ? "<0.01" : timeMs.toFixed(2)}ms - - )}
{test.tags.map((tag) => ( @@ -1292,6 +1361,9 @@ function TestCard({ {jumpResult && !jumpResult.pass && ( <> · JUMP: {jumpResult.detail} )} + {perfResult && !perfResult.pass && ( + <> · PERF: {perfResult.detail} + )}

{ + frameMonitor.current.start(); if (progressRef.current) { const el = progressRef.current; el.style.transition = "none"; @@ -1326,6 +1399,7 @@ function TestCard({ } }} onAnimationComplete={() => { + onPerfResult?.(frameMonitor.current.stop()); if (progressRef.current) { progressRef.current.style.transition = "none"; progressRef.current.style.width = "0%"; @@ -1342,6 +1416,55 @@ function TestCard({
+ + + + {result ? (result.pass ? "PASS" : "FAIL") : "…"} + + + + {test.verifyDom && ( + + + + {domResult ? `DOM ${domResult.pass ? "PASS" : "FAIL"}` : "DOM …"} + + + + )} + + + + {jumpResult ? `JUMP ${jumpResult.pass ? "OK" : "FAIL"}` : "JUMP …"} + + + + + + + {perfResult + ? perfResult.pass + ? `${perfResult.totalFrames}f` + : `${perfResult.droppedFrames} drop` + : "PERF …"} + + + + + + + {timeMs !== null ? `${timeMs < 0.01 ? "<0.01" : timeMs.toFixed(2)}ms` : "—"} + + + {isSpamTest && ( - +
{showInspector && ( @@ -1487,12 +1611,13 @@ function copyResultsToClipboard( results: TestResult[], domResults: (({ pass: boolean; detail: string }) | null)[], jumpResults: (({ pass: boolean; detail: string }) | null)[], + perfResults: (PerfResult | null)[], ) { const lines = [ "# Torph Test Results", "", - `| Test | Data | DOM | DOM Detail | Jump | Jump Detail | Time |`, - `|------|------|-----|------------|------|-------------|------|`, + `| Test | Data | DOM | DOM Detail | Jump | Jump Detail | Perf | Perf Detail | Time |`, + `|------|------|-----|------------|------|-------------|------|-------------|------|`, ...results.map((r, i) => { const status = !r.result ? "Skip" : r.result.pass ? "Pass" : "Fail"; const dom = domResults[i] @@ -1511,8 +1636,16 @@ function copyResultsToClipboard( const jumpDetail = jumpResults[i] ? jumpResults[i]!.detail : "-"; + const perf = perfResults[i] + ? perfResults[i]!.pass + ? "Pass" + : "Fail" + : "-"; + const perfDetail = perfResults[i] + ? perfResults[i]!.detail + : "-"; const time = r.timeMs !== null ? `${r.timeMs.toFixed(2)}ms` : "-"; - return `| ${r.label} | ${status} | ${dom} | ${domDetail} | ${jump} | ${jumpDetail} | ${time} |`; + return `| ${r.label} | ${status} | ${dom} | ${domDetail} | ${jump} | ${jumpDetail} | ${perf} | ${perfDetail} | ${time} |`; }), "", `Generated: ${new Date().toISOString()}`, @@ -1520,11 +1653,54 @@ function copyResultsToClipboard( navigator.clipboard.writeText(lines.join("\n")); } +function copyFailsToClipboard( + results: TestResult[], + domResults: (({ pass: boolean; detail: string }) | null)[], + jumpResults: (({ pass: boolean; detail: string }) | null)[], + perfResults: (PerfResult | null)[], +) { + const failed = results + .map((r, i) => ({ r, i })) + .filter(({ r, i }) => { + const dataFail = r.result && !r.result.pass; + const domFail = domResults[i] && !domResults[i]!.pass; + const jumpFail = jumpResults[i] && !jumpResults[i]!.pass; + const perfFail = perfResults[i] && !perfResults[i]!.pass; + return dataFail || domFail || jumpFail || perfFail; + }); + + if (failed.length === 0) { + navigator.clipboard.writeText("No failures."); + return false; + } + + const lines = [ + "# Torph Failed Tests", + "", + `| Test | Data | DOM | DOM Detail | Jump | Jump Detail | Perf | Perf Detail |`, + `|------|------|-----|------------|------|-------------|------|-------------|`, + ...failed.map(({ r, i }) => { + const status = !r.result ? "Skip" : r.result.pass ? "Pass" : "Fail"; + const dom = domResults[i] ? (domResults[i]!.pass ? "Pass" : "Fail") : "-"; + const domDetail = domResults[i] ? domResults[i]!.detail : "-"; + const jump = jumpResults[i] ? (jumpResults[i]!.pass ? "Pass" : "Fail") : "-"; + const jumpDetail = jumpResults[i] ? jumpResults[i]!.detail : "-"; + const perf = perfResults[i] ? (perfResults[i]!.pass ? "Pass" : "Fail") : "-"; + const perfDetail = perfResults[i] ? perfResults[i]!.detail : "-"; + return `| ${r.label} | ${status} | ${dom} | ${domDetail} | ${jump} | ${jumpDetail} | ${perf} | ${perfDetail} |`; + }), + "", + `Generated: ${new Date().toISOString()}`, + ]; + navigator.clipboard.writeText(lines.join("\n")); + return true; +} + export const PlaygroundTests = () => { const [activeTag, setActiveTag] = React.useState(null); const [failOnly, setFailOnly] = React.useState(false); const [morphAllSignal, setMorphAllSignal] = React.useState(0); - const [copied, setCopied] = React.useState(false); + const [copied, setCopied] = React.useState(false); const [speed, setSpeed] = React.useState("default"); const [easing, setEasing] = React.useState("default"); const [align, setAlign] = React.useState("left"); @@ -1536,6 +1712,9 @@ export const PlaygroundTests = () => { const [jumpResults, setJumpResults] = React.useState< (({ pass: boolean; detail: string }) | null)[] >(() => TESTS.map(() => null)); + const [perfResults, setPerfResults] = React.useState< + (PerfResult | null)[] + >(() => TESTS.map(() => null)); const cardRefs = React.useRef<(HTMLDivElement | null)[]>([]); const filteredIndices = TESTS.map((_, i) => i).filter((i) => { @@ -1544,7 +1723,8 @@ export const PlaygroundTests = () => { const dataFail = results[i]?.result?.pass === false; const domFail = domResults[i]?.pass === false; const jumpFail = jumpResults[i]?.pass === false; - if (!dataFail && !domFail && !jumpFail) return false; + const perfFail = perfResults[i]?.pass === false; + if (!dataFail && !domFail && !jumpFail && !perfFail) return false; } return true; }); @@ -1554,6 +1734,7 @@ export const PlaygroundTests = () => { const failed = results.filter((r) => r.result && !r.result.pass).length; const domFailed = domResults.filter((r) => r && !r.pass).length; const jumpFailed = jumpResults.filter((r) => r && !r.pass).length; + const perfFailed = perfResults.filter((r) => r && !r.pass).length; const total = results.filter((r) => r.result).length; React.useEffect(() => { @@ -1589,8 +1770,14 @@ export const PlaygroundTests = () => { }, []); const handleCopy = () => { - copyResultsToClipboard(results, domResults, jumpResults); - setCopied(true); + copyResultsToClipboard(results, domResults, jumpResults, perfResults); + setCopied("all"); + setTimeout(() => setCopied(false), 2000); + }; + + const handleCopyFails = () => { + const hadFails = copyFailsToClipboard(results, domResults, jumpResults, perfResults); + setCopied(hadFails ? "fails" : "none"); setTimeout(() => setCopied(false), 2000); }; @@ -1610,6 +1797,9 @@ export const PlaygroundTests = () => { {jumpFailed > 0 && ( · {jumpFailed} JUMP failed )} + {perfFailed > 0 && ( + · {perfFailed} PERF failed + )} v{pkg.version} @@ -1617,18 +1807,25 @@ export const PlaygroundTests = () => {
- {results.map((r) => ( - + {results.map((r, i) => ( + + + cardRefs.current[i]?.scrollIntoView({ + behavior: "smooth", + block: "center", + }) + } + /> + ))}
@@ -1640,33 +1837,41 @@ export const PlaygroundTests = () => { : 0; const diffStr = diff > 0 ? `+${diff}` : `${diff}`; return ( - - {entry.name}{" "} - {(entry.gzip / 1024).toFixed(1)}kB - {diff !== 0 && ( - 0 ? styles.bundleDiffUp : styles.bundleDiffDown - } - > - {diffStr}B - - )} - + + + {entry.name}{" "} + {(entry.gzip / 1024).toFixed(1)}kB + {diff !== 0 && ( + 0 ? styles.bundleDiffUp : styles.bundleDiffDown + } + > + {diffStr}B + + )} + + ); })}
+
@@ -1700,6 +1905,7 @@ export const PlaygroundTests = () => { morphAllSignal={morphAllSignal} domResult={domResults[i] ?? null} jumpResult={jumpResults[i] ?? null} + perfResult={perfResults[i] ?? null} speed={speed} easing={easing} align={align} @@ -1718,6 +1924,13 @@ export const PlaygroundTests = () => { return next; }) } + onPerfResult={(r) => + setPerfResults((prev) => { + const next = [...prev]; + next[i] = r; + return next; + }) + } cardRef={(el) => { cardRefs.current[i] = el; }} @@ -1753,15 +1966,16 @@ export const PlaygroundTests = () => {
{ALIGNS.map((a) => ( - + + + ))}
- + + + {showInspector ? ( + <> + + + + + ) : ( + <> + + + + )} + + @@ -1609,8 +1740,8 @@ function SandboxCard() { function copyResultsToClipboard( results: TestResult[], - domResults: (({ pass: boolean; detail: string }) | null)[], - jumpResults: (({ pass: boolean; detail: string }) | null)[], + domResults: ({ pass: boolean; detail: string } | null)[], + jumpResults: ({ pass: boolean; detail: string } | null)[], perfResults: (PerfResult | null)[], ) { const lines = [ @@ -1620,30 +1751,20 @@ function copyResultsToClipboard( `|------|------|-----|------------|------|-------------|------|-------------|------|`, ...results.map((r, i) => { const status = !r.result ? "Skip" : r.result.pass ? "Pass" : "Fail"; - const dom = domResults[i] - ? domResults[i]!.pass - ? "Pass" - : "Fail" - : "-"; - const domDetail = domResults[i] - ? domResults[i]!.detail - : "-"; + const dom = domResults[i] ? (domResults[i]!.pass ? "Pass" : "Fail") : "-"; + const domDetail = domResults[i] ? domResults[i]!.detail : "-"; const jump = jumpResults[i] ? jumpResults[i]!.pass ? "Pass" : "Fail" : "-"; - const jumpDetail = jumpResults[i] - ? jumpResults[i]!.detail - : "-"; + const jumpDetail = jumpResults[i] ? jumpResults[i]!.detail : "-"; const perf = perfResults[i] ? perfResults[i]!.pass ? "Pass" : "Fail" : "-"; - const perfDetail = perfResults[i] - ? perfResults[i]!.detail - : "-"; + const perfDetail = perfResults[i] ? perfResults[i]!.detail : "-"; const time = r.timeMs !== null ? `${r.timeMs.toFixed(2)}ms` : "-"; return `| ${r.label} | ${status} | ${dom} | ${domDetail} | ${jump} | ${jumpDetail} | ${perf} | ${perfDetail} | ${time} |`; }), @@ -1655,8 +1776,8 @@ function copyResultsToClipboard( function copyFailsToClipboard( results: TestResult[], - domResults: (({ pass: boolean; detail: string }) | null)[], - jumpResults: (({ pass: boolean; detail: string }) | null)[], + domResults: ({ pass: boolean; detail: string } | null)[], + jumpResults: ({ pass: boolean; detail: string } | null)[], perfResults: (PerfResult | null)[], ) { const failed = results @@ -1683,9 +1804,17 @@ function copyFailsToClipboard( const status = !r.result ? "Skip" : r.result.pass ? "Pass" : "Fail"; const dom = domResults[i] ? (domResults[i]!.pass ? "Pass" : "Fail") : "-"; const domDetail = domResults[i] ? domResults[i]!.detail : "-"; - const jump = jumpResults[i] ? (jumpResults[i]!.pass ? "Pass" : "Fail") : "-"; + const jump = jumpResults[i] + ? jumpResults[i]!.pass + ? "Pass" + : "Fail" + : "-"; const jumpDetail = jumpResults[i] ? jumpResults[i]!.detail : "-"; - const perf = perfResults[i] ? (perfResults[i]!.pass ? "Pass" : "Fail") : "-"; + const perf = perfResults[i] + ? perfResults[i]!.pass + ? "Pass" + : "Fail" + : "-"; const perfDetail = perfResults[i] ? perfResults[i]!.detail : "-"; return `| ${r.label} | ${status} | ${dom} | ${domDetail} | ${jump} | ${jumpDetail} | ${perf} | ${perfDetail} |`; }), @@ -1707,14 +1836,14 @@ export const PlaygroundTests = () => { const [debug, setDebug] = React.useState(false); const results = useResults(); const [domResults, setDomResults] = React.useState< - (({ pass: boolean; detail: string }) | null)[] + ({ pass: boolean; detail: string } | null)[] >(() => TESTS.map(() => null)); const [jumpResults, setJumpResults] = React.useState< - (({ pass: boolean; detail: string }) | null)[] - >(() => TESTS.map(() => null)); - const [perfResults, setPerfResults] = React.useState< - (PerfResult | null)[] + ({ pass: boolean; detail: string } | null)[] >(() => TESTS.map(() => null)); + const [perfResults, setPerfResults] = React.useState<(PerfResult | null)[]>( + () => TESTS.map(() => null), + ); const cardRefs = React.useRef<(HTMLDivElement | null)[]>([]); const filteredIndices = TESTS.map((_, i) => i).filter((i) => { @@ -1776,7 +1905,12 @@ export const PlaygroundTests = () => { }; const handleCopyFails = () => { - const hadFails = copyFailsToClipboard(results, domResults, jumpResults, perfResults); + const hadFails = copyFailsToClipboard( + results, + domResults, + jumpResults, + perfResults, + ); setCopied(hadFails ? "fails" : "none"); setTimeout(() => setCopied(false), 2000); }; @@ -1792,13 +1926,22 @@ export const PlaygroundTests = () => { · {failed} failed )} {domFailed > 0 && ( - · {domFailed} DOM failed + + {" "} + · {domFailed} DOM failed + )} {jumpFailed > 0 && ( - · {jumpFailed} JUMP failed + + {" "} + · {jumpFailed} JUMP failed + )} {perfFailed > 0 && ( - · {perfFailed} PERF failed + + {" "} + · {perfFailed} PERF failed + )} @@ -1808,9 +1951,11 @@ export const PlaygroundTests = () => {
{results.map((r, i) => ( - + { : 0; const diffStr = diff > 0 ? `+${diff}` : `${diff}`; return ( - - + + {entry.name}{" "} {(entry.gzip / 1024).toFixed(1)}kB {diff !== 0 && ( @@ -1864,7 +2008,11 @@ export const PlaygroundTests = () => { className={styles.button} onClick={handleCopyFails} > - {copied === "fails" ? "Copied!" : copied === "none" ? "No Fails" : "Copy Fails"} + {copied === "fails" + ? "Copied!" + : copied === "none" + ? "No Fails" + : "Copy Fails"}
{ALIGNS.map((a) => ( - + - )} -
-
-
-
- - {index + 1} / {test.values.length} - - - - - -
-
- {showInspector && ( - - )} -
- ); -} - -function SandboxCard() { - const [from, setFrom] = React.useState("hello world"); - const [to, setTo] = React.useState("world hello"); - const [current, setCurrent] = React.useState("hello world"); - const progressRef = React.useRef(null); - const duration = DEFAULT_TEXT_MORPH_OPTIONS.duration; - - const toggle = React.useCallback(() => { - setCurrent((c) => (c === from ? to : from)); - }, [from, to]); - - return ( -
-
-
- Sandbox -
-
- custom -
-
-

- Type any text to test morphing behavior with custom inputs. -

-
-
- - { - setFrom(e.target.value); - setCurrent(e.target.value); - }} - /> -
-
- - setTo(e.target.value)} - /> -
-
-
- { - if (progressRef.current) { - const el = progressRef.current; - el.style.transition = "none"; - el.style.width = "0%"; - el.offsetHeight; - el.style.transition = `width ${duration}ms linear`; - el.style.width = "100%"; - } - }} - onAnimationComplete={() => { - if (progressRef.current) { - progressRef.current.style.transition = "none"; - progressRef.current.style.width = "0%"; - } - }} - > - {current} - -
-
-
-
-
-
-
-
-
- ); -} - -function copyResultsToClipboard( - results: TestResult[], - domResults: ({ pass: boolean; detail: string } | null)[], - jumpResults: ({ pass: boolean; detail: string } | null)[], - perfResults: (PerfResult | null)[], -) { - const lines = [ - "# Torph Test Results", - "", - `| Test | Data | DOM | DOM Detail | Jump | Jump Detail | Perf | Perf Detail | Time |`, - `|------|------|-----|------------|------|-------------|------|-------------|------|`, - ...results.map((r, i) => { - const status = !r.result ? "Skip" : r.result.pass ? "Pass" : "Fail"; - const dom = domResults[i] ? (domResults[i]!.pass ? "Pass" : "Fail") : "-"; - const domDetail = domResults[i] ? domResults[i]!.detail : "-"; - const jump = jumpResults[i] - ? jumpResults[i]!.pass - ? "Pass" - : "Fail" - : "-"; - const jumpDetail = jumpResults[i] ? jumpResults[i]!.detail : "-"; - const perf = perfResults[i] - ? perfResults[i]!.pass - ? "Pass" - : "Fail" - : "-"; - const perfDetail = perfResults[i] ? perfResults[i]!.detail : "-"; - const time = r.timeMs !== null ? `${r.timeMs.toFixed(2)}ms` : "-"; - return `| ${r.label} | ${status} | ${dom} | ${domDetail} | ${jump} | ${jumpDetail} | ${perf} | ${perfDetail} | ${time} |`; - }), - "", - `Generated: ${new Date().toISOString()}`, - ]; - navigator.clipboard.writeText(lines.join("\n")); -} - -function copyFailsToClipboard( - results: TestResult[], - domResults: ({ pass: boolean; detail: string } | null)[], - jumpResults: ({ pass: boolean; detail: string } | null)[], - perfResults: (PerfResult | null)[], -) { - const failed = results - .map((r, i) => ({ r, i })) - .filter(({ r, i }) => { - const dataFail = r.result && !r.result.pass; - const domFail = domResults[i] && !domResults[i]!.pass; - const jumpFail = jumpResults[i] && !jumpResults[i]!.pass; - const perfFail = perfResults[i] && !perfResults[i]!.pass; - return dataFail || domFail || jumpFail || perfFail; - }); - - if (failed.length === 0) { - navigator.clipboard.writeText("No failures."); - return false; - } - - const lines = [ - "# Torph Failed Tests", - "", - `| Test | Data | DOM | DOM Detail | Jump | Jump Detail | Perf | Perf Detail |`, - `|------|------|-----|------------|------|-------------|------|-------------|`, - ...failed.map(({ r, i }) => { - const status = !r.result ? "Skip" : r.result.pass ? "Pass" : "Fail"; - const dom = domResults[i] ? (domResults[i]!.pass ? "Pass" : "Fail") : "-"; - const domDetail = domResults[i] ? domResults[i]!.detail : "-"; - const jump = jumpResults[i] - ? jumpResults[i]!.pass - ? "Pass" - : "Fail" - : "-"; - const jumpDetail = jumpResults[i] ? jumpResults[i]!.detail : "-"; - const perf = perfResults[i] - ? perfResults[i]!.pass - ? "Pass" - : "Fail" - : "-"; - const perfDetail = perfResults[i] ? perfResults[i]!.detail : "-"; - return `| ${r.label} | ${status} | ${dom} | ${domDetail} | ${jump} | ${jumpDetail} | ${perf} | ${perfDetail} |`; - }), - "", - `Generated: ${new Date().toISOString()}`, - ]; - navigator.clipboard.writeText(lines.join("\n")); - return true; -} +import { TESTS, ALL_TAGS } from "./tests"; +import type { Speed, EasingKey, Align } from "./config"; +import { SPEEDS, EASINGS, ALIGNS } from "./config"; +import type { PerfResult } from "./verify"; +import { TestCard, useResults } from "./test-card"; +import { SandboxCard } from "./sandbox-card"; +import { copyResultsToClipboard, copyFailsToClipboard } from "./export"; export const PlaygroundTests = () => { const [activeTag, setActiveTag] = React.useState(null); @@ -1846,6 +34,91 @@ export const PlaygroundTests = () => { ); const cardRefs = React.useRef<(HTMLDivElement | null)[]>([]); + // Run-all sequencer state + type RunItem = { testIndex: number; align: Align }; + const [running, setRunning] = React.useState(false); + const [runQueue, setRunQueue] = React.useState([]); + const [runCurrent, setRunCurrent] = React.useState(null); + const [runCompleted, setRunCompleted] = React.useState(0); + const [runTotal, setRunTotal] = React.useState(0); + const [hasRunResults, setHasRunResults] = React.useState(false); + const [showResults, setShowResults] = React.useState(false); + const [autoRunSignal, setAutoRunSignal] = React.useState(0); + const resultsPanelRef = React.useRef(null); + + const startRunAll = React.useCallback(() => { + // Build queue: for each test, run all alignments before moving on + const items: RunItem[] = []; + for (let i = 0; i < TESTS.length; i++) { + for (const a of ALIGNS) { + items.push({ testIndex: i, align: a }); + } + } + setRunQueue(items); + setRunCurrent(items[0]!); + setRunCompleted(0); + setRunTotal(items.length); + setRunning(true); + setHasRunResults(false); + setShowResults(false); + setAutoRunSignal((s) => s + 1); + // Reset DOM/jump/perf results + setDomResults(TESTS.map(() => null)); + setJumpResults(TESTS.map(() => null)); + setPerfResults(TESTS.map(() => null)); + }, []); + + const runAdvanceTimer = React.useRef | null>(null); + const handleCardRunComplete = React.useCallback( + (testIndex: number, runAlign: Align) => { + setRunCompleted((c) => c + 1); + setRunQueue((queue) => { + const remaining = queue.filter( + (item) => !(item.testIndex === testIndex && item.align === runAlign), + ); + if (remaining.length > 0) { + const next = remaining[0]!; + // Delay between alignment passes on same card so the shift is visible + const delay = next.testIndex === testIndex ? 400 : 100; + setRunCurrent(next); + if (runAdvanceTimer.current) clearTimeout(runAdvanceTimer.current); + runAdvanceTimer.current = setTimeout(() => { + setAutoRunSignal((s) => s + 1); + }, delay); + } else { + setRunCurrent(null); + setRunning(false); + setHasRunResults(true); + setShowResults(true); + } + return remaining; + }); + }, + [], + ); + + // Auto-scroll to the active card during a run + React.useEffect(() => { + if (running && runCurrent) { + cardRefs.current[runCurrent.testIndex]?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + } + }, [running, runCurrent]); + + // Scroll to results panel when run finishes + React.useEffect(() => { + if (hasRunResults && showResults && !running) { + setTimeout(() => { + resultsPanelRef.current?.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + }, 100); + } + }, [hasRunResults, showResults, running]); + const filteredIndices = TESTS.map((_, i) => i).filter((i) => { if (activeTag && !TESTS[i]!.tags.includes(activeTag)) return false; if (failOnly) { @@ -1865,6 +138,7 @@ export const PlaygroundTests = () => { const jumpFailed = jumpResults.filter((r) => r && !r.pass).length; const perfFailed = perfResults.filter((r) => r && !r.pass).length; const total = results.filter((r) => r.result).length; + const totalFailed = failed + domFailed + jumpFailed + perfFailed; React.useEffect(() => { function handleKeyDown(e: KeyboardEvent) { @@ -1920,30 +194,40 @@ export const PlaygroundTests = () => {
- - {passed}/{total} passed - {failed > 0 && ( - · {failed} failed - )} - {domFailed > 0 && ( - - {" "} - · {domFailed} DOM failed - - )} - {jumpFailed > 0 && ( - - {" "} - · {jumpFailed} JUMP failed - - )} - {perfFailed > 0 && ( - - {" "} - · {perfFailed} PERF failed - - )} - + {running ? ( + + Running {runCompleted}/{runTotal} + {runCurrent ? ` (${runCurrent.align})` : ""}… + + ) : ( + + {passed}/{total} passed + {failed > 0 && ( + + {" "} + · {failed} failed + + )} + {domFailed > 0 && ( + + {" "} + · {domFailed} DOM + + )} + {jumpFailed > 0 && ( + + {" "} + · {jumpFailed} JUMP + + )} + {perfFailed > 0 && ( + + {" "} + · {perfFailed} PERF + + )} + + )} v{pkg.version} {isDev && dev} @@ -1974,6 +258,17 @@ export const PlaygroundTests = () => { ))}
+ + {/* Global run progress bar */} + {running && ( +
+
+
+ )} +
{bundleSizes.map((entry) => { @@ -2025,6 +320,67 @@ export const PlaygroundTests = () => {
+ + {/* Results summary panel — shown after a run completes */} + {hasRunResults && showResults && ( +
+
+ + {totalFailed === 0 + ? `All ${total} tests passed` + : `${totalFailed} issue${totalFailed !== 1 ? "s" : ""} found`} + + +
+
+ {TESTS.map((test, i) => { + const r = results[i]; + const dom = domResults[i]; + const jump = jumpResults[i]; + const perf = perfResults[i]; + const dataFail = r?.result && !r.result.pass; + const domFail = dom && !dom.pass; + const jumpFail = jump && !jump.pass; + const perfFail = perf && !perf.pass; + const anyFail = dataFail || domFail || jumpFail || perfFail; + + return ( + + ); + })} +
+
+ )} +
+ {hasRunResults && !showResults && ( + + )}
); diff --git a/site/src/surfaces/playground-tests/sandbox-card.tsx b/site/src/surfaces/playground-tests/sandbox-card.tsx new file mode 100644 index 0000000..828d129 --- /dev/null +++ b/site/src/surfaces/playground-tests/sandbox-card.tsx @@ -0,0 +1,88 @@ +import React from "react"; +import { TextMorph } from "torph/react"; +import { DEFAULT_TEXT_MORPH_OPTIONS } from "torph"; +import styles from "./styles.module.scss"; + +export function SandboxCard() { + const [from, setFrom] = React.useState("hello world"); + const [to, setTo] = React.useState("world hello"); + const [current, setCurrent] = React.useState("hello world"); + const progressRef = React.useRef(null); + const duration = DEFAULT_TEXT_MORPH_OPTIONS.duration; + + const toggle = React.useCallback(() => { + setCurrent((c) => (c === from ? to : from)); + }, [from, to]); + + return ( +
+
+
+ Sandbox +
+
+ custom +
+
+

+ Type any text to test morphing behavior with custom inputs. +

+
+
+ + { + setFrom(e.target.value); + setCurrent(e.target.value); + }} + /> +
+
+ + setTo(e.target.value)} + /> +
+
+
+ { + if (progressRef.current) { + const el = progressRef.current; + el.style.transition = "none"; + el.style.width = "0%"; + el.offsetHeight; + el.style.transition = `width ${duration}ms linear`; + el.style.width = "100%"; + } + }} + onAnimationComplete={() => { + if (progressRef.current) { + progressRef.current.style.transition = "none"; + progressRef.current.style.width = "0%"; + } + }} + > + {current} + +
+
+
+
+
+
+
+
+
+ ); +} diff --git a/site/src/surfaces/playground-tests/styles.module.scss b/site/src/surfaces/playground-tests/styles.module.scss index dc68127..a2f85e1 100644 --- a/site/src/surfaces/playground-tests/styles.module.scss +++ b/site/src/surfaces/playground-tests/styles.module.scss @@ -491,15 +491,147 @@ text-align: center; font-size: 0.65rem; color: rgba(255, 255, 255, 0.2); - padding: 0.5rem 0; + margin: 0.5rem 0; kbd { font-family: monospace; font-size: 0.6rem; padding: 0.1rem 0.3rem; border-radius: 0.2rem; - border: 1px solid rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.04); - color: rgba(255, 255, 255, 0.35); + color: rgba(255, 255, 255, 0.3); + } +} + +// ── Run All progress ── + +.runProgress { + width: 100%; + height: 3px; + border-radius: 2px; + background: rgba(255, 255, 255, 0.06); + overflow: hidden; +} + +.runProgressBar { + height: 100%; + background: var(--primary); + border-radius: 2px; + transition: width 200ms ease; +} + +.runAllBtn { + composes: button; + background: var(--primary); + color: #000; + font-weight: 600; + + &:hover { + opacity: 0.9; + } +} + +// ── Results panel ── + +.resultsPanel { + border-radius: 0.75rem; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + overflow: hidden; +} + +.resultsPanelHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.625rem 1rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.resultsPanelTitle { + font-size: 0.8rem; + font-weight: 600; + color: rgba(255, 255, 255, 0.6); +} + +.resultsPanelClose { + all: unset; + cursor: pointer; + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.3); + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + + &:hover { + color: rgba(255, 255, 255, 0.6); + background: rgba(255, 255, 255, 0.06); + } +} + +.resultsList { + display: flex; + flex-direction: column; + max-height: 20rem; + overflow-y: auto; +} + +.resultsRow { + all: unset; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 1rem; + cursor: pointer; + font-size: 0.75rem; + transition: background 100ms ease; + + &:hover { + background: rgba(255, 255, 255, 0.04); + } +} + +.resultsRowPass { + .resultsStatus { + color: rgb(74, 222, 128); + } + + .resultsLabel { + color: rgba(255, 255, 255, 0.3); + } +} + +.resultsRowFail { + .resultsStatus { + color: rgb(248, 113, 113); + } + + .resultsLabel { + color: rgba(255, 255, 255, 0.7); + } +} + +.resultsStatus { + font-weight: 700; + font-size: 0.7rem; + width: 1rem; + text-align: center; +} + +.resultsLabel { + flex: 1; +} + +.resultsFailTags { + display: flex; + gap: 0.25rem; + + span { + font-size: 0.6rem; + font-weight: 600; + padding: 0.1rem 0.375rem; + border-radius: 0.25rem; + background: rgba(248, 113, 113, 0.15); + color: rgb(248, 113, 113); } } diff --git a/site/src/surfaces/playground-tests/test-card.tsx b/site/src/surfaces/playground-tests/test-card.tsx new file mode 100644 index 0000000..8354adb --- /dev/null +++ b/site/src/surfaces/playground-tests/test-card.tsx @@ -0,0 +1,401 @@ +import React from "react"; +import { TextMorph } from "torph/react"; +import { segmentText, diffSegments } from "torph"; +import { Tooltip } from "@/components/tooltip"; +import { Button } from "@/components/button"; +import styles from "./styles.module.scss"; +import type { TestCase } from "./tests"; +import { TESTS } from "./tests"; +import type { PerfResult, JumpSnapshot } from "./verify"; +import { FrameMonitor, takeJumpSnapshot, verifyNoJump, measurePerf } from "./verify"; +import type { Speed, EasingKey, Align } from "./config"; +import { SPEEDS, EASINGS } from "./config"; + +export type TestResult = { + label: string; + result: { pass: boolean; detail: string } | null; + timeMs: number | null; +}; + +function computeResults(): TestResult[] { + return TESTS.map((test) => { + if (!test.verify) return { label: test.label, result: null, timeMs: null }; + const { timeMs, ...result } = measurePerf(test.verify); + return { label: test.label, result, timeMs }; + }); +} + +export function useResults(): TestResult[] { + const empty = TESTS.map((t) => ({ + label: t.label, + result: null, + timeMs: null, + })); + const [results, setResults] = React.useState(empty); + React.useEffect(() => { + setResults(computeResults()); + }, []); + return results; +} + +function SegmentInspector({ from, to }: { from: string; to: string }) { + const oldSegs = segmentText(from, "en"); + const { segments: newSegs, splits } = diffSegments(oldSegs, to, "en"); + + return ( +
+
+ Old segments +
+ {oldSegs.map((s, i) => ( + + + {s.string === "\u00A0" ? "·" : s.string} + {s.id.slice(0, 6)} + + + ))} +
+
+
+ New segments +
+ {newSegs.map((s, i) => { + const persisted = oldSegs.some((o) => o.id === s.id); + return ( + + + {s.string === "\u00A0" ? "·" : s.string} + {s.id.slice(0, 6)} + + + ); + })} +
+
+ {splits.size > 0 && ( +
+ Splits +
+ {[...splits.entries()].map(([word, chars]) => ( + + + {word} → {chars.map((c) => c.string).join("")} + + + ))} +
+
+ )} +
+ ); +} + +export function TestCard({ + test, + result, + timeMs, + morphAllSignal, + domResult, + jumpResult, + perfResult, + cardRef, + onDomResult, + onJumpResult, + onPerfResult, + autoRunSignal, + onRunComplete, + speed, + easing, + align: globalAlign, + debug, +}: { + test: TestCase; + result: { pass: boolean; detail: string } | null; + timeMs: number | null; + morphAllSignal: number; + domResult: { pass: boolean; detail: string } | null; + jumpResult: { pass: boolean; detail: string } | null; + perfResult: PerfResult | null; + cardRef?: React.Ref; + onDomResult?: (result: { pass: boolean; detail: string } | null) => void; + onJumpResult?: (result: { pass: boolean; detail: string } | null) => void; + onPerfResult?: (result: PerfResult | null) => void; + autoRunSignal?: number; + onRunComplete?: () => void; + speed: Speed; + easing: EasingKey; + align: Align; + debug: boolean; +}) { + const [index, setIndex] = React.useState(0); + const [auto, setAuto] = React.useState(false); + const [showInspector, setShowInspector] = React.useState(false); + const align = (test.align as Align) || globalAlign; + const progressRef = React.useRef(null); + const intervalRef = React.useRef | null>(null); + const bodyRef = React.useRef(null); + const preAnimSnap = React.useRef(null); + const frameMonitor = React.useRef(new FrameMonitor()); + const autoRunning = React.useRef(false); + const autoRunTimer = React.useRef | null>(null); + const onRunCompleteRef = React.useRef(onRunComplete); + onRunCompleteRef.current = onRunComplete; + + const advance = React.useCallback(() => { + setIndex((i) => (i + 1) % test.values.length); + }, [test.values.length]); + + // Auto-run: advance once, wait for animation, then signal completion + React.useEffect(() => { + if (!autoRunSignal) { + autoRunning.current = false; + if (autoRunTimer.current) clearTimeout(autoRunTimer.current); + return; + } + autoRunning.current = true; + // Advance one step + setIndex((i) => (i + 1) % test.values.length); + // Fallback: if onAnimationComplete doesn't fire within 2s, force-complete + autoRunTimer.current = setTimeout(() => { + if (autoRunning.current) { + autoRunning.current = false; + onRunCompleteRef.current?.(); + } + }, 2000); + }, [autoRunSignal, test.values.length]); + + React.useEffect(() => { + if (morphAllSignal > 0) advance(); + }, [morphAllSignal, advance]); + + React.useEffect(() => { + if (auto) { + intervalRef.current = setInterval(advance, 150); + } + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [auto, advance]); + + const isSpamTest = test.tags.includes("spam"); + const prevIndex = (index - 1 + test.values.length) % test.values.length; + + return ( +
+
+
+ {test.label} +
+
+ {test.tags.map((tag) => ( + + {tag} + + ))} +
+
+

{test.description}

+
+ { + frameMonitor.current.start(); + if (progressRef.current) { + const el = progressRef.current; + el.style.transition = "none"; + el.style.width = "0%"; + el.offsetHeight; // force reflow + el.style.transition = `width ${SPEEDS[speed]}ms linear`; + el.style.width = "100%"; + } + if (bodyRef.current) { + const torphRoot = + bodyRef.current.querySelector("[torph-root]"); + if (torphRoot) { + preAnimSnap.current = takeJumpSnapshot(torphRoot); + requestAnimationFrame(() => { + if (preAnimSnap.current) { + onJumpResult?.( + verifyNoJump(torphRoot, preAnimSnap.current), + ); + } + }); + } + } + }} + onAnimationComplete={() => { + onPerfResult?.(frameMonitor.current.stop()); + if (progressRef.current) { + progressRef.current.style.transition = "none"; + progressRef.current.style.width = "0%"; + } + if (test.verifyDom && bodyRef.current) { + const torphRoot = + bodyRef.current.querySelector("[torph-root]"); + if (torphRoot) { + onDomResult?.(test.verifyDom(torphRoot)); + } + } + // Auto-run: single morph done, signal completion + if (autoRunning.current) { + if (autoRunTimer.current) clearTimeout(autoRunTimer.current); + autoRunning.current = false; + onRunCompleteRef.current?.(); + } + }} + > + {test.values[index]} + +
+
+ + + + {result ? (result.pass ? "PASS" : "FAIL") : "…"} + + + + {test.verifyDom && ( + + + + {domResult + ? `DOM ${domResult.pass ? "PASS" : "FAIL"}` + : "DOM …"} + + + + )} + + + + {jumpResult + ? `JUMP ${jumpResult.pass ? "OK" : "FAIL"}` + : "JUMP …"} + + + + + + + {perfResult + ? perfResult.pass + ? `${perfResult.totalFrames}f` + : `${perfResult.droppedFrames} drop` + : "PERF …"} + + + + + + + {timeMs !== null + ? `${timeMs < 0.01 ? "<0.01" : timeMs.toFixed(2)}ms` + : "—"} + + + + {isSpamTest && ( + + )} +
+
+
+
+ + {index + 1} / {test.values.length} + + + + + +
+
+ {showInspector && ( + + )} +
+ ); +} diff --git a/site/src/surfaces/playground-tests/tests.ts b/site/src/surfaces/playground-tests/tests.ts new file mode 100644 index 0000000..d83b046 --- /dev/null +++ b/site/src/surfaces/playground-tests/tests.ts @@ -0,0 +1,608 @@ +import React from "react"; +import { segmentText, diffSegments } from "torph"; +import type { Segment } from "torph"; +import type { VerifyFn, VerifyDomFn } from "./verify"; +import { + verifyWordPersistence, + verifyCharMorph, + verifyNoMorph, + verifyCycleStability, + verifyWordAbsent, + verifyGraphemeMorph, + combineResults, + verifyDomStandard, + verifyMultiline, +} from "./verify"; + +export type TestCase = { + label: string; + description: string; + tags: string[]; + values: string[]; + align?: React.CSSProperties["textAlign"]; + verify?: VerifyFn; + verifyDom?: VerifyDomFn; +}; + +export const TESTS: TestCase[] = [ + // ── Basics: word persistence, enter, exit, reorder ── + { + label: "Word reorder + exit", + description: + "Transaction should FLIP to its new position. Safe should exit, Processing should enter.", + tags: ["flip", "exit direction"], + values: ["Transaction Safe", "Processing Transaction"], + verify: () => + verifyWordPersistence( + "Transaction Safe", + "Processing Transaction", + "Transaction", + ), + verifyDom: (root) => verifyDomStandard(root), + }, + { + label: "Same word, reversed order", + description: + 'Both "hello" and "world" FLIP to swap positions. No enter/exit — just movement.', + tags: ["flip"], + values: ["hello world", "world hello"], + verify: () => + combineResults( + verifyWordPersistence("hello world", "world hello", "hello"), + verifyWordPersistence("hello world", "world hello", "world"), + ), + verifyDom: (root) => verifyDomStandard(root), + }, + { + label: "Add word", + description: '"hello" persists in place. "world" enters with fade + scale.', + tags: ["enter"], + values: ["hello", "hello world"], + verify: () => { + const old = segmentText("hello", "en"); + const { segments } = diffSegments(old, "hello world", "en"); + const oldCharIds = old.map((s: Segment) => s.id); + const newCharIds = segments + .filter((s: Segment) => s.string !== "\u00A0" && s.string.length === 1) + .map((s: Segment) => s.id); + const allPersist = oldCharIds.every((id) => newCharIds.includes(id)); + const worldEnters = segments.some((s: Segment) => s.string === "world"); + return { + pass: allPersist && worldEnters, + detail: allPersist + ? worldEnters + ? "hello chars persist; world enters" + : "world missing" + : "Some hello char IDs lost", + }; + }, + verifyDom: (root) => verifyDomStandard(root), + }, + { + label: "Remove word", + description: '"hello" persists. "world" exits with fade out.', + tags: ["exit"], + values: ["hello world", "hello"], + verify: () => + combineResults( + verifyWordPersistence("hello world", "hello", "hello"), + verifyWordAbsent("hello world", "hello", "world"), + ), + verifyDom: (root) => verifyDomStandard(root), + }, + { + label: "Dissimilar word replacement", + description: + '"cat" and "dog" exit as whole words (no char morph). "fish" and "bird" enter. "and" persists.', + tags: ["no morph", "enter", "exit"], + values: ["cat and dog", "fish and bird"], + verify: () => + combineResults( + verifyNoMorph("cat and dog", "fish and bird"), + verifyWordPersistence("cat and dog", "fish and bird", "and"), + ), + verifyDom: (root) => verifyDomStandard(root), + }, + { + label: "Multi-word persist", + description: + '"the" and "brown" persist across states. Changed words enter/exit smoothly.', + tags: ["flip", "enter", "exit"], + values: [ + "the quick brown fox", + "the slow brown dog", + "a quick brown fox jumps", + ], + verify: () => + combineResults( + verifyWordPersistence( + "the quick brown fox", + "the slow brown dog", + "brown", + ), + verifyWordPersistence( + "the quick brown fox", + "the slow brown dog", + "the", + ), + ), + verifyDom: (root) => verifyDomStandard(root), + }, + { + label: "Duplicate words", + description: + 'Both "the" instances persist with distinct IDs. "cat"/"dog" exit, "big"/"small" enter.', + tags: ["duplicates", "flip"], + values: ["the cat and the dog", "the big and the small"], + verify: () => { + const old = segmentText("the cat and the dog", "en"); + const { segments } = diffSegments(old, "the big and the small", "en"); + const oldThes = old.filter((s: Segment) => s.string === "the"); + const newThes = segments.filter((s: Segment) => s.string === "the"); + const bothPersist = + oldThes.length === 2 && + newThes.length === 2 && + oldThes[0]!.id === newThes[0]!.id && + oldThes[1]!.id === newThes[1]!.id; + const andPersists = + old.find((s: Segment) => s.string === "and")?.id === + segments.find((s: Segment) => s.string === "and")?.id; + return { + pass: bothPersist && andPersists, + detail: bothPersist + ? andPersists + ? 'Both "the" IDs persist; "and" persists' + : '"and" ID changed' + : 'Duplicate "the" IDs not preserved', + }; + }, + verifyDom: (root) => verifyDomStandard(root), + }, + + // ── Character morph ── + { + label: "Character morph (add prefix)", + description: + '"p" enters while "n", "p", "m" persist and FLIP. "i" and "torph" stay unchanged.', + tags: ["char morph", "split"], + values: ["npm i torph", "pnpm i torph"], + verify: () => verifyCharMorph("npm i torph", "pnpm i torph", "npm"), + verifyDom: (root) => verifyDomStandard(root), + }, + { + label: "Character morph + word swap", + description: + '"npm" morphs to "pnpm" at char level. "i" exits, "add" enters. "torph" persists.', + tags: ["char morph", "enter", "exit"], + values: ["npm i torph", "pnpm add torph"], + verify: () => + combineResults( + verifyCharMorph("npm i torph", "pnpm add torph", "npm"), + verifyWordPersistence("npm i torph", "pnpm add torph", "torph"), + ), + verifyDom: (root) => verifyDomStandard(root), + }, + { + label: "Reverse character morph", + description: + '"pnpm" splits into chars. "n", "p", "m" persist into "npm", the leading "p" exits.', + tags: ["char morph", "reverse"], + values: ["pnpm i torph", "npm i torph"], + verify: () => verifyCharMorph("pnpm i torph", "npm i torph", "pnpm"), + verifyDom: (root) => verifyDomStandard(root), + }, + { + label: "Single character change", + description: '"c", "a", "r" persist. "t" exits and "d" enters.', + tags: ["char morph"], + values: ["cart", "card"], + verify: () => verifyGraphemeMorph("cart", "card", ["c", "a", "r"]), + verifyDom: (root) => verifyDomStandard(root), + }, + { + label: "Case change", + description: + "Same words, different casing. Char morph handles the letter-level changes.", + tags: ["char morph"], + values: ["Hello World", "hello world"], + verify: () => verifyCharMorph("Hello World", "hello world", "Hello"), + verifyDom: (root) => verifyDomStandard(root), + }, + { + label: "Punctuation", + description: + '"Hello," char-morphs to "Hello" — shared char IDs persist. "world!" likewise morphs to "world".', + tags: ["char morph"], + values: ["Hello, world!", "Hello world"], + verify: () => { + const old = segmentText("Hello, world!", "en"); + const { segments } = diffSegments(old, "Hello world", "en"); + const oldIds = new Set(old.map((s: Segment) => s.id)); + const persisted = segments.filter((s: Segment) => oldIds.has(s.id)); + const pass = persisted.length >= 4; + return { + pass, + detail: pass + ? `${persisted.length} char IDs persist across punctuation change` + : `Only ${persisted.length} IDs persisted (expected ≥4)`, + }; + }, + verifyDom: (root) => verifyDomStandard(root), + }, + { + label: "Numbers", + description: + "Shared digits and symbols ($, commas) persist. New digits enter.", + tags: ["char morph"], + values: ["$1,234", "$12,345,678", "$99"], + align: "right", + verify: () => verifyGraphemeMorph("$1,234", "$12,345,678", ["$", "1", ","]), + verifyDom: (root) => verifyDomStandard(root), + }, + { + label: "Long word char morph", + description: + "Character-level morph on a long single word with partial overlap.", + tags: ["char morph", "stress"], + values: ["abcdefghijklmnop", "abcmnopqrstuvwx"], + verify: () => + verifyGraphemeMorph("abcdefghijklmnop", "abcmnopqrstuvwx", [ + "a", + "b", + "c", + "m", + "n", + "o", + "p", + ]), + verifyDom: (root) => verifyDomStandard(root), + }, + + // ── Multiline ── + { + label: "Multiline basic", + description: + "Shared words persist across line breaks. Newlines are treated as word boundaries.", + tags: ["multiline"], + values: ["hello\nworld", "hello\nuniverse"], + verify: () => + verifyWordPersistence("hello\nworld", "hello\nuniverse", "hello"), + verifyDom: (root) => + combineResults(verifyDomStandard(root), verifyMultiline(root, 2)), + }, + { + label: "Multiline add line", + description: "Adding a new line enters new words. Existing words persist.", + tags: ["multiline", "enter"], + values: ["hello world\ngoodbye", "hello world\ngoodbye\nfarewell"], + verify: () => + combineResults( + verifyWordPersistence( + "hello world\ngoodbye", + "hello world\ngoodbye\nfarewell", + "hello", + ), + verifyWordPersistence( + "hello world\ngoodbye", + "hello world\ngoodbye\nfarewell", + "goodbye", + ), + ), + verifyDom: (root) => + combineResults(verifyDomStandard(root), verifyMultiline(root, 2)), + }, + { + label: "Multiline remove line", + description: "Removing a line exits those words. Remaining words persist.", + tags: ["multiline", "exit"], + values: ["hello world\nfoo bar\ngoodbye moon", "hello world\ngoodbye moon"], + verify: () => + combineResults( + verifyWordPersistence( + "hello world\nfoo bar\ngoodbye moon", + "hello world\ngoodbye moon", + "hello", + ), + verifyWordPersistence( + "hello world\nfoo bar\ngoodbye moon", + "hello world\ngoodbye moon", + "goodbye", + ), + verifyWordAbsent( + "hello world\nfoo bar\ngoodbye moon", + "hello world\ngoodbye moon", + "foo", + ), + ), + verifyDom: (root) => + combineResults(verifyDomStandard(root), verifyMultiline(root, 2)), + }, + { + label: "Multiline reorder", + description: + "Swapping line order. Shared words persist and FLIP to new positions.", + tags: ["multiline", "flip"], + values: ["alpha bravo\ncharlie delta", "charlie delta\nalpha bravo"], + verify: () => + combineResults( + verifyWordPersistence( + "alpha bravo\ncharlie delta", + "charlie delta\nalpha bravo", + "alpha", + ), + verifyWordPersistence( + "alpha bravo\ncharlie delta", + "charlie delta\nalpha bravo", + "charlie", + ), + ), + verifyDom: (root) => + combineResults(verifyDomStandard(root), verifyMultiline(root, 2)), + }, + { + label: "Multiline with edits", + description: + "Lines change content while shared words persist across the multiline transition.", + tags: ["multiline", "flip"], + values: [ + "the quick brown fox\njumps over the lazy dog", + "the slow red fox\nleaps over the happy cat", + ], + verify: () => + combineResults( + verifyWordPersistence( + "the quick brown fox\njumps over the lazy dog", + "the slow red fox\nleaps over the happy cat", + "the", + ), + verifyWordPersistence( + "the quick brown fox\njumps over the lazy dog", + "the slow red fox\nleaps over the happy cat", + "fox", + ), + verifyWordPersistence( + "the quick brown fox\njumps over the lazy dog", + "the slow red fox\nleaps over the happy cat", + "over", + ), + ), + verifyDom: (root) => + combineResults(verifyDomStandard(root), verifyMultiline(root, 2)), + }, + { + label: "Multiline ↔ single line", + description: + "Toggling between line break and space. Words persist and FLIP between vertical/horizontal layout.", + tags: ["multiline", "flip"], + values: ["hello\nworld", "hello world"], + verify: () => + combineResults( + verifyWordPersistence("hello\nworld", "hello world", "hello"), + verifyWordPersistence("hello\nworld", "hello world", "world"), + verifyWordPersistence("hello world", "hello\nworld", "hello"), + verifyWordPersistence("hello world", "hello\nworld", "world"), + ), + verifyDom: (root) => verifyDomStandard(root), + }, + { + label: "Empty lines", + description: "Collapsing a blank line. Words on remaining lines persist.", + tags: ["multiline", "edge case"], + values: ["hello\n\nworld", "hello\nworld"], + verify: () => + combineResults( + verifyWordPersistence("hello\n\nworld", "hello\nworld", "hello"), + verifyWordPersistence("hello\n\nworld", "hello\nworld", "world"), + ), + verifyDom: (root) => verifyDomStandard(root), + }, + { + label: "Multiline empty transition", + description: + "Multiline text exits to empty, then new multiline content enters from empty.", + tags: ["multiline", "edge case"], + values: ["hello\nworld", "", "foo\nbar"], + verify: () => { + const old = segmentText("hello\nworld", "en"); + const r1 = diffSegments(old, "", "en"); + const r2 = diffSegments([], "foo\nbar", "en"); + const pass = + r1.segments.length === 0 && + r2.segments.some((s: Segment) => s.string === "foo"); + return { + pass, + detail: pass + ? "Multiline → empty → multiline works" + : `exit segs=${r1.segments.length}, enter has foo=${r2.segments.some((s: Segment) => s.string === "foo")}`, + }; + }, + verifyDom: (root) => verifyDomStandard(root), + }, + + // ── Edge cases ── + { + label: "Empty to text", + description: + '"hello world" enters from empty. Morphing back to "" fades all words out gracefully.', + tags: ["edge case"], + values: ["", "hello world", ""], + verify: () => { + const { segments } = diffSegments([], "hello world", "en"); + const old = segmentText("hello world", "en"); + const r2 = diffSegments(old, "", "en"); + const pass = + segments.length > 0 && + segments.some((s: Segment) => s.string === "hello") && + r2.segments.length === 0; + return { + pass, + detail: pass + ? "Empty → text produces segments; text → empty produces none" + : `segments=${segments.length}, reverse=${r2.segments.length}`, + }; + }, + verifyDom: (root) => verifyDomStandard(root), + }, + { + label: "Single character", + description: + "Minimal content — single char replacement. Each transition is a full exit/enter.", + tags: ["edge case"], + values: ["a", "b", "c"], + verify: () => verifyWordAbsent("a", "b", "a"), + verifyDom: (root) => verifyDomStandard(root), + }, + { + label: "Complete replacement", + description: + "No character overlap. Everything exits and enters — no morph or persistence.", + tags: ["edge case", "enter", "exit"], + values: ["abcdef", "xyz"], + verify: () => + combineResults( + verifyNoMorph("abcdef", "xyz"), + verifyWordAbsent("abcdef", "xyz", "abcdef"), + ), + verifyDom: (root) => verifyDomStandard(root), + }, + { + label: "Whitespace normalization", + description: + "Extra spaces should not cause unexpected segment splits or ID changes.", + tags: ["edge case"], + values: ["hello world", "hello world", "hello world"], + verify: () => verifyWordPersistence("hello world", "hello world", "hello"), + verifyDom: (root) => verifyDomStandard(root), + }, + + // ── Unicode & i18n ── + { + label: "Emoji", + description: + "Emoji grapheme clusters are treated as single segments and persist correctly.", + tags: ["grapheme"], + values: ["Hello 👋", "Goodbye 👋"], + verify: () => verifyWordPersistence("Hello 👋", "Goodbye 👋", "👋"), + verifyDom: (root) => verifyDomStandard(root), + }, + { + label: "Compound emoji", + description: + "Complex emoji (family, flag sequences) are treated as single grapheme segments.", + tags: ["grapheme"], + values: ["Hello 👨‍👩‍👧‍👦", "Goodbye 👨‍👩‍👧‍👦"], + verify: () => verifyWordPersistence("Hello 👨‍👩‍👧‍👦", "Goodbye 👨‍👩‍👧‍👦", "👨‍👩‍👧‍👦"), + verifyDom: (root) => verifyDomStandard(root), + }, + { + label: "Unicode accents", + description: + "Accented characters (café → cafe). Shared base chars persist.", + tags: ["grapheme"], + values: ["café", "cafe"], + verify: () => verifyGraphemeMorph("café", "cafe", ["c", "a", "f"]), + verifyDom: (root) => verifyDomStandard(root), + }, + { + label: "RTL text (Arabic)", + description: + "Arabic text segments and diffs correctly. Shared words persist.", + tags: ["i18n"], + values: ["مرحبا بالعالم", "مرحبا يا صديقي"], + verify: () => + verifyWordPersistence("مرحبا بالعالم", "مرحبا يا صديقي", "مرحبا"), + verifyDom: (root) => verifyDomStandard(root), + }, + { + label: "RTL text (Hebrew)", + description: "Hebrew text segmentation and persistence of shared words.", + tags: ["i18n"], + values: ["שלום עולם", "שלום חברים"], + verify: () => verifyWordPersistence("שלום עולם", "שלום חברים", "שלום"), + verifyDom: (root) => verifyDomStandard(root), + }, + + // ── Stress & stability ── + { + label: "Long sentence overlap", + description: '"quick", "fox", "over" persist. Other words swap in/out.', + tags: ["stress", "flip"], + values: [ + "the quick brown fox jumps over the lazy dog", + "the quick red fox leaps over the happy cat", + ], + verify: () => + combineResults( + verifyWordPersistence( + "the quick brown fox jumps over the lazy dog", + "the quick red fox leaps over the happy cat", + "quick", + ), + verifyWordPersistence( + "the quick brown fox jumps over the lazy dog", + "the quick red fox leaps over the happy cat", + "fox", + ), + verifyWordPersistence( + "the quick brown fox jumps over the lazy dog", + "the quick red fox leaps over the happy cat", + "over", + ), + ), + verifyDom: (root) => verifyDomStandard(root), + }, + { + label: "Long paragraph", + description: + "Stress test with paragraph-length text. Common words persist, unique words enter/exit.", + tags: ["stress", "flip"], + values: [ + "The quick brown fox jumps over the lazy dog while the sun sets behind the distant mountains", + "The slow gray wolf runs under the bright moon while the rain falls across the nearby valleys", + ], + verify: () => + combineResults( + verifyWordPersistence( + "The quick brown fox jumps over the lazy dog while the sun sets behind the distant mountains", + "The slow gray wolf runs under the bright moon while the rain falls across the nearby valleys", + "while", + ), + verifyWordPersistence( + "The quick brown fox jumps over the lazy dog while the sun sets behind the distant mountains", + "The slow gray wolf runs under the bright moon while the rain falls across the nearby valleys", + "the", + ), + ), + verifyDom: (root) => verifyDomStandard(root), + }, + { + label: "Multi-cycle stability", + description: + '"Transaction" ID stays the same across 4+ cycles. Exit direction should never flip.', + tags: ["stability", "cycles"], + values: ["Transaction Safe", "Processing Transaction"], + verify: () => + verifyCycleStability( + "Transaction Safe", + "Processing Transaction", + "Transaction", + ), + verifyDom: (root) => verifyDomStandard(root), + }, + { + label: "Rapid spam (auto-cycle)", + description: + "Hit Auto to toggle every 150ms. Animations should queue gracefully without glitches.", + tags: ["spam", "resilience"], + values: ["Transaction Safe", "Processing Transaction"], + verify: () => + verifyCycleStability( + "Transaction Safe", + "Processing Transaction", + "Transaction", + ), + verifyDom: (root) => verifyDomStandard(root), + }, +]; + +export const ALL_TAGS = [...new Set(TESTS.flatMap((t) => t.tags))].sort(); diff --git a/site/src/surfaces/playground-tests/verify.ts b/site/src/surfaces/playground-tests/verify.ts new file mode 100644 index 0000000..3193624 --- /dev/null +++ b/site/src/surfaces/playground-tests/verify.ts @@ -0,0 +1,645 @@ +import { segmentText, diffSegments } from "torph"; +import type { Segment } from "torph"; + +export type VerifyFn = () => { pass: boolean; detail: string }; +export type VerifyDomFn = (root: HTMLElement) => { pass: boolean; detail: string }; + +export type JumpSnapshot = { + items: Map; + rootRect: DOMRect; + rootWidth: number; + align: string; +}; + +export type PerfResult = { + pass: boolean; + detail: string; + totalFrames: number; + droppedFrames: number; + longestFrame: number; + avgFrame: number; + morphTime: number; +}; + +// ── Text verification helpers ── + +export function verifyWordPersistence( + from: string, + to: string, + word: string, +): { pass: boolean; detail: string } { + const old = segmentText(from, "en"); + const { segments } = diffSegments(old, to, "en"); + const oldSeg = old.find((s: Segment) => s.string === word); + const newSeg = segments.find((s: Segment) => s.string === word); + if (!oldSeg || !newSeg) { + return { + pass: false, + detail: `"${word}" missing in ${!oldSeg ? "old" : "new"}`, + }; + } + const pass = oldSeg.id === newSeg.id; + return { + pass, + detail: pass + ? `"${word}" ID persists` + : `"${word}" ID changed: ${oldSeg.id} → ${newSeg.id}`, + }; +} + +export function verifyCharMorph( + from: string, + to: string, + splitWord: string, +): { pass: boolean; detail: string } { + const old = segmentText(from, "en"); + const { splits } = diffSegments(old, to, "en"); + const pass = splits.has(splitWord); + return { + pass, + detail: pass + ? `"${splitWord}" split into chars` + : `"${splitWord}" was NOT split`, + }; +} + +export function verifyNoMorph( + from: string, + to: string, +): { pass: boolean; detail: string } { + const old = segmentText(from, "en"); + const { splits } = diffSegments(old, to, "en"); + const pass = splits.size === 0; + return { + pass, + detail: pass + ? "No char splits (correct)" + : `Unexpected splits: ${[...splits.keys()].join(", ")}`, + }; +} + +export function verifyCycleStability( + a: string, + b: string, + word: string, +): { pass: boolean; detail: string } { + let prev = segmentText(a, "en"); + const originalId = prev.find((s: Segment) => s.string === word)?.id; + if (!originalId) + return { pass: false, detail: `"${word}" not found in "${a}"` }; + + for (let i = 0; i < 4; i++) { + const target = i % 2 === 0 ? b : a; + const { segments } = diffSegments(prev, target, "en"); + const seg = segments.find((s: Segment) => s.string === word); + if (!seg || seg.id !== originalId) { + return { pass: false, detail: `"${word}" ID changed at cycle ${i + 1}` }; + } + prev = segments; + } + return { pass: true, detail: `"${word}" ID stable across 4 cycles` }; +} + +export function verifyWordAbsent( + from: string, + to: string, + word: string, +): { pass: boolean; detail: string } { + const old = segmentText(from, "en"); + const { segments } = diffSegments(old, to, "en"); + const found = segments.find((s: Segment) => s.string === word); + const pass = !found; + return { + pass, + detail: pass + ? `"${word}" correctly absent` + : `"${word}" unexpectedly present`, + }; +} + +export function verifyGraphemeMorph( + from: string, + to: string, + sharedChars: string[], +): { pass: boolean; detail: string } { + const oldSegs = segmentText(from, "en"); + const newSegs = segmentText(to, "en"); + const oldChars = oldSegs.map((s: Segment) => s.string); + const newChars = newSegs.map((s: Segment) => s.string); + const allShared = sharedChars.every( + (c) => oldChars.includes(c) && newChars.includes(c), + ); + return { + pass: allShared, + detail: allShared + ? `Shared chars [${sharedChars.join(",")}] present in both` + : `Some shared chars missing`, + }; +} + +export function combineResults(...results: { pass: boolean; detail: string }[]) { + const pass = results.every((r) => r.pass); + return { pass, detail: results.map((r) => r.detail).join("; ") }; +} + +// ── DOM verification helpers ── + +export function verifyItemsInBounds(root: HTMLElement): { + pass: boolean; + detail: string; +} { + const rootRect = root.getBoundingClientRect(); + const items = root.querySelectorAll( + "[torph-item]:not([torph-exiting])", + ); + const oob: string[] = []; + items.forEach((item) => { + if (item.tagName === "BR") return; + const r = item.getBoundingClientRect(); + if (r.width === 0 && r.height === 0) return; + const tolerance = 2; + if ( + r.left < rootRect.left - tolerance || + r.right > rootRect.right + tolerance || + r.top < rootRect.top - tolerance || + r.bottom > rootRect.bottom + tolerance + ) { + const text = item.textContent?.trim() || "?"; + oob.push(`"${text}" out of bounds`); + } + }); + if (oob.length > 0) return { pass: false, detail: oob.join(", ") }; + return { pass: true, detail: "all items within bounds" }; +} + +export function verifyNoOverflow(root: HTMLElement): { + pass: boolean; + detail: string; +} { + const tolerance = 2; + const overflowW = root.scrollWidth - root.offsetWidth > tolerance; + const overflowH = root.scrollHeight - root.offsetHeight > tolerance; + if (overflowW || overflowH) { + return { + pass: false, + detail: `overflow: scroll=${root.scrollWidth}x${root.scrollHeight} offset=${root.offsetWidth}x${root.offsetHeight}`, + }; + } + return { pass: true, detail: "no overflow" }; +} + +export function verifyExitCleanup(root: HTMLElement): { + pass: boolean; + detail: string; +} { + const exiting = root.querySelectorAll("[torph-exiting]"); + if (exiting.length > 0) { + return { pass: false, detail: `${exiting.length} exiting elements remain` }; + } + return { pass: true, detail: "no stale exits" }; +} + +export function verifyAlignment( + root: HTMLElement, + align: "left" | "center" | "right", +): { pass: boolean; detail: string } { + const rootRect = root.getBoundingClientRect(); + const lines = getVisualLines(root); + if (lines.length === 0) return { pass: true, detail: "no lines to check" }; + + const tolerance = 4; + const failures: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + const lineLeft = Math.min(...line.map((r) => r.left)); + const lineRight = Math.max(...line.map((r) => r.right)); + + if (align === "left") { + if (Math.abs(lineLeft - rootRect.left) > tolerance) { + failures.push( + `line ${i + 1} not left-aligned (gap=${(lineLeft - rootRect.left).toFixed(1)}px)`, + ); + } + } else if (align === "right") { + if (Math.abs(lineRight - rootRect.right) > tolerance) { + failures.push( + `line ${i + 1} not right-aligned (gap=${(rootRect.right - lineRight).toFixed(1)}px)`, + ); + } + } else if (align === "center") { + const lineMid = (lineLeft + lineRight) / 2; + const rootMid = (rootRect.left + rootRect.right) / 2; + if (Math.abs(lineMid - rootMid) > tolerance) { + failures.push( + `line ${i + 1} not centered (off=${(lineMid - rootMid).toFixed(1)}px)`, + ); + } + } + } + + if (failures.length > 0) return { pass: false, detail: failures.join(", ") }; + return { pass: true, detail: `${align}-aligned ok` }; +} + +export function getVisualLines(root: HTMLElement): DOMRect[][] { + const items = root.querySelectorAll( + "[torph-item]:not([torph-exiting]):not(br)", + ); + if (items.length === 0) return []; + + const lines: DOMRect[][] = []; + let currentLine: DOMRect[] = []; + let lastTop = -Infinity; + + items.forEach((item) => { + const r = item.getBoundingClientRect(); + if (r.width === 0 && r.height === 0) return; + if (currentLine.length > 0 && Math.abs(r.top - lastTop) > r.height * 0.5) { + lines.push(currentLine); + currentLine = []; + } + currentLine.push(r); + lastTop = r.top; + }); + if (currentLine.length > 0) lines.push(currentLine); + return lines; +} + +export function verifyMultiline( + root: HTMLElement, + expectedMinLines: number, +): { pass: boolean; detail: string } { + const lines = getVisualLines(root); + if (lines.length < expectedMinLines) { + return { + pass: false, + detail: `expected ${expectedMinLines}+ lines, got ${lines.length}`, + }; + } + return { pass: true, detail: `${lines.length} lines` }; +} + +function isIdentityOrNone(transform: string): boolean { + if (!transform || transform === "none") return true; + const match = transform.match(/matrix\(([^)]+)\)/); + if (!match) return false; + const v = match[1]!.split(",").map((s) => parseFloat(s.trim())); + return ( + Math.abs(v[0]! - 1) < 0.01 && + Math.abs(v[1]!) < 0.01 && + Math.abs(v[2]!) < 0.01 && + Math.abs(v[3]! - 1) < 0.01 && + Math.abs(v[4]!) < 1 && + Math.abs(v[5]!) < 1 + ); +} + +export function verifyNoTransformResidue(root: HTMLElement): { + pass: boolean; + detail: string; +} { + const items = root.querySelectorAll( + "[torph-item]:not([torph-exiting]):not(br)", + ); + const stuck: string[] = []; + items.forEach((item) => { + const t = getComputedStyle(item).transform; + if (!isIdentityOrNone(t)) { + stuck.push(`"${item.textContent?.trim() || "?"}" transform=${t}`); + } + }); + if (stuck.length > 0) return { pass: false, detail: stuck.join(", ") }; + return { pass: true, detail: "no transform residue" }; +} + +export function verifyNoOpacityResidue(root: HTMLElement): { + pass: boolean; + detail: string; +} { + const items = root.querySelectorAll( + "[torph-item]:not([torph-exiting]):not(br)", + ); + const stuck: string[] = []; + items.forEach((item) => { + const o = Number(getComputedStyle(item).opacity); + if (o < 0.99) { + stuck.push( + `"${item.textContent?.trim() || "?"}" opacity=${o.toFixed(2)}`, + ); + } + }); + if (stuck.length > 0) return { pass: false, detail: stuck.join(", ") }; + return { pass: true, detail: "no opacity residue" }; +} + +export function verifyStyleCleanup(root: HTMLElement): { + pass: boolean; + detail: string; +} { + const items = root.querySelectorAll( + "[torph-item]:not([torph-exiting]):not(br)", + ); + const issues: string[] = []; + items.forEach((item) => { + if (item.style.position === "absolute") { + issues.push(`"${item.textContent?.trim() || "?"}" has position:absolute`); + } + if (item.style.width && item.style.width !== "auto") { + issues.push( + `"${item.textContent?.trim() || "?"}" has width:${item.style.width}`, + ); + } + if (item.style.height && item.style.height !== "auto") { + issues.push( + `"${item.textContent?.trim() || "?"}" has height:${item.style.height}`, + ); + } + }); + if (issues.length > 0) return { pass: false, detail: issues.join(", ") }; + return { pass: true, detail: "no stale inline styles" }; +} + +export function verifyNoDuplicateIds(root: HTMLElement): { + pass: boolean; + detail: string; +} { + const items = root.querySelectorAll( + "[torph-item]:not([torph-exiting])", + ); + const seen = new Map(); + items.forEach((item) => { + const id = item.getAttribute("torph-id"); + if (id) seen.set(id, (seen.get(id) || 0) + 1); + }); + const dupes = [...seen.entries()].filter(([, count]) => count > 1); + if (dupes.length > 0) { + return { + pass: false, + detail: dupes.map(([id, n]) => `"${id}" ×${n}`).join(", "), + }; + } + return { pass: true, detail: "no duplicate IDs" }; +} + +export function verifyContainerSizeMatch(root: HTMLElement): { + pass: boolean; + detail: string; +} { + const tolerance = 2; + const hasStaleWidth = + root.style.width && + root.style.width !== "auto" && + Math.abs(parseFloat(root.style.width) - root.scrollWidth) > tolerance; + const hasStaleHeight = + root.style.height && + root.style.height !== "auto" && + Math.abs(parseFloat(root.style.height) - root.scrollHeight) > tolerance; + if (hasStaleWidth || hasStaleHeight) { + return { + pass: false, + detail: `stale size: style=${root.style.width}×${root.style.height} actual=${root.scrollWidth}×${root.scrollHeight}`, + }; + } + return { pass: true, detail: "container size matches content" }; +} + +export function verifyBrMatchesContent(root: HTMLElement): { + pass: boolean; + detail: string; +} { + const lines = getVisualLines(root); + const brs = root.querySelectorAll("br[torph-item]"); + const minExpectedBrs = Math.max(0, lines.length - 1); + if (lines.length <= 1 && brs.length === 0) { + return { pass: true, detail: "single line, no
needed" }; + } + if (brs.length < minExpectedBrs) { + return { + pass: false, + detail: `${lines.length} visual lines but only ${brs.length}
(expected >=${minExpectedBrs})`, + }; + } + return { pass: true, detail: `${brs.length}
for ${lines.length} lines` }; +} + +export function verifyDomStandard(root: HTMLElement): { + pass: boolean; + detail: string; +} { + const checks: [string, { pass: boolean; detail: string }][] = [ + ["bounds", verifyItemsInBounds(root)], + ["overflow", verifyNoOverflow(root)], + ["exits", verifyExitCleanup(root)], + ["transform", verifyNoTransformResidue(root)], + ["opacity", verifyNoOpacityResidue(root)], + ["styles", verifyStyleCleanup(root)], + ["ids", verifyNoDuplicateIds(root)], + ["size", verifyContainerSizeMatch(root)], + ]; + + const align = getComputedStyle(root).textAlign as + | "left" + | "center" + | "right" + | "start"; + const normalizedAlign = align === "start" ? "left" : align; + if ( + normalizedAlign === "left" || + normalizedAlign === "center" || + normalizedAlign === "right" + ) { + checks.push(["align", verifyAlignment(root, normalizedAlign)]); + } + + const lines = getVisualLines(root); + if (lines.length > 1) { + checks.push(["br", verifyBrMatchesContent(root)]); + } + + const failures = checks.filter(([, r]) => !r.pass); + if (failures.length > 0) { + return { + pass: false, + detail: failures.map(([name, r]) => `${name}: ${r.detail}`).join("; "), + }; + } + return { pass: true, detail: `${checks.length} DOM checks passed` }; +} + +// ── Jump detection ── + +export function takeJumpSnapshot(root: HTMLElement): JumpSnapshot { + const items = new Map(); + root.querySelectorAll("[torph-item]:not(br):not([torph-exiting])").forEach((item) => { + const id = item.getAttribute("torph-id"); + if (id) items.set(id, item.getBoundingClientRect()); + }); + return { + items, + rootRect: root.getBoundingClientRect(), + rootWidth: root.offsetWidth, + align: getComputedStyle(root).textAlign, + }; +} + +export function verifyNoJump( + root: HTMLElement, + before: JumpSnapshot, + tolerance = 2, +): { pass: boolean; detail: string } { + const after = takeJumpSnapshot(root); + const jumps: string[] = []; + const context: string[] = []; + + context.push(`align=${before.align}→${after.align}`); + context.push(`rootW: ${before.rootWidth}→${after.rootWidth}`); + context.push(`scrollW: ${root.scrollWidth}`); + + const rootDx = after.rootRect.left - before.rootRect.left; + const rootDy = after.rootRect.top - before.rootRect.top; + if (Math.abs(rootDx) > 0.5) { + context.push(`rootX: ${rootDx > 0 ? "+" : ""}${rootDx.toFixed(1)}`); + } + if (Math.abs(rootDy) > 0.5) { + context.push(`rootY: ${rootDy > 0 ? "+" : ""}${rootDy.toFixed(1)}`); + } + + after.items.forEach((cur, id) => { + const old = before.items.get(id); + if (!old) return; + // Subtract root movement so layout shifts from other cards + // during morph-all don't count as item jumps + const dx = cur.left - old.left - rootDx; + const dy = cur.top - old.top - rootDy; + const el = root.querySelector(`[torph-id="${id}"]`); + if (!el) return; + const text = el.textContent?.trim() || id; + const isExiting = el.hasAttribute("torph-exiting"); + const transform = getComputedStyle(el).transform; + const anims = el.getAnimations().length; + + if (Math.abs(dx) > tolerance || Math.abs(dy) > tolerance) { + jumps.push( + `"${text}"${isExiting ? "(exit)" : ""} ${dx > 0 ? "+" : ""}${dx.toFixed(1)},${dy > 0 ? "+" : ""}${dy.toFixed(1)} tf=${transform} anims=${anims}`, + ); + } + }); + + before.items.forEach((_, id) => { + if (!after.items.has(id)) { + context.push(`"${id}" vanished`); + } + }); + + const header = `[${context.join(" | ")}]`; + if (jumps.length > 0) { + return { pass: false, detail: `${header} ${jumps.join("; ")}` }; + } + return { pass: true, detail: `${header} no frame-0 jump` }; +} + +// ── Frame performance monitor ── + +export class FrameMonitor { + private frames: number[] = []; + private rafId: number | null = null; + private lastTime = 0; + private running = false; + private startTime = 0; + private firstFrameTime = 0; + + start() { + this.stop(); + this.frames = []; + this.lastTime = 0; + this.running = true; + this.startTime = performance.now(); + this.firstFrameTime = 0; + this.rafId = requestAnimationFrame(this.tick); + } + + stop(): PerfResult { + this.running = false; + if (this.rafId !== null) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + + const frameTimes = this.frames; + const morphTime = + this.firstFrameTime > 0 ? this.firstFrameTime - this.startTime : 0; + + if (frameTimes.length === 0) { + return { + pass: true, + detail: `no frames | morph=${morphTime.toFixed(1)}ms`, + totalFrames: 0, + droppedFrames: 0, + longestFrame: 0, + avgFrame: 0, + morphTime, + }; + } + + const longestFrame = Math.max(...frameTimes); + const avgFrame = frameTimes.reduce((a, b) => a + b, 0) / frameTimes.length; + const sorted = [...frameTimes].sort((a, b) => a - b); + const median = sorted[Math.floor(sorted.length / 2)] || 16.67; + const dropThreshold = Math.max(median * 2, 34); + const droppedFrames = frameTimes.filter((t) => t > dropThreshold).length; + const totalFrames = frameTimes.length; + + const issues: string[] = []; + if (droppedFrames > 0) { + issues.push(`${droppedFrames} dropped`); + } + if (longestFrame > 50) { + issues.push(`worst: ${longestFrame.toFixed(1)}ms`); + } + if (morphTime > 16) { + issues.push(`morph: ${morphTime.toFixed(1)}ms`); + } + + const hz = Math.round(1000 / median); + const pass = droppedFrames === 0 && longestFrame <= dropThreshold; + const detail = pass + ? `${totalFrames}f@${hz}Hz avg=${avgFrame.toFixed(1)}ms worst=${longestFrame.toFixed(1)}ms morph=${morphTime.toFixed(1)}ms` + : `${totalFrames}f@${hz}Hz ${issues.join(" | ")} avg=${avgFrame.toFixed(1)}ms morph=${morphTime.toFixed(1)}ms`; + + return { + pass, + detail, + totalFrames, + droppedFrames, + longestFrame, + avgFrame, + morphTime, + }; + } + + private tick = () => { + if (!this.running) return; + const now = performance.now(); + if (this.lastTime > 0) { + this.frames.push(now - this.lastTime); + } else { + this.firstFrameTime = now; + } + this.lastTime = now; + this.rafId = requestAnimationFrame(this.tick); + }; +} + +export function measurePerf( + fn: () => { pass: boolean; detail: string }, + iterations = 100, +) { + const start = performance.now(); + let result: { pass: boolean; detail: string } = { pass: true, detail: "" }; + for (let i = 0; i < iterations; i++) { + result = fn(); + } + const elapsed = performance.now() - start; + return { ...result, timeMs: elapsed / iterations }; +} From 09e2d7fe7f14f2be90435636ffa8524539dddb38 Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Sun, 8 Mar 2026 17:14:01 +1100 Subject: [PATCH 12/16] tests --- site/src/surfaces/playground-tests/index.tsx | 263 ++++++++++++------ .../surfaces/playground-tests/test-card.tsx | 63 ++--- 2 files changed, 211 insertions(+), 115 deletions(-) diff --git a/site/src/surfaces/playground-tests/index.tsx b/site/src/surfaces/playground-tests/index.tsx index 202db46..c2150bc 100644 --- a/site/src/surfaces/playground-tests/index.tsx +++ b/site/src/surfaces/playground-tests/index.tsx @@ -34,88 +34,175 @@ export const PlaygroundTests = () => { ); const cardRefs = React.useRef<(HTMLDivElement | null)[]>([]); - // Run-all sequencer state - type RunItem = { testIndex: number; align: Align }; + // Run-all state: morph cards sequentially, cycling through alignments const [running, setRunning] = React.useState(false); - const [runQueue, setRunQueue] = React.useState([]); - const [runCurrent, setRunCurrent] = React.useState(null); - const [runCompleted, setRunCompleted] = React.useState(0); - const [runTotal, setRunTotal] = React.useState(0); + const [runPhase, setRunPhase] = React.useState(null); const [hasRunResults, setHasRunResults] = React.useState(false); const [showResults, setShowResults] = React.useState(false); - const [autoRunSignal, setAutoRunSignal] = React.useState(0); + const [runProgress, setRunProgress] = React.useState<{ + done: number; + total: number; + } | null>(null); + const [cardMorphSignals, setCardMorphSignals] = React.useState(() => + TESTS.map(() => 0), + ); const resultsPanelRef = React.useRef(null); + const runTimers = React.useRef[]>([]); + const runQueue = React.useRef<{ cardIndex: number; align: Align }[]>([]); + const runQueuePos = React.useRef(0); + const runActive = React.useRef(false); + const animDurationRef = React.useRef(SPEEDS["default"]); + const cardTimeout = React.useRef | null>(null); + const runCardToken = React.useRef(null); + const cardOnCompletesRef = React.useRef<((() => void) | null)[]>( + TESTS.map(() => null), + ); + + const clearRunTimers = () => { + runTimers.current.forEach(clearTimeout); + runTimers.current = []; + }; + + const advanceQueue = React.useCallback(() => { + if (!runActive.current) return; + + const pos = runQueuePos.current; + const queue = runQueue.current; + + if (pos >= queue.length) { + runActive.current = false; + setRunning(false); + setRunPhase(null); + setRunProgress(null); + setHasRunResults(true); + setShowResults(true); + return; + } + + const { cardIndex, align: nextAlign } = queue[pos]!; + runQueuePos.current = pos + 1; + setRunProgress({ done: pos, total: queue.length }); + setAlign(nextAlign); + setRunPhase(nextAlign); - const startRunAll = React.useCallback(() => { - // Build queue: for each test, run all alignments before moving on - const items: RunItem[] = []; - for (let i = 0; i < TESTS.length; i++) { - for (const a of ALIGNS) { - items.push({ testIndex: i, align: a }); + // Token prevents double-advance if both onComplete and timeout fire + const token = Symbol(); + runCardToken.current = token; + const guard = () => { + if (runCardToken.current !== token) return; + runCardToken.current = null; + if (cardTimeout.current) { + clearTimeout(cardTimeout.current); + cardTimeout.current = null; } + advanceQueue(); + }; + + cardOnCompletesRef.current[cardIndex] = guard; + + setCardMorphSignals((prev) => { + const next = [...prev]; + next[cardIndex] = (next[cardIndex] ?? 0) + 1; + return next; + }); + + // Fallback if animation never fires (e.g. text unchanged) + cardTimeout.current = setTimeout(guard, animDurationRef.current + 200); + }, []); + + const startBatchRun = React.useCallback((animDuration: number) => { + clearRunTimers(); + runActive.current = false; + if (cardTimeout.current) { + clearTimeout(cardTimeout.current); + cardTimeout.current = null; } - setRunQueue(items); - setRunCurrent(items[0]!); - setRunCompleted(0); - setRunTotal(items.length); - setRunning(true); - setHasRunResults(false); - setShowResults(false); - setAutoRunSignal((s) => s + 1); - // Reset DOM/jump/perf results + setDomResults(TESTS.map(() => null)); setJumpResults(TESTS.map(() => null)); setPerfResults(TESTS.map(() => null)); + + const delay = animDuration + 200; + setAlign("left"); + setRunPhase("left"); + setRunning(true); + setHasRunResults(false); + setShowResults(false); + setRunProgress({ done: 0, total: 3 }); + setMorphAllSignal((s) => s + 1); + + runTimers.current.push( + setTimeout(() => { + setAlign("center"); + setRunPhase("center"); + setRunProgress({ done: 1, total: 3 }); + setMorphAllSignal((s) => s + 1); + }, delay), + ); + runTimers.current.push( + setTimeout(() => { + setAlign("right"); + setRunPhase("right"); + setRunProgress({ done: 2, total: 3 }); + setMorphAllSignal((s) => s + 1); + }, delay * 2), + ); + runTimers.current.push( + setTimeout(() => { + setRunning(false); + setRunPhase(null); + setRunProgress(null); + setHasRunResults(true); + setShowResults(true); + }, delay * 3), + ); }, []); - const runAdvanceTimer = React.useRef | null>(null); - const handleCardRunComplete = React.useCallback( - (testIndex: number, runAlign: Align) => { - setRunCompleted((c) => c + 1); - setRunQueue((queue) => { - const remaining = queue.filter( - (item) => !(item.testIndex === testIndex && item.align === runAlign), - ); - if (remaining.length > 0) { - const next = remaining[0]!; - // Delay between alignment passes on same card so the shift is visible - const delay = next.testIndex === testIndex ? 400 : 100; - setRunCurrent(next); - if (runAdvanceTimer.current) clearTimeout(runAdvanceTimer.current); - runAdvanceTimer.current = setTimeout(() => { - setAutoRunSignal((s) => s + 1); - }, delay); - } else { - setRunCurrent(null); - setRunning(false); - setHasRunResults(true); - setShowResults(true); + const startRunAll = React.useCallback( + (animDuration: number) => { + clearRunTimers(); + if (cardTimeout.current) { + clearTimeout(cardTimeout.current); + cardTimeout.current = null; + } + runActive.current = false; + + animDurationRef.current = animDuration; + + setDomResults(TESTS.map(() => null)); + setJumpResults(TESTS.map(() => null)); + setPerfResults(TESTS.map(() => null)); + + const queue: { cardIndex: number; align: Align }[] = []; + for (const phase of ["left", "center", "right"] as Align[]) { + for (let i = 0; i < TESTS.length; i++) { + queue.push({ cardIndex: i, align: phase }); } - return remaining; - }); + } + runQueue.current = queue; + runQueuePos.current = 0; + runActive.current = true; + + setRunning(true); + setHasRunResults(false); + setShowResults(false); + setRunProgress({ done: 0, total: queue.length }); + + advanceQueue(); }, - [], + [advanceQueue], ); - // Auto-scroll to the active card during a run - React.useEffect(() => { - if (running && runCurrent) { - cardRefs.current[runCurrent.testIndex]?.scrollIntoView({ - behavior: "smooth", - block: "center", - }); - } - }, [running, runCurrent]); - // Scroll to results panel when run finishes React.useEffect(() => { if (hasRunResults && showResults && !running) { - setTimeout(() => { + const id = setTimeout(() => { resultsPanelRef.current?.scrollIntoView({ behavior: "smooth", block: "start", }); }, 100); + return () => clearTimeout(id); } }, [hasRunResults, showResults, running]); @@ -172,10 +259,12 @@ export const PlaygroundTests = () => { return () => window.removeEventListener("keydown", handleKeyDown); }, []); + const copiedTimer = React.useRef | null>(null); const handleCopy = () => { copyResultsToClipboard(results, domResults, jumpResults, perfResults); setCopied("all"); - setTimeout(() => setCopied(false), 2000); + if (copiedTimer.current) clearTimeout(copiedTimer.current); + copiedTimer.current = setTimeout(() => setCopied(false), 2000); }; const handleCopyFails = () => { @@ -186,7 +275,8 @@ export const PlaygroundTests = () => { perfResults, ); setCopied(hadFails ? "fails" : "none"); - setTimeout(() => setCopied(false), 2000); + if (copiedTimer.current) clearTimeout(copiedTimer.current); + copiedTimer.current = setTimeout(() => setCopied(false), 2000); }; return ( @@ -196,23 +286,21 @@ export const PlaygroundTests = () => {
{running ? ( - Running {runCompleted}/{runTotal} - {runCurrent ? ` (${runCurrent.align})` : ""}… + Running…{" "} + {runProgress + ? `${runProgress.done}/${runProgress.total}` + : runPhase + ? `(${runPhase})` + : ""} ) : ( {passed}/{total} passed {failed > 0 && ( - - {" "} - · {failed} failed - + · {failed} failed )} {domFailed > 0 && ( - - {" "} - · {domFailed} DOM - + · {domFailed} DOM )} {jumpFailed > 0 && ( @@ -264,7 +352,9 @@ export const PlaygroundTests = () => {
)} @@ -413,10 +503,8 @@ export const PlaygroundTests = () => { perfResult={perfResults[i] ?? null} speed={speed} easing={easing} - align={running && runCurrent?.testIndex === i ? runCurrent.align : align} + align={align} debug={debug} - autoRunSignal={running && runCurrent?.testIndex === i ? autoRunSignal : 0} - onRunComplete={() => handleCardRunComplete(i, runCurrent?.align ?? "left")} onDomResult={(r) => setDomResults((prev) => { const next = [...prev]; @@ -442,6 +530,8 @@ export const PlaygroundTests = () => { return next; }) } + cardMorphSignal={cardMorphSignals[i]} + onComplete={() => cardOnCompletesRef.current[i]?.()} cardRef={(el) => { cardRefs.current[i] = el; }} @@ -495,21 +585,36 @@ export const PlaygroundTests = () => { > debug + {!running && ( + + )} {hasRunResults && !showResults && (