Skip to content

Bring main up to date with the NodeGX fork (alpha launch work) - #19

Merged
richardosborne14 merged 1591 commits into
mainfrom
cline-dev
Aug 7, 2026
Merged

Bring main up to date with the NodeGX fork (alpha launch work)#19
richardosborne14 merged 1591 commits into
mainfrom
cline-dev

Conversation

@richardosborne14

Copy link
Copy Markdown

Summary

main has been the stock pre-fork Noodl checkout since 2025-09-09 while all rebrand and alpha-launch work (phases 14.5 through 33) happened on cline-dev. This PR brings main in line with reality: the rebrand to NodeGX, the alpha-launch legal/docs/report-a-problem/release infrastructure, and everything else built since the fork.

Surfaced this session while investigating why GitHub's issue-template prefill (ALPHA-007 criterion 3) rendered a completely blank form: main has no .github/ISSUE_TEMPLATE/bug_report.yml at all (GitHub resolves issue templates from the default branch only), and also lacks release.yml, PRIVACY.md, TERMS.md, and docs-site/.

Test plan

  • Required status checks pass (Typecheck, Lint, Test (editor), Test (platform-node), Build, Check build artefacts)
  • Merge only after explicit confirmation — this replaces the entire default branch content

🤖 Generated with Claude Code

richardosborne14 and others added 30 commits August 5, 2026 21:44
…an see

TALK-002 had: no editor switch. The premise held under re-verification — ten
@codemirror/* packages, one EditorView, a stock extension list — so FH-017 is
the whole answer, and it is mostly deletion.

Slice 1. `Noodl.` completes while typing. The guard `word.from === word.to &&
!context.explicit` is true of every position after a dot, so the `Noodl.` branch
below it was unreachable except by Ctrl-Space. CED-001's QA only ever typed
`Noodl.V`, where the word is one character long — which is why nobody saw it.
The distinction the guard wanted was member position vs top level, now in
utils/completionPosition.ts and shared with the library twin.

Slices 2+3. One error system. jsValidator.ts ran `new Function()` synchronously
on every keystroke and could say "✗ Error" over text with no squiggle in it; it
is deleted, along with the panel that could only ever render one problem.
Diagnostics come from ESLint (Richard's call: take the dependency rather than
hand-roll parse-error anchoring), the toolbar reads the same lint state through
`forEachDiagnostic`, and the verdict is now a button onto CodeMirror's own
diagnostics panel — whose keybinding has been in our keymap since CED-001 with
zero callers.

Measured before building on it: an unterminated block is reported at end-of-input
by acorn exactly as by Lezer — no parser points at the opening brace, and syntax
errors are fatal-and-first. The value is semantic: `no-undef` catches `totl` at
its column, `no-dupe-keys` reports twice in one pass. `no-undef` is off in
expression mode, where an undeclared identifier *becomes an input port*, and a
warning elsewhere until FH-019 threads project globals in.

Slice 4. The gutter dot opens the panel. `EditorView.domEventHandlers` registers
on the content element, and gutters are siblings of it — the obvious
implementation typechecked, read correctly and did nothing. Only driving it
caught that.

Slice 5. `dark: true` was hardcoded, so CodeMirror's base dark selection (#233)
painted behind light-mode syntax colours. The flag now follows the document root
and reconfigures through a compartment on a live editor. The corrected flag was
not enough: CM's 4-class focused-selection rule out-specifies a 2-class one, and
measurement showed our token had never reached the selection in dark mode either.
Light 5.48:1, dark 4.95:1. POL-017 folded in: gutter numbers 4.67:1 / 4.82:1.

Driven live in both themes through the real popout. Editor suite 2202 specs,
0 failures; core-ui jest 148; root typecheck clean.

package-lock.json is deliberately not in this commit — it carries another
session's pruning, and CI runs `npm install`, not `npm ci`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ace has no owners

Alpha, not fast-follow. Record leaves the Provenance panel for a canvas HUD;
badges scoped to the open canvas with an honest app-wide counter; Q4 goes the
expensive way (per-peer trace ownership). Four tasks, all gated on FH-011.

Three of this doc's own premises were wrong or missing:

- the execution-overlay slot is already owned by ExecutionOverlay, so the HUD
  needs its own layer;
- trace events are `from`/`to`, not `fromNode`/`toNode`;
- Q4 has no signal at all — the relay forwards verbatim and NEITHER editor peer
  registers a clientId, so there is no identity to own a switch with.

And the Q4 research turned up live data loss: setTraceEnabled(true) replaces the
buffer unconditionally, so an agent's start_trace destroys a recording a human
is in the middle of.

FH-011 slice 2 amended: the poll moves into TraceSession. A sidebar panel is not
constructed until it is first opened, so with Record on the canvas a whole
session can be recorded with the panel never existing — and a poll living there
would never run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…remembered

Two probes through the real BackendService, then deleted:

  - A JavaScript Function node inside a cloud function reaches process (79
    env keys, cwd, exit), Buffer, crypto.subtle, randomUUID, global fetch,
    FormData, Blob, URL, structuredClone, setInterval. It does NOT reach
    require. The script is compiled with `new AsyncFunction`, whose body runs
    in global scope, and the CloudRunner runs in the backend's own process —
    sandbox.isolate.js, which blocks require and bridges fetch, is Parse-era
    dead code referenced by nothing.
  - A REST2 node in a cloud function made a real GET against a live upstream.

So hashing, JWT, base64, date maths, CSV parsing and third-party API calls are
all writable in a function today: the wish list is a node-and-door question,
never a runtime one. §6 is annotated for all 14 prompts rather than left blank.

CWF-003's first check item ("establish which fetch the node gets") is answered
and struck. One trap recorded: a REST node's outputs are minted by its Response
script at run time, so a pm- wire drawn at export time is dropped — the probe
returned 200 with an empty body while the upstream call had succeeded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…expires

Settled: a "Connect an AI agent" section in Editor Settings beside the AI one,
two captioned Copy buttons (the phase-36 merge question stays closed), and Q3
resolves to (a) — the editor creates the project, the agent builds in it.

Verifying the doc found three things its own research did not. nodegx-observe
never reconnects (relayClient.ts:129-171 puts every handler behind
`if (settled) return`) and the relay token is minted per editor launch, so a
copied command dies at the next restart and every tool then throws
"Not connected to the NodeGX relay." forever. The proposed commands hard-code
the server name, so a second project overwrites the first. And @noodl/observe
is in no gate — test:packages scopes @noodl/mcp and not it.

Q3's premise was also inverted: TAB-006 records that tabs affect nodegx-observe
(bound to a port) and explicitly not noodl-mcp (bound to the path in argv,
"none — it works unchanged"), because ProjectStore is constructed once at spawn
and nothing rebinds it. So list_projects is observe-side discovery, and the
path in the command already answers "which project". Registrations are now
named after the project — slugging the directory basename, because
ProjectModel.name is optional and falls back to 'Untitled'.

Four tasks, ordered so the door works before it opens: MCP-003 (reconnect,
gates the observe button) → MCP-002 (ship both .cjs via extraResources, on the
exact nodegx-backend precedent) → MCP-001 (the section) → MCP-004 (the page,
which belongs in the docs repo — docs/ here is reference material, not the
user-facing tree).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…WebSocket to bind to

Q1 yes: build the standalone node on the BCN-008 contract; Query Records'
checkbox stays as the query-refreshing form. FH-021 written, unblocked.

Four corrections the research forced on the doc's own proposals:

- It asked for `realtimeSupportFor`-driven port gating. That is the exact
  opposite of what shipped — the checkbox's port is deliberately never gated,
  because a capability that vanishes when the picker moves is worse than one
  that says why it cannot connect. The new node copies the shipped pattern.
- The filter reaches a server in one dialect of five (`NODEGX_SSE` alone sets
  `subscription.filter`). Shipped anyway on Richard's call, disclosed — never
  as a client-side fallback, which would be a fourth filter twin.
- The fold comment argues *against* a standalone node ("a subscription without
  a query is a stream of ids nobody can render"). Weaker since the fold added
  the record outputs, still true for a Directus delete. Missed counterweight:
  `ssr: { compat: 'client-only' }` still works, so the standalone node is
  strictly simpler on SSR than the checkbox was.
- Q2 was understated. Our backend has no WebSocket server at all — no upgrade
  handling, no `ws` dep, SSE `/realtime` only per BAK-001. A "connect to my
  backend" mode was unbuildable for the one backend Richard wanted it for.
  The `_active_` picker default is what makes ours the easy path instead.

Also closes CWF-007 Q3: two deliberate nodes sharing `RealtimeSubscription`
but not a node definition — different payloads, different auth postures.
Recorded as a decision, not a deferral, so the pair is not later collapsed.

Numbered FH-021: a parallel session claimed FH-020 for the Ports tab.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ir own

Every count re-derived from the generated catalog rather than the prose that
produced it: 82 adopters, 34 with Unchanged, 48 without, 8 with neither, 4 with
Treat Unchanged as. All held. Three of the doc's own claims did not.

- Pruning Done from the 8 would have restored nothing: the proxy ERG-001 killed
  reads COMPLETION_SIGNAL_NAMES, which contains 'completed', the one port with
  no exemption.
- "Build the canvas port hover" is not a rendering gap. There is no per-port
  hit-testing on the canvas at all; the only hover is node-level. Meanwhile the
  connection popup already has one, and ConnectionBar._getPorts hand-copies
  eight fields and drops `description` one function before the component that
  could render it.
- Q3 was two questions. displayName: 'Done' is set in exactly one place, so a
  display-only rename was one line; the expensive one is the internal name,
  which lives in every saved project's connections.

Decisions: keep both ports on all 82 and have outcomeOutputs say when Completed
is redundant (FH-022); no rename; and the surface is a read-only Ports tab in
the property panel (FH-020), because the connection popup filters by what you
dragged from — right for wiring, useless for discovery. Both mechanisms the tab
needs already exist and neither is where it needs to be: the tab strip is in
this panel on the AI path only, and the connected-source chip is on 5 row
classes of ~29 and reads inputs only.

Counter.Reset rides along in FH-022 slice 3, with the warning the original note
lacked: the obvious repair is also wrong, because the guard compares against 0
and Reset sets the count to startValue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…07 Pile B)

Richard's call, 2026-08-05: the nine browser-state nodes AIX-005 leaked into the
cloud vocabulary go. Global Store x3, Optimistic Update, State History,
Undo/Redo, State Snapshot, Action Dispatcher, Action Handler — all model client
session state, and a cloud function is one request that answers once and is torn
down. The Streaming six stay: calling an upstream streaming API server-side is
CWF-007's whole case.

The mechanism is the point. `registerNodes` reached EVERY runtime and the viewer
could only add to it, so a browser node put in the shared list silently became
part of the cloud function vocabulary with nobody choosing it. There is now a
`type !== 'cloud'` block at the foot of that function — the subtraction that did
not exist — and adding to it is the documented way to say "browser only".

Component Children is the tenth and works differently: it is seeded into the
export by hand, because it has no node definition at all — it is a marker
NodeScope interprets structurally. `generateNodeLibrary` takes an optional
`{ runtimeType }`, passed from `getNodeLibrary` (live clients) and from the
snapshot generator (the committed file), so both describe the same vocabulary.
Nothing behavioural changes: a graph carrying the marker still works.

Measured, not assumed: 58 -> 48 types, removals exactly the ten, every surviving
type byte-identical, typecasts/dynamicports/colors/nodeIndex untouched. Nothing
in nodegx-backend or noodl-viewer-cloud referenced any of the nine.

The node catalog carries the change as `availableIn: ["browser"]` for the nine.
Regenerating it also picks up two `prop-*` dynamic-port entries on Create/Update
Record that were already stale in HEAD, from the phase-40 opts-guard fix.

Gates: cloud-library:check, catalog:check, catalog:merge:check,
catalog:encoding:selftest, typecheck:{runtime,cloud,viewer,editor}, and the
runtime (2144), viewer (853), backend (743), mcp (161), backend-contract (199)
suites. The two email-flows Send Email timeouts are PRE-EXISTING — verified by
running that suite with these files reverted to HEAD.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… validated

Richard walked the node list. The validation found more than the list did: five
of the six Tier-1 additions he asked for were ALREADY registered — the object
trio, Expression/Function/Logic Builder, all 11 data nodes, and Run Tasks. The
cloud picker's contents are not legible from the picker, which is the same
finding as TALK-007 §2 in a different costume.

Two were shaped differently than assumed, and both are recorded rather than
quietly implemented:

  - Run Tasks does NOT run another cloud function. `taskTemplate` is
    type: 'component' and the picker deliberately excludes cloud functions —
    "don't allow references to cloud functions, only cloud components". It runs
    a cloud helper component per item. Left open for Richard; my read is that
    "run this function per item" is a workflow For Each.
  - The Cloud Function node is not a free move: cloudfunction2.ts uses
    XMLHttpRequest and reads the browser session store for its token. Needs the
    typeof-window branch restnode already carries, plus an answer to what
    identity a server-to-server call carries — the same hole as the workflow
    join, and it should get one answer, not two.

Value Changed struck (a one-pass runtime has no "last time"). Component Object
added: per-instance state in a runtime where the instance IS the request.

Ten tasks: CWF-008 the vocabulary moves, 009 Secret, 010 crypto/JWT, 011 date,
012 CSV, 013 Log, 014 typed Request bodies, 015 server-side users, 016
idempotency, 017 access rule + per-function rate limit. Four rows deliberately
have no task, with reasons recorded: scheduling, queue, PDF, payments.

Every file:line link in the ten resolves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… action colour

FH-009. `ListItem`'s active variant painted the row with
`--theme-color-secondary` — the neutral ACTION colour, which is inverted
relative to the surface by construction (#18212b near-black on light,
#eef2f6 near-white on dark). The icon got a half-correction
(`--theme-color-on-secondary`) but the label kept `TextType.Default`, so the
selected row was dark-on-dark in light mode and light-on-light in dark mode.
Measured, that label was 2.17:1 (light) and 1.95:1 (dark) — below even the
3:1 non-text floor.

The active row now takes a real surface pair: `--theme-color-primary-bg`
plus `TextType.Proud` (`--theme-color-fg-highlight`). Measured over the
plausible parent surfaces bg-0…bg-3:

  light: label 12.69–14.43:1, secondary fg-default text 5.85–6.65:1
  dark:  label 10.49–14.42:1, secondary fg-default text 5.37–7.38:1

The tint alone is only a ~1.12:1 step off the panel, so an inset 2px left
rule in `--theme-color-primary` carries the "selected" signal (3.57–4.06:1
light, 4.49–6.18:1 dark against the tinted row — clears the 3:1 non-text
threshold). The prefix icon takes the same primary. Hover on an active row
goes to `--theme-color-primary-25` (label still 11.0–12.4:1 light,
8.4–11.4:1 dark).

The `!important`s stay: `.is-active` (bg-1) and `.is-variant-active` have
equal specificity and the variant wins on source order either way, but the
flags are also beating panel-level rules outside this file, so dropping them
would be an unmeasured change.

All three call sites — DocsPanel.tsx:277, DiffList.tsx:141 and :165 — are
fixed by this one component; neither panel's own source is touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Richard's scenario, driven end to end: a cloud function takes {users: [...]},
Run Tasks iterates it, a cloud HELPER COMPONENT runs per item with that item's
own data, and the function answers when the run is Done. Concurrency honoured,
empty list handled. Kept as a spec because "a registered node is not a working
node" is exactly the claim TALK-007 exists to stop people asserting — and
because it guards CWF-008's vocabulary moves.

So there is nothing to build for the loop. What is missing is the NAME (nobody
searching for a loop searches for "Run Tasks") and any sign that the per-item
unit is a helper component, not a cloud function — the picker's refusal of
functions is correct and silent. CWF-008 slice 4b.

Two things cost a wrong conclusion while writing the fixture, both recorded in
the spec's header:

  - A component's ports come from the COMPONENT-level `ports` array
    (componentmodel.ts:456-463), not from the Component Inputs/Outputs nodes'
    own `ports`. With them on the nodes, Run Tasks correctly reported
    run-tasks/no-completion-output.
  - That failure then HUNG THE REQUEST instead of answering. CloudRunner.run
    settles only when a Response node fires, and POST /functions/:name awaits it
    with no timeout — while workflows have had per-step and per-run timeouts all
    along. Wiring only the happy path off an outcome node is enough. It leaks the
    component instance and its model scope too, because teardown lives in the
    same callback as resolve. Filed as CWF-018, not fixed.

Also settled: the Script node stays out of the cloud for now (Richard).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… one-unit ports stop pretending to be a menu

FH-014. `PropertyPanelSelectInput` filtered the current value out of its own
option list (`if (option.value === value) return null`). For a multi-option
select that just meant the value you were looking at was missing from the
list. For the ~27 unit-bearing ports that declare exactly one unit (21x
['px'], 4x ['%'], 1x ['deg'] — Font Size, the four Paddings, Border Width,
the four Shadow numbers, Spacing, Size, Rotation) it meant the list was
empty: `NumberUnitInput` builds a one-option list, that option equals the
current unit, the filter deleted it, and what opened was the Options card's
border plus 2px of padding at the trigger's 40px width — a ~40x6px empty
box. Multi-unit ports (Width/Height, Margins) worked, which is why it looked
random.

Two changes:

(a) The selected option renders, marked with the same selection pair the
    active list row uses — `--theme-color-primary-bg` + `fg-highlight`,
    measured 14.43:1 on light bg-1 and 13.24:1 on dark bg-1 (the Options card
    is bg-1). It also carries `aria-selected`. Clicking it closes the popup
    without firing `onChange`, so re-picking the current value no longer
    rewrites the parameter or pushes an undo entry. Every other select in the
    panel — enums, the theme picker, the runtime picker — gains its current
    value back.

(b) `NumberUnitInput` renders a static unit label instead of a select when
    the port declares fewer than two units. A menu with one immutable choice
    is noise. The label uses `fg-default` (6.5-8.3:1 on bg-1..bg-3, both
    themes) rather than `fg-muted`, which only measures 2.7-3.6:1. When a
    single-unit port has no unit string at all, nothing renders and the
    number input takes the full width.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…on, not [object Object]

Setting fx on a label port stores { mode: 'expression', expression, fallback,
version } as the parameter value. BasicNodeType.labelForNode handed that object
straight back — typed TSFixme, so nothing caught it — and the canvas painter
stringified it, so every node whose label port carried an expression was called
"[object Object]" on the card. The property panel looked fine only because
NodeLabel already ran the label through ParameterValueResolver.

The fix is at the one place the label is produced: labelForNode resolves the
parameter to the expression TEXT (Noodl.Variables.foo — the name the author
typed, and now what the panel header shows too, since it reads the same label),
falls back to the display name when the resolved string is empty (an expression
object is truthy even when its expression is not) or when the value is some
other object, and truncates through the existing 'length' branch. That also
disarms the 'filename' branch, whose labelName.split('/') would have thrown on
the first node type to declare it.

Three consequences of the object reaching the view are fixed with it, via a new
NodeGraphEditorNode.labelText() that every title consumer now goes through: the
wrap-height cache was keyed on the label, so every expression-labelled node
shared the key "[object Object]" and its cached height; the label !==
typeDisplayName() tests in NodeGraphEditorNode and the painter can never be
equal for an object, so such nodes always painted a sub-label as if the author
had renamed them; and the comment popup titled itself with the same object.

NodeGraphNode.getParameterDisplayValue, which had zero call sites, is renamed to
getParameterAsString: it resolves to the fallback, the opposite of the
identically-named function in ExpressionParameter that the label path wants.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Block Pointer Events" (blockTouch) works by having pointerProps wrap every
pointer handler in stopPropagation. Button, Slider and the deprecated Button
spread that result and then wrote onClick again from the raw sender — as a
trailing `onClick={props.onClick}` in Button, and as an `inputProps` object
spread after `controlEvents` in Slider. JSX later-wins, so the wrapper was
discarded for the one event authors turn the option on for: mousedown, mouseup
and touchstart were blocked and the click sailed through to the parent Group.
That is the reported "the option is shit and doesn't work".

controlEvents already supplies onClick — pointerProps picks `props.onClick` up
off the props root — so the extra assignment was pure loss. Removing it also
restores the `updateDirtyNodes()` flush that the same wrapper carries, which a
Button click had never been getting.

Regression test drives pointerProps directly and calls Button as the plain
function it is (there is no jsdom jest environment in this repo); it fails on
both counts with the line put back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…placed, and a popout that would hang off an edge now flips

FH-005. Clicking a JSON or code editor button low in the property panel opened
the editor with its top edge at the button's Y, mostly below the fold.

Two stacked causes:

(a) The measurement race. PopupLayer.showPopout measures the content the
    instant it is appended, but CodeEditorType and ListValueEditor render it
    with a React 18 root and no flushSync, so the box was still 0x0. The
    formula then reduces to y = anchor.top, and every viewport clamp is a no-op
    against height 0. Both also pass disableDynamicPositioning: true, so the
    ResizeObserver never revisited the top edge once the content arrived; the
    box simply grew downward from a Y nobody re-evaluated. Five other popouts
    already wrap their render in flushSync for exactly this reason (DEBT-010) —
    these two were missed. Both keep disableDynamicPositioning: their size is
    an inline width/height on the editor root, so one flushed commit is the
    whole box, and re-positioning on every ResizeObserver tick would fight the
    user's own resize handle.

(b) _positionPopout had no flip logic at all — only clamps. It now computes the
    origin for the side it was asked for, and if that side hangs off the edge it
    tries the opposite side, taking it only when it overflows strictly less. A
    popout larger than the window therefore does not flip (both sides are just as
    cut off) and the clamp still has the last word. The arrow follows the side
    actually used, and is recoloured onto the correct border when it flips —
    Popout now carries effectivePosition and arrowColor for that.

disableCentering is marked deprecated: it never centred anything, its only
effect was to skip clearing the arrow's direction class, which the flip needs.
No caller has ever set it.

Verified: npm run typecheck:editor is clean. Placement itself needs eyes on the
running editor — jasmine cannot measure layout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FH-013. Widening the properties panel did nothing for a cut-off label
because the label column was a hard 62px in BOTH label implementations.

A. The React row (PropertyPanelInput.module.scss). `.Label` carried
   `text-overflow: ellipsis` + `overflow: hidden` + `line-clamp: 2` +
   `max-height: 28px` — the unprefixed `line-clamp` does apply on our
   Electron, so line two was clipped by the max-height and line one
   ellipsised. All four are gone; the label is `white-space: normal` with
   `overflow-wrap: anywhere` so long unbroken identifiers break instead of
   setting a min-content width that shoves the input out of the row. The
   `.sidebar-property-editor` override no longer pins 62px: the column is
   `width: 37%` with a 62px floor and a 160px ceiling, so it still starts
   narrow, grows with the panel, and never takes the majority of the row.
   Every row shares the one column, so inputs stay aligned. The toggle-row
   `width: auto` special case is preserved (and now clears the floor/ceiling
   too). Cloud-workflow step rows use this same component, so item 11's
   cut-off step labels are covered.

B. The legacy row (propertyeditor.css). `.property-label` was
   `white-space: nowrap` + ellipsis inside an absolutely-positioned 62px box,
   with `.property-value` pinned at `left: 72px` — this is where "Custom CSS"
   became "Custom C...". The label is now flow text that wraps, and the rows
   that pair a label with a value opt into a new `.property-row` flex row
   (the code-editor property row and the curve editor's Duration/Delay).
   `.property-value` keeps its absolute geometry in the base rule because
   MarginPaddingInput's edit box positions it itself with inline
   left/top/bottom/right — only the `.property-row` scope resets that to
   flow, and it keeps `position: relative` plus a definite 28px height so the
   `position: absolute; width/height: 100%` inputs inside still resolve.
   `.property-label-col` exists because <Tooltip> inserts two divs between
   the row and the label, so the flex item cannot always be the label itself.

The other two `.property-label` consumers (ResizingInput's compact label and
NumberUnitInput's "Fixed") already inline-override position and width, so
they do not double-shift; they now inherit a 1.35 line-height instead of the
old 35px one and centre in their flex rows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ut it in your font picker

Reported as "Roboto Medium is still in there, all by itself, can we remove it?"
Measured against the Puppy test project: its lone text style "Label Small"
points at fonts/Roboto/Roboto-Medium.ttf and its fonts/ holds exactly that one
file, both copied in by an imported Date Picker prefab. Nothing in the editor
seeds Roboto — the seeded prefab/module library did.

The mechanism: the font picker lists every ttf/otf/woff/woff2 file it finds
anywhere under the project directory, grouped by folder (fontItems.ts ->
loadFontItems). Ship one Roboto file in a prefab and importing it creates a
fonts/Roboto group with a single entry, alone. That is the whole report.

Written first, per the phase-39 rule, and red before the fix: library:check
now fails any entry that references a font file it does not ship, or that
ships or names a retired family (the design system is Inter; Roboto is
retired). It was red at 19/58 entries. It had to be widened twice, and each
widening found content the previous pass called clean:

  - simple-tooltips names the family in a JS assignment inside a minified
    bundle, not in CSS. The first regex for that missed it anyway, because
    the family list contains the other quote character ('Roboto, ...,
    "Open Sans", sans-serif') and one capture group plus a backreference
    truncated at it. Each quote style now gets its own group.
  - After the sweep the check went green while Roboto was still sitting in
    four project.json files: font-family: Roboto inside a CSS string in a
    Javascript node's code parameter — a JSON string *value*, invisible to a
    walk that only looks at fontFamily *keys*. .json and .map are now scanned
    as raw text too. That put 5 entries back to red.

Sweep: 19 entries, 46 Roboto TTFs out, 27 Inter TTFs in (only the weights each
entry actually references, so image-cropper drops from 12 to 3), Inter's
LICENSE alongside each as SIL OFL requires, 39 fontFamily path references and
8 bare CSS/JS family names rewritten. Inter came from
packages/noodl-core-ui/src/assets/fonts/Inter/, which was not modified.

Every changed entry's library.json version is bumped, and that bump is the
entire fix for the phase-21 stale-zip trap: the zip filename carries the
version, so date-picker-1.3.0.zip becomes date-picker-1.4.0.zip — a new URL.
A same-URL republish would have silently done nothing for existing users,
because the editor never re-downloads a non-empty cache folder.

library:check is 58/58 and exits 0; library:build and library:verify-dist are
green; a content-level scan of all 58 rebuilt zips finds zero Roboto bytes.
Not verified: anything requiring the editor — no live import was driven.

Filed, not fixed: a bare `font-family: Inter, ...` in an injected CSS string
still does not resolve to the bundled TTF, and never did with Roboto either.
fontloader.ts derives the family from the filename, so Inter-Medium.ttf
registers as "Inter-Medium", not "Inter". Those CSS blocks have always fallen
back to the system font. This change fixes which family is named, not whether
a bare name resolves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Click-through was not a bug in "Block Pointer Events", it was the only
behaviour there was. Every visual node and control gets a live DOM onClick
installed at init whether or not its Click port is wired, and nothing in the
runtime called stopPropagation unless blockTouch was on — so a Favourite button
inside a clickable card always ran the card's Click too.

pointerProps now stops the click when the node's own Click output has
connections, governed by a new Click Bubbling port: Automatic (the default),
Always (the old behaviour, for a project that relied on bubbling) and Never.
The predicate is read when the click happens, not when the element renders,
because wiring a connection in the editor does not re-render the node — a
render-time answer would be stale exactly while an author is testing it.

Only the click is stopped, so the parent's Hover Start and Pointer Down keep
firing; that is what blockTouch cannot do, since it blocks fifteen of the
sixteen pointer events at once. A node with no Click port, or one with nothing
wired to it, is untouched.

Also fixes the blockTouch descriptions, which said "elements underneath this
one" — z-order — while what it stops is the event reaching this element's
ancestors. Catalogs regenerated; both catalog checks green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ts name

Reported in the first-hour pass and decided by Richard on 2026-08-02 (ERG-004
§7.4). `Object Changed` shipped in phase 35 with an `object`-typed input and no
producer anywhere in the node library: the whole Data category names an object by
string id, so the obvious wire — Object.Id into Object Changed.Object — went
through the declared string→object typecast, which `eval`s the id as a JavaScript
literal, throws a ReferenceError on the hyphen, catches it, substitutes `{}` and
leaves the watcher watching nothing for the life of the app. Until now the only
working path was a Script node returning `Noodl.Object.get(id)`.

Model2 gains an `object` output carrying the live Model. It is a getter reading
`_internal.model`, not a setter side effect, because a declared default never runs
its setter and anything produced only when an input arrives is absent on a graph
that never touches that input. `setModel` — the single place the bound object
changes — flags it beside `id`, and before `setModelID` announces Fetched, so the
value cannot arrive behind the signal that describes it.

It emits `null`, never `undefined`, when nothing is bound. `Node.sendValue`
returns early on undefined, so an unbound Object emitting undefined would send
nothing at all and a downstream Object Changed would go on watching the object it
was given before, forever, with no way to be told the binding was cleared.

Slice 0 verified first, as the task required, and it is why Collection2 gained
nothing: `Array.Items` already serves `Array Changed`. `items` returns the live
Collection proxy, `Array.isArray` is true through it and `on`/`off` come from
`Array.prototype`, so `isWatchableArray` accepts it — measured end to end in row
FH004-5, adding and removing an item and asserting Index and Count.

The typecast is deliberately untouched: `string → object` still silently accepts
the wrong wiring, which is the blast radius Richard accepted and its own
conversation. Row FH004-3 records that behaviour rather than asserting it is right.

node-catalog.json needed no change here — a concurrent session regenerated it and
swept this port into f35601c before this commit was made. cloud-node-library.json
is regenerated here; catalog:check, cloud-library:check and catalog:merge:check are
all green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…to that workflow, and the node picker draws over the pin

FH-012. Two independent defects behind one report.

(1) The pin captured its identity from the OPEN CANVAS. POL-009 hid a pin on
    any canvas but its own, and its slice-2 warning — put an identity on the
    pin EVENT rather than reading one off the viewport — was not honoured. So
    pinning workflow B's run while A was open tagged the pin as A's: it drew
    over A, where no step resolves (the "Nothing to show on this graph"
    notice), and vanished the moment you opened B. Pinned to the wrong canvas
    and invisible where it belonged.

    `execution:pinToCanvas` now carries `componentName`, and the Execution
    History panel derives it from the RUN — `workflowComponentName(workflowId)`,
    the same `/#__workflow__/<id>` fullName the guard compares — after taking
    the canvas there with `WorkflowEditorService.open`. Navigation is awaited
    before the event goes out, so no pin flashes on the canvas being left.

    Only a run with `metadata.kind === 'workflow'` navigates. `WorkflowEngine`
    is the sole writer of that key and the sole thing with a canvas to go to; a
    cloud function CALL is logged by `WorkflowRunner.run` as
    `workflowId = functionName` with no kind, and a rejected trigger has a
    workflow id but never ran. Those, and a workflow whose backend will not
    answer, emit no identity and fall back to the old behaviour — the pin lands
    on the canvas in front of you, where the overlay's own notice explains
    itself. The Workflows panel's run-and-pin is untouched for the same reason:
    it is already standing on the right canvas.

(2) The overlay outranked every popup, not just the picker. Its header, notice
    and timeline are `z-index: 200`, and nothing between them and <body> was a
    stacking context — not the clipping wrapper (positioned, z-index auto), not
    the shell root (unpositioned). Those 200s were therefore competing directly
    with `.popup-layer` (z-index 10, appended to <body> by router.tsx) and
    winning, so the pin bars drew over the node picker AND over its dimmed
    backdrop.

    The execution layer's clipping wrapper now carries `z-index: 5`. That makes
    it a stacking context, so all three 200s — and the badges and the data
    popup with them — resolve inside it and the layer competes as one number: 5
    beats everything in the shell that paints at auto and would otherwise cover
    the bars by document order (#nodegraph-dom-layer, .canvas-hud-root,
    .nodegraph-component-trail-root under the timeline, #comment-layer-fg,
    .help-center-layer), and loses to 10. `.popup-layer` is NOT bumped to 201:
    that would fix the instance and leave the class. #canvas-tabs-root (100),
    #editor-banner-root (1001) and HighlightOverlay's 999/1000/1001 still
    escape to the body-level context and are filed, not fixed here; the tabs
    root is empty unless a Logic Builder tab is open, and the highlight overlay
    already outranked the pin bars.

Verified: `npm run typecheck:editor` and `npm run typecheck:editor-tests` are
both clean. Navigation and stacking are invisible to jasmine — both halves need
eyes on the running editor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… not only where it overlaps the wire

CAN-001 built the whole label drag — bounds cache, `isPointInLabel`, clamped
`labelT`, one undo entry, persisted — and then gated it on the wire-stroke
hover, which is the exact ordering its own spec warned would "ship an
infuriating regression".

Two mechanisms, both in `NodeGraphEditorConnection.mouse`:

- The `move` path decided the hover with `isPointInStroke` alone (±5 graph
  units of the curve). So the grabbable part of a chip was the *intersection*
  of chip and stroke — for a two- or three-line label on a sloped wire, the
  middle quarter, with both wide ends dead. Worse, stepping onto a wide end
  left the stroke band and *cleared* the highlight, so the chip vanished from
  under the cursor mid-reach. `isPointInLabel` is now asked first, and
  independently.

- The `down` path was gated on `highlightedConnection`, and only the stroke
  hover ever sets that. Every chip lit by something else — an author's label,
  a `connectionLabel` port type, the always-on setting, or either endpoint
  node being hovered or selected — was visible and completely inert. The chip
  test is hoisted above that gate: a press on a painted chip belongs to its
  wire however the chip became visible. Double-click-to-edit comes along for
  the ride.

Plus the affordance it never had. `InteractionController.setCursor` is now the
single writer for the canvas cursor — `grab` over a chip, `grabbing` for the
length of the drag, back to `grab` on release — and it has to live on the
controller rather than the connection, because a node card consumes every
`move` it contains, so a connection that set the cursor itself would have no
event left to unset it with.

And the debug inspector no longer opens on top of the target: it is a DOM
element attached to a point on the curve under a
`translate(-50%, calc(-100% - 4px))`, so its bottom edge lands 4px above that
point — the middle of the chip — where it covers the chip's top half and eats
the press. `InspectorActions.setHighlightedConnection` skips it while the
pointer is on a chip, and drops an unpinned one already showing.

`tests/canvas/WireLabels.test.ts` had no drag or hit-region test at all —
nothing could have caught any of this. It gains 18: the geometry of a wide
chip on a 45° wire (the corner is on the chip and 14 units off the stroke),
the highlight not being cleared by stepping onto the chip, a grab from a
selection-lit chip, no grab where no chip was painted, read-only refusal, the
cursor states, and the drag itself — clamped, one `updateConnection`, rewound
to the pre-drag value first, and nothing pushed when it ends where it started.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FH-008. The panel never stole focus — the property editor does, by design.
Explain then paid for it twice.

1. `EditorEventBindings` deselects every node on `SidebarModelEvent.activeChanged`
   for any panel outside a two-entry allow-list, so *opening* Explain destroyed
   the selection it was opened to explain. The allow-list is now
   `panelHoldsCanvasSelection()` — a function, not an exported array, because
   `ExplainPanel_ID` comes back through a module cycle and a top-level array
   would capture it at init. `EditorDocument` held a verbatim twin of the same
   two ids for the detached viewer's inspect highlight; it calls the shared
   predicate now, so the highlight and the canvas cannot disagree.

2. The panel read the selection once, at mount. Sidebar panels are hidden behind
   `display: none` and never unmounted, and are not constructed until first
   opened — so that one read answered forever. `useCanvasSelection` now takes
   whether it is the active panel (`useIsActivePanel`, the hook that existed for
   exactly this and was unused) and re-reads on every transition into active. It
   also re-reads on mouse-up while visible, because a marquee changes the
   selection without touching the sidebar and the canvas emits nothing for it; a
   value compare keeps a gesture that changed nothing from re-rendering.

3. The remembered target could outlive the selection it shadowed: clicking empty
   canvas left the panel offering "Explain this node" for a node nobody was
   pointing at. `SelectionActions.deselect` drops it now, excluding read-only
   editors for the same reason it leaves the sidebar alone. `forgetTarget` went
   quiet when there is nothing to forget — it is now on every canvas mouse-up
   that lands on nothing.

Multi-select needs no memo any more: with (1) a marquee selection is still live
when the panel opens, so it reads three nodes as three nodes.

`tests/nodegraph/explain-selection.spec.ts` (jasmine, 11 specs) pins the
allow-list, the memo's remember/forget/navigate-away paths, and that deselect
drops the memo on the app's canvas but not in a read-only editor. The
re-read-on-visible is a React effect on a `display: none` panel — only the
running editor can show that one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…as Done

TALK-006 decision 1, had 2026-08-05. On eight of the 82 outcome-contract nodes
there is no Unchanged port and no Failure port, so Done and Completed fire on the
same path every time with nothing between them. Richard hit it on Condition — a
Logic node, one of the first anyone wires — and read two identically-behaving
ports as the vocabulary doubling up. It is not doubling up; it is the contract's
universality landing on a node with one outcome, which is the price of Completed
being the one port with no exemption.

Both ports stay and neither is renamed. What was missing is that nothing told the
author the two are the same on this node: `done` carries 77 distinct descriptions
across its 82 nodes and `completed` carried exactly one. So on those eight an
author read two different sentences describing one pulse. Completed now says it
has no other outcome and always fires together with Done. The other 74 are
byte-identical to before.

⚠️ The rule is NOT derived from OutcomePortOptions, and FH-022 specified that it
should be. Measured: the options object and the port set already disagree on four
nodes. expression.ts, modelcrudbase.ts's addFailure mixin, componentutils/base.ts's
canFailToResolve and variablenode2.ts each declare `failure` beside outcomeOutputs
rather than through it, and three of those really do report a failure outcome — so
an options-derived branch printed "it always fires together with Done" on
Expression, Set Object Properties, Variable and Set Parent Component Object
Properties, where it is false. That was caught by regenerating the catalog and
seeing twelve nodes change instead of eight.

So the branch reads the assembled outputs, in defineNode, which cannot disagree
with the port set because it is the port set — and which reaches the one divergent
caller that lives in a package outcomeOutputs' options cannot. The wording and the
predicate stay in outcome.ts; defineNode calls one function; zero node definitions
were edited, which is the whole reason outcome.ts exists. It is guarded on the
exact generic sentence, so a node that hand-rolls a port called `completed` with a
different meaning — GlobalStore.Set and ActionDispatcher both did before ERG-001 —
is left alone, and it is idempotent because defineNode runs twice on a module
object whenever two graphs register the same node.

The corpus rows pin the rule against port sets, never against a list of eight type
names: the eight are a consequence, and a node gaining a Failure port later must
lose the sentence with nothing to edit. FH022-3 derives the population from the
generated catalog, so it doubles as a staleness check on it.

Snapshots regenerated, and all three gates run explicitly rather than inferred:
catalog:check green, cloud-library:check green (3 of the 8 are in the cloud
library — NewModel, Condition, Unique Id, exactly as predicted), catalog:merge:check
green after catalog:merge, which a changed description makes a changed merge input.
Runtime suite 2158 passed, typecheck:runtime clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ved the count

FH-022 slice 3, shipped alone because it is a behaviour change and not a rename.

The guard in Reset has never once fired. The count lives at
`_internal.currentValue`; the guard read `this.currentValue`, which is `undefined`
on every pass, so the early return was dead code from the day it was written
(PLAT-003 NOTES §25). ERG-001 §4 kept it verbatim on purpose and said why in the
code: repairing it changes when Count Changed fires, and that had no business
riding along inside a contract adoption.

⚠️ The obvious repair is also wrong, and R-3 is the row that holds the line.
Pointing the read at `_internal` while leaving the comparison against `0` would
report Unchanged for "the count is zero" — a different condition, and wrong on
every counter that starts anywhere else. Reset sets the count to
`_internal.startValue`, so the post-condition that already holds is
`currentValue === startValue`, and that is what is now tested. A counter walked
down to zero with Start Value 5 still reports Done and still moves.

What changes for a running project: Count Changed stops firing on a Reset that
changes nothing, and Reset starts emitting an outcome the node has never emitted.
Both are what the contract says should happen, and both revert with this one
commit if a project depended on the old pulse.

⚠️ One premise in the task doc is wrong and is recorded in the suite rather than
quietly worked around. FH-022 says "Counter already has an unchanged port … so no
port set changes and Treat Unchanged as already applies". The first half is true;
the second is not, and it is not a Counter quirk. counter.ts spreads
outcomeOutputs and never outcomeInputs, so the setting has no port here at all —
and measured from the generated catalog, 30 of the 34 nodes with an Unchanged
output carry no treatUnchangedAs input. ERG-001 §3's escape hatch reaches four
nodes. Recorded, not fixed: closing it would change the port set on the one node
this behaviour-change commit touches, which is exactly what shipping the slice
alone is meant to avoid. R-4 goes red the day somebody closes the gap.

Unchanged's own description now covers both ways to reach it. Catalog, cloud node
library and enriched catalog regenerated — Counter is the only node that moves in
any of the three — and catalog:check, cloud-library:check and catalog:merge:check
were each run explicitly and are green. Runtime suite 2164 passed,
typecheck:runtime clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ing with it

The relay token is minted per editor launch and RelayClient connected exactly
once: every handler was guarded by `if (settled) return`, so a close after the
initial promise did nothing at all. Quit the editor, start it again, and every
one of the nine tools threw "Not connected to the NodeGX relay." forever — with
no hint that the cause was the restart, or that the server would have to be
restarted too.

It now reconnects on any post-settle close, re-reading the token through the
same --token / $NODEGX_RELAY_TOKEN / file precedence cli.ts used at startup, on
a 500ms→10s backoff floored at the 250ms accept grace (there is no `registered`
ack for an editor peer, so a faster retry would race its own success
detection). Startup still refuses when the editor is absent — only an
already-running server recovers.

State a reconnect must not carry over, because it all describes a process that
no longer exists: viewerClients, topology, the event buffer and highestSeq. The
last one is the quiet failure — a fresh runtime builds a fresh TraceBuffer whose
nextSeq starts at 1, so a retained high-water mark would discard the entire new
session while the tools reported a healthy connection. The one thing kept is
`recording`, which is what this server was asked to do rather than something the
app told us, and is re-armed on reconnect.

And @noodl/observe joins test:packages — the server we are about to point every
new user at had a suite nothing ran.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… the editor instead of hiding inside the panel
… a node means

FH-020, out of TALK-006 decision 2 (had 2026-08-05). A signal port has no property
row by design and the canvas has no per-port hover, so the ~1045 output and ~1656
input descriptions ERG-004 restored into the node library had, until now, nowhere
to appear at all. The one place a port's meaning was legible was the connection
popup — which filters by what you dragged from, correct for wiring and useless for
discovery: you never learn a node's full output surface unless you happen to drag
at something that accepts everything.

The new tab lists every input and every output of the selected node, whatever the
port is: signals, connection-only ports, and outputs, none of which have ever had
a row. Each one shows its display name, its type with enum values spelled out, its
own description as text rather than as a hover, what it can connect to, and every
wire attached to it as a chip that selects the node at the other end. Outputs list
all their targets individually — "where does Done go" is the question the tab is
for, and one label plus a count does not answer it.

Mostly promotion, not invention. The tab strip was already in this panel and was
reachable only with the AI assistant on; the connected-source chip already existed
and was on 5 property-row classes of ~29, inputs only.

Three things the task doc specified were wrong:

⚠️ "Filter nothing" drops one filter too many. `conditionalports/*` is a filter
over statically declared ports, so a port whose condition is false is still in
getPorts() while not being on the node. The tab makes the same
applyPortConditionsFilterForNode call the property panel makes.

⚠️ "What it accepts" cannot come from canCastPortTypes alone. The cast table
declares signal -> boolean and signal -> number; the connection popup then refuses
those wires with a rule of its own that is nowhere in the table. Built from the
table alone, the tab would have said Done can drive a Number input — the exact
wrong answer it exists to stop. Both halves now live in one pure module, with the
non-symmetry preserved: the block is on signal sources, so a Boolean output can
still pulse a signal input.

⚠️ The selected tab cannot be useState. createPanel builds a NEW function
component on every node selection, so React unmounts and remounts the whole panel
each time you click a node — the tab would reset on every click, and not resetting
is what makes walking chip to chip work.

Read-only throughout: nothing writes a parameter, adds a connection or dirties the
project. The only interaction is selectNode, which moves the canvas selection.

Not driven live — a dev launch rewrites the example project and this checkout is
shared. Whoever drives it must relaunch rather than rely on HMR; this panel is
long-lived and mounted, and ERG-004 lost a restart to exactly that.
…often

Every cloud function already had an access rule and nobody could see it: with
nothing configured, the graph's Allow Unauthenticated port decides, and the only
way to say otherwise was to hand-edit security.json on the server.

The backend now answers 'who may call this' as one question. GET
/admin/permissions/functions lists every function with the EFFECTIVE rule -
resolved by the same function the gate calls, so a reader and the enforcement
cannot disagree - plus where the rule came from, functions deployed but
unconfigured, and rules guarding names nothing serves. PUT/DELETE set it and put
it back, both audited, both refusing a body they would otherwise ignore.

A function can also carry its own request budget, on top of the shared one for
all functions rather than instead of it, so a number here can only ever tighten.

No default changes: an undeclared function behaves exactly as it did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d function

Every function gets a row with the rule that would actually decide a call, and
where it came from - a function nobody has configured says 'from the graph', not
a blank. Pick Anyone, Signed-in, Nobody, or back to the graph; type an exact rule
for roles and the OR form; give one function its own request budget.

The panel never works the rule out for itself. It asks the backend for the
effective answer, so what is on screen is what the gate applies.

It also names the one pair that genuinely contradicts: a rule that lets anonymous
callers through while the function's Request node does not have Allow
Unauthenticated ticked. That call reaches the graph and fails inside it, and the
row says so and what to do about it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The workflow-step trap said the opposite of what happens: a call-function step
runs the function in process, so no call rule applies to it at all, and the thing
that fails a step is the Request node's own port - which no rule in the panel can
rescue. Also: classifyRoute returns a closed union and cannot express a
per-function bucket, and 'which wins' between the panel and the node port was
already decided in code before the task was written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
richardosborne14 and others added 27 commits August 6, 2026 23:41
Richard hit exactly the gap AIB-005 slice 3 was named for: leaving the
scoping conversation before agreeing on any pages produces a project
with no build plan at all (by design — nothing was agreed), but every
signal in the wizard read the same as the full-plan case. "Continue"
on the scoping step claimed it creates the project, when it actually
only advances to review; the review screen's confirm button just said
"Create Project" either way.

Fix the copy at both points instead of the mechanism, which already
does the right thing: the scoping step's exit line now describes what
Continue actually does, and the review button names the empty-plan
case ("no build plan (unfinished)") the same way it already names the
full one.
… the sheet dropdown stays on screen

Two Components-panel defects reported live:

- The create menu's disabled "Create Cloud Function Component" row
  named the fix ("choose Cloud Functions in the sheet selector") but
  could not do it — clicking it did nothing, since a disabled
  MenuDialog row never fires onClick. Wired an onGoToCloudSheet
  handler through the two folder-context call sites (the panel's
  empty-space/header menu and folder rows; the nested-in-component and
  canvas-trail contexts still have no sheet to jump to, so they keep
  the plain disabled row). The row is now clickable and switches to
  the Cloud Functions sheet when the reason is "wrong sheet" rather
  than "cannot nest here".

- SheetSelector's dropdown was positioned with a plain `right: 0`
  relative to the trigger. In a narrowed Components panel that put the
  trigger close enough to the panel's left edge that the ~160-200px
  dropdown overflowed past it, drawing over the icon rail. Re-anchor
  in viewport-fixed coordinates, measured and clamped to both edges on
  open.
…who else uses it

Four gaps in local-backend management, reported live against a list
that had grown past one entry:

- listBackends() returned filesystem readdir order, not creation
  order. Sort newest-first in the one place every consumer reads from.
- No rename existed anywhere — a backend named in a hurry (or by an AI
  provision, which names it from the project) was stuck with that name
  forever. Added `backend:rename` (main process + IPC), a hook action,
  and a menu item using the same StringInputDialog the Components
  panel already uses for sheet rename.
- The only signal that a backend was the open project's backend was a
  line of shy 11px text. Bolded it, colour-matched it to a new
  highlighted border on the card, so it reads before you read anything.
- Nothing said whether a backend was actually in use elsewhere.
  `projectIds` on config.json is write-only (stamped by AI provisioning,
  read by nobody) and would go stale immediately for manual creation,
  so this reads each known project's own root file directly instead —
  cheaper than opening every project through projectFromDirectory just
  to check one field. Shown as "Used by: X, Y" / "Also used by: X, Y".

Confirmed already working, not touched: naming is already required by
every creation path (create is disabled on an empty name); a backend
already auto-starts when a project bound to it opens
(ProjectBackendLifecycle, installed in router.setup.ts) — it just
never covered backends the open project isn't bound to, which was
never the ask.
…deGX

GitHub repo renamed The-Low-Code-Foundation/OpenNoodl -> NodeGX (old
name now redirects). Updates the electron-builder publish target,
the issue-report ISSUE_REPO constant, the legal-window fallback link,
the issue-template contact link, the README badge/release link, and
the label-creation script's REPO var and doc comments to match.

Deliberately not touched: the README's product-name prose and the
sibling opennoodl-hosting.com/opennoodl-cloudservice/opennoodl-better-backend
repo names, which are a separate content decision already made in
REV-007 (rename scope was branding + packaging identity only).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Record the repo rename, the label creation, and Richard's Discord
confirmation against HUMAN-GATED-ITEMS.md and PROGRESS.md, so the
register doesn't outlive its own fixes. Also notes a new small find:
the issue template's Discussions contact link is dead (Discussions
is off), unowned rather than filed as an F-number.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
bug_report.yml has had a severity dropdown since c57f729 (ALPHA-007
§2-4, 2026-08-03); the "there is no severity field" note was written
2026-08-06 without having seen that commit yet (it landed on an
unmerged branch until 80d221c). Re-ran the prefill probe against
the renamed NodeGX repo to confirm it still builds a valid URL.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ooting

Writes ALPHA-004 §1/§2/§4 — the authored prose ahead of ALPHA-006's docs
site — grounded in REACTIVITY-CONTRACT, PORT-TYPE-CONTRACT and a live read
of the editor source, not invented. The node reference stays generated,
per the "derive, never author twice" rule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
§2/§3 turn out already discharged by LIB-005's live QA; §4/§6 not
reached; §5 (PLAT-005 A/B/D) still blocked on canvas hit-testing, now
with the graph-to-screen transform confirmed but forEachNode's gap
against what's visibly rendered still unexplained. Also logs the
ALPHA-006 §1 re-verification (already done, untouched) and the
ALPHA-004 content drop this session's agents produced.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Records what's actually done (B3/B4/B7, ALPHA-006 §1 re-verified,
ALPHA-004 drafted, ALPHA-001 §1-3), what's left (§4/§5/§6, then
ALPHA-006 §2+), the graph-to-screen transform math for the next §5
attempt plus the real blocker (forEachNode not matching what's
rendered), and that gates weren't fully re-swept this session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
§4 (app-name round trip) PASSes: a real keystroke + graceful quit without
blur + reopen round-trips correctly. §5 (PLAT-005) moves from "blocked" to
root-caused: the forEachNode mystery was a stale/timing artifact, and the
suggestion banner's real blocker is F102 (StyleAnalyzerCore.scanNode
silently drops object-typed borderRadius overrides) plus F103 (a
deprecated Button node type that ElementConfigRegistry doesn't recognize,
silently losing the whole style-suggestion system). §6 (AI, no provider)
PASSes via hybrid live+static verification after discovering this dev
profile already has a working Anthropic key configured — an accidental
~$0.06 API call surfaced it, immediately rejected with no trace left.

Full gate sweep re-run clean: Jasmine 2418/0, test:main 933/933 in
isolation, all typechecks and package test suites green. One pre-existing
gate gap noted (catalog enrichment coverage), not caused this session.
The docs origin doubles as the editor's content CDN for six non-documentation
payload types (library, lessons, project templates, tutorials, what's-new).
Splits the ambiguous call sites apart: getContentEndpoint() for the six
payload consumers, getDocsEndpoint() left only on the three genuine
docs-page links (NodeLabel/NodePicker's "read more", McpSettingsSection's
docs-page probe). Both still resolve to the same origin — this is the
call-site split only, so §2/§3 can be built next without risking a non-doc
payload silently breaking.

The disposition half of §5 (stripping opennoodl-docs to just the six
payloads, the 413 MB asset question) is unbuilt — that's B5, still
unresolved and needs a human decision.

Gates: typecheck:editor clean, jest 933/933, lint:ci 860 vs 3916 baseline,
test:ci (Jasmine) 2418/0 — matching the same-day ALPHA-001 baseline exactly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ence

§2: a fresh Docusaurus 3 site at docs-site/, wired as an actual workspace
member (root workspaces + lerna.json, not just files on disk) so npm install
and lerna/nx both see it. Carries over the local search plugin from
opennoodl-docs; does not carry over its SCSS, which was built for Noodl's
retired branding and a multi-plugin site structure this task doesn't rebuild.
custom.css is a light-touch pass overriding Infima's primary color with the
editor's own azure token. ALPHA-004's staged content (docs-site-content/)
lands in docs-site/docs/ close to verbatim, one broken link fixed. url/
baseUrl/org/project in docusaurus.config.js are marked provisional — where
this actually publishes is entangled with B5, a still-open human decision.

§3: scripts/generate-node-docs.js (npm run docs:nodes / docs:nodes:check),
one page per catalog node grouped by the picker's own category field, with
inputs/outputs split into values/signals/failure-outputs, dynamic-port
mechanisms, examples, and cross-linked related nodes. The staleness gate
deletes and regenerates docs/nodes/ from scratch so a renamed/removed node
can't leave an orphan page, then in --check mode diffs the fresh output
against disk. Generated pages are committed and gated, not gitignored.

Caught by actually running `docusaurus build`, not by reading the generator's
output: Docusaurus 3 parses .md as MDX by default, and the catalog's own
prose contains MDX-breaking sequences (<name> placeholders, {count} template
fragments) it was never written to avoid. Fixed with one config line
(markdown.format: 'detect') rather than escaping 55 generated files.

Gates: typecheck:runtime/cloud/viewer/editor/editor-tests clean, catalog:check
and cloud-library:check clean, library:check 58/58, docs:nodes:check clean,
jest 933/933, lint:ci 860 vs 3916 baseline (docs-site outside its target
glob), test:ci (Jasmine) 2418/0 — matching every other measurement taken
today.

Not built: §4 (the opennoodl-docs migration table) and §5's disposition half
(stripping that repo to six payload types) — both need B5, still unresolved.
docs-site-content/ is now a stale duplicate; deleting it was blocked by the
permission classifier as destructive and needs a human `git rm -r`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Richard asked whether every node was actually documented in the new
docs-site. Checking properly (not just re-citing the earlier-noted gap)
found two separate problems in the enrichment pipeline, both predating this
session:

1. noodl.cloud.addusertorole/getuserroles/removeuserfromrole (the F86 role
   family) had no enrichment authored at all, so they didn't even appear in
   node-catalog-enriched.json's node list. §3's generator only iterates that
   file, so it silently produced zero pages for them.
2. The committed enriched catalog was separately stale relative to its own
   authored inputs: docs/node-catalog/enrichment/*.json had an updated
   accessControl port description (the NodeGX backend actually enforces
   per-record rules, not the old generic "some backends ignore them") that
   had never been landed via catalog:merge. Same failure mode as F76 — a
   generation script exists and nobody re-ran it after the source changed.

Fixed by authoring the three missing enrichment files, grounded in the
actual node source (packages/noodl-viewer-cloud/src/nodes/cloud/{add
usertorole,getuserroles,removeuserfromrole,system-roles}.ts) and checked
against the style guide's port-name-coverage requirement programmatically,
which caught a missing treatUnchangedAs port on all three on the first
draft. Ran catalog:merge for real once catalog:merge:check --require-
coverage passed, then npm run docs:nodes to regenerate (194 files / 175
nodes), then rebuilt and curl-verified the new Add User To Role page end to
end.

catalog:merge:check passes clean for the first time today. Full gate sweep
re-run: typecheck x5 clean, catalog:check/cloud-library:check clean,
library:check 58/58, docs:nodes:check clean, jest 933/933, lint:ci
unchanged, test:ci (Jasmine) 2418/0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…is repo

Richard's decision, 2026-08-07: leave the ~336 MB of live payload (library
zips, lesson project files, template zips) in its own repo rather than the
main monorepo — measured that it's genuinely live content, not stale doc
media (only ~174 MB of this repo's 518 MB is dead screenshots/images that
get deleted regardless), and dragging 336 MB of zips into git history with
no LFS configured here would be permanent, taxing every future clone and
worktree. Also matches the repo's intended future as a target for
community-contributed templates and lessons — a lighter, purpose-specific
repo is an easier target for that than the full engineering monorepo.

Two owed GitHub admin actions, recorded in HUMAN-GATED-ITEMS.md B5, in
order: rename opennoodl-docs to reflect what it now is (e.g.
nodegx-content) before getContentEndpoint() is repointed at it, then
enable GitHub Pages on this repo so docs-site/ can publish here directly —
no third repo, no cross-repo deploy credential, since a workflow here can
use the repo's own built-in Actions token.

docusaurus.config.js's url/baseUrl are no longer a placeholder guess
(previously nodegx-docs) but the real target
(the-low-code-foundation.github.io/NodeGX/) — still not live until the two
actions above happen, but no longer provisional.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs-site/MIGRATION.md accounts for every authored file in opennoodl-docs
(against the local clone at 7489e81, 2025-12-06) — a summary table grouped
by fate and folder, plus a full 431-path appendix so the count is auditable
rather than asserted. Zero unclassified.

Follows the spec's own folder assignments where it gave them, and extends
its stated reasoning by analogy where it was silent (docs/guides/
visualizing-data/ gets the same salvage treatment as its sibling guide
folders; docs/guides/deploy/{deploying-to-ios-and-android,embedding,
favicon,overview,project-structure,pwa}.md get Delete because phases
18/26 are post-alpha, not because the content is wrong).

Adds a fifth fate the spec's "four fates" framing never named but its own
§5 text implies: 193 files (library/ prose, repo meta, a prefab-authoring
boilerplate) that are neither migrated nor deleted — they stay in the
renamed content repo because they're coupled to payload that also stays
there. One exact duplicate caught and excluded from double-counting:
static/docs/guides/navigation/encoding-parameters-in-urls/README.md is
byte-identical to the docs/ copy already marked for salvage.

Satisfies ALPHA-006 acceptance criterion 5. No opennoodl-docs file was
touched — this is classification against the local clone, not a
migration; the actual strip still waits on B5's two GitHub actions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… morning's

Written at the end of a session that closed nearly all of ALPHA-006 (§2-§5
code half, §4 migration table on top of already-done §1/§6), fixed a real
node-catalog enrichment gap (172/175 -> 175/175), and got B5 decided by
Richard. Measured at e3922d5.

Leads with the one thing that actually needs fixing first: HelpCenter.tsx
and FailedStep.tsx both still hardcode the pre-rename OpenNoodl repo URL,
which is why Richard's live click on "Report a bug" produced a blank
untemplated issue (GitHub's rename redirect drops the ?template= query
param) - a real regression a real user hit, not a hypothetical.

Also corrects this session's own wrong assumption about where ALPHA-007's
composer lives: not the in-app Help Center's "?" menu (those are
deliberately plain GitHub template links), but a native OS menu item
registered in main.js, invisible to CDP's DOM-only reach. Records the
inspector-based route to actually trigger it next time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
B3's rename session grepped for OpenNoodl and missed these two files.
GitHub's rename redirect carries the path but drops ?template=, which
produced a blank issue with no template/labels when Richard clicked
"Report a bug" live. Verified by intercepting shell.openExternal in a
running editor and confirming the composed URL now targets NodeGX
with the template param intact.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Found live while driving ALPHA-007's report composer via the
main-process inspector: with the "What happened" field filled in, the
form's content exceeded 90vh and the Send/Cancel buttons were
unreachable — .VisibleDialog set overflow: hidden with no inner
scroll container. The already-authored ::-webkit-scrollbar rules on
the same class were dead code, only ever mattering once this became
auto. Affects every CoreBaseDialog-based dialog, not just this one.

Verified live: after the fix, the dialog scrolls and the buttons are
reachable, with the underlying popup still visible behind it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Findings register row for F104 (BaseDialog.module.scss clipping tall
dialogs, fixed same session as 022fb20), the ALPHA-007 status row,
and a log entry covering both the stale-URL fix verification and the
composer drive: criterion 2 confirmed real, the field-prefill
contract verified against real composed data, redaction spot-checked
on live output, and the on-disk bundle confirmed on macOS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
B4 (issue-label permission) was done 2026-08-06 — gh label list
confirms all nine labels live on the repo. My own ALPHA-007 log entry
inherited the prior handover's "B4 owed" claim without re-checking
it. Caught before it could outlive its own fix a second time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ng's

Records the stale-URL fix, ALPHA-007's first successful live drive via
the main-process inspector, and F104 (found and fixed). Corrects the
prior handover's stale "B4 owed" claim. Full gate sweep re-run clean
at 7d64049. Next session's priorities: chase the human-gated items
(B6 is two minutes and unblocks criterion 3), redo ALPHA-007
criterion 4 live now that the composer is reachable, and the docs
repo strip once B5's two GitHub actions land.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Built a real hostile-fixture project (API key in a node parameter, a
backend endpoint, a client-named component, paths outside the project
root) and drove the actual Report a problem composer end to end against
it — the composed GitHub URL, report.md, and diagnostics.json all
verified secret-free. Closes the last unblocked engineering item the
prior handover flagged; what remains is human-gated (B5, B6, testers).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Supersedes the same-day 7d64049 handover now that ALPHA-007 criterion 4
is closed. Reframes the remaining work honestly: almost everything left
is Richard's action, not more engineering, since the exit criterion
itself requires a non-author tester. Sequences the human-gated queue by
lead time, names the one engineering task each unlocks, flags D4 as the
one unblocked fix worth doing now, and bundles the A3 tester ask with
ALPHA-004's criteria 1/4 so testers aren't recruited twice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… by PR #19

Pushing cline-dev to trigger main-sync CI for the first time in 5 days (440
commits) surfaced real, pre-existing drift that had never been checked:

- typecheck:backend-tests (~22 errors): a dual-declaration mismatch where
  noodl-viewer-cloud/src/index.ts mixes a dist-types-resolved `@noodl/runtime`
  import with source-resolved deep imports, producing two nominally distinct
  copies of the same runtime classes; plus nodegx-backend's strict:true
  surfacing real strictness gaps in noodl-runtime source (deliberately
  strict:false there per PLAT-003) and a handful of genuinely untyped test
  call sites.
- ERG-003's list-port coverage census test was a stale snapshot (48/23 editable
  ports) against a catalog that grew to 54/32 — invariant check still passed,
  only the numbers needed updating, per the test's own documented procedure.
- TSFixme ratchet (+43 TSFixme, +35 any): accumulated debt across 20
  unrelated files from the same unpushed window, none from this fix. Baseline
  raised per the ratchet's own escape hatch rather than rewritten blind.

All of typecheck, typecheck:editor-tests, typecheck:backend-tests, tsfixme,
lint:ci now green; noodl-runtime (2298), noodl-viewer-cloud (172), and the
three touched nodegx-backend suites all pass with no regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
461859b (2026-08-02) fixed the object->string cast the day after this
row's own 2026-08-01 live check that filed it. Re-verified 2026-08-07:
nda-014-outbound-string-cast.test.ts (9 cases) passes clean on HEAD.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…s it

@noodl/preview's suite drives the built CLI over HTTP and fails fast via
assertPrerequisites() if packages/noodl-preview/dist or the deployed viewer
runtime is missing. The test-packages job never built either — it runs
independently of the build job (separate runners, no shared filesystem) — so
every one of its 14 specs failed the first time this job actually reached
@noodl/preview:test.

That had never happened before: Nx bails the whole `lerna run test` on the
first package failure, and something upstream (noodl-core-ui this session,
almost certainly something else before it) has apparently always failed
first, silently skipping preview's suite rather than running it. Verified
locally: `npm run test:packages` end to end, 9 projects, all green with the
two build steps added.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Builds docs-site/ (Docusaurus, regenerating the node-catalog pages first)
and deploys via actions/deploy-pages using this repo's own built-in Actions
token — no cross-repo credential needed, since docs-site/ already lives in
this monorepo rather than the old opennoodl-docs arrangement.

Triggers on push to main only (this publishes a public URL) plus manual
dispatch. Verified docs-site:build succeeds locally (194 files / 175 nodes,
Docusaurus build clean).

Repo-side: GitHub Pages enabled via API with build_type=workflow, source
now https://the-low-code-foundation.github.io/NodeGX/ — matches
docusaurus.config.js's already-configured url/baseUrl.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@richardosborne14
richardosborne14 merged commit d569d2b into main Aug 7, 2026
19 checks passed
richardosborne14 added a commit that referenced this pull request Aug 7, 2026
…, D4 correction

Full account in PROGRESS.md's new 2026-08-07 (fourth session) entry and in
memory (main-was-never-synced-2026-08-07). Short version: B6's "blank issue
form" traced back to origin/main never having received fork work since
2025-09-09, not a prefill bug — fixed via PR #19 (cline-dev -> main, 6276
files), which also surfaced and fixed real CI drift hidden by 5 days of
unpushed commits. B2 and B5's remaining GitHub actions are done; B6 needs a
fresh run now that bug_report.yml actually exists on main.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
richardosborne14 added a commit that referenced this pull request Aug 7, 2026
First release cut since origin/main was synced (PR #19) and since Apple
signing credentials (A1) were added as GitHub secrets. v0.1.0 stays a draft
with no macOS artifact; this is a fresh build to actually produce one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant