From 77e0388fece41cd801531e4552ea69cb7603ebe5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 05:20:01 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=E2=80=94=2097?= =?UTF-8?q?=EB=B0=94=ED=80=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 88f9663276d5418f5202a632d740d192304f1a8b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 05:24:10 +0000 Subject: [PATCH 2/2] web: turn the guide's practice-wall knobs and read every 0..1 through controls.ts The practice wall copied the community card's old step, floored at 0.001 and rounded to three decimals, so a 0.0001..0.0004 knob fell to 0 on one notch. Also drops the fork PR number from two Lab test comments and counts the Text panel in the Lab shell's header. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AhWQdepAbAamwZVKFFLfz --- web/src/app/pattern-lab/PatternLabClient.tsx | 10 ++-- .../app/pattern-lab/panels/CodePanel.test.tsx | 4 +- .../pattern-lab/panels/PixelPanel.test.tsx | 4 +- web/src/components/3d/LedMatrixTexture.ts | 7 +-- web/src/components/community/PatternCard.tsx | 9 +-- .../guide/desk/community/parts.test.tsx | 57 +++++++++++++++++++ .../components/guide/desk/community/parts.tsx | 8 +-- web/src/lib/guide/deviceSim.ts | 7 +-- web/src/lib/pattern/controls.test.ts | 25 ++++++++ web/src/lib/pattern/controls.ts | 15 ++++- 10 files changed, 116 insertions(+), 30 deletions(-) create mode 100644 web/src/components/guide/desk/community/parts.test.tsx diff --git a/web/src/app/pattern-lab/PatternLabClient.tsx b/web/src/app/pattern-lab/PatternLabClient.tsx index 97aa3e318..8452df5a9 100644 --- a/web/src/app/pattern-lab/PatternLabClient.tsx +++ b/web/src/app/pattern-lab/PatternLabClient.tsx @@ -3,11 +3,11 @@ // Pattern Lab v2 — a layered, dockable pattern editing workspace. // // The old lab was one pattern + one fixed two-column layout. This shell hosts -// eight dockable panels (dockview) over a layer-stack project (zustand): -// Preview, Layers, Code, Pixel, Knobs, Color Ramp, Graphic Export, Director — -// the list is panels/registry.tsx. Panels rearrange Photoshop-style — drag -// tabs, split groups, float — and the arrangement persists to localStorage -// alongside the project itself. +// nine dockable panels (dockview) over a layer-stack project (zustand): +// Preview, Layers, Code, Pixel, Text, Knobs, Color Ramp, Graphic Export, +// Director — the list is panels/registry.tsx. Panels rearrange +// Photoshop-style — drag tabs, split groups, float — and the arrangement +// persists to localStorage alongside the project itself. // // Exports (community publish, firmware build) flatten the visible stack into // one standalone JS pattern (see lib/lab/flatten.ts), so every downstream diff --git a/web/src/app/pattern-lab/panels/CodePanel.test.tsx b/web/src/app/pattern-lab/panels/CodePanel.test.tsx index 2c8d8cac5..ee424abf1 100644 --- a/web/src/app/pattern-lab/panels/CodePanel.test.tsx +++ b/web/src/app/pattern-lab/panels/CodePanel.test.tsx @@ -210,8 +210,8 @@ describe("CodePanel paste", () => { }); }); -// The hint named every other layer pixel art, written before text layers -// came in (#184). A selected text layer was told to use the Pixel panel. +// The hint named every other layer pixel art, written before the Lab had +// text layers. A selected text layer was told to use the Pixel panel. describe("CodePanel on another layer", () => { it("names a text layer and sends it to the Text panel", () => { useLabStore.getState().addTextLayer(); diff --git a/web/src/app/pattern-lab/panels/PixelPanel.test.tsx b/web/src/app/pattern-lab/panels/PixelPanel.test.tsx index 9ca7ed329..d1002d7ff 100644 --- a/web/src/app/pattern-lab/panels/PixelPanel.test.tsx +++ b/web/src/app/pattern-lab/panels/PixelPanel.test.tsx @@ -59,8 +59,8 @@ describe("PixelPanel", () => { expect(useLabStore.getState().layers.filter((entry) => entry.type === "pixel")).toHaveLength(2); }); - // The hint called every other layer a code pattern, written before text - // layers came in (#184). + // The hint called every other layer a code pattern, written before the Lab + // had text layers. it("names the selected layer's kind and the panel that edits it", () => { const state = useLabStore.getState(); state.selectLayer(state.layers.find((entry) => entry.type === "code")!.id); diff --git a/web/src/components/3d/LedMatrixTexture.ts b/web/src/components/3d/LedMatrixTexture.ts index f8064dde8..394af079d 100644 --- a/web/src/components/3d/LedMatrixTexture.ts +++ b/web/src/components/3d/LedMatrixTexture.ts @@ -1,5 +1,5 @@ import * as THREE from 'three'; -import { getKnobValueDelta, LOGICAL_KNOB_RANGES, LOGICAL_KNOB_TO_WEB_KNOB, toEncoderDelta } from '@/lib/pattern/controls'; +import { getKnobValueDelta, LOGICAL_KNOB_RANGES, LOGICAL_KNOB_TO_WEB_KNOB, normalizeKnob, toEncoderDelta } from '@/lib/pattern/controls'; import { compilePatternCode, describePatternError, type PatternModule, type PatternParams } from '@/lib/pattern/harness'; type WebKnobValues = Record<'c1' | 'c2' | 'c3' | 'c4', number>; @@ -9,10 +9,7 @@ function getLogicalKnobValues(knobValues: WebKnobValues) { } function getLogicalKnobNormalized(logicalKnobValues: number[]) { - return logicalKnobValues.map((value, index) => { - const [min, max] = LOGICAL_KNOB_RANGES[index]; - return (value - min) / Math.max(0.0001, max - min); - }); + return logicalKnobValues.map((value, index) => normalizeKnob(value, LOGICAL_KNOB_RANGES[index])); } export class LedMatrixTexture { diff --git a/web/src/components/community/PatternCard.tsx b/web/src/components/community/PatternCard.tsx index 04c0abbc5..68e81d444 100644 --- a/web/src/components/community/PatternCard.tsx +++ b/web/src/components/community/PatternCard.tsx @@ -7,7 +7,7 @@ import { authClient } from "@/lib/community/auth-client"; import AuthModal from "@/components/community/AuthModal"; import { renderPatternThumb } from "@/lib/community/thumbs"; import { knobSetupFromCode } from "@/lib/community/knobs"; -import { KNOB_SPAN_FLOOR, normalizeKnob, tidyKnobValue } from "@/lib/pattern/controls"; +import { knobWheelValue, normalizeKnob } from "@/lib/pattern/controls"; import { describeMatrixShape, formatMatrix, @@ -375,15 +375,12 @@ export default function PatternCard({ if (e.ctrlKey) return; e.preventDefault(); - const [min, max] = knobSetup.ranges[activeKnobIdx] ?? [0, 1]; - const span = Math.max(KNOB_SPAN_FLOOR, max - min); - const step = span / 25; + const range = knobSetup.ranges[activeKnobIdx] ?? [0, 1]; const direction = e.deltaY < 0 ? 1 : -1; setKnobValues((prev) => { const next = [...prev]; - const current = next[activeKnobIdx] ?? min; - next[activeKnobIdx] = Math.max(min, Math.min(max, tidyKnobValue(current + direction * step))); + next[activeKnobIdx] = knobWheelValue(next[activeKnobIdx] ?? range[0], range, direction); return next; }); }; diff --git a/web/src/components/guide/desk/community/parts.test.tsx b/web/src/components/guide/desk/community/parts.test.tsx new file mode 100644 index 000000000..1ebbfd468 --- /dev/null +++ b/web/src/components/guide/desk/community/parts.test.tsx @@ -0,0 +1,57 @@ +// The practice wall's card turns its knobs the way components/community/ +// PatternCard.tsx does. It kept its own copy of that card's old step, floored +// at 0.001 and rounded to three decimals, so a knob declared 0.0001..0.0004 +// fell to 0, below its range, on the first notch, and its bar showed the +// middle of that range as 15%. + +import { beforeEach, describe, expect, it } from "vitest"; +import { fireEvent, render } from "@testing-library/react"; +import { knobWheelValue } from "@/lib/pattern/controls"; +import type { PracticePattern } from "./data"; +import { PatternCard } from "./parts"; +import { usePractice } from "./state"; + +const FINE: PracticePattern = { + id: "fine", + title: "Fine", + code: "// @knobs Fine=0.0001..0.0004, Tight=0.001..0.002, Hue=0..1, Speed=0.1..10\nfunction draw(display) {}", + hasCpp: false, + handle: "engmung", + license: "MIT", +}; + +const fresh = usePractice.getState(); + +function card() { + const { container } = render(); + const el = container.querySelector('[data-pc-card="fine"]'); + if (!el) throw new Error("no card"); + return { container, el }; +} + +describe("practice wall card", () => { + beforeEach(() => { + usePractice.setState({ ...fresh, knobs: { fine: [0.00025, 0.0015, 0.5, 5.05] }, demo: null }); + }); + + it("turns a fine knob a 25th of its range a notch, as the community's card does", () => { + const { el } = card(); + fireEvent.keyDown(el, { key: "ArrowUp" }); + const turned = usePractice.getState().knobs.fine[0]; + expect(turned).toBe(0.000262); + expect(turned).toBe(knobWheelValue(0.00025, [0.0001, 0.0004], 1)); + }); + + it("stops a fine knob at the bottom of its range, not at 0", () => { + usePractice.setState({ knobs: { fine: [0.0001, 0.0015, 0.5, 5.05] } }); + const { el } = card(); + fireEvent.keyDown(el, { key: "ArrowDown" }); + expect(usePractice.getState().knobs.fine[0]).toBe(0.0001); + }); + + it("fills the knob's bar to where the value sits in a fine range", () => { + const { container } = card(); + const fill = container.querySelector(".metaKnobFill"); + expect(fill?.style.width).toBe("50%"); + }); +}); diff --git a/web/src/components/guide/desk/community/parts.tsx b/web/src/components/guide/desk/community/parts.tsx index fbdd2ba49..482eafd1e 100644 --- a/web/src/components/guide/desk/community/parts.tsx +++ b/web/src/components/guide/desk/community/parts.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState, type MouseEvent, type ReactNode } from "react"; import { knobSetupFromCode } from "@/lib/community/knobs"; +import { knobWheelValue, normalizeKnob } from "@/lib/pattern/controls"; import { describeMatrixShape, matrixFromCode } from "@/lib/pattern/matrix"; import cs from "@/components/community/Community.module.css"; import { deskSignal } from "../deskStore"; @@ -138,8 +139,8 @@ function PlusMinusIcon({ minus }: { minus: boolean }) { function turnKnob(id: string, ranges: Array<[number, number]>, k: number, dir: 1 | -1) { const st = usePractice.getState(); const next = st.knobsOf(id).slice(); - const [min, max] = ranges[k] ?? [0, 1]; - next[k] = Number(Math.max(min, Math.min(max, (next[k] ?? min) + (dir * Math.max(0.001, max - min)) / 25)).toFixed(3)); + const range = ranges[k] ?? [0, 1]; + next[k] = knobWheelValue(next[k] ?? range[0], range, dir); st.setKnobs(id, next); deskSignal(COMMUNITY_SIGNAL.turned); } @@ -191,8 +192,7 @@ export function PatternCard({ p }: { p: PracticePattern }) { const noteEdge = overlayShowing && pos === "bottom" ? cs.deckNoteTop : cs.deckNoteBottom; const label = setup.labels[k] ?? `Knob ${k + 1}`; const value = knobs[k] ?? 0; - const [kMin, kMax] = setup.ranges[k] ?? [0, 1]; - const norm = (value - kMin) / Math.max(0.001, kMax - kMin); + const norm = normalizeKnob(value, setup.ranges[k] ?? [0, 1]); const open = () => usePractice.getState().go({ kind: "pattern", id: p.id }); const stop = (e: MouseEvent) => { diff --git a/web/src/lib/guide/deviceSim.ts b/web/src/lib/guide/deviceSim.ts index 64ad5ff8b..732d05e62 100644 --- a/web/src/lib/guide/deviceSim.ts +++ b/web/src/lib/guide/deviceSim.ts @@ -17,7 +17,7 @@ import { PatternRuntime, type ColorRamp, type PatternInput } from "@/lib/pattern import { parseRampAnnotation } from "@/lib/pattern/ramp"; import { hexToRgb } from "@/lib/pattern/color"; import { knobSetupFromCode } from "@/lib/community/knobs"; -import { LOGICAL_KNOB_DEFAULTS, knobUnitsPerTurn } from "@/lib/pattern/controls"; +import { LOGICAL_KNOB_DEFAULTS, knobUnitsPerTurn, normalizeKnob } from "@/lib/pattern/controls"; import { livePresets } from "@/lib/presets"; import { BASICS_PACK } from "@/lib/pattern/packs"; import { BUS_MAX, BUS_UNITS_PER_CLICK, DETENTS_PER_TURN, EDGES_PER_DETENT, KNOB_COUNT } from "@/lib/shared/constants"; @@ -910,10 +910,7 @@ export class DeviceSim { const input: PatternInput = { knobDeltas: toPattern.detents, knobValues: values, - knobNormalized: values.map((v, i) => { - const [min, max] = ranges[i] ?? [0, 1]; - return (v - min) / Math.max(0.0001, max - min); - }), + knobNormalized: values.map((v, i) => normalizeKnob(v, ranges[i] ?? [0, 1])), knobRanges: ranges, btnPressed: toPattern.presses, btnHeld: this.knobs.map((k, i) => this.mode === "run" && (k.down || this.remoteDown[i])), diff --git a/web/src/lib/pattern/controls.test.ts b/web/src/lib/pattern/controls.test.ts index 5cb594435..cf8efc829 100644 --- a/web/src/lib/pattern/controls.test.ts +++ b/web/src/lib/pattern/controls.test.ts @@ -20,6 +20,7 @@ import { knobDetentStep, knobMidpoint, knobUnitsPerTurn, + knobWheelValue, logicalKnobUnitsPerTurn, normalizeKnob, tidyKnobValue, @@ -142,6 +143,30 @@ describe("reading a knob as 0..1", () => { }); }); +describe("a card's wheel", () => { + it("moves a knob a 25th of its range a notch and stops at each end", () => { + expect(knobWheelValue(0.5, [0, 1], 1)).toBe(0.54); + expect(knobWheelValue(5.05, [0.1, 10], -1)).toBe(4.654); + expect(knobWheelValue(0.98, [0, 1], 1)).toBe(1); + expect(knobWheelValue(0.11, [0.1, 10], -1)).toBe(0.1); + }); + + it("crosses a range in 25 notches", () => { + let value = 0.1; + for (let notch = 0; notch < 25; notch++) value = knobWheelValue(value, [0.1, 10], 1); + expect(value).toBe(10); + }); + + it("keeps a range as fine as 0.0001..0.0004 inside its range", () => { + expect(knobWheelValue(0.00025, [0.0001, 0.0004], 1)).toBe(0.000262); + expect(knobWheelValue(0.0001, [0.0001, 0.0004], -1)).toBe(0.0001); + }); + + it("leaves a knob on a range edited down to nothing where the range is", () => { + expect(knobWheelValue(3, [3, 3], 1)).toBe(3); + }); +}); + describe("front-panel ids", () => { it("finds each knob's range through the logical order (Hue, Speed, Mode, Freq)", () => { expect(webKnobRange("c1")).toEqual(LOGICAL_KNOB_RANGES[0]); diff --git a/web/src/lib/pattern/controls.ts b/web/src/lib/pattern/controls.ts index da790caf3..8b81ebf60 100644 --- a/web/src/lib/pattern/controls.ts +++ b/web/src/lib/pattern/controls.ts @@ -39,7 +39,7 @@ export function knobDetentStep(range: readonly [number, number]) { // export and the text effects floored at 0.0001; the community's sandbox and // cards floored at 0.001, so a knob declared 0.0001..0.0004 sitting at 0.00025 // reached a pattern as 0.5 in the Lab and 0.15 on its card. -export const KNOB_SPAN_FLOOR = 0.0001; +const KNOB_SPAN_FLOOR = 0.0001; /** Where a value sits in a knob's range, 0 at the bottom and 1 at the top. Not held to 0..1. */ export function normalizeKnob(value: number, range: readonly [number, number]) { @@ -69,6 +69,19 @@ export function knobMidpoint(range: readonly [number, number]) { return tidyKnobValue((range[0] + range[1]) / 2); } +/** + * Where one notch of a card's wheel leaves a knob: a 25th of its range up or + * down, held to the range. The community's cards and the guide's practice + * wall both turn with it. The practice wall kept a copy of the cards' old + * step, floored at 0.001 and rounded to three decimals, so a 0.0001..0.0004 + * knob fell to 0, below its range, on the first notch. + */ +export function knobWheelValue(value: number, range: readonly [number, number], direction: 1 | -1) { + const [min, max] = range; + const step = Math.max(KNOB_SPAN_FLOOR, max - min) / 25; + return Math.max(min, Math.min(max, tidyKnobValue(value + direction * step))); +} + /** * Where one arrow key leaves a knob: a detent up (ArrowRight, ArrowUp) or * down (ArrowLeft, ArrowDown), held to the range the way PFParams::apply holds