diff --git a/docs/NEXT.md b/docs/NEXT.md index 12f8ebe..a5148f8 100644 --- a/docs/NEXT.md +++ b/docs/NEXT.md @@ -2,7 +2,7 @@ The flight plan. Each item carries enough context to start cold; update this file whenever an item lands (move it to "Done") or a new one is queued. Decisions made while working an item still go through `decisions/` as usual. -_Last updated: 2026-07-30 (ADR 0011 ratified — hosting + DB decided; TMview check recorded)._ +_Last updated: 2026-07-31 (schema design drafted — ADR 0012 Proposed, awaiting Jacob's ratification)._ ## Where things stand @@ -10,9 +10,9 @@ Feasibility is done and the verdict was **build it** (see `FEASIBILITY.md`). All ## Queued, roughly in order -### 1. `page.mooring.*` schema design +### 1. `page.mooring.*` schema design — **drafted, in review** -Design the record types for site config + authored pages + source bindings (ADR 0009 sketches `page.mooring.site` / `.page` / `.source` as starting points — not binding). Before shipping v1 records: review hard (schemas are effectively immutable once records exist in the wild — ADR 0004), check lexicon.community for prior art, publish on-network via `com.atproto.lexicon.schema`, and publish permission sets for our OAuth scopes (ADR 0007). Study `site.standard.*` and `id.sifa.*` for conventions (both linked in the lexicon research file). +Draft lexicons exist in `lexicons/page/mooring/` (`site.json`, `page.json`, `authSite.json` — machine-validated with `@atproto/lexicon`, sample records pass) with rationale in **ADR 0012 (Proposed)**; prior art checkpointed in `research/2026-07-31-lexicon-prior-art.md` (lexicon.community: empty slot, nothing to align with). Remaining: Jacob ratifies ADR 0012 (or amends the draft). On-network publishing (rkey = NSID + `_lexicon.mooring.page` DNS TXT) deliberately waits until just before the first real records ship — while unpublished, the drafts stay freely editable. A lexicon.community sanity-check post before first records is cheap insurance. ### 2. Build v1 (per ADR 0008 — the scope is ratified; don't re-scope) diff --git a/docs/decisions/adr/0012-page-mooring-record-schemas.md b/docs/decisions/adr/0012-page-mooring-record-schemas.md new file mode 100644 index 0000000..5bbf5d9 --- /dev/null +++ b/docs/decisions/adr/0012-page-mooring-record-schemas.md @@ -0,0 +1,32 @@ +# ADR 0012: `page.mooring.*` record schemas for v1 + +**Status:** Proposed · 2026-07-31 + +## Context + +ADR 0004 puts site config and authored content in the user's PDS; ADR 0009 fixed the namespace at `page.mooring.*` and sketched three record types (`site`, `page`, `source`) as non-binding starting points; ADR 0008 scopes v1 to one authored content type plus read-only aggregation of Bluesky, standard.site, and sifa. Schemas are effectively immutable once records exist in the wild, so this is the decision that most deserved prior-art review. + +Prior art (2026-07-31, `docs/research/2026-07-31-lexicon-prior-art.md`, with sources): lexicon.community has nothing site-shaped — we invent in an empty slot. The conventions of `site.standard.*` and `id.sifa.*` converge strongly (tid keys unless truly singleton, open unions for evolving formats, `knownValues` string enums, required `createdAt` + minimal other requires, 10× maxLength:maxGraphemes, self-labels, per-feature permission sets, on-network publishing with rkey = NSID). Blento, Linkat, wisp.place, Leaflet, and WhiteWind supply the site-builder-specific patterns: embedded arrays for v1-scale layout, `key: any` + well-known rkey when a multi-record door should stay open, typed source bindings as method+params rather than AT-URIs, and standard.site's four-semantic-color theme vocabulary. + +## Decision + +Draft lexicons live in [`lexicons/page/mooring/`](../../../lexicons/page/mooring/) (directory path mirrors the NSID, one document per file — sifa's layout). The load-bearing choices: + +1. **Two record types in v1, not three: `page.mooring.site` and `page.mooring.page`.** Source bindings are *embedded* in the site record (point 3); `page.mooring.source` is reserved as a name but not shipped — separate binding records earn their keep only if bindings grow config-heavy (Blento-style freeform grids), which v1's one-theme scope rules out. +2. **Record keys:** `site` uses `key: any` with the owner's default site at rkey **`self`** (Blento/wisp pattern) — singleton semantics today, a non-breaking door to multi-site later; readers get a deterministic AT-URI either way. `page` uses `key: tid` (standard.site maintainers' guidance; their issue #7 debate reviewed). Pages carry an optional `site` AT-URI field, "absent = the default site" — the multi-site door costs one optional field now, or a breaking change later. +3. **Layout = the `sections` array in the site record.** An ordered array of typed section objects in an **open union** (`heroSection`, `postsSection`, `writingSection`, `careerSection`, `pagesSection`); order is array position (the ecosystem-normal answer at this scale — no index fields, no fractional ranks). Each section is a *typed* binding to a known adapter with minimal optional params (e.g. `writingSection.publication` AT-URI, `careerSection.parts`), not a generic method+params mechanism — Blento v2's generic `source` union is the right shape for a freeform grid product, but v1 Mooring has three known adapters and typed objects validate better and render deterministically. **An absent `sections` array is valid: renderers default to a sensible order based on which sources have data** — an empty `site` record is a working site. +4. **Page content is an open union** (`closed: false`), v1 member `#markdown` (CommonMark, ≤100k). This follows standard.site's `document.content` — the single best-tested evolution valve in the ecosystem — so rich blocks or other formats are additive later. We do **not** adopt `site.standard.document` wholesale for authored pages (couples our core record to a young external schema and drags in the block-union rendering problem ADR 0008 deferred); instead the door to **double-writing** a `site.standard.document` at the same rkey for interop (Leaflet's pattern) is explicitly kept open for v2. +5. **Theme is an embedded object, not a record**: `{preset (knownValues, v1: "classic"), colors}` where `colors` mirrors `site.standard.theme.basic`'s four semantic roles (background/foreground/accent/accentForeground) as our own object defs with open per-color unions — same vocabulary for trivial mapping, without inheriting their record-def-used-as-object quirk. Themes-as-records (Blento v2 style) is a v2+ idea contingent on themes-as-plugins. +6. **Stays out of the PDS** (ADR 0004 boundary applied): custom domains, subdomain choice, billing, sessions, caches. Domain ownership is proven by DNS TXT against the DID (ADR 0011); the site record describes the site, not our operation of it. +7. **Conventions adopted wholesale** from the prior art: required `createdAt` (`format: datetime`) on every record, otherwise minimal `required`; `maxLength` = 10 × `maxGraphemes` on user-facing text; open `knownValues` string enums; `com.atproto.label.defs#selfLabels` on both records; sifa's `discoverable` boolean (noindex control, with its exact caveat that the underlying records stay public); fallback-to-profile overlay semantics for absent `name`/hero fields; evolution policy **add-optional-only, never remove or rename** — breaking model changes get legacy dual-read. +8. **Page `visibility` (`public`/`unlisted`/`draft`) controls rendering only** — the lexicon description says so explicitly. PDS records are publicly readable regardless; "draft" is a display state, not secrecy. Authoring UX must not imply otherwise. +9. **One permission set, `page.mooring.authSite`** ("Mooring — create and update your Mooring site configuration and pages", collections: our two record types). Granular per-feature splitting (sifa) starts mattering when we write other namespaces (v2 double-write would add `site.standard.*` collections — an additive change users re-consent to). Known caveat from sifa: `@atproto/lex-cli` codegen doesn't yet support the `permission-set` type; exclude from codegen. Whether blob upload (site icon) needs a separate `blob:` scope beyond repo permission is an implementation-time check folded into the ADR 0011 OAuth spike. +10. **Publishing:** each lexicon becomes a `com.atproto.lexicon.schema` record with **rkey = the NSID** in the `@mooring.page` account's repo, with `_lexicon.mooring.page` DNS TXT proving authority; automated idempotently from CI once scaffolding exists (sifa's script pattern). Lexicons are MIT-licensed (ADR 0002's permissive-lexicon note). **Publish before the first real records ship**, not before — while unpublished, the drafts remain freely editable. + +## Consequences + +- Schema design is done pending ratification; v1 scaffolding (NEXT item 2) has concrete record shapes to build against, and the empty-record-is-a-working-site property gives onboarding a zero-config default. +- The ADR 0009 sketch is amended: `.source` is reserved, unshipped. If a future need for per-binding records arises, it's an additive new collection, not a migration. +- Renderer contract obligations now exist in descriptions (fallback order for absent fields, skip dangling page refs, prefer most-recently-updated on duplicate paths, unknown `preset` falls back to default) — these must hold in the v1 theme. +- Once published on-network and referenced by real records, changes are governed by point 7's policy; until then, review remains open (a lexicon.community sanity-check post before first records is cheap insurance and good citizenship). +- Validation/codegen discipline (NSID↔path tests, constraint checks à la sifa's Vitest suite) belongs in the v1 scaffold; the draft JSON has not yet been machine-validated — first scaffolding step alongside the OAuth spike. diff --git a/docs/research/2026-07-31-lexicon-prior-art.md b/docs/research/2026-07-31-lexicon-prior-art.md new file mode 100644 index 0000000..fe820c9 --- /dev/null +++ b/docs/research/2026-07-31-lexicon-prior-art.md @@ -0,0 +1,56 @@ +# Lexicon prior art for `page.mooring.*` schema design + +**Checkpoint date:** 2026-07-31. Three research passes: the actual lexicon JSON and conventions of `site.standard.*` and `id.sifa.*`, plus a survey of every site/page/link/theme record schema found in the Atmosphere. Compiled to inform ADR 0012. Raw fetched schemas and full repo clones were inspected directly (sifa-lexicons at HEAD `d84385e`; standard.site schemas pulled live from their PDS). + +## Headline: the slot is empty + +**lexicon.community** (https://lexicon.community) hosts seven `community.lexicon.*` namespaces — app, bookmarks, calendar, interaction, location, payments, preference. **Nothing website/page/portfolio/theme-shaped exists or is in progress**, and the [awesome-lexicons index](https://github.com/lexicon-community/awesome-lexicons) has nothing closer than WhiteWind blogs, Frontpage, and Linkat. `page.mooring.*` invents in a genuinely empty slot; the de-facto prior art is app-specific namespaces. + +## site.standard.* — conventions from the ecosystem's breakout lexicon + +Canonical source: TypeScript-authored lexicons at https://tangled.org/standard.site/lexicons (MIT), published on-network as `com.atproto.lexicon.schema` records with **rkey = the NSID** in the standard.site account's repo (`did:plc:re3ebnp5v7ffagz6rb6xfei4`) — so DNS `_lexicon` → DID → record resolution works. Bluesky vendored the schemas for timeline previews. + +- **Every record type is `key: "tid"`** — even `publication`, so one repo can hold several. No `literal:self` anywhere. [Issue #7](https://tangled.org/standard.site/lexicons/issues/7) (open, 12 comments) debates this: maintainers defend tid ("`self` only for singleton collections"); community argues for `key: any` so existing CMSes can reuse stable post IDs. Static-site implementers mint TIDs from original publish timestamps for sortability. +- **`document.content` is a fully open union with an empty refs list** (`"refs": [], "closed": false`) — the schema names *no* member types, not even their own. Formats in the wild are all third-party `$type`s: `pub.leaflet.content` (rich blocks), `blog.pckt.content`, `app.offprint.content`, `at.markpub.markdown` (the emerging portable-markdown choice), `org.wordpress.html`, etc. `textContent` (unconstrained plain string) is the interop fallback used by aggregators/previews; publishing with only `textContent` is valid and common. +- Document→publication reference is **one string field, two schemes**: `site` (`format: uri`) holds either an AT-URI to the publication record or a plain `https://` URL for "loose" documents. Known wart: trailing-slash inconsistency ([issue #13](https://tangled.org/standard.site/lexicons/issues/13)) — consumers must trim. +- **`theme.basic`** = exactly four required semantic color roles (`background`, `foreground`, `accent`, `accentForeground`), each an **open union** currently containing only `theme.color#rgb` (`{r,g,b: int 0–255}`) — future color spaces without breaking readers. Quirk to avoid: `theme.basic` is declared `type: record`/`key: tid` but is only ever embedded inline via ref — a record def used as an object. +- **Constraint convention: `maxLength` (bytes) = 10 × `maxGraphemes`** everywhere (name 500/5000, description 3000/30000, tag 128/1280). Blobs uniformly `image/*`, 1MB. +- **Permission sets**: `site.standard.authFull` / `authSocial` — lexicon docs with `defs.main.type: "permission-set"`, consent-screen `title` + `detail`, and `permissions: [{type: "permission", resource: "repo", collection: [...NSIDs]}]`. +- Evolution: no version fields; schemas edited in place (additive-optional only so far) and republished at the same rkey. Stale vendored copies with divergent constraints circulate — validate against the on-network schema. +- Verification pattern: publication ↔ domain via `GET /.well-known/site.standard.publication` returning the AT-URI. +- Interop pattern: apps **double-write the same rkey** into their native collection and `site.standard.document` (Leaflet writes `pub.leaflet.document` + `site.standard.document` at identical `(did, rkey)`). + +## id.sifa.* — conventions from the most disciplined alpha namespace + +Source: https://github.com/singi-labs/sifa-lexicons (MIT, "Status: Alpha", v0.10.1). Published on-network same way (rkey = NSID, from CI on every merge), authority proven by `_lexicon.sifa.id TXT did=…`. + +- **Two `literal:self` singletons** (`profile.self`, `org.profile`); everything else tid-keyed. Caveat: `.self` in their *naming* means "the thing itself," not singleton (`project.self` is tid-keyed). +- **Children never reference the parent** — profile and its section records join implicitly by repo ownership. Cross-refs only for genuine relations: same-repo mutable refs are AT-URI without CID; `com.atproto.repo.strongRef` (uri+cid) only for cross-repo attestations, paired with denormalized "anchor fields" (e.g. `skillName` frozen in the endorser's repo so the endorsee can't rewrite history). +- **Two-tier date convention (learned the hard way):** record-metadata `createdAt` is `format: datetime` and **required on every record**; human/career dates are **formatless strings** documented as YYYY / YYYY-MM / YYYY-MM-DD — they shipped `format: datetime` on those twice and had to revert both times (commits #33, #70) because month-precision input fails PDS validation. No `isCurrent` boolean — absence of `endedAt` means current. +- **No explicit order/sort field anywhere in the namespace** — AppView sorts by dates; prominence via `isPrimary` flags. +- **Open enums as `knownValues` of NSID-fragment tokens** (`"id.sifa.defs#fullTime"`) backed by `{"type": "token"}` defs in a shared `defs.json` (~250 defs). Field type stays `string` so unknown values pass validation. +- Same **10× maxLength:maxGraphemes** rule, codified in their AGENTS.md and enforced by a Vitest suite (NSID↔file-path match, user-text constraint allowlist, ref-integrity walker). +- **Minimal `required`** (usually one identity field + `createdAt`); overlay semantics vs `app.bsky.actor.profile` ("absence means fall back to the bsky record"); `discoverable` boolean ("Default true when absent… emits noindex directives. Does not affect the public visibility of the underlying ATproto records"); `labels` self-labels union on most records. +- **Permission sets split per feature** for progressive authorization (`authProfile`, `authConnection`, `authMeet`…), each with consent copy; the old monolithic set kept published as legacy. Note: `@atproto/lex-cli` codegen doesn't yet support the `permission-set` type (they exclude those files from codegen). +- Evolution policy: "**never remove or rename existing fields, only add new optional fields**"; breaking model changes handled by legacy dual-read (old `volunteering` collection kept + new `involvement`, AppView serves both with a `legacy` flag). +- Repo layout: `lexicons/.json` one doc per file; `external-lexicons/` vendors referenced external schemas so validation resolves offline; idempotent publish script (getRecord → stable-stringify compare → put). + +## Site-builder record models surveyed + +- **Blento** (`app.blento.*`, https://github.com/flo-bit/blento — closest product shape). v1 live: `page` (`key: any`, main page at rkey `self`), `card` (tid; **grid layout = absolute x/y/w/h integers on each card record** + separate mobile coords; `cardType` string + `cardData: unknown` open bag — source bindings like a Bluesky feed are just `cardData.did/handle`), `section` (tid, ordered by integer `index`). v2 redesign (in repo): unified `node` graph — `parent` = rkey, `rank` = **fractional-index string** ("never a float"), open `data/layout/style/source` blobs, and a **typed source union**: `#atproto {method (XRPC NSID), params, service?}` with **`$self` substitution** for the owner's DID, `#http` gated by allowlist, `#ref` (alias another node's data). Also: Blento writes a `site.standard.publication` record at app-scoped rkey `blento.self` to join that ecosystem. +- **Linkat** (`blue.linkat.board`, https://github.com/mkizka/linkat): the canonical "whole app = one `literal:self` record" — required `cards` array of inline `{url, text, emoji}`; **ordering = array order**; no theme. +- **wisp.place** (`place.wisp.*`): `fs` manifest records with **rkey = site name** (multi-site per repo), a recursive embedded directory tree of blob refs (caps: 500 entries/dir, 1000 files), sharded via `subfs` child records referenced by AT-URI when size demands; `settings` record per site; domains handled service-side (not PDS records). +- **Leaflet** (`pub.leaflet.*`): `publication` (tid) with the richest embedded theme (color unions, background image blob, page width, fonts); `document` (tid) references publication by AT-URI string; **`publicationPage` (`key: any`, rkey derived from path slug)** — `{publication, path, title, content}`, a static about/contact page model very close to Mooring's authored pages. +- **WhiteWind** (`com.whtwnd.blog.entry`): markdown `content` ≤100k chars, `visibility (public|url|author)` as plain knownValues, theme as a named preset string (`"github-light"`). +- **Atmos** (atmos.cv): closed source, no published lexicons (live collections `cv.atmos.post`, `cv.atmos.intelligence.*` visible via UFOs API); nothing to align with. + +## Patterns observed (the ecosystem's answers) + +1. **Singletons:** `literal:self` for truly one-per-repo (bsky profile, Linkat, sifa profile); `key: any` + well-known rkey (`self`, site name) when a multi-record door should stay open (Blento, wisp). +2. **Ordered collections, by scale:** embedded array (order = position) → child records with integer index → child records with fractional-rank strings. Nobody at v1-site scale uses child records for layout. +3. **Referencing external data:** AT-URI strings for known records; strongRef only when immutability matters; **live feed bindings are XRPC method NSID + params (+ `$self`), not AT-URIs**, with renderer allowlists as the security boundary (Blento v2). +4. **Theme config:** a spectrum from preset-name string (WhiteWind) → four semantic color roles (standard.site) → rich token object (Leaflet) → theme-as-record, forkable cross-repo (Blento v2). standard.site's "required minimal `basicTheme` + open union slot for richness" is the standout interop pattern. + +## So what + +Everything needed to draft `page.mooring.*` is here, and the conventions are remarkably convergent: tid keys unless truly singleton; embedded arrays for v1-scale layout; open unions (even empty ones) wherever formats will evolve; `knownValues` string enums; required `createdAt` + minimal other requires; 10× maxLength:maxGraphemes; self-labels + `discoverable`; per-feature permission sets with consent copy; publish on-network rkey=NSID with `_lexicon` DNS proof; evolve add-optional-only with legacy dual-read. The design itself → ADR 0012. diff --git a/docs/research/README.md b/docs/research/README.md index 0c82d34..508f68a 100644 --- a/docs/research/README.md +++ b/docs/research/README.md @@ -12,5 +12,6 @@ Findings from research sessions, preserved so future sessions (human or agent) d | [`2026-07-30-appview-infra.md`](2026-07-30-appview-infra.md) | 2026-07-30 | quickslice vs HappyView vs Tap vs Microcosm; do we need an AppView? | | [`2026-07-30-comparable-stacks.md`](2026-07-30-comparable-stacks.md) | 2026-07-30 | What 8 comparable atproto apps run on; custom-domain serving patterns | | [`2026-07-30-hosting-infra.md`](2026-07-30-hosting-infra.md) | 2026-07-30 | Hosted infra target + cost model: Cloudflare vs VPS+Caddy vs Vercel vs Fly | +| [`2026-07-31-lexicon-prior-art.md`](2026-07-31-lexicon-prior-art.md) | 2026-07-31 | Schema-design prior art: site.standard/sifa conventions; Blento/Linkat/wisp/Leaflet record models | Conventions: one dated file per topic, source URLs inline, and an honest "so what" at the end of each. When you re-research a topic, add a new dated file rather than editing the old one, and update this index. diff --git a/lexicons/page/mooring/authSite.json b/lexicons/page/mooring/authSite.json new file mode 100644 index 0000000..e398ab9 --- /dev/null +++ b/lexicons/page/mooring/authSite.json @@ -0,0 +1,19 @@ +{ + "lexicon": 1, + "id": "page.mooring.authSite", + "description": "OAuth permission set for managing a Mooring site.", + "defs": { + "main": { + "type": "permission-set", + "title": "Mooring", + "detail": "Create and update your Mooring site configuration and pages.", + "permissions": [ + { + "type": "permission", + "resource": "repo", + "collection": ["page.mooring.site", "page.mooring.page"] + } + ] + } + } +} diff --git a/lexicons/page/mooring/page.json b/lexicons/page/mooring/page.json new file mode 100644 index 0000000..a34c0dd --- /dev/null +++ b/lexicons/page/mooring/page.json @@ -0,0 +1,80 @@ +{ + "lexicon": 1, + "id": "page.mooring.page", + "description": "An authored page on a Mooring site (e.g. /about, /now, /contact).", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": ["path", "title", "createdAt"], + "properties": { + "path": { + "type": "string", + "maxLength": 1024, + "description": "URL path for this page, with a leading slash (ex: /about). Uniqueness within a site is enforced by the app, not the lexicon; renderers encountering duplicates should prefer the most recently updated page." + }, + "title": { + "type": "string", + "maxGraphemes": 500, + "maxLength": 5000 + }, + "description": { + "type": "string", + "maxGraphemes": 3000, + "maxLength": 30000, + "description": "Short description of the page, used for meta tags and previews." + }, + "content": { + "type": "union", + "closed": false, + "refs": ["#markdown"], + "description": "Open union defining the page's content. Each entry must specify a $type; other lexicons may extend this with additional content formats." + }, + "site": { + "type": "string", + "format": "at-uri", + "description": "AT-URI of the page.mooring.site record this page belongs to. When absent, the page belongs to the owner's default site (rkey 'self')." + }, + "visibility": { + "type": "string", + "knownValues": ["public", "unlisted", "draft"], + "default": "public", + "description": "Rendering visibility: 'public' pages are served and listed, 'unlisted' pages are served but not listed, 'draft' pages are not served. This controls rendering only — atproto records are publicly readable regardless." + }, + "publishedAt": { + "type": "string", + "format": "datetime", + "description": "Timestamp when the page was first made public. Omit for pages that have never been published." + }, + "updatedAt": { + "type": "string", + "format": "datetime", + "description": "Timestamp of the last content update." + }, + "labels": { + "type": "union", + "refs": ["com.atproto.label.defs#selfLabels"] + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when this record was created." + } + } + } + }, + "markdown": { + "type": "object", + "required": ["text"], + "properties": { + "text": { + "type": "string", + "maxLength": 100000, + "description": "Page body as CommonMark markdown." + } + } + } + } +} diff --git a/lexicons/page/mooring/site.json b/lexicons/page/mooring/site.json new file mode 100644 index 0000000..db79249 --- /dev/null +++ b/lexicons/page/mooring/site.json @@ -0,0 +1,218 @@ +{ + "lexicon": 1, + "id": "page.mooring.site", + "description": "Configuration for a Mooring site. The record at rkey 'self' is the owner's default site; other rkeys are reserved for possible future multi-site support.", + "defs": { + "main": { + "type": "record", + "key": "any", + "description": "A Mooring site. Every field except createdAt is optional: an empty record is a valid site, and the renderer falls back to the owner's profile records and a default section order.", + "record": { + "type": "object", + "required": ["createdAt"], + "properties": { + "name": { + "type": "string", + "maxGraphemes": 500, + "maxLength": 5000, + "description": "Site title. When absent, renderers should fall back to the owner's profile displayName or handle." + }, + "description": { + "type": "string", + "maxGraphemes": 3000, + "maxLength": 30000, + "description": "Short description of the site, used for meta tags and previews." + }, + "icon": { + "type": "blob", + "accept": ["image/*"], + "maxSize": 1000000, + "description": "Square image used as the site icon/favicon. Should be at least 256x256." + }, + "sections": { + "type": "array", + "maxLength": 50, + "description": "Ordered sections of the site, rendered top to bottom; order is array position. When absent, renderers should use a sensible default order based on which sources have data.", + "items": { + "type": "union", + "closed": false, + "refs": ["#heroSection", "#postsSection", "#writingSection", "#careerSection", "#pagesSection"] + } + }, + "theme": { + "type": "ref", + "ref": "#theme" + }, + "discoverable": { + "type": "boolean", + "description": "Default true when absent. When false, the renderer emits noindex directives for the site. Does not affect the public visibility of the underlying atproto records." + }, + "labels": { + "type": "union", + "refs": ["com.atproto.label.defs#selfLabels"] + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when this record was created." + } + } + } + }, + "heroSection": { + "type": "object", + "description": "Introductory section rendered from the owner's profile records (app.bsky.actor.profile, with id.sifa.profile.self as professional-context overlay when present).", + "properties": { + "title": { + "type": "string", + "maxGraphemes": 128, + "maxLength": 1280, + "description": "Heading override. When absent, renderers should fall back to profile data." + }, + "tagline": { + "type": "string", + "maxGraphemes": 300, + "maxLength": 3000, + "description": "Short line under the heading. When absent, renderers should fall back to the profile description or sifa headline." + }, + "showAvatar": { + "type": "boolean", + "description": "Default true when absent." + } + } + }, + "postsSection": { + "type": "object", + "description": "Recent microblog posts from the owner's app.bsky.feed.post collection.", + "properties": { + "title": { + "type": "string", + "maxGraphemes": 128, + "maxLength": 1280 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "description": "Maximum number of posts to display. Renderer default applies when absent." + } + } + }, + "writingSection": { + "type": "object", + "description": "Long-form writing from the owner's site.standard.document collection (with pub.leaflet.* legacy records as fallback).", + "properties": { + "title": { + "type": "string", + "maxGraphemes": 128, + "maxLength": 1280 + }, + "publication": { + "type": "string", + "format": "at-uri", + "description": "AT-URI of a site.standard.publication record to scope the listing to. When absent, renderers should list the owner's documents across publications." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "description": "Maximum number of documents to display. Renderer default applies when absent." + } + } + }, + "careerSection": { + "type": "object", + "description": "Professional profile rendered from the owner's id.sifa.* records.", + "properties": { + "title": { + "type": "string", + "maxGraphemes": 128, + "maxLength": 1280 + }, + "parts": { + "type": "array", + "maxLength": 10, + "description": "Which parts of the sifa profile to render, in order. When absent, renderers should show the parts that have data, in a default order.", + "items": { + "type": "string", + "knownValues": ["positions", "education", "skills", "projects", "certifications", "publications"] + } + } + } + }, + "pagesSection": { + "type": "object", + "description": "Listing of the site's authored page.mooring.page records.", + "properties": { + "title": { + "type": "string", + "maxGraphemes": 128, + "maxLength": 1280 + }, + "pages": { + "type": "array", + "maxLength": 100, + "description": "Explicit ordered list of page records (AT-URIs) to show. Renderers should skip dangling references. When absent, all public pages are listed in a renderer-defined order.", + "items": { + "type": "string", + "format": "at-uri" + } + } + } + }, + "theme": { + "type": "object", + "description": "Theme selection and overrides. All fields optional; renderer defaults apply.", + "properties": { + "preset": { + "type": "string", + "knownValues": ["classic"], + "description": "Named theme preset. Unknown values should fall back to the renderer's default preset." + }, + "colors": { + "type": "ref", + "ref": "#colors" + } + } + }, + "colors": { + "type": "object", + "description": "Semantic color overrides, mirroring the role vocabulary of site.standard.theme.basic. Any subset may be set.", + "properties": { + "background": { + "type": "union", + "closed": false, + "refs": ["#rgb"], + "description": "Color used for content background." + }, + "foreground": { + "type": "union", + "closed": false, + "refs": ["#rgb"], + "description": "Color used for content text." + }, + "accent": { + "type": "union", + "closed": false, + "refs": ["#rgb"], + "description": "Color used for links and button backgrounds." + }, + "accentForeground": { + "type": "union", + "closed": false, + "refs": ["#rgb"], + "description": "Color used for button text." + } + } + }, + "rgb": { + "type": "object", + "required": ["r", "g", "b"], + "properties": { + "r": { "type": "integer", "minimum": 0, "maximum": 255 }, + "g": { "type": "integer", "minimum": 0, "maximum": 255 }, + "b": { "type": "integer", "minimum": 0, "maximum": 255 } + } + } + } +}