docs: write down how to model a feature's state, and lint the checkable part - #14472
Conversation
This advice has been given per-review rather than written down, most recently across the first-run onboarding tour stack. Putting it where the glob-loaded guidance lives so it stops being re-derived in review comments. The shape: one state as a discriminated union rather than several booleans that must agree; named events and a pure transition rather than assignments spread across callers; async orchestration in commands rather than chains of effects reacting to each other; derive anything derivable; and reserve effects for synchronising outward. Also two things that are easy to discover the hard way, so they are written down: layoutStore holds a node's body box, not the box the user sees, and node ids are graph-local so one held across a graph change names something else. Adds a lint rule for the mechanically checkable part - measuring or querying the DOM inside a computed, where every recompute becomes a layout read. It is 'warn' rather than 'error' because four pre-existing instances remain (BrushCursor, WorkflowTabs, SubgraphBreadcrumb); it should be promoted once those derive from stores instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
📄 Knowledge reviewDosu skipped reviewing this PR because your organization has used its |
🎭 Playwright: ✅ 1772 passed, 0 failed · 1 flaky📊 Browser Reports
📦 Bundle: 8.19 MB gzip ⚪ 0 BDetailsSummary
Category Glance App Entry Points — 3.62 kB (baseline 3.62 kB) • ⚪ 0 BMain entry bundles and manifests Status: 1 unchanged Graph Workspace — 1.29 MB (baseline 1.29 MB) • ⚪ 0 BGraph editor runtime, canvas, workflow orchestration Status: 2 unchanged Views & Navigation — 111 kB (baseline 111 kB) • ⚪ 0 BTop-level views, pages, and routed surfaces Status: 16 unchanged Panels & Settings — 551 kB (baseline 551 kB) • ⚪ 0 BConfiguration panels, inspectors, and settings screens Status: 26 unchanged User & Accounts — 28.7 kB (baseline 28.7 kB) • ⚪ 0 BAuthentication, profile, and account management bundles Status: 10 unchanged Editors & Dialogs — 124 kB (baseline 124 kB) • ⚪ 0 BModals, dialogs, drawers, and in-app editors Status: 7 unchanged UI Components — 70 kB (baseline 70 kB) • ⚪ 0 BReusable component library chunks Status: 15 unchanged Data & Services — 3.44 MB (baseline 3.44 MB) • ⚪ 0 BStores, services, APIs, and repositories Status: 17 unchanged Utilities & Hooks — 386 kB (baseline 386 kB) • ⚪ 0 BHelpers, composables, and utility bundles Status: 36 unchanged Vendor & Third-Party — 15.7 MB (baseline 15.7 MB) • ⚪ 0 BExternal libraries and shared vendor chunks Status: 16 unchanged Other — 12.7 MB (baseline 12.7 MB) • ⚪ 0 BBundles that do not match a named category Status: 280 unchanged ⚡ Performance Report
Show regressions
All metrics
Historical variance (last 15 runs)
Trend (last 15 commits on main)
Raw data{
"timestamp": "2026-07-31T08:17:46.400Z",
"gitSha": "e05186bfaa4ffa2193004e3d4853bc728c8335f7",
"branch": "christian/frontend-state-guidance",
"measurements": [
{
"name": "canvas-idle",
"durationMs": 2135.6229999999814,
"styleRecalcs": 9,
"styleRecalcDurationMs": 7.315,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 565.778,
"heapDeltaBytes": 4902360,
"heapUsedBytes": 72903956,
"domNodes": 18,
"jsHeapTotalBytes": 20049920,
"scriptDurationMs": 22.229999999999997,
"eventListeners": 4,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "canvas-idle",
"durationMs": 2020.762999999988,
"styleRecalcs": 9,
"styleRecalcDurationMs": 8.070999999999998,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 470.105,
"heapDeltaBytes": 4453148,
"heapUsedBytes": 72230644,
"domNodes": 18,
"jsHeapTotalBytes": 20574208,
"scriptDurationMs": 17.071,
"eventListeners": 4,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.699999999999818
},
{
"name": "canvas-mouse-sweep",
"durationMs": 1975.2379999999903,
"styleRecalcs": 79,
"styleRecalcDurationMs": 52.21600000000001,
"layouts": 12,
"layoutDurationMs": 4.244,
"taskDurationMs": 982.5010000000001,
"heapDeltaBytes": -16039388,
"heapUsedBytes": 51699096,
"domNodes": -279,
"jsHeapTotalBytes": 21225472,
"scriptDurationMs": 127.02100000000003,
"eventListeners": -146,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333335,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "canvas-mouse-sweep",
"durationMs": 1863.7339999999654,
"styleRecalcs": 75,
"styleRecalcDurationMs": 37.851000000000006,
"layouts": 12,
"layoutDurationMs": 3.758,
"taskDurationMs": 901.5620000000001,
"heapDeltaBytes": -15434800,
"heapUsedBytes": 52385172,
"domNodes": -280,
"jsHeapTotalBytes": 20701184,
"scriptDurationMs": 119.00800000000001,
"eventListeners": -148,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "canvas-zoom-sweep",
"durationMs": 1741.199999999992,
"styleRecalcs": 31,
"styleRecalcDurationMs": 17.096,
"layouts": 6,
"layoutDurationMs": 0.6200000000000001,
"taskDurationMs": 398.21799999999996,
"heapDeltaBytes": 7889860,
"heapUsedBytes": 75726816,
"domNodes": 76,
"jsHeapTotalBytes": 20312064,
"scriptDurationMs": 21.624000000000002,
"eventListeners": 19,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "canvas-zoom-sweep",
"durationMs": 1771.729999999934,
"styleRecalcs": 32,
"styleRecalcDurationMs": 17.019,
"layouts": 6,
"layoutDurationMs": 0.6119999999999999,
"taskDurationMs": 392.752,
"heapDeltaBytes": 7913584,
"heapUsedBytes": 75794568,
"domNodes": 77,
"jsHeapTotalBytes": 20049920,
"scriptDurationMs": 20.627999999999997,
"eventListeners": 19,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "dom-widget-clipping",
"durationMs": 611.6309999999885,
"styleRecalcs": 11,
"styleRecalcDurationMs": 7.533000000000001,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 394.652,
"heapDeltaBytes": -12483356,
"heapUsedBytes": 55413000,
"domNodes": 18,
"jsHeapTotalBytes": 22147072,
"scriptDurationMs": 58.373,
"eventListeners": 0,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "dom-widget-clipping",
"durationMs": 579.0600000000268,
"styleRecalcs": 9,
"styleRecalcDurationMs": 5.428000000000001,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 377.01300000000003,
"heapDeltaBytes": -11958708,
"heapUsedBytes": 55882836,
"domNodes": 14,
"jsHeapTotalBytes": 21622784,
"scriptDurationMs": 58.213,
"eventListeners": 0,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "large-graph-idle",
"durationMs": 2028.299000000004,
"styleRecalcs": 8,
"styleRecalcDurationMs": 7.122,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 682.879,
"heapDeltaBytes": 8373628,
"heapUsedBytes": 67385500,
"domNodes": -284,
"jsHeapTotalBytes": 4780032,
"scriptDurationMs": 100.57600000000001,
"eventListeners": -144,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333335,
"p95FrameDurationMs": 16.699999999999818
},
{
"name": "large-graph-idle",
"durationMs": 2026.267999999959,
"styleRecalcs": 8,
"styleRecalcDurationMs": 7.130000000000001,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 678.189,
"heapDeltaBytes": 7976384,
"heapUsedBytes": 67377820,
"domNodes": -283,
"jsHeapTotalBytes": 5304320,
"scriptDurationMs": 99.02,
"eventListeners": -142,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333332,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "large-graph-pan",
"durationMs": 2171.5090000000146,
"styleRecalcs": 68,
"styleRecalcDurationMs": 14.569000000000003,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 1256.7979999999998,
"heapDeltaBytes": 6179576,
"heapUsedBytes": 65890008,
"domNodes": -285,
"jsHeapTotalBytes": 5005312,
"scriptDurationMs": 408.754,
"eventListeners": -142,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "large-graph-pan",
"durationMs": 2139.2769999999928,
"styleRecalcs": 68,
"styleRecalcDurationMs": 14.196,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 1307.328,
"heapDeltaBytes": 2730632,
"heapUsedBytes": 62868328,
"domNodes": -285,
"jsHeapTotalBytes": 5529600,
"scriptDurationMs": 444.03800000000007,
"eventListeners": -144,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "large-graph-zoom",
"durationMs": 3164.8999999999887,
"styleRecalcs": 64,
"styleRecalcDurationMs": 14.915999999999995,
"layouts": 60,
"layoutDurationMs": 8.131,
"taskDurationMs": 1488.1899999999998,
"heapDeltaBytes": 24052456,
"heapUsedBytes": 86073552,
"domNodes": 10,
"jsHeapTotalBytes": 7864320,
"scriptDurationMs": 527.3149999999999,
"eventListeners": 8,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.699999999999818
},
{
"name": "large-graph-zoom",
"durationMs": 3212.248000000045,
"styleRecalcs": 65,
"styleRecalcDurationMs": 16.312000000000005,
"layouts": 60,
"layoutDurationMs": 8.126,
"taskDurationMs": 1527.8990000000001,
"heapDeltaBytes": -277288,
"heapUsedBytes": 61162108,
"domNodes": -287,
"jsHeapTotalBytes": 8187904,
"scriptDurationMs": 530.277,
"eventListeners": -148,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.699999999999818
},
{
"name": "minimap-idle",
"durationMs": 2027.0029999999792,
"styleRecalcs": 8,
"styleRecalcDurationMs": 7.158999999999999,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 687.663,
"heapDeltaBytes": 7788264,
"heapUsedBytes": 68552668,
"domNodes": -282,
"jsHeapTotalBytes": 5042176,
"scriptDurationMs": 102.17899999999999,
"eventListeners": -144,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "minimap-idle",
"durationMs": 2028.6619999999402,
"styleRecalcs": 8,
"styleRecalcDurationMs": 6.831,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 691.335,
"heapDeltaBytes": 7908268,
"heapUsedBytes": 68692208,
"domNodes": -283,
"jsHeapTotalBytes": 4780032,
"scriptDurationMs": 99.45199999999998,
"eventListeners": -144,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "subgraph-dom-widget-clipping",
"durationMs": 613.7819999999579,
"styleRecalcs": 47,
"styleRecalcDurationMs": 11.305000000000001,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 412.42,
"heapDeltaBytes": -10459764,
"heapUsedBytes": 57452536,
"domNodes": 20,
"jsHeapTotalBytes": 20836352,
"scriptDurationMs": 121.314,
"eventListeners": 8,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333332,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "subgraph-dom-widget-clipping",
"durationMs": 624.1049999999859,
"styleRecalcs": 47,
"styleRecalcDurationMs": 11.461999999999998,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 429.38100000000003,
"heapDeltaBytes": -12056648,
"heapUsedBytes": 55743936,
"domNodes": 20,
"jsHeapTotalBytes": 22409216,
"scriptDurationMs": 124.17299999999999,
"eventListeners": 8,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333335,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "subgraph-idle",
"durationMs": 2021.269000000018,
"styleRecalcs": 11,
"styleRecalcDurationMs": 9.306,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 509.065,
"heapDeltaBytes": -22117680,
"heapUsedBytes": 45732420,
"domNodes": 22,
"jsHeapTotalBytes": 20701184,
"scriptDurationMs": 14.878000000000002,
"eventListeners": 4,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "subgraph-idle",
"durationMs": 2039.4890000000032,
"styleRecalcs": 9,
"styleRecalcDurationMs": 7.568000000000001,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 518.965,
"heapDeltaBytes": -21859016,
"heapUsedBytes": 46374884,
"domNodes": -279,
"jsHeapTotalBytes": 20963328,
"scriptDurationMs": 16.831000000000007,
"eventListeners": -148,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333332,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "subgraph-mouse-sweep",
"durationMs": 1737.2589999999946,
"styleRecalcs": 76,
"styleRecalcDurationMs": 36.027,
"layouts": 16,
"layoutDurationMs": 4.283,
"taskDurationMs": 821.5409999999999,
"heapDeltaBytes": -21055028,
"heapUsedBytes": 46912016,
"domNodes": -281,
"jsHeapTotalBytes": 20701184,
"scriptDurationMs": 91.483,
"eventListeners": -148,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333332,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "subgraph-mouse-sweep",
"durationMs": 1728.4270000000106,
"styleRecalcs": 79,
"styleRecalcDurationMs": 38.694,
"layouts": 16,
"layoutDurationMs": 4.63,
"taskDurationMs": 820.1700000000001,
"heapDeltaBytes": -21867040,
"heapUsedBytes": 46029916,
"domNodes": 30,
"jsHeapTotalBytes": 20439040,
"scriptDurationMs": 94.51799999999999,
"eventListeners": -148,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "subgraph-transition-enter",
"durationMs": 1384.9119999999857,
"styleRecalcs": 18,
"styleRecalcDurationMs": 28.899000000000008,
"layouts": 14,
"layoutDurationMs": 11.619000000000002,
"taskDurationMs": 951.5930000000002,
"heapDeltaBytes": 31093496,
"heapUsedBytes": 99558328,
"domNodes": 13673,
"jsHeapTotalBytes": 14942208,
"scriptDurationMs": 35.891999999999996,
"eventListeners": 2371,
"totalBlockingTimeMs": 119,
"frameDurationMs": 16.670000000000012,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "viewport-pan-sweep",
"durationMs": 8185.944000000007,
"styleRecalcs": 250,
"styleRecalcDurationMs": 42.403,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 4475.282,
"heapDeltaBytes": 10739248,
"heapUsedBytes": 69858364,
"domNodes": -283,
"jsHeapTotalBytes": 7069696,
"scriptDurationMs": 1332.104,
"eventListeners": -128,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333338,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "viewport-pan-sweep",
"durationMs": 8253.021999999986,
"styleRecalcs": 249,
"styleRecalcDurationMs": 40.248,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 4426.8640000000005,
"heapDeltaBytes": 10498972,
"heapUsedBytes": 69498100,
"domNodes": -282,
"jsHeapTotalBytes": 7593984,
"scriptDurationMs": 1329.2350000000001,
"eventListeners": -128,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "vue-large-graph-idle",
"durationMs": 17805.060999999965,
"styleRecalcs": 0,
"styleRecalcDurationMs": 0,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 17750.596,
"heapDeltaBytes": -54763820,
"heapUsedBytes": 167374732,
"domNodes": -8312,
"jsHeapTotalBytes": -13312000,
"scriptDurationMs": 608.7589999999999,
"eventListeners": -16387,
"totalBlockingTimeMs": 0,
"frameDurationMs": 17.776666666666763,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "vue-large-graph-idle",
"durationMs": 17640.61700000002,
"styleRecalcs": 0,
"styleRecalcDurationMs": 0,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 17618.163,
"heapDeltaBytes": -34088528,
"heapUsedBytes": 181319692,
"domNodes": -8312,
"jsHeapTotalBytes": -14360576,
"scriptDurationMs": 565.939,
"eventListeners": -16385,
"totalBlockingTimeMs": 0,
"frameDurationMs": 17.77333333333336,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "vue-large-graph-pan",
"durationMs": 21503.59500000002,
"styleRecalcs": 148,
"styleRecalcDurationMs": 21.08199999999999,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 21467.649999999998,
"heapDeltaBytes": -80334456,
"heapUsedBytes": 150583320,
"domNodes": -8312,
"jsHeapTotalBytes": -15671296,
"scriptDurationMs": 969.9579999999999,
"eventListeners": -16381,
"totalBlockingTimeMs": 385,
"frameDurationMs": 18.333333333333332,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "vue-large-graph-pan",
"durationMs": 21340.538000000037,
"styleRecalcs": 143,
"styleRecalcDurationMs": 19.65800000000001,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 21295.602,
"heapDeltaBytes": -25235628,
"heapUsedBytes": 181995428,
"domNodes": -8312,
"jsHeapTotalBytes": -11542528,
"scriptDurationMs": 903.029,
"eventListeners": -16385,
"totalBlockingTimeMs": 373,
"frameDurationMs": 17.780000000000047,
"p95FrameDurationMs": 16.80000000000291
},
{
"name": "workflow-execution",
"durationMs": 473.1830000000059,
"styleRecalcs": 14,
"styleRecalcDurationMs": 19.910999999999998,
"layouts": 3,
"layoutDurationMs": 0.7010000000000001,
"taskDurationMs": 120.39199999999998,
"heapDeltaBytes": -15981688,
"heapUsedBytes": 51027204,
"domNodes": 132,
"jsHeapTotalBytes": 7729152,
"scriptDurationMs": 12.068000000000001,
"eventListeners": 67,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333332,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "workflow-execution",
"durationMs": 154.11100000005717,
"styleRecalcs": 10,
"styleRecalcDurationMs": 18.831,
"layouts": 7,
"layoutDurationMs": 1.9409999999999998,
"taskDurationMs": 97.333,
"heapDeltaBytes": 2850004,
"heapUsedBytes": 69930328,
"domNodes": 128,
"jsHeapTotalBytes": 2883584,
"scriptDurationMs": 6.021999999999998,
"eventListeners": 17,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66666666666665,
"p95FrameDurationMs": 16.700000000000728
}
]
} |
Codecov Report✅ All modified and coverable lines are covered by tests. @@ Coverage Diff @@
## main #14472 +/- ##
=======================================
Coverage 78.24% 78.24%
=======================================
Files 1767 1767
Lines 110746 110746
Branches 35063 35063
=======================================
Hits 86655 86655
Misses 23539 23539
Partials 552 552
Flags with carried forward coverage won't be shown. Click here to find out more. 🚀 New features to boost your workflow:
|
|
Ran the rule against current One is a real defect, not a lint nit
This is exactly the cost the rule exists to prevent, and it is the one I would I have not written that fix. It needs the dialog's move/resize paths Two are benign and only technically in scope
The honest description of these two is "a What that implies for the ruleThe current message — "every recompute becomes a layout read" — is accurate Either way, promoting to |
| // 'warn' rather than 'error' because four pre-existing instances remain, in | ||
| // BrushCursor.vue, WorkflowTabs.vue and SubgraphBreadcrumb.vue. Promote to | ||
| // 'error' once those are derived from stores instead. |
There was a problem hiding this comment.
Can we make it error now and fix them here, or in advance of this change?
DrJKL
left a comment
There was a problem hiding this comment.
Love this.
I would like to always introduce linters as Error instead of Warn.
I'll try to remember to update the layoutStore references after the ECS changees get in.
|
Thanks @DrJKL — enqueued. On One correction to my own note there, worth having on the record: I listed That was deliberate, and it is a genuine tension with the rule I am proposing here. A So the promotion to Noted on the |
…le part (#14472) Writes down the state-modelling conventions we keep re-explaining in review, and adds one lint rule for the part that is mechanically checkable. ### Why Reviews of anything with a beginning and an end — a wizard, an upload, an onboarding flow — keep converging on the same handful of points: several independent booleans that must agree, values stored that could be derived, and effects used to keep two pieces of internal state in sync rather than to talk to something outside the app. Each time it gets re-argued from scratch on the PR, which is slow for the reviewer and unpleasant for the author, who reasonably asks why nobody said so earlier. `docs/guidance/state-and-effects.md` is glob-loaded on `src/**/*.ts` and `src/**/*.vue`, so it applies automatically rather than needing to be found. ### What it says One state value as a discriminated union instead of N booleans, named events rather than assignment scattered across call sites, a pure transition function so one place owns the rules, `computed` for anything derivable, and effects reserved for synchronising outward. Plus the Vue mapping — state to Pinia, transition to a pure function, derivation to `computed`, synchronisation to `watch`. It deliberately does **not** recommend a state-machine library. There is no XState or `createMachine` precedent anywhere in `src/`, and a hand-rolled union with a pure `reduce` gets the same guarantees without the dependency. ### The lint rule `no-restricted-syntax` flagging `getBoundingClientRect`, `getComputedStyle` and `querySelector*` inside a `computed`. A derivation that measures the DOM runs a layout read on every recompute and cannot be unit-tested without a browser. It ships at **`warn`, not `error`**, because there are exactly four pre-existing instances and this PR does not fix them: | File | Count | | ---- | ----- | | `src/components/maskeditor/BrushCursor.vue` | 2 | | `src/components/breadcrumb/SubgraphBreadcrumb.vue` | 1 | | `src/components/topbar/WorkflowTabs.vue` | 1 | Promote to `error` once those derive from stores instead. I verified the count against `main` at the current head rather than trusting the number I first wrote. ### Scope Docs and lint config only — no runtime code, no behaviour change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Writes down the state-modelling conventions we keep re-explaining in review, and
adds one lint rule for the part that is mechanically checkable.
Why
Reviews of anything with a beginning and an end — a wizard, an upload, an
onboarding flow — keep converging on the same handful of points: several
independent booleans that must agree, values stored that could be derived, and
effects used to keep two pieces of internal state in sync rather than to talk to
something outside the app. Each time it gets re-argued from scratch on the PR,
which is slow for the reviewer and unpleasant for the author, who reasonably
asks why nobody said so earlier.
docs/guidance/state-and-effects.mdis glob-loaded onsrc/**/*.tsandsrc/**/*.vue, so it applies automatically rather than needing to be found.What it says
One state value as a discriminated union instead of N booleans, named events
rather than assignment scattered across call sites, a pure transition function so
one place owns the rules,
computedfor anything derivable, and effects reservedfor synchronising outward. Plus the Vue mapping — state to Pinia, transition to a
pure function, derivation to
computed, synchronisation towatch.It deliberately does not recommend a state-machine library. There is no
XState or
createMachineprecedent anywhere insrc/, and a hand-rolled unionwith a pure
reducegets the same guarantees without the dependency.The lint rule
no-restricted-syntaxflagginggetBoundingClientRect,getComputedStyleandquerySelector*inside acomputed. A derivation that measures the DOM runs alayout read on every recompute and cannot be unit-tested without a browser.
It ships at
warn, noterror, because there are exactly four pre-existinginstances and this PR does not fix them:
src/components/maskeditor/BrushCursor.vuesrc/components/breadcrumb/SubgraphBreadcrumb.vuesrc/components/topbar/WorkflowTabs.vuePromote to
erroronce those derive from stores instead. I verified the countagainst
mainat the current head rather than trusting the number I first wrote.Scope
Docs and lint config only — no runtime code, no behaviour change.
🤖 Generated with Claude Code