Commit Graph

8048 Commits

Author SHA1 Message Date
DrJKL
a7e88c7bc0 chore(eslint): forbid litegraph imports in src/world/
Adds a no-restricted-paths zone enforcing that src/world/ cannot import
from src/lib/litegraph/. The world layer owns canonical entity identity
(WidgetEntityId, value store I/O) and must not depend on litegraph
types or values; this rule prevents accidental coupling.

Two existing imports in entityIds.ts (`NodeId`, `UUID`) are temporarily
suppressed with eslint-disable-next-line + TODO comments:
- NodeId will become a branded EntityId owned by src/world/.
- UUID is a primitive string brand and should move to src/utils/.

Amp-Thread-ID: https://ampcode.com/threads/T-019e2561-ca22-76fa-8356-0c6f92548f8d
Co-authored-by: Amp <amp@ampcode.com>
2026-05-14 11:45:07 -07:00
DrJKL
3c99b75312 fix(appMode): derive entityId for legacy POJO widgets
The appModeStore identity refactor required every selected input to map
to a `widget.entityId`, set by `BaseWidget.setNodeId`. Third-party
extensions that push plain POJO widgets directly onto `node.widgets`
(bypassing `addCustomWidget`) have no `entityId`, so their selections
were dropped from App Mode with a "no canonical identity available"
warning and the linear-widgets panel rendered empty.

Add `getWidgetEntityIdForNode(node, widget)` in `litegraphUtil` that
returns `widget.entityId` when present and otherwise derives
`(rootGraphId, node.id, widget.name)` directly. Apply the helper in:
- `safeWidgetMapper` so the Vue projection includes POJOs
- `appModeStore.findWidgetByEntityId` and the legacy-NodeId branch so
  POJO selections resolve on first selection and on reload

Fixes the `@vue-nodes In App Mode, widget width updates with panel size`
browser test for nodes that use the legacy `node.widgets.push(...)`
pattern.

Amp-Thread-ID: https://ampcode.com/threads/T-019e2561-ca22-76fa-8356-0c6f92548f8d
Co-authored-by: Amp <amp@ampcode.com>
2026-05-14 11:37:39 -07:00
github-actions
14e3fd336a [automated] Update test expectations 2026-05-14 08:00:45 +00:00
DrJKL
1a90d32ab9 fix(subgraph): migrate proxyWidgets and preview exposures on paste
`_deserializeItems` previously bypassed both the ADR 0009 proxyWidget
migration and preview-exposure hydration for pasted SubgraphNodes, so
older clipboard data lost promoted widgets and preview exposures.

- Run `proxyWidgetMigrationFlush` for every top-level pasted SubgraphNode
  carrying legacy `properties.proxyWidgets` (nested hosts already covered
  by `LGraph.configure`).
- Topologically sort `parsed.subgraphs` before configuring so nested
  SubgraphNodes' source-resolution sees a fully configured interior.
- Add `remapPreviewExposures` in `remapClipboardSubgraphNodeIds` to
  patch `properties.previewExposures[].sourceNodeId` alongside
  `proxyWidgets` after collision-driven interior id remap.
- Extract `autoExposeKnownPreviewNodes` from `promoteRecommendedWidgets`
  and expose via a new late-bound `LGraph.autoExposePreviewNodes` hook,
  invoked from both `LGraph.configure` and `_deserializeItems` so older
  data without `properties.previewExposures` still derives canvas-image
  previews from known interior node types.

Amp-Thread-ID: https://ampcode.com/threads/T-019e2486-6c35-720c-b983-82df2eda9c7a
Co-authored-by: Amp <amp@ampcode.com>
2026-05-13 23:52:15 -07:00
DrJKL
ba2a7423c6 fix(subgraph): unify preview projection and editor visibility on SubgraphNode
- Skip LivePreview render on SubgraphNode hosts; previews flow through
  promotedPreviews / previewExposureStore as the single source of truth
- Treat exposures as authoritative in SubgraphEditor.getActivePreviewWidgets
  so $$canvas-image-preview rows always surface, even when the interior
  source node lacks a matching promotable widget
- Hydrate canonical/legacy preview exposures at configure time in
  SubgraphNode._hydratePreviewExposures; usePromotedPreviews is now a
  pure read with path-style nested host fallback
- Add KSampler/KSamplerAdvanced to virtual canvas-image-preview node types
  so live samplers expose preview rows pre-execution
- SubgraphEditor.open() returns early when panel is already visible to
  avoid flaky context-menu opens
- Remove obsolete proxy-widget test; convert inline @vue-nodes name
  prefix to tag annotation across subgraphPromotion.spec.ts
- Drop stale "migrates legacy host exposure keys while reading previews"
  unit tests now that migration runs at configure time

Review feedback fixes:
- useResolvedSelectedInputs: clone graphNodes array to avoid shared
  reference with app.rootGraph.nodes
- previewExposureSchema: avoid mutating the property parameter
- nodeOutputStore: case-insensitive `.svg` check

Amp-Thread-ID: https://ampcode.com/threads/T-019e23c9-3dc1-7381-ae59-c0372b3325e2
Co-authored-by: Amp <amp@ampcode.com>
2026-05-13 20:11:06 -07:00
DrJKL
293eea446f test(appMode): match widget key by nodeId:name suffix of canonical entityId
Amp-Thread-ID: https://ampcode.com/threads/T-019e2260-c2ba-70b4-9962-1638be4bf645
Co-authored-by: Amp <amp@ampcode.com>
2026-05-13 16:34:24 -07:00
DrJKL
9bcb4c3df2 test(vueNodes): assert canonical entityId forwarded to AppInput
Amp-Thread-ID: https://ampcode.com/threads/T-019e2260-c2ba-70b4-9962-1638be4bf645
Co-authored-by: Amp <amp@ampcode.com>
2026-05-13 16:32:32 -07:00
DrJKL
e9db30bfd3 fix(subgraphEditor): exclude promoted widgets from candidate list
The candidate filter compared `toKey()` outputs across the active
section (PromotedWidgetView, key includes `:sourceNodeId` suffix) and
the interior section (raw widget, no suffix). The shapes never matched,
so every link-promoted widget kept showing in the Hidden section as
`icon-eye/eye-off`. Compare on the interior `(node.id, widget.name)`
identity instead.

Update the Properties panel test to demote a link-promoted widget via
the source-node context menu (since linked promotions render
`icon-link` and disable the editor toggle by design).

Amp-Thread-ID: https://ampcode.com/threads/T-019e2260-c2ba-70b4-9962-1638be4bf645
Co-authored-by: Amp <amp@ampcode.com>
2026-05-13 15:58:02 -07:00
DrJKL
d699ce603e Merge remote-tracking branch 'origin/main' into drjkl/subgraph-promoted-widget-ratchet-squashed 2026-05-13 15:14:29 -07:00
DrJKL
301d071255 fix(appBuilder): show displayName on unknown selected-input rows
The unknown-status branch of the input-select sidebar was binding
the title to the encoded entityId (e.g. graphId:nodeId:name) instead
of the persisted displayName. Users (and the appModePruning test)
saw an unreadable id where the original widget name should appear.

Amp-Thread-ID: https://ampcode.com/threads/T-019e2260-c2ba-70b4-9962-1638be4bf645
Co-authored-by: Amp <amp@ampcode.com>
2026-05-13 15:12:57 -07:00
DrJKL
d10e133b72 fix(appMode): persist canonical entityId on click and keep dangling entries
AppInput's togglePromotion was pushing a bare nodeId tuple into
selectedInputs. The new useResolvedSelectedInputs projection drops
non-canonical ids, so clicks in App Builder never produced sidebar
entries. Forward widget.entityId through useProcessedWidgets and
NodeWidgets so AppInput can persist the canonical WidgetEntityId.

upgradeAndValidateInput also pruned any entityId whose widget was
missing, even when the host node still existed. Restore the
node-exists fallback so dynamic widgets surface as 'Widget not visible'
rows instead of being silently dropped on load.

Amp-Thread-ID: https://ampcode.com/threads/T-019e2260-c2ba-70b4-9962-1638be4bf645
Co-authored-by: Amp <amp@ampcode.com>
2026-05-13 14:32:42 -07:00
DrJKL
560a1ef8ec test(vueNodes): add distinct widgets via addWidget
Pushing the same widget reference now collapses to one render row
because dedupe keys on entityId. Use addWidget with unique names so
the test exercises real reactive add behavior.

Amp-Thread-ID: https://ampcode.com/threads/T-019e2260-c2ba-70b4-9962-1638be4bf645
Co-authored-by: Amp <amp@ampcode.com>
2026-05-13 13:46:28 -07:00
AustinMroz
9cc09cd46c Add additional subgraph test fixtures and tests (#11806)
- Adds functions to SubgraphHelper to perform widget promotion by
standard user means
  - Right Click -> Promote
  - Properties Panel
- Adds new slot fixture code that works with simple `locator.dragTo`
operations.
- Adds multiple subgraph tests with a focus on historically difficult
operations.
- Fixes a bug where the litegraph `node.selected` state would not be
unset when switching graphs. This made it so 'Selecting a node ->
leaving subgraph -> re-enter subgraph -> right click on node' would fail
to select the node because it is marked as already selected.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11806-Add-helper-functions-for-widget-promotion-3536d73d365081f58dd9cd730c1a91a9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-13 20:35:57 +00:00
pythongosssss
de1c1ee1f2 fix: add support for parsing python generated json with NaN/infinite (#12217)
## Summary

API and other legacy JSON generated by python `json.dumps` can contain
`NaN` and `Infinity` which cannot be parsed with JS `JSON.parse`. This
adds regex to replace these invalid tokens with `null`.

## Changes

- **What**: 
- add regex replace on bare NaN/infinity tokens after JSON.parse fails
- update call sites
- tests

## Review Focus
- The regex should only rewrite bare NaN/-Infinity/Infinity and not
touch string values or other invalid tokens.
- A small regex was chosen over JSON5 due to package size (30.3kB
Minified, 9kB Minified + Gzipped) or a manual parser due to the
unnecessarily complexity vs a single regex replace.
- The happy path is run first, the safe parse is only executed if that
failed, meaning no overhead the vast majority of the time and no
possiblity of corrupting valid workflows due to a bug in the fallback
parser
- Multiple call sites had to be updated due to pre-existing architecture
of the various parsers, an issue for unifying these is logged for future
cleanup
- New binary fixtures added for validating e2e import using real files

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12217-fix-add-support-for-parsing-python-generated-json-with-NaN-infinite-35f6d73d365081889fc7f4af823f29c1)
by [Unito](https://www.unito.io)
2026-05-13 20:33:19 +00:00
AustinMroz
86b1e1a965 Fix descriptions on core blueprints (#12220)
Core blueprints were storing the description under a different key than
expected, which resulted in them displaying a placeholder description.
When initializing the description for a subgraph, this alternative field
is also checked.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/ed51c4a8-00cf-4927-9cba-880532a9e926"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/f19bf80d-adcc-4e9b-a9ba-a5ac8e089e2d"
/>|

Resolves FE-681

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12220-Austin-blueprint-descriptions-35f6d73d3650812fa04df48c203bebd1)
by [Unito](https://www.unito.io)
2026-05-13 20:30:45 +00:00
DrJKL
8273c97b2f refactor(appMode): resolve selectedInputs once at the computed boundary
Introduce `useResolvedSelectedInputs` that maps each persisted
`[entityId, displayName, config?]` entry to a discriminated union of
`{ status: 'resolved', node, widget, ... } | { status: 'unknown', ... }`.
Handlers (rename, remove, resize, bounding) now receive widget instances
directly instead of re-walking `rootGraph.nodes` per interaction.

- Delete `src/world/widgetLookup.ts`; the lone remaining caller
  (`upgradeAndValidateInput` in `appModeStore.ts`) inlines the lookup as a
  module-private function.
- `inlineRenameInput` deleted; the template calls `renameWidget(widget,
  node, $event)` directly with the in-scope widget.
- `getWidgetBounding` accepts a `ResolvedSelection`; returns `undefined`
  for unresolved entries.
- `useAppModeWidgetResizing.onPointerDown` and `updateInputConfig` accept
  a widget instance, matching the existing `removeSelectedInput` shape.
- Persisted `selectedInputs` shape unchanged.
- Unresolved entries continue to render as a removable "unknown widget"
  pill in the sidebar so users can clean up dangling selections.

Slot-rename → entityId-drift bug investigated and confirmed not present:
the `renaming-input` handler in `SubgraphNode.ts` mutates `widget.label`
and `input.label`, never `widget.name`, so `entityId` is stable across
renames. Documented as an invariant in `useResolvedSelectedInputs`.

Amp-Thread-ID: https://ampcode.com/threads/T-019e2260-c2ba-70b4-9962-1638be4bf645
Co-authored-by: Amp <amp@ampcode.com>
2026-05-13 13:07:33 -07:00
pythongosssss
4321013798 fix: resolve widget input link position drift on reload (#12214)
## Summary
The position of a link relative to its slot was able to drift on load,
due to widgets inside a node being able to resize without triggering an
node-level resize event (min-height node with space at the bottom could
have widgets expand into free space, causing misalignment).

Recreation:
1. Add KSampler
2. Add Float
3. Connect Float to KSamper.denoise
4. Reload workflow (F5)
5. Observe misalignment

## Changes

- **What**: 
- track widget grid element as signal only that triggers resync
- node bound calculations skipped for widget signals
- prevent setDirty on non-graph nodes (e.g. LGraphNodePreview)
- tests

## Review Focus
This is a small focused approach to fix the reported issue - it does not
address the underlying issue of the layout not being a SSOT. This fix is
a small bandaid and investigation into resolving the layout SOT issue is
not impacted by this.

## Screenshots (if applicable)

Before:
<img width="673" height="374" alt="image"
src="https://github.com/user-attachments/assets/2d34b8e3-0731-4fd2-8553-4dd429010ced"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12214-fix-resolve-widget-input-link-position-drift-on-reload-35f6d73d3650814eb31bebb3042ff58b)
by [Unito](https://www.unito.io)
2026-05-13 20:00:50 +00:00
pythongosssss
7ce0973386 fix: prevent first user template popup when following shared link (#12024)
## Summary

When a user who has not used the app before first loads up, they are
presented with the template selection dialog. This conflicts when the
first-time user visits the app via a share link - both the share &
template dialog are triggered.

## Changes

- **What**: 
- Skip the templates browser when share param is in URL
- Tests
- Add `url` to `setup`/`goto` to allow specifying the `share` parameter

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12024-fix-prevent-first-user-template-popup-when-following-shared-link-3586d73d365081cbbcecdba45a1ad1ea)
by [Unito](https://www.unito.io)
2026-05-13 19:19:54 +00:00
DrJKL
302693bcd9 test(subgraph): fix promoted widget store-key assertions
Drop the local `getHostStateName` helper that reconstructed the now-deleted
encoded `[name, sourceNodeId, sourceWidgetName].join(':')` format. Use
`widget.name` directly — the canonical store key after the entityId
migration in c1515374b. Fixes 2 failing tests in
SubgraphWidgetPromotion.test.ts.

Amp-Thread-ID: https://ampcode.com/threads/T-019e2260-c2ba-70b4-9962-1638be4bf645
Co-authored-by: Amp <amp@ampcode.com>
2026-05-13 12:14:31 -07:00
DrJKL
06614003f2 refactor(widgetValueStore): deprecate triple-keyed get/register
Steer new callers to `widgetValueIO`'s entityId-keyed helpers
(`getWidgetState`, `readWidgetValue`, `ensureWidgetState`). The branded
`WidgetEntityId` makes producer/consumer drift over `(graphId, nodeId, name)`
a type error rather than a silent value mix-up — this was the root cause of
the PR #12197 promoted-widget rendering bugs.

JSDoc-only; no behavior change.

Amp-Thread-ID: https://ampcode.com/threads/T-019e2260-c2ba-70b4-9962-1638be4bf645
Co-authored-by: Amp <amp@ampcode.com>
2026-05-13 12:07:20 -07:00
DrJKL
c1515374b8 refactor(subgraph): key promoted widget state by entityId, drop encoded host-state name
Replace the encoded `[name, sourceNodeId, sourceWidgetName].join(':')`
host-state-name with the canonical `WidgetEntityId` per ADR 0009. Producers
and consumers flip in the same diff:

- Delete `getPromotedWidgetHostStateName` (was internal disambiguator)
- `PromotedWidgetView` reads/writes via `widgetValueIO` keyed by `entityId`
- `SubgraphNode.serialize` reads via `readWidgetValue(widget.entityId)`
- `getExplicitHostWidgetValue` drops its now-unused `subgraphNode` param
- `safeWidgetMapper` no longer overrides `storeNodeId/storeName`; those
  fields are removed from `SafeWidgetData`
- `useProcessedWidgets` looks up state via `getWidgetState(entityId)`
- `getWidgetIdentity` collapses to `${entityId}:${type}` (type suffix
  preserves the PR #9896 invariant for node-def schema drift)

Net -66 LOC. No fallback / accept-either branches: entityId is the single
source of truth for widget identity across the renderer.

Amp-Thread-ID: https://ampcode.com/threads/T-019e2260-c2ba-70b4-9962-1638be4bf645
Co-authored-by: Amp <amp@ampcode.com>
2026-05-13 11:57:29 -07:00
Yourz
6e9be7b164 feat: add Anthropic partner icon (#12216)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds the Anthropic logo to the partner-node icon set so nodes whose
category ends in `Anthropic` (e.g. the Claude node added in
Comfy-Org/ComfyUI#13867 with `category="api node/text/Anthropic"`)
render the correct provider badge in the node library.

## Changes

- `packages/design-system/src/icons/anthropic.svg` — new auto-discovered
partner icon (Anthropic A glyph, sourced from
[lobehub/lobe-icons](https://github.com/lobehub/lobe-icons), uses
`fill="currentColor"` for theme adaptation)
- `src/utils/categoryUtil.ts` — register Anthropic's brand coral
`#D97757` as the badge border color
- `packages/design-system/src/css/style.css` — add `anthropic` to the
dynamic comfy-icon safelist so Tailwind/Iconify emits CSS for
`icon-[comfy--anthropic]` in production builds
- `src/utils/categoryUtil.test.ts` — regression tests for
`getProviderIcon('Anthropic')` and `getProviderBorderStyle('Anthropic')`

## Verification

- `pnpm typecheck` ✓
- `pnpm lint` ✓ (0 errors; 3 pre-existing warnings in unrelated files)
- `pnpm format:check` ✓
- `pnpm test:unit -- src/utils/categoryUtil.test.ts` ✓ (13/13)
- `pnpm build` ✓ — confirmed `comfy--anthropic` class is emitted into
`dist/assets/index-*.css`
- Manual visual check via Playwright against `pnpm dev`: injected `<i
class="icon-[comfy--anthropic]">` elements at badge size (10px) and 48px
alongside the existing OpenAI and BFL icons and confirmed the Anthropic
"A" glyph renders correctly in coral. See screenshot.

End-to-end visual verification of the live badge in the node library
requires Comfy-Org/ComfyUI#13867 to land first (the Claude node is what
produces the `Anthropic` category that triggers the icon lookup).

Related: Comfy-Org/ComfyUI#13867

## Screenshots

![Anthropic icon rendered in coral alongside OpenAI and BFL partner
icons at badge and large
sizes](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/c0076decedd8863eec6253b44e583da6b3eaacc20081d126aaf5267c72c8cc84/pr-images/1778683078329-49e37a7b-86ed-4ef2-988f-5702433f8412.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12216-feat-add-Anthropic-partner-icon-35f6d73d36508133a134fcafaf72f4f8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-13 17:01:49 +00:00
DrJKL
9233025ab4 refactor(appMode): unify widget identity via WidgetEntityId
Introduce a canonical `WidgetEntityId` (`graphId:nodeId:name`) sourced
from the widget instance itself, replacing the ad-hoc tuple plus
NodeLocatorId scheme used by app-mode selection.

- Add `src/world/` with branded `WidgetEntityId`, lookup by id, and
  value I/O helpers.
- Expose `entityId` on `IBaseWidget` / `BaseWidget` and
  `PromotedWidgetView`; surface it through the Vue widget mapper.
- Migrate `appModeStore` selection tuples and pruning to entity-id
  identity; legacy NodeId / NodeLocatorId tuples are upgraded forward.
- Update `AppBuilder`, `AppModeWidgetList`, and resize composable to
  consume entity ids instead of (nodeId, widgetName) pairs.

Also fix a multi-host primitive bypass bug in `proxyWidgetMigration`:
host values now land on each host's input mirror, never the shared
interior consumer widget, and a marker on the primitive lets later
hosts of the same subgraph reuse the bypassed `SubgraphInput` after
the first host severs the primitive's outputs.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e2235-1469-7501-bad7-388dcb5b4a29
2026-05-13 09:50:17 -07:00
Terry Jia
4b5b184cad FE-566: fix Painter mask submission edge cases on cloud (#12196)
## Summary
Rework the painter always hands the backend a valid asset reference:
- Drop the `hasStrokes` flag and the `isCanvasEmpty` check.
- `serializeValue` falls back to the existing `modelValue` when the
canvas element is transiently unmounted, reuses the cached upload when
not dirty and a value is present, and otherwise uploads the current
canvas (a fully transparent PNG is a valid no-op mask, Painter's Python
`execute()` treats painter_alpha=0 the same as "no mask painted").
- `handleClear` now also clears `modelValue` so a user-initiated clear
doesn't resurrect a stale upload on the next serialize.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12196-FE-566-fix-Painter-mask-submission-edge-cases-on-cloud-35e6d73d365081dd8856ddb785952526)
by [Unito](https://www.unito.io)
2026-05-13 10:19:39 -04:00
pythongosssss
129bfd9f1b fix: fix drop location and zindex of dragged in images (#12194)
## Summary

Images dragged into the canvas were placed at the last graph mouse
position, which is not updated during the drag event - meaning nodes
were created in "random" locations. Additionally, the z-index was not
set so newly created nodes can appear under other nodes.

## Changes

- **What**: 
- ensure added nodes are at top level
- update graph mouse pos with position from drop event
- tests

## Screenshots (if applicable)


Before / After

https://github.com/user-attachments/assets/34b4652e-a834-4c22-b191-2875a2404ac5

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12194-fix-fix-drop-location-and-zindex-of-dragged-in-images-35e6d73d3650814781edc9f4b4b5b223)
by [Unito](https://www.unito.io)
2026-05-13 10:35:16 +00:00
Christian Byrne
e60ae14bc0 feat(website): hide Free tier behind SHOW_FREE_TIER flag (#12165)
*PR Created by the Glary-Bot Agent*

---

## Summary

Disables the Free tier on the public marketing website
(`comfy.org/cloud/pricing` and `comfy.org/cloud`) behind a single
boolean flag so re-enabling is a one-line change.

The Free tier was already removed from the Comfy Cloud sign-up flow;
this PR removes the matching promotional surfaces on the marketing site
so users hit the paywall directly.

## Changes

- **New** `apps/website/src/config/features.ts` — exports
`SHOW_FREE_TIER` (currently `false`). Flip to `true` to restore the
previous UX.
- **`apps/website/src/components/pricing/PriceSection.vue`** — when
`SHOW_FREE_TIER` is `false`:
  - drops the Free plan card from the pricing array
  - desktop grid collapses from `lg:grid-cols-4` to `lg:grid-cols-3`
- Standard plan's "Everything in Free, plus:" intro is replaced with an
aria-hidden spacer so the three remaining cards stay vertically aligned
- **`apps/website/src/components/product/cloud/PricingSection.vue`** —
hides the "Start free. Upgrade when you're ready." tagline on the
`/cloud` pricing teaser.
- **New** `apps/website/e2e/pricing.spec.ts` — three @smoke tests
asserting the paid tiers + Enterprise are visible and that all Free-tier
surfaces are absent.

All translation strings (`pricing.plan.free.*`, `cloud.pricing.tagline`)
are retained so re-enabling requires no copy work.

## Verification

- `pnpm typecheck` — clean (0 errors, 0 warnings; pre-existing hint
unrelated)
- `pnpm lint` / `oxfmt` — clean
- `pnpm test:unit` — 30/30 passing
- Playwright e2e (desktop project) — `pricing.spec.ts` 3/3 passing,
`cloud.spec.ts` 13/13 still passing
- Visual: desktop and mobile pricing pages render with balanced 3-column
layout; `/cloud` teaser card still proportional.

Screenshot below.

## Screenshots

![/cloud/pricing desktop with Free tier hidden: balanced 3-column layout
(Standard, Creator, Pro) and Enterprise section
below](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/732492b43e4794dae051b2b86643e7a5d3daebce028765f27470561681d4f1db/pr-images/1778561413584-9c1a82d1-781f-49d4-9a26-cc7523c903c4.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12165-feat-website-hide-Free-tier-behind-SHOW_FREE_TIER-flag-35e6d73d36508164b4dfcfe9fee6b5e7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-13 05:40:09 +00:00
DrJKL
acd71a6085 refactor: consolidate Outcome types and replace push loops with flatMap
- proxyWidgetMigration.ts: collapse three *Result discriminated unions
  into a single Outcome<TOk, TReason>; merge quarantineFor into
  makeQuarantineEntry; SnapshotLink extends PrimitiveBypassTargetRef;
  rename cohortReferencesPrimitive -> cohortDuplicatesPrimitive and
  drop mutable counter
- SubgraphNode.serialize: replace for+push+hasSerializableValue flag
  with flatMap and .some()
- appModeStore: replace nested for+continue+push with nested flatMap

Amp-Thread-ID: https://ampcode.com/threads/T-019e1f3a-a617-70b4-8945-3f0fbd34c1de
Co-authored-by: Amp <amp@ampcode.com>
2026-05-12 22:37:06 -07:00
DrJKL
aacb3eb5c3 refactor(usePromotedPreviews): extract helpers, drop mutable accumulators
- Add loadOrMigrateExposures(rootGraphId, primary, ...legacy) used by
  both top-level host resolution and resolveNestedHost; deduplicates
  the 'try primary -> fall back -> migrate to primary' pattern.
- Add readReactivePreviewUrls() that bundles the 4-way reactive
  dependency reads with the URL fetch fallback, keeping the comment
  about Vue dependency tracking next to the code that depends on it.
- Replace the 3-way previewMediaType ternary with a lookup map +
  default via getPreviewMediaType().
- Replace the imperative for-loop + previews.push() + continue chain
  with exposures.flatMap(); eliminates the last 'let' in the function.
- Drop the exposurePairs rename map; read exposure.name /
  exposure.sourceNodeId / exposure.sourcePreviewName directly.

Amp-Thread-ID: https://ampcode.com/threads/T-019e1f3a-a617-70b4-8945-3f0fbd34c1de
Co-authored-by: Amp <amp@ampcode.com>
2026-05-12 21:11:02 -07:00
DrJKL
d3750d7cba test(usePromotedPreviews): collapse hoisted mocks to inline singleton
Replace five separate vi.hoisted(() => vi.fn()) declarations plus the
useNodeOutputStoreMock factory with a single inline vi.mock factory
that owns the singleton store. Tests configure individual methods via
vi.mocked(useNodeOutputStore().method), matching the project's
vitest-patterns guidance and the pattern used in useGLSLPreview.test.

Amp-Thread-ID: https://ampcode.com/threads/T-019e1f3a-a617-70b4-8945-3f0fbd34c1de
Co-authored-by: Amp <amp@ampcode.com>
2026-05-12 21:02:56 -07:00
Comfy Org PR Bot
b172534f55 [chore] Update Ingest API types from cloud@9619326 (#12202)
## Automated Ingest API Type Update

This PR updates the Ingest API TypeScript types and Zod schemas from the
latest cloud OpenAPI specification.

- Cloud commit: 9619326
- Generated using @hey-api/openapi-ts with Zod plugin

These types cover cloud-only endpoints (workspaces, billing, secrets,
assets, tasks, etc.).
Overlapping endpoints shared with the local ComfyUI Python backend are
excluded.

---------

Co-authored-by: MillerMedia <7741082+MillerMedia@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-05-13 03:47:03 +00:00
DrJKL
ed93beaeef test(usePromotedPreviews): clean up mocks, parameterize media-type cases
- Replace vi.clearAllMocks() + four explicit mockReset() calls with
  single vi.resetAllMocks().
- Drop dead nodeOutputStore variable; inline the per-test mock store
  assignment.
- Parameterize video/audio media-type cases via it.for.
- Rename the previously misleading 'uses preview exposures by source
  preview name' test to clearly target the default-image branch when
  previewMediaType is unset.
- Extract arrangePromotedPreview() helper to collapse repeating four-line
  setup boilerplate.
- Replace '$$canvas-image-preview' magic strings with the existing
  CANVAS_IMAGE_PREVIEW_WIDGET constant.

Amp-Thread-ID: https://ampcode.com/threads/T-019e1f3a-a617-70b4-8945-3f0fbd34c1de
Co-authored-by: Amp <amp@ampcode.com>
2026-05-12 20:40:56 -07:00
DrJKL
4fa8948959 test(subgraph): tag new tests @vue-nodes and add DOM assertions
Audit finding: 3 of 4 new tests in this PR either lacked the @vue-nodes
tag or relied solely on canvas/state assertions. Bring them in line with
the rest of the @vue-nodes coverage:

- 'Legacy primitive proxy widgets migrate...' — add @vue-nodes tag and
  assert host renders 2 migrated widget rows before and after reload.
- 'Nested preview exposures render...' — add explicit .lg-node-widgets
  absence assertion (preview-only host has no widgets container).
- 'Legacy unresolvable proxy entry...' — add @vue-nodes tag and assert
  the missing_widget label is not rendered on the host.

Amp-Thread-ID: https://ampcode.com/threads/T-019e1f3a-a617-70b4-8945-3f0fbd34c1de
Co-authored-by: Amp <amp@ampcode.com>
2026-05-12 20:17:05 -07:00
GitHub Action
774187e075 [automated] Apply ESLint and Oxfmt fixes 2026-05-13 03:08:05 +00:00
DrJKL
ed9e984cd2 test(subgraph): use evaluateAll generic instead of HTMLInputElement cast
Amp-Thread-ID: https://ampcode.com/threads/T-019e1f3a-a617-70b4-8945-3f0fbd34c1de
Co-authored-by: Amp <amp@ampcode.com>
2026-05-12 20:04:21 -07:00
DrJKL
936fd75b69 test(subgraph): drop bespoke RawHostSnapshot interface
Inline the page.evaluate call into getPromotedWidgets so TypeScript infers
the structural shape from the canonical NodeProperty type on
SerializedNode.properties. No separate intermediate type needed.

Amp-Thread-ID: https://ampcode.com/threads/T-019e1f3a-a617-70b4-8945-3f0fbd34c1de
Co-authored-by: Amp <amp@ampcode.com>
2026-05-12 20:01:52 -07:00
DrJKL
88129d0e5a test(subgraph): assert .lg-node-widgets is absent for preview-only hosts
Tie the test to the NodeWidgets v-if="nodeData.widgets?.length" gate so a
regression that renders the .lg-node-widgets container for hosts whose only
promoted content is preview exposures will fail.

Amp-Thread-ID: https://ampcode.com/threads/T-019e1f3a-a617-70b4-8945-3f0fbd34c1de
Co-authored-by: Amp <amp@ampcode.com>
2026-05-12 19:59:21 -07:00
DrJKL
ce2f76c788 test(subgraph): use canonical guards/schemas for promoted widget snapshots
Refactor PR-review feedback on SubgraphHelper.ts and promotedWidgets.ts:

- Replace inline boolean checks (that masqueraded as type guards) with
  real `is`-predicate functions: `isPromotedWidgetSource`, `isNodeProperty`.
- Drop duplicated `[string, string]` tuple shapes in favor of the existing
  `PromotedWidgetEntry` type alias.
- Move validation outside `page.evaluate` so we can use the canonical
  `parsePreviewExposures` zod schema and the shared `PromotedWidgetSource`
  type from `src/core/...`.
- Eliminate type assertions; narrow via `in` checks and predicate guards.

Amp-Thread-ID: https://ampcode.com/threads/T-019e1f3a-a617-70b4-8945-3f0fbd34c1de
Co-authored-by: Amp <amp@ampcode.com>
2026-05-12 19:57:09 -07:00
DrJKL
a1484f6ccb fix(subgraph): per-host promoted widget rendering and legacy migration
Two CI Playwright failures in the multi-host primitive fanout test and
the legacy-prefixed proxyWidget normalization test traced to the same
class of mismatch: the migration wrote per-host widget state to a key
the Vue rendering layer didn't read from, and `classify()` accepted
source widgets that `repairCreateSubgraphInput()` then rejected.

Failure 1 — Per-host independence
The migration's `setHostWidgetState` registers values under
`(rootGraphId, hostNodeId, "<name>:<sourceNodeId>:<sourceWidgetName>")`,
but `safeWidgetMapper` produced `storeNodeId/storeName` pointing at the
shared interior key `(rootGraphId, sourceNodeId, sourceWidgetName)`. The
host-scoped value was therefore invisible to Vue; the rendered input
fell back to the empty interior default.

Add `ensureHostWidgetState()` on `PromotedWidgetView` that idempotently
registers a host-scoped store entry seeded with the current effective
value (factored shared registration into `registerHostWidgetState`).
Update `safeWidgetMapper` so promoted widgets call it and expose the
host-scoped `storeNodeId`/`storeName` derived from
`getPromotedWidgetHostStateName`. Two unit tests asserted the previous
interior-keyed identity; updated to assert the host-scoped key while
preserving the same distinct-identity invariant.

Failure 2 — Legacy nested proxy entries
`classify()` resolved source widgets via
`findSourceWidget(...) ?? getPromotableWidgets(...).find(...)`, but
`repairCreateSubgraphInput()` used only `findSourceWidget(...)`. A
legacy entry whose source was a nested SubgraphNode passed
classification then quarantined at repair time, leaving the host with
fewer rendered widgets than expected.

Extract a shared `resolveSourceWidget()` helper applying the same
fallback in both call sites.

Amp-Thread-ID: https://ampcode.com/threads/T-019e1f0b-176e-7659-80f5-20323ac54898
Co-authored-by: Amp <amp@ampcode.com>
2026-05-12 19:40:42 -07:00
DrJKL
d9209ae44b test(subgraph): use real asset fixtures and @vue-nodes tags
Replace in-test workflow synthesis with saved-workflow assets so the
subgraph serialization tests load fixtures the same way a user would.
Drop the local mutable-workflow types and the manual setting toggles by
adopting the @vue-nodes tag, which auto-enables Vue Nodes and waits
for the rendered surface.

Amp-Thread-ID: https://ampcode.com/threads/T-019e1f0b-176e-7659-80f5-20323ac54898
Co-authored-by: Amp <amp@ampcode.com>
2026-05-12 19:39:48 -07:00
Comfy Org PR Bot
a0150ffe17 1.45.6 (#12204)
Patch version increment to 1.45.6

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12204-1-45-6-35f6d73d365081fc8539ca25a55aac74)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
v1.45.6
2026-05-13 02:01:42 +00:00
Christian Byrne
c92030b158 refactor: deduplicate Civitai hostname logic in getSourceName (#11822)
## Summary

- Extract `isCivitaiHost` private helper from `isCivitaiModelUrl` in
`formatUtil.ts` for DRY hostname checking
- Add `isCivitaiUrl` exported function for hostname-only Civitai URL
detection (distinct from `isCivitaiModelUrl` which also validates the
path format)
- Refactor `getSourceName` in `assetMetadataUtils.ts` to use the shared
`isCivitaiUrl` instead of inline duplicate hostname checks
- Add tests for `isCivitaiUrl` covering `.com`, `.red`, subdomain, and
invalid URL cases

## Changes

- `packages/shared-frontend-utils/src/formatUtil.ts` — add
`isCivitaiHost` private helper + export `isCivitaiUrl`; refactor
`isCivitaiModelUrl` to use helper
- `packages/shared-frontend-utils/src/formatUtil.test.ts` — add
`isCivitaiUrl` test suite
- `src/platform/assets/utils/assetMetadataUtils.ts` — import
`isCivitaiUrl` from `@/utils/formatUtil`; remove inline hostname logic
from `getSourceName`

## Testing

### Automated

- Added `isCivitaiUrl` test suite (6 cases: `.com`, `.red`, subdomains,
non-Civitai, invalid URL)
- All 71 existing `formatUtil` tests pass
- All 53 existing `assetMetadataUtils` tests pass (behavior preserved)
- TypeScript typecheck passes

### E2E Verification Steps

1. Run unit tests: `npx vitest run
packages/shared-frontend-utils/src/formatUtil.test.ts
src/platform/assets/utils/assetMetadataUtils.test.ts`
2. Expected: all tests pass
3. Verify `getSourceName('https://civitai.red/models/123')` returns
`'Civitai'`
4. Verify `isCivitaiUrl('https://civitai.com/models/any-path')` returns
`true`
5. Verify `isCivitaiModelUrl` still rejects non-API paths while
`isCivitaiUrl` accepts them

## Review Focus

`isCivitaiUrl` (new, hostname-only) vs `isCivitaiModelUrl` (existing,
hostname+path format): `getSourceName` needs to recognize ANY Civitai
URL as a source, so using `isCivitaiModelUrl` directly would incorrectly
reject valid browse URLs like `civitai.com/models/123`.

Closes #11357

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11822-refactor-deduplicate-Civitai-hostname-logic-in-getSourceName-3546d73d36508110974ccc3b7384d82b)
by [Unito](https://www.unito.io)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 18:33:33 -07:00
Dante
988d532467 fix(queue): contain JobDetailsPopover error message overflow (#12173)
## Summary

Cap the Job Details "Error message" block at `max-h-96` (24rem / 384px)
with an internal scroll, wrap long unbreakable tokens (filenames, JSON),
and preserve newlines so the failed-job popover no longer grows
unbounded.

## Changes

- **What**: Added `max-h-96 overflow-y-auto whitespace-pre-wrap
wrap-break-word` to the error message container in
`JobDetailsPopover.vue`, plus a `FailedWithLongError` Storybook story
covering the overflow case.

## Review Focus

- 24rem cap was set per Alex's spec in the [Slack
thread](https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1778506109115989).
- `wrap-break-word` (Tailwind 4 canonical of `break-words`) is needed
because long underscore-joined filenames don't break naturally;
`whitespace-pre-wrap` preserves any newlines in the raw error.
- Not in scope: the popover z-index clipping issue Alex flagged later in
the same thread — that's a separate follow-up.

Fixes FE-660

## Screenshots

**Before** — error block grows unbounded with the panel:


![before](https://github.com/Comfy-Org/ComfyUI_frontend/raw/jaewon/fe-660-contain-textarea-overflow-and-cap-its-max-height/.github/pr-images/fe-660-before.png)

**After** — error block capped at 384px and internally scrollable:


![after](https://github.com/Comfy-Org/ComfyUI_frontend/raw/jaewon/fe-660-contain-textarea-overflow-and-cap-its-max-height/.github/pr-images/fe-660-after.png)

Reproduce locally via Storybook: `pnpm storybook` → Queue →
JobDetailsPopover → **FailedWithLongError**.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12173-fix-queue-contain-JobDetailsPopover-error-message-overflow-35e6d73d3650812d9873e5d163cad0c6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-05-12 17:51:52 -07:00
DrJKL
ff72e5789d fix(subgraph): resolve link-only inputs to host-opaque target (ADR 0009)
resolveSubgraphInputTarget previously gated on getTargetWidget() before
the SubgraphNode-host branch, so an outer SubgraphInput linked to a
nested SubgraphNode whose inner slot has no concrete widget resolved
to undefined. The consumer in useGraphNodeManager then fell back to
the interior PromotedWidgetView's deep (sourceNodeId, sourceWidgetName)
identity, which contradicts ADR 0009's host-scoped opaque contract:
identity is (hostNodeLocator, SubgraphInput.name).

Reorder so the SubgraphNode-host branch runs first and returns
(child.id, targetInput.name) regardless of whether the inner slot has
a backing widget. The widget lookup remains the gate for the
plain-interior-node branch, where a non-widget input legitimately has
no promotable target.

Three existing tests asserted the pre-ADR undefined behavior on
nested-SubgraphNode + non-widget inputs. Update those assertions and
test names to reflect the host-opaque contract; capture the inner
node id from the helper since two helper calls collide on the
hard-coded id.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e1e84-3270-730a-8c95-49a383ffdf20
2026-05-12 17:01:24 -07:00
AustinMroz
fe08ad2fcd Fix pre-commit linter skipping type checks (#12203)
Adds the `--type-aware` so that the typechecks performed by precommit
hooks have parity with the results output by a full `pnpm lint`

Most notably, unawaited promises would not be caught by the precommit
hooks prior to this PR.
```
    × typescript-eslint(no-floating-promises): Promises must be awaited, add void operator to ignore.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       
      ╭─[browser_tests/fixtures/utils/vueNodeFixtures.ts:45:5]                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              
   44 │   async select() {                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  
   45 │     this.header.click()                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             
      ·     ───────────────────                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             
   46 │   }                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 
      ╰──── 
```

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12203-Fix-pre-commit-linter-skipping-type-checks-35e6d73d365081a4adade833294df7ed)
by [Unito](https://www.unito.io)
2026-05-12 23:56:12 +00:00
DrJKL
4c0df82aee fix(subgraph): address review blockers on promotion + clone hygiene
- proxyWidgetMigration: when a disambiguator is supplied but doesn't
  match, only fall back to non-promoted widgets with the same name so
  we don't silently bind to a sibling PromotedWidgetView.
- previewExposureChain: chainFromLastStep was using the already-advanced
  currentRootGraphId for the leaf, producing an inconsistent
  (rootGraphId, sourceNodeId) pair on cycle / no-exposure / max-depth
  exits. Use the last pushed step's rootGraphId instead.
- promotedWidgetView.applyValueControlToHost: control-after-generate
  was reassigning this.value, which cascades into the shared interior
  widget via the value setter. Switch to hydrateHostValue so the
  per-host overlay is updated without touching shared state.
- promotionUtils: capture promoteValueWidgetViaSubgraphInput results at
  both call sites and emit a warning Sentry breadcrumb on failure
  instead of silently dropping {ok:false, reason}.
- LGraphNode.clone: in UUID mode, configure() was overwriting the
  borrowed source id with the freshly generated UUID before subclass
  hydration could run. Generate the UUID up front and reapply it after
  configure() so hydration sees the borrowed id and the cloned node
  still gets a fresh identity.

Amp-Thread-ID: https://ampcode.com/threads/T-019e1e84-3270-730a-8c95-49a383ffdf20
Co-authored-by: Amp <amp@ampcode.com>
2026-05-12 16:49:33 -07:00
Alexander Brown
93edf166d0 fix(website): link careers page to Ashby job description, not application form (#12200)
*PR Created by the Glary-Bot Agent*

---

## Summary

The careers page at comfy.org/careers was linking every role to its
Ashby application form (`.../{id}/application`) instead of the job
description page (`.../{id}/`). Users expect to first read the role
description, not land on the submit-resume page.

Ashby's job board API returns both `jobUrl` (description) and `applyUrl`
(application form). `toDomainRole` was preferring `applyUrl`; this PR
switches to `jobUrl` and renames the `Role` field accordingly so the
field name matches its meaning.

## Changes

- `apps/website/src/utils/ashby.ts`: use `job.jobUrl` directly instead
of `job.applyUrl ?? job.jobUrl`.
- `apps/website/src/data/roles.ts`: rename `Role.applyUrl` →
`Role.jobUrl`.
- `apps/website/src/components/careers/RolesSection.vue`: update the `<a
:href>` binding.
- `apps/website/src/data/ashby-roles.snapshot.json`: regenerated
fallback snapshot — URLs stripped of `/application`, `id`s recomputed
from the new URLs.
- Unit + E2E tests updated; new E2E assertion that links do not end in
`/application` prevents regressions.

The Ashby schema (`ashby.schema.ts`) still accepts `applyUrl` since the
API returns it — we just no longer consume it.

## Verification

- `pnpm test:unit` — 70/70 pass
- `pnpm typecheck` — 0 errors
- `pnpm build` — succeeds; inspected `dist/careers/index.html`, all 19
Ashby links now point to description URLs and zero contain
`/application`
- Oracle code review — 0 issues

Fixes user report in #hiring-ideas (Slack).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12200-fix-website-link-careers-page-to-Ashby-job-description-not-application-form-35e6d73d3650815cbedadf974f7d3364)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-12 23:32:15 +00:00
DrJKL
ce47bc3771 test(subgraph): assert per-host widget independence via Vue node DOM
Switch the multi-host primitive fanout test to @vue-nodes and assert
against rendered widget inputs. Previous version asserted the same
string-widget value 8 times instead of covering both promoted widgets
per host.

Amp-Thread-ID: https://ampcode.com/threads/T-019e1e14-fdd4-70f4-a3f7-da15caefcd71
Co-authored-by: Amp <amp@ampcode.com>
2026-05-12 15:31:59 -07:00
DrJKL
c9bf0eb023 test: replace it.each with it.for to satisfy consistent-each-for
Amp-Thread-ID: https://ampcode.com/threads/T-019e1e22-c4f0-76fb-b57e-e325abb07d6d
Co-authored-by: Amp <amp@ampcode.com>
2026-05-12 14:59:14 -07:00
DrJKL
cf2a07098b Merge remote-tracking branch 'origin/main' into drjkl/subgraph-promoted-widget-ratchet-squashed
Amp-Thread-ID: https://ampcode.com/threads/T-019e1e0b-1b64-778a-9cbc-e2675fe6a11b
Co-authored-by: Amp <amp@ampcode.com>

# Conflicts:
#	src/schemas/nodeDefSchema.ts
#	src/stores/nodeOutputStore.ts
2026-05-12 14:22:44 -07:00
DrJKL
92e26d7651 test(subgraph): assert promoted widget values via Vue node DOM
Tag the duplicate-widget-names test with @vue-nodes and use DOM
locators (`getNodeLocator`, `getByRole('textbox')`, `enterSubgraph`)
instead of `page.evaluate`-based graph introspection. The outer
node assertion now checks the rendered textbox value directly, and
the inner node assertion enters the outer subgraph and reads the
two distinguishable text inputs from the inner SubgraphNode in the
DOM.

Amp-Thread-ID: https://ampcode.com/threads/T-019e1db0-3c55-75fe-a542-8bfcbcf87d60
Co-authored-by: Amp <amp@ampcode.com>
2026-05-12 14:10:21 -07:00