Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
867c8ec
feat(presence): live cursor tracking in P2P Tic-Tac-Toe + usePointerO…
krisnye May 10, 2026
b95b5b2
fix(data-persistence): remove duplicate PersistenceMount export in wo…
krisnye May 10, 2026
42b2340
refactor(p2p-tictactoe): rewrite as standard Lit element application
krisnye May 10, 2026
e732884
fix(p2p-tictactoe): P2pElement extends DatabaseElement from @adobe/da…
krisnye May 10, 2026
b4ea970
refactor(p2p-tictactoe): move all app state into ECS, eliminate Phase…
krisnye May 10, 2026
8bdb7fe
fix state pattern to be more standard
krisnye May 10, 2026
5487d36
sync api improvements
krisnye May 10, 2026
9e6e028
refactored to simplify public API related to syncing
krisnye May 10, 2026
776dc75
improved ttt structure
krisnye May 10, 2026
433c298
feat: userId in TransactionContext, no-op suppression, generic P2P ne…
krisnye May 10, 2026
68251a4
refactor(tictactoe): centralize PlayerMark identity behind its namespace
krisnye May 10, 2026
9049cca
refactor(data): make ToTransactionFunctions an overload, drop call-si…
krisnye May 10, 2026
6a9ec63
docs(rules): add type-casts rule — never use `as` to silence type errors
krisnye May 10, 2026
5d2e251
docs(rules): add element and presentation rules (framework-agnostic)
krisnye May 10, 2026
8b661df
refactored to lazy element pattern
krisnye May 10, 2026
7ca37cd
docs(rules): drop unlocalized export from presentation rule
krisnye May 10, 2026
1e3d045
docs(rules): broaden namespace paths to all package src
krisnye May 10, 2026
4e4a156
feat(p2p): reconnect, keep-alive, ICE restart, score tracking, and pr…
krisnye May 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .claude/rules/cohesion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
paths:
- 'packages/**/*.ts'
- 'packages/**/*.tsx'
---

# Cohesion — make related code share a name

Code that serves one purpose should be reachable through one name.
Cohesion that exists only in the reader's head is a smell — make it
visible by extracting a function, a hook, or a folder.

## Within a file (statement cohesion)

When adjacent statements share a single purpose — same dependencies,
same lifetime, same conceptual job — collapse them behind one named
local helper. The surrounding scope should read at one level of
abstraction; the lower-level mechanics live inside the helper.

This is *Composed Method* / *SLAP*. The fix is local: a private
function or local hook in the same file, no public API, no claim of
reusability. Naming makes the cohesion structural instead of leaving
it for the reader to assemble.

```ts
// Smell: two statements that are one concept ("owned controller")
const controller = useMemo(() => createController(svc, cfg), [svc, cfg]);
useEffect(() => () => controller.dispose(), [controller]);

// Fix: cohesion has a name
const controller = useDisposableController(svc, cfg);
```

## Across files (folder cohesion)

One public export per file. Private declarations inside the file are
fine — they are implementation detail and are tested indirectly
through the public export. Only the public export gets a sibling
`*.test.ts`.

When a public file wants *helper sibling files*, promote the cluster
into a folder named after the concept. Helpers never sit at peer
level with unrelated public files; the folder is the new boundary.

```
ok smell fix
foo.ts foo.ts foo/
foo.test.ts foo-helper.ts foo.ts
bar.ts foo.test.ts foo-helper.ts
bar.test.ts bar.ts foo.test.ts
bar.test.ts bar.ts
bar.test.ts
```

## Heuristic

For any pair of symbols — two statements, two files — ask: "is their
relationship visible in the structure, or only in the reader's
head?" If the latter, give the cohesion a name: a function, a hook,
or a folder.
71 changes: 71 additions & 0 deletions .claude/rules/data-modelling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
---

# Data modelling — locality of knowledge

A type's identity (its members) is named only inside its own folder.
Every other module either accepts members as parameters, narrows broader
input with the type's `is` guard, or iterates `Type.values`.

This applies to any closed set: enums, role tags, status codes,
discriminated unions.

## Example

```ts
// types/http-method/schema.ts
export const schema = { type: "string", enum: ["GET", "POST", "PUT", "DELETE"] }
as const satisfies Schema;

// In an unrelated request-counter plugin:

// ❌ Members spelled outside the type folder
const counts = { GET: 0, POST: 0, PUT: 0, DELETE: 0 };
function record(m: string) {
if (m === "GET" || m === "POST" || m === "PUT" || m === "DELETE") counts[m]++;
}

// ✅ HttpMethod owns its identity; this file owns the counter
const counts: Partial<Record<HttpMethod, number>> = {};
function record(m: unknown) {
if (!HttpMethod.is(m)) return;
counts[m] = (counts[m] ?? 0) + 1;
}
```

## Shape of keyed collections

- `Record<EnumKey, T>` — every key required at all times. Default lists
every member. Use only when state is genuinely dense.
- `Partial<Record<EnumKey, T>>` — keys appear and disappear over the
lifecycle. Default `{}`. Use for sparse / per-actor state.

If the value can be meaningfully absent for a present member (a peer
exists but hasn't moved yet, an entry hasn't loaded), use `Partial`.
Reserve `Record` for descriptors where every member must have a value.

Sibling fields (`{ countGET, countPOST, countPUT, countDELETE }`) duplicate
the type's identity into field names — always replace with a keyed
collection.

## Per-member variation lives with the type

When something *genuinely differs* per member (colours, labels, icons),
the descriptor lives in the type's folder as a `Record<EnumKey, V>`,
named for its purpose:

```ts
// types/http-method/method-color.ts
export const methodColor: Record<HttpMethod, string> = {
GET: "#06f", POST: "#0a0", PUT: "#fa0", DELETE: "#f00",
};
```

A per-member string built at the call site (`"req-${method.toLowerCase()}"`,
`<HttpMethod>` BEM modifiers, SVG ids) is the same leak in derivation form:
the *mapping rule* belongs in the type's folder, not at the call site.

A static stylesheet that lists `.row--get { … } .row--post { … } …` is
the same leak in CSS form. Drive per-member visuals from the descriptor
(inline `style`, a CSS custom property, or a generated stylesheet) so
the enum is not re-encoded at the rendering layer.
89 changes: 89 additions & 0 deletions .claude/rules/element.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
---
paths:
- 'packages/**/*-element.ts'
---

# Container element authoring

A *container element* is a UI element whose only job is to bridge an
ECS / data service to a pure presentation function: subscribe to state,
hand it down, wire actions back up. It owns no business logic.

This rule is framework-agnostic. The framework-specific bindings are in
the table at the bottom.

> **Out of scope:** sibling `*presentation.ts`, `*.css.ts`, `*.test.ts`,
> and `*.stories.ts` files in the same folder are governed by their own
> rules. Pure UI primitives that take all of their inputs through props
> (no service subscription) are not container elements either.

## The discipline

A container element fits on screen. Its body contains:

1. Optional styles assignment.
2. Zero or more locator-shaped inputs (`entityId`, `userId`, `route`)
that identify *where* to load this instance's data.
3. One subscription read of the bound data service.
4. Action callbacks — one-line invocations of service transactions or
actions, never logic.
5. One delegation to the matching presentation function.

That is the entire surface. No internal state, no event handlers, no
lifecycle overrides, no derivation math, no timers, no event emitters,
no broadcast channels, no synchronous data reads.

### Bootstrap exception

A *bootstrap container* — one that constructs its service rather than
consuming a pre-existing one (e.g. an element that owns a WebRTC
handshake before mounting a child app) — may also accept dependency-
injection props that configure that construction (the plugin to use,
a child tag name, mapping functions). Those props feed a sibling
controller invoked from a `useMemo` / `useEffect` hook; they never
appear inside render branching. Use sparingly.

## Subscription reads must be raw

Each value in the subscription block is a direct read of a service
observable or computed — no `.map`, `.filter`, no inline composition.
If derivation is needed, it lives as a service computed; if used only
by this element, define the computed in a sibling file and import it.

## Action callbacks are one-liners

Each callback assembles arguments and invokes one service action or
transaction. No clamping, normalisation, branching, or shape-building.
Logic belongs in the action / transaction layer.

## Lifecycle goes through hooks

Mount / unmount / update behaviour routes through a hook abstraction
(`useEffect`, `useFocus`, etc.) called inside the render body — never
through a framework lifecycle method (`connectedCallback`, `componentDidMount`,
`onMount`).

## Action returns are not consumed

Actions are fire-and-forget. If a caller needs a result, that's a
transaction the binding invokes directly, *or* a new observable to
subscribe to — never a return value awaited in render.

## Deletion test

Strip the file to its essentials. What remains should be: optional
styles, locator props, one subscription call, callbacks, and one render
delegation. Anything else is a violation — find the missing computed,
transaction, or hook.

## Framework bindings

| Framework | Container is | Subscription hook | Locator inputs | Render delegation |
| --------- | ------------------------ | --------------------- | -------------------- | ------------------------------------------------ |
| Lit | `DatabaseElement<P>` | `useObservableValues` | `@property` only | `presentation.render({ ...values, ...callbacks })` |
| React | function component | `useObservableValues` | function arguments | `<Presentation {...values} {...callbacks} />` |
| Solid | component function | framework-specific | function arguments | `<Presentation {...values} {...callbacks} />` |

Lit-only: `static styles = styles` from a sibling `*.css.ts`. `@property`
exists *only* for locator inputs — never for state, never for derived
values, never for parent-forwarded flags.
119 changes: 119 additions & 0 deletions .claude/rules/lazy-element.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
---
paths:
- 'packages/**/elements/**/*.ts'
---

# Lazy Lit element pattern

Each Lit custom element lives in its own folder and is exposed to
external code through a single lazy wrapper function. The element class
itself is loaded only on first invocation, so consumers pay no bundle
cost for elements they never render.

## Folder layout

For an element conceptually named `foo`:

```
foo/
foo.ts # PUBLIC: lazy wrapper Foo()
foo-element.ts # PRIVATE: the LitElement class
foo-presentation.ts # PRIVATE: pure render(props) function
foo.css.ts # PRIVATE: styles
```

External code only ever imports `foo.ts`. The other three are
implementation detail of the folder.

## The lazy wrapper (`foo.ts`)

A PascalCase function that returns an `html` template fragment. It
fires a dynamic `import()` of the element file on every call (the
import promise is intentionally unawaited — the browser deduplicates
and Lit upgrades the unknown element once the class registers).

```ts
import { html } from "lit";
import type { TemplateResult } from "lit";

export const Foo = (args: { count: number }): TemplateResult => {
void import("./foo-element.js");
return html`<my-foo .count=${args.count}></my-foo>`;
};
```

Rules:

- Exactly one public export: the wrapper function.
- Argument shape is a single typed object (or omitted entirely).
- The custom-element tag is hardcoded as a literal — no shared `tagName`
symbol crosses the wrapper / element boundary.
- The wrapper passes typed args via `.prop=${args.prop}` bindings, never
attribute strings.

## The element file (`foo-element.ts`)

```ts
import { customElement } from "lit/decorators.js";
import * as presentation from "./foo-presentation.js";
import { styles } from "./foo.css.js";

const tagName = "my-foo";

declare global {
interface HTMLElementTagNameMap {
[tagName]: FooElement;
}
}

@customElement(tagName)
export class FooElement extends LitElement {
static styles = styles;
render() { return presentation.render({ /* … */ }); }
}
```

- `tagName` is a private const; the global `HTMLElementTagNameMap`
declaration uses it so the wrapper's `html\`<my-foo></my-foo>\``
template is type-checked.
- One public export: the class.
- Side-effect imports of *other* element files are forbidden — invoke
their lazy wrappers from inside the presentation instead, so children
are tree-shaken too.

## Composition: render children via wrappers

Inside a presentation, render child elements by calling their lazy
wrappers, not by writing their tag literals or by importing their
classes. This is what keeps the lazy graph honest end-to-end.

```ts
import { html } from "lit";
import { Bar } from "../bar/bar.js";

export const render = (props: { … }) => html`
<section>${Bar({ kind: props.kind })}</section>
`;
```

## Dynamic-tag rendering

Do not use `lit/static-html` / `unsafeStatic` to switch between
elements. Take a render-callback prop instead:

```ts
@property({ attribute: false })
renderChild!: (args: { service: SomeDb }) => TemplateResult;

// caller passes:
.renderChild=${({ service }) => Bar({ service })}
```

This keeps the child's lazy wrapper as the single point of contact and
preserves type checking across the boundary.

## Entry-point mounting

Even the top-level element is mounted through its lazy wrapper. A
`main.ts` calls `render(Foo(args), document.getElementById("app")!)` —
no side-effect import, no `document.createElement`.
50 changes: 50 additions & 0 deletions .claude/rules/namespace.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
paths:
- 'packages/**/src/**/*.ts'
---

# Type namespace pattern

Each type lives in its own folder. The folder's eponymous file is the only
public import surface; a `<Type>` namespace re-exports every helper.

```
src/types/<type-name>/
<type-name>.ts # type alias + `export * as <Type> from "./public.js"`
schema.ts # `export const schema = { ... } as const satisfies Schema`
public.ts # re-exports every public helper
<helper>.ts # one declaration per file, camelCase
```

## Example

```ts
// types/log-level/schema.ts
export const schema = { type: "string", enum: ["debug", "info", "warn", "error"] }
as const satisfies Schema;

// types/log-level/log-level.ts
import { Schema } from "@adobe/data/schema";
import { schema } from "./schema.js";
export type LogLevel = Schema.ToType<typeof schema>;
export * as LogLevel from "./public.js";

// types/log-level/public.ts
export { schema } from "./schema.js";
export { is } from "./is.js"; // when external code narrows broader input
export { values } from "./values.js"; // when external code iterates the set
```

Consumers import only `LogLevel` from `log-level/log-level.ts` and use it
as both type and namespace (`LogLevel.is(x)`, `LogLevel.values`).

## Rules

- Never import `schema.ts` or `public.ts` from outside the folder.
- Consumers use one import: `import { LogLevel } from "./log-level.js"`.
The same identifier serves as type *and* namespace — a separate
`import type` is unnecessary and produces a duplicate-identifier error.
- Helper files use `import type` from the sibling `<type-name>.ts` to
avoid cycling through `public.ts`.
- Add `is` / `values` / per-member descriptors only when an external
consumer actually needs them — not preemptively.
Loading