registerReroute silently overwrote existing chain entries, and the
identity-guarded delete meant a displaced owner could never vacate its
entry. Copying a subgraph node configured a throwaway live clone whose
identically-numbered reroutes shadowed the live chains, and pasting one
skipped reroute-id dedup entirely, conflating chains across graphs and
serializing corrupted parentId links. Serialize clipboard data from a
plain snapshot instead of a live clone, dedup reroute ids on paste with
the same machinery configure uses, and make registerReroute warn and
refuse foreign overwrites.
multiClone keeps node ids, so configuring the new subgraph while the
originals were still registered made every interior clone link lose
first-wins registration, hijacked the originals' reroute chains, and let
the later removal loop delete the badge keys the clones were piggybacking
on — converted nodes read disconnected and badge-less until save+reload.
Vacate the originals first: disconnect boundary links, remove interior
links and reroutes before the subgraph configures, and re-register the
subgraph's nodes in the badge store after the originals are removed.
Removing the last instance of a subgraph recursed unconditionally into
nested definitions: a nested definition still instanced elsewhere had its
inner nodes' badge registrations stripped and removal lifecycle fired
while live, while a definition referenced only inside the removed subtree
leaked its link topologies, reroute chains, and registry entry forever.
Compute the releasable set by excluding the removed instance from the
live-reference walk, then release each definition exactly once: lifecycle
first, then badge/link/reroute unregistration and registry removal.
isWidgetValue rejected null while the widget value type includes it, so
every promoted-widget hop coerced a stored null to undefined: serialize
dropped the value (deleting widgets_values entirely when all values were
null, so reload restored interior defaults), projection getters read null
back as undefined, reorder skipped rewriting it, and a null quarantined
host value fell through to stale widgets_values. Accept null in the
guard, use an explicit has-check for quarantine lookup, and compare
Reset-to-default enablement against the store state instead of
null-coalescing to the live widget value.
insertWorkflow configured a throwaway LGraph that adopted the file's
serialized graph id, registering scratch nodes into the root-scoped
stores. Inserting a workflow whose id matched the open one bound scratch
widgets first-wins to the live WidgetState objects and reverted unsaved
widget edits to file values; differing ids leaked the scratch bucket for
the session. Give the scratch graph a synthetic UUID and clear it in a
finally block so all store buckets are disposed even if copying throws.
LGraph.remove and the PrimitiveNode proxy-widget migration read
output.links through the warn-once deprecation getter, consuming the
telemetry budget before any third-party read could be observed. Migrate
both to store-backed queries, migrate two test assertions off the input
mirror, and pin node removal in the zero-deprecation-warnings regression
test.
The linkStore is the single source for input-slot connectivity.
Readers use node.isInputConnected / node.getInputLink or the slotLinks
input helpers; NodeInputSlot.link is a deprecation-telemetry accessor:
reads warn and return the store-derived id, writes warn naming
connect()/disconnectInput() and are ignored. Serialization derives
inputs[].link from the store; wire format unchanged.
Store re-keying is authoritative: moving a registered link evicts any
incumbent under the new key, so slot permutations cannot drop links;
first-wins placement remains only for load-time registration, which
duplicate-link dedup depends on. dynamicWidgets rebuilds capture one
at-rest slot-to-link snapshot and reconcile once at the end.
fixLinkInputSlots is replaced by realignInputLinkSlots inside
LGraph.configure, reading the effective (possibly deduplicated-clone)
node data. disconnectOutput's duplicated branches collapse into one
per-link loop that removes the store entry before onConnectionsChange
fires. 'link' in slot discriminators are deleted in favor of direction
threading; _setConcreteSlots writes wrappers back into node
inputs/outputs so identity-based lookups hold; addInput/addOutput drop
legacy link/links keys from extra_info. First-party code no longer
touches either deprecated mirror.
The linkStore is the single source for output-side connectivity. The
NodeOutputSlot.links field and all nine write sites are gone; a
read-only prototype getter derives the array from the store and fires
warnDeprecated as migration telemetry for extensions (no setter, so
writes throw). INodeOutputSlot keeps links as deprecated readonly so
'links in slot' discriminants still compile. Wire format is unchanged:
serialization derives outputs[].links from the store, and serialized
operators (linkFixer serialized branch, migrateReroute, unpackSubgraph
strip) are untouched; linkFixer now completes a missing input mirror
instead of deleting a store-registered link.
Also fixes createTestSubgraphNode minting duplicate node ids (the
fixture never reserved the id it assigned) and promoted-widget labels
not inheriting the source slot label - both previously masked by the
mirror or the id collision.
Extension migration: read via node.isOutputConnected(slot) /
node.getOutputNodes(slot); mutate via node.connect() /
node.disconnectOutput(). Design record: docs/architecture/
output-slot-connectivity.md Decision 6.
All ~30 internal output.links read sites now go through the slotLinks
helpers or slot.isConnected; the mirror is write-only pending deletion.
Serialization derives outputs[].links from the store, sorted ascending
by id (equal to push order for organically built graphs) and null when
empty. configure() fires its output callbacks from the serialized
argument instead of the mirror copy.
Deliberate bugfix: disconnectInput passed the mirror-array index as the
OUTPUT slot number to onConnectionsChange; it now passes
link_info.origin_slot.
Link queries now return fully-assigned links only: buildOutputIndex skips
floating topologies, matching the output.links mirror they replace. The
domain rule lives in isFloatingTopology, which also replaces the
hand-rolled sentinel checks in rerouteStore, dynamicWidgets, and
widgetValuePropagation. The slot-drag disconnect check keeps its floating
awareness via slotFloatingLinks, its pre-store behavior.
New node/slotLinks helpers (outputHasLinks/outputLinkIds/outputLinks)
back the remaining app readers: rerouteNode, widgetInputs, groupNode,
proxyWidgetMigration, and useNodeReplacement now read the store instead
of output.links. Behavior change, deliberate: PrimitiveNode's
onLastDisconnect fires on disconnect-all, where the stale mirror
previously suppressed it.
getOutputSlotLinks now returns ReadonlySet<LinkTopology> instead of link
ids: floating links draw ids from a separate counter, so a bare id cannot
be resolved safely through graph.links, and the migrated readers need
endpoints anyway. Migrated: minimap link extraction, slot-drag disconnect
check (one store query replaces a mirror read plus a slotFloatingLinks
scan), widget value propagation, and matchType link revalidation.
rerouteNode/widgetInputs/groupNode stay on the mirror deliberately - they
read inside connection callbacks whose behavior depends on mid-mutation
mirror staleness - as do the serialized-workflow repair tools. Design
record updated.
Adds a derived per-graph reverse index over link origins to linkStore,
exposing isOutputSlotConnected / getOutputSlotLinks as the output-side
mirror of the input queries. Target and origin slot keys are branded
separately so a key built for one index cannot be looked up in the other.
NodeSlots now passes connected to InputSlot and OutputSlot from the store;
the lg-slot--connected class reaches the dot via CSS, so SlotConnectionDot
needs no new prop. Design record in docs/architecture.
## Summary
Make right-side-panel promoted subgraph-input widget rows host-scoped,
removing runtime graph traversal, per ADR0009.
## Changes
- **What**: `SectionWidgets.vue` and `WidgetActions.vue` no longer walk
live links back to the interior source to identify promoted widgets. A
promoted widget is host-scoped (`widget.widgetId =
graphId:hostNodeId:inputName`, also carried on the host input slot), so
reuse that:
- `isLinked` → `inputForWidget(node, widget)?.widgetId != null`
- `isWidgetShownOnParents` → match parent inputs by `widget.widgetId`
- `handleHideInput` → drop the unreachable source-resolution branch
(with the `String(node.id)===String(parent.id)` swap), keep
`demoteWidget`
- remove the now-unused `widgetPromotedSource` export
- favorites keep their `(nodeLocatorId, widgetName)` key; added a
`TODO(ADR0009)` to move to a pure `WidgetId` key (deferred — needs a
persisted-format migration)
- **Breaking**: none. `resolvePromotedWidgetSource` in
`clearWidgetErrors` is left intact (ADR0009 permits source lookup for
diagnostics).
## Review Focus
Behavior equivalence of the three replaced predicates. The removed
`handleHideInput` branch was unreachable: reaching it requires
`!isLinked`, but whenever the old source resolved, `isLinked` was
already true. The only divergences are broken/unresolvable interior
links, which are strictly safer under the new logic. Net -55 lines, no
new APIs.
Validated: `pnpm typecheck`, `pnpm lint`, `pnpm knip` clean; 127
subgraph/parameter unit tests pass.
## Addendum: widget-value type consolidation
`f48153d` fixes null handling and starts collapsing the redundant value
types. Current state and the path to one canonical type:
**The problem.** Five spellings of the same union, each able to skew
independently:
| Type | Was | Now |
|---|---|---|
| `WidgetValue` (simplifiedWidget.ts) |
`string\|number\|boolean\|object\|undefined\|null\|void\|File[]` |
canonical: `string\|number\|boolean\|object\|undefined\|null` |
| `TWidgetValue` (litegraph widgets.ts) | `IWidget['value']` (rejected
null) | `= WidgetValue` alias |
| `WidgetState['value']` (widgetState.ts) | `unknown` | defaults to
`WidgetValue` |
| `NodeProperty` (LGraphNode.ts) | no null (runtime stored null anyway)
| `\|null` added |
| `IBaseWidget.callback` value param | `unknown` | `WidgetValue` |
The old `unknown` in the store forced `isWidgetValue(x) ? x : undefined`
laundering at 6 call sites; 4 are now deleted. The guard survives only
where it does real work: untrusted workflow JSON
(`proxyWidgetMigration.pickHostValue`) and the serialize path
(`SubgraphNode.serialize`).
**Remaining steps (follow-up PRs):**
1. Codemod `TWidgetValue` -> `WidgetValue` (~20 imports incl.
`serialisation.ts`, `litegraph-augmentation.d.ts`, panel callbacks),
delete the alias. Zero behavior change.
2. `NodeProperty = Exclude<WidgetValue, undefined>` - derives the one
legitimate variant (properties use `undefined` as "absent") instead of a
parallel hand-written union that can drift.
3. Move `WidgetValue` to a leaf module (litegraph currently imports it
from app-layer `@/types/simplifiedWidget` - inverted layering).
4. Rename the shadowing generic `migrateWidgetsValues<TWidgetValue>` in
`litegraphUtil.ts` to `<T>`.
5. (Riskier, last) Narrow the bare `object` members -
`IChartWidget.value: object`, `ICustomWidget.value: string\|object` - to
`Record<string, unknown>`. This is the only change that makes the union
meaningfully narrower than "anything non-callable"; needs an ecosystem
deprecation window since `custom` is the extension escape hatch.
**Invariant to keep:** `null` and `undefined` are distinct on purpose.
`undefined` = hole/absent in `widgets_values` arrays and
property-presence checks; `null` = a real stored value. Regression tests
in `promotionUtils.test.ts` and `LGraphNode.test.ts` pin this.
---------
Co-authored-by: Amp <amp@ampcode.com>
## Summary
Prototype: badge rows are derived presentation, not entity state, so
this replaces the materialized `nodeBadgeStore` (and its
registration/watcher/scope machinery, which amounted to cache
invalidation) with a per-node memoized derivation.
## Changes
- **What**: `nodeBadges(node)` — one lazy `computed` per node instance
in a `WeakMap`; entries die with their nodes, so registration, teardown,
and the stale-bucket bug class do not exist. A single
`graphStructureRevision` (litegraph leaf, bumped at
`add`/`remove`/`clear` chokepoints and structure events) replaces bucket
membership, `subgraphCreditsRevision`, and the
`resolveGraphId`/`resolveNode` seams. Litegraph consumes rows through a
one-function `setBadgeRowsProvider` seam, keeping its import graph
acyclic. `computeBadges` stays pure/plain-data; the identity-keyed draw
cache is unchanged. Net −456 lines.
- **Breaking**: none beyond the parent PR; `node.badges` and settings
behavior unchanged.
## Review Focus
- ECS-goal compliance despite deleting the store: authoritative truth
stays in the source stores + graph; badges were a projection, and the
design-doc amendment in `docs/architecture/node-badge-store.md`
("Prototype: derive-on-read") records the reasoning and trade-offs.
- Coarse invalidation: any structural change invalidates every badge
computed (flag only; recompute is lazy per read). Stress case is
large-workflow drag/paste.
- Legacy-canvas redraw on async price resolution still rides the
`pricingRevision` watch in `useNodeBadge`, same as the parent branch.
Child of #13460 — compare against `drjkl/and-then-there-were-none`, not
`feature/ecs-migration`.
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
## Summary
Extracts node badges into a dedicated store per ADR 0008 — slice A of
`docs/architecture/node-badge-store.md`: store, plain `BadgeData` rows,
reactive badge system, and LGraph chokepoint registration. Stacked on
#13455.
## Changes
- **What**:
- `src/types/badgeData.ts` — plain `BadgeData { kind, text, fgColor?,
bgColor?, iconKey? }` rows (decision 1).
- `src/stores/nodeBadgeStore.ts` — root-scoped buckets keyed by
`NodeId`, kind-ordered reads, identity-checked row deletes,
`registerNode`/`unregisterNode`/`clearGraph` registration trio; row
writes refused for unregistered nodes (decision 2).
- `src/systems/badgeSystem.ts` — pure `computeBadges(sources)` +
effect-scope-per-node shell watching `registeredNodeIds`; writes core
and credits rows from
nodeDef/settings/palette/pricing/widget-value/link-connectivity sources
(decisions 3–4).
- `LGraph.add`/`remove`/`clear` + subgraph-definition GC maintain the
store registration (linkStore/rerouteStore convention).
- Design record updated with implementation notes.
- **Breaking**: none yet — `node.badges`/`useNodeBadge` closures
untouched; nothing starts the system in production until slice B's
consumer cutover.
## Review Focus
- Registration is bucket-key presence; the system discovers nodes by
watching `registeredNodeIds`, so litegraph never imports pricing/nodeDef
modules (no import cycle via the litegraph barrel).
- Core rows are fine-grained (lifecycle, id, source) under one
visibility rule — unification notes in the design record (Vue gains
HideBuiltIn on id badges; legacy join/truncation becomes slice-B
draw-cache presentation).
- Subgraph credits aggregation, the legacy `node.badges` surface, and
`badgePosition` are open decisions in the design record and deliberately
not in this PR.
- Slice A+B together would exceed the ~300-line guideline, hence the
split; store row APIs (`registerBadge`/`deleteBadge`) land now because
the slice-B shim consumes them.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
## Summary
Migrates the `inputs[].link` mirror-based reactivity tracking to
`linkStore` queries and deletes the slot-link reprojection machinery in
`useGraphNodeManager`.
## Changes
- **What**: `NodeSlots.linkedWidgetedInputs`, `usePartitionedBadges`'
pricing pokes, and `trackNodePrice` now query
`linkStore.isInputSlotConnected` (root graph id + unfiltered slot
index). Deletes the `node:slot-links:changed` handler and its
load-bearing comment, the emitter-less `node:slot-errors:changed`
handler, `refreshNodeInputs`, and the node-removal refresh-all loop.
Adds unified-mode NodeSlots tests (none existed) and a `trackNodePrice`
reactivity test. Drafts `docs/architecture/node-data-store.md` for the
next phase.
- **Not removed**: the `node:slot-links:changed` emitters and trigger
type entries, the `input.link` mirror itself, the computation-path
`inp.link` reads in `useNodePricing.buildJsonataContext` (migrated in
#13498 alongside the mirror deletion), and the `node:slot-label:changed`
handler — those belong to the Slot extraction / nodeDataStore phases.
## Review Focus
- The node-removal refresh-all loop deletion (own commit) — originally
shipped as a regression fix; gated locally on the vue-nodes legacy
widget e2e spec plus the full vueNodes suite (126 passed).
- Pricing badges previously only re-evaluated for widgeted inputs (the
trigger's gate); linkStore queries now also cover non-widgeted pricing
inputs — a correctness improvement, not parity.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
## Summary
Follow-up cleanups from the reroute chain store work (#13449): deletes
the `slot._floatingLinks` mirror in favor of endpoint derivation, fixes
two position/endpoint integrity gaps the mirror had masked, and
restructures the subgraph-unpack chain stitching.
Stacked on #13449.
## Changes
- **What**:
- `slot._floatingLinks` Sets deleted — a floating link's attachment is
fully encoded in its own endpoints, so `slotFloatingLinks(network, side,
nodeId, slot)` derives it; ~25 write sites across
`addFloatingLink`/`removeFloatingLink`, `setFloatingLinkOrigin`, and
every `FloatingRenderLink` connect method are gone
- `moveInputLink`/`moveOutputLink` gain the `node` param their
`dragNewFrom*` siblings already take
- `FloatingRenderLink.connectToSubgraphOutput` now writes the link's
**target** end (it wrote `origin_id = SUBGRAPH_OUTPUT_ID`,
clobbering/skewing endpoints — masked by the mirror)
- `removeInput`/`removeOutput` renumber floating-link slot indices
alongside real links (stale indices previously persisted into serialized
floating links)
- `Reroute.snapToGrid` mirrors into the layout store like `move()` does
(Vue hit-testing read a stale spatial index after snapped drags)
- Subgraph-unpack reroute chain stitching rewritten as
collect-then-stitch (two segment walks + one generic pointer pass,
replacing three interleaved walk-and-write loops)
## Review Focus
- Scoped out with written assessments: `input.link`/`output.links`
mirror removal (roadmap-scale `SlotConnection` extraction, ~530
accesses) and reroute position ownership inversion (layout store only
seeds the active graph, no writeback)
- Corrupt-data error paths in unpack stitching are normalized to
per-link skip (the old code broke the whole loop on some
internal-segment failures)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Summary
Extracts reroute connectivity into a dedicated `rerouteStore` per ADR
0008: chain state (`parentId`, floating marker) is stored by reference,
and link membership (`linkIds`/`floatingLinkIds`) is derived from the
links' `parentId` chains instead of being hand-maintained at ~10 write
sites.
Stacked on #13436 (link topology store); design record in
`docs/architecture/reroute-chain-store.md`.
## Changes
- **What**:
- `LLink._state` now holds the link store's reactive proxy (BaseWidget
pattern), so `link.parentId` writes are tracked by Vue
- New `rerouteStore`: root-scoped buckets keyed by `RerouteId`,
`RerouteChain {id, parentId?, floating?}` registered by reference;
`Reroute` class reads/writes through the store proxy
- Membership derived via a per-graph computed reverse index over link
topologies + chain states; `Reroute.linkIds`/`floatingLinkIds` become
readonly derived accessors
- Deleted: all manual membership mutation sites,
`Reroute.validateLinks`, `Reroute.removeLink`, `Reroute.update`'s
linkIds param
- `LGraph._addReroute`/`_removeReroute` chokepoints mirror
`_addLink`/`_removeLink`
- Load-time reroute-id dedup across sibling subgraph definitions
(mirrors node-id dedup; reroute ids must now be per-root-unique)
- Serialization emits `linkIds` in ascending link-id order; goldens
added for byte-identical round-trip
- **Breaking**: `Reroute.linkIds`/`floatingLinkIds` are now
`ReadonlySet` accessors — external mutation of these sets no longer
affects membership (the chain is the single source of truth).
`Reroute.validateLinks`/`removeLink` removed.
## Review Focus
- `LLink.disconnect` now unregisters the link before pruning empty
reroutes, so derived counts exclude it
- `dropOnReroute` resolves the drop target's source output once before
connecting moved links — re-anchoring the first link's chain changes
derived membership immediately (`LinkConnector.ts`)
- Workflows whose stored `linkIds` contradict their chains are repaired
rather than preserved (design decision 5; no shim)
---------
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
## Summary
Make a Pinia store (`useLinkStore`) the single source of truth for graph
link topology (ADR-0008 / ECS), mirroring `widgetValueStore`. `LLink`
topology fields become accessors over one `_state` object registered
into the store **by reference** — one copy, no mirror, no sync bridge.
## Changes
- **What**:
- New `useLinkStore` (`src/stores/linkStore.ts`) — **keyed by target
input slot**. At most one live link can target a given input slot
(litegraph disconnects the previous link before connecting a new one),
and every consumer queries by target, so the dominant query ("is this
input connected, and by what?") is one map lookup. Public surface is 6
actions (`registerLink`, `updateEndpoint`, `deleteLink`,
`isInputSlotConnected`, `getInputSlotLink`, `clearGraph`).
- Links without a unique target live in a per-graph side collection:
floating links (either endpoint unassigned; several can share an input
slot) and links into `SUBGRAPH_OUTPUT_ID` (a constant shared by every
subgraph in a root bucket). Neither is queried by target. Floating links
never answer `isInputSlotConnected`, matching `input.link` semantics.
- `LLink` topology backed by a single `_state: LinkTopology`; endpoint
setters delegate to `updateEndpoint` (displace → patch → re-place) when
registered. `updateEndpoint`/`deleteLink` take the topology by reference
and are identity-checked; `LLink._graphId` is set only while the link
actually holds a registration.
- `LGraph._addLink` / `_removeLink` chokepoints: every `_links` map
add/remove funnels through them (both `configure` loops, subgraph
reconnect, duplicate-link purge, `linkFixer`), keeping link-store and
layout entries in sync. Subgraph-definition removal and
non-root/unconfigured `clear()` unregister their links.
- Topology registered at every litegraph create site (`connectSlots`,
both `configure` loops, `addFloatingLink`, subgraph connect) and cleaned
up on `disconnect` / `removeFloatingLink` / `clear` / node-delete
cascade.
- Deleted the `layoutStore.ylinks` connectivity mirror +
`layoutMutations.createLink`/`deleteLink` +
`Create/DeleteLinkOperation`; node removal disconnects links before
`onNodeRemoved` fires, so layout cleanup needs no connectivity mirror or
layout→link cross-store call.
- Migrated the widget-disabled hot path (`WidgetItem.isLinked`,
`AppModeWidgetList`, `buildSlotMetadata`) to O(1) `isInputSlotConnected`
/ `getInputSlotLink`; removed the now-redundant `node:slot-links:changed
→ refreshNodeInputs` re-projection.
- Serialization round-trip goldens (plain / reroute-chain / floating /
subgraph). Because the store no longer keys by link id, subgraph link-id
collisions are irrelevant to it and **workflows load without any link-id
rewriting** (the node-id dedup for widget-store keys is pre-existing and
unchanged).
- **Breaking**: `LLink` topology fields (`origin_id`, `origin_slot`,
`target_id`, `target_slot`, `type`, `parentId`) are now accessors over
`_state`; `parentId` moves from own-property to prototype accessor
(`hasOwnProperty('parentId')` now returns `false`). `asSerialisable()`
output is byte-identical (guarded by goldens).
## Review Focus
- **Register-by-reference**: `LLink._state` is the exact object the
store holds — no second copy, no sync function. Verify no path writes
topology to two places.
- **Target-keying invariant**: one live link per input slot is enforced
by litegraph (`connectSlots` disconnects first); the store's side
collection covers exactly the links that violate or escape it (floating,
subgraph-output-node targets).
- Endpoint setters must delegate fully to `updateEndpoint` when
registered and mutate `_state` directly only when unregistered.
- Lifecycle completeness: every create path registers, every destroy
path unregisters — `_addLink`/`_removeLink` are the chokepoints.
- **Reroutes explicitly deferred.** Remaining `input.link` connectedness
*reads* (`nodeDataUtils`, `useNodePricing`, GLSL preview) are strangler
residue for a follow-up — not in scope here.
Stacked on #13322 — base is `drjkl/safe-widget-cleanup`.
## Migration note (extension authors)
`LLink` topology fields (`id`, `origin_id`, `origin_slot`, `target_id`,
`target_slot`, `type`, `parentId`) are now accessors over an internal
`_state` object, so they are non-enumerable on live instances.
`Object.keys(link)`, `JSON.stringify(link)`, `{...link}`, and
`structuredClone(link)` no longer expose them. To copy or serialize a
link, use `link.asSerialisable()` — direct field reads
(`link.origin_id`) are unchanged. Persisted workflow JSON is unaffected
(byte-identical, covered by goldens).
## Screenshots
Manual render-parity checklist (run against a ~200-node workflow): links
render; connect/disconnect updates live; undo/redo; subgraph nav. E2E
`nodeSelection.spec.ts` passes 13/13 including "a linked widget is
disabled". Note: 8 `linkInteraction.spec.ts` screenshot diffs (~0.01
ratio) observed — no commit in this branch touches link
geometry/coordinates/draw order, so these are
pre-existing/environmental; confirm against base before un-drafting.
---------
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: AustinMroz <austin@comfy.org>
## Summary
Move Vue widget render-only metadata out of the graph node manager and
into dedicated widget render state alongside widget values.
## Changes
- **What**: Adds `WidgetRenderState` in `widgetValueStore`, removes
`SafeWidgetData` from `useGraphNodeManager`, and has Vue widget
rendering compose from raw LiteGraph widgets plus store-backed
value/render state.
- **What**: Updates promoted widget, app mode, parameter panel, preview,
tooltip, and widget tests for the new render-state boundary.
- **What**: Hardens related browser tests against branded id typing and
brittle default workflow node ids.
## Review Focus
Please look closely at the ownership boundary between raw LiteGraph
widgets, `WidgetState`, and `WidgetRenderState`, especially promoted
widget metadata and linked-widget rendering.
## Validation
- `pnpm typecheck`
- `pnpm typecheck:browser`
- focused widget/node unit tests
- focused Playwright check for `Should display added widgets`
---------
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: AustinMroz <austin@comfy.org>
## Summary
Replaces the Additional credits tooltip trigger with the shared Button
component so the icon renders with the neutral muted treatment used by
the rest of the UI.
## Changes
- **What**: Uses the shared Button component for the Additional credits
info action while preserving the existing tooltip and aria label.
- **Dependencies**: None.
## Review Focus
Confirm this remains a visual-only change scoped to the Plan & Credits
credits tile.
## Screenshots (if applicable)
Before
<img width="397" height="368" alt="스크린샷 2026-07-06 오후 11 30 38"
src="https://github.com/user-attachments/assets/9cda1aee-4dc2-4ce1-b001-29d0e09fc07d"
/>
After
<img width="374" height="355" alt="스크린샷 2026-07-06 오후 11 31 00"
src="https://github.com/user-attachments/assets/64d0b726-6031-42d4-84d7-35405150698c"
/>
## Testing
- `pnpm format`
- `pnpm lint`
- pre-commit hook: stylelint, oxfmt, oxlint, eslint, typecheck
- `pnpm test:unit
src/platform/cloud/subscription/components/CreditsTile.test.ts`
## Summary
Surface the supported-models catalog (`/p/supported-models`) from the
home page by adding a "Supported Models" link to the Products dropdown
and the footer.
## Changes
- **What**: Add a `nav.supportedModels` i18n entry (en `Supported
Models`, zh-CN `支持的模型`) and link it to the existing `routes.models`
constant in two places: the Products mega-menu Features column (between
Launches and Docs) and the footer Products column (after Comfy MCP).
Locale handling comes from `getRoutes`, so zh-CN resolves to
`/zh-CN/p/supported-models` automatically.
## Review Focus
- Placement is intentionally in **both** the nav and the footer (per
FE-1190). Fine to drop either, happy to adjust after review.
- Reuses the existing nav/footer link pattern, no new components or
styles, and no `new` badge (the page is not newly launched).
- Consumes `routes.models`, which was already defined but previously
unused.
FE-1190
## Screenshots
**Nav — Products dropdown, Features column**
_Desktop:_
_Mobile:_
**Footer — Products column**
_Desktop:_
_Mobile:_
Preview: https://comfy-website-preview-pr-13432.vercel.app
Remove `embed=true`, so PostHog applies the survey's own dark appearance
and it renders correctly.
Verified live on testcloud that removing the flag fixes the styling;
`distinct_id` user linkage is unaffected.
- Adds support for forcing an icon to display as a mask or image with
`icon-mask` and `icon-image`.
- Updated the logic so that svg of a solid color (like the claude logo)
display as an image by default
- Update many svg to consistently use `currentColor` so that they still
function as masks by default
## Summary
Follow-up draft PR for the CodeRabbit issues created from the #12999
review. This keeps the original stabilization PR merged as-is and moves
the non-functional TemplateHelper cleanup into its own small branch.
## Changes
- Extracted TemplateHelper route patterns into named module-scope
constants.
- Normalized the TemplateHelper route patterns to anchored regexes with
optional query-string handling.
- Extracted `mockCustomTemplates()` from `mockIndex()` and made `mock()`
register custom templates, core index, and thumbnails together.
- Added a private `registerRoute()` helper so every mocked route is
registered for teardown consistently.
- Simplified the fixed empty custom-template response to `body: '{}'`.
- Updated the cloud template filtering spec to use `templateApi.mock()`
instead of manually combining thumbnail and index mocks.
## Issues
- Closes#13014
- Closes#13016
- Closes#13017
- Closes#13018
- Related #13015: this PR normalizes the TemplateHelper route patterns
only. The broader fixture-wide route pattern convention cleanup remains
intentionally separate.
## Validation
- `pnpm exec oxfmt --check
browser_tests/fixtures/helpers/TemplateHelper.ts
browser_tests/tests/templateFilteringCount.spec.ts`
- `pnpm exec eslint browser_tests/fixtures/helpers/TemplateHelper.ts
browser_tests/tests/templateFilteringCount.spec.ts`
- `pnpm typecheck:browser`
- Pre-commit hook also ran `oxfmt`, `oxlint`, `eslint`, `pnpm
typecheck`, and `pnpm typecheck:browser` successfully.
Note: I attempted the targeted cloud Playwright spec locally with
`PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm exec
playwright test browser_tests/tests/templateFilteringCount.spec.ts
--project=cloud`, but the local 5173 app was not running with the cloud
distribution configuration, so the distribution-filter assertions failed
in the expected local/cloud mismatch way. This should be verified by
CI's cloud project.
## Summary
On Comfy Cloud, show the Manager button and open a hosted survey in the
manager modal (in place of the local node manager) so we can gauge
demand for custom nodes on Cloud.
## Changes
- **What**: `TopMenuSection` shows the Manager button when `isCloud`;
clicking it opens `ManagerSurveyDialog`, which embeds a PostHog hosted
survey via iframe. The survey URL comes per-environment from cloud
config (`manager_survey_url`), with the logged-in user's `distinct_id`
appended so responses link to the user. Includes loading/error states
and PostHog's `posthog:survey:height` iframe auto-resize.
- **Dependencies**: none
## Review Focus
- Survey URL is sourced from `remoteConfig.manager_survey_url` (must be
set per environment in cloud config); falls back to an error state when
unset or malformed.
- iframe embedding requires the PostHog survey to be `external_survey`
type with embedding enabled.
## Summary
Removes the Claude Code PreToolUse hooks added in #11201. Their `if`
patterns blocked any bash command the static pattern parser could not
fully resolve — loop variables, `$(...)`/backtick substitution,
heredocs, `${...}` expansions — with a misleading error naming a random
unrelated tool. Transcript analysis across a month of sessions found 37
hook firings: 1 true positive, 36 false positives (~97%), including
blocking `pnpm typecheck ... | tail` itself.
## Changes
- **What**: Delete `.claude/settings.json` (it contained only the hook
config) and the script-based replacement from earlier revisions of this
PR.
- Earlier revisions replaced the hooks with a stdin-inspecting matcher
script, but the hooks' original rationale — protecting Nx task
orchestration back when `test:unit` was `nx run test` — disappeared when
Nx was removed in #12355 and the pnpm scripts became direct tool
invocations. The remaining value (nudging agents toward pnpm scripts)
does not justify maintaining a bash-parsing matcher with its own edge
cases.
## Review Focus
- Agents can now run `npx tsc` / `npx vitest` etc. without being
redirected; the pnpm-script convention remains documented in AGENTS.md,
which is what agents follow in practice.
## Summary
Stop running the full Vitest suite twice in unit CI. The critical
coverage gate is now a glob-keyed `coverage.thresholds` entry enforced
during the single `pnpm test:coverage` run, instead of a second
`COVERAGE_CRITICAL=true vitest run --coverage` pass.
## Changes
- **What**: All critical directories form one brace-expanded glob key in
`coverage.thresholds`; Vitest aggregates the matching files into a
single bucket and checks the existing thresholds (69/60/67/70) against
it during the normal coverage run. Untested files matching
`coverage.include` are counted at 0%, preserving the previous gate's
semantics.
- **What**: Narrows the litegraph coverage exclusion from a blanket
`src/lib/litegraph/**` to the non-critical subfolders, so the critical
litegraph folders (`node`, `subgraph`, `utils`) are present in the
coverage report the thresholds read.
- **What**: Removes the `test:coverage:critical` script, the
`COVERAGE_CRITICAL` env branch in `vite.config.mts`, and the separate CI
gate step.
## Notes
- The normal coverage report now includes the critical litegraph
folders, so the Codecov `unit` flag and the coverage Slack baseline will
show a one-time shift.
- Filtered local runs (`pnpm test:coverage <file>`) fail the gate since
most critical files are uncovered; a full `pnpm test:coverage`
reproduces CI exactly.
Validation:
- `pnpm typecheck`, `pnpm exec eslint vite.config.mts`, `pnpm
format:check`, `pnpm knip` (pre-push)
- Smoke: `pnpm vitest run --coverage src/utils/colorUtil.test.ts` —
tests pass, then the gate fails all four metrics against the critical
bucket and exits 1, confirming enforcement happens inside the single run
- Full-suite gate numbers should be confirmed in CI
## Summary
Identifies Cloud auth users to Syft via the required `identify(email, {
source })` handoff so Syft enrichment reliably attaches to signup/login
users instead of relying only on GTM page-load capture.
## Changes
- **What**: Adds a cloud-only Syft telemetry provider that reads
`syftdata_source_id` from `remoteConfig`, lazy-loads the Syft SDK
(reusing an already-loaded GTM Syft client when present), and calls
`identify` with `source: 'signup'` or `source: 'login'` on auth and on
session restore. `trackUserLoggedIn()` dedupes against the email already
handled by `trackAuth()` so a fresh login is not identified twice.
- **Dependencies**: None.
## Review Focus
- Preserves the FE-945 startup-blocker fix: the constructor reads only
the `remoteConfig` ref (a plain reactive ref, not Pinia) and never
touches current-user state; the user-email lookup happens only in
`trackUserLoggedIn()`, after app/auth setup. The source id is present at
construction because `main.ts` awaits the anonymous
`refreshRemoteConfig` before `initTelemetry`, and a later authenticated
refresh is picked up reactively on the next `ensureSyftClient()` call.
- SDK loader is idempotent with the current GTM Syft tag during rollout
(one script per `SYFT_SRC`). On load failure it clears its own stub —
guarded by an identity check so it never evicts a real client another
loader installed — letting a subsequent call retry. Long-term cleanup is
to keep one loader path per surface.
- Acceptance should include staging Network verification for
`https://e2.sy-d.io/events` payloads containing an `identify` event for
Google, GitHub, and email auth.
Linear: GTM-168
## Summary
Repurpose the Products dropdown featured card to promote Comfy MCP.
## Changes
- **What**: Update the nav featured card title ("NEW: COMFY MCP"), alt
text, image asset (`mcp-card.webp`), and CTA ("GET STARTED") in
`mainNavigation.ts`; route the CTA to the localized `/mcp` page via
`routes.mcp`. All copy is i18n'd (en + zh-CN) in `translations.ts`,
adding a reusable `cta.getStarted` key.
## Review Focus
- CTA uses a new reusable `cta.getStarted` key rather than the
section-scoped `mcp.setup.label`, and routes to the internal
`routes.mcp` so non-en locales resolve to `/{locale}/mcp`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Summary
Add direct tests for queue job display formatting.
Base: `main`
## Changes
- Covers state icons, pending/initializing labels, running progress,
completed local/cloud output, fallback completed titles, and failed
display.
## Test Results
| | before | after |
| -- | -- | -- |
| `pnpm test:unit src/utils/queueDisplay.test.ts --run` | no direct
queue display test file | ✅ 13 passed |
## Coverage
Superseded by #13332. Historical pre-#13313 branch coverage:
`src/utils/queueDisplay.ts` 22.72% -> 79.54% (+56.82%); overall branches
52.95% -> 53.03% (+0.08%).
Codecov project coverage is intentionally omitted here because it is not
the branch-ratchet metric.
<!-- CURSOR_SUMMARY -->
---
> [!NOTE]
> **Low Risk**
> Test-only change; no runtime or production code modified.
>
> **Overview**
> Adds **`src/utils/queueDisplay.test.ts`**, a Vitest suite that
exercises **`iconForJobState`** and **`buildJobDisplay`** from
`queueDisplay.ts` without touching UI or production logic.
>
> Tests use small **`createJob` / `createTask` / `createCtx`** helpers
with a stub **`t`** and clock formatter so expectations assert i18n keys
and formatted values. Coverage includes pending “added to queue” hint,
queued/initializing labels, active vs inactive running progress,
completed local preview vs cloud duration, completed title fallback, and
failed rows with **`showClear`** behavior.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
6260c101e5. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
## Summary
Skip the website e2e report/deploy step for fork PRs, which lack the
deploy secrets and otherwise fail the job.
## Changes
- **What**: Guard the report/deploy step's `if:` in
`ci-website-e2e.yaml` so it runs only when the event is not a fork pull
request.
- **Breaking**: none. CI-config only.
## Review Focus
CI-config only — no test or coverage change. Confirms fork PRs no longer
fail on the deploy step.
<!-- CURSOR_SUMMARY -->
---
> [!NOTE]
> **Low Risk**
> CI workflow condition only; no application or test logic changes.
>
> **Overview**
> **Website E2E CI** no longer runs the **Deploy report to Cloudflare**
step on pull requests from forks.
>
> The step’s `if:` still requires `always()` and `!cancelled()`, and now
also requires either a non–pull-request event or a PR whose head repo is
**not** a fork. Playwright tests and artifact upload are unchanged; only
the wrangler deploy (which needs `CLOUDFLARE_*` secrets) is skipped for
fork PRs so those runs don’t fail when secrets aren’t available.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
02a4ab0769. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
## Summary
Skip secret-backed CI deploy and dispatch work for fork PRs so missing
repo secrets do not fail otherwise valid checks.
## Changes
- **What**: Guard Website E2E report deploy, Vercel website preview
deploy, cloud build dispatch, cloud cleanup dispatch, and Storybook
Chromatic deploy so PR paths only run for same-repo PRs.
- **Dependencies**: None
## Why
Fork `pull_request` runs do not receive repository secrets. Several CI
jobs already separated normal validation from privileged follow-up work,
but some deploy or dispatch steps could still run on fork PRs and fail
only because their secret-backed integration token was empty.
The existing Website E2E fork guard only protected the PR comment job.
It did not protect the earlier Cloudflare report deploy step inside
`website-e2e`, which uses `CLOUDFLARE_API_TOKEN` and
`CLOUDFLARE_ACCOUNT_ID`.
The same failure mode existed in these CI jobs:
- `ci-vercel-website-preview.yaml`: preview deploy uses Vercel and
website API secrets.
- `cloud-dispatch-build.yaml`: preview dispatch uses
`CLOUD_DISPATCH_TOKEN` to call `Comfy-Org/cloud`.
- `cloud-dispatch-cleanup.yaml`: preview cleanup dispatch uses
`CLOUD_DISPATCH_TOKEN`.
- `ci-tests-storybook.yaml`: Chromatic deploy uses
`CHROMATIC_PROJECT_TOKEN`.
`ci-website-build.yaml` was left unchanged. Its Ashby and Cloud nodes
integrations intentionally fall back to committed snapshots when secrets
are missing for preview/local builds, so it is not the same class of
fork-secret failure.
## Review Focus
Confirm fork PRs still run the unprivileged validation/build paths,
while same-repo PRs and non-PR events keep the existing deploy or
dispatch behavior.
## Validation PRs
Both validation PRs compare against `main`.
- Fork PR from `shihchi`:
[#13309](https://github.com/Comfy-Org/ComfyUI_frontend/pull/13309)
- Same-repo PR from `origin`:
[#13310](https://github.com/Comfy-Org/ComfyUI_frontend/pull/13310)
| Workflow | Guarded job or step | Fork #13309 | Same-repo #13310 |
| --- | --- | --- | --- |
| CI: Website E2E | `Upload test report` | success ✅ | success ✅ |
| CI: Website E2E | `Deploy report to Cloudflare` | skipped ❌ | success
✅ |
| CI: Vercel Website Preview | `deploy-preview` | skipped ❌ | success ✅
|
| Cloud Frontend Build Dispatch | `dispatch` | skipped ❌ | success ✅ |
| CI: Tests Storybook | `chromatic-deployment` | skipped ❌ | success ✅ |
Expected result: fork PRs still keep the useful validation artifact
path, but skip secret-backed deploy and dispatch work. Same-repo PRs
keep the privileged behavior.
## Screenshots (if applicable)
N/A, CI-only.
Created by Codex
<!-- CURSOR_SUMMARY -->
---
> [!NOTE]
> **Low Risk**
> Workflow `if` condition changes only; no application code. Same-repo
PR behavior is unchanged when secrets are available.
>
> **Overview**
> Adds **`github.event.pull_request.head.repo.fork == false`** guards so
fork PRs no longer run steps that need repo secrets or trigger external
deploys.
>
> **Website E2E** — the Cloudflare Playwright report deploy step now
runs only on non-PR events or same-repo PRs, so fork runs can still pass
tests and upload artifacts without failing on missing `CLOUDFLARE_*`
secrets.
>
> **Vercel website preview** — the preview deploy job is skipped
entirely for fork PRs (Vercel tokens).
>
> **Storybook Chromatic** — Chromatic deployment on `version-bump-*` PRs
is limited to non-fork PRs (`CHROMATIC_PROJECT_TOKEN`).
>
> **Cloud dispatch** — build and cleanup dispatches to the cloud repo
for preview labels no longer run for fork PRs, aligning with the
existing fork-guard comment in those workflows.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
027aabc9e3. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
---------
Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
## Summary
Instruments the churn funnel: cancellation intent, attempt, abandonment,
and request failure, plus resubscribe clicks — all client-observed from
existing request/response flows, no watchers or polling added. Covers
both billing paths: the mainline (`/customers/*` + Stripe portal) path
via the "Manage subscription" click, and the workspace path via its
in-app cancel dialog.
## Changes
- **What**:
- New events: `app:subscription_cancel_flow_opened` / `_confirmed` /
`_abandoned` / `_failed` and `app:resubscribe_button_clicked`, via
`trackSubscriptionCancellation(stage, metadata)` and
`trackResubscribeClicked` (registry, PostHog, host sink)
- All cancellation events carry a `source` discriminator:
- `manage_subscription_button` — the mainline path. Legacy users can
only cancel inside the Stripe billing portal, and in-app UI already
covers plan changes, so this click is the closest observable
cancel-intent signal for ~all production users. Only `flow_opened` fires
here (everything past the click happens in Stripe's UI). Probable, not
certain, intent — the portal also serves card updates/invoices.
- `cancel_plan_menu` — the workspace in-app dialog (allowlist-gated
pilot): `flow_opened` on mount, `confirmed` before the API call (failed
attempts still register), `failed` with the error message, `abandoned`
on "Keep subscription"/close. Successful cancels close via a different
path and never emit `abandoned`.
- Metadata carries `current_tier`, billing `cycle`, and (dialog path)
the `end_date` shown to the user
- Resubscribe clicks tracked at both call sites with `source`:
`pricing_dialog` (`useSubscriptionCheckout`, also carrying the dialog's
`payment_intent_source` from #13363) and `settings_billing_panel`
(`useResubscribe`)
- Not instrumented on purpose: the workspace "Manage billing" button and
the "Invoice history" footer link (portal opens without cancel
connotation)
## Review Focus
- Deliberately **no** client-side "cancel succeeded" event: outcome
truth is server-side. Mainline already has it
(`billing:subscription_deleted` from the Stripe webhook in comfy-api);
the workspace path needs a `subscription_cancelled` billing event type
(separate cloud-repo change). The legacy
`useSubscriptionCancellationWatcher` poller emits an undercounted
`app:monthly_subscription_cancelled`; analysis should prefer the server
event.
- `confirmed` fires before the request; growth can join
`flow_opened`/`confirmed` → server-side cancelled events by user +
timestamp.
## Summary
Reverts #13370 (the five Creative Campus customer stories) from `main`.
These are education-tied stories, and the "Education Program is live"
CTA links to the education page, which is not live yet, so they should
not be public before the education launch.
This is a clean `git revert` of the squash commit `49a90d4e2` (no
history rewrite, no force-push). No work is lost: the story branch
(`feat/website-customer-stories-education`) is intact, and the stories
will relaunch together with pricing and the education page via #13406.
## Changes
- **What**: Reverts the 5 new story MDX files, the new article block
components, and the related changes to `CustomerArticle.astro`,
`global.css`, `Figure`/`Quote`/`Contributors`, the content test, and the
e2e spec. The existing five stories and the customers pages are
unaffected.
- **Breaking**: none.
## Review Focus
- Pure inverse of #13370; the diff is `-858/+11` mirroring the original
merge.
- Files touched by #13370 are disjoint from the education-page work in
#13406, so this does not conflict with that branch.
## Verification
- Build: 497 pages (down 5 en story pages). Unit: 156/156. Typecheck: 0
errors. format:check and knip clean.
## Next steps
- Stories move into the education bundle (#13406) via a separate PR.
- When the education page and its auth (FE-1174) are ready, pricing +
customer stories + education launch together.
## Summary
- `CI: E2E Coverage`'s `Generate HTML coverage report` step fails on
every run with `genhtml: ERROR: unknown argument for --ignore-errors:
'range'`
- The runner's `apt-get install lcov` resolves to lcov 2.0-4ubuntu2
(Ubuntu 24.04/noble), but the `range` ignore-errors category was only
added in lcov 2.1
- lcov 2.0 already reports the out-of-range-line condition under the
`source` category, which is already in the ignore list, so `range` was
both unsupported and redundant on this runner
## Test plan
- [x] Confirmed lcov 2.0-4ubuntu2 is what `apt-get install lcov`
resolves to on `ubuntu-latest`
- [x] Confirmed via lcov's `lcovutil.pm` source that `range`
(`$ERROR_RANGE`) is only registered as of v2.1, and in v2.0 the
equivalent out-of-range case falls under `$ERROR_SOURCE`
- [ ] CI: E2E Coverage run on this branch's merge should pass the
"Generate HTML coverage report" step
## Summary
Add the five new Comfy Education Initiative (Creative Campus) customer
stories to `/customers`, each with its own detail page, reusing the
existing Astro content-collection pattern. Brings the listing to ten
stories. Linear: FE-1161.
## Changes
- **What**: Five new English MDX stories (Xindi Zhang, Ina Conradi,
Golan Levin, Kathy Smith, and the UAL CCI partnership) added to the
customers collection, ordered after the existing five. Adds a small set
of reusable article blocks these stories need: `Embed` (Vimeo), `Video`
(wraps the existing `VideoPlayer`), `Download` (workflow JSON),
`AuthorBio`, `EducationCta`, `AtAGlance`, a styled inline `Link`, and
`Heading4`. `Quote`'s `name` is now optional for unattributed
pull-quotes; `Figure` gained an optional rich-caption slot (for captions
that contain links); `AuthorBio` supports a single-author bio via slot.
- **Breaking**: none. All additions are backward compatible; the
existing five stories and their pages are untouched.
- **Dependencies**: none.
## Review Focus
- The logic to review is small and isolated: the new block components in
`components/customers/content/` and their registration in
`CustomerArticle.astro`. The rest of the diff is MDX content.
- **Story copy is transcribed verbatim from the source docs**;
punctuation (em/en dashes, curly quotes) is preserved as written and is
intentional, not a formatting slip.
- **Downloads (cross-origin):** the workflow JSON files are on
media.comfy.org, so the HTML `download` attribute is ignored by
browsers. The real download is forced server-side with
`Content-Disposition: attachment` on the storage objects. Xindi's two
workflow files are served from a cache-fresh `.../workflows/` path (with
an explicit `filename=`) so the CDN serves the attachment header
immediately.
- **Embed hardening:** the Vimeo `Embed` iframe carries
`referrerpolicy="strict-origin-when-cross-origin"` and a scoped
`sandbox` (`allow-scripts allow-same-origin allow-presentation
allow-popups`); the player was verified to still load and play.
- All media (card covers, inline images, one video with a poster frame,
workflow JSON/PNG downloads) is hosted on media.comfy.org. No local
assets are committed. Golan's workflow files are re-hosted there; his
lesson-plan and demo-project links intentionally stay on GitHub/p5.js as
view-only.
- English-first: Chinese versions will be added later through a separate
translation service. The listing and detail pages already handle a
locale that only has English entries, so no page-code changes were
needed.
- Tags: "Creative Campus Showcase" for the four teaching stories, and
"Creative Campus Partnership" for the UAL announcement.
## Verification
- Unit `176/176`, typecheck (astro check) `0 errors`, build `502 pages`,
`format:check`, `knip`, and `eslint` all pass.
- e2e customer specs `6/6` pass (includes a new test asserting the
Creative Campus education blocks render).
- Visual pass on all ten stories at desktop (1440) and mobile (390): no
horizontal overflow, the Vimeo player plays, and all downloads resolve
to media.comfy.org.
## Screenshots (if applicable)
Easiest way to review is the Vercel preview:
https://comfy-website-preview-pr-13370.vercel.app/customers then open
the five new stories. Verified on desktop (1440) and mobile (390).
## Summary
Shields personal-workspace billing code paths behind the new
`consolidated_billing_enabled` feature flag so they fall back to the
**legacy** billing flow while the flag is `false`. Team workspaces are
unaffected and continue to use the workspace-scoped billing flow.
## Changes
- Add `consolidatedBillingEnabled` to `useFeatureFlags` (reads the
`consolidated_billing_enabled` server flag / remote config, defaults to
`false`) and to the `RemoteConfig` type.
- New `useBillingRouting` composable — a single source of truth for
whether the active workspace uses the workspace vs. legacy billing flow:
- team workspaces disabled → legacy
- personal workspace + consolidated billing off/missing → legacy
- personal workspace + consolidated billing on → workspace
- team workspace → workspace
- workspace not loaded yet → legacy
- Route `useBillingContext` and the affected UI sites
(`SubscriptionPanel`, `useSubscriptionDialog`, `UsageLogsTable`,
`TopUpCreditsDialogContentLegacy`) through `useBillingRouting` instead
of keying on `teamWorkspacesEnabled` directly.
- Update the storybook `useFeatureFlags` mock to stay in sync.
## Testing
- `pnpm test:unit` for `useBillingRouting`, `useBillingContext`,
`useSubscriptionDialog`, and `UsageLogsTable` (new + updated coverage
for the routing matrix). Remaining quality gates (`typecheck`, `lint`)
are being verified in CI.
## Related
Requires the backend PR that adds the `consolidated_billing_enabled`
flag to `/api/features`.
---------
Co-authored-by: Amp <amp@ampcode.com>
## Summary
Restore the original `node-link.svg` asset, which PR #13095 accidentally
overwrote with a stretch-to-fill Figma export, breaking the node
connector across the marketing site.
## Changes
- **What**: Revert `apps/website/public/icons/node-link.svg` to its
intrinsic **20×32** form (`fill="#F2FF59"`). PR #13283 had replaced it
with a raw Figma export (`preserveAspectRatio="none"`, `width="100%"
height="100%"`, `fill="var(--fill-0, …)"`). Every consumer loads it as a
bare `<img src>` and relies on the intrinsic size plus
`scale-*`/`rotate` classes — with no intrinsic dimensions the connector
expanded to fill its container and distorted.
## Review Focus
- The overwrite originated in the first commit of #13283's stack and
rode through the squash merge; nothing in that PR actually referenced
this file (the MCP page uses the separate `NodeUnionIcon.vue`), so
restoring the shared asset fixes all consumers (`BuildWhatSection`,
`ProductShowcaseSection`, `OurValuesSection`, `GalleryDetailModal`)
without touching the MCP page.
- `apps/website/dist/icons/node-link.svg` is stale build output and
regenerates on the next `pnpm build`.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
## Summary
Answers "why did this user want to pay?" by capturing the triggering
product moment at every paywall/upsell entry point and carrying it
through checkout and success telemetry.
## Changes
- **What**:
- Widen `SubscriptionDialogReason` from 4 coarse values to 13 grounded
intent sources (`subscribe_to_run`, `upgrade_to_add_credits`,
`invite_member_upsell`, `settings_billing_panel`, etc.)
- Fire `app:subscription_required_modal_opened` from
`useSubscriptionDialog` (the choke point all dialog variants pass
through) — the workspace/unified path previously emitted nothing; remove
the now-duplicate emitters in `useSubscription` and
`usePricingTableUrlLoader`
- Add `payment_intent_source` to
`BeginCheckoutMetadata`/`SubscriptionSuccessMetadata`, threaded via the
existing `reason` prop: dialog → `PricingTable` →
`performSubscriptionCheckout` → pending-attempt record, so legacy
`app:monthly_subscription_succeeded` carries intent alongside
`checkout_attempt_id`
- Fire `begin_checkout` on the workspace checkout path
(`useSubscriptionCheckout`, personal + team confirm) and the team
deep-link util — both previously emitted nothing; `tier` widened to
`TierKey | 'team'`
- Implement `trackBeginCheckout` in `PostHogTelemetryProvider` (was
GTM/host-only, so `begin_checkout` never reached PostHog)
- Thread `showSubscriptionDialog(options)` through the billing-context
adapters and pass a reason at ~14 call sites; add `source` to
`app:add_api_credit_button_clicked`
## Review Focus
- `modal_opened` now fires once per dialog actually shown, so a
free-tier user clicking Upgrade emits two events (free-tier dialog, then
pricing table) where the legacy path emitted one
- Intent is threaded explicitly via props/params rather than shared
state; `useSubscriptionCheckout` gained an optional second parameter
## Summary
Small follow-up to #13289 applying two non-blocking review nits from
Alex's review.
## Changes
- **What**: drop the redundant `before:content-['']` on the
customer-story list bullet (Tailwind emits the empty `content`
automatically once another `before:` utility is present), and rename
`HEADER_OFFSET` to `HEADER_OFFSET_PX` in `ArticleNav` so the scroll
constants use consistent unit suffixes.
## Review Focus
Both changes are cosmetic with no behavior change. Confirmed in the
browser that the list bullet still renders identically (6px yellow dot)
without the explicit `content` utility.
## Notes from the #13289 review (left as-is here, open to discussion)
Three other comments from the review are intentionally not changed in
this PR; reasoning below so the decisions are on record:
- **`Category` type in `ArticleNav`**: kept the `ComponentProps<typeof
CategoryNav>` derivation. AGENTS.md says to derive component types via
`vue-component-type-helpers` rather than redefining them, so the current
form follows the styleguide. Happy to switch to a plain named type if
preferred.
- **Section ids in frontmatter vs the body `<Section>`**: kept the
`customers.content.test.ts` parity test. The short TOC labels live only
in frontmatter and Astro can't introspect the rendered MDX body to build
the nav, so the frontmatter `sections` list and the body anchor ids
can't be trivially deduplicated. A real fix would need a remark plugin
(larger, separate change). The test guards against silent drift in the
meantime.
- **`nextStory` throw**: left as a fail-loud, build-time invariant. The
slug always comes from the same `getStaticPaths` collection, so the
throw is effectively unreachable; it surfaces a future-refactor bug
loudly instead of linking to the wrong story.
## Summary
Adds an app mode validation warning so users can see when a workflow has
errors before running and jump directly back to graph mode to review
them.
## Changes
- **What**: Adds a reusable app mode warning banner above the Run button
when the execution error store reports workflow errors, including
validation and missing asset states.
- **What**: Reuses the existing graph-error navigation flow so the
warning action switches out of app mode and opens the Errors panel in
graph mode.
- **What**: Updates the app mode Run button icon and accessible label in
the warning state while keeping the Run action non-blocking.
- **What**: Adds unit coverage for the warning render/accessibility
state and an E2E flow that triggers a validation failure, dismisses the
overlay, and opens graph errors from the app mode warning.
- **Breaking**: None.
- **Dependencies**: None.
## Review Focus
The warning intentionally mirrors graph mode behavior: it surfaces the
error state but does not prevent the user from clicking Run. This avoids
turning display-level validation signals into hard execution blockers.
The warning is driven by the existing `hasAnyError` aggregate, so
missing nodes, missing models, and missing media are included alongside
prompt/node/execution errors.
## Tests
- `pnpm format`
- `pnpm lint`
- `pnpm typecheck`
- `pnpm test:unit`
- `pnpm knip`
- `pnpm test:browser:local
browser_tests/tests/appModeValidationWarning.spec.ts`
## Screenshots
<img width="461" height="994" alt="스크린샷 2026-06-25 오후 7 00 55"
src="https://github.com/user-attachments/assets/f8fc20bf-d572-46b5-9fa4-312e7c4c8076"
/>
## Summary
Add a `COVERAGE_CRITICAL` unit-coverage gate over folder-based critical
runtime areas and wire it into the unit CI job. First PR of a stacked
series that ratchets the gate upward as tests land.
## Changes
- **What**: `vite.config.mts` gains `CRITICAL_COVERAGE_INCLUDE` folder
globs for core runtime areas: `src/base`, `src/composables`, `src/core`,
`src/schemas`, `src/scripts`, `src/services`, `src/stores`, `src/utils`,
selected `src/platform` logic slices, selected
`src/lib/litegraph/src/{node,subgraph,utils}` primitives, and selected
`src/workbench` manager logic; `package.json` gains
`test:coverage:critical` (`COVERAGE_CRITICAL=true vitest run
--coverage`); `ci-tests-unit.yaml` runs the gate. The thresholds are
env-gated, so the normal `test:coverage` run is unaffected.
- **Breaking**: none.
## Review Focus
Establishes the measurement substrate, no tests added yet. Thresholds
are locked to the current baseline over the folder-based critical scope
so CI is green:
| metric | baseline | threshold |
|---|---|---|
| statements | 69.53% (24287/34930) | 69 |
| branches | 60.7% (11497/18940) | 60 |
| functions | 67.34% (4980/7395) | 67 |
| lines | 70.83% (22619/31930) | 70 |
The scope is intentionally not whole `src/platform`, `src/lib`, or
`src/workbench`: UI-heavy and specialized lanes like platform
components, telemetry/surveys, litegraph
canvas/widgets/infrastructure/types, and manager components/types stay
outside this gate for now.
Subsequent stacked PRs add tests and bump these thresholds; a later
refactor series ratchets branches to 90.
Created by Codex
<!-- CURSOR_SUMMARY -->
---
> [!NOTE]
> **Low Risk**
> Changes are limited to test/coverage configuration and CI; no
application runtime behavior is modified.
>
> **Overview**
> Introduces a **critical-path unit coverage gate** that only runs when
`COVERAGE_CRITICAL=true`, leaving the existing `pnpm test:coverage`
behavior unchanged.
>
> **Vitest** (`vite.config.mts`): when the flag is set, coverage is
limited to folder globs for core runtime areas (base, composables, core,
services, stores, utils, selected platform/workspace/auth slices,
litegraph node/subgraph/utils, workbench manager logic, etc.) and
**Vitest thresholds** are enforced (statements 69%, branches 60%,
functions 67%, lines 70%). In that mode, litegraph is no longer
blanket-excluded from coverage the way the full `src` run still excludes
`src/lib/litegraph/**`.
>
> **Tooling & CI**: adds `test:coverage:critical` in `package.json` and
a new unit CI step after Codecov upload that runs the gate so
regressions in those areas fail the job.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
25e73f3844. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
---------
Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
## Summary
Move the five customer stories on `/customers` out of the old
`customerStories.ts` array and the shared i18n file into an Astro
content collection (MDX, English and Chinese). The pages look and read
exactly the same; this just changes where the content lives so it is
easier to edit, and it sets the pattern we will reuse to migrate the
rest of the marketing content.
Linear: FE-1158
## Changes
- **What**:
- Added a `customers` content collection (`src/content.config.ts` +
`src/content/customers.schema.ts`), with one MDX file per story per
locale under `src/content/customers/{en,zh-CN}/`.
- Rebuilt the article rendering as small static components (`Section`,
`Figure`, `Quote`, `Contributors`, `Steps`, plus styled
paragraph/heading/list). The article body is now static HTML; only the
scroll-spy sidebar (`ArticleNav.vue`) ships JavaScript.
- Repointed the `/customers` listing and detail pages (both locales) to
read from the collection.
- Removed the old `customerStories.ts` array and the customer-story keys
from `translations.ts` (about 1,300 lines).
- Dropped the two "Read more" links that just redirected back to the
same page; kept the two that point to the real Substack articles.
- Switched the "Read more" button to the design-system `Button`, which
also fixes its vertical alignment.
- Added a short pattern doc at `apps/website/src/content/README.md` for
reuse.
- **Dependencies**: `@astrojs/mdx` (renders the MDX content).
## Review Focus
This is meant to be a no-visual-change migration. I checked content and
layout against the live site for all five stories in both languages, on
desktop and mobile. The only intended differences are the two removed
self-referential "Read more" links and the read-more button now using
the shared `Button`.
A few small setup changes explain part of the diff:
- `src/env.d.ts` now references `.astro/types.d.ts` so the collection
types resolve (this is the repo's first content collection).
- `astro.config.ts` sets `markdown.smartypants: false` so quotes stay
straight (MDX would otherwise curl them). This option is deprecated in
Astro 7 and moves onto the markdown processor; that belongs with the
eventual Astro 7 upgrade, not here.
- ESLint ignores the `astro:` virtual modules for `apps/website` files
(they are real at build time, but the resolver cannot see them).
- Content MDX is excluded from `oxfmt` in `.oxfmtrc.json`: the formatter
rewraps component slots and changes the rendered output (it broke the
blockquotes), so content files are kept out of it like generated files
and fixtures.
- `components/common/ContentSection.vue` and `config/contentSections.ts`
are untouched; they still power the legal and privacy pages.
The diff is large, but most of it is MDX content, the lockfile, and the
removed i18n keys. The logic to review is small: the collection config
and schema, the components, and the page wiring.
## Screenshots
No visual change is intended, so before and after of the article pages
are identical (verified across both locales and on desktop and mobile).
The one deliberate tweak is the "Read more" button, which now uses the
design-system `Button` for better vertical alignment. Before/after
captures are available if needed.
## Summary
Brand link, reroute, and slot identifiers through LiteGraph, subgraph,
and layout flows so raw numeric workflow data is converted at boundaries
while runtime APIs keep branded IDs.
## Changes
- **What**: Add canonical `LinkId`, `RerouteId`, and `SlotId` types plus
minting helpers, then re-export litegraph/layout ID types from those
modules.
- **What**: Keep `LinkId`, `RerouteId`, and `SlotId` references branded
across graph links, reroutes, node slots, subgraph slots, link
deduplication, link drop handling, layout storage, and tests.
- **What**: Convert raw numeric IDs only at periphery points: serialized
workflow DTOs, legacy graph link proxy access, copied/pasted graph data,
Yjs/string layout keys, and test fixtures.
- **What**: Move slot layout identity onto branded `SlotId` values using
stable `node:direction:index` ordering, while keeping DOM dataset values
stringified at the boundary.
- **What**: Avoid slot-key scans during link drops by carrying the link
segment identity directly through the drop path.
## Review Focus
- Branded IDs should not be widened back to `LinkId | number` /
`RerouteId | number` in runtime APIs.
- Serialized workflow shapes intentionally remain numeric for
compatibility.
- `_subgraphSlot.linkIds` remains `LinkId[]`; call sites should not
treat it as raw `number[]`.
- `MapProxyHandler` is the compatibility boundary for deprecated indexed
`graph.links[id]` access.
## Validation
- `pnpm typecheck`
- `pnpm test:unit src/lib/litegraph/src/LLink.test.ts
src/lib/litegraph/src/LGraph.test.ts
src/lib/litegraph/src/LGraphNode.test.ts
src/lib/litegraph/src/canvas/LinkConnector.core.test.ts
src/lib/litegraph/src/canvas/LinkConnector.integration.test.ts
src/lib/litegraph/src/canvas/LinkConnectorSubgraphInputValidation.test.ts
src/lib/litegraph/src/LGraphCanvas.drawConnections.test.ts
src/lib/litegraph/src/node/slotUtils.test.ts
src/lib/litegraph/src/subgraph/ExecutableNodeDTO.test.ts
src/core/graph/subgraph/promotionUtils.test.ts
src/core/graph/subgraph/migration/proxyWidgetMigration.test.ts
src/renderer/core/layout/store/layoutStore.test.ts
src/renderer/core/layout/utils/layoutUtils.test.ts
src/renderer/extensions/minimap/minimapCanvasRenderer.test.ts
src/scripts/promotedWidgetControl.test.ts`
- Commit hook: `oxfmt`, `oxlint`, `eslint`, `pnpm typecheck`
- Push hook: `knip --cache`
---------
Co-authored-by: AustinMroz <austin@comfy.org>
## Summary
Block background keybindings from firing while a modal dialog (e.g.
Templates) is open, so typing `w` no longer toggles the workflow sidebar
behind the modal.
## Changes
- **What**: In `keybindingService.keybindHandler`, gate command
execution on `dialogStore.dialogStack`. When a dialog is open, only
keybindings whose event target is inside the dialog (`[role="dialog"]`)
fire; all other matches are dropped.
## Review Focus
- The dialog scope check uses `target.closest('[role="dialog"]')` so
dialog-internal shortcuts still work — confirm PrimeVue/Reka dialogs
render with `role="dialog"` on the wrapper (they do; this is the
WAI-ARIA standard the libraries follow).
- Updated `keybindingService.escape.test.ts` "modifiers regardless of
dialog state" case to the new contract (modifiers also blocked),
matching the team consensus in FE-642 that all keybindings should be
disabled when a modal is open.
- New `keybindingService.dialog.test.ts` covers: no-dialog → fires;
dialog open + target outside → blocked; dialog open + target inside →
fires.
Fixes FE-642
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12184-fix-disable-global-keybindings-while-a-modal-dialog-is-open-35e6d73d3650812fbc5dd5490ccde24f)
by [Unito](https://www.unito.io)
Co-authored-by: Dante <bunggl@naver.com>