@adobe/data-ai 0.9.86 → 0.9.87
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/rules/cohesion.md +2 -2
- package/.claude/rules/data-modelling.md +2 -0
- package/.claude/rules/ecs.md +275 -0
- package/.claude/rules/element.md +3 -1
- package/.claude/rules/features/ecs/systems.md +57 -0
- package/.claude/rules/features/ui/binding-element.md +226 -0
- package/.claude/rules/features/ui/lazy-wrapper.md +27 -0
- package/.claude/rules/features/ui/presentation-test.md +38 -0
- package/.claude/rules/{presentation.md → features/ui/presentation.md} +2 -32
- package/.claude/rules/function-references.md +2 -2
- package/.claude/rules/hooks.md +57 -0
- package/.claude/rules/layout.md +132 -0
- package/.claude/rules/lazy-element.md +1 -1
- package/.claude/rules/lit.md +115 -0
- package/.claude/rules/namespace.md +93 -135
- package/.claude/rules/observe.md +183 -0
- package/.claude/rules/react.md +102 -0
- package/.claude/rules/service.md +164 -0
- package/.claude/rules/type-casts.md +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +2 -2
- package/package.json +3 -3
- package/.claude/rules/archetypes.md +0 -60
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- '**/*-plugin.ts'
|
|
4
|
+
- '**/*-plugin/**/*.ts'
|
|
5
|
+
- '**/*-database.ts'
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Database.Plugin authoring
|
|
9
|
+
|
|
10
|
+
Plugins are created with `Database.Plugin.create()` from `@adobe/data/ecs`.
|
|
11
|
+
|
|
12
|
+
## Property order (enforced at runtime)
|
|
13
|
+
|
|
14
|
+
Properties **must** appear in this exact order. All are optional.
|
|
15
|
+
|
|
16
|
+
| # | Property | Signature | Purpose |
|
|
17
|
+
| --- | -------------- | -------------------------------------- | ----------------------------------------- |
|
|
18
|
+
| 1 | `imports` | `Plugin` | Dep plugin: types usable, NOT re-exported |
|
|
19
|
+
| 2 | `extends` | `Plugin` | Base plugin to extend (types re-exported) |
|
|
20
|
+
| 3 | `services` | `(db) => ServiceInstance` | Singleton service factories |
|
|
21
|
+
| 4 | `components` | schema object | ECS component schemas |
|
|
22
|
+
| 5 | `resources` | `{ default: value as Type }` | Global resource schemas |
|
|
23
|
+
| 6 | `archetypes` | `['comp1', 'comp2']` | Standard ECS archetypes |
|
|
24
|
+
| 7 | `indexes` | `{ key, order?, unique?, archetype? }` | Sorted/filtered entity indexes |
|
|
25
|
+
| 8 | `computed` | `(db) => Observe<T>` | Computed observables |
|
|
26
|
+
| 9 | `transactions` | `(store, payload) => void` | Synchronous atomic mutations |
|
|
27
|
+
| 10 | `actions` | `(db, payload) => T` | General functions |
|
|
28
|
+
| 11 | `systems` | `{ create: (db) => fn \| void }` | Per-frame (60fps) or init-only |
|
|
29
|
+
|
|
30
|
+
**Wrong order throws at runtime.**
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Composition
|
|
35
|
+
|
|
36
|
+
**Single extension:**
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
export const authPlugin = Database.Plugin.create({
|
|
40
|
+
extends: environmentPlugin,
|
|
41
|
+
services: {
|
|
42
|
+
auth: db => AuthService.createLazy({ services: db.services }),
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**Combine** — `extends` accepts only one plugin. For multiple, use `Database.Plugin.combine`:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
export const generationPlugin = Database.Plugin.create({
|
|
51
|
+
extends: Database.Plugin.combine(aPlugin, bPlugin),
|
|
52
|
+
computed: { ... },
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**`imports` vs `extends`** — both make the dep's declarations usable in this plugin's factory bodies and merge it at runtime. The difference
|
|
57
|
+
is the result type: `extends` re-exports the dep's types into this plugin's type; `imports` does NOT. Use `imports` for a dep whose types
|
|
58
|
+
you don't want propagating downstream — it keeps the result type O(local), so the dep's type params (e.g. an index `IX`) never accumulate
|
|
59
|
+
through a deep `combine` chain (the TS2589 fix). The runtime merge means no separate top-level `combine` is needed for the imported plugin.
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
export const consumerPlugin = Database.Plugin.create({
|
|
63
|
+
imports: indexesPlugin, // db.indexes usable here + registered at runtime; IX not re-exported
|
|
64
|
+
extends: corePlugin,
|
|
65
|
+
computed: { ... },
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**Final composition:**
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
export const appPlugin = Database.Plugin.combine(corePlugin, themePlugin, dataPlugin, authPlugin, uiPlugin, featurePlugin);
|
|
73
|
+
export type AppPlugin = typeof appPlugin;
|
|
74
|
+
export type AppDatabase = Database.Plugin.ToDatabase<AppPlugin>;
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Data modeling example
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import { Database } from '@adobe/data/ecs';
|
|
83
|
+
import { Vec3, Vec4, F32 } from '@adobe/data/math';
|
|
84
|
+
|
|
85
|
+
const particleDataPlugin = Database.Plugin.create({
|
|
86
|
+
components: {
|
|
87
|
+
position: Vec3.schema,
|
|
88
|
+
velocity: Vec3.schema,
|
|
89
|
+
color: Vec4.schema,
|
|
90
|
+
mass: F32.schema,
|
|
91
|
+
},
|
|
92
|
+
resources: {
|
|
93
|
+
gravity: { default: 9.8 as number },
|
|
94
|
+
},
|
|
95
|
+
archetypes: {
|
|
96
|
+
Particle: ['position', 'velocity', 'color', 'mass'],
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
- **Components**: per-entity data. Use schema imports (`Vec3`, `Vec4`, `F32` from `@adobe/data/math`) or type namespaces for custom shapes.
|
|
102
|
+
- **Resources**: global state. Use **only** `{ default: value as Type }`.
|
|
103
|
+
- **Archetypes**: one per entity kind. List all components that kind requires.
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## Property details
|
|
108
|
+
|
|
109
|
+
### components
|
|
110
|
+
|
|
111
|
+
Non-persistable values (e.g. HTML elements, DOM refs) must use `ephemeral: true` on the schema.
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
components: {
|
|
115
|
+
layout: Layout.schema,
|
|
116
|
+
layoutElement: { default: null as unknown as HTMLElement, ephemeral: true },
|
|
117
|
+
},
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### resources
|
|
121
|
+
|
|
122
|
+
Use `as Type` to provide the compile-time type. Use `null as unknown as Type` for resources initialized later in a system initializer.
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
resources: {
|
|
126
|
+
themeColor: { default: 'dark' as ThemeColor },
|
|
127
|
+
connection: { default: null as unknown as WebSocket },
|
|
128
|
+
},
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### computed
|
|
132
|
+
|
|
133
|
+
Factory returning `Observe<T>` or `(...args) => Observe<T>`. Receives full db.
|
|
134
|
+
|
|
135
|
+
### transactions
|
|
136
|
+
|
|
137
|
+
Synchronous, deterministic atomic mutations. Receive `store` and a payload.
|
|
138
|
+
|
|
139
|
+
- `store.update(entity, data)` — update entity components
|
|
140
|
+
- `store.resources.x = value` — mutate resources
|
|
141
|
+
- `store.get(entity, 'component')` — read component value
|
|
142
|
+
- `store.select(archetype.components, { where })` — query entities
|
|
143
|
+
|
|
144
|
+
### actions
|
|
145
|
+
|
|
146
|
+
UI components that call actions must never consume returned values — see `.claude/rules/binding-element.md` (Actions are fire-and-forget).
|
|
147
|
+
|
|
148
|
+
**At most one transaction per action.** Multiple transactions in a single action corrupt the undo/redo stack.
|
|
149
|
+
|
|
150
|
+
### systems
|
|
151
|
+
|
|
152
|
+
`create` receives db and may optionally return a per-frame function (60fps) or just initialize values.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## Naming conventions
|
|
157
|
+
|
|
158
|
+
| Item | Convention | Example |
|
|
159
|
+
| ----------- | ----------------------------------------------------- | ---------------------------- |
|
|
160
|
+
| File | `*-plugin.ts` (kebab-case) | `layout-plugin.ts` |
|
|
161
|
+
| Export | `*Plugin` (camelCase) | `layoutPlugin` |
|
|
162
|
+
| System | `plugin_name__system` (snake_case, double underscore) | `layout_plugin__system` |
|
|
163
|
+
| Init system | `plugin_name_initialize` | `ui_state_plugin_initialize` |
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Type utilities
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
export type MyDatabase = Database.Plugin.ToDatabase<typeof myPlugin>;
|
|
171
|
+
export type MyStore = Database.Plugin.ToStore<typeof myPlugin>;
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## State minimalism
|
|
177
|
+
|
|
178
|
+
Before adding any `resource`, `component`, or `computed` to a plugin, identify at least one concrete subscriber that reads it. State that
|
|
179
|
+
nothing subscribes to is dead state — it occupies schema space, complicates serialization, and creates false surface area for future
|
|
180
|
+
maintainers.
|
|
181
|
+
|
|
182
|
+
**Before adding state, ask:**
|
|
183
|
+
|
|
184
|
+
1. Which UI component, computed, or action reads this?
|
|
185
|
+
2. Can a direct function call replace this entirely (avoiding a transaction that writes state only to read it back immediately)?
|
|
186
|
+
3. Is this "tracking in case it's needed" — if yes, do not add it.
|
|
187
|
+
|
|
188
|
+
**Computed vs. type utility:**
|
|
189
|
+
|
|
190
|
+
When a derivation is a pure function of a single typed value with no observable subscription needed, prefer a **type utility function** over
|
|
191
|
+
a computed state:
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
// WRONG — computed state when a type utility suffices
|
|
195
|
+
computed: {
|
|
196
|
+
hasMinimumDuration: db => Observe.withMapData([db.resources.clip], clip => clip.duration >= 8);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// CORRECT — type utility; zero subscription overhead, testable in pure unit tests
|
|
200
|
+
// types/clip/clip.ts
|
|
201
|
+
export namespace Clip {
|
|
202
|
+
export const hasMinimumDuration = (clip: Clip): boolean => clip.duration >= 8;
|
|
203
|
+
}
|
|
204
|
+
// Usage: Clip.hasMinimumDuration(clip) — no observable, no subscription
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Use a computed only when the result must be observable (i.e. UI needs to react when the underlying data changes). Use a type utility when
|
|
208
|
+
the derivation is needed inline and does not require reactivity.
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## Type safety
|
|
213
|
+
|
|
214
|
+
Plugin code must be **fully and honestly typed**. No `as` / `as unknown as` casts (including on `db.get()` results), no `db: any` /
|
|
215
|
+
`db: unknown` parameters, and no hand-written "surface" / "launder" types that approximate the real database or store and get cast into. We
|
|
216
|
+
hold database-plugin typing to a strict standard: if the types fight you, the fix is to model the plugin correctly (or fix the framework) —
|
|
217
|
+
never to cast around it.
|
|
218
|
+
|
|
219
|
+
### Type every `db` / `t` / `store` with its real surface
|
|
220
|
+
|
|
221
|
+
A factory, transaction, action, or system parameter must be the plugin's actual database or store type — or a type **derived** from it
|
|
222
|
+
(`Database.Plugin.ToDatabase`, `ToStore`, `Database.Archetype.RowOf`). It must reflect what the framework actually passes. Do **not**
|
|
223
|
+
declare a narrow structural subset and cast the real value into it. If the body needs services, resources, archetypes, or computeds, make
|
|
224
|
+
them genuinely present on the plugin so the parameter type already carries them:
|
|
225
|
+
|
|
226
|
+
- **`extends`** the right base so the store/db includes what you read and write — e.g. a transaction that mutates a resource archetype must
|
|
227
|
+
live on a plugin whose `extends` chain declares that archetype, or the store type won't have it.
|
|
228
|
+
- **`imports`** a dependency plugin to use its surface inside your factory bodies _without_ re-exporting its types into your result type —
|
|
229
|
+
it stays out of downstream `combine` chains and is runtime-merged + identity-deduped.
|
|
230
|
+
|
|
231
|
+
### Cross-plugin reads follow rules, not casts
|
|
232
|
+
|
|
233
|
+
- A computed factory can read its **base** plugin's resolved computeds, but not a **sibling** computed declared in the same `computed: {}`
|
|
234
|
+
block (circular). To reuse a sibling's logic, either: (a) put the shared computed on a base plugin and `extends` it; or (b) author each
|
|
235
|
+
computed as a standalone exported factory `(db) => Observe<T>` and have the consumer import that factory and call it directly
|
|
236
|
+
(`otherComputed(db)`) instead of reaching for `db.computed.other`. A direct call builds a fresh `Observe` per call — wrap the factory in
|
|
237
|
+
`memoize` from `@adobe/data/cache` (keyed on `db`) if callers must share one instance.
|
|
238
|
+
- An **inline** system/action body gets a contextually-typed `db`; a **standalone** exported system/action object does not, and cannot
|
|
239
|
+
import its own plugin's db type (circular). Prefer inline definitions where a typed `db` matters.
|
|
240
|
+
|
|
241
|
+
### Brand domain types at the schema
|
|
242
|
+
|
|
243
|
+
Model a branded value as a branded **scalar** (`number & Brand`) and brand it at the component/resource schema:
|
|
244
|
+
`{ ...numberSchema, default: x as Branded }`. The stored shape equals the runtime shape, so the column types as the brand with **zero
|
|
245
|
+
runtime cost** — reads and writes need no conversion.
|
|
246
|
+
|
|
247
|
+
### We own `@adobe/data` — fix the root, don't cast
|
|
248
|
+
|
|
249
|
+
If clean, strong typing is genuinely blocked by a framework limitation (the library's types cannot express something correctly), that is
|
|
250
|
+
**not** license to cast. Surface it as a change request to `@adobe/data` and fix it at the source, then type the plugin honestly. The repo
|
|
251
|
+
is maintained by **knye** — raise it with him.
|
|
252
|
+
|
|
253
|
+
### Casts are a last resort, never a convenience
|
|
254
|
+
|
|
255
|
+
Before any cast, you must have exhausted: the real db/store type, `extends` / `imports`, schema-level branding, and an `@adobe/data` change.
|
|
256
|
+
If a cast is still unavoidable for a runtime invariant the compiler cannot see, funnel it through **one** named, documented boundary helper
|
|
257
|
+
and back it with a compile-time drift guard (`Assert<Extends<…>>`) so it can never silently lie.
|
|
258
|
+
|
|
259
|
+
## Service registration
|
|
260
|
+
|
|
261
|
+
Services must be registered through `Database.Plugin.create({ services: { name: db => createService(db) } })`, not bolted on via
|
|
262
|
+
`Object.assign`.
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## Execute
|
|
267
|
+
|
|
268
|
+
When creating or modifying a plugin:
|
|
269
|
+
|
|
270
|
+
1. Verify property order matches the table (imports, extends, services, components, resources, archetypes, indexes, computed, transactions,
|
|
271
|
+
actions, systems).
|
|
272
|
+
2. Use `extends` for single-parent, `Database.Plugin.combine()` for multiple peers.
|
|
273
|
+
3. Ensure services only access `db.services` from extended plugins (not forward references).
|
|
274
|
+
4. Export `type *Database = Database.Plugin.ToDatabase<typeof *Plugin>` when consumers need typed db access.
|
|
275
|
+
5. Follow naming conventions for files, exports, and systems.
|
package/.claude/rules/element.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
paths:
|
|
3
3
|
- '**/features/*/ecs/**/systems/**/*.ts'
|
|
4
|
+
- '**/systems/*.ts'
|
|
4
5
|
---
|
|
5
6
|
|
|
6
7
|
# database/systems/ — the real-time tick loop
|
|
@@ -130,3 +131,59 @@ an inline body grows too large — the schedule and registration still live inli
|
|
|
130
131
|
`system-database/tick-loop.test.ts`. Test **selection/detection** logic (which
|
|
131
132
|
entities collide, which pair resolves) separately, with seeded edge-case geometries
|
|
132
133
|
— it's a system concern, not a transaction, and where subtle bugs hide.
|
|
134
|
+
|
|
135
|
+
# Archetypes — iteration rules
|
|
136
|
+
|
|
137
|
+
## Express selection in the query, not the loop
|
|
138
|
+
|
|
139
|
+
`queryArchetypes(include, { exclude })` accepts both required and excluded
|
|
140
|
+
component lists. Use them. Don't query a wider set and then skip rows or
|
|
141
|
+
archetypes with an `if`.
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
// ❌ post-filter
|
|
145
|
+
for (const arch of db.store.queryArchetypes(["position", "rotation"])) {
|
|
146
|
+
if (arch.components.has("_worldMatrix")) continue;
|
|
147
|
+
...
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ✅ declarative
|
|
151
|
+
for (const arch of db.store.queryArchetypes(
|
|
152
|
+
["position", "rotation"],
|
|
153
|
+
{ exclude: ["_worldMatrix"] },
|
|
154
|
+
)) {
|
|
155
|
+
...
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## When every row migrates out, iterate tail → head
|
|
160
|
+
|
|
161
|
+
Archetypes are densely packed. Removing or migrating a row that isn't the
|
|
162
|
+
last one triggers a hole-fill: the tail row is moved into the gap.
|
|
163
|
+
Iterating `0 → rowCount-1` while migrating every row pays this cost on
|
|
164
|
+
every iteration. Iterating `rowCount-1 → 0` means each removal is from
|
|
165
|
+
the tail — no shift, indices ahead of the cursor stay valid.
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
// ❌ shifts the tail into every hole, and the snapshot allocation is
|
|
169
|
+
// only there to survive the shifts.
|
|
170
|
+
const ids = [...];
|
|
171
|
+
for (let i = 0; i < arch.rowCount; i++) ids[i] = arch.columns.id.get(i);
|
|
172
|
+
for (const id of ids) db.store.update(id, { _worldMatrix: Mat4x4.identity });
|
|
173
|
+
|
|
174
|
+
// ✅ no shifts, no allocation
|
|
175
|
+
for (let i = arch.rowCount - 1; i >= 0; i--) {
|
|
176
|
+
db.store.update(arch.columns.id.get(i), { _worldMatrix: Mat4x4.identity });
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
If only *some* rows migrate (filter inside the loop), snapshot the ids
|
|
181
|
+
you'll touch — forward iteration is fine because rows you don't touch
|
|
182
|
+
stay put.
|
|
183
|
+
|
|
184
|
+
## Don't snapshot what the query already filters
|
|
185
|
+
|
|
186
|
+
A snapshot of "all entity ids in this archetype right now" is only
|
|
187
|
+
needed when forward iteration would invalidate. Reverse iteration
|
|
188
|
+
removes the need; an `exclude` clause removes the need to look at rows
|
|
189
|
+
that don't qualify in the first place.
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- 'src/**/*element*.ts'
|
|
4
|
+
- 'src/**/elements/**/*.ts'
|
|
5
|
+
- 'src/**/ui/**/*-element.ts'
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Binding elements
|
|
9
|
+
|
|
10
|
+
Binding elements are thin wires between application state and the UI — not designed for unit-testability. Non-trivial logic doesn't belong
|
|
11
|
+
here, it must be pushed into either the action/transaction layer or into presentations.
|
|
12
|
+
|
|
13
|
+
## Scope
|
|
14
|
+
|
|
15
|
+
This rule applies to **binding elements** — Lit elements that read state from a service via `useObservableValues`. If you are not using Lit then still try to adhere to a similar pattern to this with the same focus on separation of concerns and functional presentation if possible.
|
|
16
|
+
The discriminator is the base class:
|
|
17
|
+
|
|
18
|
+
- `ApplicationElement<T>` — from either `@adobe/data-lit` or `src/applications/application-element.ts`. Every app-level binding element
|
|
19
|
+
extends this (directly or transitively). Concrete app bases like `FooElement`, `VideoGenerationElement`, `ImageEditorElement`,
|
|
20
|
+
`StoryGenerationElement`, `ImageGenerationElement` are thin subclasses that bind a specific `MainService` type and inherit this contract.
|
|
21
|
+
- `DatabaseElement<T>` — the lower-level `@adobe/data-lit` base used by apps with no intermediate `*Element` layer (e.g.
|
|
22
|
+
`HelloWorldElement`).
|
|
23
|
+
|
|
24
|
+
Plain `LitElement` subclasses that expose their API through `@property` alone (shared UI primitives like `media-timeline`, `model-picker`,
|
|
25
|
+
`toast-with-image`) are **not** binding elements. They don't subscribe to a service; their props are the entire contract. Out of scope for
|
|
26
|
+
this rule.
|
|
27
|
+
|
|
28
|
+
Presentation files (`*-presentation.ts`), CSS files (`*.css.ts`), tests (`*.test.ts`, `*.spec.ts`), and stories are also out of scope.
|
|
29
|
+
|
|
30
|
+
## The shape
|
|
31
|
+
|
|
32
|
+
A binding element is a thin wire between a service and a presentation. The class body contains:
|
|
33
|
+
|
|
34
|
+
1. Optional static `styles` property definition
|
|
35
|
+
2. Zero or more `@property` declarations (whitelisted below).
|
|
36
|
+
3. One `render()` method whose body is: one `useObservableValues(...)` call, another lifecycle hook call (in rare cases), a null-guard, and
|
|
37
|
+
`return presentation.render({ ...values, ...callbacks })`.
|
|
38
|
+
|
|
39
|
+
No other members. No `@state`. No private handler fields. No lifecycle methods (`connectedCallback`, `updated`, `firstUpdated`,
|
|
40
|
+
`disconnectedCallback`) except on app-entrypoint classes where the lifecycle concern is app-boot shaped but cannot be expressed as a hook
|
|
41
|
+
(see `.claude/rules/hooks.md` for hook-first lifecycle).
|
|
42
|
+
|
|
43
|
+
## `@property` whitelist
|
|
44
|
+
|
|
45
|
+
The allowed set depends on **class role**:
|
|
46
|
+
|
|
47
|
+
- **App entrypoint classes** — the (typically empty-body) subclass of `ApplicationElement<T>` that binds a specific `MainService` type.
|
|
48
|
+
Examples: `VideoGenerationElement`, `HelloWorldElement`, `TodoElement`. These accept injected services and top-level inputs from the host
|
|
49
|
+
shell.
|
|
50
|
+
- **Concrete binding elements** — classes that extend an app entrypoint (or another concrete binding) and render UI. Examples: `Hud`,
|
|
51
|
+
`FooRotationControlElement`, `CommunitySubmissionConnector`. These accept only:
|
|
52
|
+
- Entity-id-shaped locator props (`entityId`, `entityIds`, `trackId`) that identify _where_ to load this element's data from.
|
|
53
|
+
- Rare parent-forwarded flags passed straight to the presentation without branching on them (e.g. `highVolumeFlag` in `audio-view.ts`).
|
|
54
|
+
Prefer promoting the flag into a service computed.
|
|
55
|
+
|
|
56
|
+
All other values come from the service.
|
|
57
|
+
|
|
58
|
+
## Observed values must be raw reads
|
|
59
|
+
|
|
60
|
+
Every expression inside `useObservableValues` is either a direct read of a service observable (`this.service.observe.*`) or a direct read of
|
|
61
|
+
a computed (`this.service.computed.*`). No `.map`, `.filter`, `.reduce`, arithmetic, conditionals, or `Observe.withMap`-inline composition.
|
|
62
|
+
|
|
63
|
+
If derivation is needed: ECS Database apps place it in a database computed (`plugins/computed`, read back as `this.service.computed.*`);
|
|
64
|
+
non-ECS apps place it in a dependent-state service function (`dependent-state-service`). If it is used only by this element, define it
|
|
65
|
+
locally in a sibling file and import it. Computed and dependent-state must use utilities from `Obsereve.*` rather than trying to call
|
|
66
|
+
observable functions.
|
|
67
|
+
|
|
68
|
+
**Bad - doesn't use utilities from `Obsereve.*`**
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
return notify => {
|
|
72
|
+
const unobserve = oValue(value => {
|
|
73
|
+
notify(value ** 2);
|
|
74
|
+
});
|
|
75
|
+
return unobserve;
|
|
76
|
+
};
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**Good - uses utilities from `Obsereve.*`**
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
return Observe.withMap(oValue => value ** 2);
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Callbacks are one-liners
|
|
86
|
+
|
|
87
|
+
- Each callback passed to the presentation invokes a UI action with optional arguments or a transaction with optional arguments.
|
|
88
|
+
- Callbacks must not encode domain knowledge, branch on state, or build complex shapes. Logic belongs in the UI action layer or transactions
|
|
89
|
+
layer.
|
|
90
|
+
- UI action functions and transactions must be called only via `this.service`, they must never be imported directly.
|
|
91
|
+
- Never consume the return value of a UI action or transaction call.
|
|
92
|
+
- Simple argument transformations are allowed — arithmetic, negation, fixed arguments, simple ternaries that select an argument value.
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
render() {
|
|
96
|
+
return presentation.render({
|
|
97
|
+
// UI action call without parameters — good
|
|
98
|
+
saveProgress: this.service.actions.saveProgress,
|
|
99
|
+
// UI action call with fixed argument — good
|
|
100
|
+
setLandscape: () => this.service.actions.setOrientation('landscape'),
|
|
101
|
+
// UI action call with simple ternary — good
|
|
102
|
+
jump: (heroMode: boolean) => this.service.actions.move(heroMode ? 100 : 10),
|
|
103
|
+
// transaction call - good
|
|
104
|
+
play: this.service.transactions.playTrack
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## No derivation math in `render()`
|
|
110
|
+
|
|
111
|
+
**Derivation math** is forbidden in `render()`. Simple argument assembly in callbacks is the only exception — see **Action callbacks are
|
|
112
|
+
one-liners** above.
|
|
113
|
+
|
|
114
|
+
## Lifecycle goes through hooks
|
|
115
|
+
|
|
116
|
+
Any need for mount/unmount/update behavior routes through a hook. **Reuse order, one hook per file, presentation vs binding placement, and
|
|
117
|
+
testing notes:** `.claude/rules/hooks.md`.
|
|
118
|
+
|
|
119
|
+
Never inline lifecycle logic in the binding. App-entrypoint classes may override lifecycle methods when the concern is app-boot shaped
|
|
120
|
+
(service wiring, ancestor lookup); even then, prefer a hook.
|
|
121
|
+
|
|
122
|
+
Rare cases when an additional hook can be used in `render()`:
|
|
123
|
+
|
|
124
|
+
- focus on element
|
|
125
|
+
- scroll to element
|
|
126
|
+
- clean up on un-mount
|
|
127
|
+
- and other lifecycle-bound behaviour
|
|
128
|
+
|
|
129
|
+
Use `useEffect` for cleanup on unmount rather than `disconnectedCallback`:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
render() {
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
return () => this.service.actions.someCleanup();
|
|
135
|
+
}, []); // empty array → runs cleanup on unmount only
|
|
136
|
+
|
|
137
|
+
const values = useObservableValues(() => ({ ... }));
|
|
138
|
+
if (!values) return nothing;
|
|
139
|
+
return presentation.render(values);
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
In binding-elements `render()` doesn't need to be explicitly decorated with `withHooks()` because it's done implicitly in the
|
|
144
|
+
`ApplicationElement` base class.
|
|
145
|
+
|
|
146
|
+
## No timers, emitters, or broadcast channels
|
|
147
|
+
|
|
148
|
+
- Never `setInterval`, `setTimeout`, or `requestAnimationFrame`.
|
|
149
|
+
- Never `new EventEmitter`. Cross-element state flows through service observables.
|
|
150
|
+
- Never `new BroadcastChannel`. Cross-tab concerns go through a dedicated service.
|
|
151
|
+
|
|
152
|
+
## No direct database reads
|
|
153
|
+
|
|
154
|
+
Never call `this.service.get()`, `this.service.read()`, `this.service.select()`, or any synchronous accessor. The service interface does not
|
|
155
|
+
expose them by design; reaching for them means a missing computed.
|
|
156
|
+
|
|
157
|
+
## Presentation contract
|
|
158
|
+
|
|
159
|
+
A binding element imports a sibling `*-presentation.ts` file that exports exactly `render` (required) and optionally `unlocalized`. Nothing
|
|
160
|
+
else — no types, no styles, no helpers. Consumers derive the props type via `Parameters<typeof render>[0]`. Presentation stays hook-free by
|
|
161
|
+
default; element hooks live here or in shared modules per `.claude/rules/hooks.md` and `.claude/rules/presentation.md`.
|
|
162
|
+
|
|
163
|
+
**For refactors whose stated scope does not include presentations**, the matching `*-presentation.ts` file must not change. Verify with
|
|
164
|
+
`git diff --name-only | grep presentation`. If a scope-limited refactor requires a presentation signature change, stop and reconsider the
|
|
165
|
+
binding-side helper — the scope fence exists because presentations are the public contract between binding and render output.
|
|
166
|
+
|
|
167
|
+
Callback prop names use **verb** or **verbNoun** form. Never `on*` prefix. The binding-side variable matches the presentation prop name 1:1.
|
|
168
|
+
See `.claude/rules/presentation.md` for full examples.
|
|
169
|
+
|
|
170
|
+
## Size budget
|
|
171
|
+
|
|
172
|
+
A compliant binding element file is small enough that the `render()` body fits on screen without scrolling. If it doesn't, a concern has not
|
|
173
|
+
been extracted yet — find it.
|
|
174
|
+
|
|
175
|
+
## Deletion test
|
|
176
|
+
|
|
177
|
+
Delete everything except the class body. What remains should be: static styles assignment, property declarations, one
|
|
178
|
+
`useObservableValues(...)` call, another lifecycle hook call (in rare cases), a null-guard, and one render call. Anything else is a
|
|
179
|
+
violation.
|
|
180
|
+
|
|
181
|
+
## Good examples (in-tree)
|
|
182
|
+
|
|
183
|
+
- `src/applications/image-generation/elements/sub-navigation-panel/sub-navigation-panel.ts` — concrete binding element; one-liner action
|
|
184
|
+
callback
|
|
185
|
+
- `src/applications/foo/elements/properties-panel/views/audio-view.ts` — entity-id-only + parent-forwarded flag
|
|
186
|
+
- `src/applications/image-editor/elements/hud/hud.ts` — concrete binding element (extends `ImageEditorElement`); `useObservableValues` reads
|
|
187
|
+
a dependentState observable; forwards to presentation with spread.
|
|
188
|
+
- `src/applications/video-generation/elements/commerce-coachmark/commerce-coachmark.ts` — localization-only binding; trivially small and
|
|
189
|
+
compliant.
|
|
190
|
+
|
|
191
|
+
## Counter-example (do not land)
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
@customElement(tagName)
|
|
195
|
+
export class BadControlElement extends FooElement {
|
|
196
|
+
@property({ attribute: false }) entityIds: readonly Entity[] = [];
|
|
197
|
+
@state() private _cachedSum = 0; // violation: @state
|
|
198
|
+
private _handleChange?: (e: Event) => void; // violation: private handler
|
|
199
|
+
|
|
200
|
+
connectedCallback() {
|
|
201
|
+
// violation: lifecycle override
|
|
202
|
+
super.connectedCallback();
|
|
203
|
+
this._handleChange = e => (this._cachedSum += Number((e.target as HTMLInputElement).value));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
@withHooks // Redundant
|
|
207
|
+
render() {
|
|
208
|
+
const values = useObservableValues(() => ({
|
|
209
|
+
items: this.service.observe.select(/* ... */),
|
|
210
|
+
total: Observe.withMap(
|
|
211
|
+
// violation: logic in observed values
|
|
212
|
+
this.service.observe.select(/* ... */),
|
|
213
|
+
rows => rows.reduce((a, r) => a + r.duration, 0)
|
|
214
|
+
),
|
|
215
|
+
}));
|
|
216
|
+
const clamped = Math.max(0, Math.min(100, values.total)); // violation: derivation in render
|
|
217
|
+
const onSave = (v: number) => {
|
|
218
|
+
// violation: logic in callback + onX name
|
|
219
|
+
const normalized = Math.round(v * 100) / 100;
|
|
220
|
+
if (!Number.isFinite(normalized)) return;
|
|
221
|
+
this.service.transactions.updateThing({ value: normalized });
|
|
222
|
+
};
|
|
223
|
+
return presentation.render({ total: clamped, onSave });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
```
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- 'src/**/elements/**/*.ts'
|
|
4
|
+
- 'src/**/ui/**/*.ts'
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Lazy Wrapper
|
|
8
|
+
|
|
9
|
+
A lazy element wrapper is a strongly typed function that emits a lit template and dynamically loads the actual element code to ensure no loading till first real usage.
|
|
10
|
+
Consumers of this element DO NOT use the <todo-row...> markup, instead they import this lazy function and call it with `{TodoRow({...})}`.
|
|
11
|
+
|
|
12
|
+
# Example from data-lit-todo
|
|
13
|
+
|
|
14
|
+
# todo-row.ts
|
|
15
|
+
import { html, type TemplateResult } from "lit";
|
|
16
|
+
import type { Entity } from "@adobe/data/ecs";
|
|
17
|
+
|
|
18
|
+
export const TodoRow = (args: {
|
|
19
|
+
entity: Entity;
|
|
20
|
+
index: number;
|
|
21
|
+
}): TemplateResult => {
|
|
22
|
+
void import("./todo-row-element.js");
|
|
23
|
+
return html`<todo-row
|
|
24
|
+
.entity=${args.entity}
|
|
25
|
+
.index=${args.index}
|
|
26
|
+
></todo-row>`;
|
|
27
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- '**/*presentation.test.ts'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Testing
|
|
7
|
+
|
|
8
|
+
A pure `render(props)` is trivial to test — no DOM, no rendering, no
|
|
9
|
+
component lifecycle. Pass stub props (no-op callbacks) and assert on the
|
|
10
|
+
**declaration** it returns. Co-locate tests as `<name>-presentation.test.ts`.
|
|
11
|
+
|
|
12
|
+
For Lit, wrap the result with `Template.from` (from `@adobe/data-lit`) and
|
|
13
|
+
assert on the query, not on positional `.values` indices:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { describe, it, expect } from "vitest";
|
|
17
|
+
import { Template } from "@adobe/data-lit";
|
|
18
|
+
import { render } from "./todo-toolbar-presentation.js";
|
|
19
|
+
|
|
20
|
+
it("wires the Add button and shows the stats", () => {
|
|
21
|
+
const addTodo = () => {};
|
|
22
|
+
const t = Template.from(render(props({ addTodo, totalCount: 3, completedCount: 1 })));
|
|
23
|
+
expect(t.has("<sp-textfield")).toBe(true); // presence
|
|
24
|
+
expect(t.text).toContain("1 / 3 completed"); // static + primitive text
|
|
25
|
+
expect(t.values).toContain(addTodo); // callback wired (identity, not values[3])
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`Template.Query` exposes `text`, `values`, `children`, `has(fragment)`, and
|
|
30
|
+
`find(fragment)`. Prefer **identity** checks (`values` `toContain` a
|
|
31
|
+
callback / `nothing` / a bound value) over positions. To exercise a
|
|
32
|
+
callback, pull it from `values` and call it with a plain synthetic event —
|
|
33
|
+
no mocks, no DOM.
|
|
34
|
+
|
|
35
|
+
A presentation that only composes lazy child-element wrappers (or imports
|
|
36
|
+
Spectrum components) needs a `// @vitest-environment jsdom` docblock so the
|
|
37
|
+
module's custom-element side effects can load — the assertions still never
|
|
38
|
+
render or touch the DOM. Presentations of native elements run under Node.
|