Files
uopi/docs/superpowers/plans/2026-06-24-local-arrays-panel-logic.md
T
2026-06-24 10:40:36 +02:00

33 KiB

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.tsnew: 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.tsxLocalVars 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:

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
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

// 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
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): numberkept 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:

  | { 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:

    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:

  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.

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
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:

      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
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':

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
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
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:

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:

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:

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:

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] ?? '':

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:

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
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:

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:

  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
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:

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
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
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.timeraction.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

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.