Compare commits

...

6 Commits

Author SHA1 Message Date
Christian Byrne
dfcb336499 fix: give backport push a token that can update workflow files (#13038)
## Summary

The `PR Backport` workflow silently fails for any PR that also modifies
a file under `.github/workflows/**`.

## Root cause

The `backport` job checks out with the default `GITHUB_TOKEN` and reuses
those persisted credentials for `git push`. GitHub refuses to let that
token create or update workflow files:

```
! [remote rejected]  backport-12804-to-core-1.45 -> backport-12804-to-core-1.45
  (refusing to allow a GitHub App to create or update workflow
   `.github/workflows/ci-tests-e2e.yaml` without `workflows` permission)
error: failed to push some refs
```

The cherry-pick itself succeeds — only the push is rejected. And because
the `run:` step inherits `set -e`, the loop aborts before writing the
`failed=` output, so the "Comment on failures" step (`if: failure() &&
steps.backport.outputs.failed`) posts nothing. The result is a red job
with no explanation on the PR.

## History

PR #12804 touched `.github/workflows/ci-tests-e2e.yaml` and
`.github/actions/setup-frontend/action.yaml`. Its backport run
([27788259837](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27788259837/job/82230406910))
failed exactly this way: cherry-pick clean on every target, push
rejected on the workflow file. All four backports (#12966, #12967,
#12968, #12969) had to be created manually.

## Changes

Check out with `PR_GH_TOKEN` (already used by the Create-PR step) so the
push carries `workflow` scope.

> [!IMPORTANT]
> `PR_GH_TOKEN` must have **workflow** write permission for this to take
effect. If it does not, the secret needs that scope added.

## Follow-up (not in this PR)

The push failure aborts the whole job under `set -e` with no PR comment.
Even with the token fixed, a push rejected for another reason (branch
protection, etc.) would still fail silently. Wrapping the push so a
single-target failure is recorded as a `push-failed` reason and reported
via the existing failure-comment step would make the workflow degrade
gracefully.

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-26 20:37:02 +00:00
jaeone94
caabebe145 Redesign missing model detection contract for promoted subgraph widgets (#13059)
## Summary

Redesign missing-model detection for ADR 0009 promoted subgraph widgets
so candidates are created from the widget value the user can actually
edit, while still using the concrete interior widget as the
schema/options source.

## Why This PR Exists

This PR comes from the follow-up missing-model detection work for the
ADR 0009 / 1.46 subgraph widget changes introduced by
[#12197](https://github.com/Comfy-Org/ComfyUI_frontend/pull/12197).

[#12197](https://github.com/Comfy-Org/ComfyUI_frontend/pull/12197)
intentionally changed promoted subgraph widgets to be represented
through subgraph input links. After that change, the promoted widget on
the host `SubgraphNode` is the editable value owner, and linked interior
widgets are no longer guaranteed to mirror that value.

The old missing-model contract still treated the concrete interior node
widget as the effective source of truth in subgraph cases:

- recursive scans entered the subgraph and scanned the interior widget
value;
- candidates were keyed by the interior node/widget identity;
- the parent subgraph host mostly received propagated
highlight/navigation behavior;
- subgraph container widgets were not treated as first-class candidate
sources.

That contract breaks after ADR 0009. A user can resolve a missing model
by changing the promoted host widget to an installed model, while the
linked interior widget can still hold the old stale value. If detection
keeps scanning the linked interior value, entering the subgraph or
reloading the workflow can re-create a false missing-model error that no
longer corresponds to the value the user can edit.

ADR 0009 also means the same subgraph definition can be reused by
multiple `SubgraphNode` hosts. Once missing-model detection moves from
the interior definition widget to the promoted host widget, the selected
value is no longer a property of the shared definition alone. It is a
property of a specific host instance. That makes the old interior-node
identity insufficient for mode changes, removal handling, and re-scan
behavior: a single interior leaf definition can be reachable through
multiple host execution paths, and only the affected host path should
add, remove, or restore a candidate.

This PR also builds on
[#12990](https://github.com/Comfy-Org/ComfyUI_frontend/pull/12990),
which narrowed workflow-level `models[]` and embedded model data to
metadata enrichment only. Together, the intended boundary is:

- live widgets create missing-model candidates;
- workflow/root-level `models[]` and node metadata only enrich
candidates that already came from a live widget;
- promoted widget values are read from the editable host widget, not
inferred from stale interior `widgets_values`.

## Changes

- **What**:
  - Introduces promoted-widget scan targets that split:
    - host promoted widget value and candidate identity;
    - concrete leaf widget/node definition data.
- Scans the outermost unlinked promoted widget on a `SubgraphNode` host
as the selected value owner.
- Skips linked interior widgets as candidate sources, preventing stale
linked widget values from producing duplicate or false missing-model
candidates.
- Resolves the concrete leaf widget for combo options, asset-widget
support, node type, directory lookup, and embedded metadata enrichment.
- Keys promoted missing-model candidates by the host execution id and
host promoted widget name.
- Adds `sourceExecutionId` to promoted candidates so liveness still
follows the concrete source execution path, including nested inactive
subgraph containers.
- Uses source-scope activity for pipeline filtering and async
verification, while keeping highlight/store/clearing identity
host-keyed.
- Removes host-keyed promoted candidates when their source execution
scope is removed or bypassed.
- Re-scans ancestor subgraph hosts when an interior source path is
un-bypassed, so host-keyed promoted errors can reappear correctly.
- Handles shared subgraph definitions by deriving promoted source paths
from the concrete host instance path, rather than treating the shared
definition node id as globally unique.
- Shares promoted source resolution between Vue node processing and the
right-side panel to avoid drift.
- Aligns missing-model clearing across Vue node widgets, legacy canvas
widgets, and right-side panel Parameters/Nodes section widgets.
- Adds unit coverage for scan identity, source-scope liveness, dynamic
mode changes, source-scope removal, shared-definition host isolation,
and right-side panel clearing.
- Adds nested promoted-widget E2E coverage for OSS and Cloud flows
across Vue, Parameters tab, and legacy widget surfaces.
- **Breaking**: None expected.
- **Dependencies**: None.

## New Detection Contract

A missing-model candidate is created from an unlinked final editable
value owner.

That value owner can be:

- a normal node model widget; or
- the outermost promoted model widget displayed on a `SubgraphNode`
host.

For promoted widgets:

- the host promoted widget supplies the selected value;
- the host execution id and host widget name are the candidate identity;
- the concrete leaf widget supplies definition data such as combo
options and asset-browser support;
- the concrete source execution path is retained as `sourceExecutionId`
for liveness only;
- linked interior widgets are skipped as candidate sources because their
values are not authoritative when driven by a promoted input.

For a nested chain:

`Outer promoted widget A -> inner promoted widget B -> concrete widget
C`

only `A` creates the candidate. `B` and `C` are linked along the
promoted-input path and are skipped as selected-value sources, while `C`
still provides the concrete widget definition used to evaluate `A`.

## Shared Definition And Source-Scope Liveness

ADR 0009 promoted widgets make subgraphs behave more like reusable
definitions with host-owned inputs. Two host `SubgraphNode`s can point
at the same interior subgraph definition while carrying different
promoted widget values. In that shape, the missing-model candidate must
be keyed to the editable host surface, but the activity check cannot use
the host id alone.

For example, if two outer hosts share the same nested subgraph
definition, one host can select a valid model while the other still
selects a missing model. The result should be one missing-model
reference, not a single definition-level error and not two errors after
one host is fixed. Likewise, bypassing or un-bypassing an interior
nested container should affect only the host execution paths that
actually pass through that container.

This PR therefore separates two concepts:

- **candidate identity**: host execution id + host promoted widget name,
used for storage, highlight, navigation, and clearing;
- **candidate liveness**: concrete source execution path, used for
scan-time activity checks, pipeline filtering, async verification,
source-scope removal, and re-exposure after mode changes.

That separation is the reason this PR updates more than the scan itself.
Moving the detection target to the subgraph host also requires the
mode-change and removal paths to understand that a host-keyed candidate
can be invalidated by a descendant source path, and can need to be
restored by re-scanning an ancestor host when an interior source path
becomes active again.

## Review Focus

Please review the identity split carefully:

- candidate/store/highlight/clearing identity should remain host-keyed
for promoted widgets;
- liveness should use `sourceExecutionId` when present, falling back to
`nodeId` for normal candidates;
- scan-time activity checks should account for the source node itself
and all ancestor subgraph containers;
- source-scope removal should remove host-keyed candidates whose
concrete source path was removed or bypassed;
- un-bypassing an interior source path should re-scan affected ancestor
subgraph hosts so host-keyed candidates can reappear;
- shared subgraph definitions should not merge errors across different
host instances;
- linked interior widgets should not produce their own missing-model
candidates;
- asset-browser eligibility should be resolved from the concrete leaf
node type and widget name, not the synthetic subgraph host type;
- right-side panel edits should clear host missing-model errors and
source validation errors consistently.

The E2E matrix intentionally keeps nested promoted workflows only.
Nested promoted widgets cover the same editable host path as single
promoted widgets while also exercising the `A -> B -> C` chain that can
break source-scope liveness and re-scan behavior. The nested fixture
also includes multiple host instances that share the same subgraph
definition, so it verifies that fixing one host does not accidentally
clear or suppress another host's missing-model candidate. Direct/single
promoted behavior is still covered at the unit level.

## Non-Goals

- This PR does not reintroduce workflow-level `models[]` candidate
creation.
- This PR does not infer selected model values from `widgets_values`.
- This PR does not synchronize linked interior widget values back from
promoted host widgets.
- This PR does not redesign missing-media scanning; missing media still
skips subgraph containers and remains keyed by concrete interior paths.
The shared async post-verification active-scope filter is intentionally
stricter, so a pending missing-media candidate is no longer surfaced if
its own node is bypassed or removed while verification is in flight.

## Validation

- `pnpm exec vitest run
src/components/rightSidePanel/parameters/SectionWidgets.test.ts
src/platform/missingModel/missingModelScan.test.ts
src/composables/graph/useErrorClearingHooks.test.ts
src/platform/missingModel/missingModelPipeline.test.ts
src/platform/missingModel/missingModelStore.test.ts
src/utils/graphTraversalUtil.test.ts
src/composables/graph/useGraphNodeManager.test.ts
src/renderer/extensions/vueNodes/composables/useProcessedWidgets.test.ts
--reporter=dot`
  - 8 files passed, 294 tests passed.
- `pnpm exec vitest run
src/platform/missingModel/missingModelScan.test.ts
src/core/graph/subgraph/resolveConcretePromotedWidget.test.ts
src/components/rightSidePanel/parameters/SectionWidgets.test.ts`
  - 3 files passed, 71 tests passed.
- `pnpm typecheck`
- `pnpm typecheck:browser`
- `pnpm format:check`
- targeted ESLint for changed production/unit/E2E files
- `git diff --check`
- `pnpm build`
- `pnpm build:cloud`
- OSS affected E2E on the 8188 build:
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:8188 pnpm
exec playwright test
browser_tests/tests/propertiesPanel/errorsTabModeAware.spec.ts
--project=chromium --grep "Changing an OSS .*promoted|Refreshing a
resolved promoted|Reloading a resolved nested"`
  - 5 passed.
- Cloud affected E2E on the 8188 cloud build:
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:8188 pnpm
exec playwright test
browser_tests/tests/propertiesPanel/errorsTabCloudMissingModels.spec.ts
--project=cloud --grep "Changing a Cloud .*promoted"`
- 2 passed; the Cloud legacy promoted asset-modal case still fails until
[#13075](https://github.com/Comfy-Org/ComfyUI_frontend/pull/13075) is
merged.
- Full OSS `errorsTabModeAware.spec.ts` on the 8188 build:
- 23 passed; 3 existing paste/clipboard cases failed before the promoted
subgraph section with node count remaining at 1 after
`clipboard.paste()`.
- Commit hooks ran `oxfmt`, `oxlint`, `eslint`, `pnpm typecheck`, and
browser typecheck where applicable.
- Pre-push hook ran `pnpm knip --cache`.

## Screenshots

Before


https://github.com/user-attachments/assets/6380c1da-1d92-4b70-888e-3ade572c4b5b

After


https://github.com/user-attachments/assets/4cfc24d6-3dc3-4e36-9b31-72fea6b3d9d5
2026-06-26 19:38:12 +00:00
imick-io
7d3d8ce63f feat(website): add /drops landing page (livestream hero + drops grid) (#13053)
## Summary

Adds the `/drops` landing page in English and Simplified Chinese, ahead
of the 2026-06-24 livestream. The page is composed of a livestream-gated
hero, a 10-card drops grid, a subscribe banner, and a closing CTA.
Production media for the hero and several cards is wired in; the rest
fall back to placeholders.

## Changes

- **New `/drops` page** (`pages/drops.astro`, `pages/zh-CN/drops.astro`)
plus en/zh-CN translations.
- **`HeroLivestream01` block** — renders the rotating-logo video before
the stream window, swaps to a YouTube nocookie iframe between
`startDateTime` and `endDateTime`. SSR stays deterministic on the logo
and only flips to the embed after client hydration. Takes
`youtubeVideoId` directly (no URL parsing).
- **Drops grid** — `data/drops.ts` defines the 10 cards with `imageFor`
/ `videoFor` media helpers, badges, and per-locale
title/description/href. `DropCard.vue` renders an image or autoplaying
muted video inside the new `Card` primitive set (`Card`, `CardHeader`,
`CardTitle`, `CardDescription`, `CardContent`, `CardFooter`).
- **Supporting blocks** — `SubscribeBanner` uses the Button `link`
variant with an animated hover underline; closing `CtaCenter01` is
extended with a terms link. `resolveRel` is shared from `utils/cta.ts`
instead of duplicated per block.
- **Visual extension to `HeroLivestream01.visual`** — discriminated
`image | video` union so the hero can render a looping video.
- **E2E** — `e2e/drops.spec.ts` covers both locales (hero + grid render,
locale-correct links).

## Review Focus

- `youtubeVideoId` is currently a `nlLZfNIqF8M` placeholder;
`startDateTime`/`endDateTime` are placeholders too — see TODO in
`HeroSection.vue`. These need to be swapped to the production stream +
window before launch.
- Several drop cards still point at placeholder destinations (e.g. Comfy
MCP at `/mcp`) — TODO noted in `drops.ts`.
- Drop card media is a mix of images and short MP4s served from
`media.comfy.org`. The videos autoplay muted/looped with
`preload="metadata"`.

## Test plan

- [ ] `pnpm --filter website dev` → visit `/drops` and `/zh-CN/drops`;
confirm hero video plays, grid renders all 10 cards, video cards loop,
subscribe banner link styled correctly
- [ ] Temporarily set `startDateTime` to the past and `endDateTime` to
the future; confirm hero swaps to the YouTube iframe after hydration
- [ ] `pnpm --filter website test:e2e drops.spec.ts`
- [ ] `pnpm typecheck:website`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-26 19:09:04 +00:00
Alexis Rolland
506e33e7dd ci: Update CLA allowlist for GitHub Actions (#13169)
## Summary

- Update CLA check `allowlist` to add `GitHub Action` ,
`action@github.com`, `Glary-Bot`
- Reorder list alphabetically
2026-06-26 11:19:31 -07:00
AustinMroz
7376402fc6 Essentials tab redesign (#12744)
Subsumes #12304 

Redesigns the Essentials tab to be frontend designed with more
accessible icons and tighter organization.
<img width="381" height="1345" alt="image"
src="https://github.com/user-attachments/assets/193f7f5f-20c8-4bf0-8304-ec2c990186d0"
/>

---------

Co-authored-by: comfydesigner <comfydesigner@users.noreply.github.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-26 17:57:58 +00:00
Alexander Brown
be38f14619 docs: retarget ECS architecture docs to dedicated stores over a single World (#13039)
## Summary

Realign the ECS architecture docs and ADR 0008 with the shipped
direction (PR #12617): entity data lives in dedicated Pinia stores keyed
by string IDs, rather than one unified \"World\" registry addressed by
branded entity IDs.

## Changes

- **What**: Docs-only. Retarget the `docs/architecture/` set + ADR 0008
+ agent guidance from the single-World/branded-`*EntityId` model to the
dedicated-store model that PR #12617 actually shipped
(`widgetValueStore` keyed by `WidgetId`, `layoutStore`,
`nodeOutputStore`, `domWidgetStore`, `subgraphNavigationStore`,
`previewExposureStore`).
- `AGENTS.md` + `.agents/checks/adr-compliance.md`: point entity-data
guidance at dedicated stores; fix the inverted `world.getComponent`
compliance check (it flagged correct store-based code).
- `ADR 0008`: dated amendment note (stays Proposed); rewrite the World
section → dedicated stores, Branded ID design (`WidgetId` composite
string), migration strategy, render-loop, consequences, notes.
- `proto-ecs-stores.md`: flip the \"Unified World / branded IDs\"
framing from gap-to-close → target; replace the deleted
`PromotedWidgetViewManager` with the `input.widgetId` store-backed
model; fix key formats and store count.
- `ecs-target-architecture.md` / `ecs-lifecycle-scenarios.md` /
`ecs-migration-plan.md`: reframe all `world.*` APIs and `*EntityId`
brands to per-store APIs + string keys; mark already-shipped migration
phases done.
- `subgraph-boundaries-and-promotion.md` / `entity-interactions.md` /
`entity-problems.md`: scope-tagged store entries; swap removed
`PromotionStore` for `previewExposureStore`.
- `appendix-critical-analysis.md`: post-pivot status banner + resolution
notes on the critiques the pivot vindicated; still-open gaps (extension
callbacks, atomicity, Y.js↔ECS) left live.
- `appendix-ecs-pattern-survey.md`: supersede banner; keep the external
library survey (§1).
- Delete obsolete `ecs-world-command-api.md` (its command-pattern
argument folded into ADR 0008).
- **Breaking**: None (documentation only).

## Review Focus

- ADR 0008 stays **Proposed** with an amendment note rather than a new
superseding ADR — confirm that's the preferred mechanism vs. a fresh
ADR.
- Numeric per-kind brands (`NodeEntityId`, `LinkEntityId`, …) are
retained in ADR 0008 but explicitly marked aspirational/unshipped; only
`WidgetId` (composite string) reflects shipped code.
- `appendix-ecs-pattern-survey.md` §2–§4 are kept under a supersede
banner as historical record (they describe the deleted `src/world/`
substrate) rather than rewritten — confirm that's preferred over
deletion.
- Net −384 lines; no code or test changes.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:22:36 +00:00
192 changed files with 5936 additions and 3338 deletions

View File

@@ -33,15 +33,15 @@ Flag:
- **New circular entity dependencies** — New circular imports between `LGraph``Subgraph`, `LGraphNode``LGraphCanvas`, or similar entity classes.
- **Direct `graph._version++`** — Mutating the private version counter directly instead of through a public API. Extensions already depend on this side-channel; it must become a proper API.
### Centralized Registries and ECS-Style Access
### Dedicated Stores and Data/Behavior Separation
All entity data access should move toward centralized query patterns, not instance property access.
Entity data lives in dedicated Pinia stores keyed by string IDs (`widgetValueStore`, `domWidgetStore`, `layoutStore`, `nodeOutputStore`, `subgraphNavigationStore`, `previewExposureStore`), not on entity instances.
Flag:
- **New instance method/property patterns** — Adding `node.someProperty` or `node.someMethod()` for data that should be a component in the World, queried via `world.getComponent(entityId, ComponentType)`.
- **New instance method/property patterns** — Adding `node.someProperty` or `node.someMethod()` for data that belongs in a dedicated store (e.g. widget values → `widgetValueStore` keyed by `WidgetId`).
- **OOP inheritance for entity modeling** — Extending entity classes with new subclasses instead of composing behavior through components and systems.
- **Scattered state** — New entity state stored in multiple locations (class properties, stores, local variables) instead of being consolidated in the World or in a single store.
- **Duplicated authority** — Storing the same entity state in both a class property and a store, or across two stores, so ownership becomes ambiguous. Each piece of state should have one owning store.
### Extension Ecosystem Impact

View File

@@ -41,7 +41,7 @@ jobs:
# Allowlist bots so they don't need to sign (optional, comma-separated).
# *[bot] is a catch-all for any GitHub App bot account.
allowlist: actions-user,ampagent,claude,comfy-pr-bot,github-actions,*[bot],Glary Bot
allowlist: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,Glary Bot,Glary-Bot,*[bot]
# Custom PR comment messages
custom-notsigned-prcomment: |

View File

@@ -67,6 +67,11 @@ jobs:
uses: actions/checkout@v6
with:
fetch-depth: 0
# Persist a token with `workflow` scope so the backport push can
# include changes to .github/workflows/**. The default GITHUB_TOKEN
# is refused by GitHub when a push creates/updates workflow files,
# which silently aborted the whole job (see PR #12804 backport).
token: ${{ secrets.PR_GH_TOKEN }}
- name: Configure git
run: |

View File

@@ -246,7 +246,7 @@ All architectural decisions are documented in `docs/adr/`. Code changes must be
### Entity Architecture Constraints (ADR 0003 + ADR 0008)
1. **Command pattern for all mutations**: Every entity state change must be a serializable, idempotent, deterministic command — replayable, undoable, and transmittable over CRDT. No imperative fire-and-forget mutation APIs. Systems produce command batches, not direct side effects.
2. **Centralized registries and ECS-style access**: Entity data lives in the World (centralized registry), queried via `world.getComponent(entityId, ComponentType)`. Do not add new instance properties/methods to entity classes. Do not use OOP inheritance for entity modeling.
2. **Dedicated stores over instance state**: Entity data lives in dedicated Pinia stores keyed by string IDs — widget values in `widgetValueStore` keyed by `WidgetId` (`graphId:nodeId:name`, see `src/types/widgetId.ts`), plus `domWidgetStore`, `layoutStore`, `nodeOutputStore`, `subgraphNavigationStore`, and `previewExposureStore`. Prefer a focused store to a single unified registry. Do not add new instance properties/methods to entity classes for data that belongs in a store. Do not use OOP inheritance for entity modeling.
3. **No god-object growth**: Do not add methods to `LGraphNode`, `LGraphCanvas`, `LGraph`, or `Subgraph`. Extract to systems, stores, or composables.
4. **Plain data components**: ECS components are plain data objects — no methods, no back-references to parent entities. Behavior belongs in systems (pure functions).
5. **Extension ecosystem impact**: Changes to entity callbacks (`onConnectionsChange`, `onRemoved`, `onAdded`, `onConnectInput/Output`, `onConfigure`, `onWidgetChanged`), `node.widgets` access, `node.serialize`, or `graph._version++` affect 40+ custom node repos and require migration guidance.

View File

@@ -0,0 +1,231 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { externalLinks } from '../src/config/routes'
import { drops } from '../src/data/drops'
import type { Locale } from '../src/i18n/translations'
import { t } from '../src/i18n/translations'
import { test } from './fixtures/blockExternalMedia'
const PATH_EN = '/launches'
const PATH_ZH = '/zh-CN/launches'
const CLOUD_URL = 'https://cloud.comfy.org'
const LOCALES: ReadonlyArray<readonly [string, Locale]> = [
[PATH_EN, 'en'],
[PATH_ZH, 'zh-CN']
]
function heroSection(page: Page, locale: Locale) {
return page.locator('section').filter({
has: page.getByRole('heading', {
level: 1,
name: t('launches.hero.title', locale)
})
})
}
function ctaSection(page: Page, locale: Locale) {
return page.locator('section').filter({
has: page.getByRole('heading', {
level: 2,
name: t('launches.cta.heading', locale)
})
})
}
function dropsSection(page: Page, locale: Locale) {
return page.locator('section').filter({
has: page.getByRole('heading', {
level: 2,
name: t('launches.section.title', locale)
})
})
}
test.describe('Launches landing — desktop @smoke', () => {
test('renders the configured title at /launches', async ({ page }) => {
await page.goto(PATH_EN)
await expect(page).toHaveTitle(t('launches.page.title', 'en'))
})
test('renders the localized title at /zh-CN/launches', async ({ page }) => {
await page.goto(PATH_ZH)
await expect(page).toHaveTitle(t('launches.page.title', 'zh-CN'))
})
test('is indexable at both locales', async ({ page }) => {
await page.goto(PATH_EN)
await expect(page.locator('meta[name="robots"]')).toHaveCount(0)
await page.goto(PATH_ZH)
await expect(page.locator('meta[name="robots"]')).toHaveCount(0)
})
test('hero h1 renders the localized title in both locales', async ({
page
}) => {
await page.goto(PATH_EN)
await expect(
page.getByRole('heading', {
level: 1,
name: t('launches.hero.title', 'en')
})
).toBeVisible()
await page.goto(PATH_ZH)
await expect(
page.getByRole('heading', {
level: 1,
name: t('launches.hero.title', 'zh-CN')
})
).toBeVisible()
})
test('hero primary CTA links to /download per locale', async ({ page }) => {
for (const [path, locale, expectedHref] of [
[PATH_EN, 'en', '/download'],
[PATH_ZH, 'zh-CN', '/zh-CN/download']
] as const) {
await page.goto(path)
const primary = heroSection(page, locale).getByRole('link', {
name: t('launches.hero.primary', locale)
})
await expect(primary).toBeVisible()
await expect(primary).toHaveAttribute('href', expectedHref)
}
})
test('hero secondary CTA opens external Cloud in a new tab on both locales', async ({
page
}) => {
for (const [path, locale] of LOCALES) {
await page.goto(path)
const secondary = heroSection(page, locale).getByRole('link', {
name: t('launches.hero.secondary', locale)
})
await expect(secondary).toBeVisible()
await expect(secondary).toHaveAttribute('href', CLOUD_URL)
await expect(secondary).toHaveAttribute('target', '_blank')
await expect(secondary).toHaveAttribute('rel', 'noopener noreferrer')
}
})
test('closing CTA shows heading and both action buttons in both locales', async ({
page
}) => {
for (const [path, locale] of LOCALES) {
await page.goto(path)
const section = ctaSection(page, locale)
await expect(
section.getByRole('heading', {
level: 2,
name: t('launches.cta.heading', locale)
})
).toBeVisible()
const primary = section.getByRole('link', {
name: t('launches.cta.primary', locale)
})
await expect(primary).toBeVisible()
await expect(primary).toHaveAttribute('href', externalLinks.cloud)
await expect(primary).toHaveAttribute('target', '_blank')
await expect(primary).toHaveAttribute('rel', 'noopener noreferrer')
const secondary = section.getByRole('link', {
name: t('launches.cta.secondary', locale)
})
await expect(secondary).toBeVisible()
await expect(secondary).toHaveAttribute('href', externalLinks.workflows)
await expect(secondary).toHaveAttribute('target', '_blank')
await expect(secondary).toHaveAttribute('rel', 'noopener noreferrer')
}
})
test('drops section renders one card per data entry with the correct localized href in both locales', async ({
page
}) => {
for (const [path, locale] of LOCALES) {
await page.goto(path)
const section = dropsSection(page, locale)
await expect(
section.getByRole('heading', {
level: 2,
name: t('launches.section.title', locale)
})
).toBeVisible()
const cards = section.locator('[data-slot="card"]')
await expect(cards).toHaveCount(drops.length)
for (const [i, drop] of drops.entries()) {
const card = cards.nth(i)
await expect(card).toContainText(drop.title[locale])
const explore = card.getByRole('link', {
name: drop.cta.label[locale]
})
await expect(explore).toBeVisible()
await expect(explore).toHaveAttribute('href', drop.cta.href[locale])
}
}
})
test('desktop: first 4 drop cards are wider than cards 5+', async ({
page
}) => {
await page.goto(PATH_EN)
const cards = dropsSection(page, 'en').locator('[data-slot="card"]')
await expect(cards).toHaveCount(drops.length)
await expect
.poll(async () => {
const firstWidth = (await cards.nth(0).boundingBox())?.width ?? 0
const fifthWidth = (await cards.nth(4).boundingBox())?.width ?? 0
return firstWidth - fifthWidth
})
.toBeGreaterThan(0)
})
})
test.describe('Launches landing — mobile @mobile', () => {
test('drops grid stacks in a single column at mobile width', async ({
page
}) => {
await page.goto(PATH_EN)
const cards = dropsSection(page, 'en').locator('[data-slot="card"]')
await expect(cards).toHaveCount(drops.length)
const viewport = page.viewportSize()
expect(viewport, 'viewport size').not.toBeNull()
await expect
.poll(async () => (await cards.nth(0).boundingBox())?.width ?? 0)
.toBeGreaterThanOrEqual(viewport!.width * 0.7)
await expect
.poll(async () => {
const firstBox = await cards.nth(0).boundingBox()
const secondBox = await cards.nth(1).boundingBox()
if (!firstBox || !secondBox) return false
return secondBox.y >= firstBox.y + firstBox.height
})
.toBe(true)
})
test('closing CTA heading stays within viewport width', async ({ page }) => {
await page.goto(PATH_EN)
const heading = page.getByRole('heading', {
level: 2,
name: t('launches.cta.heading', 'en')
})
await heading.scrollIntoViewIfNeeded()
await expect(heading).toBeVisible()
const box = await heading.boundingBox()
expect(box, 'CTA heading bounding box').not.toBeNull()
const viewport = page.viewportSize()
expect(viewport, 'viewport size').not.toBeNull()
expect(box!.x + box!.width).toBeLessThanOrEqual(viewport!.width + 1)
})
})

View File

@@ -1,10 +1,14 @@
<script setup lang="ts">
import BrandButton from '../common/BrandButton.vue'
import type { AnchorHTMLAttributes } from 'vue'
import Button from '../ui/button/Button.vue'
import { resolveRel } from '../../utils/cta'
type Cta = {
label: string
href: string
target?: '_blank' | '_self' | '_parent' | '_top'
target?: AnchorHTMLAttributes['target']
rel?: AnchorHTMLAttributes['rel']
}
type TermsLink = {
@@ -12,10 +16,11 @@ type TermsLink = {
href: string
}
defineProps<{
const { heading, primaryCta, secondaryCta, termsLink } = defineProps<{
heading: string
primaryCta: Cta
termsLink: TermsLink
secondaryCta?: Cta
termsLink?: TermsLink
}>()
</script>
@@ -24,23 +29,37 @@ defineProps<{
class="max-w-9xl mx-auto flex flex-col items-center px-6 py-16 text-center lg:py-24"
>
<h2
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
class="max-w-3xl text-4xl/snug font-light tracking-tight text-pretty text-primary-comfy-canvas lg:text-6xl/snug"
>
{{ heading }}
</h2>
<BrandButton
:href="primaryCta.href"
:target="primaryCta.target"
:rel="primaryCta.target === '_blank' ? 'noopener noreferrer' : undefined"
variant="outline"
size="lg"
class="mt-10 px-20 py-4 text-base uppercase lg:mt-12"
>
{{ primaryCta.label }}
</BrandButton>
<div class="mt-10 flex flex-col gap-4 sm:flex-row lg:mt-12">
<Button
as="a"
:href="primaryCta.href"
:target="primaryCta.target"
:rel="resolveRel(primaryCta)"
variant="default"
size="lg"
>
{{ primaryCta.label }}
</Button>
<Button
v-if="secondaryCta"
as="a"
:href="secondaryCta.href"
:target="secondaryCta.target"
:rel="resolveRel(secondaryCta)"
variant="outline"
size="lg"
>
{{ secondaryCta.label }}
</Button>
</div>
<a
v-if="termsLink"
:href="termsLink.href"
class="mt-8 text-sm text-primary-comfy-canvas/70 underline underline-offset-4 transition-colors hover:text-primary-comfy-canvas"
>

View File

@@ -0,0 +1,166 @@
<script setup lang="ts">
import type { AnchorHTMLAttributes } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useNow } from '@vueuse/core'
import Button from '../ui/button/Button.vue'
import { resolveRel } from '../../utils/cta'
type Cta = {
label: string
href: string
target?: AnchorHTMLAttributes['target']
rel?: AnchorHTMLAttributes['rel']
}
type Visual =
| {
type: 'image'
src: string
alt: string
width?: number
height?: number
}
| {
type: 'video'
src: string
alt: string
poster?: string
width?: number
height?: number
}
const {
visual,
eyebrow,
title,
subtitle,
primaryCta,
secondaryCta,
youtubeVideoId,
startDateTime,
endDateTime
} = defineProps<{
visual?: Visual
eyebrow?: string
title: string
subtitle?: string
primaryCta: Cta
secondaryCta?: Cta
youtubeVideoId: string
startDateTime: string
endDateTime: string
}>()
const embedUrl = computed(
() =>
`https://www.youtube-nocookie.com/embed/${youtubeVideoId}?autoplay=1&mute=1&rel=0`
)
// Keep SSR/initial paint deterministic on the logo and only flip to the embed
// after client hydration — avoids a build-time `now` leaking into the markup.
const mounted = ref(false)
onMounted(() => {
mounted.value = true
})
const now = useNow({ interval: 30_000 })
const startMs = computed(() => new Date(startDateTime).getTime())
const endMs = computed(() => new Date(endDateTime).getTime())
const isLive = computed(
() =>
mounted.value &&
now.value.getTime() >= startMs.value &&
now.value.getTime() < endMs.value
)
</script>
<template>
<section
class="max-w-9xl mx-auto flex flex-col items-center px-6 py-16 text-center lg:py-24"
>
<div
v-if="isLive"
class="mb-10 aspect-video w-full overflow-hidden rounded-2xl lg:mb-12"
>
<iframe
:src="embedUrl"
:title="title"
class="size-full"
loading="lazy"
allow="autoplay; encrypted-media; picture-in-picture"
allowfullscreen
/>
</div>
<slot v-else name="visual">
<img
v-if="visual?.type === 'image'"
:src="visual.src"
:alt="visual.alt"
:width="visual.width"
:height="visual.height"
fetchpriority="high"
decoding="async"
class="mb-10 h-auto w-full max-w-md lg:mb-12 lg:max-w-lg"
/>
<video
v-else-if="visual?.type === 'video'"
:src="visual.src"
:poster="visual.poster"
:aria-label="visual.alt"
:width="visual.width"
:height="visual.height"
autoplay
loop
muted
playsinline
preload="metadata"
class="mb-10 h-auto w-full max-w-md lg:mb-12 lg:max-w-2xl"
/>
</slot>
<p
v-if="eyebrow"
class="mb-4 text-sm font-medium tracking-wide text-primary-comfy-canvas/70 uppercase"
>
{{ eyebrow }}
</p>
<h1
class="max-w-3xl text-4xl/snug font-light tracking-tight text-pretty text-primary-comfy-canvas lg:text-6xl/snug"
>
{{ title }}
</h1>
<p
v-if="subtitle"
class="mt-6 max-w-2xl text-base text-primary-comfy-canvas/70 lg:text-lg"
>
{{ subtitle }}
</p>
<div class="mt-10 flex flex-col gap-4 sm:flex-row lg:mt-12">
<Button
as="a"
:href="primaryCta.href"
:target="primaryCta.target"
:rel="resolveRel(primaryCta)"
size="lg"
>
{{ primaryCta.label }}
</Button>
<Button
v-if="secondaryCta"
as="a"
:href="secondaryCta.href"
:target="secondaryCta.target"
:rel="resolveRel(secondaryCta)"
variant="outline"
size="lg"
>
{{ secondaryCta.label }}
</Button>
</div>
</section>
</template>

View File

@@ -6,6 +6,7 @@ import type { HTMLAttributes } from 'vue'
import type { BrandButtonVariants } from './brandButton.variants'
import { brandButtonVariants } from './brandButton.variants'
import { resolveRel } from '../../utils/cta'
const props = defineProps<{
href?: string
@@ -16,9 +17,8 @@ const props = defineProps<{
class?: HTMLAttributes['class']
}>()
const resolvedRel = computed(
() =>
props.rel ?? (props.target === '_blank' ? 'noopener noreferrer' : undefined)
const resolvedRel = computed(() =>
resolveRel({ rel: props.rel, target: props.target })
)
</script>

View File

@@ -44,6 +44,7 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
title: t('footer.resources', locale),
links: [
{ label: t('nav.learning', locale), href: routes.learning },
{ label: t('nav.launches', locale), href: routes.launches },
{
label: t('footer.blog', locale),
href: externalLinks.blog,

View File

@@ -25,7 +25,7 @@ const {
data-slot="badge"
:data-variant="variant"
:data-size="size"
:class="cn(badgeVariants({ variant, size }), className)"
:class="cn(badgeVariants({ size, variant }), className)"
>
<slot name="prepend">
<component :is="prependIcon" v-if="prependIcon" />

View File

@@ -4,15 +4,16 @@ import { cva } from 'cva'
export const badgeVariants = cva({
base: 'text-primary-warm-gray font-formula leading-none focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
variants: {
variant: {
default: 'bg-transparency-ink-t80',
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas',
accent:
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent px-2 py-0.5 text-[9px] font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
},
size: {
md: 'px-4 py-1 text-xs',
xs: 'px-2 py-0.5 text-[9px]'
},
variant: {
default: 'bg-transparency-ink-t80',
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas',
category: 'text-primary-comfy-yellow px-0 font-semibold uppercase',
accent:
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent px-2 py-0.5 text-[9px] font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
}
},
defaultVariants: {

View File

@@ -8,7 +8,8 @@ export const buttonVariants = cva(
{
variants: {
size: {
default: 'h-10 px-6 py-2.5',
sm: 'h-8 px-4 py-2 text-xs md:text-sm',
default: 'h-10 px-6 py-2.5 text-xs md:text-sm',
lg: 'h-14 px-8 py-4 text-base'
},
variant: {
@@ -17,6 +18,8 @@ export const buttonVariants = cva(
outline:
'text-primary-comfy-yellow hover:bg-primary-comfy-yellow border uppercase hover:text-primary-comfy-ink',
link: "text-primary-comfy-yellow h-auto justify-start px-0 py-1 text-base uppercase hover:opacity-90 [&_svg:not([class*='size-'])]:size-6",
underlineLink:
"text-primary-comfy-yellow relative h-auto justify-start px-0 py-1 uppercase after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-full after:origin-left after:scale-x-0 after:bg-current after:transition-transform after:duration-200 hover:opacity-90 hover:after:scale-x-100 [&_svg:not([class*='size-'])]:size-6",
nav: 'text-primary-warm-white hover:text-primary-comfy-yellow h-auto justify-between px-0 py-1 text-start text-2xl font-medium',
navMuted:
'hover:text-primary-comfy-yellow h-auto w-full justify-between px-0 py-1 text-start text-2xl font-medium text-primary-comfy-canvas uppercase'

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card"
:class="
cn(
'bg-transparency-white-t4 text-primary-warm-white rounded-4.5xl flex flex-col gap-6 shadow-sm',
className
)
"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div data-slot="card-content" :class="cn('px-6', className)">
<slot />
</div>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card-description"
:class="
cn('line-clamp-3 text-base text-primary-comfy-canvas/70', className)
"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div data-slot="card-footer" :class="cn('flex items-center', className)">
<slot />
</div>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div data-slot="card-header" :class="cn('flex flex-col gap-1.5', className)">
<slot />
</div>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card-title"
:class="
cn(
'text-xl leading-none font-medium text-primary-comfy-canvas md:text-2xl',
className
)
"
>
<slot />
</div>
</template>

View File

@@ -22,6 +22,12 @@ interface HeroLogoConfig {
cursorTiltStrength: number
bgScale: number
slideDuration: number
svgMarkup: string
fitAxis: 'width' | 'height'
targetSize: number
respectReducedMotion: boolean
baseUrl: string
fadeInDurationMs: number
}
const DEFAULTS: HeroLogoConfig = {
@@ -34,19 +40,25 @@ const DEFAULTS: HeroLogoConfig = {
extrudeDepth: 200,
cursorTiltStrength: 0.5,
bgScale: 0.8,
slideDuration: 0.4
slideDuration: 0.4,
svgMarkup: SVG_MARKUP,
fitAxis: 'height',
targetSize: 3,
respectReducedMotion: true,
baseUrl: BASE_URL,
fadeInDurationMs: 0
}
function buildImageUrls(): string[] {
function buildImageUrls(baseUrl: string): string[] {
return Array.from({ length: IMAGE_COUNT }, (_, i) => {
const index = String(i).padStart(5, '0')
return `${BASE_URL}/image_sequence_${index}.webp`
return `${baseUrl}/image_sequence_${index}.webp`
})
}
function parseShapes(): THREE.Shape[] {
function parseShapes(markup: string): THREE.Shape[] {
const loader = new SVGLoader()
const svgData = loader.parse(SVG_MARKUP)
const svgData = loader.parse(markup)
const shapes: THREE.Shape[] = []
svgData.paths.forEach((path) => {
shapes.push(...SVGLoader.createShapes(path))
@@ -85,7 +97,8 @@ export function useHeroLogo(
onMounted(async () => {
try {
const container = containerRef.value
if (!container || prefersReducedMotion()) return
if (!container || (cfg.respectReducedMotion && prefersReducedMotion()))
return
const { width, height } = container.getBoundingClientRect()
@@ -102,6 +115,9 @@ export function useHeroLogo(
renderer.domElement.style.width = '100%'
renderer.domElement.style.height = '100%'
renderer.domElement.style.opacity = '0'
if (cfg.fadeInDurationMs > 0) {
renderer.domElement.style.transition = `opacity ${cfg.fadeInDurationMs}ms ease`
}
renderer.domElement.setAttribute('aria-hidden', 'true')
container.appendChild(renderer.domElement)
@@ -126,24 +142,36 @@ export function useHeroLogo(
camera.position.z = cfg.zoom
// SVG shape
const shapes = parseShapes()
const shapes = parseShapes(cfg.svgMarkup)
const tempGeo = new THREE.ShapeGeometry(shapes)
tempGeo.computeBoundingBox()
const bb = tempGeo.boundingBox!
const bb = tempGeo.boundingBox
if (!bb) {
tempGeo.dispose()
cleanup?.()
return
}
const cx = (bb.max.x + bb.min.x) / 2
const cy = (bb.max.y + bb.min.y) / 2
const scaleFactor = 3 / (bb.max.y - bb.min.y)
const fitExtent =
cfg.fitAxis === 'width' ? bb.max.x - bb.min.x : bb.max.y - bb.min.y
if (fitExtent <= 0) {
tempGeo.dispose()
cleanup?.()
return
}
const scaleFactor = cfg.targetSize / fitExtent
tempGeo.dispose()
// Image sequence textures — load first frame eagerly, rest lazily
const urls = buildImageUrls()
const urls = buildImageUrls(cfg.baseUrl)
const textures = await loadTextures(urls.slice(0, 1))
if (disposed) return
renderer.domElement.style.opacity = '1'
loaded.value = true
loadTextures(urls.slice(1)).then((rest) => {
void loadTextures(urls.slice(1)).then((rest) => {
if (!disposed) textures.push(...rest)
})

View File

@@ -8,6 +8,7 @@ const baseRoutes = {
cloudEnterprise: '/cloud/enterprise',
api: '/api',
gallery: '/gallery',
launches: '/launches',
about: '/about',
careers: '/careers',
customers: '/customers',
@@ -59,6 +60,7 @@ export const externalLinks = {
discord: 'https://discord.com/invite/comfyorg',
docs: 'https://docs.comfy.org/',
docsApi: 'https://docs.comfy.org/api-reference/cloud',
docsMcp: 'https://docs.comfy.org/agent-tools/cloud',
docsSubscription: 'https://docs.comfy.org/support/subscription/subscribing',
github: 'https://github.com/Comfy-Org/ComfyUI',
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',

View File

@@ -0,0 +1,248 @@
// Image URLs are placeholders at media.comfy.org/website/drops/<id>.png —
// asset uploads and native zh-CN review are pending follow-ups (see
// apps/website/.scratch/drops-page/PRD.md).
import { externalLinks } from '../config/routes'
import type { LocalizedText } from '../i18n/translations'
type DropMedia =
| { type: 'image'; src: string; alt: LocalizedText }
| { type: 'video'; src: string; alt: LocalizedText; poster?: string }
export type Drop = {
id: string
badge?: LocalizedText
category: LocalizedText
media: DropMedia
title: LocalizedText
description: LocalizedText
cta: { label: LocalizedText; href: LocalizedText }
}
const EXPLORE: LocalizedText = { en: 'EXPLORE', 'zh-CN': '探索' }
const PLATFORM: LocalizedText = { en: 'Platform', 'zh-CN': '平台' }
const CLOUD: LocalizedText = { en: 'Cloud', 'zh-CN': '云端' }
const COMMUNITY: LocalizedText = { en: 'Community', 'zh-CN': '社区' }
const DEVELOPER: LocalizedText = { en: 'Developer', 'zh-CN': '开发者' }
const MODELS_AND_NODES: LocalizedText = {
en: 'Models & Nodes',
'zh-CN': '模型与节点'
}
const NEW_BADGE: LocalizedText = { en: 'NEW', 'zh-CN': '新' }
const FEATURED_BADGE: LocalizedText = { en: 'FEATURED', 'zh-CN': '精选' }
function imageFor(fileName: string, alt: LocalizedText): DropMedia {
return {
type: 'image',
src: `https://media.comfy.org/website/drops/${fileName}`,
alt
}
}
function videoFor(
fileName: string,
alt: LocalizedText,
poster?: string
): DropMedia {
return {
type: 'video',
src: `https://media.comfy.org/website/drops/${fileName}`,
alt,
...(poster && {
poster: `https://media.comfy.org/website/drops/${poster}`
})
}
}
export const drops: readonly Drop[] = [
{
id: 'desktop-client',
badge: NEW_BADGE,
category: PLATFORM,
media: imageFor('Drops_2x2card_Desktop.jpg', {
en: 'New Desktop Client',
'zh-CN': '新桌面客户端'
}),
title: { en: 'New Desktop Client', 'zh-CN': '新桌面客户端' },
description: {
en: 'A faster, redesigned desktop app for ComfyUI — one-click install and managed updates.',
'zh-CN': '更快、重新设计的 ComfyUI 桌面应用程序 — 一键安装与受管更新。'
},
cta: {
label: EXPLORE,
href: { en: '/download', 'zh-CN': '/zh-CN/download' }
}
},
{
id: 'app-mode',
badge: NEW_BADGE,
category: PLATFORM,
media: videoFor('Drops_2x2card_APP.mp4', {
en: 'App Mode',
'zh-CN': 'App 模式'
}),
title: { en: 'App Mode', 'zh-CN': 'App 模式' },
description: {
en: 'A simplified view of your workflows. Flip back to the node graph anytime to go deeper.',
'zh-CN': '工作流的简化视图。随时切换回节点图视图以深入了解。'
},
// TODO: no destination page yet — link out when App Mode lands.
cta: {
label: EXPLORE,
href: {
en: 'https://docs.comfy.org/interface/app-mode',
'zh-CN': 'https://docs.comfy.org/zh/interface/app-mode'
}
}
},
{
id: 'comfy-api',
badge: NEW_BADGE,
category: DEVELOPER,
media: imageFor('Drops_2x2card_API.jpg', {
en: 'Comfy API',
'zh-CN': 'Comfy API'
}),
title: { en: 'Comfy API', 'zh-CN': 'Comfy API' },
description: {
en: 'Turn any workflow into a production endpoint. Automate generation and scale to thousands of outputs.',
'zh-CN': '将任意工作流变成生产端点。自动化生成并扩展到数千个输出。'
},
cta: {
label: EXPLORE,
href: { en: '/api', 'zh-CN': '/zh-CN/api' }
}
},
{
id: 'comfy-mcp',
badge: NEW_BADGE,
category: CLOUD,
media: imageFor('Drops_2x2card_MCP.jpg', {
en: 'Comfy MCP',
'zh-CN': 'Comfy MCP'
}),
title: { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
description: {
en: 'The full power of ComfyUI from anywhere — no setup, no GPU required.',
'zh-CN': '随时随地体验 ComfyUI 的全部能力 — 无需配置,无需 GPU。'
},
cta: {
label: EXPLORE,
href: { en: externalLinks.docsMcp, 'zh-CN': externalLinks.docsMcp }
}
},
{
id: 'community-workflows',
category: COMMUNITY,
media: imageFor('Drops_3x3card_Comm Workflows.jpg', {
en: 'Community Workflows',
'zh-CN': '社区工作流'
}),
title: {
en: 'Community Workflows',
'zh-CN': '社区工作流'
},
description: {
en: 'Browse and remix thousands of community-shared workflows. Start from a proven template.',
'zh-CN': '浏览和混搭数千个社区共享的工作流。从经过验证的模板开始。'
},
cta: {
label: EXPLORE,
href: { en: externalLinks.workflows, 'zh-CN': externalLinks.workflows }
}
},
{
id: 'supported-models',
category: MODELS_AND_NODES,
media: imageFor('Drops_Supported models.jpg', {
en: 'Supported Models',
'zh-CN': '支持的模型'
}),
title: { en: 'Supported Models', 'zh-CN': '支持的模型' },
description: {
en: 'Run the latest open and partner models — every checkpoint, LoRA, and ControlNet, ready to use in your graph.',
'zh-CN':
'运行最新的开源和合作伙伴模型 — 每个 checkpoint、LoRA 和 ControlNet 都可直接在工作流中使用。'
},
cta: {
label: EXPLORE,
href: { en: '/p/supported-models', 'zh-CN': '/zh-CN/p/supported-models' }
}
},
{
id: 'supported-nodes',
category: MODELS_AND_NODES,
media: videoFor('Drops_3x3card_supported nodes.mp4', {
en: 'Supported Nodes',
'zh-CN': '支持的节点'
}),
title: { en: 'Supported Nodes', 'zh-CN': '支持的节点' },
description: {
en: 'Thousands of community and partner nodes, curated and verified to run on Comfy Cloud.',
'zh-CN':
'数千个社区与合作伙伴节点,经过精选与验证,可在 Comfy Cloud 上运行。'
},
cta: {
label: EXPLORE,
href: {
en: '/cloud/supported-nodes',
'zh-CN': '/zh-CN/cloud/supported-nodes'
}
}
},
{
id: 'comfy-enterprise',
category: CLOUD,
media: imageFor('Drops_3x3card_enterprise.png', {
en: 'Comfy Enterprise',
'zh-CN': 'Comfy 企业版'
}),
title: { en: 'Comfy Enterprise', 'zh-CN': 'Comfy 企业版' },
description: {
en: 'Enterprise-grade infrastructure for the creative engine inside your organization.',
'zh-CN': '为您组织内创意引擎提供的企业级基础设施。'
},
cta: {
label: EXPLORE,
href: { en: '/cloud/enterprise', 'zh-CN': '/zh-CN/cloud/enterprise' }
}
},
{
id: 'learning-hub',
category: COMMUNITY,
media: imageFor('Drops_3x3_Learninghub.jpg', {
en: 'Learning Hub',
'zh-CN': '学习中心'
}),
title: { en: 'Learning Hub', 'zh-CN': '学习中心' },
description: {
en: 'Walkthroughs and ready-to-run workflows to take you from first render to production pipeline.',
'zh-CN': '配套教程与开箱即用的工作流,带您从第一次渲染走向生产管线。'
},
cta: {
label: { en: 'START LEARNING', 'zh-CN': '开始学习' },
href: { en: '/learning', 'zh-CN': '/zh-CN/learning' }
}
},
{
id: 'share-comfy',
badge: NEW_BADGE,
category: COMMUNITY,
media: videoFor('Drops_3x3card_Affilliate.mp4', {
en: 'Comfy Affiliate',
'zh-CN': 'Comfy Affiliate'
}),
title: {
en: 'Comfy Affiliate',
'zh-CN': 'Comfy Affiliate'
},
description: {
en: 'Share Comfy with your audience and earn for every creator you bring on board.',
'zh-CN': '与您的受众分享 Comfy为您带来的每一位创作者获得回报。'
},
// /affiliates is locale-invariant: same URL in both locales.
cta: {
label: { en: 'LEARN MORE', 'zh-CN': '了解更多' },
href: { en: '/affiliates', 'zh-CN': '/affiliates' }
}
}
]

View File

@@ -180,6 +180,11 @@ export function getMainNavigation(locale: Locale): NavItem[] {
},
// TODO: no /brand page yet
// { label: t('nav.brand', locale), href: '#' },
{
label: t('nav.launches', locale),
href: routes.launches,
badge: 'new'
},
{
label: t('nav.blogs', locale),
href: externalLinks.blog,

View File

@@ -1849,6 +1849,7 @@ const translations = {
'nav.aboutUs': { en: 'About Us', 'zh-CN': '关于我们' },
'nav.careers': { en: 'Careers', 'zh-CN': '招聘' },
'nav.customerStories': { en: 'Customer Stories', 'zh-CN': '客户故事' },
'nav.launches': { en: 'Launches', 'zh-CN': '发布' },
'nav.downloadLocal': { en: 'DOWNLOAD DESKTOP', 'zh-CN': '下载桌面版' },
'nav.launchCloud': { en: 'LAUNCH CLOUD', 'zh-CN': '启动云端' },
'nav.ctaDesktopPrefix': { en: 'DOWNLOAD', 'zh-CN': '下载' },
@@ -4928,6 +4929,70 @@ const translations = {
'affiliate.cta.termsLabel': {
en: 'Read the affiliate program terms',
'zh-CN': '阅读联盟计划条款'
},
// Launches page (/launches) — head metadata
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
'launches.page.title': {
en: 'ComfyUI Live Demo & Q&A — June 29 Launch Livestream',
'zh-CN': 'ComfyUI 直播演示与问答 — 6 月 29 日发布直播'
},
'launches.page.description': {
en: 'Join the ComfyUI livestream on June 29 for a hands-on product demo and live Q&A. See whats new across desktop, cloud, and community, and get your questions answered.',
'zh-CN':
'6 月 29 日加入 ComfyUI 直播,观看实操产品演示并参与实时问答。了解桌面、云端和社区的最新内容,并获得解答。'
},
// Launches page (/launches) — hero section
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
'launches.hero.title': {
en: 'Everything new in ComfyUI',
'zh-CN': 'ComfyUI 全新内容'
},
'launches.hero.primary': {
en: 'Download Desktop',
'zh-CN': '下载桌面版'
},
'launches.hero.secondary': {
en: 'Launch Cloud',
'zh-CN': '启动云端'
},
'launches.hero.visualAlt': {
en: 'Comfy',
'zh-CN': 'Comfy'
},
// Launches page (/launches) — subscribe banner
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
'launches.banner.text': {
en: 'Join the live stream. Get answers in real time.',
'zh-CN': '加入直播,实时获得解答。'
},
'launches.banner.cta': {
en: 'Join livestream',
'zh-CN': '加入直播'
},
// Launches page (/launches) — closing CTA
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
'launches.cta.heading': {
en: 'Everything Comfy ships. All in one place.',
'zh-CN': 'Comfy 的全部内容,一处尽享。'
},
'launches.cta.primary': {
en: 'Open Comfy Cloud',
'zh-CN': '打开 Comfy Cloud'
},
'launches.cta.secondary': {
en: 'Try Workflow',
'zh-CN': '试用工作流'
},
// Launches page (/launches) — launches grid
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
'launches.section.title': {
en: 'Latest Launches',
'zh-CN': '最新发布'
}
} as const satisfies Record<string, Record<Locale, string>>

View File

@@ -0,0 +1,20 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import CtaSection from '../templates/drops/CtaSection.vue'
import DropsSection from '../templates/drops/DropsSection.vue'
import HeroSection from '../templates/drops/HeroSection.vue'
import SubscribeBanner from '../templates/drops/SubscribeBanner.vue'
import { t } from '../i18n/translations'
const locale = 'en' as const
---
<BaseLayout
title={t('launches.page.title', locale)}
description={t('launches.page.description', locale)}
>
<SubscribeBanner locale={locale} client:load />
<HeroSection locale={locale} client:load />
<DropsSection locale={locale} />
<CtaSection locale={locale} />
</BaseLayout>

View File

@@ -0,0 +1,20 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import CtaSection from '../../templates/drops/CtaSection.vue'
import DropsSection from '../../templates/drops/DropsSection.vue'
import HeroSection from '../../templates/drops/HeroSection.vue'
import SubscribeBanner from '../../templates/drops/SubscribeBanner.vue'
import { t } from '../../i18n/translations'
const locale = 'zh-CN' as const
---
<BaseLayout
title={t('launches.page.title', locale)}
description={t('launches.page.description', locale)}
>
<SubscribeBanner locale={locale} client:load />
<HeroSection locale={locale} client:load />
<DropsSection locale={locale} />
<CtaSection locale={locale} />
</BaseLayout>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import CtaCenter01 from '../../components/blocks/CtaCenter01.vue'
import { externalLinks } from '../../config/routes'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<CtaCenter01
:heading="t('launches.cta.heading', locale)"
:primary-cta="{
label: t('launches.cta.primary', locale),
href: externalLinks.cloud,
target: '_blank'
}"
:secondary-cta="{
label: t('launches.cta.secondary', locale),
href: externalLinks.workflows,
target: '_blank'
}"
/>
</template>

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
import type { Drop } from '../../data/drops'
import type { Locale } from '../../i18n/translations'
import Badge from '../../components/ui/badge/Badge.vue'
import ButtonPill from '../../components/ui/button-pill/ButtonPill.vue'
import Card from '../../components/ui/card/Card.vue'
import CardContent from '../../components/ui/card/CardContent.vue'
import CardDescription from '../../components/ui/card/CardDescription.vue'
import CardFooter from '../../components/ui/card/CardFooter.vue'
import CardHeader from '../../components/ui/card/CardHeader.vue'
import CardTitle from '../../components/ui/card/CardTitle.vue'
const { drop, locale } = defineProps<{
drop: Drop
locale: Locale
}>()
</script>
<template>
<Card class="group/pill-trigger relative h-full overflow-hidden">
<a
:href="drop.cta.href[locale]"
:aria-label="`${drop.title[locale]} ${drop.cta.label[locale]}`"
class="rounded-4.5xl focus-visible:ring-primary-comfy-yellow absolute inset-0 z-10 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
/>
<div class="flex flex-col-reverse">
<CardHeader class="gap-2 px-6">
<Badge variant="category">
{{ drop.category[locale] }}
</Badge>
<CardTitle class="pt-4">
{{ drop.title[locale] }}
</CardTitle>
<CardDescription>
{{ drop.description[locale] }}
</CardDescription>
</CardHeader>
<CardContent class="relative p-2">
<div class="aspect-video w-full overflow-hidden rounded-4xl">
<img
v-if="drop.media.type === 'image'"
:src="drop.media.src"
:alt="drop.media.alt[locale]"
loading="lazy"
decoding="async"
class="size-full object-cover object-center transition-transform duration-500 ease-out group-hover/pill-trigger:scale-105"
/>
<video
v-else
:src="drop.media.src"
:poster="drop.media.poster"
:aria-label="drop.media.alt[locale]"
autoplay
loop
muted
playsinline
preload="metadata"
class="size-full object-cover object-center transition-transform duration-500 ease-out group-hover/pill-trigger:scale-105"
/>
</div>
<Badge v-if="drop.badge" variant="accent" class="absolute top-6 left-8">
{{ drop.badge[locale] }}
</Badge>
</CardContent>
</div>
<CardFooter class="mt-auto px-6 pb-6">
<ButtonPill as="span" variant="ghost" icon-position="left">
{{ drop.cta.label[locale] }}
</ButtonPill>
</CardFooter>
</Card>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import DropCard from './DropCard.vue'
import { drops } from '../../data/drops'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<h2
class="text-primary-warm-white text-3xl font-light tracking-tight lg:text-5xl"
>
{{ t('launches.section.title', locale) }}
</h2>
<div class="mt-10 grid grid-cols-1 gap-6 md:grid-cols-6 lg:mt-12">
<div
v-for="(drop, index) in drops"
:key="drop.id"
:class="index < 4 ? 'md:col-span-3' : 'md:col-span-2'"
>
<DropCard :drop :locale />
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import HeroLivestream01 from '../../components/blocks/HeroLivestream01.vue'
import LaunchesHeroLogo from './LaunchesHeroLogo.vue'
import { externalLinks, getRoutes } from '../../config/routes'
import { t } from '../../i18n/translations'
import { livestream } from './livestream'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const routes = getRoutes(locale)
</script>
<template>
<HeroLivestream01
:title="t('launches.hero.title', locale)"
:primary-cta="{
label: t('launches.hero.primary', locale),
href: routes.download
}"
:secondary-cta="{
label: t('launches.hero.secondary', locale),
href: externalLinks.cloud,
target: '_blank'
}"
:youtube-video-id="livestream.youtubeVideoId"
:start-date-time="livestream.startDateTime"
:end-date-time="livestream.endDateTime"
>
<template #visual>
<LaunchesHeroLogo :label="t('launches.hero.visualAlt', locale)" />
</template>
</HeroLivestream01>
</template>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import { onMounted, ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import Button from '@/components/ui/button/Button.vue'
import { resolveRel } from '../../utils/cta'
import { livestream } from './livestream'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const signUpHref = `https://www.youtube.com/watch?v=${livestream.youtubeVideoId}`
const signUpRel = resolveRel({ target: '_blank' })
// Hide once the livestream window closes — both for visitors arriving after
// the event and for visitors whose tab is open when it ends.
const endMs = new Date(livestream.endDateTime).getTime()
const visible = ref(true)
// useTimeoutFn auto-clears on unmount. Arm it client-side only so SSR never
// schedules a long-lived server timer.
const { start } = useTimeoutFn(
() => {
visible.value = false
},
() => Math.max(0, endMs - Date.now()),
{ immediate: false }
)
onMounted(() => {
if (endMs - Date.now() <= 0) {
visible.value = false
} else {
start()
}
})
</script>
<template>
<div v-if="visible" class="px-4">
<div
class="bg-primary-comfy-plum max-w-8xl rounded-5xl text-primary-warm-white mx-auto flex w-full flex-col items-center justify-center gap-2 px-6 py-5 text-center text-sm sm:flex-row sm:gap-4"
>
<p class="ppformula-text-center">
{{ t('launches.banner.text', locale) }}
</p>
<Button
:href="signUpHref"
as="a"
variant="underlineLink"
size="sm"
target="_blank"
:rel="signUpRel"
>
{{ t('launches.banner.cta', locale) }}
</Button>
</div>
</div>
</template>

View File

@@ -0,0 +1,5 @@
export const livestream = {
youtubeVideoId: 'yo7b_zHd20g',
startDateTime: '2026-06-29T15:00:00Z',
endDateTime: '2026-07-02T17:15:00Z'
} as const

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { resolveRel } from './cta'
describe('resolveRel', () => {
it('prefers an explicit rel over the target-derived default', () => {
expect(resolveRel({ rel: 'nofollow', target: '_blank' })).toBe('nofollow')
})
it('adds noopener noreferrer for _blank targets', () => {
expect(resolveRel({ target: '_blank' })).toBe('noopener noreferrer')
})
it('returns undefined for non-blank targets', () => {
expect(resolveRel({ target: '_self' })).toBeUndefined()
expect(resolveRel({})).toBeUndefined()
})
})

View File

@@ -0,0 +1,10 @@
import type { AnchorHTMLAttributes } from 'vue'
export function resolveRel(cta: {
rel?: AnchorHTMLAttributes['rel']
target?: AnchorHTMLAttributes['target']
}): AnchorHTMLAttributes['rel'] {
return (
cta.rel ?? (cta.target === '_blank' ? 'noopener noreferrer' : undefined)
)
}

View File

@@ -0,0 +1,197 @@
{
"id": "test-missing-model-nested-promoted-widget",
"revision": 0,
"last_node_id": 4,
"last_link_id": 0,
"nodes": [
{
"id": 3,
"type": "outer-subgraph-with-promoted-missing-model",
"pos": [10, 250],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"title": "Resolved Shared Outer Subgraph",
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": ["resolved_model.safetensors"]
},
{
"id": 4,
"type": "outer-subgraph-with-promoted-missing-model",
"pos": [450, 250],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"title": "Outer Subgraph with Promoted Missing Model",
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": ["fake_model.safetensors"]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "outer-subgraph-with-promoted-missing-model",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 2,
"lastLinkId": 2,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Outer Subgraph with Promoted Missing Model",
"inputNode": {
"id": -10,
"bounding": [100, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [600, 200, 120, 60]
},
"inputs": [
{
"id": "outer-ckpt-name-input-id",
"name": "ckpt_name",
"type": "COMBO",
"linkIds": [2],
"pos": { "0": 150, "1": 220 }
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 2,
"type": "inner-subgraph-with-promoted-missing-model",
"pos": [250, 180],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "ckpt_name",
"name": "ckpt_name",
"type": "COMBO",
"widget": {
"name": "ckpt_name"
},
"link": 2
}
],
"outputs": [],
"properties": {},
"widgets_values": ["fake_model.safetensors"]
}
],
"links": [
{
"id": 2,
"origin_id": -10,
"origin_slot": 0,
"target_id": 2,
"target_slot": 0,
"type": "COMBO"
}
]
},
{
"id": "inner-subgraph-with-promoted-missing-model",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 1,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Inner Subgraph with Promoted Missing Model",
"inputNode": {
"id": -10,
"bounding": [100, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [500, 200, 120, 60]
},
"inputs": [
{
"id": "inner-ckpt-name-input-id",
"name": "ckpt_name",
"type": "COMBO",
"linkIds": [1],
"pos": { "0": 150, "1": 220 }
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [250, 180],
"size": [315, 98],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "ckpt_name",
"name": "ckpt_name",
"type": "COMBO",
"widget": {
"name": "ckpt_name"
},
"link": 1
}
],
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": null },
{ "name": "CLIP", "type": "CLIP", "links": null },
{ "name": "VAE", "type": "VAE", "links": null }
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["fake_model.safetensors"]
}
],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "COMBO"
}
]
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"models": [
{
"name": "fake_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
}
],
"version": 0.4
}

View File

@@ -93,7 +93,7 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
public readonly searchInput: Locator
public readonly sidebarContent: Locator
public readonly allTab: Locator
public readonly blueprintsTab: Locator
public readonly essentialsTab: Locator
public readonly sortButton: Locator
public readonly nodePreview: Locator
@@ -101,8 +101,8 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
super(page, 'node-library')
this.searchInput = page.getByPlaceholder('Search...')
this.sidebarContent = page.locator('.sidebar-content-container')
this.allTab = this.getTab('All')
this.blueprintsTab = this.getTab('Blueprints')
this.allTab = this.getTab('All nodes')
this.essentialsTab = this.getTab('Essentials')
this.sortButton = this.sidebarContent.getByRole('button', { name: 'Sort' })
this.nodePreview = page.getByTestId(TestIds.sidebar.nodePreviewCard)
}

View File

@@ -0,0 +1,136 @@
import type { Page, Route } from '@playwright/test'
import {
getComboSpecComboOptions,
isComboInputSpec,
isComboInputSpecV1
} from '@/schemas/nodeDefSchema'
import type {
ComboInputSpec,
ComboInputSpecV2,
ComfyNodeDef,
InputSpec
} from '@/schemas/nodeDefSchema'
export type ObjectInfoResponse = Record<string, ComfyNodeDef>
type ComboInput = ComboInputSpec | ComboInputSpecV2
const OBJECT_INFO_ROUTE = '**/object_info'
function getRequiredInput(
objectInfo: ObjectInfoResponse,
nodeType: string,
inputName: string
): InputSpec {
const nodeInfo = objectInfo[nodeType]
if (!nodeInfo) {
throw new Error(`Missing object_info entry for ${nodeType}`)
}
const requiredInputs = nodeInfo.input?.required
if (!requiredInputs) {
throw new Error(`Missing required inputs for ${nodeType}`)
}
const input = requiredInputs[inputName]
if (!input) {
throw new Error(`Missing input ${nodeType}.${inputName}`)
}
return input
}
function getComboInput(
objectInfo: ObjectInfoResponse,
nodeType: string,
inputName: string
): ComboInput {
const input = getRequiredInput(objectInfo, nodeType, inputName)
if (isComboInputSpec(input)) {
return input
}
throw new Error(`Expected ${nodeType}.${inputName} to be a combo input`)
}
export function setComboInputOptions(
objectInfo: ObjectInfoResponse,
nodeType: string,
inputName: string,
values: ReadonlyArray<string | number>
): void {
const input = getComboInput(objectInfo, nodeType, inputName)
const nextValues = [...values]
if (isComboInputSpecV1(input)) {
input[0] = nextValues
return
}
input[1] = { ...input[1], options: nextValues }
}
export function appendComboInputOptions(
objectInfo: ObjectInfoResponse,
nodeType: string,
inputName: string,
values: ReadonlyArray<string | number>
): void {
const input = getComboInput(objectInfo, nodeType, inputName)
setComboInputOptions(objectInfo, nodeType, inputName, [
...getComboSpecComboOptions(input),
...values
])
}
export async function routeObjectInfoFromSetupApi(
page: Page,
customize?: (objectInfo: ObjectInfoResponse) => void | Promise<void>
): Promise<() => Promise<void>> {
const setupApiUrl =
process.env.PLAYWRIGHT_SETUP_API_URL ?? 'http://127.0.0.1:8188'
const objectInfoUrl = new URL('/object_info', setupApiUrl).toString()
const objectInfoRouteHandler = async (route: Route) => {
let objectInfo: ObjectInfoResponse
try {
const response = await fetch(objectInfoUrl, {
signal: AbortSignal.timeout(5_000)
})
if (!response.ok) {
await route.fulfill({
status: response.status,
contentType: response.headers.get('content-type') ?? 'text/plain',
body: await response.text()
})
return
}
objectInfo = (await response.json()) as ObjectInfoResponse
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
await route.fulfill({
status: 502,
contentType: 'application/json',
body: JSON.stringify({
error: `Failed to fetch setup object_info from ${objectInfoUrl}: ${message}`
})
})
return
}
await customize?.(objectInfo)
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(objectInfo)
})
}
await page.route(OBJECT_INFO_ROUTE, objectInfoRouteHandler)
return async () => {
if (page.isClosed()) return
await page.unroute(OBJECT_INFO_ROUTE, objectInfoRouteHandler)
}
}

View File

@@ -0,0 +1,424 @@
import { readFileSync } from 'fs'
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
import { assetPath } from '@e2e/fixtures/utils/paths'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
const PROMOTED_MODEL_WIDGET_NAME = 'ckpt_name'
export interface PromotedMissingModelWorkflow {
workflowName: string
hostNodeId: number
hostNodeTitle: string
sharedDefinitionSiblingHostNodeId?: number
sharedDefinitionSiblingHostNodeTitle?: string
}
type RootWorkflowNode = {
id: number | string
widgets_values?: unknown[] | Record<string, unknown>
}
type RootWorkflowData = ComfyWorkflowJSON & {
nodes?: RootWorkflowNode[]
}
export const NESTED_PROMOTED_MISSING_MODEL_WORKFLOW: PromotedMissingModelWorkflow =
{
workflowName: 'missing/missing_model_nested_promoted_widget',
hostNodeId: 4,
hostNodeTitle: 'Outer Subgraph with Promoted Missing Model',
sharedDefinitionSiblingHostNodeId: 3,
sharedDefinitionSiblingHostNodeTitle: 'Resolved Shared Outer Subgraph'
}
export function getMissingModelLabel(group: Locator, modelName: string) {
return group.getByRole('button', { name: modelName, exact: true })
}
export async function expectSingleMissingModelReference(
group: Locator,
modelName: string
) {
await expectMissingModelReferenceCount(group, modelName, 1)
}
export async function expectMissingModelReferenceCount(
group: Locator,
modelName: string,
count: number
) {
await expect(getMissingModelLabel(group, modelName)).toHaveCount(1)
const badge = group.getByTestId(TestIds.dialogs.missingModelReferenceCount)
if (count === 1) {
await expect(badge).toBeHidden()
return
}
await expect(badge).toBeVisible()
await expect(badge).toHaveText(String(count))
}
export async function loadPromotedMissingModelAndOpenErrorsTab(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow,
modelName: string
): Promise<Locator> {
await loadWorkflowAndOpenErrorsTab(comfyPage, workflow.workflowName)
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expectSingleMissingModelReference(missingModelGroup, modelName)
return missingModelGroup
}
export async function loadPromotedMissingModelWithHostValuesAndOpenErrorsTab(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow,
hostValues: Record<number, string>,
modelName: string,
expectedReferenceCount: number
): Promise<Locator> {
await loadPromotedMissingModelWithHostValues(comfyPage, workflow, hostValues)
const errorOverlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(errorOverlay).toBeVisible()
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(errorOverlay).toBeHidden()
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expectMissingModelReferenceCount(
missingModelGroup,
modelName,
expectedReferenceCount
)
return missingModelGroup
}
export async function expectNoMissingModelUi(comfyPage: ComfyPage) {
const panel = new PropertiesPanelHelper(comfyPage.page)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).toBeHidden()
await panel.open(comfyPage.actionbar.propertiesButton)
await expect(
panel.root.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeHidden()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
).toBeHidden()
}
export async function selectVueComboPromotedModelByTitle(
comfyPage: ComfyPage,
nodeTitle: string,
modelName: string
) {
await comfyPage.vueNodes.selectComboOption(
nodeTitle,
PROMOTED_MODEL_WIDGET_NAME,
modelName
)
}
export async function selectVueAssetPromotedModel(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow,
currentModelName: string,
modelName: string
) {
await selectModelFromFormDropdown(
comfyPage,
comfyPage.vueNodes.getNodeByTitle(workflow.hostNodeTitle),
currentModelName,
modelName
)
}
export async function selectSectionComboPromotedModel(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow,
modelName: string
) {
const panel = await openHostNodeParametersPanel(comfyPage, workflow)
const combo = panel.contentArea.getByRole('combobox', {
name: PROMOTED_MODEL_WIDGET_NAME,
exact: true
})
await combo.click()
await comfyPage.page
.getByRole('option', { name: modelName, exact: true })
.click()
}
export async function selectSectionAssetPromotedModel(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow,
currentModelName: string,
modelName: string
) {
const panel = await openHostNodeParametersPanel(comfyPage, workflow)
await selectModelFromFormDropdown(
comfyPage,
panel.contentArea,
currentModelName,
modelName
)
}
export async function setLegacyPromotedComboModel(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow,
modelName: string
) {
await comfyPage.page.evaluate(
({ hostNodeId, widgetName, value }) => {
type LegacyPromotedWidget = {
name?: string
value?: unknown
callback?: (value: string) => void
setValue?: (
value: string,
options: {
e: PointerEvent
node: unknown
canvas: unknown
}
) => void
}
type LegacyPromotedNode = {
onWidgetChanged?: (
name: string,
newValue: string,
oldValue: unknown,
widget: LegacyPromotedWidget
) => void
widgets?: LegacyPromotedWidget[]
}
type LegacyPromotedGraph = {
getNodeById: (nodeId: number) => LegacyPromotedNode | undefined
}
const currentGraph = window.app?.graph as LegacyPromotedGraph | undefined
const hostNode: LegacyPromotedNode | undefined =
currentGraph?.getNodeById(hostNodeId)
if (!hostNode) {
throw new Error(`Expected subgraph host node ${hostNodeId}`)
}
const widget = hostNode.widgets?.find(
(entry) => entry.name === widgetName
) as LegacyPromotedWidget | undefined
if (!widget) {
throw new Error(`Expected host ${widgetName} widget`)
}
const oldValue = widget.value
if (widget.setValue) {
widget.setValue(value, {
e: new PointerEvent('pointerup'),
node: hostNode,
canvas: window.app!.canvas
})
return
}
widget.value = value
widget.callback?.(value)
hostNode.onWidgetChanged?.(
widget.name ?? widgetName,
value,
oldValue,
widget
)
},
{
hostNodeId: workflow.hostNodeId,
widgetName: PROMOTED_MODEL_WIDGET_NAME,
value: modelName
}
)
}
export async function selectLegacyPromotedAssetModel(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow,
assetId: string
) {
await clickLegacyHostPromotedWidget(comfyPage, workflow)
const modal = comfyPage.page.locator(
'[data-component-id="AssetBrowserModal"]'
)
await expect(modal).toBeVisible()
const assetCard = modal.locator(`[data-asset-id="${assetId}"]`)
await expect(assetCard).toBeVisible()
await assetCard.getByRole('button', { name: 'Use', exact: true }).click()
await expect(modal).toBeHidden()
}
export async function expectResolvedPromotedModelSuppressesStaleInteriorErrors(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow,
expectedStaleInteriorWidgets: Array<{
subgraphNodeIdToEnter: string
nodeTitle: string
}>,
resolvedModelName: string,
staleModelName: string
) {
await loadPromotedMissingModelWithHostValues(comfyPage, workflow, {
[workflow.hostNodeId]: resolvedModelName
})
const promotedModelCombo = comfyPage.vueNodes
.getNodeByTitle(workflow.hostNodeTitle)
.getByRole('combobox', { name: PROMOTED_MODEL_WIDGET_NAME, exact: true })
await expect(promotedModelCombo).toContainText(resolvedModelName)
await expectNoMissingModelUi(comfyPage)
for (const step of expectedStaleInteriorWidgets) {
await enterSubgraphForStaleInteriorCheck(
comfyPage,
step.subgraphNodeIdToEnter
)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.nextFrame()
const node = comfyPage.vueNodes.getNodeByTitle(step.nodeTitle)
await expect(node).toBeVisible()
const staleCombo = node.getByRole('combobox', {
name: PROMOTED_MODEL_WIDGET_NAME,
exact: true
})
await expect(
staleCombo,
`${step.nodeTitle} should expose the stale linked interior widget`
).toBeDisabled()
await expect(
staleCombo,
`${step.nodeTitle} should keep the stale interior value`
).toContainText(staleModelName)
await expectNoMissingModelUi(comfyPage)
}
}
async function openHostNodeParametersPanel(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow
): Promise<PropertiesPanelHelper> {
await comfyPage.vueNodes.selectNode(String(workflow.hostNodeId))
const panel = new PropertiesPanelHelper(comfyPage.page)
await panel.open(comfyPage.actionbar.propertiesButton)
await expect(panel.getTab('Parameters')).toBeVisible()
await panel.switchToTab('Parameters')
return panel
}
async function loadPromotedMissingModelWithHostValues(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow,
hostValues: Record<number, string>
) {
const graphData = readPromotedMissingModelWorkflow(workflow.workflowName)
for (const [hostNodeId, value] of Object.entries(hostValues)) {
setRootHostWidgetValue(graphData, Number(hostNodeId), value)
}
await comfyPage.workflow.loadGraphData(graphData)
await comfyPage.vueNodes.waitForNodes()
}
function readPromotedMissingModelWorkflow(workflowName: string) {
return JSON.parse(
readFileSync(assetPath(`${workflowName}.json`), 'utf-8')
) as RootWorkflowData
}
function setRootHostWidgetValue(
graphData: RootWorkflowData,
hostNodeId: number,
value: string
) {
const hostNode = graphData.nodes?.find(
(node) => Number(node.id) === hostNodeId
)
if (!hostNode) throw new Error(`Expected host node ${hostNodeId}`)
if (Array.isArray(hostNode.widgets_values)) {
hostNode.widgets_values[0] = value
return
}
hostNode.widgets_values = {
...(hostNode.widgets_values ?? {}),
[PROMOTED_MODEL_WIDGET_NAME]: value
}
}
async function selectModelFromFormDropdown(
comfyPage: ComfyPage,
root: Locator,
currentModelName: string,
nextModelName: string
) {
const trigger = root
.getByRole('button', { name: currentModelName, exact: true })
.first()
await expect(trigger).toBeVisible()
await trigger.click()
const menu = comfyPage.page.getByTestId('form-dropdown-menu')
await expect(menu).toBeVisible()
await menu.getByText(nextModelName, { exact: true }).click()
await expect(menu).toBeHidden()
}
async function clickLegacyHostPromotedWidget(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow
) {
const hostNode = await comfyPage.nodeOps.getNodeRefById(workflow.hostNodeId)
await hostNode.centerOnNode()
const widget = await hostNode.getWidgetByName(PROMOTED_MODEL_WIDGET_NAME)
await widget.click()
}
async function enterSubgraphForStaleInteriorCheck(
comfyPage: ComfyPage,
nodeId: string
) {
const numericNodeId = Number(nodeId)
if (Number.isNaN(numericNodeId)) {
throw new Error(`Expected numeric subgraph node id, got ${nodeId}`)
}
const normalizedNodeId = String(numericNodeId)
const enterButton =
comfyPage.vueNodes.getSubgraphEnterButton(normalizedNodeId)
if ((await enterButton.count()) > 0) {
await comfyPage.vueNodes.enterSubgraph(normalizedNodeId)
return
}
await comfyPage.page.evaluate((targetNodeId) => {
const graph = window.app?.canvas.graph
const node = graph?.getNodeById(targetNodeId)
if (!node?.isSubgraphNode()) {
throw new Error(`Expected visible subgraph node ${targetNodeId}`)
}
window.app!.canvas.setGraph(node.subgraph)
}, numericNodeId)
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
}

View File

@@ -33,49 +33,11 @@ test.describe('Node Library Essentials Tab', { tag: '@ui' }, () => {
})
})
test('Node library opens via sidebar', async ({ comfyPage }) => {
const tabButton = comfyPage.page.locator('.node-library-tab-button')
await tabButton.click()
const sidebarContent = comfyPage.page.locator(
'.comfy-vue-side-bar-container'
)
await expect(sidebarContent).toBeVisible()
})
test('Essentials tab is visible in node library', async ({ comfyPage }) => {
const tabButton = comfyPage.page.locator('.node-library-tab-button')
await tabButton.click()
const essentialsTab = comfyPage.page.getByRole('tab', {
name: /essentials/i
})
await expect(essentialsTab).toBeVisible()
})
test('Clicking essentials tab shows essential node cards', async ({
comfyPage
}) => {
const tabButton = comfyPage.page.locator('.node-library-tab-button')
await tabButton.click()
const essentialsTab = comfyPage.page.getByRole('tab', {
name: /essentials/i
})
await essentialsTab.click()
const essentialCards = comfyPage.page.locator('[data-node-name]')
await expect(essentialCards.first()).toBeVisible()
})
test('Essential node cards have node names', async ({ comfyPage }) => {
const tabButton = comfyPage.page.locator('.node-library-tab-button')
await tabButton.click()
const essentialsTab = comfyPage.page.getByRole('tab', {
name: /essentials/i
})
await essentialsTab.click()
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', true)
const tab = comfyPage.menu.nodeLibraryTabV2
await tab.open()
await tab.essentialsTab.click()
const firstCard = comfyPage.page.locator('[data-node-name]').first()
await expect(firstCard).toBeVisible()
@@ -86,21 +48,18 @@ test.describe('Node Library Essentials Tab', { tag: '@ui' }, () => {
test('Node library can switch between all and essentials tabs', async ({
comfyPage
}) => {
const tabButton = comfyPage.page.locator('.node-library-tab-button')
await tabButton.click()
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', true)
const tab = comfyPage.menu.nodeLibraryTabV2
await tab.open()
await tab.allTab.click()
const essentialsTab = comfyPage.page.getByRole('tab', {
name: /essentials/i
})
const allNodesTab = comfyPage.page.getByRole('tab', { name: /^all$/i })
await essentialsTab.click()
await expect(essentialsTab).toHaveAttribute('aria-selected', 'true')
await tab.essentialsTab.click()
await expect(tab.essentialsTab).toHaveAttribute('aria-selected', 'true')
const essentialCards = comfyPage.page.locator('[data-node-name]')
await expect(essentialCards.first()).toBeVisible()
await allNodesTab.click()
await expect(allNodesTab).toHaveAttribute('aria-selected', 'true')
await expect(essentialsTab).toHaveAttribute('aria-selected', 'false')
await tab.allTab.click()
await expect(tab.allTab).toHaveAttribute('aria-selected', 'true')
await expect(tab.essentialsTab).toHaveAttribute('aria-selected', 'false')
})
})

View File

@@ -10,7 +10,18 @@ import {
countAssetRequestsByTag,
createCloudAssetsFixture
} from '@e2e/fixtures/assetApiFixture'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
import {
cleanupFakeModel,
loadWorkflowAndOpenErrorsTab
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
import {
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
expectNoMissingModelUi,
loadPromotedMissingModelAndOpenErrorsTab,
selectLegacyPromotedAssetModel,
selectSectionAssetPromotedModel,
selectVueAssetPromotedModel
} from '@e2e/fixtures/utils/promotedMissingModel'
import { TestIds } from '@e2e/fixtures/selectors'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
@@ -20,6 +31,8 @@ const WORKFLOW = 'missing/nested_subgraph_installed_model'
const IMPORT_SECTIONS_WORKFLOW = 'missing/cloud_missing_model_import_sections'
const OUTER_SUBGRAPH_NODE_ID = '205'
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
const FAKE_MODEL_NAME = 'fake_model.safetensors'
const RESOLVED_PROMOTED_MODEL_NAME = 'resolved_model.safetensors'
const CLOUD_IMPORTABLE_MODEL_NAME = 'cloud_importable_model.safetensors'
const CLOUD_UNKNOWN_MODEL_NAME = 'cloud_unknown_model.safetensors'
const CLOUD_IMPORTED_CANONICAL_MODEL_NAME =
@@ -55,7 +68,25 @@ const EXISTING_CLOUD_IMPORTABLE_MODEL: Asset & { hash?: string } = {
}
}
const RESOLVED_PROMOTED_MODEL_ASSET: Asset & { hash?: string } = {
id: 'test-resolved-promoted-model',
name: RESOLVED_PROMOTED_MODEL_NAME,
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000205',
size: 1_024,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],
created_at: '2026-05-05T00:00:00Z',
updated_at: '2026-05-05T00:00:00Z',
last_access_time: '2026-05-05T00:00:00Z',
user_metadata: {
filename: RESOLVED_PROMOTED_MODEL_NAME
}
}
const test = createCloudAssetsFixture([LOTUS_DIFFUSION_MODEL])
const promotedModelTest = createCloudAssetsFixture([
RESOLVED_PROMOTED_MODEL_ASSET
])
function getRequestedIncludeTags(requestUrl: string): string[] {
return (
@@ -363,3 +394,84 @@ test.describe(
})
}
)
promotedModelTest.describe(
'Errors tab - Cloud promoted subgraph missing models',
{ tag: '@cloud' },
() => {
promotedModelTest.beforeEach(async ({ comfyPage }) => {
await cleanupFakeModel(comfyPage)
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
})
promotedModelTest.afterEach(async ({ comfyPage }) => {
await cleanupFakeModel(comfyPage)
})
promotedModelTest(
'Changing a Cloud Vue promoted asset widget clears a nested subgraph error',
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
async ({ comfyPage }) => {
await loadPromotedMissingModelAndOpenErrorsTab(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
FAKE_MODEL_NAME
)
await selectVueAssetPromotedModel(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
FAKE_MODEL_NAME,
RESOLVED_PROMOTED_MODEL_NAME
)
await expectNoMissingModelUi(comfyPage)
}
)
promotedModelTest(
'Changing a Cloud Vue promoted asset from the Parameters tab clears a nested subgraph error',
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
async ({ comfyPage }) => {
await loadPromotedMissingModelAndOpenErrorsTab(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
FAKE_MODEL_NAME
)
await selectSectionAssetPromotedModel(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
FAKE_MODEL_NAME,
RESOLVED_PROMOTED_MODEL_NAME
)
await expectNoMissingModelUi(comfyPage)
}
)
promotedModelTest(
'Changing a Cloud legacy promoted asset clears a nested subgraph error',
{ tag: ['@canvas', '@widget', '@subgraph'] },
async ({ comfyPage }) => {
await loadPromotedMissingModelAndOpenErrorsTab(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
FAKE_MODEL_NAME
)
await selectLegacyPromotedAssetModel(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
RESOLVED_PROMOTED_MODEL_ASSET.id
)
await expectNoMissingModelUi(comfyPage)
}
)
}
)

View File

@@ -8,6 +8,10 @@ import {
} from '@e2e/fixtures/assetApiFixture'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
routeObjectInfoFromSetupApi,
setComboInputOptions
} from '@e2e/fixtures/utils/objectInfo'
import {
createRouteMockJob,
jobsRouteFixture
@@ -86,50 +90,6 @@ interface CloudUploadAssetState {
isUploadedAssetAvailable: boolean
}
type ObjectInfoResponse = Record<
string,
{ input?: { required?: Record<string, unknown> } }
>
function setComboInputOptions(
objectInfo: ObjectInfoResponse,
nodeType: string,
inputName: string,
values: string[]
) {
const nodeInfo = objectInfo[nodeType]
if (!nodeInfo) {
throw new Error(`Missing object_info entry for ${nodeType}`)
}
const requiredInputs = nodeInfo.input?.required
if (!requiredInputs) {
throw new Error(`Missing required inputs for ${nodeType}`)
}
const input = requiredInputs[inputName]
if (!Array.isArray(input)) {
throw new Error(`Expected ${nodeType}.${inputName} to be a combo input`)
}
const [valuesOrType, options] = input
const optionsObject =
options && typeof options === 'object' && !Array.isArray(options)
if (Array.isArray(valuesOrType)) {
input[0] = values
} else if (valuesOrType !== 'COMBO') {
throw new Error(`Expected ${nodeType}.${inputName} to have combo options`)
}
if (optionsObject) {
Object.assign(options, { options: values })
} else if (!Array.isArray(valuesOrType)) {
throw new Error(
`Expected ${nodeType}.${inputName} to have options metadata`
)
}
}
async function routeCloudBootstrapApis(page: Page) {
await page.route('**/api/settings**', async (route) => {
await route.fulfill({
@@ -161,57 +121,10 @@ async function routeCloudBootstrapApis(page: Page) {
})
}
async function routeSetupObjectInfo(
page: Page,
customize?: (objectInfo: ObjectInfoResponse) => void
) {
const setupApiUrl =
process.env.PLAYWRIGHT_SETUP_API_URL ?? 'http://127.0.0.1:8188'
const objectInfoUrl = new URL('/object_info', setupApiUrl).toString()
const objectInfoRouteHandler = async (route: Route) => {
try {
const response = await fetch(objectInfoUrl, {
signal: AbortSignal.timeout(5_000)
})
if (!response.ok) {
await route.fulfill({
status: response.status,
contentType: response.headers.get('content-type') ?? 'text/plain',
body: await response.text()
})
return
}
const objectInfo = (await response.json()) as ObjectInfoResponse
customize?.(objectInfo)
await route.fulfill({
status: response.status,
contentType: 'application/json',
body: JSON.stringify(objectInfo)
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
await route.fulfill({
status: 502,
contentType: 'application/json',
body: JSON.stringify({
error: `Failed to fetch setup object_info from ${objectInfoUrl}: ${message}`
})
})
}
}
await page.route('**/object_info', objectInfoRouteHandler)
return async () =>
await page.unroute('**/object_info', objectInfoRouteHandler)
}
const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset]).extend({
page: async ({ page }, use) => {
await routeCloudBootstrapApis(page)
const unrouteObjectInfo = await routeSetupObjectInfo(page)
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(page)
try {
await use(page)
@@ -225,13 +138,16 @@ const cloudEmptyMediaInputsTest = createCloudAssetsFixture([]).extend({
page: async ({ page }, use) => {
await routeCloudBootstrapApis(page)
const unrouteObjectInfo = await routeSetupObjectInfo(page, (objectInfo) => {
for (const node of emptyMediaLoaderNodes) {
setComboInputOptions(objectInfo, node.nodeType, node.widgetName, [
node.serverOnlyOption
])
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(
page,
(objectInfo) => {
for (const node of emptyMediaLoaderNodes) {
setComboInputOptions(objectInfo, node.nodeType, node.widgetName, [
node.serverOnlyOption
])
}
}
})
)
try {
await use(page)
@@ -246,7 +162,7 @@ const cloudUploadRaceTest = comfyPageFixture.extend<{
}>({
page: async ({ page }, use) => {
await routeCloudBootstrapApis(page)
const unrouteObjectInfo = await routeSetupObjectInfo(page)
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(page)
const state: CloudUploadAssetState = {
isUploadedAssetAvailable: false

View File

@@ -8,12 +8,46 @@ import {
openErrorsTab,
loadWorkflowAndOpenErrorsTab
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
import {
appendComboInputOptions,
routeObjectInfoFromSetupApi
} from '@e2e/fixtures/utils/objectInfo'
import {
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
expectMissingModelReferenceCount,
expectNoMissingModelUi,
expectResolvedPromotedModelSuppressesStaleInteriorErrors,
expectSingleMissingModelReference,
getMissingModelLabel,
loadPromotedMissingModelAndOpenErrorsTab,
loadPromotedMissingModelWithHostValuesAndOpenErrorsTab,
selectSectionComboPromotedModel,
selectVueComboPromotedModelByTitle,
setLegacyPromotedComboModel
} from '@e2e/fixtures/utils/promotedMissingModel'
const FAKE_MODEL_NAME = 'fake_model.safetensors'
const RESOLVED_PROMOTED_MODEL_NAME = 'resolved_model.safetensors'
function getModelLabel(group: Locator, modelName: string = FAKE_MODEL_NAME) {
return group.getByRole('button', { name: modelName, exact: true })
}
const promotedModelTest = test.extend({
page: async ({ page }, use) => {
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(
page,
(objectInfo) =>
appendComboInputOptions(
objectInfo,
'CheckpointLoaderSimple',
'ckpt_name',
[RESOLVED_PROMOTED_MODEL_NAME]
)
)
try {
await use(page)
} finally {
await unrouteObjectInfo()
}
}
})
async function expectReferenceBadge(group: Locator, count: number) {
await expect(
@@ -169,7 +203,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toBeVisible()
await expect(getModelLabel(missingModelGroup)).toBeVisible()
await expect(
getMissingModelLabel(missingModelGroup, FAKE_MODEL_NAME)
).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.click('title')
@@ -265,7 +301,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
const node1 = await comfyPage.nodeOps.getNodeRefById('1')
await node1.click('title')
await expect(getModelLabel(missingModelGroup)).toBeVisible()
await expect(
getMissingModelLabel(missingModelGroup, FAKE_MODEL_NAME)
).toBeVisible()
await expect(
missingModelGroup.getByTestId(TestIds.dialogs.missingModelLocate)
).toHaveCount(1)
@@ -381,92 +419,184 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
await cleanupFakeModel(comfyPage)
})
test(
'Resolving a promoted missing model widget through the legacy canvas path clears its error',
{ tag: ['@canvas', '@widget', '@subgraph'] },
promotedModelTest(
'Changing an OSS Vue promoted model clears a nested subgraph error',
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
async ({ comfyPage }) => {
const resolvedModelName = 'v1-5-pruned-emaonly-fp16.safetensors'
let missingModelGroup: Locator
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_model_promoted_widget'
)
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(getModelLabel(missingModelGroup)).toBeVisible()
await comfyPage.page.evaluate((value) => {
const hostNode = window.app!.graph!.getNodeById(2)
if (!hostNode?.isSubgraphNode()) {
throw new Error('Expected subgraph host node')
}
const interiorNode = hostNode.subgraph.getNodeById(1)
const widget = interiorNode?.widgets?.find(
(entry) => entry.name === 'ckpt_name'
await test.step('A: shared-definition active host reports the missing model', async () => {
missingModelGroup = await loadPromotedMissingModelAndOpenErrorsTab(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
FAKE_MODEL_NAME
)
type SettableWidget = typeof widget & {
setValue?: (
value: string,
options: {
e: PointerEvent
node: unknown
canvas: unknown
}
) => void
}
const settableWidget = widget as SettableWidget | undefined
})
if (!settableWidget?.setValue) {
throw new Error('Expected concrete ckpt_name widget')
await test.step('B: bypassing the resolved sibling host keeps the active host error visible', async () => {
const siblingHostNodeId =
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.sharedDefinitionSiblingHostNodeId
if (siblingHostNodeId === undefined) {
throw new Error('Expected a shared-definition sibling host')
}
settableWidget.setValue(value, {
e: new PointerEvent('pointerup'),
node: hostNode,
canvas: window.app!.canvas
})
}, resolvedModelName)
const siblingHost =
await comfyPage.nodeOps.getNodeRefById(siblingHostNodeId)
await siblingHost.centerOnNode()
await siblingHost.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => siblingHost.isBypassed()).toBeTruthy()
await comfyPage.canvas.click({ position: { x: 700, y: 650 } })
await openErrorsTab(comfyPage)
await expectSingleMissingModelReference(
missingModelGroup,
FAKE_MODEL_NAME
)
})
await expect(missingModelGroup).toBeHidden()
await test.step('C: changing the active host promoted widget resolves the model', async () => {
const activeHost = await comfyPage.nodeOps.getNodeRefById(
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeId
)
await activeHost.centerOnNode()
await selectVueComboPromotedModelByTitle(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeTitle,
RESOLVED_PROMOTED_MODEL_NAME
)
})
await test.step('D: the missing model UI clears', async () => {
await expectNoMissingModelUi(comfyPage)
})
await test.step('E: two missing shared-definition hosts report two references', async () => {
const siblingHostNodeId =
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.sharedDefinitionSiblingHostNodeId
if (siblingHostNodeId === undefined) {
throw new Error('Expected a shared-definition sibling host')
}
missingModelGroup =
await loadPromotedMissingModelWithHostValuesAndOpenErrorsTab(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
{
[siblingHostNodeId]: FAKE_MODEL_NAME,
[NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeId]:
FAKE_MODEL_NAME
},
FAKE_MODEL_NAME,
2
)
})
await test.step('F: changing one missing host leaves the other missing reference', async () => {
await selectVueComboPromotedModelByTitle(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeTitle,
RESOLVED_PROMOTED_MODEL_NAME
)
await expectMissingModelReferenceCount(
missingModelGroup,
FAKE_MODEL_NAME,
1
)
})
await test.step('G: changing the remaining missing host clears the model error', async () => {
const siblingHostTitle =
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.sharedDefinitionSiblingHostNodeTitle
if (siblingHostTitle === undefined) {
throw new Error('Expected a shared-definition sibling host title')
}
await selectVueComboPromotedModelByTitle(
comfyPage,
siblingHostTitle,
RESOLVED_PROMOTED_MODEL_NAME
)
await expectNoMissingModelUi(comfyPage)
})
}
)
test(
promotedModelTest(
'Changing an OSS Vue promoted model from the Parameters tab clears a nested subgraph error',
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
async ({ comfyPage }) => {
await loadPromotedMissingModelAndOpenErrorsTab(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
FAKE_MODEL_NAME
)
await selectSectionComboPromotedModel(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
RESOLVED_PROMOTED_MODEL_NAME
)
await expectNoMissingModelUi(comfyPage)
}
)
promotedModelTest(
'Changing an OSS legacy promoted model clears a nested subgraph error',
{ tag: ['@canvas', '@widget', '@subgraph'] },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await loadPromotedMissingModelAndOpenErrorsTab(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
FAKE_MODEL_NAME
)
await setLegacyPromotedComboModel(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
RESOLVED_PROMOTED_MODEL_NAME
)
await expectNoMissingModelUi(comfyPage)
}
)
promotedModelTest(
'Refreshing a resolved promoted missing model clears the combo invalid state',
{ tag: ['@widget', '@subgraph'] },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_model_promoted_widget'
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.workflowName
)
await comfyPage.vueNodes.waitForNodes()
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(getModelLabel(missingModelGroup)).toBeVisible()
await expect(
getMissingModelLabel(missingModelGroup, FAKE_MODEL_NAME)
).toBeVisible()
const promotedModelCombo = comfyPage.vueNodes
.getNodeByTitle('Subgraph with Promoted Missing Model')
.getNodeByTitle(NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeTitle)
.getByRole('combobox', { name: 'ckpt_name', exact: true })
await expect(promotedModelCombo).toHaveAttribute('aria-invalid', 'true')
const objectInfoRoute = /\/object_info$/
try {
await comfyPage.page.route(objectInfoRoute, async (route) => {
const response = await route.fetch()
const objectInfo = await response.json()
const ckptName =
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
await route.fulfill({ response, json: objectInfo })
})
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(
comfyPage.page,
(objectInfo) =>
appendComboInputOptions(
objectInfo,
'CheckpointLoaderSimple',
'ckpt_name',
[FAKE_MODEL_NAME, RESOLVED_PROMOTED_MODEL_NAME]
)
)
try {
await comfyPage.page
.getByTestId(TestIds.dialogs.missingModelRefresh)
.click()
@@ -478,11 +608,31 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
'true'
)
} finally {
await comfyPage.page.unroute(objectInfoRoute)
await unrouteObjectInfo()
}
}
)
promotedModelTest(
'Reloading a resolved nested promoted model ignores stale interior values',
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
async ({ comfyPage }) => {
await expectResolvedPromotedModelSuppressesStaleInteriorErrors(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
[
{
subgraphNodeIdToEnter: '4',
nodeTitle: 'Inner Subgraph with Promoted Missing Model'
},
{ subgraphNodeIdToEnter: '2', nodeTitle: 'Load Checkpoint' }
],
RESOLVED_PROMOTED_MODEL_NAME,
FAKE_MODEL_NAME
)
}
)
test('Bypassing a subgraph hides interior errors, un-bypassing restores them', async ({
comfyPage
}) => {

View File

@@ -10,20 +10,6 @@ test.describe('Node library sidebar V2', () => {
await tab.open()
})
test('Can switch between tabs', async ({ comfyPage }) => {
const tab = comfyPage.menu.nodeLibraryTabV2
await expect(tab.allTab).toHaveAttribute('aria-selected', 'true')
await tab.blueprintsTab.click()
await expect(tab.blueprintsTab).toHaveAttribute('aria-selected', 'true')
await expect(tab.allTab).toHaveAttribute('aria-selected', 'false')
await tab.allTab.click()
await expect(tab.allTab).toHaveAttribute('aria-selected', 'true')
await expect(tab.blueprintsTab).toHaveAttribute('aria-selected', 'false')
})
test('All tab displays node tree with folders', async ({ comfyPage }) => {
const tab = comfyPage.menu.nodeLibraryTabV2
@@ -123,8 +109,9 @@ test.describe('Node library sidebar V2', () => {
test('Blueprint previews include description', async ({ comfyPage }) => {
const tab = comfyPage.menu.nodeLibraryTabV2
await tab.blueprintsTab.click()
await tab.allTab.click()
await tab.expandFolder('Comfy Blueprints')
await tab.getNode('test blueprint').hover()
await expect(tab.nodePreview, 'Preview displays on hover').toBeVisible()
await expect(tab.nodePreview).toContainText('Inverts the image')

View File

@@ -471,11 +471,10 @@ test.describe(
'subgraphs/subgraph-with-promoted-text-widget'
)
let initialWidgetCount = 0
await expect
.poll(() => getPromotedWidgetCount(comfyPage, '11'))
.toBeGreaterThan(0)
initialWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
const initialWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()

View File

@@ -6,6 +6,21 @@ Date: 2026-03-23
Proposed
### Amendment (2026-06-19, PR 12617)
The single central registry this ADR calls the "World" was superseded during
implementation. Runtime entity data is held in dedicated Pinia stores keyed by
string IDs — `widgetValueStore`, `domWidgetStore`, `layoutStore`,
`nodeOutputStore`, `subgraphNavigationStore`, and `previewExposureStore`.
Widget values are keyed by `WidgetId` (`graphId:nodeId:name`, see
`src/types/widgetId.ts`); the `world/*` layer (`widgetValueIO`, `entityIds`,
`brand`, `WidgetEntityId`) was deleted. The ECS principles below still hold —
plain-data components, separation of data from behavior, command-driven
mutation, and no god-object growth — realized across those stores. Where the
text below says "the World," read "the set of dedicated stores"; where it shows
`world.getComponent(id, Component)`, read the matching store getter (for
example `widgetValueStore.getWidget(widgetId)`).
## Context
The litegraph layer is built on deeply coupled OOP classes (`LGraphNode`, `LLink`, `Subgraph`, `BaseWidget`, `Reroute`, `LGraphGroup`, `SlotBase`). Each entity directly references its container and children — nodes hold widget arrays, widgets back-reference their node, links reference origin/target node IDs, subgraphs extend the graph class, and so on.
@@ -40,7 +55,7 @@ Six entity kinds, each with a branded ID type:
| ----------- | ------------------------------------------------- | --------------------------- | ----------------- |
| Node | `LGraphNode` | `NodeId = number \| string` | `NodeEntityId` |
| Link | `LLink` | `LinkId = number` | `LinkEntityId` |
| Widget | `BaseWidget` subclasses (25+) | name + parent node | `WidgetEntityId` |
| Widget | `BaseWidget` subclasses (25+) | name + parent node | `WidgetId` |
| Slot | `SlotBase` / `INodeInputSlot` / `INodeOutputSlot` | index on parent node | `SlotEntityId` |
| Reroute | `Reroute` | `RerouteId = number` | `RerouteEntityId` |
| Group | `LGraphGroup` | `number` | `GroupEntityId` |
@@ -54,7 +69,6 @@ Each entity kind gets a nominal/branded type wrapping its underlying primitive.
```ts
type NodeEntityId = number & { readonly __brand: 'NodeEntityId' }
type LinkEntityId = number & { readonly __brand: 'LinkEntityId' }
type WidgetEntityId = number & { readonly __brand: 'WidgetEntityId' }
type SlotEntityId = number & { readonly __brand: 'SlotEntityId' }
type RerouteEntityId = number & { readonly __brand: 'RerouteEntityId' }
type GroupEntityId = number & { readonly __brand: 'GroupEntityId' }
@@ -63,7 +77,12 @@ type GroupEntityId = number & { readonly __brand: 'GroupEntityId' }
type GraphId = string & { readonly __brand: 'GraphId' }
```
Widgets and Slots currently lack independent IDs. The ECS will assign synthetic IDs at entity creation time via an auto-incrementing counter (matching the pattern used by `lastNodeId`, `lastLinkId`, etc. in `LGraphState`).
> **Amended (PR 12617):** Widgets are keyed by a branded composite **string**,
> `WidgetId = graphId:nodeId:name` (`src/types/widgetId.ts`), rather than a
> synthetic numeric `WidgetEntityId`. The composite stays self-documenting and
> survives renames at the store layer. The numeric per-kind brands above for
> Node/Link/Reroute/Group remain aspirational and unshipped; treat them as
> design intent. Slots have no independent ID yet.
### Component Decomposition
@@ -139,18 +158,24 @@ A node carrying a subgraph gains these additional components. Subgraphs are not
| `GroupVisual` | `color` |
| `GroupChildren` | child entity refs (nodes, reroutes) |
### World
### Dedicated stores
A central registry (the "World") maps entity IDs to their component sets. One
World exists per workflow instance, containing all entities across all nesting
levels. Each entity carries a `graphScope` identifier linking it to its
containing graph. The World also maintains a scope registry mapping each
`graphId` to its parent (or null for the root graph).
Component data lives in a set of dedicated Pinia stores, each owning one
concern and keyed by a string ID that embeds its graph scope (for example
`widgetValueStore` keyed by `WidgetId = graphId:nodeId:name`, `layoutStore`
keyed by `nodeId`/`linkId`/`rerouteId`, `nodeOutputStore` keyed by
`subgraphId:nodeId`). Each store provides a clear-by-graph lifecycle hook
(`clearGraph(graphId)`) and query helpers. A scope registry maps each `graphId`
to its parent (or null for the root graph).
> The original design centralized this in one "World" registry per workflow
> instance; PR 12617 replaced that with the dedicated stores above. The
> remainder of this section describes scoping, which applies per store.
The "single source of truth" claim in this ADR is scoped to one workflow
instance. In a future linked-subgraph model, shared definitions can be loaded
into multiple workflow instances, but mutable runtime components
(`WidgetValue`, execution state, selection, transient layout caches) remain
instance, per concern. In a future linked-subgraph model, shared definitions
can be loaded into multiple workflow instances, but mutable runtime state
(widget values, execution state, selection, transient layout caches) remains
instance-scoped unless explicitly declared shareable.
### Subgraph recursion model
@@ -166,7 +191,7 @@ queries by `graphScope`.
### Systems (future work)
Systems are pure functions that query the World for entities with specific component combinations. Initial candidates:
Systems are pure functions that query the relevant store(s) for entities with specific component combinations. Initial candidates:
- **RenderSystem** — queries `Position` + `Dimensions` (where present) + `*Visual` components
- **SerializationSystem** — queries all components to produce/consume workflow JSON
@@ -178,25 +203,23 @@ System design is deferred to a future ADR. For detailed before/after walkthrough
### Migration Strategy
1. **Define types**branded IDs, component interfaces, World type in a new `src/ecs/` directory
2. **Bridge layer** — adapter functions that read ECS components from existing class instances (zero-copy where possible)
3. **New features first** — any new cross-cutting feature (e.g., CRDT sync) builds on ECS components rather than class properties
4. **Incremental extraction** — migrate one component at a time from classes to the World, using the bridge layer for backward compatibility
5. **Deprecate class properties** — once all consumers read from the World, mark class properties as deprecated
1. **Define types**string-key ID types (for example `WidgetId`) and plain-data component interfaces, owned by the store for each concern
2. **Bridge layer** — adapter functions that read component data from existing class instances (zero-copy where possible)
3. **New features first** — any new cross-cutting feature (e.g., CRDT sync) builds on store-backed components rather than class properties
4. **Incremental extraction** — migrate one component at a time from classes into its dedicated store, using the bridge layer for backward compatibility
5. **Deprecate class properties** — once all consumers read from the store, mark class properties as deprecated
For the phased migration roadmap with shipping milestones, see [ECS Migration Plan](../architecture/ecs-migration-plan.md). For the full target architecture, see [ECS Target Architecture](../architecture/ecs-target-architecture.md). For an inventory of existing stores that already partially implement ECS patterns, see [Proto-ECS Stores](../architecture/proto-ecs-stores.md).
### Relationship to ADR 0003 (Command Pattern / CRDT)
[ADR 0003](0003-crdt-based-layout-system.md) establishes that all mutations flow through serializable, idempotent commands. This ADR (0008) defines the entity data model and the World store. They are complementary architectural layers:
[ADR 0003](0003-crdt-based-layout-system.md) establishes that all mutations flow through serializable, idempotent commands. This ADR (0008) defines the entity data model and the dedicated stores that hold it. They are complementary architectural layers:
- **Commands** (ADR 0003) describe mutation intent — serializable objects that can be logged, replayed, sent over a wire, or undone.
- **Systems** (ADR 0008) are command handlers — they validate and execute mutations against the World.
- **The World** (ADR 0008) is the store — it holds component data. It does not know about commands.
- **Systems** (ADR 0008) are command handlers — they validate and execute mutations against the relevant stores.
- **The dedicated stores** (ADR 0008) hold component data and expose mutation APIs (for example `useLayoutMutations()`, `widgetValueStore.setValue`); each owns its own transaction boundary.
The World's imperative API (`setComponent`, `deleteEntity`, etc.) is internal. External callers submit commands; the command executor wraps each in a World transaction. This is analogous to Redux: the store's internal mutation is imperative, but the public API is action-based.
For the full design showing how each lifecycle scenario maps to a command, see [World API and Command Layer](../architecture/ecs-world-command-api.md).
A store's imperative mutators are internal implementation. External callers submit commands; each mutating store wraps its writes in a transaction (the Y.js-backed `layoutStore` already does this). This follows Redux: internal mutation is imperative, while the public API is action-based.
### Alternatives Considered
@@ -210,26 +233,26 @@ For the full design showing how each lifecycle scenario maps to a command, see [
- Cross-cutting concerns (undo/redo, CRDT sync, serialization) can be implemented as systems without modifying entity classes
- Components are independently testable — no need to construct an entire `LGraphNode` to test position logic
- Branded IDs prevent a class of bugs where IDs are accidentally used across entity kinds
- The World provides a single source of truth for runtime entity state inside a workflow instance, simplifying debugging and state inspection
- Branded IDs (including the composite `WidgetId` string) prevent a class of bugs where IDs are accidentally used across entity kinds
- Each dedicated store provides a single source of truth for its concern inside a workflow instance, simplifying debugging and state inspection
- Aligns with the CRDT layout system direction from ADR 0003
### Negative
- Additional indirection: reading a node's position requires a World lookup instead of `node.pos`
- Additional indirection: reading a node's position requires a store lookup instead of `node.pos`
- Learning curve for contributors unfamiliar with ECS patterns
- Migration period where both OOP and ECS patterns coexist, increasing cognitive load
- Widgets and Slots need synthetic IDs, adding ID management complexity
### Render-Loop Performance Implications and Mitigations
Replacing direct property reads (`node.pos`) with component lookups (`world.getComponent(nodeId, Position)`) does add per-read overhead in the hot render path. In modern JS engines, hot `Map.get()` paths are heavily optimized and are often within a low constant factor of object property reads, but this ADR treats render-loop cost as a first-class risk rather than assuming it is free.
Replacing direct property reads (`node.pos`) with store lookups (for example `layoutStore` position reads) does add per-read overhead in the hot render path. In modern JS engines, hot `Map.get()` paths are heavily optimized and are often within a low constant factor of object property reads, but this ADR treats render-loop cost as a first-class risk rather than assuming it is free.
Planned mitigations for the ECS render path:
1. Pre-collect render queries into frame-stable caches (`visibleNodeIds`, `visibleLinkIds`, and resolved component references) and rebuild only on topology/layout dirty signals, not on every draw call.
2. Keep archetype-style buckets for common render signatures (for example: `Node = Position+Dimensions+NodeVisual`, `Reroute = Position+RerouteVisual`) so systems iterate arrays instead of probing unrelated entities.
3. Allow a hot-path storage upgrade behind the World API (for example, SoA-style typed arrays for `Position` and `Dimensions`) if profiling shows `Map.get()` dominates frame time.
3. Allow a hot-path storage upgrade behind a store's API (for example, SoA-style typed arrays for `Position` and `Dimensions`) if profiling shows `Map.get()` dominates frame time.
4. Gate migration of each render concern with profiling parity checks against the legacy path (same workflow, same viewport, same frame budget).
5. Treat parity as a release gate: ECS render path must stay within agreed frame-time budgets (for example, no statistically significant regression in p95 frame time on representative 200-node and 500-node workflows).
@@ -247,7 +270,6 @@ Companion architecture documents that expand on the design in this ADR:
| [ECS Target Architecture](../architecture/ecs-target-architecture.md) | Full target architecture showing how entities and interactions transform under ECS |
| [ECS Migration Plan](../architecture/ecs-migration-plan.md) | Phased migration roadmap with shipping milestones and go/no-go criteria |
| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) |
| [World API and Command Layer](../architecture/ecs-world-command-api.md) | How each lifecycle scenario maps to a command in the World API |
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
| [ADR 0009: Subgraph promoted widgets](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Follow-up decision for promoted widget identity and value ownership at subgraph boundaries |
| [Appendix: Critical Analysis](../architecture/appendix-critical-analysis.md) | Independent verification of the accuracy of the architecture documents |
@@ -258,5 +280,5 @@ Companion architecture documents that expand on the design in this ADR:
- The 25+ widget types (`BooleanWidget`, `NumberWidget`, `ComboWidget`, etc.) will share the same ECS component schema. Widget-type-specific behavior lives in systems, not in component data.
- Subgraphs are not a separate entity kind. A `GraphId` scope identifier (branded `string`) tracks which graph an entity belongs to. The scope DAG must be acyclic — see [Subgraph Boundaries](../architecture/subgraph-boundaries-and-promotion.md).
- The existing `LGraphState.lastNodeId` / `lastLinkId` / `lastRerouteId` counters extend naturally to `lastWidgetId` and `lastSlotId`.
- The internal ECS model and the serialization format are deliberately separate concerns. The `SerializationSystem` translates between the flat World and the nested serialization format. Backward-compatible loading of all prior workflow formats is a hard, indefinite constraint.
- Widgets are addressed by the composite `WidgetId` string, so they need no synthetic counter. The existing `LGraphState.lastNodeId` / `lastLinkId` / `lastRerouteId` counters cover the kinds that have numeric IDs.
- The internal ECS model and the serialization format are deliberately separate concerns. The `SerializationSystem` translates between the store-backed component data and the nested serialization format. Backward-compatible loading of all prior workflow formats is a hard, indefinite constraint.

View File

@@ -2,6 +2,13 @@
_In which we examine the shadow material of a codebase in individuation, verify its self-reported symptoms, and note where the ego's aspirations outpace the psyche's readiness for transformation._
> **Post-pivot status (PR 12617).** This analysis was written against the
> single-World ECS target. The project has since chosen dedicated Pinia stores
> over one unified World, which acts on several of the concerns raised below.
> Resolution notes are inlined where the pivot answers a critique; the still-open
> gaps (extension-callback continuity, atomicity/undo, Y.js ↔ ECS coexistence)
> remain live. Verification snapshots predate PR 12617.
---
## I. On the Accuracy of Self-Diagnosis
@@ -15,7 +22,7 @@ The god-objects are as large as claimed. `LGraphCanvas` contains 9,094 lines —
Some thirty specific line references were verified against the living code. The `renderingColor` getter sits precisely at line 328. The `drawNode()` method begins exactly at line 5554, and within it, at lines 5562 and 5564, the render pass mutates state — `_setConcreteSlots()` and `arrange()` — just as the documents confess. The scattered `_version++` increments appear at every claimed location across all three files. The module-scope store invocations in `LLink.ts:24` and `Reroute.ts:23` are exactly where indicated.
The stores — all six of them — exist at their stated paths with their described APIs. The `WidgetValueStore` does indeed hold plain `WidgetState` objects. The `PromotionStore` does maintain its ref-counted maps. The `LayoutStore` does wrap Y.js CRDTs.
The stores — six of them — exist at their stated paths with their described APIs. The `WidgetValueStore` does indeed hold plain `WidgetState` objects, keyed by `WidgetId`. The `LayoutStore` does wrap Y.js CRDTs. (The `PromotionStore` named in the original snapshot was removed by PR 12617; promoted value state now lives in `WidgetValueStore`, and `PreviewExposureStore` holds host-scoped preview exposures.)
This level of factual accuracy — 28 out of 30 sampled citation checks
(93.3%) — is, one might say, the work of a consciousness that has genuinely
@@ -47,6 +54,12 @@ This is the individuation dream: the fragmented psyche imagines itself unified,
It is a beautiful vision. It is also, in several respects, a fantasy that has not yet been tested against reality.
> **Resolved (PR 12617).** The single World was set aside. The project keeps the
> fragments deliberately apart — dedicated stores, each holding one concern and
> keyed by its own string identity. The integration the dream sought lives in the
> shared discipline (plain-data components, command-driven mutation), with the
> stores standing on their own.
### The Line-Count Comparisons
The lifecycle scenarios compare current implementations against projected ECS equivalents:
@@ -93,6 +106,12 @@ But one must be careful not to mistake diversity for disorder. Some of these com
The documents present branded IDs as an unqualified improvement. They are an improvement in _type safety_. Whether they are an improvement in _comprehensibility_ depends on whether the system provides good lookup APIs. The analysis would benefit from acknowledging this tradeoff rather than presenting it as a pure gain.
> **Resolved (PR 12617).** The pivot honored this concern. `WidgetValueStore`
> keeps the self-documenting composite as its key, branded as a string
> (`WidgetId = graphId:nodeId:name`, `src/types/widgetId.ts`), gaining cross-kind
> safety while preserving the structural meaning the synthetic integer would have
> shed.
## V. On the Subgraph: The Child Who Contains the Parent
The documents describe the `Subgraph extends LGraph` relationship as a circular dependency. This is technically accurate and architecturally concerning. But it is also, symbolically, the most interesting structure in the entire system.
@@ -115,13 +134,13 @@ This is sound. The documents would benefit from being equally realistic about th
### Factual Corrections Required
| Document | Error | Correction |
| --------------------- | ---------------------------------- | ---------------------------------- |
| `entity-problems.md` | `toJSON() (line 1033)` | `toString() (line 1033)` |
| `entity-problems.md` | `execute() (line 1418)` | `doExecute() (line 1411)` |
| `entity-problems.md` | `~539 method/property definitions` | ~848; methodology should be stated |
| `entity-problems.md` | `configure()` ~180 lines | ~247 lines |
| `proto-ecs-stores.md` | `resolveDeepest()` in diagram | `reconcile()` / `getOrCreate()` |
| Document | Error | Correction |
| --------------------- | ---------------------------------- | ----------------------------------------------------- |
| `entity-problems.md` | `toJSON() (line 1033)` | `toString() (line 1033)` |
| `entity-problems.md` | `execute() (line 1418)` | `doExecute() (line 1411)` |
| `entity-problems.md` | `~539 method/property definitions` | ~848; methodology should be stated |
| `entity-problems.md` | `configure()` ~180 lines | ~247 lines |
| `proto-ecs-stores.md` | `resolveDeepest()` in diagram | moot — `PromotedWidgetViewManager` removed (PR 12617) |
### Analytical Gaps
@@ -129,7 +148,7 @@ This is sound. The documents would benefit from being equally realistic about th
2. **Atomicity guarantees** are claimed but not mechanically specified.
3. **Y.js / ECS coexistence** is an open architectural question the documents do not engage.
4. **ECS line-count projections** are aspirational and should be marked as estimates.
5. **Composite key tradeoffs** deserve more nuance than "branded IDs fix everything."
5. **Composite key tradeoffs** deserve more nuance than "branded IDs fix everything." _(Resolved by PR 12617: `WidgetId` keeps the composite as a branded string.)_
### What the Documents Do Well

View File

@@ -1,20 +1,24 @@
# Appendix: ECS Pattern Survey
_A survey of mainstream Entity Component System libraries — bitECS, miniplex,
koota, ECSY, Thyseus, and Bevy — captured during the world-consolidation
analysis that shipped slice 1 of
[ADR 0008](../adr/0008-entity-component-system.md). This appendix records
which structural patterns our `src/world/` substrate adopts, which it
deliberately departs from, and where the trade-offs are load-bearing rather
than incidental. Thyseus is called out specifically because it is the most
Bevy-shaped of the TypeScript ECSs surveyed — its `Commands` parameter is the
closest external analog to the command layer ADR 0003 / ADR 0008 are
converging on, so it gets dedicated treatment in §2.5 and §3.5._
> **Superseded (PR 12617).** The single `src/world/` substrate this appendix
> analyzes was removed; the project adopted dedicated Pinia stores
> (`widgetValueStore`, `domWidgetStore`, `layoutStore`, `nodeOutputStore`,
> `subgraphNavigationStore`, `previewExposureStore`) keyed by string IDs. §1
> (the external library survey) remains valid reference material and supports
> the dedicated-store direction — its first unanimous finding, that components
> live with the code that owns them, is exactly what per-domain stores do. §2§4
> describe the deleted `src/world/` substrate (`world.ts`, `entityIds.ts`,
> `widgetComponents.ts`, `WidgetEntityId`) and are retained for historical
> rationale only; read their references to "the World" as "the relevant
> dedicated store."
The in-code anchors for the load-bearing constraints discussed below are the
doc-comments in [src/world/world.ts](../../src/world/world.ts) (storage
strategy) and [src/world/entityIds.ts](../../src/world/entityIds.ts) (identity
contract) — see §3 below.
_A survey of mainstream Entity Component System libraries — bitECS, miniplex,
koota, ECSY, Thyseus, and Bevy. This appendix records which structural patterns
the surveyed libraries share, which the project departs from, and where the
trade-offs carry weight. Thyseus is called out specifically because it is the
most Bevy-shaped of the TypeScript ECSs surveyed — its `Commands` parameter is
the closest external analog to the command layer ADR 0003 / ADR 0008 converge
on, so it gets dedicated treatment in §2.5 and §3.5._
---
@@ -49,9 +53,9 @@ Two structural patterns are unanimous across the surveyed libraries:
because it commits to a full system-execution runtime, not just
storage.
Our slice-1 end state — five source files under
[src/world/](../../src/world/), ~14 exported names total — sits squarely in
this band.
The dedicated-store end state — each store a small, focused module keyed by a
string ID — sits squarely in this band: a small surface per store, with
component shapes defined next to the store that owns them.
---
@@ -141,12 +145,11 @@ export function spawnEntities(commands: Commands) {
```
`commands.spawn()`, `.add(component)`, and `.remove(component)` enqueue
deferred mutations against a command buffer; the World applies them at
deferred mutations against a command buffer; the substrate applies them at
defined sync points in the schedule. This is the same shape Bevy uses
and is the closest direct external analog to the mutation layer
[ADR 0003](../adr/0003-crdt-based-layout-system.md) and the
[World API and Command Layer](./ecs-world-command-api.md) describe for
this codebase.
and is the closest direct external analog to the per-store mutation layer
[ADR 0003](../adr/0003-crdt-based-layout-system.md) describes for this
codebase (realized as store mutation APIs such as `useLayoutMutations()`).
We deliberately match the **shape** of this pattern: external callers
submit commands; only the executor calls the World's imperative
@@ -172,8 +175,8 @@ yet:
The point of calling Thyseus out separately is that when ADR 0008 lands
its command executor slice, "what does this look like in Thyseus?" is a
load-bearing comparison point — not a curiosity. Diverging from the
Bevy/Thyseus shape there should require an explicit justification, not
comparison point worth taking seriously. Diverging from the
Bevy/Thyseus shape there should require an explicit justification rather than
silent drift.
---
@@ -181,7 +184,7 @@ silent drift.
## 3. Patterns We Explicitly Do NOT Adopt
Each of the following is a real industry idiom we considered and rejected
on load-bearing grounds. None of these are pure performance trade-offs.
on structural grounds. None of these are pure performance trade-offs.
### 3.1 Replace-on-write usage idioms
@@ -215,8 +218,8 @@ SoA storage spreads each component's fields across parallel typed arrays,
so the per-entity "row object" is reconstructed on read. **A future
migration to SoA would lose the proxy on the row object** — and with it
the shared-reactive-identity contract that `BaseWidget._state` and the
`widgetValueStore` facade rely on. This is a load-bearing constraint, not
just a perf optimization decision.
`widgetValueStore` facade rely on. This constraint carries real weight
beyond a perf optimization decision.
The contract is pinned in the doc-comment at the top of
[src/world/world.ts](../../src/world/world.ts) — copied here for
@@ -261,7 +264,7 @@ The contract is pinned in the doc-comment at the top of
* Entity IDs are deterministic, content-addressed, and string-prefix
* encoded — NOT opaque numeric IDs (cf. bitECS, koota, miniplex).
*
* `widgetEntityId(rootGraphId, nodeId, name)` is load-bearing:
* `widgetEntityId(rootGraphId, nodeId, name)` carries real weight:
* consumers consistently pass `rootGraph.id` so widgets viewed at
* different subgraph depths share identity. Migrating to numeric IDs
* would break cross-subgraph value sharing. See ADR 0008 and

View File

@@ -2,7 +2,9 @@
This document walks through the major entity lifecycle operations — showing the current imperative implementation and how each transforms under the ECS architecture from [ADR 0008](../adr/0008-entity-component-system.md).
Each scenario follows the same structure: **Current Flow** (what happens today), **ECS Flow** (what it looks like with the World), and a **Key Differences** table.
ECS principles are realized across a set of dedicated Pinia stores keyed by string IDs (shipped in PR 12617): `widgetValueStore` (keyed by `WidgetId` = `graphId:nodeId:name`, see `src/types/widgetId.ts`), `layoutStore` (mutated via `useLayoutMutations()`), `nodeOutputStore`, `domWidgetStore`, `subgraphNavigationStore`, and `previewExposureStore`. Components live as plain-data entries in these stores; systems read and mutate them through store getters and command-style mutations.
Each scenario follows the same structure: **Current Flow** (what happens today), **ECS Flow** (the store-backed target), and a **Key Differences** table.
## 1. Node Removal
@@ -63,47 +65,43 @@ Problems: the graph method manually disconnects every slot, cleans up reroutes,
sequenceDiagram
participant Caller
participant CS as ConnectivitySystem
participant W as World
participant VS as VersionSystem
participant LM as useLayoutMutations()
participant LS as layoutStore
participant WVS as widgetValueStore
participant NOS as nodeOutputStore
participant DWS as domWidgetStore
Caller->>CS: removeNode(world, nodeId)
Caller->>CS: removeNode(nodeId)
CS->>W: getComponent(nodeId, Connectivity)
W-->>CS: { inputSlotIds, outputSlotIds }
CS->>LS: read node links (incoming + outgoing)
LS-->>CS: linkIds
loop each slotId
CS->>W: getComponent(slotId, SlotConnection)
W-->>CS: { linkIds }
loop each linkId
CS->>CS: removeLink(world, linkId)
Note over CS,W: removes Link entity + updates remote slots
end
CS->>W: deleteEntity(slotId)
loop each linkId
CS->>LM: deleteLink(linkId)
Note over LM,LS: removes link entry +<br/>updates both slot endpoints
end
CS->>W: getComponent(nodeId, WidgetContainer)
W-->>CS: { widgetIds }
loop each widgetId
CS->>W: deleteEntity(widgetId)
loop each widget on node
CS->>WVS: deleteWidget(widgetId)
CS->>DWS: unregisterWidget(widgetId)
end
CS->>W: deleteEntity(nodeId)
Note over W: removes Position, NodeVisual, NodeType,<br/>Connectivity, Execution, Properties,<br/>WidgetContainer — all at once
CS->>VS: markChanged()
CS->>NOS: removeNodeOutputs(nodeId)
CS->>LM: deleteNode(nodeId)
Note over CS,LS: coordinated cleanup across stores —<br/>each store drops its entry for the node
```
### Key Differences
| Aspect | Current | ECS |
| ------------------- | ------------------------------------------------ | ------------------------------------------------------ |
| Lines of code | ~107 in one method | ~30 in system function |
| Entity types known | Graph knows about all 6+ types | ConnectivitySystem knows Connectivity + SlotConnection |
| Cleanup | Manual per-slot, per-link, per-reroute | `deleteEntity()` removes all components atomically |
| Canvas notification | `setDirtyCanvas()` called explicitly | RenderSystem sees missing entity on next frame |
| Store cleanup | WidgetValueStore/LayoutStore NOT cleaned up | World deletion IS the cleanup |
| Undo/redo | `beforeChange()`/`afterChange()` manually placed | System snapshots affected components before deletion |
| Testability | Needs full LGraph + LGraphCanvas | Needs only World + ConnectivitySystem |
| Aspect | Current | ECS |
| ------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
| Lines of code | ~107 in one method | ~30 in system function |
| Entity types known | Graph knows about all 6+ types | ConnectivitySystem coordinates layoutStore + widget/output stores |
| Cleanup | Manual per-slot, per-link, per-reroute | `deleteLink()`/`deleteNode()` mutations per layout entry |
| Canvas notification | `setDirtyCanvas()` called explicitly | Vue reactivity: components re-render when store entries change |
| Store cleanup | WidgetValueStore/LayoutStore NOT cleaned up | Coordinated: `deleteWidget`, `deleteLink`/`deleteNode`, `removeNodeOutputs`, `unregisterWidget` |
| Undo/redo | `beforeChange()`/`afterChange()` manually placed | Layout mutations are command records, replayable and undoable |
| Testability | Needs full LGraph + LGraphCanvas | Needs only the relevant stores + ConnectivitySystem |
## 2. Serialization
@@ -165,41 +163,37 @@ Problems: serialization logic lives in 6 different `serialize()` methods across
sequenceDiagram
participant Caller
participant SS as SerializationSystem
participant W as World
participant LS as layoutStore
participant WVS as widgetValueStore
participant CLS as node class state
Caller->>SS: serialize(world)
Caller->>SS: serialize(graphId)
SS->>W: queryAll(NodeType, Position, Properties, WidgetContainer, Connectivity)
W-->>SS: all node entities with their components
SS->>LS: read node layouts (position, size, z-index)
LS-->>SS: layout entries for graphId
SS->>W: queryAll(LinkEndpoints)
W-->>SS: all link entities
SS->>LS: read links + reroutes for graphId
LS-->>SS: link / reroute entries
SS->>W: queryAll(SlotIdentity, SlotConnection)
W-->>SS: all slot entities
SS->>WVS: getWidget(widgetId) per node widget
WVS-->>SS: WidgetState values
SS->>W: queryAll(RerouteLinks, Position)
W-->>SS: all reroute entities
SS->>CLS: read type / properties / flags
CLS-->>SS: per-node class data
SS->>W: queryAll(GroupMeta, GroupChildren, Position)
W-->>SS: all group entities
SS->>W: queryAll(SubgraphStructure, SubgraphMeta)
W-->>SS: all subgraph entities
SS->>SS: assemble JSON from component data
SS->>SS: assemble JSON from store entries + class state
SS-->>Caller: SerializedGraph
```
### Key Differences
| Aspect | Current | ECS |
| ---------------------- | ----------------------------------------------- | ---------------------------------------------- |
| Serialization logic | Spread across 6 classes (`serialize()` on each) | Single SerializationSystem |
| Widget values | Collected inline during `node.serialize()` | WidgetValue component queried directly |
| Subgraph recursion | `asSerialisable()` recursively calls itself | Flat query — SubgraphStructure has entity refs |
| Adding a new component | Modify the entity's `serialize()` method | Add component to query in SerializationSystem |
| Testing | Need full object graph to test serialization | Mock World with test components |
| Aspect | Current | ECS |
| ---------------------- | ----------------------------------------------- | --------------------------------------------------------- |
| Serialization logic | Spread across 6 classes (`serialize()` on each) | Single SerializationSystem reading the stores |
| Widget values | Collected inline during `node.serialize()` | `widgetValueStore.getWidget(widgetId)` read directly |
| Subgraph recursion | `asSerialisable()` recursively calls itself | Flat read — layout entries carry scope tags, no recursion |
| Adding a new component | Modify the entity's `serialize()` method | Read one more store in SerializationSystem |
| Testing | Need full object graph to test serialization | Seed the stores with test entries |
## 3. Deserialization
@@ -274,64 +268,48 @@ Problems: two-phase creation is necessary because nodes need to reference each o
sequenceDiagram
participant Caller
participant SS as SerializationSystem
participant W as World
participant LS as LayoutSystem
participant LM as useLayoutMutations()
participant WVS as widgetValueStore
participant ES as ExecutionSystem
Caller->>SS: deserialize(world, data)
Caller->>SS: deserialize(graphId, data)
SS->>W: clear() [remove all entities]
SS->>WVS: clearGraph(graphId)
Note over SS,WVS: drop stale widget entries for this graph
Note over SS,W: All entities created in one pass — no two-phase needed
Note over SS,LM: All entries created in one pass — no two-phase needed
loop each node in data
SS->>W: createEntity(NodeEntityId)
SS->>W: setComponent(id, Position, {...})
SS->>W: setComponent(id, NodeType, {...})
SS->>W: setComponent(id, NodeVisual, {...})
SS->>W: setComponent(id, Properties, {...})
SS->>W: setComponent(id, Execution, {...})
SS->>LM: createNode(nodeId, { position, size, ... })
end
loop each slot in data
SS->>W: createEntity(SlotEntityId)
SS->>W: setComponent(id, SlotIdentity, {...})
SS->>W: setComponent(id, SlotConnection, {...})
end
Note over SS,W: Slots reference links by ID — no resolution needed yet
loop each link in data
SS->>W: createEntity(LinkEntityId)
SS->>W: setComponent(id, LinkEndpoints, {...})
SS->>LM: createLink(linkId, source, target)
end
Note over SS,W: Connectivity assembled from slot/link components
Note over SS,LM: links reference node + slot IDs directly,<br/>no instance resolution needed
loop each widget in data
SS->>W: createEntity(WidgetEntityId)
SS->>W: setComponent(id, WidgetIdentity, {...})
SS->>W: setComponent(id, WidgetValue, {...})
SS->>WVS: registerWidget(widgetId, { value, ... })
end
SS->>SS: create reroutes, groups, subgraphs similarly
SS->>SS: create reroutes, groups via layout mutations;<br/>subgraph scopes tagged on entries
Note over SS,W: Systems react to populated World
Note over SS,ES: Systems read the populated stores
SS->>LS: runLayout(world)
SS->>ES: computeExecutionOrder(world)
SS->>ES: computeExecutionOrder(graphId)
```
### Key Differences
| Aspect | Current | ECS |
| ------------------ | -------------------------------------------------------------------------- | ------------------------------------------------------------ |
| Two-phase creation | Required (nodes must exist before link resolution) | Not needed — components reference IDs, not instances |
| Widget restoration | Hidden inside `node.configure()` line ~900 | Explicit: WidgetValue component written directly |
| Store population | Side effect of `widget.setNodeId()` | World IS the store — writing component IS population |
| Callback cascade | `onConnectionsChange`, `onInputAdded`, `onConfigure` fire during configure | No callbacks — systems query World after deserialization |
| Subgraph ordering | Topological sort required | Flat write — SubgraphStructure just holds entity IDs |
| Error handling | Failed node → placeholder with `has_errors=true` | Failed entity → skip; components that loaded are still valid |
| Two-phase creation | Required (nodes must exist before link resolution) | Not needed — links reference string IDs, not instances |
| Widget restoration | Hidden inside `node.configure()` line ~900 | Explicit: `widgetValueStore.registerWidget(widgetId, state)` |
| Store population | Side effect of `widget.setNodeId()` | Direct: writing the store entry is the population |
| Callback cascade | `onConnectionsChange`, `onInputAdded`, `onConfigure` fire during configure | No callbacks — systems read the stores after deserialization |
| Subgraph ordering | Topological sort required | Flat write — scope tags on entries, no instance ordering |
| Error handling | Failed node → placeholder with `has_errors=true` | Failed entry → skip; entries that loaded are still valid |
## 4. Pack Subgraph
@@ -394,50 +372,50 @@ Problems: 200+ lines in one method. Manual boundary link analysis. Clone-seriali
sequenceDiagram
participant Caller
participant CS as ConnectivitySystem
participant W as World
participant LS as layoutStore
participant LM as useLayoutMutations()
participant SNS as subgraphNavigationStore
Caller->>CS: packSubgraph(world, selectedEntityIds)
Caller->>CS: packSubgraph(selectedNodeIds)
CS->>W: query Connectivity + SlotConnection for selected nodes
CS->>LS: read links for selected nodes
CS->>CS: classify links as internal vs boundary
CS->>W: create new GraphId scope in scopes registry
CS->>SNS: register new subgraph graphId
Note over CS,W: Create SubgraphNode entity in parent scope
Note over CS,LM: Create SubgraphNode layout entry in parent graph
CS->>W: createEntity(NodeEntityId) [the SubgraphNode]
CS->>W: setComponent(nodeId, Position, { center of selection })
CS->>W: setComponent(nodeId, SubgraphStructure, { graphId, interface })
CS->>W: setComponent(nodeId, SubgraphMeta, { name: 'New Subgraph' })
CS->>LM: createNode(subgraphNodeId, { position: center of selection })
CS->>CS: record SubgraphNode interface (boundary slots)
Note over CS,W: Re-parent selected entities into new graph scope
Note over CS,LS: Re-tag selected entries into new graph scope
loop each selected entity
CS->>W: update graphScope to new graphId
loop each selected node + link
CS->>LS: set graphId scope tag to new subgraph graphId
end
Note over CS,W: Create boundary slots on SubgraphNode
Note over CS,LM: Reconnect boundary links to SubgraphNode slots
loop each boundary input link
CS->>W: create SlotEntity on SubgraphNode
CS->>W: update LinkEndpoints to target new slot
CS->>LM: deleteLink(oldLinkId)
CS->>LM: createLink(newLinkId, source, subgraphNode input slot)
end
loop each boundary output link
CS->>W: create SlotEntity on SubgraphNode
CS->>W: update LinkEndpoints to source from new slot
CS->>LM: deleteLink(oldLinkId)
CS->>LM: createLink(newLinkId, subgraphNode output slot, target)
end
```
### Key Differences
| Aspect | Current | ECS |
| -------------------------- | ------------------------------------------------- | ------------------------------------------------------- |
| Entity movement | Clone → serialize → configure → remove originals | Re-parent entities: update graphScope to new GraphId |
| Boundary links | Disconnect → remove → recreate → reconnect | Update LinkEndpoints to point at new SubgraphNode slots |
| Intermediate inconsistency | Graph is partially disconnected during operation | Atomic: all component writes happen together |
| Code size | 200+ lines | ~50 lines in system |
| Undo | `beforeChange()`/`afterChange()` wraps everything | Snapshot affected components before mutation |
| Aspect | Current | ECS |
| -------------------------- | ------------------------------------------------- | ------------------------------------------------------------ |
| Entity movement | Clone → serialize → configure → remove originals | Re-tag entries: change graphId scope tag on store entries |
| Boundary links | Disconnect → remove → recreate → reconnect | `deleteLink`/`createLink` against the new SubgraphNode slots |
| Intermediate inconsistency | Graph is partially disconnected during operation | Mutations batch together as one command sequence |
| Code size | 200+ lines | ~50 lines in system |
| Undo | `beforeChange()`/`afterChange()` wraps everything | Layout mutation commands replay and undo as a batch |
## 5. Unpack Subgraph
@@ -496,48 +474,49 @@ Problems: ID remapping is complex and error-prone. Magic IDs (SUBGRAPH_INPUT_ID
sequenceDiagram
participant Caller
participant CS as ConnectivitySystem
participant W as World
participant LS as layoutStore
participant LM as useLayoutMutations()
participant SNS as subgraphNavigationStore
Caller->>CS: unpackSubgraph(world, subgraphNodeId)
Caller->>CS: unpackSubgraph(subgraphNodeId)
CS->>W: getComponent(subgraphNodeId, SubgraphStructure)
W-->>CS: { graphId, interface }
CS->>CS: read SubgraphNode interface (boundary slots)
CS->>W: query entities where graphScope = graphId
W-->>CS: all child entities (nodes, links, reroutes, etc.)
CS->>LS: query entries where graphId scope = subgraph graphId
LS-->>CS: child entries (nodes, links, reroutes)
Note over CS,W: Re-parent entities to containing graph scope
Note over CS,LS: Re-tag entries to containing graph scope
loop each child entity
CS->>W: update graphScope to parent scope
loop each child entry
CS->>LS: set graphId scope tag to parent scope
end
Note over CS,W: Reconnect boundary links
Note over CS,LM: Reconnect boundary links
loop each boundary slot in interface
CS->>W: getComponent(slotId, SlotConnection)
CS->>W: update LinkEndpoints: SubgraphNode slot → internal node slot
CS->>LM: deleteLink(boundaryLinkId)
CS->>LM: createLink(newLinkId, external slot → internal node slot)
end
CS->>W: deleteEntity(subgraphNodeId)
CS->>W: remove graphId from scopes registry
CS->>LM: deleteNode(subgraphNodeId)
CS->>SNS: drop subgraph graphId
Note over CS,W: Offset positions
Note over CS,LM: Offset positions
loop each moved entity
CS->>W: update Position component
loop each moved node
CS->>LM: moveNode(nodeId, position + offset)
end
```
### Key Differences
| Aspect | Current | ECS |
| ----------------- | --------------------------------------------------- | --------------------------------------------------------------- |
| ID remapping | `nodeIdMap[oldId] = newId` for every node and link | No remapping — entities keep their IDs, only graphScope changes |
| Magic IDs | SUBGRAPH_INPUT_ID = -10, SUBGRAPH_OUTPUT_ID = -20 | No magic IDs — boundary modeled as slot entities |
| Clone vs move | Clone nodes, assign new IDs, configure from scratch | Move entity references between scopes |
| Link reconnection | Remap origin_id/target_id, create new LLink objects | Update LinkEndpoints component in place |
| Complexity | ~200 lines with deduplication and reroute remapping | ~40 lines, no remapping needed |
| Aspect | Current | ECS |
| ----------------- | --------------------------------------------------- | ----------------------------------------------------------------- |
| ID remapping | `nodeIdMap[oldId] = newId` for every node and link | No remapping — entries keep their IDs, only the scope tag changes |
| Magic IDs | SUBGRAPH_INPUT_ID = -10, SUBGRAPH_OUTPUT_ID = -20 | No magic IDs — boundary modeled as SubgraphNode interface slots |
| Clone vs move | Clone nodes, assign new IDs, configure from scratch | Re-tag store entries between scopes |
| Link reconnection | Remap origin_id/target_id, create new LLink objects | `deleteLink`/`createLink` against the resolved endpoints |
| Complexity | ~200 lines with deduplication and reroute remapping | ~40 lines, no remapping needed |
## 6. Connect Slots
@@ -591,33 +570,28 @@ Problems: the source node orchestrates everything — it reaches into the graph'
sequenceDiagram
participant Caller
participant CS as ConnectivitySystem
participant W as World
participant VS as VersionSystem
participant LS as layoutStore
participant LM as useLayoutMutations()
Caller->>CS: connect(world, outputSlotId, inputSlotId)
Caller->>CS: connect(outputSlot, inputSlot)
CS->>W: getComponent(inputSlotId, SlotConnection)
CS->>LS: read input slot link
opt already connected
CS->>CS: removeLink(world, existingLinkId)
CS->>LM: deleteLink(existingLinkId)
end
CS->>W: createEntity(LinkEntityId)
CS->>W: setComponent(linkId, LinkEndpoints, {<br/> originNodeId, originSlotIndex,<br/> targetNodeId, targetSlotIndex, type<br/>})
CS->>W: update SlotConnection on outputSlotId (add linkId)
CS->>W: update SlotConnection on inputSlotId (set linkId)
CS->>VS: markChanged()
CS->>LM: createLink(linkId, {<br/> originNodeId, originSlotIndex,<br/> targetNodeId, targetSlotIndex, type<br/>})
Note over LM,LS: createLink updates both slot endpoints<br/>and emits a command record
```
### Key Differences
| Aspect | Current | ECS |
| ---------------- | ------------------------------------------------------------ | ------------------------------------------------------------- |
| Orchestrator | Source node (reaches into graph, target, reroutes) | ConnectivitySystem (queries World) |
| Side effects | `_version++`, `setDirtyCanvas()`, `afterChange()`, callbacks | `markChanged()` — one call |
| Reroute handling | Manual: iterate chain, add linkId to each | RerouteLinks component updated by system |
| Slot mutation | Direct: `output.links.push()`, `input.link = id` | Component update: `setComponent(slotId, SlotConnection, ...)` |
| Orchestrator | Source node (reaches into graph, target, reroutes) | ConnectivitySystem (reads layoutStore) |
| Side effects | `_version++`, `setDirtyCanvas()`, `afterChange()`, callbacks | `createLink()` command — endpoints + change tracking included |
| Reroute handling | Manual: iterate chain, add linkId to each | Reroute entries updated via layout mutations |
| Slot mutation | Direct: `output.links.push()`, `input.link = id` | `createLink(linkId, ...)` updates both endpoints |
| Validation | `onConnectInput`/`onConnectOutput` callbacks on nodes | Validation system or guard function |
## 7. Copy / Paste
@@ -688,57 +662,61 @@ parent IDs all remapped independently. ~300 lines across multiple methods.
sequenceDiagram
participant User
participant CS as ClipboardSystem
participant W as World
participant LS as layoutStore
participant WVS as widgetValueStore
participant LM as useLayoutMutations()
participant CB as Clipboard
rect rgb(40, 40, 60)
Note over User,CB: Copy
User->>CS: copy(world, selectedEntityIds)
CS->>W: snapshot all components for selected entities
CS->>W: snapshot components for child entities (slots, widgets)
CS->>W: snapshot connected links (LinkEndpoints)
CS->>CB: store component snapshot
User->>CS: copy(selectedNodeIds)
CS->>LS: snapshot layout entries (nodes, links, reroutes)
CS->>WVS: snapshot WidgetState for each widgetId
CS->>CB: store cross-store snapshot
end
rect rgb(40, 60, 40)
Note over User,CB: Paste
User->>CS: paste(world, position)
User->>CS: paste(position)
CS->>CB: retrieve snapshot
CS->>CS: generate ID remap table (old → new branded IDs)
CS->>CS: build ID remap table (old → new nodeId / WidgetId)
loop each entity in snapshot
CS->>W: createEntity(newId)
loop each component
CS->>W: setComponent(newId, type, remappedData)
Note over CS,W: entity ID refs in component data<br/>are remapped via table
end
loop each node in snapshot
CS->>LM: createNode(newNodeId, remapped layout)
end
loop each link in snapshot
CS->>LM: createLink(newLinkId, remapped endpoints)
Note over CS,LM: node + slot refs remapped via table
end
loop each widget in snapshot
CS->>WVS: registerWidget(newWidgetId, WidgetState)
end
CS->>CS: offset all Position components to cursor
CS->>LM: batchMoveNodes(offset all to cursor)
end
```
### Key Differences
| Aspect | Current | ECS |
| -------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------ |
| Copy format | Clone → serialize → JSON (format depends on class) | Component snapshot (uniform format for all entities) |
| ID remapping | Separate logic per entity type (nodes, reroutes, subgraphs, links) | Single remap table applied to all entity ID refs in all components |
| Paste reconstruction | `createNode()``add()``configure()``connect()` per node | `createEntity()``setComponent()` per entity (flat) |
| Subgraph handling | Recursive clone + UUID remap + deduplication | Snapshot includes SubgraphStructure component with entity refs |
| Code complexity | ~300 lines across 4 methods | ~60 lines in one system |
| Aspect | Current | ECS |
| -------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------- |
| Copy format | Clone → serialize → JSON (format depends on class) | Store-entry snapshot (uniform shape across stores) |
| ID remapping | Separate logic per entity type (nodes, reroutes, subgraphs, links) | One remap table applied to string keys (`nodeId`, `WidgetId`) |
| Paste reconstruction | `createNode()``add()``configure()``connect()` per node | `createNode`/`createLink`/`registerWidget` per entry (flat) |
| Subgraph handling | Recursive clone + UUID remap + deduplication | Snapshot carries scope tags; remap rewrites graphId keys |
| Code complexity | ~300 lines across 4 methods | ~60 lines in one system |
## Summary: Cross-Cutting Benefits
| Benefit | Scenarios Where It Applies |
| ----------------------------- | -------------------------------------------------------------------------- |
| **Atomic operations** | Node Removal, Pack/Unpack — no intermediate inconsistent state |
| **No scattered `_version++`** | All scenarios — VersionSystem handles change tracking |
| **No callback cascades** | Deserialization, Connect — systems query World instead of firing callbacks |
| **Uniform ID handling** | Copy/Paste, Unpack — one remap table instead of per-type logic |
| **Entity deletion = cleanup** | Node Removal — `deleteEntity()` removes all components |
| **No two-phase creation** | Deserialization — components reference IDs, not instances |
| **Move instead of clone** | Pack/Unpack — entities keep their IDs, just change scope |
| **Testable in isolation** | All scenarios — mock World, test one system |
| **Undo/redo for free** | All scenarios — snapshot components before mutation, restore on undo |
| Benefit | Scenarios Where It Applies |
| ----------------------------- | ---------------------------------------------------------------------------------------------------- |
| **Batched operations** | Node Removal, Pack/Unpack — mutations apply together as one command sequence |
| **No scattered `_version++`** | All scenarios — layout mutation commands carry change tracking |
| **No callback cascades** | Deserialization, Connect — systems read the stores instead of firing callbacks |
| **Uniform ID handling** | Copy/Paste, Unpack — one remap table over string keys instead of per-type logic |
| **Coordinated cleanup** | Node Removal — `deleteWidget` + `deleteLink`/`deleteNode` + `removeNodeOutputs` + `unregisterWidget` |
| **No two-phase creation** | Deserialization — store entries reference string IDs, not instances |
| **Move instead of clone** | Pack/Unpack — entries keep their IDs, only the scope tag changes |
| **Testable in isolation** | All scenarios — seed the relevant stores, test one system |
| **Undo/redo for free** | All scenarios — layout mutation commands replay and undo |

View File

@@ -10,6 +10,14 @@ target architecture, see [ECS Target Architecture](ecs-target-architecture.md).
For verified accuracy of these documents, see
[Appendix: Critical Analysis](appendix-critical-analysis.md).
> **Target end-state (revised):** N dedicated Pinia stores keyed by composite
> string IDs, one store per concern (widget values, DOM widgets, layout, node
> outputs, subgraph navigation, preview exposure). The earlier "single unified
> World with branded numeric entity IDs and `getComponent`/`setComponent`" model
> was rejected. PR 12617 shipped the first stores against composite
> `graphId:nodeId:name` string keys (`WidgetId`). Phases below are reframed
> around dedicated stores; shipped work is marked ✅.
## Planning assumptions
- The bridge period is expected to span 2-3 release cycles.
@@ -23,36 +31,16 @@ For verified accuracy of these documents, see
Zero behavioral risk. Prepares the codebase for extraction without changing
runtime semantics. All items are independently shippable.
### 0a. Centralize version counter
### 0a. Centralize version counter ✅ Shipped
`graph._version++` appears in 19 locations across 7 files. The counter is only
read once — for debug display in `LGraphCanvas.renderInfo()` (line 5389). It
is not used for dirty-checking, caching, or reactivity.
`LGraph.incrementVersion()` exists and is used everywhere. The counter is only
read for debug display in `LGraphCanvas.renderInfo()`; it is not used for
dirty-checking, caching, or reactivity.
**Change:** Add `LGraph.incrementVersion()` and replace all 19 direct
increments.
**Remaining cleanup:** One stray direct `_version++` at `LGraph.ts:831` should
be replaced with `incrementVersion()`.
```
incrementVersion(): void {
this._version++
}
```
| File | Sites |
| ---------------------- | ------------------------------------------------------- |
| `LGraph.ts` | 5 (lines 956, 989, 1042, 1109, 2643) |
| `LGraphNode.ts` | 8 (lines 833, 2989, 3138, 3176, 3304, 3539, 3550, 3567) |
| `LGraphCanvas.ts` | 2 (lines 3084, 7880) |
| `BaseWidget.ts` | 1 (line 439) |
| `SubgraphInput.ts` | 1 (line 137) |
| `SubgraphInputNode.ts` | 1 (line 190) |
| `SubgraphOutput.ts` | 1 (line 102) |
**Why first:** Creates the seam where a VersionSystem can later intercept,
batch, or replace the mechanism. Mechanical find-and-replace with zero
behavioral change.
**Risk:** None. Existing null guards at call sites are preserved.
**Risk:** None. Mechanical one-line change; existing null guards preserved.
### 0b. Add missing ID type aliases
@@ -79,246 +67,198 @@ Five factual errors verified during code review (see
- `entity-problems.md`: `toJSON()` should be `toString()`, `execute()` should
be `doExecute()`, method count ~539 should be ~848, `configure()` is ~240
lines not ~180
- `proto-ecs-stores.md`: `resolveDeepest()` does not exist on
PromotedWidgetViewManager; actual methods are `reconcile()` / `getOrCreate()`
---
## Phase 1: Types and World Shell
## Phase 1: Types and Dedicated Stores
Introduces the ECS type vocabulary and an empty World. No migration of existing
code — new types coexist with old ones.
Introduces the ID type vocabulary and the dedicated stores. Phase 1 end-state is
N dedicated Pinia stores, each keyed by a composite string ID, coexisting with
legacy class instances.
### 1a. Branded entity ID types
### 1a. Branded string ID types ✅ Shipped (PR 12617)
Define branded types in a new `src/ecs/entityId.ts`:
```
type NodeEntityId = number & { readonly __brand: 'NodeEntityId' }
type LinkEntityId = number & { readonly __brand: 'LinkEntityId' }
type WidgetEntityId = number & { readonly __brand: 'WidgetEntityId' }
type SlotEntityId = number & { readonly __brand: 'SlotEntityId' }
type RerouteEntityId = number & { readonly __brand: 'RerouteEntityId' }
type GroupEntityId = number & { readonly __brand: 'GroupEntityId' }
type GraphId = string & { readonly __brand: 'GraphId' } // scope, not entity
```
Add cast helpers (`asNodeEntityId(id: number): NodeEntityId`) for use at
system boundaries (deserialization, legacy bridge).
**Does NOT change existing code.** The branded types are new exports consumed
only by new ECS code.
**Risk:** Low. New files, no modifications to existing code.
**Consideration:** `NodeId = number | string` is the current type. The branded
`NodeEntityId` narrows to `number`. The `string` branch exists solely for
subgraph-related nodes (GroupNode hack). The migration must decide whether to:
- Keep `NodeEntityId = number` and handle the string case at the bridge layer
- Or define `NodeEntityId = number | string` with branding (less safe)
Recommend the former: the bridge layer coerces string IDs to a numeric
mapping, and only branded numeric IDs enter the World.
### 1b. Component interfaces
Define component interfaces in `src/ecs/components/`:
```
src/ecs/
entityId.ts # Branded ID types
components/
position.ts # Position (shared by Node, Reroute, Group)
nodeType.ts # NodeType
nodeVisual.ts # NodeVisual
connectivity.ts # Connectivity
execution.ts # Execution
properties.ts # Properties
widgetContainer.ts # WidgetContainer
linkEndpoints.ts # LinkEndpoints
...
world.ts # World type and factory
```
Components are TypeScript interfaces only — no runtime code. They mirror
the decomposition in ADR 0008 Section "Component Decomposition."
**Risk:** None. Interface-only files.
### 1c. World type
Define the World as a typed container:
`src/types/widgetId.ts` ships the branded string `WidgetId`:
```ts
interface World {
nodes: Map<NodeEntityId, NodeComponents>
links: Map<LinkEntityId, LinkComponents>
widgets: Map<WidgetEntityId, WidgetComponents>
slots: Map<SlotEntityId, SlotComponents>
reroutes: Map<RerouteEntityId, RerouteComponents>
groups: Map<GroupEntityId, GroupComponents>
scopes: Map<GraphId, GraphId | null> // graph scope DAG (parent or null for root)
createEntity<K extends EntityKind>(kind: K): EntityIdFor<K>
deleteEntity<K extends EntityKind>(kind: K, id: EntityIdFor<K>): void
getComponent<C>(id: EntityId, component: ComponentKey<C>): C | undefined
setComponent<C>(id: EntityId, component: ComponentKey<C>, data: C): void
}
type WidgetId = string & { readonly __brand: 'WidgetId' }
```
Subgraphs are not a separate entity kind. A node with a `SubgraphStructure`
component represents a subgraph. The `scopes` map tracks the graph nesting DAG.
See [Subgraph Boundaries](subgraph-boundaries-and-promotion.md) for the full
model.
Format: `graphId:nodeId:name`. A `parseWidgetId()` helper splits a `WidgetId`
back into its `{ graphId, nodeId, name }` parts at store boundaries.
World scope is per workflow instance. Linked subgraph definitions can be reused
The composite string key carries the structural relationship (graph -> node ->
widget) directly in the key. There is no synthetic opaque number and no reverse
lookup index.
**Consideration:** `NodeId = number | string`. The `string` branch exists for
subgraph-related nodes (GroupNode hack). The `WidgetId` format stringifies the
`nodeId` segment, so both numeric and string node IDs flow through unchanged.
### 1b. Plain-data store state shapes
Each dedicated store holds plain-data records for its concern — no methods on the
records, behavior lives in store actions and composables. State shapes mirror the
decomposition in ADR 0008 Section "Component Decomposition" (position, node type,
node visual, connectivity, execution, properties, widget container, link
endpoints).
**Risk:** None. Type-only definitions.
### 1c. Dedicated stores
Phase 1 end-state is a set of dedicated Pinia stores, one per concern, each
keyed by its own composite string ID. Each store owns its data and exposes a
narrow accessor surface. There is no single container that fronts all entities.
Shipped stores:
| Store | File |
| ------------------------- | ----------------------------------------------- |
| `widgetValueStore` | `src/stores/widgetValueStore.ts` |
| `domWidgetStore` | `src/stores/domWidgetStore.ts` |
| `layoutStore` | `src/renderer/core/layout/store/layoutStore.ts` |
| `nodeOutputStore` | `src/stores/nodeOutputStore.ts` |
| `subgraphNavigationStore` | `src/stores/subgraphNavigationStore.ts` |
| `previewExposureStore` | `src/stores/previewExposureStore.ts` |
`widgetValueStore` exposes `registerWidget`, `getWidget`, `setValue`,
`deleteWidget`, `getNodeWidgets`, and `clearGraph`, all `WidgetId`-native. There
is no shared `lastWidgetId` counter; identity comes from the composite key.
Store scope is per workflow instance. Linked subgraph definitions can be reused
across instances, but mutable runtime state (widget values, execution state,
selection/transient view state) remains instance-scoped through `graphId`.
selection/transient view state) stays instance-scoped through `graphId` embedded
in each composite key.
Initial implementation: plain `Map`-backed. No reactivity, no CRDT, no
persistence. The World exists but nothing populates it yet.
Subgraphs are not a separate store. Subgraph nesting is tracked in
`subgraphNavigationStore`. See
[Subgraph Boundaries](subgraph-boundaries-and-promotion.md) for the full model.
**Risk:** Low. New code, no integration points.
**Risk:** Low. Stores are additive; integration happens in Phase 2.
---
## Phase 2: Bridge Layer
## Phase 2: Store Integration
Connects the legacy class instances to the World. Both old and new code can
read entity state; writes still go through legacy classes.
Connects the legacy class instances to the dedicated stores. Both old and new
code can read entity state; writes for not-yet-migrated concerns still go through
legacy classes.
### 2a. Read-only bridge for Position
### 2a. Position reads through layoutStore
The LayoutStore (`src/renderer/core/layout/store/layoutStore.ts`) already
extracts position data for nodes, links, and reroutes into Y.js CRDTs. The
bridge reads from LayoutStore and populates the World's `Position` component.
`layoutStore` (`src/renderer/core/layout/store/layoutStore.ts`) already extracts
position data for nodes, links, and reroutes into Y.js CRDTs and is the source of
truth for layout.
**Approach:** A `PositionBridge` that observes LayoutStore changes and mirrors
them into the World. New code reads `world.getComponent(nodeId, Position)`;
legacy code continues to read `node.pos` / LayoutStore directly.
**Approach:** New code reads position via `layoutStore` queries (and
`useLayoutMutations()` for writes); legacy code continues to read `node.pos`
directly during the transition. No second copy of position data is introduced —
`layoutStore` stays authoritative.
**Open question:** Should the World wrap the Y.js maps or maintain its own
plain-data copy? Options:
**Risk:** Medium. The legacy `node.pos` read path must stay consistent with
`layoutStore` during the transition. Watch for stale reads during render.
| Approach | Pros | Cons |
| ---------------------- | ------------------------------------- | ----------------------------------------------- |
| World wraps Y.js | Single source of truth; no sync lag | World API becomes CRDT-aware; harder to test |
| World copies from Y.js | Clean World API; easy to test | Two copies of position data; sync overhead |
| World replaces Y.js | Pure ECS; no CRDT dependency in World | Breaks collaboration (ADR 0003); massive change |
### 2b. Consolidate widget callers onto widgetValueStore ✅ Largely shipped (PR 12617)
**Recommendation:** Start with "World copies from Y.js" for simplicity. The
copy is cheap (position is small data). Revisit if sync overhead becomes
measurable.
`widgetValueStore` (`src/stores/widgetValueStore.ts`) holds widget state in
plain records keyed by `WidgetId` (`graphId:nodeId:name`) and is the source of
truth for widget values. PR 12617 reverted the earlier synthetic-numeric-ID
bridge approach.
**Risk:** Medium. Introduces a sync point between two state systems. Must
ensure the bridge doesn't create subtle ordering bugs (e.g., World reads stale
position during render).
**Remaining work:** Consolidate the remaining widget callers onto
`widgetValueStore`. Reads use `getWidget(widgetId)` / `getNodeWidgets(graphId,
nodeId)`; writes use `setValue(widgetId, value)`; `parseWidgetId()` recovers the
`{ graphId, nodeId, name }` parts at boundaries.
### 2b. Read-only bridge for WidgetValue
WidgetValueStore (`src/stores/widgetValueStore.ts`) already extracts widget
state into plain `WidgetState` objects keyed by `graphId:nodeId:name`. This is
the closest proto-ECS store.
**Approach:** A `WidgetBridge` that maps `WidgetValueStore` entries into
`WidgetValue` components in the World, keyed by `WidgetEntityId`. Requires
assigning synthetic widget IDs (via `lastWidgetId` counter on LGraphState).
**Dependency:** Requires 1a (branded IDs) for `WidgetEntityId`.
**Risk:** Low-Medium. WidgetValueStore is well-structured. Main complexity is
the ID mapping — widgets currently lack independent IDs, so the bridge must
maintain a `(nodeId, widgetName) -> WidgetEntityId` lookup.
**Risk:** Low. The store is well-structured and `WidgetId`-native; identity comes
from the composite key with no separate lookup index.
**Promoted-widget caveat:** ADR 0009 assigns promoted value widgets a
host-boundary identity (`host node locator + SubgraphInput.name`). Interior
source node/widget identity is preserved only as migration and diagnostic
metadata.
### 2c. Read-only bridge for Node metadata
### 2c. Node metadata stores
Populate `NodeType`, `NodeVisual`, `Properties`, `Execution` components by
reading from `LGraphNode` instances. These are simple property copies.
Populate node-metadata records (node type, visual, properties, execution) by
reading from `LGraphNode` instances. These are simple property copies into the
relevant store.
**Approach:** When a node is added to the graph (`LGraph.add()`), the bridge
creates the corresponding entity in the World and populates its components.
When a node is removed, the bridge deletes the entity.
The `incrementVersion()` method from Phase 0a becomes the hook point — when
version increments, the bridge can re-sync changed components. (This is why
centralizing version first matters.)
**Approach:** When a node is added to the graph (`LGraph.add()`), the store
records its metadata. When a node is removed, the store drops it. The
`incrementVersion()` seam from Phase 0a is a candidate hook point for re-sync
when changed.
**Risk:** Medium. Must handle the full node lifecycle (add, configure, remove)
without breaking existing behavior. The bridge is read-only (World mirrors
classes, not the reverse), which limits blast radius.
without breaking existing behavior. Stores mirror the classes during the
transition, which limits blast radius.
### Bridge sunset criteria (applies to every Phase 2 bridge)
### Store sunset criteria (applies to every Phase 2 concern)
A bridge can move from "transitional" to "removal candidate" only when:
A legacy path can move from "transitional" to "removal candidate" only when:
- All production reads for that concern flow through World component queries.
- All production writes for that concern flow through system APIs.
- Serialization parity tests show no diff between legacy and World paths.
- Extension compatibility tests pass without bridge-only fallback paths.
- All production reads for that concern flow through store accessors.
- All production writes for that concern flow through store actions.
- Serialization parity tests show no diff between legacy and store-driven paths.
- Extension compatibility tests pass without legacy-only fallback paths.
These criteria prevent the bridge from becoming permanent by default.
These criteria prevent the dual path from becoming permanent by default.
### Bridge duration and maintenance controls
### Dual-path duration and maintenance controls
To contain dual-path maintenance cost during Phases 2-4:
- Every bridge concern has a named owner and target sunset release.
- Every PR touching bridge-covered data paths must include parity tests for both
legacy and World-driven execution.
- Bridge fallback usage is instrumented in integration/e2e and reviewed every
milestone; upward trends block new bridge expansion.
- Any bridge that misses its target sunset release requires an explicit risk
- Every concern has a named owner and target sunset release.
- Every PR touching store-covered data paths must include parity tests for both
legacy and store-driven execution.
- Legacy fallback usage is instrumented in integration/e2e and reviewed every
milestone; upward trends block new dual-path expansion.
- Any concern that misses its target sunset release requires an explicit risk
review and revised removal plan.
---
## Phase 3: Systems
Introduce system functions that operate on World data. Systems coexist with
Introduce system functions that operate on store data. Systems coexist with
legacy methods — they don't replace them yet.
### 3a. SerializationSystem (read-only)
A function `serializeFromWorld(world: World): SerializedGraph` that produces
workflow JSON by querying World components. Run alongside the existing
`LGraph.serialize()` in tests to verify equivalence.
A function `serializeFromStores(): SerializedGraph` that produces workflow JSON
by querying the dedicated stores. Run alongside the existing `LGraph.serialize()`
in tests to verify equivalence.
**Why first:** Serialization is read-only and has a clear correctness check
(output must match existing serialization). It exercises every component type
and proves the World contains sufficient data.
(output must match existing serialization). It exercises every store and proves
the stores contain sufficient data.
**Risk:** Low. Runs in parallel with existing code; does not replace it.
### 3b. VersionSystem
Replace the `incrementVersion()` method with a system that owns all change
tracking. The system observes component mutations on the World and
auto-increments the version counter.
Move change tracking behind a system that observes store mutations and
auto-increments the version counter, replacing scattered explicit increment
calls.
**Dependency:** Requires Phase 2 bridges to be in place (otherwise the World
doesn't see changes).
**Dependency:** Requires Phase 2 store integration (otherwise the system doesn't
see changes).
**Risk:** Medium. Must not miss any change that the scattered `_version++`
currently catches. The 19-site inventory from Phase 0a serves as the test
matrix.
historically caught.
### 3c. ConnectivitySystem (queries only)
A system that can answer connectivity queries by reading `Connectivity`,
`SlotConnection`, and `LinkEndpoints` components from the World:
A system that answers connectivity queries by reading connectivity, slot, and
link-endpoint records from the relevant stores:
- "What nodes are connected to this node's inputs?"
- "What links pass through this reroute?"
- "What is the execution order?"
Does not perform mutations yet — just queries. Validates that the World's
connectivity data is complete and consistent with the class-based graph.
Does not perform mutations yet — just queries. Validates that store connectivity
data is complete and consistent with the class-based graph.
**Risk:** Low. Read-only system with equivalence tests.
@@ -326,27 +266,27 @@ connectivity data is complete and consistent with the class-based graph.
## Phase 4: Write Path Migration
Systems begin owning mutations. Legacy class methods delegate to systems.
This is the highest-risk phase.
Systems begin owning mutations. Legacy class methods delegate to stores and
systems. This is the highest-risk phase.
### 4a. Position writes through World
### 4a. Position writes through layoutStore
New code writes position via `world.setComponent(nodeId, Position, ...)`.
The bridge propagates changes back to LayoutStore and `LGraphNode.pos`.
New code writes position via `useLayoutMutations()` against `layoutStore`. A
compatibility shim propagates changes back to `LGraphNode.pos` for legacy
readers.
**This inverts the data flow:** Phase 2 had legacy -> World (read bridge).
Phase 4 has World -> legacy (write bridge). Both paths must work during the
transition.
**This inverts the data flow:** Phase 2 had legacy -> store (read path). Phase 4
has store -> legacy (write path). Both must work during the transition.
**Risk:** High. Two-way sync between World and legacy state. Must handle
re-entrant updates (World write triggers bridge, which writes to legacy,
which must NOT trigger another World write).
**Risk:** High. Two-way sync between `layoutStore` and legacy state. Must handle
re-entrant updates (store write triggers the shim, which writes to legacy, which
must NOT trigger another store write).
### 4b. ConnectivitySystem mutations
`connect()`, `disconnect()`, `removeNode()` operations implemented as system
functions on the World. Legacy `LGraphNode.connect()` etc. delegate to the
system.
functions over the connectivity stores. Legacy `LGraphNode.connect()` etc.
delegate to the system.
**Extension API concern:** The current system fires callbacks at each step:
@@ -363,8 +303,8 @@ the system knowing about the callback API.
**Phase 4 callback contract (locked):**
- `onConnectOutput()` and `onConnectInput()` run before any World mutation.
- If either callback rejects, abort with no component writes, no version bump,
- `onConnectOutput()` and `onConnectInput()` run before any store mutation.
- If either callback rejects, abort with no store writes, no version bump,
and no lifecycle events.
- `onConnectionsChange()` fires synchronously after commit, preserving current
source-then-target ordering.
@@ -374,14 +314,14 @@ the system knowing about the callback API.
**Risk:** High. Extensions depend on callback ordering and timing. Must be
validated against real-world extensions.
### 4c. Widget write path
### 4c. Widget write path ✅ Largely shipped (PR 12617)
Widget value changes go through the World instead of directly through
WidgetValueStore. The World's `WidgetValue` component becomes the single
source of truth; WidgetValueStore becomes a read-through cache or is removed.
`widgetValueStore.setValue()` is already the widget write path and the source of
truth for widget values. Remaining work routes the last legacy widget writers
through `setValue()` rather than mutating widget instances directly.
**Risk:** Medium. WidgetValueStore is already well-abstracted. The main
change is routing writes through the World instead of the store.
**Risk:** Medium. The store is well-abstracted and `WidgetId`-native. The main
change is migrating the remaining direct-mutation call sites onto `setValue()`.
### 4d. Layout write path and render decoupling
@@ -407,25 +347,25 @@ Before enabling ECS render reads as default for any migrated family:
- Compare legacy vs ECS p95 frame time and mean draw cost.
- Block rollout on statistically significant regression beyond agreed budget
(default budget: 5% p95 frame-time regression ceiling).
- Capture profiler traces proving the dominant cost is not repeated
`world.getComponent()` lookups.
- Capture profiler traces proving the dominant cost is not repeated store
accessor lookups.
### Phase 3 -> 4 gate (required)
Phase 4 starts only when all of the following are true:
- A transaction wrapper API exists on the World and is used by connectivity and
widget write paths in integration tests.
- A store/command-executor transaction wrapper exists and is used by connectivity
and widget write paths in integration tests.
- Undo batching parity is proven: one logical user action yields one undo
checkpoint in both legacy and ECS paths.
checkpoint in both legacy and store-driven paths.
- Callback timing and rejection semantics from Phase 4b are covered by
integration tests.
- A representative extension suite passes, including `rgthree-comfy`.
- Write bridge re-entrancy tests prove there is no World <-> legacy feedback
- Write-path re-entrancy tests prove there is no store <-> legacy feedback
loop.
- Layout migration for any enabled node family passes read-only render checks
(no `arrange()` writes during draw).
- Render hot-path benchmark gate passes for every family moving to ECS-first
- Render hot-path benchmark gate passes for every family moving to store-first
reads.
---
@@ -435,10 +375,11 @@ Phase 4 starts only when all of the following are true:
Remove bridge layers and deprecated class properties. This phase happens
per-component, not all at once.
### 5a. Remove Position bridge
### 5a. Remove Position compatibility shim
Once all position reads and writes go through the World, remove the bridge
and the `pos`/`size` properties from `LGraphNode`, `Reroute`, `LGraphGroup`.
Once all position reads and writes go through `layoutStore`, remove the
compatibility shim and the `pos`/`size` properties from `LGraphNode`, `Reroute`,
`LGraphGroup`.
### 5b. Remove widget class hierarchy
@@ -448,9 +389,9 @@ replaced with component data + system functions. `BaseWidget`, `NumberWidget`,
### 5c. Dissolve god objects
`LGraphNode`, `LLink`, `LGraph` become thin shells — their only role is
holding the entity ID and delegating to the World. Eventually, they can be
removed entirely, replaced by entity ID + component queries.
`LGraphNode`, `LLink`, `LGraph` become thin shells — their only role is holding
the composite ID and delegating to the stores. Eventually, they can be removed
entirely, replaced by composite IDs + store queries.
**Risk:** Very High. This is the irreversible step. Must be done only after
thorough validation that all consumers (including extensions) work with the
@@ -460,8 +401,8 @@ ECS path.
Legacy removal starts only when all of the following are true:
- The component being removed has no remaining direct reads or writes outside
World/system APIs.
- The concern being removed has no remaining direct reads or writes outside
store/system APIs.
- Serialization equivalence tests pass continuously for one release cycle.
- A representative extension compatibility matrix is green, including
`rgthree-comfy`.
@@ -489,20 +430,20 @@ The team prepares a single go/no-go packet containing:
### CRDT / ECS coexistence
The LayoutStore uses Y.js CRDTs for collaboration-ready position data
(per [ADR 0003](../adr/0003-crdt-based-layout-system.md)). The ECS World
uses plain `Map`s. These must coexist.
`layoutStore` uses Y.js CRDTs for collaboration-ready position data
(per [ADR 0003](../adr/0003-crdt-based-layout-system.md)). The other dedicated
stores hold plain reactive data. These must coexist.
**Options explored in Phase 2a.** The recommended path (World copies from Y.js)
defers the hard question. Eventually, the World may need to be CRDT-native —
but this requires a separate ADR.
`layoutStore` stays authoritative for layout (Phase 2a), so position data has a
single CRDT-backed home. Whether other stores need CRDT backing is open and
requires a separate ADR.
**Questions to resolve:**
- Should non-position components also be CRDT-backed for collaboration?
- Does the World need an operation log for undo/redo, or can that remain
external (Y.js undo manager)?
- How does conflict resolution work when two users modify the same component?
- Should non-position stores also be CRDT-backed for collaboration?
- Do the stores need an operation log for undo/redo, or can that remain external
(Y.js undo manager)?
- How does conflict resolution work when two users modify the same record?
### Extension API preservation
@@ -529,7 +470,7 @@ event listeners instead of callbacks.
**Phase 4 decisions:**
- Rejection callbacks act as pre-commit guards (reject before World mutation).
- Rejection callbacks act as pre-commit guards (reject before store mutation).
- Callback dispatch remains synchronous during the bridge period.
- Callback order remains: output validation -> input validation -> commit ->
output change notification -> input change notification.
@@ -546,16 +487,12 @@ incrementally to ECS-native patterns.
const seedWidget = node.widgets?.find((w) => w.name === 'seed')
seedWidget?.setValue(42)
// ECS pattern (using the bridge/world widget lookup index)
const seedWidgetId = world.widgetIndex.getByNodeAndName(nodeId, 'seed')
// Store pattern (composite WidgetId, no reverse-lookup index needed)
const seedWidgetId = widgetValueStore
.getNodeWidgets(graphId, nodeId)
.find((id) => parseWidgetId(id).name === 'seed')
if (seedWidgetId) {
const widgetValue = world.getComponent(seedWidgetId, WidgetValue)
if (widgetValue) {
world.setComponent(seedWidgetId, WidgetValue, {
...widgetValue,
value: 42
})
}
widgetValueStore.setValue(seedWidgetId, 42)
}
```
@@ -606,17 +543,15 @@ lifecycleEvents.on('entity.removed', (event) => {
// Legacy pattern (do not add new usages)
graph._version++
// Bridge-safe transitional pattern (Phase 0a)
// Transitional pattern (Phase 0a)
graph.incrementVersion()
// ECS-native pattern: mutate through command/system API.
// Store-native pattern: mutate through the command/system API.
// VersionSystem bumps once at transaction commit.
executor.run({
type: 'SetWidgetValue',
execute(world) {
const value = world.getComponent(widgetId, WidgetValue)
if (!value) return
world.setComponent(widgetId, WidgetValue, { ...value, value: 42 })
execute() {
widgetValueStore.setValue(widgetId, 42)
}
})
```
@@ -628,9 +563,11 @@ executor.run({
### Atomicity and transactions
The ECS lifecycle scenarios claim operations are "atomic." This requires
the World to support transactions — the ability to batch multiple component
writes and commit or rollback as a unit.
The lifecycle scenarios claim operations are "atomic." This requires a
store/command-executor transaction — the ability to batch multiple store writes
and commit or rollback as a unit. `layoutStore` already wraps its mutations in
Y.js transactions; the command executor extends the same discipline across
stores.
**Current state:** `beforeChange()` / `afterChange()` provide undo/redo
checkpoints but not true transactions. The graph can be in an inconsistent
@@ -638,10 +575,10 @@ state between these calls.
**Phase 4 baseline semantics:**
- Mutating systems run inside `world.transaction(label, fn)`.
- The bridge maps one World transaction to one `beforeChange()` /
- Mutating systems run inside a single command-executor transaction.
- The bridge maps one executor transaction to one `beforeChange()` /
`afterChange()` bracket.
- Operations with multiple component writes (for example `connect()` touching
- Operations with multiple store writes (for example `connect()` touching
slots, links, and node metadata) still commit as one transaction and therefore
one undo entry.
- Failed transactions do not publish partial writes, lifecycle events, or
@@ -649,65 +586,64 @@ state between these calls.
**Questions to resolve:**
- How should `world.transaction()` interact with Y.js transactions when a
component is CRDT-backed?
- How should the command-executor transaction interact with the Y.js
transactions that `layoutStore` already runs?
- Is eventual consistency acceptable for derived data updates between
transactions, or must post-transaction state always be immediately
consistent?
### Keying strategy unification
The 6 proto-ECS stores use 6 different keying strategies:
The dedicated stores use per-concern keying strategies:
| Store | Key Format |
| ----------------------- | --------------------------------- |
| WidgetValueStore | `"${nodeId}:${widgetName}"` |
| PromotionStore | `"${sourceNodeId}:${widgetName}"` |
| DomWidgetStore | Widget UUID |
| LayoutStore | Raw nodeId/linkId/rerouteId |
| NodeOutputStore | `"${subgraphId}:${nodeId}"` |
| SubgraphNavigationStore | subgraphId or `'root'` |
| Store | Key Format |
| ------------------------- | ---------------------------------- |
| `widgetValueStore` | `WidgetId` (`graphId:nodeId:name`) |
| `domWidgetStore` | Widget UUID |
| `layoutStore` | Raw nodeId/linkId/rerouteId |
| `nodeOutputStore` | `"${subgraphId}:${nodeId}"` |
| `subgraphNavigationStore` | subgraphId or `'root'` |
ADR 0009 refines the promoted-widget target: promoted value widgets should use
host boundary identity (`host node locator + SubgraphInput.name`), not interior
source node/widget identity.
The World unifies these under branded entity IDs. But stores that use
composite keys (e.g., `nodeId:widgetName`) reflect a genuine structural
reality — a widget is identified by its relationship to a node. Synthetic
`WidgetEntityId`s replace this with an opaque number, requiring a reverse
lookup index.
Composite string keys won over synthetic numeric IDs. A widget is identified by
its relationship to a graph and node, and the `graphId:nodeId:name` key carries
that relationship directly. PR 12617 kept the composite string instead of an
opaque number, so no reverse lookup index is required — `parseWidgetId()`
recovers the parts on demand.
**Trade-off:** Type safety and uniformity vs. self-documenting keys. The
World should maintain a lookup index (`(nodeId, widgetName) -> WidgetEntityId`)
for the transition period.
**Resolution:** Self-documenting composite keys, parsed at boundaries. Each store
keeps the key format that matches its concern; there is no forced unification
under a single ID space.
---
## Dependency Graph
```
Phase 0a (incrementVersion) ──
Phase 0b (ID type aliases) ───
Phase 0a (incrementVersion) ──── ✅ shipped (one stray cleanup remaining)
Phase 0b (ID type aliases) ───
Phase 0c (doc fixes) ─────────┤── no dependencies between these
Phase 1a (branded IDs) ────────┤
Phase 1b (component interfaces) ┤── 1b depends on 1a
Phase 1c (World type) ─────────┘── 1c depends on 1a, 1b
Phase 2a (Position bridge) ────┐── depends on 1c
Phase 2b (Widget bridge) ──────┤── depends on 1a, 1c
Phase 2c (Node metadata bridge) ┘── depends on 0a, 1c
Phase 1a (branded WidgetId) ── ✅ shipped (PR 12617)
Phase 1b (store state shapes) ─┐── depends on 1a
Phase 1c (dedicated stores) ──┘── widgetValueStore + 5 others shipped (PR 12617)
Phase 2a (Position via layoutStore) ─┐── depends on 1c
Phase 2b (Widget consolidation) ────┤── ✅ largely shipped; depends on 1a, 1c
Phase 2c (Node metadata stores) ────┘── depends on 1c
Phase 3a (SerializationSystem) ─── depends on 2a, 2b, 2c
Phase 3b (VersionSystem) ──────── depends on 0a, 2c
Phase 3b (VersionSystem) ──────── depends on 2c (store-level change tracking)
Phase 3c (ConnectivitySystem) ──── depends on 2c
Phase 3->4 gate checklist ──────── depends on 3a, 3b, 3c
Phase 4a (Position writes) ────── depends on 2a, 3b
Phase 4b (Connectivity mutations) ─ depends on 3c, 3->4 gate
Phase 4c (Widget writes) ─────── depends on 2b
Phase 4c (Widget writes) ─────── ✅ largely shipped; depends on 2b
Phase 4d (Layout decoupling) ─── depends on 2a, 3->4 gate
Phase 4->5 exit criteria ──────── depends on all of Phase 4
@@ -715,16 +651,19 @@ Phase 4->5 exit criteria ──────── depends on all of Phase 4
Phase 5 (legacy removal) ─────── depends on 4->5 exit criteria
```
The dedicated stores (1c) are the hub: Phase 2 routes legacy data into them,
Phase 3 systems read from them, Phase 4 routes writes through them.
## Risk Summary
| Phase | Risk | Reversibility | Extension Impact |
| ------------------ | ---------- | ----------------------- | --------------------------- |
| 0 (Foundation) | None | Fully reversible | None |
| 1 (Types/World) | Low | New files, deletable | None |
| 2 (Bridge) | Low-Medium | Bridge is additive | None |
| 3 (Systems) | Low-Medium | Systems run in parallel | None |
| 4 (Write path) | High | Two-way sync is fragile | Callbacks must be preserved |
| 5 (Legacy removal) | Very High | Irreversible | Extensions must migrate |
| Phase | Risk | Reversibility | Extension Impact |
| --------------------- | ---------- | ----------------------- | --------------------------- |
| 0 (Foundation) | None | Fully reversible | None |
| 1 (Types/Stores) | Low | New files, deletable | None |
| 2 (Store integration) | Low-Medium | Additive store reads | None |
| 3 (Systems) | Low-Medium | Systems run in parallel | None |
| 4 (Write path) | High | Two-way sync is fragile | Callbacks must be preserved |
| 5 (Legacy removal) | Very High | Irreversible | Extensions must migrate |
The plan is designed so that Phases 0-3 can ship without any risk to
extensions or existing behavior. Phase 4 is where the real migration begins,

View File

@@ -2,30 +2,29 @@
This document describes the target ECS architecture for the litegraph entity system. It shows how the entities and interactions from the [current system](entity-interactions.md) transform under ECS, and how the [structural problems](entity-problems.md) are resolved. For the full design rationale, see [ADR 0008](../adr/0008-entity-component-system.md).
## 1. World Overview
## 1. Store Overview
The World is the single source of truth for runtime entity state in one
workflow instance. Entities are just branded IDs. Components are plain data
objects. Systems are functions that query the World.
The source of truth for runtime entity state in one workflow instance is the set
of dedicated Pinia stores. Each store is keyed by per-store string IDs.
Components are plain data objects. Systems are functions that query the relevant
store(s).
```mermaid
graph TD
subgraph World["World (Central Registry)"]
subgraph Stores["Dedicated Stores (source of truth)"]
direction TB
NodeStore["Nodes
Map&lt;NodeEntityId, NodeComponents&gt;"]
LinkStore["Links
Map&lt;LinkEntityId, LinkComponents&gt;"]
ScopeRegistry["Graph Scopes
Map&lt;GraphId, ParentGraphId | null&gt;"]
WidgetStore["Widgets
Map&lt;WidgetEntityId, WidgetComponents&gt;"]
SlotStore["Slots
Map&lt;SlotEntityId, SlotComponents&gt;"]
RerouteStore["Reroutes
Map&lt;RerouteEntityId, RerouteComponents&gt;"]
GroupStore["Groups
Map&lt;GroupEntityId, GroupComponents&gt;"]
WidgetValueStore["widgetValueStore
Map&lt;WidgetId, WidgetValue&gt;"]
DomWidgetStore["domWidgetStore
Map&lt;WidgetId, DomWidgetState&gt;"]
LayoutStore["layoutStore (Y.js CRDT)
nodeId / linkId / rerouteId → layout"]
NodeOutputStore["nodeOutputStore
Map&lt;nodeLocatorId, outputs&gt;"]
SubgraphNavStore["subgraphNavigationStore
active subgraph path"]
PreviewExposureStore["previewExposureStore
preview exposure state"]
end
subgraph Systems["Systems (Behavior)"]
@@ -38,53 +37,50 @@ Map&lt;GroupEntityId, GroupComponents&gt;"]
VS["VersionSystem"]
end
RS -->|reads| World
SS -->|reads/writes| World
CS -->|reads/writes| World
LS -->|reads/writes| World
ES -->|reads| World
VS -->|reads/writes| World
RS -->|reads| Stores
SS -->|reads/writes| Stores
CS -->|reads/writes| LayoutStore
LS -->|reads/writes| LayoutStore
ES -->|reads| NodeOutputStore
VS -->|reads/writes| LayoutStore
style World fill:#1a1a2e,stroke:#16213e,color:#e0e0e0
style Stores fill:#1a1a2e,stroke:#16213e,color:#e0e0e0
style Systems fill:#0f3460,stroke:#16213e,color:#e0e0e0
```
### Entity IDs
### Entity Keys
Each store addresses entities by its own string-key convention.
```mermaid
graph LR
subgraph "Branded IDs (compile-time distinct)"
NID["NodeEntityId
number & { __brand: 'NodeEntityId' }"]
LID["LinkEntityId
number & { __brand: 'LinkEntityId' }"]
WID["WidgetEntityId
number & { __brand: 'WidgetEntityId' }"]
SLID["SlotEntityId
number & { __brand: 'SlotEntityId' }"]
RID["RerouteEntityId
number & { __brand: 'RerouteEntityId' }"]
GID["GroupEntityId
number & { __brand: 'GroupEntityId' }"]
subgraph "Per-store string keys"
WID["WidgetId
graphId:nodeId:name
(branded string, src/types/widgetId.ts)"]
NLID["nodeLocatorId
subgraphId:nodeId"]
NID["nodeId (raw)"]
LID["linkId (raw)"]
RID["rerouteId (raw)"]
end
GRID["GraphId
string & { __brand: 'GraphId' }"]:::scopeId
NID -.-x LID
LID -.-x WID
WID -.-x SLID
classDef scopeId fill:#2a2a4a,stroke:#4a4a6a,color:#e0e0e0,stroke-dasharray:5
linkStyle 0 stroke:red,stroke-dasharray:5
linkStyle 1 stroke:red,stroke-dasharray:5
linkStyle 2 stroke:red,stroke-dasharray:5
WID -->|widgetValueStore, domWidgetStore| W["keyed lookups"]
NLID -->|nodeOutputStore| W
NID -->|layoutStore| W
LID -->|layoutStore| W
RID -->|layoutStore| W
```
Red dashed lines = compile-time errors if mixed. No more accidentally passing a `LinkId` where a `NodeId` is expected.
`WidgetId = graphId:nodeId:name` is itself a branded string (see
`src/types/widgetId.ts`). `nodeLocatorId = subgraphId:nodeId` addresses node
outputs. `layoutStore` keys layout records by raw `nodeId` / `linkId` /
`rerouteId`. Each store enforces its own key shape; there is no single shared
entity-ID type across stores.
Note: `GraphId` is a scope identifier, not an entity ID. It identifies which graph an entity belongs to. Subgraphs are nodes with a `SubgraphStructure` component — see [Subgraph Boundaries](subgraph-boundaries-and-promotion.md).
Note: `graphId` is a scope identifier. It identifies which graph an entity
belongs to and forms the prefix of `WidgetId`. Subgraphs are nodes with a
`SubgraphStructure` component — see [Subgraph Boundaries](subgraph-boundaries-and-promotion.md).
### Linked subgraphs and instance-varying state
@@ -94,9 +90,10 @@ instance-scoped.
- Shared definition-level data (interface shape, default metadata) can be reused
across instances.
- Runtime state (`WidgetValue`, execution/transient state, selection) is scoped
to the containing `graphId` chain inside one World instance.
- "Single source of truth" therefore means one source per workflow instance,
not one global source across all linked instances.
to the containing `graphId` chain inside one workflow instance.
- "Single source of truth" therefore means one source per workflow instance
across the dedicated stores, with no global source shared across all linked
instances.
### Recursive subgraphs without inheritance
@@ -130,7 +127,7 @@ graph LR
B12["connect(), disconnect()"]
end
subgraph After["NodeEntityId + Components"]
subgraph After["nodeId-keyed components (across stores)"]
direction TB
A1["Position
{ pos, size, bounding }"]
@@ -180,7 +177,7 @@ target_id, target_slot, type"]
B5["resolve()"]
end
subgraph After["LinkEntityId + Components"]
subgraph After["linkId-keyed components (layoutStore)"]
direction TB
A1["LinkEndpoints
{ originId, originSlot,
@@ -214,7 +211,7 @@ graph LR
B5["useWidgetValueStore()"]
end
subgraph After["WidgetEntityId + Components"]
subgraph After["WidgetId + components"]
direction TB
A1["WidgetIdentity
{ name, widgetType, parentNodeId }"]
@@ -228,8 +225,7 @@ graph LR
B2 -.-> A2
B3 -.-> A3
B4 -.->|"moves to"| SYS1["RenderSystem"]
B5 -.->|"absorbed by"| SYS2["World (is the store)"]
B6 -.->|"moves to"| SYS3["PromotionSystem"]
B5 -.->|"absorbed by"| SYS2["widgetValueStore"]
style Before fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
style After fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
@@ -237,7 +233,7 @@ graph LR
## 3. System Architecture
Systems are pure functions that query the World for entities with specific component combinations. Each system owns exactly one concern.
Systems are pure functions that query the relevant store(s) for entities with specific component combinations. Each system owns exactly one concern.
```mermaid
graph TD
@@ -389,30 +385,25 @@ graph TD
VS["VersionSystem"]
end
World["World
(instance-scoped source of truth)"]
subgraph Components["Component Stores"]
Pos["Position"]
Vis["*Visual"]
Con["Connectivity"]
Val["*Value"]
subgraph Stores["Dedicated Stores (instance-scoped source of truth)"]
LayoutStore["layoutStore"]
WidgetValueStore["widgetValueStore"]
DomWidgetStore["domWidgetStore"]
NodeOutputStore["nodeOutputStore"]
end
Systems -->|"query/mutate"| World
World -->|"contains"| Components
Systems -->|"query/mutate"| Stores
style Systems fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
style World fill:#1a1a4a,stroke:#2a2a6a,color:#e0e0e0
style Components fill:#1a3a3a,stroke:#2a4a4a,color:#e0e0e0
style Stores fill:#1a3a3a,stroke:#2a4a4a,color:#e0e0e0
```
Key differences:
- **No circular dependencies**: entities are IDs, not class instances
- **No Demeter violations**: systems query the World directly, never reach through entities
- **No scattered store access**: the World _is_ the store; systems are the only writers
- **Unidirectional**: Input → Systems → World → Render (no back-edges)
- **No Demeter violations**: systems query stores directly, never reach through entities
- **Data lives in dedicated stores**: systems are the only writers
- **Unidirectional**: Input → Systems → Stores → Render (no back-edges)
- **Instance safety**: linked definitions can be reused without forcing shared
mutable widget/execution state across instances
@@ -447,9 +438,10 @@ No inheritance hierarchy.
Subgraph = node + component."]
S3["One system per concern.
Systems don't overlap."]
S4["Branded per-kind IDs.
Compile-time type errors."]
S5["Systems query World.
S4["Consistent per-store
string-key conventions
(WidgetId, nodeLocatorId, raw ids)."]
S5["Systems query stores.
No entity→entity refs."]
S6["VersionSystem owns
all change tracking."]
@@ -479,30 +471,30 @@ sequenceDiagram
participant Legacy as Legacy Code
participant Class as LGraphNode (class)
participant Bridge as Bridge Adapter
participant World as World (ECS)
participant Store as layoutStore (ECS)
participant New as New Code / Systems
Note over Legacy,New: Phase 1: Bridge reads from class, writes to World
Note over Legacy,New: Phase 1: Bridge reads from class, writes to store
Legacy->>Class: node.pos = [100, 200]
Class->>Bridge: pos setter intercepted
Bridge->>World: world.setComponent(nodeId, Position, { pos: [100, 200] })
Bridge->>Store: useLayoutMutations().moveNode(nodeId, { pos: [100, 200] })
New->>World: world.getComponent(nodeId, Position)
World-->>New: { pos: [100, 200], size: [...] }
New->>Store: layoutStore read for nodeId
Store-->>New: { pos: [100, 200], size: [...] }
Note over Legacy,New: Phase 2: New features build on ECS directly
New->>World: world.setComponent(nodeId, Position, { pos: [150, 250] })
World->>Bridge: change detected
New->>Store: useLayoutMutations().moveNode(nodeId, { pos: [150, 250] })
Store->>Bridge: change detected
Bridge->>Class: node._pos = [150, 250]
Legacy->>Class: node.pos
Class-->>Legacy: [150, 250]
Note over Legacy,New: Phase 3: Legacy code migrated, bridge removed
New->>World: world.getComponent(nodeId, Position)
World-->>New: { pos: [150, 250] }
New->>Store: layoutStore read for nodeId
Store-->>New: { pos: [150, 250] }
```
### Incremental layout/render separation
@@ -524,16 +516,17 @@ incremental rollout safety.
```mermaid
graph LR
subgraph Phase1["Phase 1: Types Only"]
T1["Define branded IDs"]
T1["Define string-key types
(WidgetId, nodeLocatorId)"]
T2["Define component interfaces"]
T3["Define World type"]
T3["Define store shapes"]
end
subgraph Phase2["Phase 2: Bridge"]
B1["Bridge adapters
class ↔ World sync"]
class ↔ store sync"]
B2["New features use
World as source"]
stores as source"]
B3["Old code unchanged"]
end

View File

@@ -1,349 +0,0 @@
# World API and Command Layer
How the ECS World's imperative API relates to ADR 0003's command pattern
requirement, and why the two are complementary rather than conflicting.
This document responds to the concern that `world.setComponent()` and
`ConnectivitySystem.connect()` are "imperative mutators" incompatible with
serializable, idempotent commands. The short answer: they are the
**implementation** of commands, not a replacement for them.
## Architectural Layering
```
Caller → Command → System (handler) → World (store) → Y.js (sync)
Command Log (undo, replay, sync)
```
- **Commands** describe intent. They are serializable, deterministic, and
idempotent.
- **Systems** are command handlers. They validate, execute, and emit lifecycle
events.
- **The World** is the store. It holds component data. It does not know about
commands.
This is the same relationship Redux has between actions, reducers, and the
store. The store's `dispatch()` is imperative. That does not make Redux
incompatible with serializable actions.
## Proposed World Mutation API
The World exposes a thin imperative surface. Every mutation goes through a
system, and every system call is invoked by a command.
### World Core API
```ts
interface World {
// Reads (no command needed)
getComponent<C>(id: EntityId, key: ComponentKey<C>): C | undefined
hasComponent(id: EntityId, key: ComponentKey<C>): boolean
queryAll<C extends ComponentKey[]>(...keys: C): QueryResult<C>[]
// Mutations (called only by systems, inside transactions)
createEntity<K extends EntityKind>(kind: K): EntityIdFor<K>
deleteEntity<K extends EntityKind>(kind: K, id: EntityIdFor<K>): void
setComponent<C>(id: EntityId, key: ComponentKey<C>, data: C): void
removeComponent(id: EntityId, key: ComponentKey<C>): void
// Transaction boundary
transaction<T>(label: string, fn: () => T): T
}
```
These methods are **internal**. External callers never call
`world.setComponent()` directly — they submit commands.
### Command Interface
```ts
interface Command<T = void> {
readonly type: string
execute(world: World): T
}
```
A command is a plain object with a `type` discriminator and an `execute`
method that receives the World. The command executor wraps every
`execute()` call in a World transaction.
### Command Executor
```ts
interface CommandExecutor {
run<T>(command: Command<T>): T
batch(label: string, commands: Command[]): void
}
function createCommandExecutor(world: World): CommandExecutor {
return {
run(command) {
return world.transaction(command.type, () => command.execute(world))
},
batch(label, commands) {
world.transaction(label, () => {
for (const cmd of commands) cmd.execute(world)
})
}
}
}
```
Every command execution:
1. Opens a World transaction (maps to one `beforeChange`/`afterChange`
bracket for undo).
2. Calls the command's `execute()`, which invokes system functions.
3. Commits the transaction. On failure, rolls back — no partial writes, no
lifecycle events, no version bump.
## From Imperative Calls to Commands
The lifecycle scenarios in
[ecs-lifecycle-scenarios.md](ecs-lifecycle-scenarios.md) show system calls
like `ConnectivitySystem.connect(world, outputSlotId, inputSlotId)`. These
are the **internals** of a command. Here is how each scenario maps:
### Connect Slots
The lifecycle scenario shows:
```ts
// Inside ConnectivitySystem — this is the handler, not the public API
ConnectivitySystem.connect(world, outputSlotId, inputSlotId)
```
The public API is a command:
```ts
const connectSlots: Command = {
type: 'ConnectSlots',
outputSlotId,
inputSlotId,
execute(world) {
ConnectivitySystem.connect(world, this.outputSlotId, this.inputSlotId)
}
}
executor.run(connectSlots)
```
The command object is serializable (`{ type, outputSlotId, inputSlotId }`).
It can be sent over a wire, stored in a log, or replayed.
### Move Node
```ts
const moveNode: Command = {
type: 'MoveNode',
nodeId,
pos: [150, 250],
execute(world) {
LayoutSystem.moveNode(world, this.nodeId, this.pos)
}
}
```
### Remove Node
```ts
const removeNode: Command = {
type: 'RemoveNode',
nodeId,
execute(world) {
ConnectivitySystem.removeNode(world, this.nodeId)
}
}
```
### Set Widget Value
```ts
const setWidgetValue: Command = {
type: 'SetWidgetValue',
widgetId,
value,
execute(world) {
world.setComponent(this.widgetId, WidgetValue, {
...world.getComponent(this.widgetId, WidgetValue)!,
value: this.value
})
}
}
```
### Batch: Paste
Paste is a compound operation — many entities created in one undo step:
```ts
const paste: Command = {
type: 'Paste',
snapshot,
offset,
execute(world) {
const remap = new Map<EntityId, EntityId>()
for (const entity of this.snapshot.entities) {
const newId = world.createEntity(entity.kind)
remap.set(entity.id, newId)
for (const [key, data] of entity.components) {
world.setComponent(newId, key, remapEntityRefs(data, remap))
}
}
// Offset positions
for (const [, newId] of remap) {
const pos = world.getComponent(newId, Position)
if (pos) {
world.setComponent(newId, Position, {
...pos,
pos: [pos.pos[0] + this.offset[0], pos.pos[1] + this.offset[1]]
})
}
}
}
}
executor.run(paste) // one transaction, one undo step
```
## Addressing the Six Concerns
The PR review raised six "critical conflicts." Here is how the command layer
resolves each:
### 1. "The World API is imperative, not command-based"
Correct — by design. The World is the store. Commands are the public
mutation API above it. `world.setComponent()` is to commands what
`state[key] = value` is to Redux reducers.
### 2. "Systems are orchestrators, not command producers"
Systems are command **handlers**. A command's `execute()` calls system
functions. Systems do not spontaneously mutate the World — they are invoked
by commands.
### 3. "Auto-incrementing IDs are non-stable in concurrent environments"
For local-only operations, auto-increment is fine. For CRDT sync, entity
creation goes through a CRDT-aware ID generator (Y.js provides this via
`doc.clientID` + logical clock). The command layer can select the ID
strategy:
```ts
// Local-only command
world.createEntity(kind) // auto-increment
// CRDT-aware command (future)
world.createEntityWithId(kind, crdtGeneratedId)
```
This is an ID generation concern, not an ECS architecture concern.
### 4. "No transaction primitive exists"
`world.transaction(label, fn)` is the primitive. It maps to one
`beforeChange`/`afterChange` bracket. The command executor wraps every
`execute()` call in a transaction. See the [migration plan's Phase 3→4
gate](ecs-migration-plan.md#phase-3---4-gate-required) for the acceptance
criteria.
### 5. "No idempotency guarantees"
Idempotency is a property of the command, not the store. Two strategies:
- **Content-addressed IDs**: The command specifies the entity ID rather than
auto-generating. Replaying the command with the same ID is a no-op if the
entity already exists.
- **Command deduplication**: The command log tracks applied command IDs.
Replaying an already-applied command is skipped.
Both are standard CRDT patterns and belong in the command executor, not the
World.
### 6. "No error semantics"
Commands return results. The executor can wrap execution:
```ts
type CommandResult<T> =
| { status: 'applied'; value: T }
| { status: 'rejected'; reason: string }
| { status: 'no-op' }
function run<T>(command: Command<T>): CommandResult<T> {
try {
const value = world.transaction(command.type, () => command.execute(world))
return { status: 'applied', value }
} catch (e) {
if (e instanceof RejectionError) {
return { status: 'rejected', reason: e.message }
}
throw e
}
}
```
Rejection semantics (e.g., `onConnectInput` returning false) throw a
`RejectionError` inside the system, which the transaction rolls back.
## Why Two ADRs
ADR 0003 defines the command pattern and CRDT sync layer.
ADR 0008 defines the entity data model.
They are **complementary architectural layers**, not competing proposals:
| Concern | Owns It |
| ------------------------- | -------- |
| Entity taxonomy and IDs | ADR 0008 |
| Component decomposition | ADR 0008 |
| World (store) | ADR 0008 |
| Command interface | ADR 0003 |
| Undo/redo via command log | ADR 0003 |
| CRDT sync | ADR 0003 |
| Serialization format | ADR 0008 |
| Replay and idempotency | ADR 0003 |
Merging them into a single mega-ADR would conflate the data model with the
mutation strategy. Keeping them separate allows each to evolve independently
— the World can change its internal representation without affecting the
command API, and the command layer can adopt new sync strategies without
restructuring the entity model.
## Relationship to Lifecycle Scenarios
The [lifecycle scenarios](ecs-lifecycle-scenarios.md) show system-level
calls (`ConnectivitySystem.connect()`, `ClipboardSystem.paste()`, etc.).
These are the **inside** of a command — what the command handler does when
the command is executed.
The scenarios deliberately omit the command layer to focus on how systems
interact with the World. Adding command wrappers is mechanical: every
system call shown in the scenarios becomes the body of a command's
`execute()` method.
## When This Gets Built
The command layer is not part of the initial ECS migration phases (03).
During Phases 03, the bridge layer provides mutation entry points that
will later become command handlers. The command layer is introduced in
Phase 4 when write paths migrate from legacy to ECS:
- **Phase 4a**: Position write commands replace direct `node.pos =` assignment
- **Phase 4b**: Connectivity commands replace `node.connect()` /
`node.disconnect()`
- **Phase 4c**: Widget value commands replace direct store writes
Each Phase 4 step introduces commands for one concern, with the system
function as the handler and the World transaction as the atomicity
boundary.

View File

@@ -1,6 +1,6 @@
# Entity Interactions (Current System)
This document maps the relationships and interaction patterns between all entity types in the litegraph layer as it exists today. It serves as a baseline for the ECS migration planned in [ADR 0008](../adr/0008-entity-component-system.md).
This document maps the relationships and interaction patterns between all entity types in the litegraph layer as it exists today. It serves as a baseline for the ECS migration planned in [ADR 0008](../adr/0008-entity-component-system.md), whose target is realized as a set of dedicated Pinia stores (see [Proto-ECS Stores](proto-ecs-stores.md)).
## Entities
@@ -361,7 +361,7 @@ graph TD
subgraph Stores
WVS["WidgetValueStore
(Pinia)"]
PS["PromotionStore
PES["PreviewExposureStore
(Pinia)"]
LM["LayoutMutations
(composable)"]
@@ -379,9 +379,9 @@ lastRerouteId, lastGroupId)"]
Widget <-->|"value, label, disabled"| WVS
WVS -.->|"keyed by graphId:nodeId:name"| Widget
%% PromotionStore
SGNode -->|"tracks promoted widgets"| PS
Widget -.->|"isPromotedByAny() query"| PS
%% PreviewExposureStore
SGNode -->|"host-scoped preview exposures"| PES
PES -.->|"keyed by host node locator"| SGNode
%% LayoutMutations
Node -->|"pos/size setter"| LM

View File

@@ -115,7 +115,7 @@ If slots are reordered (e.g., by an extension adding a slot), all links referenc
### No Cross-Kind ID Safety
Nothing prevents passing a `LinkId` where a `NodeId` is expected — they're both `number`. This is the core motivation for the branded ID types proposed in ADR 0008.
Nothing prevents passing a `LinkId` where a `NodeId` is expected — they're both `number`. The dedicated-store direction addresses this with branded string keys where cross-kind safety pays off (for example `WidgetId` in `widgetValueStore`, `src/types/widgetId.ts`).
## 5. Law of Demeter Violations
@@ -201,12 +201,12 @@ This means:
## How ECS Addresses These Problems
| Problem | ECS Solution |
| ---------------------- | ----------------------------------------------------------------------------- |
| God objects | Data split into small, focused components; behavior lives in systems |
| Circular dependencies | Entities are just IDs; components have no inheritance hierarchy |
| Mixed concerns | Each system handles exactly one concern (render, serialize, execute) |
| Inconsistent IDs | Branded per-kind IDs with compile-time safety |
| Demeter violations | Systems query the World directly; no entity-to-entity references |
| Scattered side effects | Version tracking becomes a system responsibility; stores become systems |
| Render-time mutations | Render system reads components without writing; layout system runs separately |
| Problem | ECS Solution |
| ---------------------- | ------------------------------------------------------------------------------------------- |
| God objects | Data split into small, focused components in dedicated stores; behavior lives in systems |
| Circular dependencies | Entities are addressed by string keys; components have no inheritance hierarchy |
| Mixed concerns | Each system handles exactly one concern (render, serialize, execute) |
| Inconsistent IDs | Branded string keys per store (for example `WidgetId`) for cross-kind safety |
| Demeter violations | Systems query the relevant store directly; no entity-to-entity references |
| Scattered side effects | Version tracking becomes a system responsibility; mutations flow through store command APIs |
| Render-time mutations | Render system reads components without writing; layout system runs separately |

View File

@@ -6,17 +6,20 @@ For the full problem analysis, see [Entity Problems](entity-problems.md). For th
## 1. What's Already Extracted
Five stores extract entity state out of class instances into centralized,
queryable registries. Promoted value-widget topology is no longer a store; ADR
0009 represents it as ordinary linked `SubgraphInput` state.
Six dedicated stores extract entity state out of class instances into focused,
queryable registries, each owning one concern. Promoted value-widget topology is
no longer a store; ADR 0009 represents it as ordinary linked `SubgraphInput`
state, and promoted value data lives in `WidgetValueStore` keyed by the input's
`WidgetId`.
| Store | Extracts From | Scoping | Key Format | Data Shape |
| ----------------------- | ------------------- | ----------------------- | ------------------------------- | ----------------------------- |
| WidgetValueStore | `BaseWidget` | `graphId → nodeId:name` | `"${nodeId}:${widgetName}"` | Plain `WidgetState` object |
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
| LayoutStore | Node, Link, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
| Store | Extracts From | Scoping | Key Format | Data Shape |
| ----------------------- | ------------------- | ----------------- | ---------------------------------- | ----------------------------- |
| WidgetValueStore | `BaseWidget` | `graphId` | `WidgetId` (`graphId:nodeId:name`) | Plain `WidgetState` object |
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
| LayoutStore | Node, Link, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
| PreviewExposureStore | Subgraph host node | host node locator | host locator + exposure name | Display-only preview state |
ADR 0009 refines promoted-widget identity: promoted value widgets are keyed by
the host boundary (`host node locator + SubgraphInput.name`), while interior
@@ -31,9 +34,9 @@ The closest thing to a true ECS component store in the codebase today.
### State Shape
```
Map<UUID, Map<WidgetKey, WidgetState>>
Map<UUID, Map<WidgetId, WidgetState>>
│ │ │
graphId "nodeId:name" pure data object
graphId "graphId:nodeId:name" pure data object
```
`WidgetState` is a plain data object with no methods:
@@ -56,7 +59,7 @@ Map<UUID, Map<WidgetKey, WidgetState>>
**Phase 2 — `setNodeId()`:** Widget replaces its `_state` with a reference to the store's object:
```
widget._state = useWidgetValueStore().registerWidget(graphId, { ...this._state, nodeId })
widget._state = useWidgetValueStore().registerWidget(widgetId, { ...this._state, nodeId })
```
After registration, the widget's getters/setters (`value`, `label`, `disabled`) are pass-throughs to the store. Mutations to the widget automatically sync to the store via shared object reference.
@@ -119,20 +122,22 @@ Legacy `properties.proxyWidgets` is load-time migration input only.
╰────────────────╯ ╰──────────────────────╯
```
`PromotedWidgetViewManager`
(`src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.ts`) now reconciles
synthetic widget views derived from linked subgraph inputs. It does not sit on
top of a promotion registry.
A promoted host widget is ordinary `WidgetState` in `WidgetValueStore`, keyed by
the `WidgetId` carried on the `SubgraphInput` (`input.widgetId`). `SubgraphNode.widgets`
is a read-only projection over the node's inputs that resolves each value via
`useWidgetValueStore().getWidget(input.widgetId)`. There is no synthetic widget
view object and no view cache to reconcile (PR 12617 deleted `PromotedWidgetView`
and `PromotedWidgetViewManager`).
### ECS Alignment
| Aspect | ECS-like | Why |
| ----------------------------- | --------- | ------------------------------------------------------------- |
| Canonical topology | Yes | Value exposure is ordinary subgraph input/link state |
| Host-scoped preview state | Yes | Preview exposure data is keyed by host locator |
| Legacy migration boundary | Yes | `proxyWidgets` is consumed into canonical state or quarantine |
| View reconciliation | Partially | ViewManager preserves synthetic widget object identity |
| Entity class drives view sync | **No** | SubgraphNode still owns synthetic view cache invalidation |
| Aspect | ECS-like | Why |
| ---------------------------- | -------- | -------------------------------------------------------------- |
| Canonical topology | Yes | Value exposure is ordinary subgraph input/link state |
| Host-scoped preview state | Yes | Preview exposure data is keyed by host locator |
| Legacy migration boundary | Yes | `proxyWidgets` is consumed into canonical state or quarantine |
| Promoted value is plain data | Yes | Host widget is `WidgetState` in the store, keyed by `WidgetId` |
| Projection over data | Yes | `SubgraphNode.widgets` derives from inputs; no view cache |
## 4. LayoutStore (CRDT)
@@ -171,14 +176,14 @@ These module-scope calls create implicit dependencies on the Vue runtime and mak
### ECS Alignment
| Aspect | ECS-like | Why |
| ---------------------------- | --------- | --------------------------------------------------- |
| Position data extracted | Yes | Closest to the ECS `Position` component |
| CRDT-ready | Yes | Enables collaboration (ADR 0003) |
| Covers multiple entity kinds | Yes | Nodes, links, reroutes in one store |
| Mutation API (composable) | Partially | System-like, but called from entities, not a system |
| Module-scope access | **No** | Domain objects import store at module level |
| No entity ID branding | **No** | Plain numbers, no type safety across kinds |
| Aspect | ECS-like | Why |
| ---------------------------- | --------- | ------------------------------------------------------- |
| Position data extracted | Yes | Closest to the ECS `Position` component |
| CRDT-ready | Yes | Enables collaboration (ADR 0003) |
| Covers multiple entity kinds | Yes | Nodes, links, reroutes in one store |
| Mutation API (composable) | Partially | System-like, but called from entities, not a system |
| Module-scope access | **No** | Domain objects import store at module level |
| Per-store keying | Yes | Owns `nodeId`/`linkId`/`rerouteId` keys for its concern |
## 5. Pattern Analysis
@@ -190,30 +195,32 @@ These module-scope calls create implicit dependencies on the Vue runtime and mak
4. **Query APIs**: `getWidget()`, preview exposure queries, `getNodeWidgets()` — system-like queries
5. **Separation of data from behavior**: The stores hold data; classes retain behavior
### What's Missing vs Full ECS
### Target Design and Remaining Gaps
Dedicated per-domain stores with their own string keys are the target, not a way
station toward one unified registry. The remaining gaps are about behavior and
data flow, not about collapsing the stores together.
```mermaid
graph TD
subgraph Have["What We Have"]
subgraph Have["What We Have (and Want)"]
style Have fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
H1["Centralized data stores"]
H1["Dedicated per-domain stores"]
H2["Plain data components
(WidgetState, LayoutMap)"]
H3["Query APIs
(getWidget, preview exposures)"]
H4["Graph-scoped lifecycle"]
H5["Partial position extraction
H5["Per-store string keys
(WidgetId, nodeLocatorId)"]
H6["Position extraction
(LayoutStore)"]
end
subgraph Missing["What's Missing"]
style Missing fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
M1["Unified World
(6 stores, 6 keying strategies)"]
M2["Branded entity IDs
(keys are string concatenations)"]
M3["System layer
(mutations from anywhere)"]
M3["System / command layer
(sanctioned mutation path)"]
M4["Complete extraction
(behavior still on classes)"]
M5["No entity-to-entity refs
@@ -225,19 +232,21 @@ graph TD
### Keying Strategy Comparison
Each store invents its own identity scheme:
Each store owns the identity scheme that fits its concern:
| Store | Key Format | Entity ID Used | Type-Safe? |
| ---------------- | --------------------------- | ----------------------- | ---------- |
| WidgetValueStore | `"${nodeId}:${widgetName}"` | NodeId (number\|string) | No |
| DomWidgetStore | Widget UUID | UUID (string) | No |
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
| Store | Key Format | Key Type | Type-Safe? |
| ---------------- | ---------------------------------- | ------------------ | ---------------- |
| WidgetValueStore | `WidgetId` (`graphId:nodeId:name`) | branded string | Yes (`WidgetId`) |
| DomWidgetStore | Widget UUID | UUID (string) | No |
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
In the ECS target, all of these would use branded entity IDs (`WidgetEntityId`, `NodeEntityId`, etc.) with compile-time cross-kind protection.
For promoted value widgets, ADR 0009 narrows the target key to host boundary
identity (`host node locator + SubgraphInput.name`) instead of interior source
identity.
`WidgetValueStore` already keys on a branded `WidgetId` string (`src/types/widgetId.ts`),
which carries its scope and survives renames at the store layer. The remaining
stores can adopt their own branded string keys where cross-kind safety pays off,
without a shared entity-ID space. For promoted value widgets, ADR 0009 keys on
the host boundary: the input's `WidgetId` (host node locator + `SubgraphInput.name`),
not interior source identity.
## 6. Extraction Map

View File

@@ -74,7 +74,7 @@ graph TD
subgraph Unified["Unified: Composition"]
direction TB
W["World (flat)"]
W["Dedicated stores (flat)"]
N1["Node A
graphScope: root"]
N2["Node B (subgraph carrier)
@@ -95,30 +95,29 @@ graph TD
style Unified fill:#1a2a1a,stroke:#2a4a2a,color:#e0e0e0
```
In the ECS World:
Across the dedicated stores:
- **Every graph is a graph.** The "root" graph is simply the one whose
`graphScope` has no parent.
- **Nesting is a component, not a type.** A node can carry a
`SubgraphStructure` component, which references another graph scope. That
scope contains its own entities — nodes, links, widgets, reroutes, groups —
all living in the same flat World.
- **One World per workflow.** All entities across all nesting levels coexist in
a single World, each tagged with a `graphScope` identifier. There are no
sub-worlds, no recursive containers. The fractal structure is encoded in the
data, not in the container hierarchy.
- **Entity taxonomy: six kinds, not seven.** ADR 0008 defines seven entity kinds
including `SubgraphEntityId`. Under unification, "subgraph" is not an entity
kind — it is a node with a component. The taxonomy becomes: Node, Link,
Widget, Slot, Reroute, Group.
- **ID counters remain global.** All entity IDs are allocated from a single
counter space, shared across all nesting levels. This preserves the current
`rootGraph.state` behavior and guarantees ID uniqueness across the entire
World.
- **Graph scope parentage is tracked.** The World maintains a scope registry:
each `graphId` maps to its parent `graphId` (or null for the root). This
enables the ancestor walk required by the acyclicity invariant and supports
queries like "all entities transitively contained by this graph."
- **Nesting is a component.** A node can carry a `SubgraphStructure` component,
which references another graph scope. That scope contains its own entities —
nodes, links, widgets, reroutes, groups — and each store entry tags the scope
it belongs to.
- **Scope tagging over container nesting.** Entries across all nesting levels
live in the same stores, each tagged with a `graphScope` identifier. The
stores stay flat; the fractal structure is encoded in the keys and scope tags,
with no recursive containers.
- **Entity taxonomy: six kinds.** A subgraph is a node carrying a
`SubgraphStructure` component. The taxonomy is: Node, Link, Widget, Slot,
Reroute, Group. `SubgraphEntityId` is replaced by a `GraphId` scope identifier.
- **Numeric ID counters stay shared.** Node/link/reroute IDs are allocated from
a single counter space across nesting levels, preserving the current
`rootGraph.state` behavior and guaranteeing uniqueness. Widget keys embed
their scope directly (`WidgetId = graphId:nodeId:name`).
- **Graph scope parentage is tracked.** A scope registry maps each `graphId` to
its parent `graphId` (or null for the root). This enables the ancestor walk
required by the acyclicity invariant and supports queries like "all entities
transitively contained by this graph."
### The acyclicity invariant
@@ -367,15 +366,17 @@ sequenceDiagram
Exec->>IW: reads input value (42)
```
### Candidate B: Simplified component promotion
### Candidate B: Simplified component promotion (rejected)
ADR 0009 chose Candidate A. Candidate B is retained here as the rejected
alternative; it relied on a source-widget lookup model that no longer exists.
Promotion remains a first-class concept, simplified from three layers to one:
- A `WidgetPromotion` component on a widget entity:
`{ promotedTo: NodeEntityId, sourceWidget: WidgetEntityId }`
- The SubgraphNode's widget list includes promoted widget entity IDs directly
- Value reads/writes delegate to the source widget's `WidgetValue` component via
World lookup
- A `WidgetPromotion` component on a widget entity referencing the host node and
source widget
- The SubgraphNode's widget list includes promoted widget references directly
- Value reads/writes delegate to the source widget's value via a store lookup
- Serialized as `properties.proxyWidgets` (unchanged)
This removes the ViewManager and proxy widget reconciliation but preserves the
@@ -399,8 +400,9 @@ concept of promotion as distinct from connection.
Whichever candidate is chosen:
- **`WidgetEntityId` is internal.** Serialization uses widget name + parent node
reference. This is settled (see Section 4).
- **Internal identity is the `WidgetId` string.** Serialization uses widget name
- parent node reference, while runtime state keys on `WidgetId`
(`graphId:nodeId:name`). This is settled (see Section 4).
- **The type → widget mapping is authoritative.** The widget registry
(`widgetStore.widgets`) is the single source of truth for which types produce
widgets. No parallel mechanism should duplicate this.
@@ -473,10 +475,10 @@ and produces the recursive `ExportedSubgraph` structure, matching the current
format exactly. Existing workflows, the ComfyUI backend, and third-party tools
see no change.
| Direction | Format | Notes |
| --------------- | ------------------------------- | ------------------------------------------ |
| **Save/export** | Nested (current shape) | SerializationSystem walks scope tree |
| **Load/import** | Nested (current) or future flat | Migration: normalize to flat World on load |
| Direction | Format | Notes |
| --------------- | ------------------------------- | -------------------------------------------------- |
| **Save/export** | Nested (current shape) | SerializationSystem walks scope tree |
| **Load/import** | Nested (current) or future flat | Migration: normalize to store-backed state on load |
The migration pattern: load any supported format and normalize to the internal
model. The system accepts old formats indefinitely but produces the current
@@ -486,7 +488,7 @@ format on save.
| Context | Identity | Example |
| -------------------- | ---------------------------------------------------------- | ---------------------------------- |
| **Internal (World)** | `WidgetEntityId` (opaque branded number) | `42 as WidgetEntityId` |
| **Internal (store)** | `WidgetId` composite string | `'graphId:42:seed' as WidgetId` |
| **Serialized** | Position in `widgets_values[]` + name from node definition | `widgets_values[2]` → third widget |
On save: the `SerializationSystem` queries `WidgetIdentity.name` and
@@ -494,7 +496,7 @@ On save: the `SerializationSystem` queries `WidgetIdentity.name` and
order.
On load: widget values are matched by name against the node definition's input
specs, then assigned `WidgetEntityId`s from the global counter.
specs, then registered in `WidgetValueStore` under their `WidgetId`.
This is the existing contract, preserved exactly.
@@ -553,7 +555,7 @@ This document proposes or surfaces the following changes to
| Entity taxonomy | 7 kinds including `SubgraphEntityId` | 6 kinds — subgraph is a node with `SubgraphStructure` component |
| `SubgraphEntityId` | `string & { __brand: 'SubgraphEntityId' }` | Eliminated; replaced by `GraphId` scope identifier |
| Subgraph components | `SubgraphStructure`, `SubgraphMeta` listed as separate-entity components | Become node components on SubgraphNode entities |
| World structure | Implied per-graph containment | Flat World with `graphScope` tags; one World per workflow |
| Storage structure | Implied per-graph containment | Dedicated stores with `graphScope`-tagged entries; no single registry |
| Acyclicity | Not addressed | DAG invariant on `SubgraphStructure.graphId` references, enforced on mutation |
| Boundary model | Deferred | Typed interface contracts on `SubgraphStructure`; no virtual nodes or magic IDs |
| Widget promotion | Treated as a given feature to migrate | ADR 0009 chooses Candidate A: promoted value widgets are linked inputs |

View File

@@ -13,10 +13,7 @@
@plugin "./lucideStrokePlugin.js";
/* Safelist dynamic comfy icons for node library folders */
@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,bytedance-mono,comfy-logo,credits,elevenlabs,extensions-blocks,file-output,gemini,gemini-mono,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]");
@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,bytedance-mono,claude,comfy-logo,credits,elevenlabs,extensions-blocks,file-output,gemini,gemini-mono,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
@custom-variant touch (@media (hover: none));

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.30005 7L7.65005 9.5M12 12L12 22M12 12L16.35 9.5M12 12L7.65005 9.5M20.7001 7L16.35 9.5M16.35 9.5V19.8156M16.35 9.5L7.5 4.2699M7.5 4.2699L11 2.2699C11.304 2.09437 11.6489 2.00195 12 2.00195C12.3511 2.00195 12.696 2.09437 13 2.2699L16.5 4.2699L20 6.2699C20.3037 6.44526 20.556 6.69742 20.7315 7.00106C20.9071 7.30471 20.9996 7.64918 21 7.9999V15.9999C20.9996 16.3506 20.9071 16.6951 20.7315 16.9987C20.556 17.3024 20.3037 17.5545 20 17.7299L16.35 19.8156L13 21.7299C12.696 21.9054 12.3511 21.9979 12 21.9979C11.6489 21.9979 11.304 21.9054 11 21.7299L7.65005 19.8156L4 17.7299C3.69626 17.5545 3.44398 17.3024 3.26846 16.9987C3.09294 16.6951 3.00036 16.3506 3 15.9999V7.9999C3.00036 7.64918 3.09294 7.30471 3.26846 7.00106C3.44398 6.69742 3.69626 6.44526 4 6.2699L7.5 4.2699ZM7.65005 9.5V19.8156M7.65005 9.5L16.5 4.2699" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1014 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.65 6.36397V5.71397H11.35V6.36397H12H12.65ZM11.35 17.6777C11.35 18.0367 11.641 18.3277 12 18.3277C12.359 18.3277 12.65 18.0367 12.65 17.6777H12H11.35ZM17.6569 14.0049L18.2695 14.222L17.6569 14.0049ZM17.6586 10L18.2714 9.78339L17.6586 10ZM16.6114 15.8388L17.1107 16.255L16.6114 15.8388ZM12.65 20.5C12.65 20.141 12.359 19.85 12 19.85C11.641 19.85 11.35 20.141 11.35 20.5H12H12.65ZM11.35 22C11.35 22.359 11.641 22.65 12 22.65C12.359 22.65 12.65 22.359 12.65 22H12H11.35ZM12.65 2C12.65 1.64101 12.359 1.35 12 1.35C11.641 1.35 11.35 1.64101 11.35 2H12H12.65ZM11.35 3.5C11.35 3.85899 11.641 4.15 12 4.15C12.359 4.15 12.65 3.85899 12.65 3.5H12H11.35ZM20.5 11.35C20.141 11.35 19.85 11.641 19.85 12C19.85 12.359 20.141 12.65 20.5 12.65V12V11.35ZM22 12.65C22.359 12.65 22.65 12.359 22.65 12C22.65 11.641 22.359 11.35 22 11.35V12V12.65ZM2 11.35C1.64101 11.35 1.35 11.641 1.35 12C1.35 12.359 1.64101 12.65 2 12.65V12V11.35ZM3.5 12.65C3.85899 12.65 4.15 12.359 4.15 12C4.15 11.641 3.85899 11.35 3.5 11.35V12V12.65ZM18.47 17.5508C18.2162 17.2969 17.8046 17.2969 17.5508 17.5508C17.2969 17.8046 17.2969 18.2162 17.5508 18.47L18.0104 18.0104L18.47 17.5508ZM18.6114 19.5307C18.8653 19.7845 19.2768 19.7845 19.5307 19.5307C19.7845 19.2768 19.7845 18.8653 19.5307 18.6114L19.0711 19.0711L18.6114 19.5307ZM5.38855 4.46931C5.13471 4.21547 4.72315 4.21547 4.46931 4.46931C4.21547 4.72315 4.21547 5.13471 4.46931 5.38855L4.92893 4.92893L5.38855 4.46931ZM5.52997 6.44921C5.78381 6.70305 6.19537 6.70305 6.44921 6.44921C6.70305 6.19537 6.70305 5.78381 6.44921 5.52997L5.98959 5.98959L5.52997 6.44921ZM6.44921 18.47C6.70305 18.2162 6.70305 17.8046 6.44921 17.5508C6.19537 17.2969 5.78382 17.2969 5.52997 17.5508L5.98959 18.0104L6.44921 18.47ZM4.46931 18.6114C4.21547 18.8653 4.21547 19.2768 4.46931 19.5307C4.72315 19.7845 5.13471 19.7845 5.38855 19.5307L4.92893 19.0711L4.46931 18.6114ZM19.5307 5.38855C19.7845 5.13471 19.7845 4.72315 19.5307 4.46931C19.2768 4.21547 18.8653 4.21547 18.6114 4.46931L19.0711 4.92893L19.5307 5.38855ZM17.5508 5.52997C17.2969 5.78381 17.2969 6.19537 17.5508 6.44921C17.8046 6.70305 18.2162 6.70305 18.47 6.44921L18.0104 5.98959L17.5508 5.52997ZM12 18V17.35C9.04528 17.35 6.65 14.9547 6.65 12H6H5.35C5.35 15.6727 8.32731 18.65 12 18.65V18ZM6 12H6.65C6.65 9.04528 9.04528 6.65 12 6.65V6V5.35C8.32731 5.35 5.35 8.32731 5.35 12H6ZM18 12H17.35C17.35 12.6281 17.242 13.2296 17.0442 13.7878L17.6569 14.0049L18.2695 14.222C18.5161 13.5264 18.65 12.7781 18.65 12H18ZM12 14L11.9994 14.65L17.6563 14.6549L17.6569 14.0049L17.6574 13.3549L12.0006 13.35L12 14ZM12 12H11.35V14H12H12.65V12H12ZM12 12V12.65H18V12V11.35H12V12ZM12 10H11.35V12H12H12.65V10H12ZM17.6586 10L17.0457 10.2166C17.2426 10.7736 17.35 11.3735 17.35 12H18H18.65C18.65 11.2239 18.5168 10.4776 18.2714 9.78339L17.6586 10ZM12 10V10.65H17.6586V10V9.35H12V10ZM12 14H11.35V15.8388H12H12.65V14H12ZM12 15.8388H11.35V17.6777H12H12.65V15.8388H12ZM17.6569 14.0049L17.0442 13.7878C16.8311 14.3891 16.5132 14.9414 16.1121 15.4227L16.6114 15.8388L17.1107 16.255C17.6087 15.6575 18.0041 14.9708 18.2695 14.222L17.6569 14.0049ZM16.6114 15.8388L16.1121 15.4227C15.1297 16.6015 13.6525 17.35 12 17.35V18V18.65C14.0546 18.65 15.8919 17.7175 17.1107 16.255L16.6114 15.8388ZM12 15.8388V16.4888H16.6114V15.8388V15.1888H12V15.8388ZM12 6.36397H11.35V8.18199H12H12.65V6.36397H12ZM12 8.18199H11.35V10H12H12.65V8.18199H12ZM12 6V6.65C13.6612 6.65 15.1452 7.40634 16.1275 8.59587L16.6287 8.18199L17.1299 7.7681C15.9112 6.29234 14.0654 5.35 12 5.35V6ZM16.6287 8.18199L16.1275 8.59587C16.5223 9.07398 16.8353 9.62135 17.0457 10.2166L17.6586 10L18.2714 9.78339C18.0094 9.0421 17.62 8.36161 17.1299 7.7681L16.6287 8.18199ZM12 8.18199V8.83199H16.6287V8.18199V7.53199H12V8.18199ZM12 20.5H11.35V22H12H12.65V20.5H12ZM12 2H11.35V3.5H12H12.65V2H12ZM20.5 12V12.65H22V12V11.35H20.5V12ZM2 12V12.65H3.5V12V11.35H2V12ZM18.0104 18.0104L17.5508 18.47L18.6114 19.5307L19.0711 19.0711L19.5307 18.6114L18.47 17.5508L18.0104 18.0104ZM4.92893 4.92893L4.46931 5.38855L5.52997 6.44921L5.98959 5.98959L6.44921 5.52997L5.38855 4.46931L4.92893 4.92893ZM5.98959 18.0104L5.52997 17.5508L4.46931 18.6114L4.92893 19.0711L5.38855 19.5307L6.44921 18.47L5.98959 18.0104ZM19.0711 4.92893L18.6114 4.46931L17.5508 5.52997L18.0104 5.98959L18.47 6.44921L19.5307 5.38855L19.0711 4.92893Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 18L19.9427 15.9426C19.6926 15.6927 19.3536 15.5522 19 15.5522C18.6464 15.5522 18.3074 15.6927 18.0573 15.9426L12 22M10 17V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H17M12 13.9666C12 12.9057 11.5786 11.8883 10.8284 11.1381C10.0783 10.388 9.06087 9.96655 8 9.96655C6.93913 9.96655 5.92172 10.388 5.17157 11.1381C4.42143 11.8883 4 12.9057 4 13.9666M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18M9.41415 8.04751C10.1952 7.26647 10.1952 6.00014 9.41415 5.21909C8.6331 4.43804 7.36677 4.43804 6.58572 5.21909C5.80467 6.00014 5.80467 7.26647 6.58572 8.04751C7.36677 8.82856 8.6331 8.82856 9.41415 8.04751ZM3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M22 18L19.9427 15.9426C19.6926 15.6927 19.3536 15.5522 19 15.5522C18.6464 15.5522 18.3074 15.6927 18.0573 15.9426L12 22M10 17V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H17M12 13.9666C12 12.9057 11.5786 11.8883 10.8284 11.1381C10.0783 10.388 9.06087 9.96655 8 9.96655C6.93913 9.96655 5.92172 10.388 5.17157 11.1381C4.42143 11.8883 4 12.9057 4 13.9666M5 18L7 20L5 22M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M9.41415 8.04751C10.1952 7.26647 10.1952 6.00014 9.41415 5.21909C8.6331 4.43804 7.36677 4.43804 6.58572 5.21909C5.80467 6.00014 5.80467 7.26647 6.58572 8.04751C7.36677 8.82856 8.6331 8.82856 9.41415 8.04751ZM3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655Z" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 18V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H18M14 17V18.6667L18 16L16.6579 15.1053M12 13.9666C12 12.9057 11.5786 11.8883 10.8284 11.1381C10.0783 10.388 9.06087 9.96655 8 9.96655C6.93913 9.96655 5.92172 10.388 5.17157 11.1381C4.42143 11.8883 4 12.9057 4 13.9666M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18M9.41415 8.04751C10.1952 7.26647 10.1952 6.00014 9.41415 5.21909C8.6331 4.43804 7.36677 4.43804 6.58572 5.21909C5.80467 6.00014 5.80467 7.26647 6.58572 8.04751C7.36677 8.82856 8.6331 8.82856 9.41415 8.04751ZM3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10 18V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H18M14 17V18.6667L18 16L16.6579 15.1053M12 13.9666C12 12.9057 11.5786 11.8883 10.8284 11.1381C10.0783 10.388 9.06087 9.96655 8 9.96655C6.93913 9.96655 5.92172 10.388 5.17157 11.1381C4.42143 11.8883 4 12.9057 4 13.9666M5 18L7 20L5 22M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M9.41415 8.04751C10.1952 7.26647 10.1952 6.00014 9.41415 5.21909C8.6331 4.43804 7.36677 4.43804 6.58572 5.21909C5.80467 6.00014 5.80467 7.26647 6.58572 8.04751C7.36677 8.82856 8.6331 8.82856 9.41415 8.04751ZM3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655Z" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 9C18 12.3137 15.3137 15 12 15C8.68629 15 6 12.3137 6 9C6 5.68629 8.68629 3 12 3C15.3137 3 18 5.68629 18 9Z" stroke="white" stroke-width="1.3"/>
<path d="M14.5 15C14.5 18.3137 11.8137 21 8.5 21C5.18629 21 2.5 18.3137 2.5 15C2.5 11.6863 5.18629 9 8.5 9C11.8137 9 14.5 11.6863 14.5 15Z" stroke="white" stroke-width="1.3"/>
<path d="M21.5 15C21.5 18.3137 18.8137 21 15.5 21C12.1863 21 9.5 18.3137 9.5 15C9.5 11.6863 12.1863 9 15.5 9C18.8137 9 21.5 11.6863 21.5 15Z" stroke="white" stroke-width="1.3"/>
</svg>

After

Width:  |  Height:  |  Size: 614 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.6154 18.8127C10.0968 18.9352 9.55598 19 9 19C5.13401 19 2 15.866 2 12C2 8.13401 5.13401 5 9 5C9.55598 5 10.0968 5.06482 10.6154 5.18731M13.3846 18.8127C13.9032 18.9352 14.444 19 15 19C18.866 19 22 15.866 22 12C22 8.13401 18.866 5 15 5C14.444 5 13.9032 5.06482 13.3846 5.18731M19 12C19 15.866 15.866 19 12 19C8.13401 19 5 15.866 5 12C5 8.13401 8.13401 5 12 5C15.866 5 19 8.13401 19 12Z" stroke="white" stroke-width="1.3"/>
</svg>

After

Width:  |  Height:  |  Size: 538 B

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 18L19.9427 15.9426C19.6926 15.6927 19.3536 15.5522 19 15.5522C18.6464 15.5522 18.3074 15.6927 18.0573 15.9426L12 22M10 17V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H17M2.29912 4.63977C2.11384 4.8332 2 5.09561 2 5.38461V12.9231C2 13.5178 2.48215 14 3.07692 14H10.6154C10.8974 14 11.1541 13.8916 11.3461 13.7141M2.29912 4.63977C2.49515 4.43512 2.77116 4.30769 3.07692 4.30769H10.6154C10.9061 4.30769 11.1524 4.46662 11.3461 4.65384M2.29912 4.63977L4.59359 2.34615C4.79033 2.13329 5.07191 2 5.38463 2H12.9231C13.2201 2 13.4891 2.12025 13.6839 2.31473M11.3461 13.7141C11.559 13.5174 11.6923 13.2358 11.6923 12.9231V5.38461C11.6923 5.08055 11.5488 4.84967 11.3461 4.65384M11.3461 13.7141L13.6538 11.4064C13.8667 11.2097 14 10.9281 14 10.6154V3.07692C14 2.77918 13.8792 2.50967 13.6839 2.31473M11.3461 4.65384L13.6839 2.31473M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M22 18L19.9427 15.9426C19.6926 15.6927 19.3536 15.5522 19 15.5522C18.6464 15.5522 18.3074 15.6927 18.0573 15.9426L12 22M10 17V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H17M11.3461 4.65384C11.1524 4.46662 10.9061 4.30769 10.6154 4.30769H3.07692C2.77116 4.30769 2.49515 4.43512 2.29912 4.63977C2.11384 4.8332 2 5.09561 2 5.38461V12.9231C2 13.5178 2.48215 14 3.07692 14H10.6154C10.8974 14 11.1541 13.8916 11.3461 13.7141C11.559 13.5174 11.6923 13.2358 11.6923 12.9231V5.38461C11.6923 5.08055 11.5488 4.84967 11.3461 4.65384ZM11.3461 13.7141L13.6538 11.4064C13.8667 11.2097 14 10.9281 14 10.6154V3.07692C14 2.77918 13.8792 2.50967 13.6839 2.31473C13.4891 2.12025 13.2201 2 12.9231 2H5.38463C5.07191 2 4.79033 2.13329 4.59359 2.34615L2.29912 4.63977M11.3461 4.65384L13.6839 2.31473M5 18L7 20L5 22M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.5 10H20.6667C21.403 10 22 10.597 22 11.3333V20.6667C22 21.403 21.403 22 20.6667 22H11.3333C10.597 22 10 21.403 10 20.6667V17M14 15.5V18.6667L18 16L15 14M2.29912 4.63977C2.11384 4.8332 2 5.09561 2 5.38461V12.9231C2 13.5178 2.48215 14 3.07692 14H10.6154C10.8974 14 11.1541 13.8916 11.3461 13.7141M2.29912 4.63977C2.49515 4.43512 2.77116 4.30769 3.07692 4.30769H10.6154C10.9061 4.30769 11.1524 4.46662 11.3461 4.65384M2.29912 4.63977L4.59359 2.34615C4.79033 2.13329 5.07191 2 5.38463 2H12.9231C13.2201 2 13.4891 2.12025 13.6839 2.31473M11.3461 13.7141C11.559 13.5174 11.6923 13.2358 11.6923 12.9231V5.38461C11.6923 5.08055 11.5488 4.84967 11.3461 4.65384M11.3461 13.7141L13.6538 11.4064C13.8667 11.2097 14 10.9281 14 10.6154V3.07692C14 2.77918 13.8792 2.50967 13.6839 2.31473M11.3461 4.65384L13.6839 2.31473M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17.5 10H20.6667C21.403 10 22 10.597 22 11.3333V20.6667C22 21.403 21.403 22 20.6667 22H11.3333C10.597 22 10 21.403 10 20.6667V17M14 15.5V18.6667L18 16L15 14M11.3461 4.65384C11.1524 4.46662 10.9061 4.30769 10.6154 4.30769H3.07692C2.77116 4.30769 2.49515 4.43512 2.29912 4.63977C2.11384 4.8332 2 5.09561 2 5.38461V12.9231C2 13.5178 2.48215 14 3.07692 14H10.6154C10.8974 14 11.1541 13.8916 11.3461 13.7141C11.559 13.5174 11.6923 13.2358 11.6923 12.9231V5.38461C11.6923 5.08055 11.5488 4.84967 11.3461 4.65384ZM11.3461 13.7141L13.6538 11.4064C13.8667 11.2097 14 10.9281 14 10.6154V3.07692C14 2.77918 13.8792 2.50967 13.6839 2.31473C13.4891 2.12025 13.2201 2 12.9231 2H5.38463C5.07191 2 4.79033 2.13329 4.59359 2.34615L2.29912 4.63977M11.3461 4.65384L13.6839 2.31473M5 18L7 20L5 22M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.8 12H20M4 12H5.2M16.8083 16.8083L17.6569 17.6569M6.34315 6.34315L7.19167 7.19167M7.1907 16.8083L6.34217 17.6569M17.6559 6.34315L16.8074 7.19167M12 18.8V20M12 4V5.2M11.4448 10.6596L9.7218 6.49994M16.2426 7.75736C18.5858 10.1005 18.5858 13.8995 16.2426 16.2426C13.8995 18.5858 10.1005 18.5858 7.75736 16.2426C5.41421 13.8995 5.41421 10.1005 7.75736 7.75736C10.1005 5.41421 13.8995 5.41421 16.2426 7.75736ZM18.0104 5.98959C21.3299 9.30905 21.3299 14.691 18.0104 18.0104C14.691 21.3299 9.30905 21.3299 5.98959 18.0104C2.67014 14.691 2.67014 9.30905 5.98959 5.98959C9.30905 2.67014 14.691 2.67014 18.0104 5.98959Z" stroke="white" stroke-width="1.3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 785 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 18V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H18M14 17V18.6667L18 16L16.6579 15.1053M5 18L7 20L5 22M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M2 11.3333H8M5 14V11.3333M8 14L8 11.3333M8 2H13C13.5523 2 14 2.44772 14 3L14 4.66667V11.3333L14 13C14 13.5523 13.5523 14 13 14H8L3 14C2.44771 14 2 13.5523 2 13V3C2 2.44771 2.44772 2 3 2L8 2ZM8 14V2M8 2L8 4.66667M2 4.66667H8M5 4.66667V2M8 4.66667V11.3333M8 4.66667H14M8 11.3333H14M11 14V11.3333M11 4.66667V2" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 764 B

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.0104 18.0104C21.3299 14.691 21.3299 9.30905 18.0104 5.98959C14.691 2.67014 9.30905 2.67014 5.98959 5.98959C2.67014 9.30905 2.67014 14.691 5.98959 18.0104C9.30905 21.3299 14.691 21.3299 18.0104 18.0104Z" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-dasharray="1 2"/>
<path d="M20.5 12H22M2 12H3.5M18.0103 18.0103L19.778 19.778M4.22168 4.22168L5.98945 5.98945M5.98974 18.0103L4.22197 19.778M19.7783 4.22168L18.0106 5.98945M12 20.5V22M12 2V3.5M16.2426 7.75736C18.5858 10.1005 18.5858 13.8995 16.2426 16.2426C13.8995 18.5858 10.1005 18.5858 7.75736 16.2426C5.41421 13.8995 5.41421 10.1005 7.75736 7.75736C10.1005 5.41421 13.8995 5.41421 16.2426 7.75736Z" stroke="white" stroke-width="1.3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 845 B

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 3H5C3.89543 3 3 3.89543 3 5V19C3 20.1046 3.89543 21 5 21H19C20.1046 21 21 20.1046 21 19V5C21 3.89543 20.1046 3 19 3Z" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.6504 18.3252C13.8297 18.3254 13.9746 18.471 13.9746 18.6504C13.9744 18.8296 13.8296 18.9744 13.6504 18.9746C13.471 18.9746 13.3254 18.8297 13.3252 18.6504C13.3252 18.4709 13.4709 18.3252 13.6504 18.3252ZM17.6504 17.8252C17.8297 17.8254 17.9746 17.971 17.9746 18.1504C17.9744 18.3296 17.8296 18.4744 17.6504 18.4746C17.471 18.4746 17.3254 18.3297 17.3252 18.1504C17.3252 17.9709 17.4709 17.8252 17.6504 17.8252ZM7.65039 17.3252C7.8297 17.3254 7.97461 17.471 7.97461 17.6504C7.9744 17.8296 7.82957 17.9744 7.65039 17.9746C7.47103 17.9746 7.32541 17.8297 7.3252 17.6504C7.3252 17.4709 7.4709 17.3252 7.65039 17.3252ZM11.6504 16.3252C11.8297 16.3254 11.9746 16.471 11.9746 16.6504C11.9744 16.8296 11.8296 16.9744 11.6504 16.9746C11.471 16.9746 11.3254 16.8297 11.3252 16.6504C11.3252 16.4709 11.4709 16.3252 11.6504 16.3252ZM15.6504 16.3252C15.8297 16.3254 15.9746 16.471 15.9746 16.6504C15.9744 16.8296 15.8296 16.9744 15.6504 16.9746C15.471 16.9746 15.3254 16.8297 15.3252 16.6504C15.3252 16.4709 15.4709 16.3252 15.6504 16.3252ZM18.6504 15.8252C18.8297 15.8254 18.9746 15.971 18.9746 16.1504C18.9744 16.3296 18.8296 16.4744 18.6504 16.4746C18.471 16.4746 18.3254 16.3297 18.3252 16.1504C18.3252 15.9709 18.4709 15.8252 18.6504 15.8252ZM9.65039 14.3252C9.8297 14.3254 9.97461 14.471 9.97461 14.6504C9.9744 14.8296 9.82957 14.9744 9.65039 14.9746C9.47103 14.9746 9.32541 14.8297 9.3252 14.6504C9.3252 14.4709 9.4709 14.3252 9.65039 14.3252ZM13.6504 14.3252C13.8297 14.3254 13.9746 14.471 13.9746 14.6504C13.9744 14.8296 13.8296 14.9744 13.6504 14.9746C13.471 14.9746 13.3254 14.8297 13.3252 14.6504C13.3252 14.4709 13.4709 14.3252 13.6504 14.3252ZM17.6504 13.8252C17.8297 13.8254 17.9746 13.971 17.9746 14.1504C17.9744 14.3296 17.8296 14.4744 17.6504 14.4746C17.471 14.4746 17.3254 14.3297 17.3252 14.1504C17.3252 13.9709 17.4709 13.8252 17.6504 13.8252ZM15.6504 12.3252C15.8297 12.3254 15.9746 12.471 15.9746 12.6504C15.9744 12.8296 15.8296 12.9744 15.6504 12.9746C15.471 12.9746 15.3254 12.8297 15.3252 12.6504C15.3252 12.4709 15.4709 12.3252 15.6504 12.3252ZM18.6504 11.8252C18.8297 11.8254 18.9746 11.971 18.9746 12.1504C18.9744 12.3296 18.8296 12.4744 18.6504 12.4746C18.471 12.4746 18.3254 12.3297 18.3252 12.1504C18.3252 11.9709 18.4709 11.8252 18.6504 11.8252ZM11.6504 11.3252C11.8297 11.3254 11.9746 11.471 11.9746 11.6504C11.9744 11.8296 11.8296 11.9744 11.6504 11.9746C11.471 11.9746 11.3254 11.8297 11.3252 11.6504C11.3252 11.4709 11.4709 11.3252 11.6504 11.3252ZM17.6504 9.8252C17.8297 9.82541 17.9746 9.97103 17.9746 10.1504C17.9744 10.3296 17.8296 10.4744 17.6504 10.4746C17.471 10.4746 17.3254 10.3297 17.3252 10.1504C17.3252 9.9709 17.4709 9.8252 17.6504 9.8252ZM7.65039 9.3252C7.8297 9.32541 7.97461 9.47103 7.97461 9.65039C7.9744 9.82957 7.82957 9.9744 7.65039 9.97461C7.47103 9.97461 7.32541 9.8297 7.3252 9.65039C7.3252 9.4709 7.4709 9.3252 7.65039 9.3252ZM13.6504 9.3252C13.8297 9.32541 13.9746 9.47103 13.9746 9.65039C13.9744 9.82957 13.8296 9.9744 13.6504 9.97461C13.471 9.97461 13.3254 9.8297 13.3252 9.65039C13.3252 9.4709 13.4709 9.3252 13.6504 9.3252ZM18.6504 7.8252C18.8297 7.82541 18.9746 7.97103 18.9746 8.15039C18.9744 8.32957 18.8296 8.4744 18.6504 8.47461C18.471 8.47461 18.3254 8.3297 18.3252 8.15039C18.3252 7.9709 18.4709 7.8252 18.6504 7.8252ZM11.6504 7.3252C11.8297 7.32541 11.9746 7.47103 11.9746 7.65039C11.9744 7.82957 11.8296 7.9744 11.6504 7.97461C11.471 7.97461 11.3254 7.8297 11.3252 7.65039C11.3252 7.4709 11.4709 7.3252 11.6504 7.3252ZM15.6504 7.3252C15.8297 7.32541 15.9746 7.47103 15.9746 7.65039C15.9744 7.82957 15.8296 7.9744 15.6504 7.97461C15.471 7.97461 15.3254 7.8297 15.3252 7.65039C15.3252 7.4709 15.4709 7.3252 15.6504 7.3252ZM17.6504 5.8252C17.8297 5.82541 17.9746 5.97103 17.9746 6.15039C17.9744 6.32957 17.8296 6.4744 17.6504 6.47461C17.471 6.47461 17.3254 6.3297 17.3252 6.15039C17.3252 5.9709 17.4709 5.8252 17.6504 5.8252ZM9.65039 5.3252C9.8297 5.32541 9.97461 5.47103 9.97461 5.65039C9.9744 5.82957 9.82957 5.9744 9.65039 5.97461C9.47103 5.97461 9.32541 5.8297 9.3252 5.65039C9.3252 5.4709 9.4709 5.3252 9.65039 5.3252ZM13.6504 5.3252C13.8297 5.32541 13.9746 5.47103 13.9746 5.65039C13.9744 5.82957 13.8296 5.9744 13.6504 5.97461C13.471 5.97461 13.3254 5.8297 13.3252 5.65039C13.3252 5.4709 13.4709 5.3252 13.6504 5.3252Z" stroke="white" stroke-width="0.65"/>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 14.9999L17.914 11.9139C17.5389 11.539 17.0303 11.3284 16.5 11.3284C15.9697 11.3284 15.4611 11.539 15.086 11.9139L12.6935 14.3064M14.5 21H19C20.1046 21 21 20.1046 21 19V5C21 3.89543 20.1046 3 19 3H5C3.89543 3 3 3.89543 3 5V13M11 9C11 10.1046 10.1046 11 9 11C7.89543 11 7 10.1046 7 9C7 7.89543 7.89543 7 9 7C10.1046 7 11 7.89543 11 9ZM2.03125 18.6735C1.98958 18.5613 1.98958 18.4378 2.03125 18.3255C2.43708 17.3415 3.12595 16.5001 4.01054 15.9081C4.89512 15.3161 5.93558 15 7 15C8.06442 15 9.10488 15.3161 9.98946 15.9081C10.874 16.5001 11.5629 17.3415 11.9687 18.3255C12.0104 18.4378 12.0104 18.5613 11.9687 18.6735C11.5629 19.6575 10.874 20.4989 9.98946 21.0909C9.10488 21.683 8.06442 21.999 7 21.999C5.93558 21.999 4.89512 21.683 4.01054 21.0909C3.12595 20.4989 2.43708 19.6575 2.03125 18.6735ZM8.49992 18.4995C8.49992 19.3278 7.82838 19.9994 6.99999 19.9994C6.17161 19.9994 5.50007 19.3278 5.50007 18.4995C5.50007 17.6711 6.17161 16.9995 6.99999 16.9995C7.82838 16.9995 8.49992 17.6711 8.49992 18.4995Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.03125 18.6735L1.42188 18.8997C1.42457 18.907 1.4274 18.9142 1.43035 18.9213L2.03125 18.6735ZM2.03125 18.3255L1.43035 18.0777C1.4274 18.0849 1.42457 18.0921 1.42188 18.0993L2.03125 18.3255ZM11.9687 18.3255L12.5781 18.0993C12.5754 18.0921 12.5726 18.0849 12.5697 18.0777L11.9687 18.3255ZM11.9687 18.6735L12.5697 18.9213C12.5726 18.9142 12.5754 18.907 12.5781 18.8997L11.9687 18.6735ZM9.54038 7.54038C9.28654 7.79422 9.28654 8.20578 9.54038 8.45962C9.79422 8.71346 10.2058 8.71346 10.4596 8.45962L10 8L9.54038 7.54038ZM15.4596 3.45962C15.7135 3.20578 15.7135 2.79422 15.4596 2.54038C15.2058 2.28654 14.7942 2.28654 14.5404 2.54038L15 3L15.4596 3.45962ZM12.5404 10.5404L12.0808 11L13 11.9192L13.4596 11.4596L13 11L12.5404 10.5404ZM20.4596 4.45962C20.7135 4.20578 20.7135 3.79422 20.4596 3.54038C20.2058 3.28654 19.7942 3.28654 19.5404 3.54038L20 4L20.4596 4.45962ZM15.5404 13.5404C15.2865 13.7942 15.2865 14.2058 15.5404 14.4596C15.7942 14.7135 16.2058 14.7135 16.4596 14.4596L16 14L15.5404 13.5404ZM21.4596 9.45962C21.7135 9.20578 21.7135 8.79422 21.4596 8.54038C21.2058 8.28654 20.7942 8.28654 20.5404 8.54038L21 9L21.4596 9.45962ZM14.5 20.35C14.141 20.35 13.85 20.641 13.85 21C13.85 21.359 14.141 21.65 14.5 21.65V21V20.35ZM2.35 13C2.35 13.359 2.64101 13.65 3 13.65C3.35899 13.65 3.65 13.359 3.65 13H3H2.35ZM2.03125 18.6735L2.64062 18.4473C2.65313 18.481 2.65313 18.518 2.64062 18.5517L2.03125 18.3255L1.42188 18.0993C1.32604 18.3575 1.32604 18.6415 1.42188 18.8997L2.03125 18.6735ZM2.03125 18.3255L2.63215 18.5733C2.9889 17.7083 3.59447 16.9687 4.37207 16.4483L4.01054 15.9081L3.649 15.3679C2.65744 16.0316 1.88526 16.9747 1.43035 18.0777L2.03125 18.3255ZM4.01054 15.9081L4.37207 16.4483C5.14968 15.9278 6.0643 15.65 7 15.65V15V14.35C5.80685 14.35 4.64056 14.7043 3.649 15.3679L4.01054 15.9081ZM7 15V15.65C7.9357 15.65 8.85032 15.9278 9.62793 16.4483L9.98946 15.9081L10.351 15.3679C9.35944 14.7043 8.19315 14.35 7 14.35V15ZM9.98946 15.9081L9.62793 16.4483C10.4055 16.9687 11.0111 17.7083 11.3678 18.5733L11.9687 18.3255L12.5697 18.0777C12.1147 16.9747 11.3426 16.0316 10.351 15.3679L9.98946 15.9081ZM11.9687 18.3255L11.3594 18.5517C11.3469 18.518 11.3469 18.481 11.3594 18.4473L11.9687 18.6735L12.5781 18.8997C12.674 18.6415 12.674 18.3575 12.5781 18.0993L11.9687 18.3255ZM11.9687 18.6735L11.3678 18.4257C11.0111 19.2907 10.4055 20.0303 9.62793 20.5508L9.98946 21.0909L10.351 21.6311C11.3426 20.9675 12.1147 20.0244 12.5697 18.9213L11.9687 18.6735ZM9.98946 21.0909L9.62793 20.5508C8.85032 21.0712 7.9357 21.349 7 21.349V21.999V22.649C8.19315 22.649 9.35944 22.2948 10.351 21.6311L9.98946 21.0909ZM7 21.999V21.349C6.0643 21.349 5.14968 21.0712 4.37207 20.5508L4.01054 21.0909L3.649 21.6311C4.64056 22.2948 5.80685 22.649 7 22.649V21.999ZM4.01054 21.0909L4.37207 20.5508C3.59447 20.0303 2.9889 19.2907 2.63215 18.4257L2.03125 18.6735L1.43035 18.9213C1.88526 20.0244 2.65744 20.9675 3.649 21.6311L4.01054 21.0909ZM8.49992 18.4995H7.84992C7.84992 18.9689 7.46939 19.3494 6.99999 19.3494V19.9994V20.6494C8.18736 20.6494 9.14992 19.6868 9.14992 18.4995H8.49992ZM6.99999 19.9994V19.3494C6.53059 19.3494 6.15007 18.9689 6.15007 18.4995H5.50007H4.85007C4.85007 19.6868 5.81262 20.6494 6.99999 20.6494V19.9994ZM5.50007 18.4995H6.15007C6.15007 18.0301 6.53059 17.6495 6.99999 17.6495V16.9995V16.3495C5.81262 16.3495 4.85007 17.3121 4.85007 18.4995H5.50007ZM6.99999 16.9995V17.6495C7.46939 17.6495 7.84992 18.0301 7.84992 18.4995H8.49992H9.14992C9.14992 17.3121 8.18736 16.3495 6.99999 16.3495V16.9995ZM10 8L10.4596 8.45962L15.4596 3.45962L15 3L14.5404 2.54038L9.54038 7.54038L10 8ZM13 11L13.4596 11.4596L20.4596 4.45962L20 4L19.5404 3.54038L12.5404 10.5404L13 11ZM16 14L16.4596 14.4596L21.4596 9.45962L21 9L20.5404 8.54038L15.5404 13.5404L16 14ZM5 3V3.65H19V3V2.35H5V3ZM19 3V3.65C19.7456 3.65 20.35 4.25442 20.35 5H21H21.65C21.65 3.53645 20.4636 2.35 19 2.35V3ZM21 5H20.35V19H21H21.65V5H21ZM21 19H20.35C20.35 19.7456 19.7456 20.35 19 20.35V21V21.65C20.4636 21.65 21.65 20.4636 21.65 19H21ZM3 5H3.65C3.65 4.25442 4.25442 3.65 5 3.65V3V2.35C3.53645 2.35 2.35 3.53645 2.35 5H3ZM19 21V20.35H14.5V21V21.65H19V21ZM3 13H3.65V5H3H2.35V13H3ZM5 3L4.54038 3.45962L20.5404 19.4596L21 19L21.4596 18.5404L5.45962 2.54038L5 3Z" fill="#8A8A8A"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 17L12.9427 14.9426C12.6926 14.6927 12.3536 14.5522 12 14.5522C11.6464 14.5522 11.3074 14.6927 11.0573 14.9426L5 21M20 15C20.5523 15 21 14.5523 21 14V5C21 4.46957 20.7893 3.96086 20.4142 3.58579C20.0391 3.21071 19.5304 3 19 3H10C9.44772 3 9 3.44772 9 4M17 18C17.5523 18 18 17.5523 18 17V8C18 7.46957 17.7893 6.96086 17.4142 6.58579C17.0391 6.21071 16.5304 6 16 6H7C6.44772 6 6 6.44772 6 7M4.33333 9H13.6667C14.403 9 15 9.59695 15 10.3333V19.6667C15 20.403 14.403 21 13.6667 21H4.33333C3.59695 21 3 20.403 3 19.6667V10.3333C3 9.59695 3.59695 9 4.33333 9ZM8.33333 13C8.33333 13.7364 7.73638 14.3333 7 14.3333C6.26362 14.3333 5.66667 13.7364 5.66667 13C5.66667 12.2636 6.26362 11.6667 7 11.6667C7.73638 11.6667 8.33333 12.2636 8.33333 13Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15 17L12.9427 14.9426C12.6926 14.6927 12.3536 14.5522 12 14.5522C11.6464 14.5522 11.3074 14.6927 11.0573 14.9426L5 21M20 15C20.5523 15 21 14.5523 21 14V5C21 4.46957 20.7893 3.96086 20.4142 3.58579C20.0391 3.21071 19.5304 3 19 3H10C9.44772 3 9 3.44772 9 4M17 18C17.5523 18 18 17.5523 18 17V8C18 7.46957 17.7893 6.96086 17.4142 6.58579C17.0391 6.21071 16.5304 6 16 6H7C6.44772 6 6 6.44772 6 7M4.33333 9H13.6667C14.403 9 15 9.59695 15 10.3333V19.6667C15 20.403 14.403 21 13.6667 21H4.33333C3.59695 21 3 20.403 3 19.6667V10.3333C3 9.59695 3.59695 9 4.33333 9ZM8.33333 13C8.33333 13.7364 7.73638 14.3333 7 14.3333C6.26362 14.3333 5.66667 13.7364 5.66667 13C5.66667 12.2636 6.26362 11.6667 7 11.6667C7.73638 11.6667 8.33333 12.2636 8.33333 13Z" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 937 B

After

Width:  |  Height:  |  Size: 935 B

View File

@@ -1,11 +1,3 @@
<svg width="48" height="24" viewBox="0 0 48 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1683_13217)">
<path d="M33.2579 14.2434C33.2579 8.04845 24.8306 0.0257698 24.4718 -0.312477C24.2037 -0.565892 23.7862 -0.561497 23.5226 -0.305153C23.1637 0.0434776 14.7439 8.30814 14.7439 14.2436C14.7439 19.3484 18.8966 23.5 24.0003 23.5C29.1067 23.5015 33.2579 19.3486 33.2579 14.2434ZM24.0015 22.1299C19.6538 22.1299 16.1165 18.5924 16.1165 14.2449C16.1165 9.63656 22.2409 2.98187 24.009 1.15676C25.7829 2.94092 31.8885 9.42993 31.8885 14.2449C31.887 18.5926 28.349 22.1299 24.0015 22.1299Z" fill="#8A8A8A"/>
<path d="M28.4502 12.5882C28.0766 12.6482 27.8218 12.9998 27.8804 13.3733C28.1265 14.9143 27.5918 16.2605 26.0933 17.8748C25.8355 18.1516 25.8516 18.5852 26.1284 18.843C26.2603 18.9661 26.4273 19.0261 26.5943 19.0261C26.7788 19.0261 26.9619 18.9529 27.0967 18.8064C28.3037 17.5071 29.6381 15.6907 29.2339 13.1552C29.1753 12.7831 28.8222 12.5295 28.4502 12.5882Z" fill="#8A8A8A"/>
</g>
<defs>
<clipPath id="clip0_1683_13217">
<rect width="48" height="24" fill="white"/>
</clipPath>
</defs>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.4292 17.4286C12.4292 17.4286 10.7681 17.4818 9.85773 16.5714C8.94733 15.661 9.00058 14.8571 9.00058 14M12 20.6429C13.5913 20.6429 15.1174 20.0107 16.2426 18.8855C17.3679 17.7603 18 16.2342 18 14.6429C18 9.5 12 4.35715 12 4.35715C12 4.35715 6 9.5 6 14.6429C6 16.2342 6.63214 17.7603 7.75736 18.8855C8.88258 20.0107 10.4087 20.6429 12 20.6429Z" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 542 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.2508 12.7276C12.4587 13.0908 11.5409 13.0908 10.7488 12.7276M10.7488 7.2724C11.5409 6.9092 12.4587 6.9092 13.2508 7.2724M9.27202 11.251C8.90883 10.4589 8.90883 9.54111 9.27202 8.74897M14.7272 8.74897C15.0904 9.54111 15.0904 10.4589 14.7272 11.251M6.18003 19.5412C6.06141 20.0143 6 20.504 6 21M18 21C18 20.504 17.9386 20.0143 17.82 19.5412M7 17.6833C7.21936 17.3526 7.47257 17.0421 7.75736 16.7574C8.04215 16.4726 8.35262 16.2194 8.68333 16M17 17.6833C16.7806 17.3526 16.5274 17.0421 16.2426 16.7574C15.9579 16.4726 15.6474 16.2194 15.3167 16M10.6747 15.1482C11.1062 15.0504 11.5505 15 12 15C12.4495 15 12.8938 15.0504 13.3253 15.1482M5 3H19C20.1046 3 21 3.89543 21 5V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19V5C3 3.89543 3.89543 3 5 3Z" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 953 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 12L17.9427 9.94263C17.6926 9.69267 17.3536 9.55225 17 9.55225C16.6464 9.55225 16.3074 9.69267 16.0573 9.94263L10 16M5 16H3M15 18.5H3M11.0667 20.9333H3M9.33333 4H18.6667C19.403 4 20 4.59695 20 5.33333V14.6667C20 15.403 19.403 16 18.6667 16H9.33333C8.59695 16 8 15.403 8 14.6667V5.33333C8 4.59695 8.59695 4 9.33333 4ZM13.3333 8C13.3333 8.73638 12.7364 9.33333 12 9.33333C11.2636 9.33333 10.6667 8.73638 10.6667 8C10.6667 7.26362 11.2636 6.66667 12 6.66667C12.7364 6.66667 13.3333 7.26362 13.3333 8Z" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 697 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 8.33331L9.62844 6.96175C9.46175 6.79511 9.2357 6.7015 9 6.7015C8.7643 6.7015 8.53825 6.79511 8.37156 6.96175L4.33333 11M11 18.3333L9.62844 16.9618C9.46175 16.7951 9.2357 16.7015 9 16.7015C8.7643 16.7015 8.53825 16.7951 8.37156 16.9618L4.33333 21M21 8.33331L19.6284 6.96175C19.4618 6.79511 19.2357 6.7015 19 6.7015C18.7643 6.7015 18.5382 6.79511 18.3716 6.96175L14.3333 11M21 18.3333L19.6284 16.9618C19.4618 16.7951 19.2357 16.7015 19 16.7015C18.7643 16.7015 18.5382 16.7951 18.3716 16.9618L14.3333 21M3.88889 3H10.1111C10.602 3 11 3.39797 11 3.88889V10.1111C11 10.602 10.602 11 10.1111 11H3.88889C3.39797 11 3 10.602 3 10.1111V3.88889C3 3.39797 3.39797 3 3.88889 3ZM3.88889 13H10.1111C10.602 13 11 13.398 11 13.8889V20.1111C11 20.602 10.602 21 10.1111 21H3.88889C3.39797 21 3 20.602 3 20.1111V13.8889C3 13.398 3.39797 13 3.88889 13ZM13.8889 3H20.1111C20.602 3 21 3.39797 21 3.88889V10.1111C21 10.602 20.602 11 20.1111 11H13.8889C13.398 11 13 10.602 13 10.1111V3.88889C13 3.39797 13.398 3 13.8889 3ZM13.8889 13H20.1111C20.602 13 21 13.398 21 13.8889V20.1111C21 20.602 20.602 21 20.1111 21H13.8889C13.398 21 13 20.602 13 20.1111V13.8889C13 13.398 13.398 13 13.8889 13Z" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 19C3 20.1046 3.79594 21 4.77778 21H11V3H4.77778C3.79594 3 3 3.89543 3 5M3 19V5M3 19C3 19.5304 3.21071 20.0391 3.58579 20.4142C3.96086 20.7893 4.46957 21 5 21M3 5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3M11 1L11 23M21 15L17.9 11.9C17.5237 11.5312 17.017 11.3258 16.4901 11.3284C15.9632 11.331 15.4586 11.5415 15.086 11.914L14 13M11 16L6 21M14 3H19.1538C20.1734 3 21 3.89543 21 5V19C21 20.1046 20.1734 21 19.1538 21H14V3ZM11 9C11 10.1046 10.1046 11 9 11C7.89543 11 7 10.1046 7 9C7 7.89543 7.89543 7 9 7C10.1046 7 11 7.89543 11 9Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 19C3 20.1046 3.79594 21 4.77778 21H11V3H4.77778C3.79594 3 3 3.89543 3 5M3 19V5M3 19C3 19.5304 3.21071 20.0391 3.58579 20.4142C3.96086 20.7893 4.46957 21 5 21M3 5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3M11 1L11 23M21 15L17.9 11.9C17.5237 11.5312 17.017 11.3258 16.4901 11.3284C15.9632 11.331 15.4586 11.5415 15.086 11.914L14 13M11 16L6 21M14 3H19.1538C20.1734 3 21 3.89543 21 5V19C21 20.1046 20.1734 21 19.1538 21H14V3ZM11 9C11 10.1046 10.1046 11 9 11C7.89543 11 7 10.1046 7 9C7 7.89543 7.89543 7 9 7C10.1046 7 11 7.89543 11 9Z" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 760 B

After

Width:  |  Height:  |  Size: 758 B

View File

@@ -1,3 +0,0 @@
<svg width="48" height="24" viewBox="0 0 48 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 1.2002C18.4418 1.2002 18.7998 1.55817 18.7998 2V5.2002H29C29.9941 5.2002 30.7998 6.00589 30.7998 7V17.7002H34C34.4418 17.7002 34.7998 18.0582 34.7998 18.5C34.7998 18.9418 34.4418 19.2998 34 19.2998H30.7998V22.5C30.7998 22.9418 30.4418 23.2998 30 23.2998C29.5582 23.2998 29.2002 22.9418 29.2002 22.5V19.2998H19C18.0059 19.2998 17.2002 18.4941 17.2002 17.5V6.7998H14C13.5582 6.7998 13.2002 6.44183 13.2002 6C13.2002 5.55817 13.5582 5.2002 14 5.2002H17.2002V2C17.2002 1.55817 17.5582 1.2002 18 1.2002ZM18.7998 17.5C18.7998 17.6105 18.8895 17.7002 19 17.7002H29.2002V7C29.2002 6.88954 29.1105 6.79981 29 6.7998H18.7998V17.5Z" fill="#8A8A8A"/>
</svg>

Before

Width:  |  Height:  |  Size: 755 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.125 21C16.125 19.6076 15.5719 18.2723 14.5873 17.2877C13.6027 16.3031 12.2674 15.75 10.875 15.75M10.875 15.75C9.48261 15.75 8.14726 16.3031 7.16269 17.2877C6.17812 18.2723 5.625 19.6076 5.625 21M10.875 15.75C12.808 15.75 14.375 14.183 14.375 12.25C14.375 10.317 12.808 8.75 10.875 8.75C8.942 8.75 7.375 10.317 7.375 12.25C7.375 14.183 8.942 15.75 10.875 15.75ZM18.1875 5.8125C17.8727 5.50826 17.4724 5.25 17 5.25H4.75C4.25313 5.25 3.80462 5.45707 3.48607 5.78963C3.18499 6.10395 3 6.53037 3 7V19.25C3 20.2165 3.7835 21 4.75 21H17C17.4583 21 17.8755 20.8238 18.1875 20.5354C18.5334 20.2157 18.75 19.7582 18.75 19.25V7C18.75 6.5059 18.5168 6.13071 18.1875 5.8125ZM18.1875 20.5354L20.4375 18.2854C20.7834 17.9657 21 17.5082 21 17V4.75C21 4.26618 20.8037 3.82821 20.4863 3.51144C20.1697 3.19541 19.7327 3 19.25 3H7.00003C6.49187 3 6.03429 3.21659 5.71458 3.5625L3.48607 5.78963M18.1875 5.8125L20.4863 3.51144" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.5 21H5M5 21C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V9M5 21L14.086 11.914C14.4586 11.5415 14.9632 11.331 15.4901 11.3284M18.5 13.7503L20.5 15.7503M11 9C11 10.1046 10.1046 11 9 11C7.89543 11 7 10.1046 7 9C7 7.89543 7.89543 7 9 7C10.1046 7 11 7.89543 11 9ZM21.5871 14.6562C21.8514 14.3919 22 14.0334 22 13.6596C22 13.2858 21.8516 12.9273 21.5873 12.6629C21.323 12.3986 20.9645 12.25 20.5907 12.25C20.2169 12.25 19.8584 12.3984 19.594 12.6627L12.921 19.3373C12.8049 19.453 12.719 19.5955 12.671 19.7523L12.0105 21.9283C11.9975 21.9715 11.9966 22.0175 12.0076 22.0612C12.0187 22.105 12.0414 22.1449 12.0734 22.1768C12.1053 22.2087 12.1453 22.2313 12.189 22.2423C12.2328 22.2533 12.2787 22.2523 12.322 22.2393L14.4985 21.5793C14.6551 21.5317 14.7976 21.4463 14.9135 21.3308L21.5871 14.6562Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.5 21H5M5 21C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V9M5 21L14.086 11.914C14.4586 11.5415 14.9632 11.331 15.4901 11.3284M18.5 13.7503L20.5 15.7503M11 9C11 10.1046 10.1046 11 9 11C7.89543 11 7 10.1046 7 9C7 7.89543 7.89543 7 9 7C10.1046 7 11 7.89543 11 9ZM21.5871 14.6562C21.8514 14.3919 22 14.0334 22 13.6596C22 13.2858 21.8516 12.9273 21.5873 12.6629C21.323 12.3986 20.9645 12.25 20.5907 12.25C20.2169 12.25 19.8584 12.3984 19.594 12.6627L12.921 19.3373C12.8049 19.453 12.719 19.5955 12.671 19.7523L12.0105 21.9283C11.9975 21.9715 11.9966 22.0175 12.0076 22.0612C12.0187 22.105 12.0414 22.1449 12.0734 22.1768C12.1053 22.2087 12.1453 22.2313 12.189 22.2423C12.2328 22.2533 12.2787 22.2523 12.322 22.2393L14.4985 21.5793C14.6551 21.5317 14.7976 21.4463 14.9135 21.3308L21.5871 14.6562Z" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.5 21H5M5 21C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V9.5M5 21L14.086 11.914C14.4586 11.5415 14.9632 11.331 15.4901 11.3284M18.3109 20.2074L12.9705 18.7508M15.4997 15.2586C14.5976 16.6137 13.5146 16.9887 12.208 17.2328C12.1646 17.2407 12.1241 17.2597 12.0904 17.2881C12.0567 17.3164 12.0309 17.3531 12.0157 17.3944C12.0004 17.4358 11.9962 17.4804 12.0035 17.5238C12.0107 17.5673 12.0291 17.6081 12.057 17.6423L15.7172 22.0841C15.7916 22.163 15.8896 22.2157 15.9964 22.2341C16.1033 22.2525 16.2133 22.2356 16.3098 22.1861C17.3673 21.4615 18.9999 19.6549 18.9999 18.7589M11 9C11 10.1046 10.1046 11 9 11C7.89543 11 7 10.1046 7 9C7 7.89543 7.89543 7 9 7C10.1046 7 11 7.89543 11 9ZM20.188 12.5694C20.2866 12.4709 20.4036 12.3927 20.5324 12.3393C20.6611 12.286 20.7992 12.2585 20.9386 12.2585C21.078 12.2585 21.216 12.286 21.3448 12.3393C21.4735 12.3927 21.5905 12.4709 21.6891 12.5694C21.7877 12.668 21.8659 12.785 21.9192 12.9138C21.9725 13.0426 22 13.1806 22 13.32C22 13.4594 21.9725 13.5974 21.9192 13.7262C21.8659 13.855 21.7877 13.972 21.6891 14.0705L19.68 16.0802C19.6331 16.1271 19.6068 16.1906 19.6068 16.2569C19.6068 16.3232 19.6331 16.3868 19.68 16.4337L20.152 16.9057C20.378 17.1317 20.5049 17.4382 20.5049 17.7578C20.5049 18.0774 20.378 18.3838 20.152 18.6098L19.68 19.0819C19.6331 19.1287 19.5695 19.1551 19.5032 19.1551C19.4369 19.1551 19.3733 19.1287 19.3265 19.0819L15.1767 14.9326C15.1298 14.8857 15.1035 14.8221 15.1035 14.7558C15.1035 14.6895 15.1298 14.626 15.1767 14.5791L15.6487 14.107C15.8747 13.8811 16.1812 13.7541 16.5008 13.7541C16.8203 13.7541 17.1268 13.8811 17.3528 14.107L17.8249 14.5791C17.8717 14.6259 17.9353 14.6523 18.0016 14.6523C18.0679 14.6523 18.1315 14.6259 18.1784 14.5791L20.188 12.5694Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.5 21H5M5 21C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V9.5M5 21L14.086 11.914C14.4586 11.5415 14.9632 11.331 15.4901 11.3284M18.3109 20.2074L12.9705 18.7508M15.4997 15.2586C14.5976 16.6137 13.5146 16.9887 12.208 17.2328C12.1646 17.2407 12.1241 17.2597 12.0904 17.2881C12.0567 17.3164 12.0309 17.3531 12.0157 17.3944C12.0004 17.4358 11.9962 17.4804 12.0035 17.5238C12.0107 17.5673 12.0291 17.6081 12.057 17.6423L15.7172 22.0841C15.7916 22.163 15.8896 22.2157 15.9964 22.2341C16.1033 22.2525 16.2133 22.2356 16.3098 22.1861C17.3673 21.4615 18.9999 19.6549 18.9999 18.7589M11 9C11 10.1046 10.1046 11 9 11C7.89543 11 7 10.1046 7 9C7 7.89543 7.89543 7 9 7C10.1046 7 11 7.89543 11 9ZM20.188 12.5694C20.2866 12.4709 20.4036 12.3927 20.5324 12.3393C20.6611 12.286 20.7992 12.2585 20.9386 12.2585C21.078 12.2585 21.216 12.286 21.3448 12.3393C21.4735 12.3927 21.5905 12.4709 21.6891 12.5694C21.7877 12.668 21.8659 12.785 21.9192 12.9138C21.9725 13.0426 22 13.1806 22 13.32C22 13.4594 21.9725 13.5974 21.9192 13.7262C21.8659 13.855 21.7877 13.972 21.6891 14.0705L19.68 16.0802C19.6331 16.1271 19.6068 16.1906 19.6068 16.2569C19.6068 16.3232 19.6331 16.3868 19.68 16.4337L20.152 16.9057C20.378 17.1317 20.5049 17.4382 20.5049 17.7578C20.5049 18.0774 20.378 18.3838 20.152 18.6098L19.68 19.0819C19.6331 19.1287 19.5695 19.1551 19.5032 19.1551C19.4369 19.1551 19.3733 19.1287 19.3265 19.0819L15.1767 14.9326C15.1298 14.8857 15.1035 14.8221 15.1035 14.7558C15.1035 14.6895 15.1298 14.626 15.1767 14.5791L15.6487 14.107C15.8747 13.8811 16.1812 13.7541 16.5008 13.7541C16.8203 13.7541 17.1268 13.8811 17.3528 14.107L17.8249 14.5791C17.8717 14.6259 17.9353 14.6523 18.0016 14.6523C18.0679 14.6523 18.1315 14.6259 18.1784 14.5791L20.188 12.5694Z" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.9999 4L11.9999 3.35L11.3499 3.35V4H11.9999ZM11.9999 20H11.3499C11.3499 20.1724 11.4184 20.3377 11.5403 20.4596C11.6622 20.5815 11.8275 20.65 11.9999 20.65L11.9999 20ZM11.9999 11.35H11.3499V12.65H11.9999V12V11.35ZM19.9999 12.65C20.3589 12.65 20.6499 12.359 20.6499 12C20.6499 11.641 20.3589 11.35 19.9999 11.35V12V12.65ZM11.9999 13.35H11.3499V14.65H11.9999V14V13.35ZM19.9999 14.65C20.3589 14.65 20.6499 14.359 20.6499 14C20.6499 13.641 20.3589 13.35 19.9999 13.35V14V14.65ZM11.9999 9.34999H11.3499V10.65H11.9999V9.99999V9.34999ZM19.9999 10.65C20.3589 10.65 20.6499 10.359 20.6499 9.99999C20.6499 9.64101 20.3589 9.34999 19.9999 9.34999V9.99999V10.65ZM11.9999 7.34999H11.3499V8.64999H11.9999V7.99999V7.34999ZM18.9999 8.64999C19.3589 8.64999 19.6499 8.35898 19.6499 7.99999C19.6499 7.64101 19.3589 7.34999 18.9999 7.34999V7.99999V8.64999ZM11.9999 15.35H11.3499V16.65H11.9999V16V15.35ZM18.9999 16.65C19.3589 16.65 19.6499 16.359 19.6499 16C19.6499 15.641 19.3589 15.35 18.9999 15.35V16V16.65ZM11.9999 17.35H11.3499V18.65H11.9999V18V17.35ZM17.4999 18.65C17.8589 18.65 18.1499 18.359 18.1499 18C18.1499 17.641 17.8589 17.35 17.4999 17.35V18V18.65ZM11.9999 5.34999H11.3499V6.64999H11.9999V5.99999V5.34999ZM17.4999 6.64999C17.8589 6.64999 18.1499 6.35898 18.1499 5.99999C18.1499 5.64101 17.8589 5.34999 17.4999 5.34999V5.99999V6.64999ZM14.4999 4.64999C14.8589 4.64999 15.1499 4.35897 15.1499 3.99999C15.1499 3.641 14.8589 3.34999 14.4999 3.34999L14.4999 3.99999L14.4999 4.64999ZM14.4999 20.65C14.8589 20.65 15.1499 20.359 15.1499 20C15.1499 19.641 14.8589 19.35 14.4999 19.35L14.4999 20L14.4999 20.65ZM11.9999 4H11.3499V20H11.9999H12.6499V4H11.9999ZM11.9999 12V12.65H19.9999V12V11.35H11.9999V12ZM11.9999 14V14.65H19.9999V14V13.35H11.9999V14ZM11.9999 9.99999V10.65H19.9999V9.99999V9.34999H11.9999V9.99999ZM11.9999 7.99999V8.64999H18.9999V7.99999V7.34999H11.9999V7.99999ZM11.9999 16V16.65H18.9999V16V15.35H11.9999V16ZM11.9999 18V18.65H17.4999V18V17.35H11.9999V18ZM11.9999 5.99999V6.64999H17.4999V5.99999V5.34999H11.9999V5.99999ZM11.9999 4L11.9999 4.65L14.4999 4.64999L14.4999 3.99999L14.4999 3.34999L11.9999 3.35L11.9999 4ZM11.9999 20L11.9999 20.65L14.4999 20.65L14.4999 20L14.4999 19.35L11.9999 19.35L11.9999 20ZM20.4852 11.9705H19.8352C19.8352 16.2978 16.3272 19.8058 11.9999 19.8058V20.4558V21.1058C17.0452 21.1058 21.1352 17.0158 21.1352 11.9705H20.4852ZM11.9999 20.4558V19.8058C7.67262 19.8058 4.16465 16.2978 4.16465 11.9705H3.51465H2.86465C2.86465 17.0158 6.95465 21.1058 11.9999 21.1058V20.4558ZM3.51465 11.9705H4.16465C4.16465 7.64323 7.67262 4.13526 11.9999 4.13526V3.48526V2.83526C6.95465 2.83526 2.86465 6.92526 2.86465 11.9705H3.51465ZM11.9999 3.48526V4.13526C16.3272 4.13526 19.8352 7.64323 19.8352 11.9705H20.4852H21.1352C21.1352 6.92526 17.0452 2.83526 11.9999 2.83526V3.48526Z" fill="#8A8A8A"/>
<path d="M12.0001 4L12.0001 3.35L11.3501 3.35V4H12.0001ZM12.0001 20H11.3501C11.3501 20.1724 11.4185 20.3377 11.5404 20.4596C11.6623 20.5815 11.8277 20.65 12.0001 20.65L12.0001 20ZM12.0001 11.35H11.3501V12.65H12.0001V12V11.35ZM20.0001 12.65C20.359 12.65 20.6501 12.359 20.6501 12C20.6501 11.641 20.359 11.35 20.0001 11.35V12V12.65ZM12.0001 13.35H11.3501V14.65H12.0001V14V13.35ZM20.0001 14.65C20.359 14.65 20.6501 14.359 20.6501 14C20.6501 13.641 20.359 13.35 20.0001 13.35V14V14.65ZM12.0001 9.34999H11.3501V10.65H12.0001V9.99999V9.34999ZM20.0001 10.65C20.359 10.65 20.6501 10.359 20.6501 9.99999C20.6501 9.64101 20.359 9.34999 20.0001 9.34999V9.99999V10.65ZM12.0001 7.34999H11.3501V8.64999H12.0001V7.99999V7.34999ZM19.0001 8.64999C19.359 8.64999 19.6501 8.35898 19.6501 7.99999C19.6501 7.64101 19.359 7.34999 19.0001 7.34999V7.99999V8.64999ZM12.0001 15.35H11.3501V16.65H12.0001V16V15.35ZM19.0001 16.65C19.359 16.65 19.6501 16.359 19.6501 16C19.6501 15.641 19.359 15.35 19.0001 15.35V16V16.65ZM12.0001 17.35H11.3501V18.65H12.0001V18V17.35ZM17.5001 18.65C17.859 18.65 18.1501 18.359 18.1501 18C18.1501 17.641 17.859 17.35 17.5001 17.35V18V18.65ZM12.0001 5.34999H11.3501V6.64999H12.0001V5.99999V5.34999ZM17.5001 6.64999C17.859 6.64999 18.1501 6.35898 18.1501 5.99999C18.1501 5.64101 17.859 5.34999 17.5001 5.34999V5.99999V6.64999ZM14.5001 4.64999C14.859 4.64999 15.1501 4.35897 15.1501 3.99999C15.1501 3.641 14.859 3.34999 14.5001 3.34999L14.5001 3.99999L14.5001 4.64999ZM14.5001 20.65C14.859 20.65 15.1501 20.359 15.1501 20C15.1501 19.641 14.859 19.35 14.5001 19.35L14.5001 20L14.5001 20.65ZM12.0001 4H11.3501V20H12.0001H12.6501V4H12.0001ZM12.0001 12V12.65H20.0001V12V11.35H12.0001V12ZM12.0001 14V14.65H20.0001V14V13.35H12.0001V14ZM12.0001 9.99999V10.65H20.0001V9.99999V9.34999H12.0001V9.99999ZM12.0001 7.99999V8.64999H19.0001V7.99999V7.34999H12.0001V7.99999ZM12.0001 16V16.65H19.0001V16V15.35H12.0001V16ZM12.0001 18V18.65H17.5001V18V17.35H12.0001V18ZM12.0001 5.99999V6.64999H17.5001V5.99999V5.34999H12.0001V5.99999ZM12.0001 4L12.0001 4.65L14.5001 4.64999L14.5001 3.99999L14.5001 3.34999L12.0001 3.35L12.0001 4ZM12.0001 20L12.0001 20.65L14.5001 20.65L14.5001 20L14.5001 19.35L12.0001 19.35L12.0001 20ZM20.4853 11.9705H19.8353C19.8353 16.2978 16.3274 19.8058 12.0001 19.8058V20.4558V21.1058C17.0453 21.1058 21.1353 17.0158 21.1353 11.9705H20.4853ZM12.0001 20.4558V19.8058C7.67275 19.8058 4.16477 16.2978 4.16477 11.9705H3.51477H2.86477C2.86477 17.0158 6.95478 21.1058 12.0001 21.1058V20.4558ZM3.51477 11.9705H4.16477C4.16477 7.64323 7.67275 4.13526 12.0001 4.13526V3.48526V2.83526C6.95478 2.83526 2.86477 6.92526 2.86477 11.9705H3.51477ZM12.0001 3.48526V4.13526C16.3274 4.13526 19.8353 7.64323 19.8353 11.9705H20.4853H21.1353C21.1353 6.92526 17.0453 2.83526 12.0001 2.83526V3.48526Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.5 15H4.33333C3.59695 15 3 14.403 3 13.6667V4.33333C3 3.59695 3.59695 3 4.33333 3H13.6667C14.403 3 15 3.59695 15 4.33333V11L12.9427 8.94263C12.6926 8.69267 12.3536 8.55225 12 8.55225C11.6464 8.55225 11.3074 8.69267 11.0573 8.94263L5 15M18.3109 20.2074L12.9705 18.7508M15.4997 15.2586C14.5976 16.6137 13.5146 16.9887 12.208 17.2328C12.1646 17.2407 12.1241 17.2597 12.0904 17.2881C12.0567 17.3164 12.0309 17.3531 12.0157 17.3944C12.0004 17.4358 11.9962 17.4804 12.0035 17.5238C12.0107 17.5673 12.0291 17.6081 12.057 17.6423L15.7172 22.0841C15.7916 22.163 15.8896 22.2157 15.9964 22.2341C16.1033 22.2525 16.2133 22.2356 16.3098 22.1861C17.3673 21.4615 18.9999 19.6549 18.9999 18.7589M18 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V9M10 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V18M8.33333 7C8.33333 7.73638 7.73638 8.33333 7 8.33333C6.26362 8.33333 5.66667 7.73638 5.66667 7C5.66667 6.26362 6.26362 5.66667 7 5.66667C7.73638 5.66667 8.33333 6.26362 8.33333 7ZM20.188 12.5694C20.2866 12.4709 20.4036 12.3927 20.5324 12.3393C20.6611 12.286 20.7992 12.2585 20.9386 12.2585C21.078 12.2585 21.216 12.286 21.3448 12.3393C21.4735 12.3927 21.5905 12.4709 21.6891 12.5694C21.7877 12.668 21.8659 12.785 21.9192 12.9138C21.9725 13.0426 22 13.1806 22 13.32C22 13.4594 21.9725 13.5974 21.9192 13.7262C21.8659 13.855 21.7877 13.972 21.6891 14.0705L19.68 16.0802C19.6331 16.1271 19.6068 16.1906 19.6068 16.2569C19.6068 16.3232 19.6331 16.3868 19.68 16.4337L20.152 16.9057C20.378 17.1317 20.5049 17.4382 20.5049 17.7578C20.5049 18.0774 20.378 18.3838 20.152 18.6098L19.68 19.0819C19.6331 19.1287 19.5695 19.1551 19.5032 19.1551C19.4369 19.1551 19.3733 19.1287 19.3265 19.0819L15.1767 14.9326C15.1298 14.8857 15.1035 14.8221 15.1035 14.7558C15.1035 14.6895 15.1298 14.626 15.1767 14.5791L15.6487 14.107C15.8747 13.8811 16.1812 13.7541 16.5008 13.7541C16.8203 13.7541 17.1268 13.8811 17.3528 14.107L17.8249 14.5791C17.8717 14.6259 17.9353 14.6523 18.0016 14.6523C18.0679 14.6523 18.1315 14.6259 18.1784 14.5791L20.188 12.5694Z" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 14.9999L17.914 11.9139C17.5389 11.539 17.0303 11.3284 16.5 11.3284C15.9697 11.3284 15.4611 11.539 15.086 11.9139L12.6935 14.3064M14.5 21H19C20.1046 21 21 20.1046 21 19V5C21 3.89543 20.1046 3 19 3H5C3.89543 3 3 3.89543 3 5V13M11 9C11 10.1046 10.1046 11 9 11C7.89543 11 7 10.1046 7 9C7 7.89543 7.89543 7 9 7C10.1046 7 11 7.89543 11 9ZM2.03125 18.6735C1.98958 18.5613 1.98958 18.4378 2.03125 18.3255C2.43708 17.3415 3.12595 16.5001 4.01054 15.9081C4.89512 15.3161 5.93558 15 7 15C8.06442 15 9.10488 15.3161 9.98946 15.9081C10.874 16.5001 11.5629 17.3415 11.9687 18.3255C12.0104 18.4378 12.0104 18.5613 11.9687 18.6735C11.5629 19.6575 10.874 20.4989 9.98946 21.0909C9.10488 21.683 8.06442 21.999 7 21.999C5.93558 21.999 4.89512 21.683 4.01054 21.0909C3.12595 20.4989 2.43708 19.6575 2.03125 18.6735ZM8.49992 18.4995C8.49992 19.3278 7.82838 19.9994 6.99999 19.9994C6.17161 19.9994 5.50007 19.3278 5.50007 18.4995C5.50007 17.6711 6.17161 16.9995 6.99999 16.9995C7.82838 16.9995 8.49992 17.6711 8.49992 18.4995Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 3C20.1046 3 21 3.89543 21 5M21 19C21 20.1046 20.1046 21 19 21H18C18 19.4087 17.3679 17.8826 16.2426 16.7574C15.1174 15.6321 13.5913 15 12 15C10.4087 15 8.88258 15.6321 7.75736 16.7574C6.63214 17.8826 6 19.4087 6 21H5C3.89543 21 3 20.1046 3 19M3 5C3 3.89543 3.89543 3 5 3M21 13.5V16M21 8V11M3 11V8M3 16V13.5M8 3H10.5M13.5 3H16M10.5 21H8.5M15.5 21H13.5M14.1213 12.1213C15.2929 10.9497 15.2929 9.05025 14.1213 7.87868C12.9497 6.70711 11.0503 6.70711 9.87868 7.87868C8.70711 9.05025 8.70711 10.9497 9.87868 12.1213C11.0503 13.2929 12.9497 13.2929 14.1213 12.1213Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19 3C20.1046 3 21 3.89543 21 5M21 19C21 20.1046 20.1046 21 19 21H18C18 19.4087 17.3679 17.8826 16.2426 16.7574C15.1174 15.6321 13.5913 15 12 15C10.4087 15 8.88258 15.6321 7.75736 16.7574C6.63214 17.8826 6 19.4087 6 21H5C3.89543 21 3 20.1046 3 19M3 5C3 3.89543 3.89543 3 5 3M21 13.5V16M21 8V11M3 11V8M3 16V13.5M8 3H10.5M13.5 3H16M10.5 21H8.5M15.5 21H13.5M14.1213 12.1213C15.2929 10.9497 15.2929 9.05025 14.1213 7.87868C12.9497 6.70711 11.0503 6.70711 9.87868 7.87868C8.70711 9.05025 8.70711 10.9497 9.87868 12.1213C11.0503 13.2929 12.9497 13.2929 14.1213 12.1213Z" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 760 B

View File

@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 17L12.9427 14.9426C12.6926 14.6927 12.3536 14.5522 12 14.5522C11.6464 14.5522 11.3074 14.6927 11.0573 14.9426L5 21M23 10L21 12M21 12L19 10M21 12V8C21 5.23858 18.7614 3 16 3H13M4.33333 9H13.6667C14.403 9 15 9.59695 15 10.3333V19.6667C15 20.403 14.403 21 13.6667 21H4.33333C3.59695 21 3 20.403 3 19.6667V10.3333C3 9.59695 3.59695 9 4.33333 9ZM8.33333 13C8.33333 13.7364 7.73638 14.3333 7 14.3333C6.26362 14.3333 5.66667 13.7364 5.66667 13C5.66667 12.2636 6.26362 11.6667 7 11.6667C7.73638 11.6667 8.33333 12.2636 8.33333 13Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15 17L12.9427 14.9426C12.6926 14.6927 12.3536 14.5522 12 14.5522C11.6464 14.5522 11.3074 14.6927 11.0573 14.9426L5 21M19 10L21 12L23 10M21 12V8C21 5.23858 18.7614 3 16 3H13M4.33333 9H13.6667C14.403 9 15 9.59695 15 10.3333V19.6667C15 20.403 14.403 21 13.6667 21H4.33333C3.59695 21 3 20.403 3 19.6667V10.3333C3 9.59695 3.59695 9 4.33333 9ZM8.33333 13C8.33333 13.7364 7.73638 14.3333 7 14.3333C6.26362 14.3333 5.66667 13.7364 5.66667 13C5.66667 12.2636 6.26362 11.6667 7 11.6667C7.73638 11.6667 8.33333 12.2636 8.33333 13Z" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 725 B

After

Width:  |  Height:  |  Size: 717 B

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 3H21M21 3V8M21 3L17 7M18 21H19C19.5304 21 20.0391 20.7893 20.4142 20.4142C20.7893 20.0391 21 19.5304 21 19M21 12V15M3 6V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3M9 3H12M15 17L12.9427 14.9426C12.6926 14.6927 12.3536 14.5522 12 14.5522C11.6464 14.5522 11.3074 14.6927 11.0573 14.9426L5 21M4.33333 9H13.6667C14.403 9 15 9.59695 15 10.3333V19.6667C15 20.403 14.403 21 13.6667 21H4.33333C3.59695 21 3 20.403 3 19.6667V10.3333C3 9.59695 3.59695 9 4.33333 9ZM8.33333 13C8.33333 13.7364 7.73638 14.3333 7 14.3333C6.26362 14.3333 5.66667 13.7364 5.66667 13C5.66667 12.2636 6.26362 11.6667 7 11.6667C7.73638 11.6667 8.33333 12.2636 8.33333 13Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 868 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.3 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V11.914M17.9 11.9C17.5237 11.5312 17.017 11.3258 16.4901 11.3284C15.9632 11.331 15.4586 11.5415 15.086 11.914M6 21L10.543 16.457M11 9C11 10.1046 10.1046 11 9 11C7.89543 11 7 10.1046 7 9C7 7.89543 7.89543 7 9 7C10.1046 7 11 7.89543 11 9ZM13.0283 14.5459C12.9995 14.4735 12.9925 14.3943 13.0082 14.318C13.0239 14.2418 13.0616 14.1718 13.1167 14.1167C13.1718 14.0616 13.2418 14.0239 13.318 14.0082C13.3943 13.9925 13.4735 13.9995 13.5459 14.0283L20.7456 16.8282C20.8228 16.8584 20.8887 16.9118 20.9342 16.981C20.9798 17.0502 21.0027 17.1319 20.9998 17.2147C20.9969 17.2976 20.9683 17.3774 20.918 17.4433C20.8678 17.5092 20.7983 17.5579 20.7192 17.5826L17.9641 18.4369C17.8398 18.4754 17.7267 18.5435 17.6347 18.6355C17.5427 18.7275 17.4746 18.8406 17.4361 18.9649L16.5826 21.7192C16.5579 21.7983 16.5092 21.8678 16.4433 21.918C16.3774 21.9683 16.2976 21.9969 16.2147 21.9998C16.1319 22.0027 16.0502 21.9798 15.981 21.9342C15.9118 21.8887 15.8584 21.8228 15.8282 21.7456L13.0283 14.5459Z" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.65908 19.0121L19.0121 7.65914M4.98779 16.3408L16.3408 4.98785M11.666 20.3478L20.3477 11.6661M3.65215 12.3339L12.3338 3.65221M18.0104 5.98959C21.3299 9.30905 21.3299 14.691 18.0104 18.0104C14.691 21.3299 9.30905 21.3299 5.98959 18.0104C2.67014 14.691 2.67014 9.30905 5.98959 5.98959C9.30905 2.67014 14.691 2.67014 18.0104 5.98959Z" stroke="white" stroke-width="1.3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 505 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 21H11V3H7C4.79086 3 3 4.79086 3 7V17C3 19.2091 4.79086 21 7 21ZM7 21C7 21 7 19 8.5 17.5C10 16 11 16 11 16M11 1L11 23M21 15L16.5 10.5L14 13L15.086 11.914M14 3V21H21V3H14ZM11 9C11 10.1046 10.1046 11 9 11C7.89543 11 7 10.1046 7 9C7 7.89543 7.89543 7 9 7C10.1046 7 11 7.89543 11 9Z" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 477 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 9.96658L11.9427 7.90924C11.6926 7.65928 11.3536 7.51886 11 7.51886C10.6464 7.51886 10.3074 7.65928 10.0573 7.90924L4 13.9666M5 18L7 20L5 22M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M16.6001 10.1608L20.8005 12.561C20.9827 12.6662 21.1341 12.8176 21.2394 12.9998C21.3448 13.182 21.4003 13.3887 21.4005 13.5991V18.3996C21.4003 18.61 21.3448 18.8167 21.2394 18.999C21.1341 19.1812 20.9827 19.3325 20.8005 19.4377L16.6001 21.8379C16.4176 21.9433 16.2107 21.9987 16 21.9987C15.7894 21.9987 15.5824 21.9433 15.4 21.8379L11.1995 19.4377C11.0173 19.3325 10.8659 19.1812 10.7606 18.999C10.6553 18.8167 10.5997 18.61 10.5995 18.3996V16.5M16 15.9994L21.2206 12.9991M16 15.9994L15 15.4247M16 15.9994L16 22M3.33333 1.96661H12.6667C13.403 1.96661 14 2.56357 14 3.29995V12.6333C14 13.3697 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3697 2 12.6333V3.29995C2 2.56357 2.59695 1.96661 3.33333 1.96661ZM7.33333 5.96661C7.33333 6.70299 6.73638 7.29995 6 7.29995C5.26362 7.29995 4.66667 6.70299 4.66667 5.96661C4.66667 5.23023 5.26362 4.63328 6 4.63328C6.73638 4.63328 7.33333 5.23023 7.33333 5.96661Z" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 18L19.9427 15.9426C19.6926 15.6927 19.3536 15.5522 19 15.5522C18.6464 15.5522 18.3074 15.6927 18.0573 15.9426L12 22M10 17V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H17M14 9.96651L11.9427 7.90918C11.6926 7.65922 11.3536 7.5188 11 7.5188C10.6464 7.5188 10.3074 7.65922 10.0573 7.90918L4 13.9665M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18M3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655ZM7.33333 5.96655C7.33333 6.70293 6.73638 7.29989 6 7.29989C5.26362 7.29989 4.66667 6.70293 4.66667 5.96655C4.66667 5.23017 5.26362 4.63322 6 4.63322C6.73638 4.63322 7.33333 5.23017 7.33333 5.96655Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M22 18L19.9427 15.9426C19.6926 15.6927 19.3536 15.5522 19 15.5522C18.6464 15.5522 18.3074 15.6927 18.0573 15.9426L12 22M10 17V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H17M14 9.96651L11.9427 7.90918C11.6926 7.65922 11.3536 7.5188 11 7.5188C10.6464 7.5188 10.3074 7.65922 10.0573 7.90918L4 13.9665M5 18L7 20L5 22M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655ZM7.33333 5.96655C7.33333 6.70293 6.73638 7.29989 6 7.29989C5.26362 7.29989 4.66667 6.70293 4.66667 5.96655C4.66667 5.23017 5.26362 4.63322 6 4.63322C6.73638 4.63322 7.33333 5.23017 7.33333 5.96655Z" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.6151 11.9999L20.5432 14.8147C20.6816 14.8931 20.7967 15.0068 20.8768 15.1443C20.9569 15.2817 20.9991 15.438 20.9991 15.597C20.9991 15.7561 20.9569 15.9124 20.8768 16.0498C20.7967 16.1872 20.6816 16.301 20.5432 16.3794L15.6151 19.2029M8.38492 11.9999L3.45687 14.8147C3.31847 14.8931 3.20335 15.0068 3.12326 15.1443C3.04317 15.2817 3.00098 15.438 3.00098 15.597C3.00098 15.7561 3.04317 15.9124 3.12326 16.0498C3.20335 16.1872 3.31847 16.301 3.45687 16.3794L4.6883 17.085M4.6883 17.085L11.1007 20.7589C11.3742 20.9168 11.6843 20.9999 12 20.9999C12.3157 20.9999 12.6259 20.9168 12.8993 20.7589L15.6151 19.2029M4.6883 17.085C4.6883 17.085 13.8671 15.4346 15.3747 15.9371C17.062 16.4996 15.6151 19.2029 15.6151 19.2029M12.8993 13.5646C12.6259 13.7224 12.3157 13.8055 12 13.8055C11.6843 13.8055 11.3742 13.7224 11.1007 13.5646L3.45687 9.18508C3.31847 9.10665 3.20335 8.99291 3.12326 8.85546C3.04317 8.71802 3.00098 8.56179 3.00098 8.40271C3.00098 8.24363 3.04317 8.0874 3.12326 7.94995C3.20335 7.81251 3.31847 7.69877 3.45687 7.62033L11.1007 3.24084C11.3742 3.08298 11.6843 2.99988 12 2.99988C12.3157 2.99988 12.6259 3.08298 12.8993 3.24084L20.5432 7.62033C20.6816 7.69877 20.7967 7.81251 20.8768 7.94995C20.9569 8.0874 20.9991 8.24363 20.9991 8.40271C20.9991 8.56179 20.9569 8.71802 20.8768 8.85546C20.7967 8.99291 20.6816 9.10665 20.5432 9.18508L12.8993 13.5646ZM12.6231 6.99949C12.1336 7.38197 11.292 7.45559 10.7435 7.16393C10.195 6.87227 10.1472 6.32577 10.6367 5.94329C11.1263 5.5608 11.9678 5.48718 12.5163 5.77884C13.0649 6.0705 13.1127 6.617 12.6231 6.99949Z" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 17.9999V20.6666C10 21.403 10.597 21.9999 11.3333 21.9999H20.6667C21.403 21.9999 22 21.403 22 20.6666V11.3333C22 10.5969 21.403 9.99994 20.6667 9.99994H18M14 16.9999V18.6666L18 15.9999L16.6579 15.1052M14 9.96651L11.9427 7.90918C11.6926 7.65922 11.3536 7.5188 11 7.5188C10.6464 7.5188 10.3074 7.65922 10.0573 7.90918L4 13.9665M5 21.9999L7 19.9999M7 19.9999H4C3.46957 19.9999 2.96086 19.7892 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 17.9999V16.9999M7 19.9999L5 17.9999M3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655ZM7.33333 5.96655C7.33333 6.70293 6.73638 7.29989 6 7.29989C5.26362 7.29989 4.66667 6.70293 4.66667 5.96655C4.66667 5.23017 5.26362 4.63322 6 4.63322C6.73638 4.63322 7.33333 5.23017 7.33333 5.96655Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10 18V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H18M14 17V18.6667L18 16L16.6579 15.1053M14 9.96658L11.9427 7.90924C11.6926 7.65928 11.3536 7.51886 11 7.51886C10.6464 7.51886 10.3074 7.65928 10.0573 7.90924L4 13.9666M5 18L7 20L5 22M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M3.33333 1.96661H12.6667C13.403 1.96661 14 2.56357 14 3.29995V12.6333C14 13.3697 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3697 2 12.6333V3.29995C2 2.56357 2.59695 1.96661 3.33333 1.96661ZM7.33333 5.96661C7.33333 6.70299 6.73638 7.29995 6 7.29995C5.26362 7.29995 4.66667 6.70299 4.66667 5.96661C4.66667 5.23023 5.26362 4.63328 6 4.63328C6.73638 4.63328 7.33333 5.23023 7.33333 5.96661Z" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 989 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 8V3H16M21 3L17 7M18 21H19C19.5304 21 20.0391 20.7893 20.4142 20.4142C20.7893 20.0391 21 19.5304 21 19M21 12V15M3 6V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3M9 3H12M15 17L12.9427 14.9426C12.6926 14.6927 12.3536 14.5522 12 14.5522C11.6464 14.5522 11.3074 14.6927 11.0573 14.9426L5 21M4.33333 9H13.6667C14.403 9 15 9.59695 15 10.3333V19.6667C15 20.403 14.403 21 13.6667 21H4.33333C3.59695 21 3 20.403 3 19.6667V10.3333C3 9.59695 3.59695 9 4.33333 9ZM8.33333 13C8.33333 13.7364 7.73638 14.3333 7 14.3333C6.26362 14.3333 5.66667 13.7364 5.66667 13C5.66667 12.2636 6.26362 11.6667 7 11.6667C7.73638 11.6667 8.33333 12.2636 8.33333 13Z" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 861 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 17C5 13.8174 6.26428 10.7652 8.51472 8.51472C10.7652 6.26428 13.8174 5 17 5M5 17C3.89543 17 3 17.8954 3 19C3 20.1046 3.89543 21 5 21C6.10457 21 7 20.1046 7 19C7 17.8954 6.10457 17 5 17ZM17 5C17 6.10457 17.8954 7 19 7C20.1046 7 21 6.10457 21 5C21 3.89543 20.1046 3 19 3C17.8954 3 17 3.89543 17 5ZM12.034 12.681C11.998 12.5906 11.9892 12.4915 12.0088 12.3962C12.0285 12.3008 12.0756 12.2133 12.1445 12.1445C12.2133 12.0756 12.3008 12.0285 12.3962 12.0088C12.4915 11.9892 12.5906 11.998 12.681 12.034L21.681 15.534C21.7775 15.5717 21.8599 15.6384 21.9168 15.725C21.9737 15.8116 22.0023 15.9137 21.9987 16.0172C21.9951 16.1207 21.9594 16.2206 21.8966 16.3029C21.8338 16.3853 21.7469 16.4461 21.648 16.477L18.204 17.545C18.0486 17.593 17.9073 17.6783 17.7923 17.7933C17.6773 17.9083 17.592 18.0496 17.544 18.205L16.477 21.648C16.4461 21.7469 16.3853 21.8338 16.3029 21.8966C16.2206 21.9594 16.1207 21.9951 16.0172 21.9987C15.9137 22.0023 15.8116 21.9737 15.725 21.9168C15.6384 21.8599 15.5717 21.7775 15.534 21.681L12.034 12.681Z" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,3 +0,0 @@
<svg width="48" height="24" viewBox="0 0 48 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22.0936 7.58276L16.0996 15.7728C15.9586 15.9654 15.8911 16.2022 15.9095 16.4403C15.9278 16.6783 16.0307 16.902 16.1996 17.0708L17.0166 17.8888C17.1879 18.0599 17.4156 18.1631 17.6573 18.1791C17.8989 18.1951 18.1382 18.1228 18.3306 17.9758L26.1836 11.9818M27.5935 21.1557C26.5935 20.4817 25.4655 19.9817 24.0935 19.9817C22.0355 19.9817 20.1655 22.3377 18.0935 21.9817C16.0215 21.6257 15.3185 18.6127 16.5935 17.4817M32.0935 6.98169C32.0935 9.74311 29.8549 11.9817 27.0935 11.9817C24.3321 11.9817 22.0935 9.74311 22.0935 6.98169C22.0935 4.22027 24.3321 1.98169 27.0935 1.98169C29.8549 1.98169 32.0935 4.22027 32.0935 6.98169Z" stroke="#8A8A8A" stroke-width="1.95" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 824 B

View File

@@ -1,7 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.07572 3C8.28083 10.9427 13.8455 15.7083 21 14.914" stroke="#8A8A8A" stroke-width="1.3"/>
<path d="M9.75 8.25L15 3" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round"/>
<path d="M12 12L18.75 5.25" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="square"/>
<path d="M15.75 14.25L21 9" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round"/>
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 679 B

View File

@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 21.7299L4 17.7299C3.69626 17.5545 3.44398 17.3024 3.26846 16.9987C3.09294 16.6951 3.00036 16.3506 3 15.9999V7.9999C3.00036 7.64918 3.09294 7.30471 3.26846 7.00106C3.44398 6.69742 3.69626 6.44526 4 6.2699L11 2.2699C11.304 2.09437 11.6489 2.00195 12 2.00195C12.3511 2.00195 12.696 2.09437 13 2.2699L20 6.2699C20.3037 6.44526 20.556 6.69742 20.7315 7.00106C20.9071 7.30471 20.9996 7.64918 21 7.9999V15M3.30005 7L12 12M12 12L20.7001 7M12 12V16.5M14 19L17 22M17 22V16.5M17 22L20 19" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 21.7299L4 17.7299C3.69626 17.5545 3.44398 17.3024 3.26846 16.9987C3.09294 16.6951 3.00036 16.3506 3 15.9999V7.9999C3.00036 7.64918 3.09294 7.30471 3.26846 7.00106C3.44398 6.69742 3.69626 6.44526 4 6.2699L11 2.2699C11.304 2.09437 11.6489 2.00195 12 2.00195C12.3511 2.00195 12.696 2.09437 13 2.2699L20 6.2699C20.3037 6.44526 20.556 6.69742 20.7315 7.00106C20.9071 7.30471 20.9996 7.64918 21 7.9999V15M3.30005 7L12 12M12 12L20.7001 7M12 12V16.5M20 19.5L17 16.5L14 19.5M17 16.5V22" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 679 B

After

Width:  |  Height:  |  Size: 677 B

View File

@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 10V13M6 6V17M10 3V21M14 8V14.5M18 5V12.5M22 10V13M14 19.5L17 16.5M17 16.5V22M17 16.5L20 19.5" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 10V13M6 6V17M10 3V21M14 8V14.5M18 5V12.5M22 10V13M20 19.5L17 16.5L14 19.5M17 16.5V22" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 294 B

After

Width:  |  Height:  |  Size: 284 B

Some files were not shown because too many files have changed in this diff Show More