Skip to content

Latest commit

 

History

History
504 lines (383 loc) · 34.9 KB

File metadata and controls

504 lines (383 loc) · 34.9 KB

Contribution surfaces

Every slot WE accepts a contribution into — what each one is for, what shape it takes, where its rules live, how it gets registered, and how it reaches other people.

This page exists because the knowledge was complete and unfindable. Every system here has a README and most have a CONVENTIONS.md, but they are organised by package, which only helps somebody who already knows the layering. A contributor — or an agent working on their behalf — arrives with an intent ("we want a different feed", "calls should be transcribable") and needs the intent routed to a slot before any of that documentation is reachable.

So this page is a router, not a manual. Each entry is a few lines and a pointer. The authoring rules stay in the CONVENTIONS.md beside the code, which is the only place they can stay correct.


Choosing a surface

The spine of every decision here is one rule, from @we/template-kit's conventions:

Code owns only what data cannot express. Behaviour and focus management, accessibility semantics, browser APIs, measurement, performance-critical rendering — that is the whole list. Everything above it is arrangement, and arrangement stays data.

Almost every "which surface?" question is that rule applied at a different altitude. Work down this list and stop at the first row that fits; the earlier rows are cheaper for everyone, and a contribution one rung too high permanently costs more than it should.

You want to… Surface Why not the next rung down
Change how one space looks Theme A template fork to change colours abandons every later improvement to the template
Change what one section of a space is View Forking a whole shell to change one page makes every upstream fix a merge conflict
Change a space's whole chrome, arrangement and route table Shell template —
Reuse an arrangement across templates Fragment A component would make it opaque to the editor and unforkable by the people using it
Use a visual element a library already ships — a chart, a rating, a map Foreign element (seed) Writing a primitive to wrap one re-implements what the library ships — see docs/getting-started/seed-system.md
Add a stateless piece of UI that needs measurement, focus or a browser API Primitive or component A fragment cannot express behaviour; this is the line the rule above draws
Computation the expression library lacks Expression function An operator would be new syntax, and the grammar is closed — see "no new value operators"
Add a new kind of content a user composes into a page Block type —
Add a stateful capability a community turns on Feature module Modules hold state and talk to ports; if yours holds neither, it is a fragment or a component
Add a new source of nodes, or a new arrangement, in a graph Graph plugin A module would rebuild the engine; expanders and layouts plug into the one that exists
Add a new kind of thing that gets stored Model —
Ship a differently-shaped deployment of WE Seed Nothing needs to be written at all — a seed selects from what exists
Run WE on data that isn't AD4M Backend adapter —

Three of these pairs come apart in ways worth knowing, because each has been got wrong at least once:

  • Component vs fragment. AvatarStack is a component (it does overlap maths); the count beside it is a fragment. we-modal is a primitive (focus trap, top layer); the confirm dialog inside it is a fragment. The question is never "how complicated is it" — it is "does it need to do something, or only be arranged a certain way".
  • Fragment vs operator. When the repetition is a value/onInput wiring pattern, ask whether it is component-agnostic first. field stayed a fragment because which event carries the value is design-system knowledge, and an operator would smuggle that table into the schema resolver.
  • Module vs everything else. A feature module is the rung above blocks: a bundle of stateful capability that installs into a space. @we/module-notes and @we/module-call contain no framework code at all — every piece of their UI is a schema fragment. If what you have is presentation, it is not a module.

And one rule that overrides all of the above: never extract speculatively. Three real uses of the same shape, or a divergence that is already a bug. Two is a coincidence. This applies to every row of the table, not just fragments.

The reuse units, on their axes. Four of the rows above — fragment, primitive, component, widget — are all "a reusable piece of UI", and their names say nothing about what separates them. Untangling them has cost real time more than once, and @we/widgets ended up as export {} because its definition never distinguished it from its neighbours. The names are kept (they are in every import path), but they are placed here on the two axes that actually decide which one a thing is:

Arrangement only (data) Needs a browser API (code)
Framework-neutral Fragment — expands to plain nodes Primitive — a Lit element
Bound to the host's framework (does not exist: data is neutral) Component — a Solid function; Widget — a component whose props are a whole feature's protocol

Read it as two questions, in order. Does it need to do something, or only be arranged? If arranged: a fragment, whatever the framework, because data is neutral by construction. If it must do something: can it be neutral? A focus trap, a top layer, measurement and keyboard handling can — that is a primitive. Only what needs the host's reactive framework in its implementation (a component that takes callbacks and renders children through it) is a component; a widget is a component large enough to own a protocol of its own (GraphView and its plugin catalogue), and lives with the feature it belongs to.

The cell that does not exist is the point of the table: nothing that is arrangement should ever be framework-bound, and if a "component" turns out to be arrangement it is a fragment written in the wrong language. For distribution the axes matter more than the names — the left column crosses the trust boundary as data; the right column merges.


The surfaces

Surface What it is Reaches people by
Themes Token overrides + CSS, as a visual identity Install at runtime · repo
Shell templates A whole space interface, as a schema Install at runtime · repo
Views One section of a space, as a schema Install at runtime · repo
Portable fragments A named arrangement that expands to plain nodes Repo
WE-domain fragments The same, allowed to name WE's stores Repo
Design tokens Spacing, colour, radius… and the semantic roles Repo
Primitives Lit web components — framework-neutral currency Repo
Components Solid layout and composite components Repo
Widgets The highest design-system layer Repo
Block types A kind of content a user composes into a page Repo
Expression functions Computation the expression library lacks Repo
Stores Shell state and actions — schema-facing API Repo
Models A kind of thing that gets stored Repo
Feature modules A stateful capability a community turns on Repo (bundled)
Graph plugins Expanders, layouts, renderers, behaviours Repo
Globe layers A layer on the globe Repo
Seeds What a deployment is Fork
Backend adapters WE over something other than AD4M Repo
Platform hosts WE on a new platform Repo

"Install at runtime" means the thing can be published and installed by a person using WE, without touching this repository. Only three surfaces can do that today — templates, themes and views — see How a contribution reaches other people for why, and for what is planned.


Data surfaces

Themes

Token overrides plus optional CSS, layered over any template. The parametric vocabulary is ThemeOverrides; a theme needing hand-written CSS is a theme the parametric system could not express, and two of the built-ins (channels, timeline) exist to find out how far it stretches.

  • Lives in packages/design-system/2-themes/src/<name>/
  • Conventions 2-themes/CONVENTIONS.md — "Adding a New Theme"
  • Copy packages/design-system/2-themes/src/cyberpunk/ (CSS + parameters) or THEME_PRESETS.channels (parameters only)
  • Register add to themeRegistry in packages/app-shell/src/shared/registries/themeRegistry.ts
  • Verify pnpm --filter @we/themes test — the contrast and sanitise suites are the real check

Shell templates

A whole space interface as a JSON node tree: chrome, arrangement, route table. meta.role absent means shell. Mark where sections go with { path: '$views' } rather than hardcoding routes, and read spaceStore.viewNav rather than writing a nav strip from a literal array — the two lists drift otherwise, and have.

  • Lives in packages/templates/showcase/src/ (or templates/default/ for the built-in space experience)
  • Conventions docs/architecture/views.md for the shell/view split; the schema reference in CLAUDE.md for everything else
  • Copy packages/templates/showcase/src/DiscordTemplate.schema.ts
  • Register add the id to we-seed.json's templates, then pnpm --filter @we/app-shell generate-templates
  • Verify pnpm validate:schemas, then pnpm --filter @we/schema-shared role-audit

Views

One section of a space rather than the whole interface — meta.role: 'view'. The unit exists because for most communities the intent is "we want a different feed", not "we want a different application", and before views the smallest forkable thing was the entire shell.

  • Lives in packages/templates/views/src/views/<Name>View/
  • Conventions docs/architecture/views.md
  • Copy packages/templates/views/src/views/AboutView/
  • Register add to CATALOGUE in packages/app-shell/scripts/generateViewRegistry.mjs, add the id to we-seed.json's views, then pnpm --filter @we/app-shell generate-views
  • Verify pnpm validate:schemas

A view's id is a stable public name — it appears in we-seed.json, in Space.enabledViews and in each agent's hidden list. Its file path is an implementation detail. That is why the catalogue is written by hand rather than discovered: deriving the id from the path would make moving a file a breaking change for every space that had turned that section off.

Portable fragments

A named, parameterised shape that expands into plain schema nodes at authoring time. The runtime never knows fragments exist — what ships is JSON indistinguishable from JSON written by hand, which is what lets a template outlive the kit it was built from.

Portable means it may not name a store. kit.test.ts reads the source to enforce this. A fragment naming spaceStore.members resolves to nothing on a deployment without that store — silently — and package.json cannot say so.

  • Lives in packages/schema-system/kit/src/
  • Conventions templates/kit/CONVENTIONS.md — governs both kits
  • Copy packages/schema-system/kit/src/states/emptyState.ts
  • Register export from src/index.ts
  • Verify pnpm --filter @we/template-kit test; update the recipe in packages/ai-context/src/fragments/patterns.ts if the expansion changed materially

WE-domain fragments

The same thing, allowed to name WE's own stores (profileStore, spaceStore, runtimeStore) and $agent. The split into two packages is the kit's honest dependency declaration.

  • Lives in packages/templates/kit/src/we/
  • Conventions templates/kit/CONVENTIONS.md
  • Copy packages/templates/kit/src/we/agentByline.ts
  • Register export from src/index.ts
  • Verify pnpm --filter @we/template-kit test

Vocabulary surfaces

These are the developer layer. Lowest volume, and load-bearing: modules define the vocabulary, and the vocabulary sets the ceiling on what every template above can express.

Design tokens

Spacing, colour, typography, radius, z-index — and the semantic role variables, which are the half that matters. A theme pins roles, not scale positions, so a value naming a step cannot hear what a theme decided and is invisible to the contrast layer entirely.

  • Lives in packages/design-system/1-tokens/src/
  • Conventions 1-tokens/CONVENTIONS.md — "Adding a New Token", and "Roles vs scale positions"
  • Register nothing; the CSS is generated and snapshot-tested
  • Verify pnpm --filter @we/tokens test, then rebuild downstream: pnpm --filter @we/tokens --filter @we/themes build

Primitives

Atomic Lit web components (we-button, we-input, …). Framework-neutral, which is what makes them the currency the schema renderer can mount in any framework without a per-framework wrapper.

  • Lives in packages/design-system/3-primitives/src/primitives/
  • Conventions 3-primitives/CONVENTIONS.md — event naming, base class, the variant/size pattern
  • Copy packages/design-system/3-primitives/src/primitives/badge.ts
  • Register nothing by hand — the custom-elements manifest is generated by pnpm --filter @we/primitives build, and @we/ai-context reads it from there
  • Verify pnpm --filter @we/primitives test, then pnpm --filter @we/ai-context generate-context so the new element reaches the component registry in CLAUDE.md

Components

Solid layout and composite components (Column, Row, Grid, Card, AvatarStack). Split as .types.ts (agnostic) + .solid.tsx (framework), so a second framework adapter implements the types rather than re-deriving them.

  • Lives in packages/design-system/4-components/src/components/<group>/
  • Conventions design-system/CONVENTIONS.md — especially "Does this deserve to be code at all?"
  • Copy packages/design-system/4-components/src/components/people/AvatarStack/
  • Register add to componentRegistry.tsx in packages/app-shell/src/frameworks/solid/registries/
  • Verify pnpm --filter @we/components test, then generate-context

Widgets

The highest design-system layer — composition above widgets is the schema system's job. Currently empty by design: its one widget was retired once @we/template-kit's rail fragments replaced it, and feature widgets live with their module family, not here. Read that as a strong prior that what you have is a fragment or a module rather than a widget.

Block types

A composable content unit a user arranges within a page — text, image, embed, code, task, event. Blocks are stored as models, so a new block type is usually a new model too.

  • Lives in packages/block-system/shared/src/ (registration, model) + frameworks/solid/src/ (input + display components)
  • Conventions block-system/CONVENTIONS.md — "Adding a New Block Type", and read "Common Mistakes" before starting
  • Copy the image block registration in packages/block-system/shared/src/core-blocks.ts
  • Register registerBlock() in core-blocks.ts; components via registerCoreBlockComponents()
  • Verify pnpm --filter @we/block-shared test and pnpm --filter @we/block-solid typecheck

Expression functions

Computation the expression language's library lacks. The grammar itself — references, operators, comprehensions — is closed and is not a contribution surface; what grows is the function library (count, filter, plural, pick, …), and every function is available to every template ever written, so removing one is breaking. Pure and total only: wrong-typed input answers with the empty value of its kind, never a throw. Three real uses before adding one, as for a component.

  • Lives in packages/schema-system/shared/src/expressions/functions.ts
  • Conventions schema-system/CONVENTIONS.md — "Adding a function to the expression library"
  • Copy plural in functions.ts
  • Register defineFunction({ name, category, params, doc, example, impl }) — the registry is the declaration; the validator, the evaluator and the generated context all read it
  • Verify pnpm --filter @we/schema-shared test, then generate-context (the library table in the schema reference comes from the registry)

A function this deployment alone needs is a host source instead — registered in packages/app-shell/src/shared/sources/index.ts, catalogued under "Host functions" in the generated context, and known to the validator from there.

Stores

State and actions the app shell holds — and, because every member is reachable from a template via an expression ({ $: 'spaceStore.members' }) or $action, schema-facing public API. Name members for template authors rather than for the code that calls them, and treat a removal as breaking.

This surface has the strictest registration on the page, and both halves fail the build rather than failing quietly, which is deliberate: a store member is vocabulary, and undeclared vocabulary is either invisible or dangerous.

  • Lives in packages/app-shell/src/frameworks/solid/stores/
  • Conventions app-shell/CONVENTIONS.md — the provider nesting order is load-bearing: a store may read stores above it, never below
  • Register classify it in templateSurface.ts — an unclassified member fails templateSurface.test.ts — and describe it in packages/ai-context/src/fragments/stores.ts, where a stale entry fails generate-context
  • Verify pnpm --filter @we/app-shell test, then generate-context

Classification is a security decision, not bookkeeping. Before the allowlist existed, every member of every store was in the bag a template rendered against — 388 of them, including runtimeStore.trustAgent, accountStore.removeAccount and the agent settings holding the API key. A template that merely painted could log you out or trust an attacker's DID. Put a new member in the narrowest group that works.

Models

A kind of thing that gets stored. The manifest is the source of truth and the decorated AD4M classes are build artifacts — edit the manifest, then run both generators.

  • Lives in packages/entities/src/manifest/
  • Conventions entities/CONVENTIONS.md, and docs/architecture/relations.md before adding any relation between two entities
  • Copy packages/entities/src/manifest/Signal.ts
  • Register pnpm --filter @we/entities generate:types and pnpm --filter @we/backend-ad4m generate:classes
  • Verify pnpm --filter @we/backend-ad4m test — coreManifest.test.ts holds the generated classes and the manifest in exhaustive agreement, so a stale generation fails there

Capability surfaces

Feature modules

A bundle of stateful capability that installs into a space: calls, notes, transcription, polls, the globe, the graph. A module is three things — a manifest (who it is, what it requires), its contributions (entities, parts, panels, blocks, views, functions, settings, …) and an optional createStore, the one piece that is code. The shell fans the contributions out to registries it already has and decides where everything renders. A module never imports the shell; the shell's registry is generated from the seed, so it never names a module by hand either.

Declare what you can, write only what a declaration cannot say. What pnpm create-module scaffolds has no store at all. A store is built from injected reactivity and only the kernels its manifest names (records, presence, media, …), and its members are private until deps.state / deps.action publish one with a sentence. A module with no framework imports cannot suffer the second-runtime problem — a bundle carrying its own copy of a reactive framework gets a second one, and reactivity silently stops crossing the boundary, with no error.

  • Lives in packages/module-system/<id>/ — or any package exporting createModule(host)
  • Conventions docs/guides/writing-a-module.md, then the contract — packages/module-system/shared/src/module.ts and kernels.ts
  • Copy pnpm create-module <id> "<Name>" scaffolds one that is declaration only; pocket and notes keep the agent's own things in their personal space; polls uses every kind of contribution; call is the one built on kernels
  • Register we-seed.json's modules (an id, or { "id", "package"?, "enabled"? }), the package in @we/app-shell's and @we/ai-context's dependencies, then pnpm --filter @we/app-shell generate-modules
  • Verify pnpm --filter @we/module-<id> test with lintModule from @we/module-testing, pnpm validate:schemas (it covers module-system/, and knows the module's members once generate-context has run)

A module needing a specific backend declares it (requires: { backends: ['ad4m'] }). Everything else stays backend-neutral through the kernels, and @coasys/* may not be imported anywhere else.

Graph plugins

An expander answers "what is adjacent to this kind of node" and the engine does the rest — dedup, expansion state, collapse, layout, rendering. Adding a new source of nodes is an expander, not a change to the core. Layouts, renderers and behaviours plug in the same way.

  • Lives in packages/graph-system/expanders/src/ (or layouts/src/)
  • Conventions graph-system/CONVENTIONS.md — read "What gets exposed" and "Invariants worth protecting" first
  • Copy packages/graph-system/expanders/src/collection.ts
  • Register export from the package index, and add an entry to GRAPH_PLUGIN_CATALOG in packages/module-system/graph/src/catalog.ts
  • Verify pnpm --filter @we/graph-core test, then generate-context

The catalog step is not optional bookkeeping. Props tell an author that layout.type is a string; nothing in a prop list says which strings exist, and a plugin nobody can name might as well not be registered. The globe was the cautionary case — its layer protocol is good, and until it had a catalog of layer names nothing reached the generated context, so no LLM could author a globe template.

Globe layers

A layer on the globe — surface layers (data, country outlines, H3 hexagons) and background layers (skybox, stars, solar system). Drawn by Cesium, and by MapLibre where a kind has a MapLibre renderer.

  • Lives in packages/module-system/globe/layers/src/planet/ or background/
  • Conventions globe/layers/README.md and its EXAMPLES.md
  • Register export from src/index.ts, add it to layerKinds in packages/module-system/globe/module/src/layers.ts and to maplibreLayerKinds in packages/module-system/globe/layers/src/maplibre/index.ts, and add an entry to GLOBE_LAYER_CATALOG in packages/module-system/globe/module/src/catalog.ts, filed under the slot its metadata declares
  • Verify pnpm --filter @we/app-shell test (globeModule.test.ts checks the catalogue against the registry), then generate-context

Foundation surfaces

Seeds

What a deployment is: which modules, templates and views ship, which apps are embedded, how the executor is wired, what the shell is white-labelled to. Nothing needs to be written — a seed selects from what exists. White-labeling a deployment is a matter of swapping the seed.

  • Lives in we-seed.json; examples in seed-examples/
  • Conventions getting-started/seed-system.md; the shape's source of truth is packages/app-shell/src/types/seed.ts
  • Verify pnpm validate:seed

Backend adapters

WE over something that isn't AD4M. Implement the ports in @we/backend-shared — DataSource + QueryAdapter, ephemeral, presence, transcription, model manifest — and register your model implementations in the entity proxy registry. Consumers never learn which backend they are on.

  • Lives in packages/backend-system/<name>/
  • Conventions backend-system/shared/README.md
  • Copy packages/backend-system/inmemory/ — the reference implementation, and how stores test without an executor
  • Verify run the shared suite, describeBackendConformance from @we/backend-conformance, against your ports — inmemory/tests/portsConformance.test.ts is a harness to copy, and ad4m/tests/live/ shows one for a backend that needs a running process

Platform hosts

WE on a new platform. A host is thin: a PlatformAdapter (where am I running) and a BackendConnector (how do I reach the data layer), over @we/app-shell.

  • Lives in apps/<name>/
  • Copy apps/we-web/ — the smallest of the three
  • Verify pnpm --filter <app> build

Verifying your work

Four checks cover most surfaces, and three of them are not widely known:

pnpm validate:schemas                          # every .schema.ts in shell, default, views, showcase, modules
pnpm --filter @we/schema-shared role-audit     # colours naming a scale position where a role belongs
pnpm --filter @we/schema-shared surface-audit  # what each surface-sunken is actually sitting on
pnpm validate:seed                             # we-seed.json against its schema

role-audit and surface-audit import and walk the composed tree, so they attribute nodes a fragment from another package contributed — which no grep over source can do. Run them after any template, view or fragment change.

Then scope the rebuild to what you touched. A full pnpm build walks the monorepo and takes minutes; reserve it for the end. But do rebuild — a stale dist is invisible and costs more time than the build saves. See "Rebuilding — scope it to what changed" in CLAUDE.md.

Do not stand up a full AD4M executor to check a UI change. Typecheck, validate, run the package's tests, and read the composed output. Most of what an executor would tell you is already asserted somewhere cheaper.


How a contribution reaches other people

This is the part to be honest about, because the gap between the design and the implementation is wide and is easy to mistake for a documentation problem.

Templates and themes are genuinely installable today. They are pure data, they are distributed as AD4M expressions, and they pass through a real trust boundary on the way in:

  • templateSurface.ts is the allowlist of what a template may name, grouped into capabilities written in the words a person would read at install time. Anything not granted is absent rather than blocked, so a hostile template gets no error channel to probe.
  • templateAcceptance.ts is the ingest gate: structurally broken schemas are refused outright, and references past the tier are admitted but reported, because a quietly half-broken template looks exactly like one that is fine.

Everything else on this page ships as code a deployment chooses. Feature modules are bundled rather than dynamically loaded. The reason used to be given as the second-runtime problem above; that is solved — createStore(deps) injects the reactivity primitives — and the reason that remains is trust: a store factory is arbitrary JavaScript with access to the kernels the host hands it, and the capability model covers what a schema may name, not what code may do. The kernels narrow that — a store reaches only what its manifest declared, and an install screen names it — but they do not sandbox it.

So: three of nineteen surfaces have a marketplace path (templates, themes, views), and a module has a deployment path: publish a package exporting createModule, and a deployment names it in its seed ({ "id": "polls", "package": "@acme/we-module-polls" }) and rebuilds. A module author no longer has to clone the monorepo; a person running a deployment still decides which code it runs.

How it changes is the subject of internal/plans/module-marketplace.md, which places every surface on this page on a distribution ladder — pure data, declared data + fragments, sandboxed embed, host-provided capability kernels, code — and says which rung each can reach and what moves it. The short version: sections, content types and (with research) modules can come down the ladder; primitives, components, plugins and stores are code and stay merge-only by decision. Nothing import()s a bundle from an expression, at any rung. When the marketplace grows a type enum, it is derived from this page's list and carries the rung, so the slots people can contribute, the slots the marketplace distributes, and what an install screen says is being trusted stay one list.


Where you're standing

WE is not a framework where developers build modules and everyone else consumes them. The widest rungs need no code at all, and the volume is inverted from what a developer expects.

Using WE, no code. Reshape your own space; build a template or theme that fits how your group actually works, in the browser, and share it. This is the highest-volume and most under-served contribution in the ecosystem. Nothing on this page is required reading.

Authoring templates and themes seriously. You want the schema reference — the Component Registry, Design Tokens and Schema Operators sections of CLAUDE.md, which are generated from the code and are always current. Then Themes, Shell templates and Views above.

Contributing to this repo. Start at docs/architecture/codebase-map.md for the layering, then the surface you want above, then that surface's CONVENTIONS.md. Branch from dev, never main. Read CONTRIBUTING.md for the workflow.

Building a deployment on WE. You mostly want Seeds — and to discover that you need fewer new surfaces than you expected, since a seed selects from what already exists.


Keeping this page honest

A hand-maintained list of surfaces drifts. packages/ai-context/src/tests/ai-context.test.ts asserts that every CONVENTIONS.md in the repo is referenced here and that every path this page names exists, so a new authoring-rules file or a moved reference example fails the test until this page catches up.

That is the same discipline the rest of the repo uses on its own documentation — templateSurface.test.ts failing on an unclassified store member, generate-context failing on a fragments/stores.ts entry that no longer resolves. A list nothing checks is a list that is wrong within two months.

The router in packages/ai-context/src/fragments/contribution-surfaces.ts is a compressed version of this page and ships in CLAUDE.md / AGENTS.md. When you add a surface here, add its row there too — it is deliberately short, so it stays a table of pointers rather than a second copy of this page.