From 3f6cc026dee74ec50e3cd91a84a22a0b9104d68d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 02:37:38 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=E2=80=94=2092?= =?UTF-8?q?=EB=B0=94=ED=80=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017uwozb36EhjHzHsJQiTQPU From 225e0d1171319e360b64de9c118d84788d0ef30f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 02:41:17 +0000 Subject: [PATCH 2/2] web: fold the Pattern Lab's twice-written helpers into one each The FNV-1a hash, the file download, commentSafe, roundRangeValue and sortedLane each had two or three bodies; a new test pins the paste and shader keys the hash files under. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017uwozb36EhjHzHsJQiTQPU --- web/src/app/pattern-lab/HardwareModal.tsx | 17 +++----- web/src/app/pattern-lab/panels/KnobsPanel.tsx | 6 +-- .../pattern-lab/panels/director/showFiles.ts | 10 +---- .../panels/director/useShowEditing.ts | 6 +-- web/src/lib/lab/capture/probe.ts | 12 +----- web/src/lib/lab/capture/shaderStore.ts | 12 +----- web/src/lib/lab/cppPrompt.ts | 6 +-- web/src/lib/lab/director/bake.ts | 2 +- web/src/lib/lab/flatten.ts | 2 +- web/src/lib/lab/hardwarePastes.ts | 9 ++-- web/src/lib/lab/hash.test.ts | 41 +++++++++++++++++++ web/src/lib/lab/hash.ts | 14 +++++++ web/src/lib/lab/pixelToCode.ts | 7 +--- web/src/lib/pattern/controls.ts | 8 ++++ 14 files changed, 83 insertions(+), 69 deletions(-) create mode 100644 web/src/lib/lab/hash.test.ts create mode 100644 web/src/lib/lab/hash.ts diff --git a/web/src/app/pattern-lab/HardwareModal.tsx b/web/src/app/pattern-lab/HardwareModal.tsx index e9ff44ff1..632456ba5 100644 --- a/web/src/app/pattern-lab/HardwareModal.tsx +++ b/web/src/app/pattern-lab/HardwareModal.tsx @@ -12,6 +12,7 @@ import { cleanPastedUnit, enforcePatternName, } from "@/lib/lab/hExport"; +import { downloadBlob } from "@/lib/lab/capture/download"; import { buildCppPrompt } from "@/lib/lab/cppPrompt"; import { promptKey, readPastes, writePastes } from "@/lib/lab/hardwarePastes"; import { currentPerformanceJson } from "@/lib/lab/director/publish"; @@ -197,17 +198,11 @@ export default function HardwareModal({ const copyLabel = (key: string, idle: string) => copied?.key === key ? (copied.ok ? "Copied ✓" : "Copy failed") : idle; - const downloadH = () => { - const blob = new Blob([assembled], { type: "text/plain" }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = `${ - exportData.namespaceName.replace(/^LabStack_/, "").toLowerCase() || "pattern" - }.h`; - anchor.click(); - URL.revokeObjectURL(url); - }; + const downloadH = () => + downloadBlob( + new Blob([assembled], { type: "text/plain" }), + `${exportData.namespaceName.replace(/^LabStack_/, "").toLowerCase() || "pattern"}.h`, + ); const label = lone?.name ?? name; diff --git a/web/src/app/pattern-lab/panels/KnobsPanel.tsx b/web/src/app/pattern-lab/panels/KnobsPanel.tsx index d5e3a11b0..f19ea70e6 100644 --- a/web/src/app/pattern-lab/panels/KnobsPanel.tsx +++ b/web/src/app/pattern-lab/panels/KnobsPanel.tsx @@ -10,7 +10,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { labEngine } from "@/lib/lab/engine"; import { captureSession } from "@/lib/lab/capture/session"; import { useLabStore } from "@/lib/lab/store"; -import { knobArrowValue } from "@/lib/pattern/controls"; +import { knobArrowValue, roundRangeValue } from "@/lib/pattern/controls"; import local from "./KnobsPanel.module.css"; import dock from "../LabPanels.module.css"; @@ -39,10 +39,6 @@ function formatRangeControlValue(value: number) { return value.toFixed(3); } -function roundRangeValue(value: number) { - return Math.round(value * 1000) / 1000; -} - function getDigitStep(text: string, index: number) { const char = text[index]; if (!char || char === "-" || char === ".") return null; diff --git a/web/src/app/pattern-lab/panels/director/showFiles.ts b/web/src/app/pattern-lab/panels/director/showFiles.ts index dbc49c3fc..3e853f345 100644 --- a/web/src/app/pattern-lab/panels/director/showFiles.ts +++ b/web/src/app/pattern-lab/panels/director/showFiles.ts @@ -4,20 +4,12 @@ // blank title falls back to it, so the file downloads as .pfs. import { encodePfst, pfsFilename, serializePerformance, validatePerformance } from "@/lib/pattern/pfst"; +import { downloadBlob } from "@/lib/lab/capture/download"; import { bakeShowV2, showFromPerformance } from "@/lib/lab/director/bake"; import { midiFilename, showToMidi } from "@/lib/lab/director/exporters/midi"; import type { DirectorShow } from "@/lib/lab/director/types"; import { readPerformanceFile } from "../../community"; -function downloadBlob(blob: Blob, filename: string) { - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = filename; - anchor.click(); - URL.revokeObjectURL(url); -} - const named = (director: DirectorShow, patternName: string) => director.title.trim() ? director : { ...director, title: patternName }; diff --git a/web/src/app/pattern-lab/panels/director/useShowEditing.ts b/web/src/app/pattern-lab/panels/director/useShowEditing.ts index 4e4822609..d05b94448 100644 --- a/web/src/app/pattern-lab/panels/director/useShowEditing.ts +++ b/web/src/app/pattern-lab/panels/director/useShowEditing.ts @@ -4,7 +4,7 @@ // panel's two keys (Delete, Space). import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; -import { resolveLane, resolvedLaneValue } from "@/lib/lab/director/bake"; +import { resolveLane, resolvedLaneValue, sortedLane } from "@/lib/lab/director/bake"; import { DEFAULT_CURVE_CP, DIRECTOR_MAX_SECONDS, @@ -29,10 +29,6 @@ export function interpOf(k: DirectorKeyframe): Interp { return k.h === "auto" ? "auto" : "manual"; } -function sortedLane(lane: DirectorKeyframe[]): DirectorKeyframe[] { - return [...lane].sort((a, b) => a.t - b.t); -} - export function useShowEditing({ director, updateDirector, diff --git a/web/src/lib/lab/capture/probe.ts b/web/src/lib/lab/capture/probe.ts index 9ebaebc67..d54c9a67e 100644 --- a/web/src/lib/lab/capture/probe.ts +++ b/web/src/lib/lab/capture/probe.ts @@ -27,6 +27,7 @@ import type { MatrixSize } from "@/lib/pattern/matrix"; import { LabEngine } from "../engine"; +import { fnv1a } from "../hash"; import type { CaptureProject } from "./types"; import { stretchNearest } from "./core"; import { isRasterLayer, type Layer } from "../types"; @@ -348,22 +349,13 @@ export function probeKey(project: CaptureProject): string { if (!layer.visible) continue; parts.push( layer.type === "code" - ? `c:${layer.id}:${layer.role}:${layer.maskInvert}:${layer.opacity > 0}:${layer.recolor}:${layer.code.length}:${hash(layer.code)}` + ? `c:${layer.id}:${layer.role}:${layer.maskInvert}:${layer.opacity > 0}:${layer.recolor}:${layer.code.length}:${fnv1a(layer.code)}` : `p:${layer.id}:${layer.role}:${layer.maskInvert}:${layer.opacity > 0}:${layer.rev}`, ); } return parts.join("|"); } -function hash(text: string): number { - let h = 2166136261; - for (let i = 0; i < text.length; i++) { - h ^= text.charCodeAt(i); - h = Math.imul(h, 16777619); - } - return h >>> 0; -} - export function describeProbe(result: ProbeResult): string { switch (result.reason) { case "scales": diff --git a/web/src/lib/lab/capture/shaderStore.ts b/web/src/lib/lab/capture/shaderStore.ts index 23fea6de2..ac753f692 100644 --- a/web/src/lib/lab/capture/shaderStore.ts +++ b/web/src/lib/lab/capture/shaderStore.ts @@ -17,6 +17,7 @@ // Bounded on purpose: the newest few, a couple of hundred KB. A shader lost to // pruning is one re-paste; a full quota breaks saving settings too. +import { fnv1a } from "../hash"; import { LAB_STORAGE, readJson, writeJson } from "../persist"; const STORAGE_KEY = LAB_STORAGE.shader; @@ -26,17 +27,8 @@ const MAX_TOTAL_CHARS = 200_000; type Entry = { source: string; at: number }; type Store = Record; -function hash(text: string): number { - let h = 2166136261; - for (let i = 0; i < text.length; i++) { - h ^= text.charCodeAt(i); - h = Math.imul(h, 16777619); - } - return h >>> 0; -} - function keysFor(layerId: string, code: string): string[] { - return [`id:${layerId}`, `code:${hash(code)}`]; + return [`id:${layerId}`, `code:${fnv1a(code)}`]; } function read(): Store { diff --git a/web/src/lib/lab/cppPrompt.ts b/web/src/lib/lab/cppPrompt.ts index ad4eac962..95cbebc73 100644 --- a/web/src/lib/lab/cppPrompt.ts +++ b/web/src/lib/lab/cppPrompt.ts @@ -8,16 +8,12 @@ import { PATTERN_MATRIX_WIDTH, buildRampLUTRGBA, } from "@/lib/pattern/harness"; -import { knobDetentStep } from "@/lib/pattern/controls"; +import { knobDetentStep, roundRangeValue } from "@/lib/pattern/controls"; import { withMatrixAnnotation, type MatrixSize } from "@/lib/pattern/matrix"; import { codeUsesValueField } from "@/lib/pattern/ramp"; import type { KnobRange, RampState } from "./types"; import { rampStateToHarness } from "./engine"; -function roundRangeValue(value: number) { - return Math.round(value * 1000) / 1000; -} - type CppPromptArgs = { code: string; matrix: MatrixSize; diff --git a/web/src/lib/lab/director/bake.ts b/web/src/lib/lab/director/bake.ts index 03920bb51..b8db6ae3b 100644 --- a/web/src/lib/lab/director/bake.ts +++ b/web/src/lib/lab/director/bake.ts @@ -69,7 +69,7 @@ export function cubicBezierY( return sampleY(t); } -function sortedLane(lane: DirectorKeyframe[]): DirectorKeyframe[] { +export function sortedLane(lane: DirectorKeyframe[]): DirectorKeyframe[] { return [...lane].sort((a, b) => a.t - b.t); } diff --git a/web/src/lib/lab/flatten.ts b/web/src/lib/lab/flatten.ts index 4c8b2632c..3a8634e50 100644 --- a/web/src/lib/lab/flatten.ts +++ b/web/src/lib/lab/flatten.ts @@ -89,7 +89,7 @@ function stripForEmbedding(code: string): string { } /** A layer name inside a `//` comment: every line terminator JS knows, not just \n. */ -function commentSafe(name: string): string { +export function commentSafe(name: string): string { return name.replace(/[\r\n\u2028\u2029]/g, " "); } diff --git a/web/src/lib/lab/hardwarePastes.ts b/web/src/lib/lab/hardwarePastes.ts index f6c0b388b..7d41d452d 100644 --- a/web/src/lib/lab/hardwarePastes.ts +++ b/web/src/lib/lab/hardwarePastes.ts @@ -16,6 +16,8 @@ // record would wipe each other's pastes. A tab's record ends with the tab, // and nothing has to prune it. +import { fnv1a } from "./hash"; + const KEY = "patternflow_lab_hardware_pastes_v1"; /** @@ -24,12 +26,7 @@ const KEY = "patternflow_lab_hardware_pastes_v1"; */ export function promptKey(prompt: string): string { const asked = prompt.replace(/, current [^,\n]*/g, ""); - let h = 2166136261; - for (let i = 0; i < asked.length; i++) { - h ^= asked.charCodeAt(i); - h = Math.imul(h, 16777619); - } - return `${asked.length}:${(h >>> 0).toString(36)}`; + return `${asked.length}:${fnv1a(asked).toString(36)}`; } /** Every paste this tab kept, by the key of the prompt it answers. */ diff --git a/web/src/lib/lab/hash.test.ts b/web/src/lib/lab/hash.test.ts new file mode 100644 index 000000000..08aee1aaa --- /dev/null +++ b/web/src/lib/lab/hash.test.ts @@ -0,0 +1,41 @@ +// Until 25 October 2026 the Lab wrote its FNV-1a hash out three times +// (capture/probe.ts, capture/shaderStore.ts, hardwarePastes.ts). Two of the +// copies name what a browser keeps: the paste a tab files under its prompt, +// and the shader filed under its pattern's source. Moving the hash to one +// place must not move either key, or the next reload finds nothing there. + +import { beforeEach, describe, expect, it } from "vitest"; +import { loadShaderSource, saveShaderSource } from "./capture/shaderStore"; +import { promptKey } from "./hardwarePastes"; +import { fnv1a } from "./hash"; +import { LAB_STORAGE } from "./persist"; + +const PROMPT = [ + "- Knob 1: min 0, max 1, current 0.5, calibrated encoder step 0.01 per detent", + "- Knob 2: min 0, max 4, current 2, calibrated encoder step 0.083 per detent", +].join("\n"); + +describe("keys the Lab files under a hash", () => { + beforeEach(() => window.localStorage.clear()); + + it("is 32-bit FNV-1a", () => { + expect(fnv1a("")).toBe(0x811c9dc5); + expect(fnv1a("a")).toBe(0xe40c292c); + expect(fnv1a("foobar")).toBe(0xbf9cf968); + }); + + it("files a To hardware paste under the key it had", () => { + expect(promptKey(PROMPT)).toBe("128:1q359yv"); + // Turning a knob is no reason to ask again, so the position stays out. + expect(promptKey(PROMPT.replace("current 0.5", "current 0.75"))).toBe("128:1q359yv"); + }); + + it("files a shader under the source hash it had", () => { + const code = "function render(ctx) { return 0; }"; + saveShaderSource("layer-a", code, "void main() {}"); + const saved = JSON.parse(window.localStorage.getItem(LAB_STORAGE.shader) ?? "{}"); + expect(Object.keys(saved).sort()).toEqual(["code:968204348", "id:layer-a"]); + // A reload mints new layer ids, and the hash finds the shader again. + expect(loadShaderSource("layer-b", code)).toBe("void main() {}"); + }); +}); diff --git a/web/src/lib/lab/hash.ts b/web/src/lib/lab/hash.ts new file mode 100644 index 000000000..ef9605ae2 --- /dev/null +++ b/web/src/lib/lab/hash.ts @@ -0,0 +1,14 @@ +// ── Short hash of a string ─────────────────────────────────────────────────── +// 32-bit FNV-1a over UTF-16 code units. Two of its callers file things in the +// browser under it: a To hardware paste (hardwarePastes.ts) and a shader twin +// (capture/shaderStore.ts). A change here orphans both, so hash.test.ts pins +// their keys. + +export function fnv1a(text: string): number { + let h = 2166136261; + for (let i = 0; i < text.length; i++) { + h ^= text.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} diff --git a/web/src/lib/lab/pixelToCode.ts b/web/src/lib/lab/pixelToCode.ts index 0e5476619..80334fb3e 100644 --- a/web/src/lib/lab/pixelToCode.ts +++ b/web/src/lib/lab/pixelToCode.ts @@ -25,7 +25,7 @@ // canvas with a small drawing in it shrinks from ~44 KB to a few KB. import { withMatrixAnnotation } from "@/lib/pattern/matrix"; -import { rleEncodeRGBA } from "./flatten"; +import { commentSafe, rleEncodeRGBA } from "./flatten"; import { bytesToBase64 } from "./serialize"; import { activeTextEffect } from "./textEffects"; import { fontLabel } from "./textFonts"; @@ -122,11 +122,6 @@ function asciiPreview(sprite: SpriteCrop): string[] { return rows; } -/** A layer name inside a `//` comment: no line terminators. */ -function commentSafe(name: string): string { - return name.replace(/[\r\n\u2028\u2029]/g, " "); -} - const B64_DECODER = `function decodeB64(s, n) { const A = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; const out = new Uint8ClampedArray(n); diff --git a/web/src/lib/pattern/controls.ts b/web/src/lib/pattern/controls.ts index d207b990a..da790caf3 100644 --- a/web/src/lib/pattern/controls.ts +++ b/web/src/lib/pattern/controls.ts @@ -56,6 +56,14 @@ export function tidyKnobValue(value: number) { return Number(value.toPrecision(12)); } +/** + * Three decimals, the grain of the Lab's knob range fields: an edge typed or + * dragged in the Knobs panel, and the detent step the C++ prompt quotes. + */ +export function roundRangeValue(value: number) { + return Math.round(value * 1000) / 1000; +} + /** The middle of a knob's range, where a card with no saved knob state starts. */ export function knobMidpoint(range: readonly [number, number]) { return tidyKnobValue((range[0] + range[1]) / 2);