Alpha

The alpha link type: pages authored as data, so a client can publish one without repository access and without a deploy.

Why a document, not HTML

The obvious design is to store the page's HTML. WordPress Gutenberg does exactly that, and documents the consequence: on load "the saved markup for each block is regenerated using the attributes… if the newly generated markup does not match what was already stored, the block is marked as invalid." Changing a block's output between versions breaks every post already saved, and the workaround is maintaining deprecations forever.

Alpha stores a node tree with semantic props instead. Two properties follow that are worth the extra machinery:

  • Improving a node reaches every page that uses it. Change the markup in render() and every stored document picks it up on the next request. Nothing to migrate, no deprecations.
  • There is no Tailwind safelist. Class names live in repository source, in lib/alpha/tokens.ts, where Tailwind's ordinary file scanning finds them. Content stores tone: "inverse", never class="bg-slate-900" — which also means restyling the design system does not touch content.

Sanity's Portable Text guidance puts the rule succinctly: model meaning, not appearance.

Record shape

{
  "uuid": "<64 alphanumeric characters>",
  "type": "alpha",
  "config": {
    "logo": "/logos/onnne-demo.svg",
    "primaryColor": "#114ef6",
    "title": "onnne.link — Alpha demo",
    "phone": "12025550101", // powers contactButton channel "call"
    "whatsapp": { "phone": "12025550101" }, // powers channel "whatsapp"
    "locale": "en"
  },
  "data": {
    "schemaVersion": 1,
    "locales": ["en", "ar"],
    "defaultLocale": "en",
    "doc": { "type": "root", "children": [/* nodes */] }
  }
}

config is the same metadata every other link type carries. Contact numbers live there rather than in the document, so a client changing their number edits one field — and Alpha needs no contact API and no secrets.

Localization is per field

{
  "type": "heading",
  "props": { "text": { "en": "Our Solutions", "ar": "حلولنا" } }
}

The structure exists once; only text carries locales. Adding a third language is a translation job, not a duplication job, and the trees cannot drift apart. A field missing the active locale falls back to another rather than rendering blank, so a partly translated page still reads.

Nodes

Every node is one entry in the registry (lib/alpha/registry.ts), and each entry owns three things: how its props validate, how it renders, and — via schema — what an editor should offer for it. There is one declaration, so the editor and the renderer cannot drift apart.

A node is { "type": "…", "props": { … }, "children": [ … ] }. Unknown types and invalid props fail validation, and the page returns the standard 500 "Link Unavailable".

This table is generated from the registry — regenerate it when the vocabulary changes.

Type Label Flags Props
badge Badge label: localizedText required
tone: token (default | muted | primary | inverse) — default primary
contactButton Contact button channel: token (call | whatsapp) — default whatsapp
label: localizedText
message: text
floating: boolean — default false
container Container children width: token (sm | md | lg | xl) — default xl
align: token (start | center | end) — default start
gallery Gallery images: media required
cols: token (2 | 3 | 4) — default 4
gap: token (sm | md | lg | xl) — default md
grid Grid children cols: token (1 | 2 | 3 | 4) — default 3
gap: token (sm | md | lg | xl) — default lg
heading Heading text: localizedText required
level: token (1 | 2 | 3 | 4) — default 2
size: token (sm | md | lg | xl) — default lg
tone: token (default | muted | primary | inverse) — default default
align: token (start | center | end) — default start
image Image src: media required
alt: localizedText
height: token (sm | md | lg | xl) — default lg
rounded: boolean — default false
link Link label: localizedText required
href: url required
variant: token (primary | secondary | plain) — default primary
external: boolean — default true
localeToggle Language switch interactive tone: token (default | muted | primary | inverse) — default default
rawHtml Custom HTML html: html required
responsiveVideo Responsive video interactive desktop: media required
mobile: media
breakpoint: number — default 767
full: boolean — default true
root Document children
section Section children tone: token (default | muted | primary | inverse) — default default
pad: token (sm | md | lg | xl) — default lg
pattern: token (none | grid | dots | glow | rings) — default none
anchor: text
full: boolean — default false
stack Stack children direction: token (vertical | horizontal) — default vertical
gap: token (sm | md | lg | xl) — default md
align: token (start | center | end) — default start
motion: token (none | orbit | orbit-reverse | pulse) — default none
tabs Tabs children, interactive labels: localizedText required
tone: token (default | muted | primary | inverse) — default primary
text Text text: localizedText required
size: token (sm | md | lg | xl) — default lg
tone: token (default | muted | primary | inverse) — default muted
align: token (start | center | end) — default start
themeToggle Theme switch interactive tone: token (default | muted | primary | inverse) — default default

interactive marks a node that needs client-side behaviour. A document containing none is rendered server-side and ships no JavaScript at all, like every other link type. One that does mounts a single shared island, islands/alpha/AlphaRuntime.tsx, which re-renders the same validated tree client-side through the same registry.

That is why the language toggle costs nothing: switching locale re-renders from data already in memory. There is no second copy of the markup in the HTML, no fetch, and no flash.

Styling

Props are tokens — tone, pad, size, align, cols, motion, pattern — mapped to Tailwind classes in lib/alpha/tokens.ts. To add a visual option, add a token there; do not put class names in content.

Colours resolve through CSS custom properties defined in assets/alpha/design-system.css, so light and dark work without any token needing a dark variant.

Surfaces re-point the tokens

A section with tone: "inverse" also carries an alpha-inverse marker class, and the stylesheet re-points --alpha-foreground, --alpha-muted-foreground, --alpha-border and --alpha-surface-muted for everything inside it. A node asking for --alpha-foreground means "readable text on the surface I am on", and without this it got the default surface's colour instead — the secondary link variant rendered black on black in the light theme and white on white in the dark one, so the Alpha demo's second hero button was an outlined pill with no visible label in both.

--alpha-primary is deliberately left alone: the brand blue is legible on both inverse surfaces, and remapping it turned the hero's primary button into a plain white pill.

Section patterns

pattern adds a decorative background layer — grid, dots, glow or rings. It is a ::before in the scoped stylesheet, so it costs no markup and nothing to author, and its colours are mixed from currentColor so one definition works on every tone. pattern: "none" contributes no class at all, which is why adding this prop left every existing record's markup untouched. Patterns are hidden under prefers-contrast: more.

Everything in that stylesheet is scoped to #alpha. The page this was derived from styled body, html, ::selection and [dir="rtl"] globally; merging those unscoped would have restyled all 21 business cards and links pages served from the same deployment. Nothing here may target body.

The escape hatch

rawHtml is the only node whose output is not generated by a component, and so the only XSS surface. It is sanitised with an allowlist on every load of a record, not merely on save: records reach the database by routes we do not control — a migration, a support script, a direct edit — and a sanitiser that only guards the write path guards nothing.

Stripped: <script>, inline event handlers, style attributes, javascript: and data: URLs, protocol-relative URLs, <iframe>/<object>/<embed>. Links opening a new tab always get rel="noopener noreferrer".

Prefer a real node. Reach for rawHtml when the vocabulary genuinely cannot express something, and consider whether that gap should become a node instead.

Why the sanitiser is injected, not imported

The registry is bundled for the browser, and sanitize-html is a Node library: it depends on postcss, which imports node:fs, node:path and node:url. Importing it from a node definition put those specifiers in both island entry chunks, the browser refused them, and neither island hydrated — with a green test suite, because server rendering was unaffected.

So the seam is inverted. rawHtml declares htmlProps: ["html"] (lib/alpha/nodes/content.tsx) and parseDoc applies whatever sanitiser its caller supplies:

parseDoc(raw, e, "data.doc", { sanitizeHtml: sanitizeRawHtml });

Two callers pass one: parseLink in lib/validate.ts, and components/LinkPageView.tsx, which re-resolves the stored document at render time. Both are server-only.

parseDoc fails closed: with no sanitiser the prop becomes "", so a caller that forgets one renders an empty block rather than raw markup. Two tests hold the line — tests/islands_test.ts fails if any island reaches an npm package outside a browser-safe allowlist, and a CI step fails if a built client chunk imports a node: specifier.

Assets

Referenced by URL, never imported — clients have no repository access. Values must be https:, mailto:, tel: or root-relative; anything else fails validation.

Per-document assets live under static/alpha/<document>/, so each record owns a folder and none can collide. Root-relative paths keep the record portable: moving those files to a CDN later is a find-and-replace in the document, with no code change.

Adding a node type

  1. Write the definition in lib/alpha/nodes/type, label, schema, parseProps, render, and interactive if it needs behaviour.
  2. Export it from the file's array so registry.ts picks it up.
  3. If it uses hooks, extract a named component and have render return it. The hooks work either way, but naming the component makes the ownership explicit and satisfies the linter.
  4. Add validation and render tests in tests/alpha-nodes_test.ts.
  5. Regenerate the table above.

Limits

MAX_NODES 2000, MAX_DEPTH 32. A stored document is untrusted input and the renderer must not become a denial-of-service vector. The proof-of-concept document is 205 nodes.

The editor

/builder (BUILDER.md) authors these documents, and its property panels are generated from the schema on each node definition — the use that field was added for. components/builder/custom/PropsForm.tsx dispatches on FieldSpec.kind; the insert menu is built from the same declarations.

The consequence worth knowing when adding a node type: writing the definition is the whole job. The node becomes insertable and editable with no change in the builder, which is the same property that keeps the renderer and the editor from drifting apart.

It reads those schemas over HTTP, from GET /builder/schema, rather than importing NODE_REGISTRY. An editor needs each node's label and props; it does not need render(), the token maps or the design system, and the builder is meant to stay liftable into its own release. tests/builder_test.ts asserts the served vocabulary matches the registry field for field, so the indirection cannot become a divergence.

Three props are shapes a FieldSpec cannot describe — tabs.labels, gallery.images and responsiveVideo.desktop/.mobile — and are named in an OVERRIDES map rather than inferred. A fourth would be an argument for extending FieldSpec instead.

ALPHA_SCHEMA_VERSION lives in registry.ts, beside MAX_NODES and MAX_DEPTH, rather than in lib/validate.ts: those constants describe a document, and the record validator reaches the filesystem through lib/mikro/serve.ts.

What is deliberately not here yet

Draft and publish. One document per link; an edit is live immediately. There is no rollback, and the builder's preview is of a draft in a browser, not of a stored record. schemaVersion exists from day one so the record can be migrated when that changes.

Saving from the editor. /builder can produce a document but not store one; that waits on the same storage decision as everything else (ROADMAP.md).