Add Phase 1 (panel-logic TS) implementation plan for local arrays
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,779 @@
|
|||||||
|
# Local Array Values — Phase 1 (Panel Logic, TypeScript) Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add first-class array-valued local variables to the panel-logic (client TS) flow engine — declaration with dynamic/capped/fixed sizing, an array-aware expression language, mutation nodes (unifying the legacy accumulate/export/clear arrays), persistence, and binding from the plot / table / multi-LED widgets.
|
||||||
|
|
||||||
|
**Architecture:** The expression evaluator (`expr.ts`) becomes value-polymorphic — `EvalValue = number | EvalValue[]` (tagged union, "Approach 1"). Array locals are declared as a new `type:'array'` `StateVar` and held per-panel in `localstate.ts`, which enforces the sizing policy on every write. Mutation is performed by new `action.array.*` nodes; reads/transforms are pure functions in the expression language. Widgets read array locals through the existing `ds:'local'` plumbing.
|
||||||
|
|
||||||
|
**Tech Stack:** Preact 10 + TypeScript, bundled by esbuild via the Go tool `tools/buildfrontend/main.go` (no npm/node). Stores are the hand-rolled `web/src/lib/store.ts` writable/readable primitives.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- No npm / no Node.js. Frontend builds **only** via `make frontend` (esbuild, pure Go). There is **no JS test runner and no typecheck gate** — esbuild strips types without checking them. Phase-1 verification = `make frontend` builds clean + the manual smoke checklist in Task 9.
|
||||||
|
- The authoritative automated tests for the shared array semantics live in **Phase 2 (Go `expr.go`)**, not here. Keep `expr.ts` semantics documented precisely so the Go port can mirror them exactly.
|
||||||
|
- Booleans are numbers (`1`/`0`) at array leaves; `elem:'bool'` is display metadata only.
|
||||||
|
- Expressions are **pure** (no side effects). Mutator-named functions (`push/set/insert/remove/pop/shift`) return **new** arrays; the sizing policy is enforced only at store time in `localstate.ts`.
|
||||||
|
- Dynamic arrays are guarded by a global safety cap `ARRAY_MAX = 1_000_000` elements.
|
||||||
|
- Preserve backward compatibility: existing `accumulate`/`clear`/`export` nodes keep working via aliasing + auto-declared locals.
|
||||||
|
- Follow existing file conventions; commit frequently with `Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- `web/src/lib/types.ts` — extend `StateVar` with array fields. (Modify)
|
||||||
|
- `web/src/lib/expr.ts` — polymorphic value model, array literals, indexing, array functions. (Modify — the core change)
|
||||||
|
- `web/src/lib/localstate.ts` — array init from `initial`, sizing-policy enforcement on write, array metadata. (Modify)
|
||||||
|
- `web/src/lib/arraypolicy.ts` — **new**: pure helpers `applySizing(value, sv)` and `parseInitialArray(sv)` shared by `localstate.ts` and `logic.ts`. (Create)
|
||||||
|
- `web/src/lib/logic.ts` — new `action.array.*` node handlers; accumulate/clear aliasing; export-by-index with custom headers; auto-declare migration in `load()`. (Modify)
|
||||||
|
- `web/src/lib/types.ts` `LogicNodeKind` — add the new node kinds. (Modify, same file as above)
|
||||||
|
- `web/src/lib/xml.ts` — round-trip the new `<statevar>` array attributes. (Modify)
|
||||||
|
- `web/src/LogicEditor.tsx` — `LocalVars` array declaration form + array node palette entries + inspectors. (Modify)
|
||||||
|
- `web/src/lib/flowDebug.ts` — compact array stringify for node value badges. (Modify)
|
||||||
|
- `web/src/widgets/PlotWidget.tsx`, `web/src/widgets/TableWidget.tsx`, `web/src/widgets/MultiLed.tsx` (exact filenames verified in Task 7) — array source modes. (Modify)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Extend `StateVar` with array fields
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/lib/types.ts` (the `StateVar` interface, ~lines 69-76)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `StateVar` with new optional fields `elem?: 'number'|'bool'|'array'`, `sizing?: 'dynamic'|'capped'|'fixed'`, `capacity?: number`, and `type` union extended with `'array'`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Extend the interface**
|
||||||
|
|
||||||
|
In `web/src/lib/types.ts`, change the `StateVar` interface to:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface StateVar {
|
||||||
|
name: string;
|
||||||
|
type?: 'number' | 'bool' | 'string' | 'array';
|
||||||
|
initial: string;
|
||||||
|
unit?: string;
|
||||||
|
low?: number;
|
||||||
|
high?: number;
|
||||||
|
// array-only (present when type === 'array'):
|
||||||
|
elem?: 'number' | 'bool' | 'array';
|
||||||
|
sizing?: 'dynamic' | 'capped' | 'fixed';
|
||||||
|
capacity?: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build**
|
||||||
|
|
||||||
|
Run: `make frontend`
|
||||||
|
Expected: `Frontend built successfully → …/web/dist`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/lib/types.ts
|
||||||
|
git commit -m "feat(logic): add array fields to StateVar type"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Array sizing-policy helpers (`arraypolicy.ts`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web/src/lib/arraypolicy.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `StateVar`, `EvalValue` (Task 3 finalizes `EvalValue`; here use `type EvalValue = number | EvalValue[]` locally re-exported from `expr.ts` once Task 3 lands — for ordering, define the alias in this file and have `expr.ts` import it).
|
||||||
|
- Produces:
|
||||||
|
- `export type ArrVal = number | ArrVal[];`
|
||||||
|
- `export const ARRAY_MAX = 1_000_000;`
|
||||||
|
- `export function parseInitialArray(sv: StateVar): ArrVal[]` — initial contents for an array local.
|
||||||
|
- `export function applySizing(arr: ArrVal[], sv: StateVar): ArrVal[]` — enforce dynamic/capped/fixed on a candidate array value.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the module**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// web/src/lib/arraypolicy.ts
|
||||||
|
// Pure helpers for array-valued local state: parse the declared initial value
|
||||||
|
// and enforce the declared sizing policy (dynamic / capped / fixed). Shared by
|
||||||
|
// localstate.ts (write path) and logic.ts (node handlers + migration).
|
||||||
|
|
||||||
|
import type { StateVar } from './types';
|
||||||
|
|
||||||
|
export type ArrVal = number | ArrVal[];
|
||||||
|
export const ARRAY_MAX = 1_000_000;
|
||||||
|
|
||||||
|
// zeroFill builds a length-n array of zeros (flat; fixed nested init must come
|
||||||
|
// from an explicit `initial` literal).
|
||||||
|
function zeroFill(n: number): ArrVal[] {
|
||||||
|
return new Array(Math.max(0, n)).fill(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseInitialArray returns the starting contents of an array local.
|
||||||
|
export function parseInitialArray(sv: StateVar): ArrVal[] {
|
||||||
|
const cap = sv.capacity ?? 0;
|
||||||
|
const raw = (sv.initial ?? '').trim();
|
||||||
|
let parsed: ArrVal[] | null = null;
|
||||||
|
if (raw) {
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(raw);
|
||||||
|
if (Array.isArray(j)) parsed = j as ArrVal[];
|
||||||
|
} catch { parsed = null; }
|
||||||
|
}
|
||||||
|
if (sv.sizing === 'fixed') {
|
||||||
|
if (!parsed) return zeroFill(cap);
|
||||||
|
// truncate / zero-pad to capacity
|
||||||
|
const out = parsed.slice(0, cap);
|
||||||
|
while (out.length < cap) out.push(0);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return parsed ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// applySizing returns arr clamped to the declared policy.
|
||||||
|
// dynamic → unchanged (but globally capped at ARRAY_MAX, dropping oldest)
|
||||||
|
// capped → keep at most capacity elements, dropping oldest (ring/FIFO)
|
||||||
|
// fixed → exactly capacity elements (truncate / zero-pad); never grow/shrink
|
||||||
|
export function applySizing(arr: ArrVal[], sv: StateVar): ArrVal[] {
|
||||||
|
const cap = sv.capacity ?? 0;
|
||||||
|
switch (sv.sizing) {
|
||||||
|
case 'fixed': {
|
||||||
|
const out = arr.slice(0, cap);
|
||||||
|
while (out.length < cap) out.push(0);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
case 'capped':
|
||||||
|
return arr.length > cap ? arr.slice(arr.length - cap) : arr;
|
||||||
|
default:
|
||||||
|
return arr.length > ARRAY_MAX ? arr.slice(arr.length - ARRAY_MAX) : arr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build**
|
||||||
|
|
||||||
|
Run: `make frontend`
|
||||||
|
Expected: builds clean (module compiles; not yet imported anywhere).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/lib/arraypolicy.ts
|
||||||
|
git commit -m "feat(logic): add array sizing-policy helpers"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Make `expr.ts` value-polymorphic
|
||||||
|
|
||||||
|
This is the core change. `Resolver` and the evaluator move from `number` to `ArrVal = number | ArrVal[]`. Add array-literal and indexing syntax plus the array function set. Keep all existing scalar behavior identical (numbers, booleans-as-1/0, operators, ternary, the existing math funcs).
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/lib/expr.ts` (whole file — see current contents)
|
||||||
|
- Modify import in: any caller of `Resolver`/`evalExpr` that assumed a `number` return — audit in Step 5.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `ArrVal` from `./arraypolicy`.
|
||||||
|
- Produces:
|
||||||
|
- `export type Resolver = (ds: string, name: string) => ArrVal;`
|
||||||
|
- `export function evalValue(src: string, resolve: Resolver): ArrVal` — full value (number or array).
|
||||||
|
- `export function evalExpr(src: string, resolve: Resolver): number` — **kept** for scalar callers; returns `NaN` if the value is an array or unparseable.
|
||||||
|
- `export function evalBool(src, resolve): boolean` — unchanged signature.
|
||||||
|
- `export function collectRefs(src): RefLite[]` — now also walks array-literal / index AST nodes.
|
||||||
|
- `export function checkExpr(src): string|null` — unchanged signature; parser now accepts the new syntax.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add AST nodes for array literal and indexing**
|
||||||
|
|
||||||
|
In the `Node` union add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
| { t: 'arr'; items: Node[] }
|
||||||
|
| { t: 'index'; a: Node; i: Node }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Tokenizer — add `[` and `]`**
|
||||||
|
|
||||||
|
In `tokenize`, extend the single-char punctuation set to include brackets:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
if ('+-*/%<>!()?:,[]'.includes(c)) { toks.push({ k: c }); i++; continue; }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Parser — array literals + postfix indexing**
|
||||||
|
|
||||||
|
Replace `primary()` so that after producing a base node it consumes any chain of `[ expr ]`, and add the `[ … ]` literal:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function atom(): Node {
|
||||||
|
const t = peek();
|
||||||
|
if (!t) throw new Error('unexpected end of expression');
|
||||||
|
if (t.k === 'num') { eat(); return { t: 'num', v: parseFloat(t.v!) }; }
|
||||||
|
if (t.k === '[') {
|
||||||
|
eat('[');
|
||||||
|
const items: Node[] = [];
|
||||||
|
if (peek()?.k !== ']') {
|
||||||
|
items.push(ternary());
|
||||||
|
while (peek()?.k === ',') { eat(','); items.push(ternary()); }
|
||||||
|
}
|
||||||
|
eat(']');
|
||||||
|
return { t: 'arr', items };
|
||||||
|
}
|
||||||
|
if (t.k === 'sig') {
|
||||||
|
eat();
|
||||||
|
const raw = t.v!;
|
||||||
|
const idx = raw.indexOf(':');
|
||||||
|
const ds = idx < 0 ? raw : raw.slice(0, idx);
|
||||||
|
const name = idx < 0 ? '' : raw.slice(idx + 1);
|
||||||
|
return { t: 'sig', ds, name };
|
||||||
|
}
|
||||||
|
if (t.k === 'ident') {
|
||||||
|
eat();
|
||||||
|
const id = t.v!;
|
||||||
|
if (id === 'true') return { t: 'num', v: 1 };
|
||||||
|
if (id === 'false') return { t: 'num', v: 0 };
|
||||||
|
if (peek()?.k === '(') {
|
||||||
|
eat('(');
|
||||||
|
const args: Node[] = [];
|
||||||
|
if (peek()?.k !== ')') {
|
||||||
|
args.push(ternary());
|
||||||
|
while (peek()?.k === ',') { eat(','); args.push(ternary()); }
|
||||||
|
}
|
||||||
|
eat(')');
|
||||||
|
return { t: 'call', fn: id, args };
|
||||||
|
}
|
||||||
|
return { t: 'var', name: id };
|
||||||
|
}
|
||||||
|
if (t.k === '(') { eat('('); const e = ternary(); eat(')'); return e; }
|
||||||
|
throw new Error(`unexpected token '${t.k}' in expression`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function primary(): Node {
|
||||||
|
let n = atom();
|
||||||
|
while (peek()?.k === '[') {
|
||||||
|
eat('[');
|
||||||
|
const i = ternary();
|
||||||
|
eat(']');
|
||||||
|
n = { t: 'index', a: n, i };
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(`unary` still calls `primary`; no other parser change.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Evaluator — return `ArrVal`, add array funcs + indexing**
|
||||||
|
|
||||||
|
Replace the evaluation section. Helpers `asNum` (coerce to number, throw on array) and `asArr` (require array) gate type errors.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { ArrVal } from './arraypolicy';
|
||||||
|
|
||||||
|
export type Resolver = (ds: string, name: string) => ArrVal;
|
||||||
|
|
||||||
|
function asNum(v: ArrVal): number {
|
||||||
|
if (typeof v !== 'number') throw new Error('expected a number, got an array');
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
function asArr(v: ArrVal): ArrVal[] {
|
||||||
|
if (!Array.isArray(v)) throw new Error('expected an array, got a number');
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
// resolve a possibly-negative index against length
|
||||||
|
function idx(i: number, len: number): number {
|
||||||
|
const k = Math.trunc(i) < 0 ? len + Math.trunc(i) : Math.trunc(i);
|
||||||
|
if (k < 0 || k >= len) throw new Error(`index ${i} out of range (len ${len})`);
|
||||||
|
return k;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ARR_FUNCS: Record<string, (a: ArrVal[]) => ArrVal> = {
|
||||||
|
len: a => asArr(a[0]).length,
|
||||||
|
sum: a => asArr(a[0]).reduce((s, x) => s + asNum(x), 0),
|
||||||
|
mean: a => { const r = asArr(a[0]); return r.length ? r.reduce((s, x) => s + asNum(x), 0) / r.length : 0; },
|
||||||
|
// min/max: scalar-variadic OR single-array — see ev() dispatch below
|
||||||
|
slice: a => { const r = asArr(a[0]); const s = a[1] === undefined ? 0 : asNum(a[1]); const e = a[2] === undefined ? r.length : asNum(a[2]); return r.slice(s, e); },
|
||||||
|
concat: a => asArr(a[0]).concat(asArr(a[1])),
|
||||||
|
reverse: a => asArr(a[0]).slice().reverse(),
|
||||||
|
sort: a => asArr(a[0]).slice().sort((x, y) => asNum(x) - asNum(y)),
|
||||||
|
scale: a => asArr(a[0]).map(x => asNum(x) * asNum(a[1])),
|
||||||
|
add: a => { const x = asArr(a[0]), y = asArr(a[1]); const n = Math.min(x.length, y.length); const o: ArrVal[] = []; for (let k = 0; k < n; k++) o.push(asNum(x[k]) + asNum(y[k])); return o; },
|
||||||
|
sub: a => { const x = asArr(a[0]), y = asArr(a[1]); const n = Math.min(x.length, y.length); const o: ArrVal[] = []; for (let k = 0; k < n; k++) o.push(asNum(x[k]) - asNum(y[k])); return o; },
|
||||||
|
push: a => asArr(a[0]).concat([a[1]]),
|
||||||
|
set: a => { const r = asArr(a[0]).slice(); r[idx(asNum(a[1]), r.length)] = a[2]; return r; },
|
||||||
|
insert: a => { const r = asArr(a[0]).slice(); const k = Math.max(0, Math.min(r.length, Math.trunc(asNum(a[1])))); r.splice(k, 0, a[2]); return r; },
|
||||||
|
remove: a => { const r = asArr(a[0]).slice(); r.splice(idx(asNum(a[1]), r.length), 1); return r; },
|
||||||
|
pop: a => { const r = asArr(a[0]).slice(); r.pop(); return r; },
|
||||||
|
shift: a => { const r = asArr(a[0]).slice(); r.shift(); return r; },
|
||||||
|
indexOf: a => { const r = asArr(a[0]); for (let k = 0; k < r.length; k++) if (r[k] === a[1]) return k; return -1; },
|
||||||
|
contains: a => { const r = asArr(a[0]); for (let k = 0; k < r.length; k++) if (r[k] === a[1]) return 1; return 0; },
|
||||||
|
fill: a => new Array(Math.max(0, Math.trunc(asNum(a[0])))).fill(a[1]),
|
||||||
|
};
|
||||||
|
|
||||||
|
function ev(n: Node, R: Resolver): ArrVal {
|
||||||
|
switch (n.t) {
|
||||||
|
case 'num': return n.v;
|
||||||
|
case 'arr': return n.items.map(it => ev(it, R));
|
||||||
|
case 'sig': return R(n.ds, n.name);
|
||||||
|
case 'var': return R('local', n.name);
|
||||||
|
case 'index': return asArr(ev(n.a, R))[idx(asNum(ev(n.i, R)), asArr(ev(n.a, R)).length)];
|
||||||
|
case 'un': return n.op === '-' ? -asNum(ev(n.a, R)) : (asNum(ev(n.a, R)) === 0 ? 1 : 0);
|
||||||
|
case 'tern': return asNum(ev(n.c, R)) !== 0 ? ev(n.a, R) : ev(n.b, R);
|
||||||
|
case 'call': {
|
||||||
|
const args = n.args.map(a => ev(a, R));
|
||||||
|
// min/max keep scalar-variadic form, plus 1-arg array form
|
||||||
|
if ((n.fn === 'min' || n.fn === 'max') && !(args.length === 1 && Array.isArray(args[0]))) {
|
||||||
|
const nums = args.map(asNum);
|
||||||
|
return n.fn === 'min' ? Math.min(...nums) : Math.max(...nums);
|
||||||
|
}
|
||||||
|
if (n.fn === 'min' || n.fn === 'max') {
|
||||||
|
const r = asArr(args[0]).map(asNum);
|
||||||
|
return n.fn === 'min' ? Math.min(...r) : Math.max(...r);
|
||||||
|
}
|
||||||
|
const af = ARR_FUNCS[n.fn];
|
||||||
|
if (af) return af(args);
|
||||||
|
const sf = SCALAR_FUNCS[n.fn];
|
||||||
|
if (sf) return sf(args.map(asNum));
|
||||||
|
throw new Error(`unknown function '${n.fn}'`);
|
||||||
|
}
|
||||||
|
case 'bin': {
|
||||||
|
const a = asNum(ev(n.a, R)), b = asNum(ev(n.b, R));
|
||||||
|
switch (n.op) {
|
||||||
|
case '+': return a + b; case '-': return a - b; case '*': return a * b;
|
||||||
|
case '/': return a / b; case '%': return a % b;
|
||||||
|
case '<': return a < b ? 1 : 0; case '<=': return a <= b ? 1 : 0;
|
||||||
|
case '>': return a > b ? 1 : 0; case '>=': return a >= b ? 1 : 0;
|
||||||
|
case '==': return a === b ? 1 : 0; case '!=': return a !== b ? 1 : 0;
|
||||||
|
case '&&': return (a !== 0 && b !== 0) ? 1 : 0;
|
||||||
|
case '||': return (a !== 0 || b !== 0) ? 1 : 0;
|
||||||
|
default: throw new Error(`unknown operator '${n.op}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rename the existing `FUNCS` table to `SCALAR_FUNCS` (same entries: abs/min/max/sqrt/floor/ceil/round/sign/pow/log/exp/sin/cos) — but **remove** `min`/`max` from it since they are now handled in the `call` dispatch above.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Public functions — split `evalValue` / `evalExpr`**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function evalValue(src: string, resolve: Resolver): ArrVal {
|
||||||
|
return ev(parseCached(src), resolve);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evalExpr(src: string, resolve: Resolver): number {
|
||||||
|
try {
|
||||||
|
const v = ev(parseCached(src), resolve);
|
||||||
|
return typeof v === 'number' ? v : NaN;
|
||||||
|
} catch {
|
||||||
|
return NaN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`evalBool` keeps calling `evalExpr` (array → NaN → false, acceptable). Update `collectRefs`'s `walk` switch to also recurse the new nodes:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
case 'arr': n.items.forEach(walk); break;
|
||||||
|
case 'index': walk(n.a); walk(n.i); break;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Audit callers of `Resolver`/`evalExpr`**
|
||||||
|
|
||||||
|
Run: `grep -rn "Resolver\|evalExpr\|evalValue" web/src` and confirm every resolver implementation can return `ArrVal` (returning a plain `number` still satisfies `ArrVal`). The write/condition callers that need a number keep using `evalExpr`; only array-targeting nodes (Task 4) use `evalValue`.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Build**
|
||||||
|
|
||||||
|
Run: `make frontend`
|
||||||
|
Expected: builds clean.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/lib/expr.ts
|
||||||
|
git commit -m "feat(logic): array-aware expression engine (literals, indexing, array funcs)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Array values + sizing in `localstate.ts`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/lib/localstate.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `parseInitialArray`, `applySizing`, `ArrVal` from `./arraypolicy`; `StateVar` from `./types`.
|
||||||
|
- Produces: `writeLocalState(name, value, sv?)` — when `sv` is an array declaration, `applySizing` is enforced; `initLocalState` instantiates array locals from `parseInitialArray`; metadata carries `elem`/`sizing`/`capacity`.
|
||||||
|
- Produces: `export function declaredVar(name): StateVar | undefined` — lookup used by `logic.ts` node handlers to know a local's sizing policy.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Track declarations + array init**
|
||||||
|
|
||||||
|
Add a `decls = new Map<string, StateVar>()` populated in `initLocalState`; extend `coerce` for `type==='array'`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { parseInitialArray, applySizing, type ArrVal } from './arraypolicy';
|
||||||
|
|
||||||
|
const decls = new Map<string, StateVar>();
|
||||||
|
export function declaredVar(name: string): StateVar | undefined { return decls.get(name); }
|
||||||
|
|
||||||
|
function coerce(v: StateVar): any {
|
||||||
|
switch (v.type) {
|
||||||
|
case 'bool': return v.initial === 'true' || v.initial === '1';
|
||||||
|
case 'string': return v.initial;
|
||||||
|
case 'array': return parseInitialArray(v);
|
||||||
|
default: { const n = parseFloat(v.initial); return isNaN(n) ? 0 : n; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `initLocalState`, `decls.set(v.name, v)` before publishing, and extend the metadata object with `elem: v.elem, sizing: v.sizing, capacity: v.capacity` (add these optional fields to `SignalMeta` in `types.ts`).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Enforce sizing on write**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function writeLocalState(name: string, value: any): void {
|
||||||
|
const sv = decls.get(name);
|
||||||
|
let v = value;
|
||||||
|
if (sv?.type === 'array' && Array.isArray(value)) v = applySizing(value as ArrVal[], sv);
|
||||||
|
valueW(name).set({ value: v, quality: 'good', ts: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build**
|
||||||
|
|
||||||
|
Run: `make frontend`
|
||||||
|
Expected: builds clean.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/lib/localstate.ts web/src/lib/types.ts
|
||||||
|
git commit -m "feat(logic): array local init + sizing enforcement in localstate"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Array action nodes + accumulate/export unification in `logic.ts`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/lib/types.ts` (`LogicNodeKind` union — add `'action.array.push' | 'action.array.set' | 'action.array.remove' | 'action.array.pop' | 'action.array.clear'`)
|
||||||
|
- Modify: `web/src/lib/logic.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `evalValue`, `evalExpr` from `./expr`; `writeLocalState`, `getLocalValueStore`, `declaredVar` from `./localstate`; `applySizing` from `./arraypolicy`.
|
||||||
|
- Produces: node handlers for the five `action.array.*` kinds; `accumulate`→push and `clear`→array.clear aliasing; export-by-index with custom header labels; `ensureArrayDecls(graph)` auto-declare migration called from `load()`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read current array machinery**
|
||||||
|
|
||||||
|
Run: `grep -n "arrays\|accumulate\|action.export\|action.clear\|runNode\|case 'action" web/src/lib/logic.ts` to locate the dispatch switch and the legacy `arrays:Map` (around the lines noted in the design's code map: store ~225, accumulate ~614, export ~626, clear ~632, exportArrays ~733).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace the `{t,v}` store with array-local reads/writes**
|
||||||
|
|
||||||
|
Array locals are the single source of truth. Implement a helper to read the current array value of a local:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { get } from './store';
|
||||||
|
import { getLocalValueStore, writeLocalState, declaredVar } from './localstate';
|
||||||
|
import { applySizing, type ArrVal } from './arraypolicy';
|
||||||
|
|
||||||
|
function curArray(name: string): ArrVal[] {
|
||||||
|
const v = get(getLocalValueStore(name)).value;
|
||||||
|
return Array.isArray(v) ? (v as ArrVal[]) : [];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(If `store.ts` lacks a synchronous `get`, read via a one-shot subscribe; confirm in Step 1.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the five node handlers**
|
||||||
|
|
||||||
|
In the node dispatch switch:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
case 'action.array.push': {
|
||||||
|
const arr = curArray(p.array);
|
||||||
|
writeLocalState(p.array, [...arr, evalValue(p.expr, ctx.resolve)]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'action.array.set': {
|
||||||
|
const arr = curArray(p.array).slice();
|
||||||
|
const path = String(p.index ?? '').split(',').map(s => Math.trunc(evalExpr(s, ctx.resolve)));
|
||||||
|
setPath(arr, path, evalValue(p.expr, ctx.resolve)); // setPath: nested index assignment, defined below
|
||||||
|
writeLocalState(p.array, arr);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'action.array.remove': {
|
||||||
|
const arr = curArray(p.array).slice();
|
||||||
|
const i = Math.trunc(evalExpr(p.index, ctx.resolve));
|
||||||
|
const k = i < 0 ? arr.length + i : i;
|
||||||
|
if (k >= 0 && k < arr.length) arr.splice(k, 1);
|
||||||
|
writeLocalState(p.array, arr);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'action.array.pop': {
|
||||||
|
const arr = curArray(p.array).slice(); arr.pop();
|
||||||
|
writeLocalState(p.array, arr);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'action.array.clear': {
|
||||||
|
const sv = declaredVar(p.array);
|
||||||
|
writeLocalState(p.array, sv ? applySizing([], sv) : []); // fixed → zero-refill via applySizing
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `setPath`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function setPath(arr: ArrVal[], path: number[], v: ArrVal): void {
|
||||||
|
let cur: ArrVal[] = arr;
|
||||||
|
for (let d = 0; d < path.length - 1; d++) {
|
||||||
|
let k = path[d]; if (k < 0) k = cur.length + k;
|
||||||
|
if (!Array.isArray(cur[k])) cur[k] = [];
|
||||||
|
cur = cur[k] as ArrVal[];
|
||||||
|
}
|
||||||
|
let last = path[path.length - 1]; if (last < 0) last = cur.length + last;
|
||||||
|
cur[last] = v;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Alias accumulate/clear and rewrite export**
|
||||||
|
|
||||||
|
In the dispatch switch, make the legacy kinds delegate:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
case 'action.accumulate': // legacy alias → push
|
||||||
|
{ const arr = curArray(p.array); writeLocalState(p.array, [...arr, evalValue(p.expr, ctx.resolve)]); }
|
||||||
|
break;
|
||||||
|
case 'action.clear': // legacy alias → array.clear
|
||||||
|
{ const sv = declaredVar(p.array); writeLocalState(p.array, sv ? applySizing([], sv) : []); }
|
||||||
|
break;
|
||||||
|
```
|
||||||
|
|
||||||
|
Rewrite `action.export` to read array locals by column and emit index-aligned CSV with custom headers. Parse `p.columns` as `[{array, label}]`; header row uses `label || array`; row `r` joins `col[r] ?? ''`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
case 'action.export': {
|
||||||
|
const cols = JSON.parse(p.columns || '[]') as { array: string; label?: string }[];
|
||||||
|
const data = cols.map(c => curArray(c.array));
|
||||||
|
const rows = Math.max(0, ...data.map(d => d.length));
|
||||||
|
const header = cols.map(c => csvCell(c.label || c.array)).join(',');
|
||||||
|
const lines = [header];
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
lines.push(data.map(d => (r < d.length ? csvCell(String(d[r])) : '')).join(','));
|
||||||
|
}
|
||||||
|
downloadCsv(lines.join('\n'), p.filename || 'export.csv'); // reuse existing blob-download helper
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Keep/rename the existing CSV-escape and blob-download helpers as `csvCell`/`downloadCsv`; drop `exportArrays`/`interpAt` and the `align` param handling.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Auto-declare migration in `load()`**
|
||||||
|
|
||||||
|
After the graph is loaded but before subscriptions, ensure any array referenced by an array node / accumulate / export but **not** declared gets a dynamic numeric array local:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function ensureArrayDecls(graph: LogicGraph, vars: StateVar[]): StateVar[] {
|
||||||
|
const have = new Set(vars.map(v => v.name));
|
||||||
|
const out = vars.slice();
|
||||||
|
const need = (name: string) => {
|
||||||
|
if (name && !have.has(name)) { have.add(name); out.push({ name, type: 'array', elem: 'number', sizing: 'dynamic', initial: '' }); }
|
||||||
|
};
|
||||||
|
for (const n of graph.nodes) {
|
||||||
|
if (n.kind === 'action.accumulate' || n.kind === 'action.clear' || n.kind.startsWith('action.array.')) need(n.params.array);
|
||||||
|
if (n.kind === 'action.export') { try { (JSON.parse(n.params.columns || '[]') as any[]).forEach(c => need(c.array)); } catch {} }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Call this so the resulting list is passed to `initLocalState` (wherever `load()` currently calls it; if `load()` doesn't own statevars, thread the merged list through the same path the panel uses to init local state).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Build**
|
||||||
|
|
||||||
|
Run: `make frontend`
|
||||||
|
Expected: builds clean.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/lib/logic.ts web/src/lib/types.ts
|
||||||
|
git commit -m "feat(logic): array action nodes + accumulate/export unification + migration"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Persist array `<statevar>` attributes (`xml.ts`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/lib/xml.ts` (statevar read ~lines 67-77 and the corresponding write path)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `<statevar>` round-trips `elem`, `sizing`, `capacity` in addition to existing attrs, only emitting them when present.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write path — emit array attrs**
|
||||||
|
|
||||||
|
Where statevars are serialized, append the optional attrs:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function statevarXml(v: StateVar): string {
|
||||||
|
const a = [`name="${esc(v.name)}"`, `type="${v.type ?? 'number'}"`, `initial="${esc(v.initial)}"`];
|
||||||
|
if (v.unit) a.push(`unit="${esc(v.unit)}"`);
|
||||||
|
if (v.low !== undefined) a.push(`low="${v.low}"`);
|
||||||
|
if (v.high !== undefined) a.push(`high="${v.high}"`);
|
||||||
|
if (v.type === 'array') {
|
||||||
|
if (v.elem) a.push(`elem="${v.elem}"`);
|
||||||
|
if (v.sizing) a.push(`sizing="${v.sizing}"`);
|
||||||
|
if (v.capacity !== undefined) a.push(`capacity="${v.capacity}"`);
|
||||||
|
}
|
||||||
|
return `<statevar ${a.join(' ')}/>`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Adapt to the file's existing serialization style — match how `unit`/`low`/`high` are currently emitted.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Read path — parse array attrs**
|
||||||
|
|
||||||
|
Where `<statevar>` is parsed into a `StateVar`, add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
elem: el.getAttribute('elem') as any || undefined,
|
||||||
|
sizing: el.getAttribute('sizing') as any || undefined,
|
||||||
|
capacity: el.hasAttribute('capacity') ? Number(el.getAttribute('capacity')) : undefined,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build + round-trip sanity (manual)**
|
||||||
|
|
||||||
|
Run: `make frontend`. Then in Task 9's smoke test confirm an array statevar survives save→reload.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/lib/xml.ts
|
||||||
|
git commit -m "feat(logic): round-trip array statevar attributes in panel XML"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Editor — array declaration form + array nodes
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/LogicEditor.tsx` (the `LocalVars` subcomponent + the palette node list + the inspector)
|
||||||
|
- Modify: `web/src/lib/flowDebug.ts` (compact array badge formatting)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `StateVar` shape (Task 1), the new node kinds (Task 5).
|
||||||
|
- Produces: UI to declare array locals and place/inspect `action.array.*` nodes; debug badges that stringify arrays compactly.
|
||||||
|
|
||||||
|
- [ ] **Step 1: `LocalVars` array form**
|
||||||
|
|
||||||
|
In the `LocalVars` subcomponent, when the type select value is `array`, reveal: an `elem` select (number/bool/array), a `sizing` select (dynamic/capped/fixed), a `capacity` number input (shown for capped/fixed), and the existing `initial` text field repurposed as a JSON literal with inline `JSON.parse` validation (red hint on parse error). Persist via the existing `onStateVarsChange` path.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Palette + inspector for array nodes**
|
||||||
|
|
||||||
|
Add the five `action.array.*` kinds to the Actions palette group (label/icon consistent with existing entries). In the inspector `switch`, add cases rendering: `array` (a select of declared array-local names), and `expr`/`index` fields using the existing `ExprField` (which already runs `checkExpr`). `action.array.set` shows both `index` (path, comma-separated) and `expr`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Compact array badges**
|
||||||
|
|
||||||
|
In `flowDebug.ts`, where a node value is stringified for the badge, format arrays as e.g. `[1, 2, 3, …](n=N)` truncated to the first ~3 elements:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function fmtBadge(v: unknown): string {
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
const head = v.slice(0, 3).map(x => Array.isArray(x) ? '[…]' : String(x)).join(', ');
|
||||||
|
return `[${head}${v.length > 3 ? ', …' : ''}](n=${v.length})`;
|
||||||
|
}
|
||||||
|
return typeof v === 'number' ? String(+v.toFixed(4)) : String(v);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Wire `fmtBadge` into the existing badge render site (replace the inline number formatting).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build**
|
||||||
|
|
||||||
|
Run: `make frontend`
|
||||||
|
Expected: builds clean.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/LogicEditor.tsx web/src/lib/flowDebug.ts
|
||||||
|
git commit -m "feat(logic): array local declaration form + array nodes + array debug badges"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Widgets read array locals
|
||||||
|
|
||||||
|
**Files (verify exact paths first):**
|
||||||
|
- Run: `ls web/src/widgets` and `grep -rln "bitset\|MultiLed\|multi-led\|TableWidget\|PlotWidget" web/src/widgets`
|
||||||
|
- Modify: the plot widget, table widget, multi-LED/bitset widget.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: a bound local whose `SignalValue.value` may be `ArrVal[]`; metadata `elem`/`sizing`/`capacity` from `getLocalMetaStore`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Plot — accept a 1-D numeric array local as a waveform**
|
||||||
|
|
||||||
|
In the plot widget's value-ingest path, when a bound signal's value is a numeric array, feed it through the **same** code path already used for EPICS `float64[]` waveform samples (multidimensional/FFT/waterfall). Nested arrays → render the existing "unsupported" placeholder. (Locate the waveform branch via `grep -n "Array.isArray\|waveform\|float64" web/src/widgets/PlotWidget.tsx`.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Table — array source mode**
|
||||||
|
|
||||||
|
Add an `array` source mode (config flag) that, when the bound value is an array, renders one row per element (index + value with the per-signal value format). For `elem:'array'` (2-D), rows = outer index, configured columns map to inner positions. Scalars → existing multi-signal behavior.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Multi-LED — array source mode**
|
||||||
|
|
||||||
|
Add an `array` source mode: render one LED per element, lit by element truthiness; LED count tracks array length live; when meta `elem==='bool'`, use the declared on/off labels.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build**
|
||||||
|
|
||||||
|
Run: `make frontend`
|
||||||
|
Expected: builds clean.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/widgets
|
||||||
|
git commit -m "feat(widgets): array source modes for plot, table, multi-LED"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: Manual smoke verification + docs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `TODO.md` (do NOT check the box yet — Phase 2 Go port still pending; add a sub-note that panel-logic arrays are done)
|
||||||
|
- Modify: `docs/TECHNICAL_SPEC.md` (document array statevars + array expression functions + the export change)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Build the whole app**
|
||||||
|
|
||||||
|
Run: `make all`
|
||||||
|
Expected: frontend + backend build clean.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Manual smoke checklist** (run `go run ./cmd/uopi`, open a panel in edit mode → Logic tab)
|
||||||
|
|
||||||
|
- Declare a `capped` array `hist` capacity 5; add a `trigger.timer` → `action.array.push{array:hist, expr:{ds:stub:sine_1hz}}`; in view mode confirm `hist` rings at 5 elements (debug badge shows `[…](n=5)`).
|
||||||
|
- Add `action.write{target: avg, expr: mean(hist)}` (scalar local `avg`); confirm it tracks.
|
||||||
|
- Indexing: `action.write{target: last, expr: hist[-1]}` updates to the newest sample.
|
||||||
|
- `fixed` array `grid` capacity 4 initial `[0,0,0,0]`; `action.array.set{array:grid, index:"2", expr:42}`; confirm element 2 = 42 and length stays 4.
|
||||||
|
- Save the panel, reload it, confirm the array statevars + nodes persist (Task 6).
|
||||||
|
- Legacy: open/confirm an existing panel using `action.accumulate`/`action.export` still records and exports CSV (now index-aligned; header uses custom labels).
|
||||||
|
- Widgets: bind `hist` to a plot (waveform), a table (one row per element), and a multi-LED (one LED per element) and confirm they render and update live.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit docs**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add TODO.md docs/TECHNICAL_SPEC.md
|
||||||
|
git commit -m "docs(logic): document panel-logic array locals"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Notes (spec coverage)
|
||||||
|
|
||||||
|
- Data model (spec §2) → Task 1 + Task 2 (`arraypolicy`).
|
||||||
|
- Expression engine (spec §3) → Task 3 (all functions, indexing, literals, negative index, errors, ref-collection).
|
||||||
|
- Mutation nodes + accumulate/export unification + migration (spec §4) → Task 5.
|
||||||
|
- Persistence panel XML (spec §5) → Task 6. (Control-logic Go persistence = Phase 2.)
|
||||||
|
- Editor UI + debug badges (spec §6, panel side) → Task 7. (Control editor = Phase 2.)
|
||||||
|
- Widgets (spec §7) → Task 8.
|
||||||
|
- Testing (spec §8): TS has no runner → manual smoke (Task 9); the authoritative automated suite + cross-engine parity table is **Phase 2 (Go)**.
|
||||||
|
|
||||||
|
**Deferred to Phase 2 (separate plan):** all of `internal/controllogic` (boxed `value` in `expr.go`, `Graph.StateVars`, `getLocal`/`setLocal` policy, JSON round-trip, config-apply/snapshot boxing), `ControlLogicEditor.tsx` LocalVars parity, the Go debug-event array payload, and the full Go test + parity suite.
|
||||||
Reference in New Issue
Block a user