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