uniweb 1.0.0 → 1.0.2

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.
Files changed (129) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +532 -3
  3. package/package.json +60 -11
  4. package/partials/adding-pages.hbs +77 -0
  5. package/partials/agents.md +3000 -0
  6. package/partials/ai-assistance.hbs +28 -0
  7. package/partials/components-docs.hbs +13 -0
  8. package/partials/config-reference.hbs +66 -0
  9. package/partials/deployment.hbs +13 -0
  10. package/partials/learn-more.hbs +6 -0
  11. package/partials/quick-start.hbs +8 -0
  12. package/partials/search-docs.hbs +110 -0
  13. package/src/backend/asset-download.js +222 -0
  14. package/src/backend/client.js +986 -0
  15. package/src/backend/foundation-bring-along.js +692 -0
  16. package/src/backend/payment-handoff.js +362 -0
  17. package/src/backend/service-request.js +210 -0
  18. package/src/backend/site-media.js +255 -0
  19. package/src/backend/site-preview.js +77 -0
  20. package/src/backend/site-sync.js +2515 -0
  21. package/src/backend/workspace.js +234 -0
  22. package/src/commands/add.js +1900 -0
  23. package/src/commands/build.js +1214 -0
  24. package/src/commands/clone.js +466 -0
  25. package/src/commands/content.js +202 -0
  26. package/src/commands/deploy.js +537 -0
  27. package/src/commands/dev.js +202 -0
  28. package/src/commands/docs.js +530 -0
  29. package/src/commands/doctor.js +1982 -0
  30. package/src/commands/export.js +116 -0
  31. package/src/commands/families.js +82 -0
  32. package/src/commands/forget.js +197 -0
  33. package/src/commands/handoff.js +21 -0
  34. package/src/commands/i18n.js +1707 -0
  35. package/src/commands/inspect.js +375 -0
  36. package/src/commands/invite.js +24 -0
  37. package/src/commands/org.js +96 -0
  38. package/src/commands/publish.js +1087 -0
  39. package/src/commands/pull.js +1257 -0
  40. package/src/commands/push.js +663 -0
  41. package/src/commands/refresh.js +212 -0
  42. package/src/commands/register.js +1092 -0
  43. package/src/commands/rename.js +647 -0
  44. package/src/commands/site.js +358 -0
  45. package/src/commands/snapshot.js +461 -0
  46. package/src/commands/starter.js +206 -0
  47. package/src/commands/status.js +302 -0
  48. package/src/commands/sync.js +93 -0
  49. package/src/commands/template.js +107 -0
  50. package/src/commands/update.js +887 -0
  51. package/src/commands/validate.js +376 -0
  52. package/src/framework-index.json +248 -0
  53. package/src/index.js +2241 -0
  54. package/src/templates/fetchers/github.js +113 -0
  55. package/src/templates/fetchers/npm.js +85 -0
  56. package/src/templates/fetchers/release.js +92 -0
  57. package/src/templates/index.js +190 -0
  58. package/src/templates/processor.js +333 -0
  59. package/src/templates/resolver.js +232 -0
  60. package/src/templates/validator.js +155 -0
  61. package/src/utils/agents-stamp.js +110 -0
  62. package/src/utils/args.js +126 -0
  63. package/src/utils/asset-upload.js +270 -0
  64. package/src/utils/bytes.js +30 -0
  65. package/src/utils/code-upload.js +394 -0
  66. package/src/utils/config.js +417 -0
  67. package/src/utils/conformance.js +274 -0
  68. package/src/utils/dep-survey.js +176 -0
  69. package/src/utils/destination-prompt.js +146 -0
  70. package/src/utils/discover.js +75 -0
  71. package/src/utils/env.js +22 -0
  72. package/src/utils/exit.js +30 -0
  73. package/src/utils/fetch-retry.js +109 -0
  74. package/src/utils/flag-guard.js +246 -0
  75. package/src/utils/foundation-name.js +179 -0
  76. package/src/utils/git.js +342 -0
  77. package/src/utils/host-prompt.js +51 -0
  78. package/src/utils/install-integrity.js +210 -0
  79. package/src/utils/interactive.js +118 -0
  80. package/src/utils/json-file.js +72 -0
  81. package/src/utils/names.js +91 -0
  82. package/src/utils/placement.js +151 -0
  83. package/src/utils/pm.js +165 -0
  84. package/src/utils/preview.js +20 -0
  85. package/src/utils/pull-written.js +132 -0
  86. package/src/utils/records-guard.js +88 -0
  87. package/src/utils/registry-auth.js +994 -0
  88. package/src/utils/registry-orgs.js +290 -0
  89. package/src/utils/scaffold.js +483 -0
  90. package/src/utils/schemaless-report.js +30 -0
  91. package/src/utils/semver-precedence.js +80 -0
  92. package/src/utils/session-file.js +95 -0
  93. package/src/utils/site-content-refs.js +23 -0
  94. package/src/utils/site-copies.js +123 -0
  95. package/src/utils/site-data-upload.js +302 -0
  96. package/src/utils/site-identity.js +137 -0
  97. package/src/utils/snapshot-settings.js +112 -0
  98. package/src/utils/update-check.js +302 -0
  99. package/src/utils/uwx-read.js +157 -0
  100. package/src/utils/workspace.js +223 -0
  101. package/src/versions.js +467 -0
  102. package/starter/foundation/main.js +50 -0
  103. package/starter/foundation/sections/Section/index.jsx +95 -0
  104. package/starter/foundation/sections/Section/meta.js +44 -0
  105. package/starter/site/pages/about/1-about.md.hbs +13 -0
  106. package/starter/site/pages/about/page.yml +3 -0
  107. package/starter/site/pages/home/1-welcome.md.hbs +14 -0
  108. package/starter/site/pages/home/page.yml +3 -0
  109. package/templates/foundation/_gitignore +5 -0
  110. package/templates/foundation/components/.gitkeep +0 -0
  111. package/templates/foundation/main.js.hbs +33 -0
  112. package/templates/foundation/package.json.hbs +51 -0
  113. package/templates/foundation/sections/.gitkeep +0 -0
  114. package/templates/foundation/styles.css +6 -0
  115. package/templates/foundation/vite.config.js +3 -0
  116. package/templates/site/_gitignore +12 -0
  117. package/templates/site/entry.js +7 -0
  118. package/templates/site/index.html.hbs +13 -0
  119. package/templates/site/package.json.hbs +27 -0
  120. package/templates/site/public/favicon.svg +7 -0
  121. package/templates/site/site.yml.hbs +91 -0
  122. package/templates/site/theme.yml +99 -0
  123. package/templates/site/vite.config.js +7 -0
  124. package/templates/workspace/AGENTS.md.hbs +2 -0
  125. package/templates/workspace/README.md.hbs +87 -0
  126. package/templates/workspace/_gitignore +29 -0
  127. package/templates/workspace/_vscode/settings.json +6 -0
  128. package/templates/workspace/package.json.hbs +20 -0
  129. package/templates/workspace/pnpm-workspace.yaml.hbs +26 -0
@@ -0,0 +1,3000 @@
1
+ # AGENTS.md
2
+
3
+ ## Part 0 — Read this first
4
+
5
+ A Uniweb project separates **what the site says** from **how it's built**. Content authors write markdown — choosing section types, setting params, composing pages. Component developers build section types that receive pre-parsed content and render it. Neither touches the other's files.
6
+
7
+ **Why that separation is cheap rather than ceremonial.** Every Uniweb site ships a runtime. A foundation is an ES module that plugs into it — either **standalone** (inlined into the site's own bundle, one self-describing artifact) or **linked** (loaded by URL at runtime as a federated module). Same foundation either way; the packaging is a deploy-time decision, not something your components know about. The runtime owns the content pipeline, section wrapping, backgrounds, theming, light/dark, prerendering, routing, and i18n; it orchestrates rendering and bridges the author's world into yours. Your components receive `{ content, params }` and render. **These are enforced guarantees, not conventions you have to uphold** — so you can drop the defensive code you'd write elsewhere.
8
+
9
+ | The runtime handles | So components should NOT contain |
10
+ |---|---|
11
+ | Section backgrounds (image, video, gradient, color, overlay) from `background:` | Background rendering code, `bg-white`/`bg-gray-900` on the wrapper |
12
+ | Context classes (`context-light`/`medium`/`dark`) on every section | Theme maps: `const themes = { light: {…}, dark: {…} }` |
13
+ | Token resolution — `text-heading` adapts automatically | Conditionals: `isDark ? 'text-white' : 'text-gray-900'` |
14
+ | Content parsing with a guaranteed shape | Defensive null checks on content fields |
15
+ | Section wrapping in `<section>` with context class | An outer `<section>` with background/theme classes |
16
+ | Light/dark scheme resolution before first paint | `localStorage` reads, `typeof window` guards |
17
+ | i18n via locale-specific content directories | String wrapping with `t()` or `<Trans>` |
18
+
19
+ Concretely, the code you don't write: no theme maps, no `isDark ? … : …`, no null checks on content fields, no `t()` wrapping, no per-component background rendering, no SSR guards, no outer `<section>`. A hero that's 200 lines of undifferentiated React elsewhere is a 40-line component plus a markdown file here — and the markdown is editable by someone who has never opened your code.
20
+
21
+ Components *should* contain: layout (`grid`, `flex`, `max-w-7xl`), spacing (`p-6`, `gap-8`), typography scale (`text-3xl`, `font-bold`), animations, border-radius — anything that stays the same regardless of theme context.
22
+
23
+ Once content reaches your component, **it's standard React.** Standard Tailwind. Import any library, use any pattern. The `{ content, params }` interface applies only to *section types* (the components authors select in markdown); everything else in your foundation is ordinary React with ordinary props.
24
+
25
+ You're building a *system* of section types, not individual pages. That's what makes theming, i18n, and multi-site tractable — they're properties of the system rather than things bolted onto each component.
26
+
27
+ ### Route yourself
28
+
29
+ | Your situation | Start at |
30
+ |---|---|
31
+ | New or nearly empty project | **Part 1**, then Part 4 |
32
+ | Existing project, content task (pages, copy, colors, nav) | **Part 2**, then Part 3 |
33
+ | Existing project, component task (new section type, restyle) | **Part 2**, then Part 4 |
34
+ | Porting a design from React / Next / a generated site | **Part 1**, then Part 5 → *Migrating* |
35
+
36
+ ### Three commands that replace guessing
37
+
38
+ ```bash
39
+ uniweb inspect <path> # print the parsed content shape of a section or page
40
+ uniweb <command> --help # exact flags, always current — no side effects
41
+ uniweb doctor # diagnose project configuration (--fix to repair)
42
+ ```
43
+
44
+ `uniweb inspect` is the important one. Every parse rule in Part 3 is faster to *confirm* than to re-derive, and re-deriving from memory is where agents go wrong. Don't predict the shape — print it.
45
+
46
+ ### The four silent failures
47
+
48
+ Most Uniweb mistakes do not fail the build. The build goes green, the terminal is clean, and the page is wrong. If you don't look at the page or run `inspect`, you will report success on broken output.
49
+
50
+ 1. **An unknown section type renders a red `Component not found: <Type>` box.** The build does not fail. Never write a `type:` you haven't confirmed exists — see Part 2, step 2.
51
+ 2. **An undeclared param is accepted and does nothing.** Frontmatter keys no `meta.js` declares are passed through and ignored, not rejected.
52
+ 3. **Content lands in the wrong field.** A heading becomes a `subtitle` when you wanted an item, or the reverse. That's the level rule (Part 3). `uniweb inspect` settles it.
53
+ 4. **A section silently doesn't render.** `@`-prefixed files are child sections (only rendered via `nest:`), `_`-prefixed files are drafts, and section types nested below the root of `sections/` without a `meta.js` are never discovered.
54
+
55
+ ### Documentation
56
+
57
+ This guide covers what you can't derive by reading code. Everything else is in the full documentation — and **you can reach all of it without guessing at URLs.**
58
+
59
+ **Start at the index.** One address, and it's the only documentation URL worth memorizing:
60
+
61
+ ```
62
+ https://www.uniweb.io/llms.txt
63
+ ```
64
+
65
+ It lists every documentation page with a one-line description and a fetchable link. Go there whenever your question isn't answered in this file — and *especially* when you don't know whether a feature exists at all. That's where guessing does the most damage: **a 404 on a path you invented tells you your guess was wrong, not that the feature is missing.** Concluding "the framework doesn't support X" from a failed fetch is a mistake worth actively guarding against; the index answers the existence question directly.
66
+
67
+ Then **follow the link the index gives you** rather than constructing one yourself. The index is the source of truth for where pages live, so it stays correct when the site's URL conventions change. (Pages also serve markdown to any client that sends `Accept: text/markdown`.)
68
+
69
+ **Clone the repo instead** when you expect several lookups, or may lose network access later:
70
+
71
+ ```bash
72
+ git clone --depth 1 https://github.com/uniweb/docs .uniweb-docs # then gitignore it
73
+ ```
74
+
75
+ Same content, greppable, and one network operation instead of many. `grep -ri "queryable" .uniweb-docs` beats any number of fetches while you're still exploring.
76
+
77
+ Documentation paths in this guide are given bare — `development/creating-components.md` — so they resolve three ways: as a key in the index, as a file in a clone, or at `https://raw.githubusercontent.com/uniweb/docs/main/{path}`.
78
+
79
+ > **Don't confuse the two doc sites.** `www.uniweb.io/docs` is the **developer** documentation — this is the one you want. `docs.uniweb.app` is the *author-facing* app documentation, written for non-technical people using the visual editor; it won't answer framework questions.
80
+
81
+ **The table below saves you the round trip** for the tasks that come up most:
82
+
83
+ | Task | Page |
84
+ |------|------|
85
+ | Writing page content | `authoring/writing-content.md` |
86
+ | Theming and styling | `authoring/theming.md` |
87
+ | Working with records and queries | `authoring/collections.md` |
88
+ | Where-object predicate format | `authoring/predicates.md` |
89
+ | Data sources / custom transports | `development/data-sources.md` |
90
+ | Building components | `development/creating-components.md` |
91
+ | Schemas in practice | `development/schemas-in-practice.md` |
92
+ | Workspace layouts and their wiring | `development/project-structures.md` |
93
+ | Migrating an existing design | `development/converting-existing.md` |
94
+ | Internationalization (full model) | `development/internationalization.md` |
95
+ | Kit API (hooks, components) | `reference/kit-reference.md` |
96
+ | Content shape reference | `reference/content-structure.md` |
97
+ | Component metadata (`meta.js`) | `reference/component-metadata.md` |
98
+ | Site configuration | `reference/site-configuration.md` |
99
+ | Queries — everything a query can say | `reference/queries.md` |
100
+ | Data fetching model | `reference/data-fetching.md` |
101
+ | Navigation patterns | `reference/navigation-patterns.md` |
102
+
103
+ For anything not in that table, start at the index. For CLI flags, prefer `uniweb <command> --help` over any of this — it's always current.
104
+
105
+ ---
106
+
107
+ ## Part 1 — Starting a project
108
+
109
+ **Always use the CLI to scaffold — never hand-write `package.json`, `vite.config.js`, `entry.js`, or `index.html`.** The CLI resolves correct versions and structure; hand-written config is the most common way to end up with a project that can't build.
110
+
111
+ ```bash
112
+ npx uniweb@latest create my-project --template marketing
113
+ cd my-project && pnpm install
114
+ uniweb dev
115
+ ```
116
+
117
+ `@latest` matters: a project starts on the packages and templates of the CLI that creates it, and a bare `npx uniweb` runs a globally installed `uniweb` when there is one, however old.
118
+
119
+ **Choosing a template.** `--template <name>` gives you a working site plus a foundation you can study and edit. `--template none` gives you the same two packages with no content — the right choice when you're building a foundation from scratch or porting a design. `--blank` gives you an empty workspace and assumes you'll add packages with `uniweb add`; use it only if you already know the framework.
120
+
121
+ Official templates include `marketing` (tokens, insets, grids, multi-line headings), `docs` (sidebar nav, code highlighting), `blog` (records of the standard `@std/article` schema, a query, a parametric `[slug]` page), `dynamic` (live API data, loading states), `international` (i18n, records, multi-locale routing), `store` (product grids, e-commerce), `academic` (publications, timeline, math), `extensions` (multi-foundation, runtime loading). `uniweb template list` shows them all.
122
+
123
+ **npm or pnpm.** Projects include both `pnpm-workspace.yaml` and npm workspaces. Replace `pnpm` with `npm` in any command in this guide.
124
+
125
+ ### What you get
126
+
127
+ ```
128
+ my-project/
129
+ ├── src/ # the foundation — component developer's domain
130
+ ├── site/ # the site — content author's domain
131
+ └── pnpm-workspace.yaml
132
+ ```
133
+
134
+ A site is pure content. A foundation is the site's source code — that's why it lives in `src/`. The foundation's `package.json::name` is `src`, symmetric with `site` — a **workspace** name, how the site depends on it. What the foundation **registers as** (`@org/<name>`, the name sites pin) is `name` in its `main.js`, which `uniweb create` sets to the project's name. **`src` and `foundation` are never a foundation's name** — `uniweb register` refuses them, because every project in an org would register the same one.
135
+
136
+ - **Foundation** (`src/`): React components. Those in `sections/` and `layouts/` are *section types* — selectable by authors via `type:`, or used for layout areas. Everything in `components/` and `utils/` is ordinary React and JS: the developer's workbench, not visible to authors.
137
+ - **Site** (`site/`): markdown content and configuration, plus optional records of structured content and the queries that reach them or an external data source.
138
+
139
+ **The composition boundary.** Authors compose pages from finished section types — choosing types, writing content, setting params. Developers compose section types from building blocks — importing helpers, using libraries, writing JSX. Two different levels of composition, and the section type is the boundary between them. Don't expose building-block composition to authors; build complete, self-contained section types that handle their own internal structure.
140
+
141
+ ### Growing the workspace
142
+
143
+ ```bash
144
+ uniweb add project docs # co-located pair → docs/src + docs/site
145
+ uniweb add foundation [name|path] [--path parent] # no name → ./src/, bare name → ./name/, slash → that path
146
+ uniweb add site [name|path]
147
+ uniweb add extension <name> [--site <name>] # secondary foundation, wired to a site
148
+ uniweb add section Hero [--foundation ui] # sections/Hero/{index.jsx,meta.js}
149
+ ```
150
+
151
+ The CLI creates exactly the folder you ask for — no silent nesting under `foundations/` or `sites/`. If the target exists or the package name is taken, it stops with a precise error and suggests alternatives (using the same `classifyPackage` logic the build uses, so cross-type collisions are caught: you can't `add site marketing` when a foundation named `marketing` exists).
152
+
153
+ `uniweb add section` scaffolds a CCA-proper starter, and the dev server picks it up with no build or install.
154
+
155
+ ### Learn from a working template
156
+
157
+ **When you're unsure how to implement a pattern — data fetching, i18n, layouts, insets, theming — read a real one instead of guessing.** Install an official template as a second project in your workspace:
158
+
159
+ ```bash
160
+ uniweb add project marketing --from marketing
161
+ pnpm install
162
+ ```
163
+
164
+ That creates `marketing/src/` + `marketing/site/` alongside your project. You don't need to build or run it. Read:
165
+
166
+ - `marketing/src/sections/` — components with `meta.js`: what content they expect, what params they accept, what presets they define
167
+ - `marketing/site/pages/` — real content files, showing the markdown → component mapping
168
+ - `marketing/site/theme.yml` and `site.yml` — theming and configuration in practice
169
+
170
+ This is the highest-bandwidth way to learn the framework, and it works with no network access. Install several if you like; each is independent. To run one: `cd marketing/site && pnpm dev`.
171
+
172
+ ### The loop
173
+
174
+ ```bash
175
+ uniweb dev # from the workspace root; it picks the site for you
176
+ pnpm build # production build (SSG + SPA)
177
+ pnpm preview # preview the production build
178
+ ```
179
+
180
+ Markdown, `theme.yml`, and component edits hot-reload. New section types are picked up without a restart.
181
+
182
+ ---
183
+
184
+ ## Part 2 — Working in an existing project
185
+
186
+ The rest of this guide explains how Uniweb works. This part is what to do *first* when you've been handed a project that already uses it, plus a task.
187
+
188
+ ### 1. Orient — what kind of workspace is this?
189
+
190
+ **There is no single layout. Read `pnpm-workspace.yaml` first** — its `packages:` globs name the shape in one glance:
191
+
192
+ | Globs | Layout | What it implies |
193
+ |---|---|---|
194
+ | `src`, `site` | **single** (the default) | foundation in `src/`, site in `site/` |
195
+ | `foundations/*`, `sites/*` | **segregated** | several sites may share one foundation — a foundation edit hits all of them |
196
+ | `*/src`, `*/site` | **co-located projects** | one self-contained pair per project: `docs/src` + `docs/site` |
197
+ | a package whose `main.js` says `extension: true` — `effects`, `extensions/*` (alongside any of the above) | extensions present | extra section types, runtime-loaded — see step 2 |
198
+
199
+ Those names are convention; the globs are the truth. Full guide with the wiring for each: `development/project-structures.md`.
200
+
201
+ **A site is any package with a `site.yml` and a `pages/` folder** — a workspace can hold several. Everything below calls yours `<site>/`.
202
+
203
+ **Find the foundation by following the wiring, not by guessing the folder.** `foundation:` in `site.yml` is a **package name**, not a path, and folder name matches package name only by convention — co-located projects break it deliberately (folder `docs/src`, package `docs-src`). Go from name to folder via the matching `file:` dependency in `<site>/package.json`:
204
+
205
+ ```json
206
+ // docs/site/package.json → the foundation is at docs/src/
207
+ { "dependencies": { "docs-src": "file:../src" } }
208
+ ```
209
+
210
+ If `foundation:` isn't a workspace package name, the source isn't in this repo at all:
211
+
212
+ | `foundation:` value | What it means | Can you add a section type? |
213
+ |---|---|---|
214
+ | a workspace package name (`src`, `docs-src`, `marketing`) | source is in this repo — follow the `file:` dep to it | **Yes** |
215
+ | a versioned registry ref (`@org/name@1.2.3`) | published; **its source is not in this repo** | **No** — work within the types it offers |
216
+ | an `https://…` URL, or `{ url: … }` | same, loaded from that URL | **No** |
217
+
218
+ A versionless `@org/name` is an error rather than a shorthand — the build rejects it and asks for a version.
219
+
220
+ > **Foundations are never npm packages.** They're runtime federated modules, not libraries. Don't `npm install` one, and don't add one to `dependencies` by hand. The four supported shapes are exactly the ones above: a workspace sibling, a `file:` dependency, a versioned registry ref, or a full URL.
221
+
222
+ > **The same `@scope/name` shape means different things in different places.** As a `foundation:` value it is a **catalog** ref (never npm). As a data-schema ref in `meta.js` it resolves on disk from an **npm package** — `@std/*` from `@uniweb/schemas`, any other `@org/*` from that org's `@org/schemas`. And `@uniweb/kit` in `dependencies` is an ordinary npm package. Position decides the registry; the shape tells you nothing.
223
+
224
+ **Check `paths:` in `site.yml` before going looking for content.** It mounts outside directories into the page tree (`paths: { pages/docs: ../../../docs }`), so a route's markdown may live in another repo or a submodule rather than under `<site>/pages/`.
225
+
226
+ ### 2. Learn this project's vocabulary — before you write anything
227
+
228
+ **A foundation is a fixed vocabulary of section types, and every project's is different.** Nothing in the framework tells you what a given project offers; you read it — in the foundation folder you resolved in step 1, not a guessed `src/`:
229
+
230
+ ```bash
231
+ ls <foundation>/sections/ # the section types available
232
+ cat <foundation>/sections/Hero/meta.js # what one expects and accepts
233
+ ```
234
+
235
+ **Extensions add to that vocabulary.** If `site.yml` carries an `extensions:` list, each entry is a second foundation contributing its own section types, usable by name in exactly the same way. Enumerate their `sections/` too. The primary foundation wins on a name collision, and extensions are checked in declared order.
236
+
237
+ **An extension *is* a foundation** — same build, same output; it just contributes no Layout or theme variables. So each entry takes the **same shapes** `foundation:` does: a workspace package name, a versioned catalog ref (`@org/name@1.2.3`), or a full URL. Use the same table above to decide whether you can add a section type to one.
238
+
239
+ > **A site-relative URL (`/effects/entry.js`) only works where the site serves its own files** — `uniweb export` and `uniweb deploy --host=<adapter>`. A site published to Uniweb hosting ships no JS, so nothing serves that path, and `uniweb publish` rejects it with a pointer to the catalog-ref form. Register the extension (`uniweb register` in its directory) and reference it like any other foundation. When you reference a workspace-local extension, `uniweb publish` brings it along exactly as it does the primary — releasing it if its code changed, and pinning the released version on the published site.
240
+
241
+ Each `meta.js` is a catalog entry: `description` (what the type is for), `content:` (what markdown it expects), `params:` (what frontmatter it accepts, with defaults), `presets:` (named param bundles), and optionally `family:` (the standard section family it belongs to). Read them as a menu — that is what they are. There is no CLI command that lists them; reading the folder *is* the discovery step.
242
+
243
+ > **Never write a `type:` or a param you haven't confirmed exists.** Both failures are silent (Part 0) — invisible from the terminal, visible only on the page.
244
+
245
+ #### `family:` — so an editor can draw your section
246
+
247
+ An editor showing an author a section picker has to know what each section type
248
+ **is**, to pick its illustration and a label it can translate. Two ways to tell
249
+ it, in this order:
250
+
251
+ 1. **Name the component for its family.** Run `uniweb families`, and use one of
252
+ those names when it fits — `Hero`, `Footer`, `Pricing`, `FAQ`, `CardGrid`.
253
+ Then there is nothing to declare: the name resolves on its own.
254
+ 2. **Declare `family:`** when the name is yours — a variant, or your own
255
+ vocabulary:
256
+ ```js
257
+ // sections/ProfileHero/meta.js
258
+ export default { title: 'Researcher Profile', family: 'profile' }
259
+ ```
260
+ One value, the dominant shape: a `HeroWithEstimate` is `family: 'hero'`.
261
+
262
+ **Leave it off when nothing fits.** A section that is genuinely specific to this
263
+ site gets a generic illustration, and that is the right outcome — do not force it
264
+ into the nearest family. `uniweb doctor` shows what each of your sections
265
+ resolved to, and suggests a family for any it recognises by another name.
266
+
267
+ ### 3. Find your lane
268
+
269
+ The architecture exists to keep content and code separate. Your task sits in one of them. Decide before you edit, then stay there.
270
+
271
+ | Task | Lane | Files you touch |
272
+ |---|---|---|
273
+ | Add / edit / reorder a section on a page | content | `<site>/pages/**` |
274
+ | Add a page | content | `<site>/pages/<name>/` |
275
+ | Change colors, fonts, light/dark | content | `<site>/theme.yml` |
276
+ | Change header, footer, or nav content | content | `<site>/layout/*.md`, or page order in `site.yml` |
277
+ | Change how a section type *looks* everywhere | foundation | `<foundation>/sections/<Type>/` |
278
+ | Add a new section type | foundation | `<foundation>/sections/<NewType>/` |
279
+ | Expose a new knob to authors | foundation | `<foundation>/sections/<Type>/meta.js` + the component |
280
+
281
+ **If you're on a content task and find yourself wanting to open the foundation, stop.** Usually it means you missed a param the section type already exposes — re-read its `meta.js`. If the knob genuinely doesn't exist, that's a foundation change: say so explicitly rather than quietly crossing the boundary. Editing a section type changes it for every page that uses it — and in a segregated workspace, for **every site in the repo sharing that foundation**, which is exactly the layout chosen when a foundation is the primary deliverable. Check who else depends on it before you edit.
282
+
283
+ ### 4. The loop
284
+
285
+ ```bash
286
+ uniweb dev # from the workspace root; it picks the site
287
+ ```
288
+
289
+ **When you're unsure what shape your markdown produces, don't reason about it — run it.** From the *site* directory (paths resolve against your shell's working directory):
290
+
291
+ ```bash
292
+ uniweb inspect pages/home/hero.md # the parsed content shape
293
+ uniweb inspect pages/home/ # every section on the page
294
+ uniweb inspect pages/home/hero.md --full # include empty fields (matches runtime)
295
+ uniweb inspect pages/home/hero.md --sequence # include the sequence array
296
+ uniweb inspect pages/home/hero.md --raw # the ProseMirror AST
297
+ ```
298
+
299
+ This settles any question in Part 3 — whether a heading became a subtitle or an item, whether the parser saw your data block, why `content.title` is an array. One command beats re-deriving the rules.
300
+
301
+ ### 5. Recipes
302
+
303
+ Paths are relative to the site package you identified in step 1 — `site/` in the default layout, `sites/main/` or `docs/site/` in others.
304
+
305
+ **Add a section to a page.** `ls <site>/pages/<page>/` to see the existing sections and their numeric prefixes → pick a `type:` from the vocabulary (step 2) → create `<site>/pages/<page>/N-name.md` with frontmatter plus markdown. Use a decimal (`2.5-…`) to slot between existing sections without renaming them.
306
+
307
+ **Add a page.** Create `<site>/pages/<name>/`, add `page.yml` (at minimum `title:`), add one or more section `.md` files. It gets the route `/<name>` automatically. Only touch `pages:` in the parent `page.yml` / `site.yml` if you need a specific order.
308
+
309
+ **Change the brand color.** `<site>/theme.yml` → `colors.primary`. One line, and every component follows. Do **not** edit Tailwind color classes in components to change brand color — that is the exact anti-pattern this system removes.
310
+
311
+ **Change how code blocks look.** `<site>/theme.yml` → `code:`. Name one of Shiki's 65 bundled themes (`code: dracula`), name one per scheme (`code: { light: github-light, dark: github-dark }`), or take a theme and adjust it (`code: { theme: github-dark, background: '#0D0D0D' }`). Hand-picking every syntax colour is possible and is the last thing to reach for.
312
+
313
+ **Change a nav item.** First check how nav is produced. If `<site>/layout/header.md` lists the links (a markdown list, or a `yaml:nav` block), edit it there. If it doesn't, the Header is generating nav from the page hierarchy — change page titles and order in `site.yml` / `page.yml` instead.
314
+
315
+ **Update the project's Uniweb dependencies — and this file.** `npx uniweb@latest update`. One command aligns every `@uniweb/*` dependency *and* refreshes this AGENTS.md together. Preview with `--dry-run`. ⛔ **The `@latest` is doing the work** — a bare `uniweb update` runs the CLI this project already pins, which aligns you to the matrix you already have and correctly reports nothing to do. **Don't reach for `npm update` / `pnpm update`** — see *Staying current* in Part 5 for why that breaks things quietly.
316
+
317
+ **Change one section's columns / spacing / variant.** Check that type's `meta.js` `params:` first. If the knob exists, set it in that section's frontmatter and you're done, in the content lane. If it doesn't, it's a foundation change — see the warning in step 3.
318
+
319
+ ---
320
+
321
+ ## Part 3 — The content lane
322
+
323
+ ### Section format
324
+
325
+ Each `.md` file is a section. Frontmatter on top, content below:
326
+
327
+ ```markdown
328
+ ---
329
+ type: Hero
330
+ theme: dark
331
+ ---
332
+
333
+ #> New in v1 ← pretitle (the label line above the title)
334
+
335
+ # Build the system. ← title (the big headline)
336
+
337
+ ## Not every page. ← subtitle (one step smaller, directly below)
338
+
339
+ Description paragraph.
340
+
341
+ [Call to Action](/link)
342
+
343
+ ![Image](./image.jpg)
344
+ ```
345
+
346
+ Heading levels set *structure* (pretitle, title, subtitle), not font size — the component controls visual sizing. Write a pretitle as a `#>` label line — any number of leading `#`s spells the same label, so match the heading you are labelling. A smaller ordinary heading directly above the title is *also* read as a pretitle, which is how files written before label lines keep working; prefer `#>`, because it says what it is wherever it lands rather than depending on what follows it.
347
+
348
+ **A section with no `type:` renders through the foundation's default section type — a component named `Section`, unless the foundation's `main.js` sets `defaultSection` to something else.** This is what lets a folder of plain markdown with no frontmatter at all become pages: mounted documentation, an imported wiki, anything written before it met this framework. If such content renders blank, the foundation has no `Section` — that, not the markdown, is what to fix.
349
+
350
+ **Markdown order ≠ rendering order.** The parser extracts content into a flat structure; the component decides how to arrange it visually. Write markdown in semantic order, not visual order — start with the heading, then add icons, images, and text in any order.
351
+
352
+ **Placing content *before* the first heading changes the parse:** headings after body content become items, not the section title. This is by design — it's how repeating content groups are created.
353
+
354
+ ### The parsed object
355
+
356
+ The semantic parser produces a flat, guaranteed structure. No null checks needed — empty strings and arrays when content is absent:
357
+
358
+ ```js
359
+ content = {
360
+ title: '', // Main heading (string or string[] for multi-line)
361
+ pretitle: '', // `#>` label line(s) — also filled by smaller headings
362
+ // stacked above the title, for older content
363
+ // (string or string[])
364
+ subtitle: '', // Line(s) one step below the title — each further
365
+ // one-step descent is another line (string or string[])
366
+ paragraphs: [], // Text blocks
367
+ links: [], // { href, label, role } — standalone links (not inside lists)
368
+ images: [], // { src, alt, role, href }
369
+ icons: [], // { library, name, role }
370
+ videos: [], // { src, alt, role, poster, href }
371
+ documents: [], // { url, alt, caption, role: 'pdf', preview?, author?, description? } — from {role=pdf}
372
+ insets: [], // Inline @Component references — { refId }
373
+ lists: [], // [[{ paragraphs, links, lists, … }]] — each item is an object, not a string
374
+ quotes: [], // Blockquotes
375
+ snippets: [], // Fenced code — [{ language, code }]
376
+ data: {}, // Tagged blocks — ```yaml:tag / ```json:tag give the parsed value,
377
+ // ```md:tag gives { items, sequence } (see Concept blocks)
378
+ tables: [], // Only when present — [{ rows: [{ cells: [{ children, header, align }] }] }]
379
+ headings: [], // Only from nested content (quote/list bodies) — a
380
+ // section's headline never spills here
381
+ items: [], // Each has the same flat structure — from headings after body content
382
+ sequence: [], // All elements in document order
383
+ }
384
+ ```
385
+
386
+ > **Don't predict this — print it.** `uniweb inspect <path>` shows the actual parsed shape of any section or page. The rules below are the ones most often gotten wrong from memory.
387
+
388
+ ### Markdown → content, side by side
389
+
390
+ ```markdown
391
+ #> Eyebrow │ content.pretitle = "Eyebrow"
392
+ # Our Features │ content.title = "Our Features"
393
+ ## Build better products │ content.subtitle = "Build better products"
394
+ │
395
+ We help teams ship faster. │ content.paragraphs[0] = "We help teams…"
396
+ │
397
+ [Get Started](/start) │ content.links[0] = { href: "/start", label: "Get Started" }
398
+ │
399
+ ### Fast │ content.items[0].title = "Fast"
400
+ ![](lu-zap) │ content.items[0].icons[0] = { library: "lu", name: "zap" }
401
+ Lightning quick. │ content.items[0].paragraphs[0] = "Lightning quick."
402
+ │
403
+ ### Secure │ content.items[1].title = "Secure"
404
+ ![](lu-shield) │ content.items[1].icons[0] = { library: "lu", name: "shield" }
405
+ Enterprise-grade security. │ content.items[1].paragraphs[0] = "Enterprise-grade…"
406
+ ```
407
+
408
+ The staircase rule produces this — each heading relates to the one before it: the same size adds another line to the same part; **one step smaller** joins the headline as the next part down (the subtitle, then further subtitle lines); **two steps smaller** starts an item; and once body content has begun, *any* heading starts an item. `#>` label lines become `pretitle`; so do smaller headings stacked above the title, which is the older spelling.
409
+
410
+ ### Items have the full content shape
411
+
412
+ This is the most commonly overlooked feature. Each item has `title`, `pretitle`, `subtitle`, `paragraphs`, `links`, `icons`, `lists`, `snippets`, and `data` — you don't need workarounds for structured content inside items. Heading hierarchy within an item follows the same rules (`####` inside a `###` item becomes that item's `subtitle`).
413
+
414
+ ````markdown
415
+ ### Starter ← items[0].title
416
+ $9/month ← items[0].paragraphs[0]
417
+
418
+ ```yaml:details
419
+ trial: 14 days
420
+ seats: 1
421
+ ``` ← items[0].data.details = { trial: "14 days", seats: 1 }
422
+ ````
423
+
424
+ ### Subtitle vs items — the step rule
425
+
426
+ A heading directly after the title becomes `subtitle` **only when it is exactly one step smaller** (H1→H2, H2→H3) — and each *further* one-step line continues the subtitle (a three-line header is `subtitle: [line2, line3]`). **Two steps smaller starts items** — the idiom for entries with no lead paragraph:
427
+
428
+ ```markdown
429
+ # Our Stats │ content.title = "Our Stats"
430
+ ### 15,000+ │ content.items[0].title = "15,000+"
431
+ Students from 90 countries │ content.items[0].paragraphs[0]
432
+ │
433
+ ### 200+ │ content.items[1].title = "200+"
434
+ Programs offered │ content.items[1].paragraphs[0]
435
+ ```
436
+
437
+ With `## 15,000+` instead, the first stat would join the headline as `content.subtitle`. A `---` divider, or any body content after the headline, also closes it — after body, headings of *any* size start items.
438
+
439
+ ### Multi-line headings
440
+
441
+ Consecutive headings at the same level merge into a title array — one heading split across visual lines. Kit's `<H1>`, `<H2>`, … render arrays as a single tag with line breaks.
442
+
443
+ ```markdown
444
+ # Build the future │ content.title = ["Build the future", "with confidence"]
445
+ # with confidence │
446
+
447
+ # Build the future │ content.title = [
448
+ # [with confidence]{accent} │ "Build the future",
449
+ │ "<span accent=\"true\">with confidence</span>"
450
+ │ ]
451
+ ```
452
+
453
+ **Rule:** a repeated size always continues the part it repeats — title lines, or subtitle lines once the headline has stepped down. Use `---` to force separate items where adjacent headings would otherwise continue the headline.
454
+
455
+ ### Sequential content
456
+
457
+ `content.sequence` is the flat, ordered list of all elements before any grouping. Each element has a `type` (`heading`, `paragraph`, `image`, `codeBlock`, `dataBlock`, `list`, `link`, `divider`, `inset`, …) plus type-specific fields. Use it when grouping isn't the right lens — rendering prose in document order, or finding elements regardless of which group they landed in:
458
+
459
+ ```js
460
+ const allData = {}
461
+ for (const el of content.sequence) {
462
+ if (el.type === 'dataBlock') allData[el.tag] = el.data
463
+ }
464
+ ```
465
+
466
+ Grouped fields and `sequence` are two interpretations of the same content. Grouped suits structured layouts (cards, features); sequential suits prose and cross-group searching.
467
+
468
+ ### Choosing how to model content
469
+
470
+ The decision rule: **would a content author need to change this?** Yes → markdown, frontmatter, or a tagged data block. No → component code.
471
+
472
+ Start with the content, not the component. Write the markdown an author would naturally write, check what shape the parser produces (`uniweb inspect <file>`), *then* build the component to receive it. You have three layers, and most of the design skill is choosing between them:
473
+
474
+ **Pure markdown** — headings, paragraphs, links, images, lists, items. The default. If the content reads naturally as markdown and the parser captures it, stop here.
475
+
476
+ **Frontmatter params** — `columns: 3`, `variant: centered`. Configuration an author might change but that isn't *content*. Would changing this alter the section's *meaning* or just its *presentation*? Presentation → param. Meaning → content.
477
+
478
+ **Tagged data blocks** — for content that doesn't fit markdown patterns: products with SKUs, team members with roles, event schedules, form definitions. When information is genuinely structured data that an author still owns, a well-named block (`yaml:pricing`, `yaml:speakers`) is clearer than contorting markdown. Formats: `yaml` and `json`. Parsed at build time into `content.data.tagName`.
479
+
480
+ Read the markdown out loud. If an author would understand what every line does, you've chosen right. The moment markdown feels like it's encoding data rather than expressing content, step up to a tagged block.
481
+
482
+ **When you are building the foundation, you are designing these, not choosing from a menu.** The examples in this guide illustrate patterns, not exhaustive inventories. Any param name works in `meta.js`, any tag name works for data blocks, any section type name works. The framework has fixed mechanisms (content shape, context modes, token system); nearly everything else is yours to define.
483
+
484
+ **When you are writing content against a foundation that already exists, the opposite holds.** The vocabulary is fixed and finite: the section types are exactly what `sections/**` declares, and their params are exactly what each `meta.js` lists. Inventing a name there doesn't extend the system — it renders a red `Component not found` box on a build that otherwise reports success.
485
+
486
+ **Parameter naming matters.** Would an author understand it without reading code? `columns: 3` yes, `gridCols: 3` no. `variant: centered` yes, `renderMode: flex-center` no. `align: left` yes, `contentAlignment: flex-start` no.
487
+
488
+ ### Repeated values — `placeholders:`
489
+
490
+ A value that appears on many pages goes in `site.yml` once, not in a dozen markdown files:
491
+
492
+ ```yaml
493
+ placeholders:
494
+ product: Uniweb
495
+ vendor:
496
+ email: billing@acme.example
497
+ ```
498
+
499
+ Any page then references it by name — `Write to {vendor.email}.` Dot paths work, and a record's own field of the same name always wins, so `{title}` on an article page is that article's title regardless of what the site declares.
500
+
501
+ ⛔ **Resolution is a foundation capability, not a framework one.** `{…}` is resolved by the foundation's `handlers.content` hook — normally `createLoomHandlers` from `@uniweb/loom`. Under a foundation without one, `placeholders:` is inert and pages render the literal `{vendor.email}`, which reads as a typo. The build warns when a site declares the key and the foundation has no content handler.
502
+
503
+ ```js
504
+ // main.js — a foundation opts in
505
+ import { createLoomHandlers } from '@uniweb/loom'
506
+ export default { handlers: createLoomHandlers({ vars: (data) => data?.profile?.[0] }) }
507
+ ```
508
+
509
+ Placeholders are the simplest case of a larger language: the same expressions filter, sort, count and format live data (`{COUNT OF publications WHERE refereed}`). Reach for one when a value repeats; reach for the rest when content is genuinely derived from records.
510
+
511
+ ### Icons
512
+
513
+ Image syntax with a library prefix — **two interchangeable spellings, the same everywhere** (markdown, and Kit's `<Icon name>`):
514
+
515
+ ```markdown
516
+ ![](lucide:house) colon + friendly name — every library
517
+ ![](lu:house) colon + short code
518
+ ![](lu-house) dash — short codes only, to stay unambiguous with ordinary paths
519
+ ```
520
+
521
+ Short codes: `lu` (Lucide), `hi` / `hi2` (Heroicons v1 / v2), `fi` (Feather), `pi` (Phosphor), `tb` (Tabler), `bs` (Bootstrap), `md` (Material), `ai` (Ant Design), `ri` (Remix), `si` (Simple Icons), `io5` (Ionicons), `bi` (Boxicons), `vsc` (VS Code), `wi` (Weather), `gi` (Game), `fa` / `fa6` (Font Awesome 5 / 6). Browse at [react-icons.github.io/react-icons](https://react-icons.github.io/react-icons/).
522
+
523
+ Optional attributes: `{size=20}`, `{color=red}`. Custom SVGs: `![Logo](./logo.svg){role=icon}` (or `![Logo](icon:./logo.svg)`) — always available, no library needed.
524
+
525
+ ### Links and media attributes
526
+
527
+ ```markdown
528
+ [text](url){target=_blank} <!-- Open in new tab -->
529
+ [text](./file.pdf){download} <!-- Download -->
530
+ ![alt](./img.jpg){role=banner} <!-- Role determines array: images, icons, videos or documents -->
531
+ ![alt](./demo.mp4){role=video} <!-- → content.videos[], not content.images[] -->
532
+ ![alt](./report.pdf){role=pdf} <!-- → content.documents[] — a file, drawn as a link -->
533
+ ![alt](./img.jpg){href=/products} <!-- Clickable media: href + target ride along -->
534
+ ```
535
+
536
+ Sizing and loading ride on the image: `{width=800 height=600 loading=lazy fit=cover position=center}`. A video adds `{poster=./thumb.jpg autoplay muted loop controls}`; a `{role=pdf}` document adds `{preview=./cover.jpg author=… description=…}` and arrives in `content.documents` — `{ url, name, mime, size?, alt, caption, … }` — declare it in `content:` as `documents`.
537
+
538
+ **Quote values containing spaces:** `{note="Ready to go"}`, not `{note=Ready to go}` — unquoted values end at the first space.
539
+
540
+ **Separators are forgiving.** `:` works wherever `=` does, and pairs may be separated by whitespace, a comma, or both — `{role=banner width=1200}`, `{role:banner, width:1200}` and `{role:banner,width:1200}` are identical. `=` and spaces are canonical, and that is what gets written back on save. Two rules keep it unambiguous: the separator must **touch** the key (`{note:warning}` is one pair, `{note : warning}` is two flags), and a value containing a **comma** must be quoted (`{style="a, b"}`). A value containing a colon needs no quoting — only the first colon separates, so `{href:https://example.com}` is fine.
541
+
542
+ Standalone links (alone on a line) become buttons in `content.links[]`. Inline links stay as `<a>` tags inside `content.paragraphs[]`. Multiple links sharing a paragraph are all promoted:
543
+
544
+ ```markdown
545
+ [Primary](/start) ← standalone → content.links[0]
546
+ [One](/a) [Two](/b) ← links-only paragraph → both in content.links[]
547
+ Check out [this](/a) link. ← inline → stays in paragraphs as an <a> tag
548
+ ```
549
+
550
+ ### Inline text styling
551
+
552
+ ```markdown
553
+ # Build [faster]{accent} with structure
554
+ This is [less important]{muted} context.
555
+ ```
556
+
557
+ `accent` and `callout` (both accent-colored + bold) and `muted` (subtle) are built-in defaults that adapt to context. `--accent` is your brand color unless you declare a separate `colors.accent`, and resolves to the shade you authored — not a darkened one, since accent is decorative emphasis rather than body-size link text. Components receive HTML strings with spans applied: `<span accent="true">faster</span>`.
558
+
559
+ Sites can adjust these or add named styles in `theme.yml`'s `inline:` section. Overrides merge **property by property**, so declare only what differs (`accent: { font-weight: inherit }` keeps the default color); to drop a default property rather than change it, give it a neutral value (`initial`, `inherit`, `unset`).
560
+
561
+ > **On a site synced with Uniweb Cloud, the built-in style names round-trip *editable*** — an author who opens a page containing `[Get started]{accent}` gets the editor's own styling control for it, and can change or remove it like any text they styled there.
562
+ >
563
+ > **A name the editor doesn't know is preserved exactly, but not editable** — a style you defined yourself in `inline:`, two names on one span, or a `class`/`id` set. Nothing is lost and nothing is approximated: it survives every edit and save untouched, the editor simply offers no control for it. That is a permanent property rather than a gap, because the name set is open by design — you can add styles the editor has no way to know about.
564
+
565
+ ### Fenced code: data blocks vs snippets
566
+
567
+ Fenced code serves three purposes depending on its info string: `yaml:`/`json:` for data, `md:` for a named kind of prose (see *Concept blocks*), and a bare language for a code sample.
568
+
569
+ **Tagged data blocks** — structured data parsed into JS objects. The tag is the key in `content.data` — for a section whose component declares that key in `meta.js` `data:`; the format (`yaml`/`yml`/`json`) is a serialization format, not a display language.
570
+
571
+ ````markdown
572
+ ```yaml:form
573
+ fields:
574
+ - name: email
575
+ type: email
576
+ submitLabel: Send
577
+ ```
578
+ ````
579
+
580
+ → `content.data?.form` = `{ fields: [...], submitLabel: "Send" }`, when the component declares `data: { form: … }`. A block under a key the component does not declare stays out of `content.data` (the dev console says so) and is still in `content.sequence`.
581
+
582
+ **Code snippets** — display content with a language for syntax highlighting, collected in `content.snippets` as `[{ language, code }]`. Filter with `content.snippets.filter(s => s.language === 'css')`.
583
+
584
+ Both appear in `content.sequence`. `<Prose>` handles the difference automatically — it renders snippets with highlighting and skips tagged data blocks, which components access separately via `content.data`.
585
+
586
+ ### Math (LaTeX)
587
+
588
+ Authors write LaTeX directly; it compiles to MathML Core **at build time** — no runtime math library, no extra CSS. Three forms, matching Pandoc / GitHub / VS Code / Jupyter / Obsidian convention:
589
+
590
+ | Form | Mode |
591
+ |---|---|
592
+ | `$x^2$` | Inline |
593
+ | `$$x^2$$` | Display (block on its own line, inline display mid-paragraph) |
594
+ | ` ```math ` fence | Display (multi-line friendly) |
595
+ | `\$` | Literal `$` |
596
+
597
+ **Disambiguation gotcha:** a `$…$` span counts as math only when the body has no whitespace next to the delimiters *and* the closing `$` isn't immediately followed by a digit. So `It costs $5 and $10 total` and `Budget: $200` stay prose without escaping, while `Let $f(x) = 5$ be a function` is math. Use `\$` when the rules would otherwise trip.
598
+
599
+ Math rides the normal content pipeline — it appears in prerendered HTML, survives `compile('epub')` and `compile('pagedjs')`, and roundtrips through the editor. Component code needs nothing special.
600
+
601
+ ### Lists as navigation menus
602
+
603
+ Markdown lists model nav, menus, and grouped links. Each list item is a full content object with `paragraphs`, `links`, `icons`, and nested `lists`.
604
+
605
+ ```markdown
606
+ - ![](lu-home) [Home](/) ← content.lists[0], each item.links[0] + item.icons[0]
607
+ - ![](lu-book) [Docs](/docs)
608
+
609
+ - Product ← nested: group.paragraphs[0] is the label,
610
+ - [Features](/features) ← group.lists[0] holds sub-items
611
+ - [Pricing](/pricing)
612
+ ```
613
+
614
+ **For richer navigation** with icons, descriptions, or hierarchy, use a `yaml:nav` tagged block:
615
+
616
+ ````markdown
617
+ ```yaml:nav
618
+ - label: Dashboard
619
+ href: /
620
+ icon: lu:layout-grid # same icon spellings as anywhere else —
621
+ - label: Docs # lu:name, lucide:name, lu-name, or an SVG path
622
+ href: /docs
623
+ icon: /icons/book.svg
624
+ children:
625
+ - label: Getting Started
626
+ href: /docs/quickstart
627
+ ```
628
+ ````
629
+
630
+ Access: `content.data?.nav`, with `data: { nav: {} }` in the component's `meta.js` — an array of `{ label, href, icon, text, children, target }`. Components can support both modes: use `content.data?.nav` when provided, fall back to `website.getPageHierarchy()`. Full pattern: `reference/navigation-patterns.md`.
631
+
632
+ ### Section backgrounds
633
+
634
+ Set `background` in frontmatter — the runtime renders it:
635
+
636
+ ```yaml
637
+ background: /images/hero.jpg # Image
638
+ background: /videos/hero.mp4 # Video
639
+ background: linear-gradient(135deg, #667eea, #764ba2) # Gradient
640
+ background: '#1a1a2e' # Color (hex — quote in YAML)
641
+ background: primary-900 # Palette token (bare name or var())
642
+
643
+ background: # Object form for more control
644
+ image: { src: /img.jpg, position: center top }
645
+ overlay: { enabled: true, type: dark, opacity: 0.5 }
646
+ ```
647
+
648
+ Components that render their own background declare `background: 'self'` in `meta.js`, and the runtime then draws none; to draw the author's background themselves they render kit's `<SectionBackground block={block} />`.
649
+
650
+ ### Page organization
651
+
652
+ ```
653
+ site/layout/ # header.md, footer.md, left.md — rendered on every page
654
+ site/pages/home/
655
+ ├── page.yml # title, description, order
656
+ ├── 1-hero.md # Numeric prefix sets order
657
+ ├── 2-features.md
658
+ └── 3-cta.md
659
+ ```
660
+
661
+ Decimals insert between: `2.5-testimonials.md` goes between `2-` and `3-`. **Ignored:** `README.md`, and `_*.md` / `_*/` (drafts and private files).
662
+
663
+ ```yaml
664
+ # page.yml
665
+ title: About Us
666
+ id: about # Stable identity (for page: links, survives moves)
667
+ order: 2 # Navigation sort position
668
+ sections: [hero, team, ...] # Section order within this page (same ... rule)
669
+ pages: [team, history, ...] # Child page order, when this page has child folders
670
+ redirect: academic # Redirect to a child page (relative/absolute path, or URL)
671
+ slug: { fr: a-propos } # Localized URL segment per language
672
+
673
+ # folder.yml — a folder OF PAGES, where page.yml means a page built from sections
674
+ title: Getting Started
675
+ pages: [quickstart, app-tour, ...] # Child page order (... = rest)
676
+
677
+ # site.yml
678
+ index: home # Just set the homepage
679
+ pages: [home, about, ...] # Order pages (... = rest, first = homepage)
680
+ foundation: '@acme/ui@1.2.0' # The component system (see Part 2, step 1)
681
+ extensions: ['@acme/fx@0.3.1'] # Secondary foundations — same shapes as foundation:
682
+ ```
683
+
684
+ **The trailing `...` is not decoration — dropping it changes behaviour.** `pages:`
685
+ in `folder.yml` and `site.yml`, and `sections:` in `page.yml`, all read it the same way:
686
+
687
+ | written | means |
688
+ |---|---|
689
+ | `[a, b, ...]` | **inclusive** — `a` and `b` pinned in that order, **everything else follows** |
690
+ | `['...']` | identical to omitting the key |
691
+ | `[a, b]` — no `...` | **strict** — and every unlisted sibling is dropped from **every** menu |
692
+
693
+ ⚠️ **Strict is a navigation filter, not a delete, which is what makes it easy to
694
+ miss.** Unlisted pages stay routed, stay in `dist/`, stay in `llms.txt` and still
695
+ resolve by URL — only the links to them disappear. Your route count does not
696
+ change and nothing warns. **If you list pages in order and mean "these first",
697
+ end the list with `...`.**
698
+
699
+ **Configuration cascades: `page.yml` → `folder.yml` → `site.yml` → foundation defaults.** Each level inherits from the one above and overrides specific values, the way CSS specificity works. This is what makes bulk assignment natural — put `layout: marketing` in a `folder.yml` and every page in that folder inherits it, while one page can still override with its own `page.yml`. Reach for `folder.yml` before editing the same key into a dozen `page.yml` files.
700
+
701
+ **Route mapping:** folder structure maps 1:1 to routes. Every folder keeps its natural route — `pages:` controls **order only**, not which child "becomes" the parent. The only exception is the site root, where `index:` (or first in `pages:`) sets `/` — declared in `site.yml`, or in the pages directory's own `folder.yml` / `page.yml` (how a content repo carries its order), with `site.yml` winning.
702
+
703
+ **Content-less containers:** folders with `page.yml` but no markdown are structural groups (`hasContent: false`). Visiting one auto-redirects to the first descendant with content — this is what supports courses → modules → lessons at any depth.
704
+
705
+ **A local folder for a mounted route should use `folder.yml`, not `page.yml`.** The filename is how you say what the folder holds — `page.yml` for a page built from sections, `folder.yml` for a folder of pages — and for a mount that answer also decides how the mounted tree is read. A `page.yml` stub over a mounted folder of pages says "sections", and top-level markdown in the mounted repo collapses into one page instead of becoming pages of its own. Keep `layout:`, `title:` and SEO in that stub; the mounted repo's own `folder.yml` supplies its ordering and title where the stub is silent.
706
+
707
+ **SEO & social cards:** set site-wide defaults in `site.yml`; any page overrides per-field in `page.yml` (page wins, site fills gaps). These render into every page's static `<head>` — Open Graph, Twitter Card, canonical, robots — so shares and crawlers see them without running JS. The social `image` is the field most worth setting once at site level.
708
+
709
+ ```yaml
710
+ # site.yml
711
+ keywords: [components, react, cms]
712
+ seo:
713
+ image: /og-default.png
714
+ noindex: false # true keeps the WHOLE site out of search
715
+
716
+ # page.yml
717
+ seo:
718
+ image: /og-about.png
719
+ canonical: https://acme.com/about
720
+ ```
721
+
722
+ **Localized URLs:** on a multilingual site (`languages:` in site.yml), `slug: { <lang>: <segment> }` gives a page a native URL segment per language; the folder name stays the canonical route. Nested folders compose automatically, and localized URLs flow through navigation, the language switcher, and the sitemap. See Part 5 for the translation workflow.
723
+
724
+ **Knowledge pages — content for an AI agent, not for a visitor.** `knowledge: true` in a `page.yml` or `folder.yml` marks a page as agent-only: it is **never rendered** — no route, no HTML — and it cascades to the whole subtree by route prefix, so one marker on `pages/kb/` covers everything under it and `/kb` never claims `/kbase`. Write plain markdown; a section `type:` there never selects a component, because nothing renders these pages.
725
+
726
+ ⚠️ **A knowledge page is not published to anyone — it powers a service your site offers.** Everything your site *does* publish is published on equal terms: an AI visiting your site sees exactly what a person sees. `llms.txt` and the per-page `.md` files are a convenience for that reader, not a privileged tier — no visitor, human or machine, gets more than another.
727
+
728
+ A knowledge page is different in kind, not in audience. It is never a page anyone reads; it is source material for an assistant the site offers **to its visitors** — they ask, they get an answer, and the material behind it stays unpublished. That is why it is kept out of `llms.txt`, the `.md` projections and the search index: not because some readers are trusted less, but because a knowledge page was never a page.
729
+
730
+ ⇒ If what you want is "make this readable by any AI that visits", write an ordinary page. It is already published to them, on the same terms as to everyone else.
731
+
732
+ ⛔ **`knowledge:` is not a security control — don't put secrets there.** "Not published" is about who the prose is *written for*, not about confidentiality. The assistant's job is to answer visitors using this material and it can quote it back to whoever asks, so a visitor who prompts it can surface what a knowledge page says — **by design, not by leak.** The test for what belongs here is "is this written for the assistant to reason with?", not "do I want to keep this from people?" Anything that genuinely must not reach a visitor doesn't belong in your site's content at all.
733
+
734
+ ```yaml
735
+ # pages/kb/page.yml
736
+ title: Product Knowledge
737
+ knowledge: true
738
+ ```
739
+
740
+ What can actually *read* it depends on where you deploy, because reading it needs a server:
741
+
742
+ - **`uniweb deploy`** (backend-hosted) — the content travels with the site, so an assistant running over it can grep and read your knowledge material alongside the public content when answering a visitor's question.
743
+ - **`uniweb export` / `uniweb deploy --host <adapter>`** (static host) — **the build drops these pages and says so**: `Dropped 2 knowledge page(s) from this build: /kb, /kb/pricing`. Files are served as files here; there is no assistant, so nothing would read them. The warning means the flag was honoured.
744
+
745
+ The artifacts **your build emits** to describe the public site never name them — `llms.txt`, the per-page `.md` projections, and the search index all describe pages a visitor reads, and a knowledge page's prose was written for the assistant instead. Two settings **outrank** `knowledge:`, so a contradiction resolves toward the narrower reach: `agents.exclude` in `site.yml`, and any `_`-prefixed route segment. *(A host that derives these artifacts itself, rather than serving the ones your build produced, applies its own rules — ask your host what it does with `knowledge:` before relying on it there.)*
746
+
747
+ Don't confuse it with the visibility flags: `hidden: true` is a **draft** (not published at all), `hideIn` only controls **nav placement** (still reachable by URL), and `knowledge: true` is a **different audience** (never rendered for anyone). ⛔ `hidden: true` on `pages/404/` means a **draft 404** — it is not published and the site falls back to the generic `404.html`; an authored 404 needs no flag, since it is never listed in navigation anyway. A **section** takes `hidden: true` in its frontmatter with the same meaning: previewed in `uniweb dev`, left out of a published build with its child sections, and never passed to the component as a param. (`_name.md` is the other way to keep a section out: no build reads it, dev included.)
748
+
749
+ **A site can be nothing but knowledge — that is a real and useful shape.** A site is a set of routes; it does not have to have HTML pages. Mark the root `knowledge: true` and every page inherits it, so the site renders nothing at all and exists to *be* an agent: a `/_agent/chat` URL that a web app, a mobile app, or someone else's backend sends requests to.
750
+
751
+ ```
752
+ site/pages/
753
+ ├── page.yml # knowledge: true — cascades to everything
754
+ ├── house-style.md
755
+ ├── pricing.md
756
+ └── support.md
757
+ ```
758
+
759
+ Measured on exactly that shape: **the agent corpus holds every page; the public projections hold nothing** — `llms.txt` lists no pages, the search index has 0 entries, and no `.md` files are emitted. Nothing leaks, by construction rather than by configuration, because there is no public surface to leak onto.
760
+
761
+ ⛔ **This shape needs a host that actually runs an agent, and deploying is not the same as having one.** Two ways to end up with nothing:
762
+
763
+ - **A static host** (`uniweb export`, `deploy --host`) drops every page — they have no reader there — and you get an empty SPA shell. The build still says *"complete"*, because 0 pages is not an error: it reports `Collected 0 pages` and pre-renders none.
764
+ - **A backend-hosted deployment that does not offer the service.** Whether an agent endpoint exists is the host's to decide, per site — it is not implied by deploying successfully.
765
+
766
+ ⇒ **The way to know is to ask, at render:** `isAssistantEnabled()` from `@uniweb/kit` is true only where an agent endpoint is declared. On a site that is *only* knowledge there is no component to ask — so confirm with your host that the agent is enabled for that site before you build an integration against it. If you meant to build an agent endpoint and got silence, this is where to look.
767
+
768
+ ### Your site is readable by agents, automatically
769
+
770
+ Every build emits two things an AI agent can use directly, alongside the HTML. **Free, on by default, nothing to install.**
771
+
772
+ | Artifact | What it is |
773
+ |---|---|
774
+ | `/llms.txt` | An annotated index of the site — every page, with a one-line description, linking to the `.md` below |
775
+ | `/{route}.md` | Each page as clean markdown: the content, no navigation, no chrome |
776
+
777
+ The point is the pair. An agent fetches the index, *reads* what each page is about, and goes straight to the one it needs — two requests, no HTML stripping, no guessing at URLs. Descriptions come from `page.yml`'s `description:`, then `seo.ogDescription`, then the page's opening paragraph, so a site usually gets a complete index without writing any of them.
778
+
779
+ **Large sites also get per-branch indexes.** A branch with at least 5 pages gets its own — `/docs/llms.txt` lists just the docs, titled by that folder. The site index still lists everything; a branch index is an extra entry point, not a replacement, so an agent that starts at `/llms.txt` still reaches any page in two hops.
780
+
781
+ ```yaml
782
+ # site.yml — all optional; these are the defaults
783
+ agents:
784
+ index: true # /llms.txt
785
+ markdown: true # /{route}.md
786
+ branchIndexes: true # /docs/llms.txt for branches big enough to want one
787
+ branchMinPages: 5 # how big is big enough
788
+ exclude: [/internal] # keep a branch out of both (cascades)
789
+
790
+ agents: false # or turn the whole thing off in one word
791
+ ```
792
+
793
+ #### Expecting agent traffic from somewhere specific
794
+
795
+ If your site runs an agent (see *Knowledge pages* above), you can declare where its requests are expected to come from. Useful when the site exists to serve someone else's app rather than its own visitors — a chat endpoint your web app calls.
796
+
797
+ ```yaml
798
+ # site.yml
799
+ agents:
800
+ expectedOrigins:
801
+ - https://app.example.com
802
+ - https://partner.example.org
803
+ ```
804
+
805
+ **It is a declaration, not a gate — the name is the contract.** You are telling the host where you expect callers from; a host that offers an agent may then decline requests whose browser-sent `Origin` is not one of them, before it spends anything answering. Behaviour where it is honoured:
806
+
807
+ | | |
808
+ |---|---|
809
+ | not declared | no check at all — nothing happens unless you ask for it |
810
+ | your own site's origin | always fine, without listing it — declaring a partner must not take down your own chat box |
811
+ | a request with **no** `Origin` | **allowed** — see below, this is deliberate |
812
+ | matching | exact, per entry; no wildcards |
813
+
814
+ ⛔ **It is not a spend limit, and it cannot be.** `Origin` is set by the browser and enforced by the browser: anything that is not a browser sends whatever it likes, or nothing at all. So this stops **casual misuse and third-party embedding** — someone dropping your endpoint into their own page — and it does **not** stop anyone determined to run up your bill. Do not size your budget on it.
815
+
816
+ ⭐ **And that is why a request with no `Origin` is allowed rather than refused.** There are two shapes of caller and this check only sees one:
817
+
818
+ - your app's **JavaScript**, in a browser — sends `Origin`, covered here;
819
+ - your app's **server**, calling from its own backend — never sends one, and refusing it would break the integration this feature partly exists for.
820
+
821
+ Server-to-server callers are authenticated by other means, arranged with your host — not by anything in `site.yml`. **Never put a shared secret in `site.yml`:** everything in it is delivered to every visitor as part of the site payload.
822
+
823
+ ⚠️ Spelling matters and nothing downstream will catch it — the block is forwarded to your host as opaque data, so a typo is carried and simply never acted on. `uniweb doctor` flags unknown keys in `agents:` for exactly this reason; run it before you rely on one.
824
+
825
+ **Set `seo.baseUrl` if you want absolute links** in the index — without it the links are root-relative, which still works for an agent that arrived via the index. `uniweb doctor` warns when it's unset.
826
+
827
+ **What's excluded, and it's deliberate:** `seo.noindex` pages, `hidden` pages, `_`-prefixed drafts, and a parametric page's URL pattern (`/blog/:slug`). An index *describes* pages rather than merely listing them, so an unlinked page would become both discoverable and summarized — which is why these exclusions are load-bearing rather than tidy-up. `noindex` or `hidden` on a **folder** takes the whole branch with it.
828
+
829
+ **Declaring how your content may be used** is a separate axis from whether it may be fetched, and it goes in `seo.robots`:
830
+
831
+ ```yaml
832
+ # site.yml
833
+ seo:
834
+ robots:
835
+ contentSignals:
836
+ search: true # may appear in search results
837
+ ai-input: true # may be retrieved at inference time
838
+ ai-train: false # may not be used to train a model
839
+ ```
840
+
841
+ That emits a `Content-Signal:` line in `robots.txt`. Declare only what you mean — an omitted signal says nothing, which is not the same as saying no.
842
+
843
+ ### Records, queries and parametric pages
844
+
845
+ Most content lives in `pages/` — a fixed composition of sections on a fixed set of pages. **The other kind is a set of records: repeating content managed as one file per item**, that pages pull from. Blog posts, team members, products, case studies, bibliographies.
846
+
847
+ It is a small database, not a page tree. Three things, deliberately separate:
848
+
849
+ ```
850
+ site/
851
+ ├── pages/
852
+ ├── records/ # your site's records — every file in a schema folder is one
853
+ │ ├── article/ # the folder names their data schema
854
+ │ │ ├── getting-started.md
855
+ │ │ └── design-tips.md
856
+ │ ├── person/
857
+ │ │ └── alice.yml
858
+ │ └── folder.yml # optional: sorts records into folders
859
+ ├── queries.yml # how content is REACHED
860
+ └── site.yml
861
+ ```
862
+
863
+ **`records/{schema}/` — the site's records.** Putting a file here is what makes it a record; nothing else lists it. The folder names the data schema and nothing else:
864
+
865
+ | on disk | schema |
866
+ |---|---|
867
+ | `records/article/…` | `@/article` — your foundation's own |
868
+ | `records/std/person/…` | `@std/person` — the shared standard set |
869
+ | `records/acme/project/…` | `@acme/project` — an org's |
870
+
871
+ Keep those folders flat: `records/article/design-tips.md` works; `records/article/2025/design-tips.md` is read as the `2025` schema of an `article` org, which is not what you meant. Organise in `records/folder.yml` instead. A file whose name starts with `_` is not a record — somewhere to keep work in progress.
872
+
873
+ **Links are records too — `@uniweb/link`.** A file in `records/uniweb/link/` holding `url:` and nothing else is a link record (one per file; no body, no other fields). Its display text and tags go on its `records/folder.yml` entry (`- path: uniweb/link/launch-video.yml` with `label:` / `tags:`), like any record's. Select links with a query `schema: '@uniweb/link'` and declare them in `meta.js` as `data: { videos: '@uniweb/link' }`; each arrives as `{ url, $name, $label, $tags }`. A link's `url` is absolute (`https://…`), and `uniweb push` refuses a relative one and a `draft: true` link.
874
+
875
+ **Files are records too — `@uniweb/file`.** The file itself is the record: any file dropped into `records/uniweb/file/` (a PDF, a spreadsheet, any type) is a record named by its file name without the extension; nothing in it is read. Its label and tags go on its `records/folder.yml` entry (`- path: uniweb/file/brochure.pdf`). Query with `schema: '@uniweb/file'`, declare with `data: { downloads: '@uniweb/file' }`; each arrives as `{ file: { url, name, mime, size }, $name, $label, $tags }` — link to `file.url`. `uniweb push` uploads the file with the site's media; a pull fetches it back. Two files of one name with different extensions clash — rename one. On a site that goes to a backend, a component must declare the type (`@uniweb/link` or `@uniweb/file`) for a query over it to be accepted.
876
+
877
+ **Four formats, one shape.** All of these produce the same records at runtime:
878
+
879
+ | Format | Best for | Notes |
880
+ |---|---|---|
881
+ | `.md` | Items with prose — articles, case studies | Frontmatter holds fields, body holds text; auto-excerpt and first-image extraction |
882
+ | `.yml` / `.yaml` | Structured records with no prose body — team, products | Cleaner than a markdown file with an empty body |
883
+ | `.json` | Same as YAML | When the data comes out of another tool |
884
+ | `.bib` | Bibliographies | Each `@entry{key, …}` is a record, its cite key its name (`$name`); normalized to CSL-JSON, LaTeX accents converted to Unicode |
885
+
886
+ **One record per file.** A file's name is its record's name (`records/team/alice.yml` is `alice`); a `slug:` key at its top, or a file holding a list, stops the build — to rename a record, rename its file. A `.bib` file holds many entries, each named by its cite key. **A component reads a record's name as `$name`** — the one name it carries on every site; the delivered record has no `slug`. You can mix both in one folder — an exported `refs.bib` beside a hand-written `extras.yml`.
887
+
888
+ Item frontmatter conventionally uses `title`, `date`, `tags`, `image`, `description`, `author` — plus any fields your content needs (`price`, `role`, `order`). Images can sit beside the item file and be referenced with `./`. **`draft: true` keeps a record off the live site without deleting it**: it is still a record, in `records/`, and `pnpm dev` shows it so you can preview it, but a build for hosting leaves it out, and a backend you `uniweb push` it to keeps it in the site's folder without delivering it. (`published: false`, an older spelling, is refused.)
889
+
890
+ **A record's file follows its data schema's sections.** A schema with one section (the `fields:` form) is written flat, as above. A schema with more than one is written **by section** — each field under its section's name — and a field left at the top stops the build and a push, naming where it goes. `@std/article` has two, its card `brief` and `body`:
891
+
892
+ ```markdown
893
+ <!-- records/std/article/hello.md -->
894
+ ---
895
+ brief:
896
+ title: Hello
897
+ author: Ada
898
+ date: 2026-05-01
899
+ ---
900
+
901
+ The body — the value of the schema's content field, `body.content`.
902
+ ```
903
+
904
+ **A component receives it in one of two shapes, and its `meta.js` says which**, per `data:` key — the same on a static site and a hosted one. `data: { articles: '@std/article' }` gets **briefs**: the brief's fields at the top (`article.title`), no other section. `data: { articles: { schema: '@std/article', whole: true } }` gets each record **whole**, as stored: every section under its own name, the brief's included (`article.brief.title`, `article.body.content`). Both carry `$name`, the handle. Lists carry briefs; a section that shows a record in full declares `whole: true`. A `fields:` schema's brief is the whole record, so its components need no `whole: true`. Full rules: `reference/entity-content.md`.
905
+
906
+ **A reference names the record it points at.** A `{ ref: '@/speaker' }` field is written as that record's name — `speaker: ada` for `records/speaker/ada.yml` (its file's name) — and a `many` one as a list of names. A component receives `{ entity, brief }`: the record reduced to its brief, so `talk.speaker.brief.name`. `uniweb validate` and a push report a name that matches no record; a push sends each reference to the record it names (in the same push when that record is new too), and a pull writes the name back.
907
+
908
+ **`records/folder.yml` — optional, and only for folders.** It is the one file in `records/` that is not a record, it moves with the directory (`paths.records`), and like a pages folder's `folder.yml` it describes the folder it sits in. Every record sits at the top of the site's records folder unless `folder.yml` places it in a sub-folder. Add one only when a query needs to ask for a *slice* of the records rather than all of them — most sites never do:
909
+
910
+ ```yaml
911
+ - folder: archive
912
+ label: The Archive
913
+ records:
914
+ - article/2025-*.md
915
+ ```
916
+
917
+ A path under a folder is relative to `records/`, naming one file or matching many. A record's entry can instead be `{ path, tags, label }` — what the folder says about the record: a component receives them beside it as `$tags` (in the order written) and `$label`, and a query reads them like any key (`where: { $tags: featured }`); such an entry may sit at the top level, where a bare path is refused. A `label` — a record entry's or a folder's — is one text in the site's language, or one per language (`label: { en: Launch video, fr: Vidéo de lancement }`); `$label` is the page's language, else its base language, else the site's. A folder takes `tags:` too. A query reads one folder with `scope: archive` — the folder and everything inside it. A record sits in one folder; if you want a computed subset, that is a query, not a second placement. Structure is for querying, not for navigation — and `folder.yml` never lists a record at the top level (the build refuses it: every file in `records/` is a record already).
918
+
919
+ ⚠️ **With a backend, `records/` is what `uniweb push` sends** — every record in it. A site with no `records/` at all leaves the backend's records alone; an *empty* `records/` removes them, and the CLI asks before it does. Records pushed to a backend are served once the site is published.
920
+
921
+ **`queries.yml` — how content is reached.** A bare map of name → query. A query names a schema and the site's records of that schema are its rows:
922
+
923
+ ```yaml
924
+ recent:
925
+ schema: '@/article'
926
+ sort: date desc # `date desc`, `title asc`, …
927
+ limit: 10
928
+
929
+ team:
930
+ schema: '@std/person'
931
+ where: { active: true } # a predicate — see authoring/predicates.md
932
+ ```
933
+
934
+ You can keep the same declarations under `queries:` in `site.yml` instead, if you would rather have one file.
935
+
936
+ **Show a query on a page** with `query:` in `page.yml` or a section's frontmatter (the whole result), or `fetch:` for anything more — a `limit`, a `where`. A list — `query: [team, articles]` — declares several, each filling its own key in the sections whose components declare it. `query:` takes names only; `data:`, its old name, is now an error.
937
+
938
+ **Who receives it:** a section's own declaration reaches that section; a page's, every section on the page; a parent page's, the sections of the pages directly under it. **The site is the root page:** `query:` in `site.yml` reaches the layout areas (header, footer, …) and the sections of top-level pages — the homepage included — and no page further down. A section on `/docs/setup` that needs site-wide data names the query itself.
939
+
940
+ **A fetch always names a query.** A string in `fetch:` is a query name: `fetch: team` is `fetch: { query: team }`, and `fetch: [team, articles]` is a list of those. A fetch never names a file — `/data/<query>.json` is what the build generates from a query for a site with no backend, and writing that path in a `fetch:` stops the build. That is what keeps a site portable: name the query, debug locally against the generated data, publish, and the same page reads live records from the host with nothing changed.
941
+
942
+ **A query selects a set of records; a `fetch:` takes from that set, never adding to it.** The query's `scope`, `where`, `sort` and `limit` decide which records it selects — its `limit` included: `recent` above is the 10 newest articles. A fetch's `where` keeps the ones that also match, its `sort` re-orders them, and its `limit` takes the first N — never more than the query selects. That is the whole list — `scope` belongs to the query, and the build stops on a `fetch:` that carries one; for another folder branch, declare another query. **Which records get a page is the query's to decide, never a fetch's:** each record `recent` selects gets its page, an 11th-newest article gets none, and a list's `limit: 3` still leaves all 10 their pages — so a condition or count that should decide which pages exist belongs on the query. Two fetches under one key at one level: the first is used, and the build warns.
943
+
944
+ ```yaml
945
+ # pages/blog/page.yml | # a section on the homepage
946
+ title: Blog | ---
947
+ query: recent | type: ArticleTeaser
948
+ | fetch: { query: recent, limit: 3 }
949
+ | ---
950
+ ```
951
+
952
+ **Give each record its own page with a `[slug]/` folder** — a *parametric page*, what other frameworks call a dynamic route — inside the page that names the query:
953
+
954
+ ```
955
+ pages/blog/
956
+ ├── page.yml # title: Blog / query: recent
957
+ ├── list.md
958
+ └── [slug]/
959
+ ├── page.yml
960
+ └── article.md # just `type: Article` — the record arrives automatically
961
+ ```
962
+
963
+ `records/article/design-tips.md` becomes `/blog/design-tips`. The section inside `[slug]/` needs no special markdown — the matched record is delivered to it. Generated pages are excluded from navigation menus.
964
+
965
+ **Link a card with `item.$route` — never compose the URL.** Every record a query delivers carries `$route`, the URL of the parametric page whose route query is that query (`/blog/design-tips`), wherever the list appears — the query's own page, the homepage, a sidebar. No page for the query, or no value for the field its URL is built from, means no `$route`, never a broken one. `detailPage: page:<id>` on a fetch links the records to another page instead. `$` marks a field the framework fills, so a record's own `route` field is left alone. ⛔ `route:` on a query is retired and stops the build.
966
+
967
+ **Which query the URL narrows — the page's route query:** the `[slug]` page's own `query:`, else its parent page's (the usual shape, above), else `site.yml`'s (for a top-level `pages/[slug]/` only — the site's reaches no deeper page); if none declares one, the query its sections all declare. The first query of that level wins. Every section the route query reaches gets the one record; a section declaring a *different* query of its own gets that query as declared. The folder name says what the URL segment matches: `[slug]` the record's name (`$name`, a compiled record's file name) — or the field its route query binds `:slug` to (`where: { slug: :slug }`, how an external API's records, which have no `$name`, get pages), `[uuid]` its `$uuid`, any other `[name]` the record's own field of that name — and when that field holds several values, **any member** matches (the record's own link is its first value). Routing by a field that is not unique picks one record and which one is not guaranteed; the build warns. A folder inside `[slug]/` (`[slug]/cv/` → `/blog/:slug/cv`) is a parametric page too, about the same record: its route query is the `[slug]` page's, wherever that is declared, and a query `cv/` declares itself arrives under its own key. **A section chooses how it uses the page's record with `current:`** on its own `fetch:` — `only` (the default: the record), `exclude` (the others: "related"), `include` (all of them: a pager) — e.g. `fetch: { query: articles, as: related, current: exclude, limit: 3 }`, where `limit` counts the others, all from the query's set. **`current:` follows the query, not the key:** a fetch of the route query gets the record unless it says otherwise, whatever its `as`; a fetch of another query gets that query's records and reads `current:` only when written (`exclude` drops the page's record, `only` keeps just it). `refine: true` / `detail: false` are retired and stop the build. `[dir]` and `[path]` are refused as folder names, and so is any folder inside `[...path]/`.
968
+
969
+ > **The record arrives under the key the component declares, as that key says** — one record for a key declared `single: true` (`content.data.article` for `article: { schema: '@std/article', single: true }` over an `@std/article` query; `null` when the URL names none), a single-element array for any other key (`content.data.recent[0]`). It is the record's brief, or the record whole where the key declares `whole: true` — a section that shows an article's body declares `whole: true`. See *Data* in Part 4.
970
+
971
+ **Records with URLs of their own shape — `[...path]/`.** A folder named exactly `[...path]` (one fixed spelling) captures the rest of the URL: `/blog/my-post` and `/blog/rust/2025/my-post` both reach it. The capture yields three standard variables — `:path` (the whole capture), `:dir` (everything before the last segment), `:slug` (the last segment, the record's handle) — and the record is still delivered by its handle, so the section reads it as before. The same three variables exist under every parametric page: under `[slug]`, `:slug` and `:path` are the segment and `:dir` is empty. A record's own URL (`$route`, and the page a static build writes) is its name, as under `[slug]`: `/blog/my-post` wherever `records/folder.yml` places it — a record does not carry its folder. A query may bind a part — `scope: :dir` exposes the folder branch, `where: { tag: :dir }` keeps it private — and an unbound or empty variable drops its clause, so one saved query serves the query's page and its parametric page, on a static site and a hosted one alike. Without `scope: :dir` the directory is decoration: the record is found by its handle wherever it sits. Reference: `reference/dynamic-routes.md`.
972
+
973
+ **Two options for bigger sets:**
974
+
975
+ A list carries briefs, so a schema that keeps its heavy parts out of the brief — `@std/article`'s body is its own section — keeps lists light with no configuration, while a section declaring `whole: true` still receives the whole record on a `[slug]` page, and other components fetch it on demand with `useWholeRecord`. File-based records are written one file per record, whole, at `/data/<name>/<name of the record>.json`; an external query names its one-record request with `record:` instead (*Fetching from other sources* in Part 4). (`deferred:` is removed: a schema's brief decides what a list holds.)
976
+
977
+ `queryable:` declares which fields a reader may filter on, with enough metadata for the foundation to render controls:
978
+
979
+ ```yaml
980
+ # queries.yml
981
+ members:
982
+ schema: '@std/person'
983
+ queryable:
984
+ department: { type: enum, label: Department, options: [biology, physics, chemistry] }
985
+ tenured: { type: boolean, label: Tenured }
986
+ start_year: { type: range, label: Start year, min: 1800, max: 2025 }
987
+ ```
988
+
989
+ The site declares the *surface*; the foundation reads the metadata, renders matching controls (dropdown, toggle, slider), and composes the predicate when the reader picks values.
990
+
991
+ Full author guide: `authoring/collections.md`. Every key a query takes: `reference/queries.md`. Predicate operators: `authoring/predicates.md`.
992
+
993
+ ---
994
+
995
+ ## Part 4 — The foundation lane
996
+
997
+ You're not building pages — you're building a **system** of section types that authors compose into pages. Name by purpose, not content: `Testimonial` not `WhatClientsSay`, `SplitContent` not `AboutSection`. Expect consolidation: a React site with 30+ components typically maps to 8–15 Uniweb section types.
998
+
999
+ ### Where things go
1000
+
1001
+ ```
1002
+ src/ # the foundation package (folder name is `src`)
1003
+ ├── sections/ # Section types (auto-discovered)
1004
+ │ ├── Hero.jsx # Bare file — no folder needed
1005
+ │ ├── Features/ # Folder when you need meta.js
1006
+ │ │ ├── index.jsx
1007
+ │ │ └── meta.js
1008
+ │ └── insets/ # Organizational subdirectory (lowercase)
1009
+ ├── layouts/ # Custom layouts (optional, auto-discovered)
1010
+ ├── components/ # Your React components — any props you like, no meta.js
1011
+ ├── utils/ # Helper functions, non-React logic
1012
+ ├── main.js
1013
+ ├── styles.css
1014
+ └── package.json # name: "src"
1015
+ ```
1016
+
1017
+ **Discovery:** PascalCase files and folders at the root of `sections/` are auto-discovered. Nested levels require a `meta.js`. Lowercase directories are organizational only. `hidden: true` excludes a component entirely. Everything outside `sections/` and `layouts/` is ordinary React.
1018
+
1019
+ **JSX goes in `.jsx` files** (or `.tsx`). Discovery also accepts a `.js` component, but only one without JSX: JSX compiles in no `.js` file, and the build stops on one, naming the file to rename.
1020
+
1021
+ **Source root.** The foundation package's source lives at the package root — the `src/` folder *is* the foundation. The build reads `package.json::main` (for new scaffolds, `main: "./_entry.generated.js"`). Older foundations nest source in `foundation/src/` with `main` pointing at `./src/_entry.generated.js`; both work through the same code path.
1022
+
1023
+ **Import aliases.** Foundations include subpath imports for shared internals — use them instead of brittle relative paths:
1024
+
1025
+ | Alias | Maps to | Use for |
1026
+ |-------|---------|---------|
1027
+ | `#components/*` | `./components/*` | Shared React components |
1028
+ | `#utils/*` | `./utils/*` | Helper functions, non-React logic |
1029
+
1030
+ ```jsx
1031
+ import LessonHeader from '#components/LessonHeader' // ✅
1032
+ import LessonHeader from '../../components/LessonHeader' // ❌ breaks if you reorganize sections/
1033
+ ```
1034
+
1035
+ Within the same directory, use normal relative imports (`./AIFeedbackCard`).
1036
+
1037
+ **Foundation entry (`main.js`).** A single `export default { … }` whose top-level keys are the foundation's identity — `name` (what it registers as, `@org/<name>`) and `description` — and the capabilities it provides — `defaultLayout`, `defaultSection`, `viewTransitions`, `defaultInsets`, `xref`, `outputs`, `handlers` — plus an optional named `vars` export. The capabilities are read at render; the one thing a foundation declares that *isn't* — which host services it supports — lives in `package.json` instead (see [Declaring what your foundation supports](#declaring-what-your-foundation-supports)). Section types and layouts are auto-discovered and merged in by `@uniweb/build`. The build carries into `default.capabilities` in `dist/entry.js` only what a published page reads of your default export — the code it calls (`handlers`, `defaultInsets`, `xref`, `transports`, `outputs`) and its data, lean (`defaultLayout`, `defaultSection`, `viewTransitions`, `scroll`, each var's `default`, `type` and `applyTo`, each `data:` key's schema ref). `name`, `description`, a var's label or description and any key the framework does not read stay out of the published bundle: they are for the editor, which reads them from the foundation's schema. You never write that wrapper. The file is `main.js` — never `main.jsx`, which the build refuses — and it is plain JavaScript: a component it declares (in `defaultInsets`, `xref`) lives in a `.jsx` file it imports. The one place it matters: when you import your **own** `main.js` from a component (e.g. a download button calling `compileDocument(website, { foundation })`), you get the bare default object — pass it through directly, Press handles both shapes.
1038
+
1039
+ ### Props interface
1040
+
1041
+ ```jsx
1042
+ function MyComponent({ content, params, block }) {
1043
+ const { title, paragraphs, links, items } = content // Guaranteed shape
1044
+ const { columns, variant } = params // Defaults from meta.js
1045
+ const { website } = useWebsite() // Or block.website
1046
+ }
1047
+ ```
1048
+
1049
+ **Frontmatter becomes `params` — except the names framework keeps for itself.** Every section can be given these, whatever its type, and none of them reaches your component as a param. So **never declare one in `meta.js` `params:`** — a component that does never receives what an author writes.
1050
+
1051
+ | in a section's frontmatter | what it is | a component reads it from |
1052
+ |---|---|---|
1053
+ | `type`, `id`, `hidden` | the section itself — its section type, its id, and a draft (`hidden: true`: left out of a published build, kept by `uniweb dev`) | — |
1054
+ | `query`, `fetch` | the section's data | `content.data`, under the keys its `meta.js` `data:` declares |
1055
+ | `theme`, `background`, `grid`, `vars` | the section's settings, which framework applies — the color context and the background around your component, the section's theme and your component's CSS variables in the page stylesheet, the child layout | `block`, when the component needs one for its own logic: `useColorContext(block)` for the context (a light vs. dark logo, say), `<SectionBackground block={block} />` to draw the background itself, `<ChildGrid from={block} />` for the grid |
1056
+ | `props` | params written as one map | `params` — its keys are merged in, one by one |
1057
+ | `data`, `preset`, `input` | nothing: `data:` is refused (it was `query:`'s old name); `preset:` and `input:` are ignored with a warning | — |
1058
+
1059
+ The build warns when `params:` declares any of them. A preset may still set `theme` or `background` — a preset's params are written into the section's frontmatter when an author starts a section from it, so `dark: { label: 'Dark', params: { theme: 'dark' } }` is a dark section. Every other key is a param, declared or not; components ignore the ones they don't use.
1060
+
1061
+ ### Rendering content with Kit
1062
+
1063
+ Content fields are **HTML strings** — they contain `<strong>`, `<em>`, `<a>` from markdown. **Never render them with raw `{content.title}` in JSX** — that shows HTML tags as visible text. Use Kit components:
1064
+
1065
+ ```jsx
1066
+ import { H1, H2, H3, P, Span } from '@uniweb/kit'
1067
+
1068
+ <H1 text={content.title} className="text-heading text-5xl font-bold" />
1069
+ <H2 text={content.subtitle} className="text-heading text-2xl" />
1070
+ <P text={content.paragraphs} className="text-body" />
1071
+ <Span text={listItem.paragraphs[0]} className="text-subtle" />
1072
+ ```
1073
+
1074
+ Kit provides `H1`–`H6`. These render their own HTML tag — **don't wrap them**: `<H2 text={…} />`, not `<h2><H2 text={…} /></h2>`.
1075
+
1076
+ **Full content rendering** (article/docs sections where the author controls the flow):
1077
+
1078
+ ```jsx
1079
+ import { Section, Prose } from '@uniweb/kit'
1080
+
1081
+ <Section block={block} width="lg" padding="md" />
1082
+ <Prose content={content} block={block} />
1083
+ ```
1084
+
1085
+ `Prose` renders from the parsed sequence — headings, paragraphs, images, snippets, lists — with prose typography. Tagged data blocks are **skipped**; access them via `content.data` for custom rendering. Pass `content` (needs `.sequence`), and `block` too if the content uses insets. Also works as a pure typography wrapper: `<Prose>{children}</Prose>`.
1086
+
1087
+ `Article` is an older alternative rendering from `block.rawContent` (raw ProseMirror), including data blocks. Prefer `Prose` for new components.
1088
+
1089
+ ### The Kit, by use case
1090
+
1091
+ Names only — for signatures and props, read the package: it's on disk at `node_modules/@uniweb/kit`, and `reference/kit-reference.md` has the full API.
1092
+
1093
+ **Text:** `H1`–`H6`, `P`, `Span`, `Div`, `Text` (with `as`)
1094
+ **Content:** `Section`, `Prose`, `Article`, `Render` (ProseMirror → React), `ChildBlocks`, `splitContent()`
1095
+ **Media:** `Visual` (a `content.media` item by its kind; or the first of inset/video/image), `Image`, `Media` (video), `Icon`, `Asset`
1096
+ **Navigation:** `Link`, `useActiveRoute()`, `useWebsite()`, `useRouting()`
1097
+ **Header/layout:** `useScrolled(threshold)`, `useMobileMenu()`, `useAppearance()`
1098
+ **Overlays:** `Overlay` (modals, palettes, drawers, toasts — portals out of the layout, contains focus; read the note below before hand-rolling one)
1099
+ **Keyboard:** `useShortcut('mod+k', fn)`, `useShortcuts({…})`, `useShortcutLabel('mod+k')` — `mod` is Cmd on Apple platforms and Ctrl elsewhere, and the label renders per-platform so a hint never shows the wrong key
1100
+ **Long-form prose:** `<Prose>` and the container you put `<Render>` output in both use Tailwind Typography's `prose` classes. One line in the foundation's `styles.css` supplies them and points them at the site's theme:
1101
+
1102
+ ```css
1103
+ @import "@uniweb/kit/prose-tokens.css"; /* the plugin, wired to theme.yml */
1104
+ ```
1105
+
1106
+ Nothing to install — the import brings the plugin with it. **Skip the import and prose is completely unstyled** — lists lose their bullets and numbers, and text its typography — while the markup looks right. The foundation build warns about it, and `uniweb doctor` flags it. Use **one** `prose` container per subtree — the `--tw-prose-*` variables are inherited, so a nested one silently resets them and the outer container goes on looking correct. The section that renders the document is usually the better owner; a layout should supply column width and padding.
1107
+
1108
+ **Documentation shells:** `useHeadings()` (the page's headings + the one being read, derived from content so it prerenders), `website.getBranchHierarchy({ route, for })` (the page tree for one branch). Kit ships no ready-made layout — a layout is your foundation's design; write it in `src/layouts/` and use these for the behaviour.
1109
+ **Layout helpers:** `useGridLayout(columns, { gap })`, `useAccordion({ multiple, defaultOpen })`
1110
+ **Theming data:** `useThemeData()`, `useColorContext(block)`
1111
+ **Data fetching:** `useFetched`, `useCacheEntry`, `useWholeRecord`
1112
+ **Forms:** `useFormValues` (an author-designed form's state), `valueAt(values, path)`, `useFormSubmit`, `submitForm`, `resolveSubmitTarget` — see *Forms* below
1113
+ **Utilities:** `cn()`, `SafeHtml`, `SocialIcon`, `filterSocialLinks(links)`, `getSocialPlatform(url)`, `getLocaleLabel(locale)`
1114
+ **Other styled:** `Code`, `Alert`, `Table`, `Details`, `Divider`, `Disclaimer`
1115
+
1116
+ Four things you won't discover by reading exports:
1117
+
1118
+ > **Content fields are HTML strings.** Covered above — it's the single most common rendering bug.
1119
+
1120
+ > **`<Link>` takes `to` or `href`.** `to="page:about"` resolves a page ID (survives moves), external URLs get `target="_blank"` automatically, and `reload` forces a full page load.
1121
+
1122
+ > **`cn()` gotcha — a later `text-<size>` silently drops an earlier `leading-*`.** Tailwind's size classes set line height too, so `cn()` treats the size as replacing the leading: `cn('leading-[1.1] text-4xl')` → `text-4xl`. Put the size first, or fold the leading into it (`text-[clamp(2rem,5vw,4rem)]/[1.1]`). Most likely to bite when the size comes from a lookup and the leading sits in a shared base string.
1123
+
1124
+ > **Kit hooks are SSR-safe by design.** If you hit "document is not defined" during a build, don't add `typeof document` guards — reach for the hook instead (`useAppearance()` for scheme, `useScrolled()` for scroll position).
1125
+
1126
+ > **A modal needs `<Overlay>`, not a bigger z-index.** The runtime gives each layout area its own `view-transition-name` so header, rails and body animate independently. That makes each area a stacking context *and* a containing block for `fixed` children — so a dialog rendered from inside your Header is sealed into the header's context and paints *under* the page body no matter what z-index it carries. Raising the number looks like it should work and never does. `<Overlay onClose={close}>…</Overlay>` renders into `document.body`, where the z-index means what you expect.
1127
+ >
1128
+ > It also does the part that is easy to skip: a modal overlay **contains focus** — moves focus in, cycles Tab within, marks the rest of the page `inert`, and returns focus to whatever opened it. That is what makes the `role="dialog" aria-modal="true"` on your box true rather than merely asserted; without it a keyboard user Tabs straight out into a page a screen-reader user has been told is unreachable. Plus Escape, the scrim click, the scroll lock, and a dimmed scrim you override with `className` (`bg-transparent` removes it). `modal={false}` for a toast: it escapes the stacking context and nothing else, so the page stays usable. Your dialog's markup stays yours.
1129
+
1130
+ ### Icon component
1131
+
1132
+ ```jsx
1133
+ {content.icons.map((icon, i) => <Icon key={i} {...icon} />)} // From content
1134
+ <Icon name="search" /> // Lucide (default)
1135
+ <Icon name="hi2-arrow-right" /> // Other library by prefix
1136
+ <Icon name="close" /> // Built-in (no network)
1137
+ ```
1138
+
1139
+ Built-ins (instant, no network): `check`, `close`, `menu`, `chevronDown`, `chevronRight`, `externalLink`, `download`, `play`, and a few others. Other props: `svg`, `url`, `size` (default `'24'`), `className`.
1140
+
1141
+ ### Section wrapper
1142
+
1143
+ The runtime wraps every section in `<section>` with its context class and background. Customize with static properties:
1144
+
1145
+ ```jsx
1146
+ function Hero({ content, params }) {
1147
+ return <div className="max-w-7xl mx-auto px-6">…</div>
1148
+ }
1149
+
1150
+ Hero.className = 'pt-32 md:pt-48' // adds classes to the runtime wrapper
1151
+ Hero.as = 'div' // changes the wrapper element
1152
+ export default Hero
1153
+ ```
1154
+
1155
+ **Layout components typically need `p-0`** to suppress default padding:
1156
+
1157
+ ```jsx
1158
+ Header.className = 'p-0'
1159
+ Header.as = 'header'
1160
+ ```
1161
+
1162
+ ### meta.js
1163
+
1164
+ ```javascript
1165
+ export default {
1166
+ title: 'Feature Grid',
1167
+ description: 'Grid of feature cards with icons',
1168
+ family: 'features', // the standard section type it is — the editor's
1169
+ // illustration and label (`uniweb families`). Optional;
1170
+ // a name like `Features` resolves on its own
1171
+ // hidden: true, // Exclude from export entirely (internal helpers)
1172
+ // background: 'self', // Component renders its own background
1173
+ // inset: true, // Available for @ComponentName in markdown
1174
+ // children: true, // Accepts child sections
1175
+
1176
+ content: {
1177
+ title: 'Section heading',
1178
+ paragraphs: 'Introduction [0-1]',
1179
+ items: {
1180
+ label: 'Feature cards [3-6]',
1181
+ content: { icon: 'Icon [1]', title: 'Feature', paragraphs: 'Description' }, // one entry
1182
+ },
1183
+ // media: 'Photo, video or diagram [1]', // an image, a video, or an embedded component
1184
+ // sequence: 'Prose and media', // renders content.sequence — the section as written
1185
+ },
1186
+
1187
+ params: {
1188
+ columns: { type: 'number', default: 3 },
1189
+ variant: { type: 'select', options: ['default', 'centered', 'split'], default: 'default' },
1190
+ },
1191
+
1192
+ presets: {
1193
+ default: { label: 'Standard', params: { columns: 3 } },
1194
+ compact: { label: 'Compact', params: { columns: 4 } },
1195
+ },
1196
+
1197
+ // context / initialState: keys are developer-defined, not framework fields.
1198
+ context: {}, // Static — neighbors read via getNextBlockInfo().context
1199
+ initialState: {}, // Dynamic — neighbors read .state; component updates via useBlockState()
1200
+ }
1201
+ ```
1202
+
1203
+ **All defaults belong in `meta.js`, not inline in component code.** `meta.js` is also the catalog entry another agent reads to discover your section type (Part 2, step 2) — write `description` and `content:` for that reader.
1204
+
1205
+ **`content:` names what the component reads**, each as a label for the author with an optional count (`'Cards [3-6]'`): `title`, `pretitle`, `subtitle`, `paragraphs`, `links`, `lists`, `items`, `media`, `icons`, `documents`, `snippets`, `tables`, `math`, `quotes`, `sequence`.
1206
+
1207
+ - **`media` is an image, a video, or an embedded component.** `media: 'Hero media [1]'` takes any of the three; `media: { label, types: ['image'] }` narrows it; `image:`, `videos:` and `insets:` are the same slot narrowed to one type. Read it as `content.media` — the author's order, each item with its `kind` — and render it with `<Visual media={content.media} block={block} />`. Rendering the slot with `<Image>` drops a diagram an author placed there, and kit's `<Media>` is a video player.
1208
+ - **`sequence`** says the component renders `content.sequence` — the whole section as written, headline included. `{ label, except: ['table', 'math'] }` names the kinds it leaves out.
1209
+ - **`items: { label, content: { … } }`** says what each entry holds, in the same vocabulary.
1210
+ - The build warns on what it can't read, and refuses `visuals:` (declare `media`) and the `content:` element `data` (declare the block's tag in `data:`).
1211
+
1212
+ > **`meta.js` is a user interface, not just metadata.** When a foundation is published, every `meta.js` is registered as the foundation's schema, and the visual editor builds its controls from it: the build generates a `schema.json` from every `meta.js` in the foundation, and the editor renders it: a `select` param with `options` becomes a dropdown, a `boolean` becomes a toggle, `label` and `description` become the words a non-technical author reads, and each entry in `presets` becomes a one-click choice. That's the real reason param naming matters — `variant: centered` and `renderMode: flex-center` aren't a style preference, they're the difference between a legible control and a baffling one. Write `meta.js` as though someone who will never see your code has to use it, because that's exactly who does.
1213
+
1214
+ ### Two worlds: section types and your own components
1215
+
1216
+ The `{ content, params, block }` interface is not "how components work in Uniweb." It is how **section types** work — the components an author names with `type:` in frontmatter. A section type is a *public interface*: its name, params, and content expectations are a contract with content authors, and `meta.js` is its documentation.
1217
+
1218
+ **Everything under `components/` is yours.** It's a normal React project in there, and the framework has no opinion about it:
1219
+
1220
+ - **Any props you want** — `<PricingCard tier={tier} price={price} featured />`. No `content`, no `params`, no `block` unless you choose to pass them.
1221
+ - **Any composition** — children, render props, context, `forwardRef`, custom hooks, whatever the job needs.
1222
+ - **Any library** — install it and import it.
1223
+ - **No `meta.js`, no naming convention, no auto-discovery, no author visibility.**
1224
+
1225
+ Two symptoms of getting this backwards, both worth checking yourself against:
1226
+
1227
+ > **Don't give helper components a `{ content, params }` signature.** Copying the section interface downward is the most common version of this mistake. It makes every helper harder to reuse and hides what it actually needs. A card that takes `title`, `price`, and `features` is better than one that takes `content` and digs through it.
1228
+
1229
+ > **Don't put helpers in `sections/`.** Anything at the root of `sections/` becomes author-selectable vocabulary. A `Button` or `Card` appearing in the editor's section picker is a bug in your foundation's interface design, not a harmless extra.
1230
+
1231
+ **A section type is usually a composite.** Its job is to translate parsed content into whatever your own components expect, then arrange them:
1232
+
1233
+ ```jsx
1234
+ // sections/Pricing/index.jsx
1235
+ import PricingCard from '#components/PricingCard'
1236
+ import formatPrice from '#utils/formatPrice'
1237
+
1238
+ export default function Pricing({ content, params }) {
1239
+ const currency = params.currency || 'USD'
1240
+ return (
1241
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-6xl mx-auto">
1242
+ {content.items.map((tier, i) => (
1243
+ <PricingCard key={i} tier={tier} price={formatPrice(tier.data, currency)} />
1244
+ ))}
1245
+ </div>
1246
+ )
1247
+ }
1248
+ ```
1249
+
1250
+ The author writes `type: Pricing` and defines tiers as content items. `PricingCard` knows nothing about Uniweb — it takes a tier and a price. That boundary is what makes it testable, reusable across section types, and replaceable without touching the author-facing contract.
1251
+
1252
+ **There are many ways to use `components/`**, and no expected shape. Presentational primitives shared across section types (buttons, cards, badges). A thin wrapper adapting the parsed content shape to a third-party component's props. Layout scaffolding several section types share. Stateful widgets. Legacy components carried in during a migration. The Front Desk pattern below is *one* specific arrangement worth knowing — not a requirement, and not the main reason to have helper components.
1253
+
1254
+ ### The Front Desk pattern
1255
+
1256
+ One arrangement of the above is worth naming, because it comes up whenever a single `type:` needs to cover genuinely different renderings.
1257
+
1258
+ Section types naturally use params to adjust their own rendering — `variant: flipped` reverses a flex direction, `columns: 3` sets a grid. That's the baseline, not a pattern. The **Front Desk pattern** is the step beyond it: a section type that does virtually no rendering itself, instead reading the author's params, picking the right helper component, and translating author-friendly vocabulary into developer-oriented props.
1259
+
1260
+ The workers behind the desk need not share an interface. A `Hero` might delegate to a `SliderHero` (image carousel) and a `ContactHero` (quote form) expecting different content and different props. The front desk declares the **union** of everything its workers might need — some content goes unused for a given variant, and that's normal in CCA: params change behavior, and that includes not rendering some content.
1261
+
1262
+ ```js
1263
+ // meta.js — the union of all variants' needs
1264
+ export default {
1265
+ params: {
1266
+ variant: { type: 'select', options: ['slider', 'contact'], default: 'slider' },
1267
+ slideInterval: { type: 'number', default: 5 },
1268
+ density: { type: 'select', options: ['default', 'compact'], default: 'default' },
1269
+ style: { type: 'select', options: ['default', 'dramatic'], default: 'default' },
1270
+ }
1271
+ }
1272
+ ```
1273
+
1274
+ ```jsx
1275
+ // sections/Hero/index.jsx — the front desk
1276
+ import { SliderHero } from '#components/SliderHero'
1277
+ import { ContactHero } from '#components/ContactHero'
1278
+
1279
+ const variants = { slider: SliderHero, contact: ContactHero }
1280
+
1281
+ export default function Hero({ content, block, params }) {
1282
+ const Variant = variants[params.variant] || SliderHero
1283
+ return (
1284
+ <Variant
1285
+ title={content.title} subtitle={content.paragraphs[0]} links={content.links} block={block}
1286
+ images={content.images} // only some variants use these
1287
+ formData={content.data?.quote}
1288
+ interval={params.slideInterval} // author vocabulary → developer props
1289
+ compact={params.density === 'compact'}
1290
+ transition={params.style === 'dramatic' ? 'zoom' : 'fade'}
1291
+ />
1292
+ )
1293
+ }
1294
+ ```
1295
+
1296
+ `SliderHero` uses `images`, `interval`, `transition` and ignores the rest; `ContactHero` uses `formData` and `compact` and ignores the rest. The author writes `variant: contact` — they don't know or care that `ContactHero` exists.
1297
+
1298
+ This is the system-building pattern at its clearest: **section types are the public interface** (author-friendly names, documented in `meta.js`); **helper components are the implementation** (ordinary React props). The section type is the thin translation layer between the two worlds.
1299
+
1300
+
1301
+ **When to reach for this pattern:** when a page type has consistent structural elements — header bars, navigation footers, contextual sidebars — that the content author shouldn't have to add as separate sections. If the author would otherwise repeat the same boilerplate sections on every page of a given type, the section component should compose them internally.
1302
+
1303
+ **Common mistake:** solving structural repetition at the layout level. If only some page types need a content header (lessons do, the homepage doesn't), that's a section concern. The layout owns page-wide chrome; the section owns its internal structure.
1304
+
1305
+ ### Params, component vars, foundation vars
1306
+
1307
+ Three places a value can live, and picking the right one is most of the design:
1308
+
1309
+ | | Declared in | Reaches the component as | Author overrides in | CSS scope |
1310
+ |---|---|---|---|---|
1311
+ | **Param** | `meta.js` `params:` | a JS prop | section frontmatter | — |
1312
+ | **Component var** | `meta.js` `vars:` | a CSS custom property | section frontmatter `vars:` | `#section-{id}` |
1313
+ | **Foundation var** | `main.js` `vars:` | a CSS custom property | `theme.yml` `vars:` | `:root` (global) |
1314
+
1315
+ **Param when the value drives logic** — a count, a variant name, a boolean the JSX branches on. **Component var when it only drives CSS** and belongs to one section type; it never enters JS, so it doesn't re-render anything:
1316
+
1317
+ ```js
1318
+ // meta.js — same schema as foundation vars (default, label, type, options, group, description)
1319
+ vars: {
1320
+ 'card-gap': { default: '1.5rem', label: 'Card Gap', type: 'select', options: ['1rem', '1.5rem', '2rem'] },
1321
+ 'card-radius': { default: 'var(--radius-md)', description: 'Inherits the foundation var by default' },
1322
+ }
1323
+ ```
1324
+
1325
+ ```jsx
1326
+ <div className="grid gap-[var(--card-gap)]"> // emitted on #section-{id}
1327
+ ```
1328
+
1329
+ ```yaml
1330
+ ---
1331
+ type: PricingTable
1332
+ vars: { card-gap: 2rem } # author override — only declared names apply
1333
+ ---
1334
+ ```
1335
+
1336
+ Unknown var names in frontmatter are ignored, and component vars are always context-independent (unlike foundation `color`/`gradient` vars, which resolve per light/dark context).
1337
+
1338
+ **Foundation var when the value must stay consistent *across* components** — shared radii, spacing scales, extra typefaces. A header height is a layout param, not a foundation var; a sidebar width is a layout param too. Declare foundation vars in **`main.js`**, the single source of truth:
1339
+
1340
+ ```js
1341
+ export const vars = {
1342
+ 'radius-lg': { default: '1rem', description: 'Large border radius' },
1343
+ 'section-padding-y': { default: 'clamp(4rem, 6vw, 7rem)', description: 'Vertical section padding' },
1344
+ }
1345
+ ```
1346
+
1347
+ Each entry ships the default as a CSS custom property (so `var(--section-padding-y)` resolves everywhere), gives the visual editor a description and type, and is what a site overrides under `vars:` in `theme.yml`. You don't declare these anywhere else — the defaults reach the browser on their own, in dev and production, bundled or runtime-loaded.
1348
+
1349
+ A `styles.css` `@theme` block is a different tool: it registers a token so **Tailwind generates utilities** from it (`@theme { --breakpoint-xs: 30rem }` → `xs:` variants). Use it to extend Tailwind's vocabulary, not to ship a plain default.
1350
+
1351
+ **A name matching a Tailwind namespace is an intentional override.** A var named after a Tailwind v4 scale — `radius-*`, `shadow-*`, `spacing`, `font-*` — redefines that scale wherever the matching utility appears: declaring `radius-lg` retunes every `rounded-lg` in the foundation. That's the point when you mean it — name deliberately so you don't reshape a utility by accident.
1352
+
1353
+ ### Semantic theming
1354
+
1355
+ Components use **semantic CSS tokens** instead of hardcoded colors. The runtime applies a context class (`context-light`, `context-medium`, `context-dark`) to each section from its `theme:` frontmatter.
1356
+
1357
+ ```jsx
1358
+ <h2 className="text-slate-900">…</h2> // ❌ Hardcoded — breaks in dark context
1359
+ <h2 className="text-heading">…</h2> // ✅ Semantic — adapts to any context and brand
1360
+ ```
1361
+
1362
+ **Semantic tokens** (available as Tailwind `text-*`, `bg-*`, `border-*` classes):
1363
+
1364
+ | Token | Purpose |
1365
+ |-------|---------|
1366
+ | `heading` | Heading text |
1367
+ | `body` | Body text |
1368
+ | `subtle` | Secondary/de-emphasized text |
1369
+ | `section` | Section background |
1370
+ | `card` | Card/panel/well background |
1371
+ | `muted` | Hover states, zebra rows |
1372
+ | `border` | Lines, dividers |
1373
+ | `ring` | Focus indicators |
1374
+ | `link` / `link-hover` | Link colors |
1375
+ | `primary` / `primary-foreground` / `primary-hover` / `primary-border` | Primary actions |
1376
+ | `secondary` / `secondary-foreground` / `secondary-hover` / `secondary-border` | Secondary actions |
1377
+ | `success` / `warning` / `error` / `info` | Status colors |
1378
+ | `success-subtle` / `warning-subtle` / `error-subtle` / `info-subtle` | Status backgrounds (alerts) |
1379
+
1380
+ **Palette shades** are also available — `text-primary-600`, `bg-neutral-100`, `border-accent-300` — 11 shades (50–950) per palette color (primary, secondary, accent, neutral). See `theme-tokens.css` for the complete mapping.
1381
+
1382
+ > **Debugging a token that "does nothing": don't probe it in the browser.** Tailwind v4 is JIT — it emits only the classes it finds in *scanned source*. So a class you assign from JavaScript, to a `<div>` you created in the console, **has never been scanned and can never resolve**, whatever the token. A comparison against a deliberately bogus class agrees with you either way, because both are ungenerated, and classes that happen to appear in source elsewhere look like they "pass" — so the probe confirms whichever answer you started with.
1383
+ >
1384
+ > **Ask the build instead.** The one command that splits the question:
1385
+ >
1386
+ > ```bash
1387
+ > grep -o '\.text-link{[^}]*}' dist/assets/*.css
1388
+ > ```
1389
+ >
1390
+ > A rule prints → the utility exists, and something at runtime is overriding it. Nothing prints → Tailwind never generated it, and there are only two reasons: your `styles.css` doesn't `@import "@uniweb/kit/theme-tokens.css"` (or declares its own `@theme inline` that omits the token), or your `@source` globs don't cover the file the class is written in. Either way, write the class into a real source file first — a token that's only ever composed at runtime is invisible to the scanner by design.
1391
+
1392
+ **Authors control context** via `theme: dark` in frontmatter, alternating `light` (default), `medium`, and `dark` across sections for visual rhythm. **The three presets aren't the limit** — a section's `theme:` is `theme.yml` for that section: tokens beside `mode`, and `theme.yml`'s own `colors`, `contexts` and `vars`, scoped to it:
1393
+
1394
+ ```yaml
1395
+ theme:
1396
+ mode: light
1397
+ section: neutral-100 # Subtle off-white surface
1398
+ card: neutral-50 # Cards lighter than surface
1399
+ primary: neutral-900 # Dark buttons instead of brand color
1400
+ colors: { accent: '#e65100' } # A palette for this section only
1401
+ contexts: # Per context, for a section that follows the site's scheme
1402
+ dark: { link: accent-300 }
1403
+ ```
1404
+
1405
+ `background:` also accepts CSS variables and hex, so authors can alternate `var(--neutral-50)` / `var(--primary-50)` surfaces with no component code. If a source design uses subtle surface variations (`--surface-base` vs `--surface-sunken`), map those to backgrounds or token overrides in frontmatter — not to component code.
1406
+
1407
+ ### Section context vs site scheme
1408
+
1409
+ Uniweb splits what other frameworks fuse into one "dark mode":
1410
+
1411
+ - **Section context** — the `theme:` field on one section (`light`/`medium`/`dark`). Per-section, author-controlled.
1412
+ - **Site scheme** — the global light/dark preference under `appearance:` in `theme.yml`. The site-wide toggle a visitor flips.
1413
+
1414
+ They compose rather than fight: a section with **no `theme:`** inherits the site scheme and follows the toggle; a section that **pins** `theme: dark` stays dark in either scheme. A dark site can carry one bright white CTA. You never write `isDark ? … : …` — semantic tokens adapt to whichever scheme and context resolve around them.
1415
+
1416
+ ```yaml
1417
+ appearance:
1418
+ default: light # 'light' | 'dark' | 'system'
1419
+ allowToggle: true # offer a visitor switch (also generates the dark tokens)
1420
+ respectSystemPreference: true # first visit follows the OS; false pins `default`
1421
+ schemes: [light, dark] # optional — declares the available set
1422
+ ```
1423
+
1424
+ Shorthands: `appearance: light` / `dark` (fixed, no toggle), `appearance: system` (follow the OS).
1425
+
1426
+ A site **has dark mode** whenever it offers a toggle, defaults to `dark`/`system`, or lists `dark` in `schemes:`. Precedence: a stored choice wins over the OS, which wins over `default:`. Note that `default: light` is a *fallback*, not a guarantee — a dark OS still wins on first visit unless `respectSystemPreference: false`. (`default: system` is just the honest way to say "there is no fixed default — use the OS.")
1427
+
1428
+ | Goal | `theme.yml` |
1429
+ |---|---|
1430
+ | Always light, no switch | `appearance: light` |
1431
+ | Always dark, no switch | `appearance: dark` |
1432
+ | Toggle, follow the OS on first visit | `appearance: { default: system, allowToggle: true }` |
1433
+ | Toggle, but always start light | `appearance: { default: light, allowToggle: true, respectSystemPreference: false }` |
1434
+
1435
+ **Rendering a toggle.** The runtime resolves the scheme and applies it to `<html>` (`scheme-dark`/`scheme-light`) *before the page paints* — never touch `localStorage` or `document` yourself, and there's no flash of the wrong scheme. A section type only renders the button:
1436
+
1437
+ ```jsx
1438
+ import { useAppearance } from '@uniweb/kit'
1439
+
1440
+ function SchemeToggle() {
1441
+ const { scheme, toggle, canToggle } = useAppearance()
1442
+ if (!canToggle) return null // hidden unless the site enables toggling
1443
+ return <button onClick={toggle}>{scheme === 'dark' ? 'Light' : 'Dark'}</button>
1444
+ }
1445
+ ```
1446
+
1447
+ **Tailwind `dark:` variant.** If your foundation uses `dark:` utilities, bind them to the site scheme so they track the toggle. In `styles.css`:
1448
+
1449
+ ```css
1450
+ @custom-variant dark (&:where(.scheme-dark, .scheme-dark *));
1451
+ ```
1452
+
1453
+ Without this, `dark:` falls back to `@media (prefers-color-scheme: dark)` and ignores the site's setting. Prefer semantic tokens over `dark:` where you can — they adapt to per-section context, which a global `dark:` cannot.
1454
+
1455
+ ### theme.yml
1456
+
1457
+ ```yaml
1458
+ # site/theme.yml
1459
+ colors:
1460
+ primary: '#3b82f6' # Your exact hex appears at shade 500
1461
+ secondary: '#64748b'
1462
+ accent: '#8b5cf6'
1463
+ neutral: stone # Named preset: stone, zinc, gray, slate, neutral
1464
+
1465
+ contexts:
1466
+ light:
1467
+ section: '#fafaf9' # Override individual tokens per context
1468
+
1469
+ appearance: { default: light, allowToggle: true }
1470
+
1471
+ fonts:
1472
+ heading: "'Inter', system-ui, sans-serif"
1473
+ body: "'Inter', system-ui, sans-serif"
1474
+
1475
+ inline:
1476
+ callout: { color: var(--accent), font-weight: '600' }
1477
+
1478
+ vars:
1479
+ radius: 0.75rem
1480
+ ```
1481
+
1482
+ Each color generates 11 OKLCH shades (50–950); shade 500 is your exact input. `neutral` takes a named preset rather than hex. Context override keys match token names: `section:` not `bg:`, `primary:` not `btn-primary-bg:`.
1483
+
1484
+ **How colors reach components:** your hex → 11 shades → semantic tokens → components. In light/medium, `--primary` uses shade 600, `--link` 600, `--ring` 500; in dark, `--primary` uses 500 and `--link` 400.
1485
+
1486
+ **Buttons use shade 600 — darker than your input color.** That's an accessibility choice for contrast with white text. For brand-exact buttons:
1487
+
1488
+ ```yaml
1489
+ colors: { primary: "#E35D25" }
1490
+ contexts:
1491
+ light:
1492
+ primary: primary-500 # Your exact color on buttons
1493
+ primary-hover: primary-600
1494
+ ```
1495
+
1496
+ > **Contrast warning:** bright brand colors (orange, yellow, light green) at shade 500 may fail WCAG 4.5:1 against white text. Test for readability — if contrast is insufficient, keep the default 600 mapping.
1497
+
1498
+ ### Fonts
1499
+
1500
+ Font families are a **site** setting, under `fonts:` in `theme.yml`. A foundation never installs a font package (no `@fontsource/*`) and never hardcodes a family — if you reach for either, you're off the paved path. Three roles are wired onto standard elements for you:
1501
+
1502
+ | Role | Wired onto | Typical use |
1503
+ |---|---|---|
1504
+ | `body` | `body` (all text by default) | paragraphs, UI |
1505
+ | `heading` | `h1, h2, h3` | titles |
1506
+ | `code` | `code, pre, kbd, samp` | code, monospace |
1507
+
1508
+ (`code` was previously named `mono`; `fonts.mono` is no longer an alias — rename it.)
1509
+
1510
+ Because the site wires families onto real elements, **the norm is that a foundation doesn't set fonts itself** — render semantic markup (`<H1>`, `<P>`, `<code>` from the kit) and the roles apply. Everything else stays Tailwind.
1511
+
1512
+ **In a component, weight is yours; family is the site's.** Weight/size/style utilities (`font-bold`, `italic`, `text-xl`) are ordinary design vocabulary. A font-**family** utility is different: `font-sans`/`font-serif` resolve to Tailwind's built-in stacks unless the foundation makes them site-controlled, so a bare `font-serif` silently hardcodes a typeface.
1513
+
1514
+ **When a design needs typefaces the three roles can't express** — an editorial serif, a display face for hero titles — declare each as a **font var** in `main.js`. A `font-*`-named var is recognized as a typeface automatically (no `type` needed); a bare-named one takes `type: 'font'`. Either way the site loads and owns the family:
1515
+
1516
+ ```js
1517
+ // foundation src/main.js
1518
+ export const vars = {
1519
+ 'font-serif': {
1520
+ default: 'ui-serif, Georgia, serif',
1521
+ description: 'Editorial serif — blurbs, taglines, quotes',
1522
+ applyTo: ['blockquote', '.tagline'], // framework emits the rule, like a built-in role
1523
+ },
1524
+ 'font-display': {
1525
+ default: 'ui-sans-serif, system-ui, sans-serif',
1526
+ // no applyTo → the component wires it (a `font-display` utility, or var(--font-display))
1527
+ },
1528
+ }
1529
+ ```
1530
+
1531
+ The `fonts:` block takes **any** role name, built-in or foundation-added, so a site sets its whole type system in one place. The three roles are defaults you can **retarget** too — redeclaring a role's var with a new `applyTo` changes which elements it paints, while the site still owns the family.
1532
+
1533
+ ```yaml
1534
+ # site/theme.yml — loading, hosted or self-hosted; both config-only, no dependency
1535
+ fonts:
1536
+ heading: "Poppins, sans-serif"
1537
+ serif: '"Fraunces", Georgia, serif' # a foundation-declared role, set by name
1538
+ import:
1539
+ - url: "https://fonts.googleapis.com/css2?family=Poppins:wght@600;700"
1540
+ faces:
1541
+ - { family: "Fraunces", src: /fonts/fraunces.woff2, weight: "100 900" }
1542
+ ```
1543
+
1544
+ The build auto-adds preconnect hints for imports, emits `@font-face` + preload for faces, and drops any family no role references.
1545
+
1546
+ > **`serif` and `font-serif` are the same font var** — the `font-` spelling is just the one Tailwind's `font-serif` utility reads. Declare it either way in `main.js`; the site sets it by the bare role name under `fonts:`.
1547
+
1548
+ > The `code` role owns `--font-code`. Tailwind's `font-mono` utility (which reads `--font-mono`) is a **separate** concern — control it by declaring your own `font-mono` font var. So `fonts.code` styles code without disturbing `font-mono`-styled labels, and vice-versa.
1549
+
1550
+ ### Tailwind v4 and styles.css
1551
+
1552
+ ```css
1553
+ @import "tailwindcss";
1554
+ @import "@uniweb/kit/theme-tokens.css";
1555
+ @source "./sections/**/*.{js,jsx}";
1556
+ @source "./components/**/*.{js,jsx}";
1557
+ @source "../node_modules/@uniweb/kit/src/**/*.jsx";
1558
+
1559
+ @theme {
1560
+ --breakpoint-xs: 30rem;
1561
+ }
1562
+ ```
1563
+
1564
+ Semantic tokens come from `theme-tokens.css` (populated from `theme.yml`). Use `@theme` only for values tokens don't cover. **Custom CSS is expected alongside Tailwind** — shadow systems, border hierarchies, gradients, glassmorphism. Tailwind handles layout; tokens handle context; `styles.css` handles everything else.
1565
+
1566
+ **Tokens are a floor, not a ceiling.** They solve context adaptation — the hard problem. A great foundation adds design vocabulary on top:
1567
+
1568
+ ```css
1569
+ .border-subtle { border-color: color-mix(in oklch, var(--border), transparent 50%); }
1570
+ .text-tertiary { color: color-mix(in oklch, var(--body), var(--subtle) 50%); }
1571
+ ```
1572
+
1573
+ These compose with tokens — they adapt per context because they reference token variables — while adding nuance the token set doesn't provide. Use palette shades directly (`var(--primary-300)`, `bg-neutral-200`) for fine-grained control. **The priority: design quality > portability > configurability.** A beautiful foundation for one site is worth more than a generic one that looks flat.
1574
+
1575
+ Two lines a ported CSS reset will try to bring with it, both of which break things:
1576
+
1577
+ > **Don't set `scroll-behavior: smooth` globally.** The runtime owns scrolling: it already smooth-scrolls anchor targets itself (`scrollIntoView({ behavior: 'smooth' })`), so the CSS adds nothing there — but it resets and restores scroll on route changes with the two-argument `scrollTo(x, y)`, which *inherits* the property. Route changes then animate their scroll-to-top, and back-button restoration (which scrolls, checks the position on the next frame, and retries) keeps interrupting its own animation. Scope it to a specific scrollable element if you need it; never to `html` or `body`.
1578
+
1579
+ > **Font smoothing is a per-scheme decision, not a reset.** `-webkit-font-smoothing: antialiased` (macOS only) forces grayscale rasterization, which thins strokes. On dark surfaces that usefully counteracts the bloom of light text on near-black; on light surfaces it costs contrast and makes body text spindly. Scope it — `.scheme-dark { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }` — rather than putting it on `html`.
1580
+
1581
+ ### Composition: items, child sections, insets
1582
+
1583
+ Pages are sequences of sections — the obvious layer. The framework also supports real nesting, without leaving markdown.
1584
+
1585
+ | Pattern | How authored | Use when |
1586
+ |---|---|---|
1587
+ | **Items** (`content.items`) | Heading groups within one `.md` | Repeating content in one section: cards, features, FAQ entries |
1588
+ | **Concept blocks** (`content.data[tag]`) | ` ```md:<tag> ` fence around markdown | A named *kind* of content — an FAQ, a callout, a set of steps |
1589
+ | **Child sections** (`block.childBlocks`) | `@`-prefixed `.md` files + `nest:` | Children needing their own section type, rich content, or independent editing |
1590
+ | **Insets** (`block.insets`) | `![](@Component)` in markdown | Self-contained visuals/widgets: charts, diagrams, code demos |
1591
+ | **Block insets** (`block.insets`) | ` ```@Component ` fence around markdown | A component that *wraps* authored prose: callouts, disclosures, admonitions |
1592
+
1593
+ Does the author write content *inside* the nested element? **Yes** → child sections, or a block inset when the wrapper is presentational and lives mid-page. **No** (self-contained, param-driven) → inset. Repeating same-structure groups → items. These compose: a child section can contain insets; items work inside children; a block inset can contain both.
1594
+
1595
+ **Child sections in columns.** A section type that lays its children out in a grid offers layouts in `meta.js` — `children: { grid: [3, 2, '40/60'] }`, the first being its default — and renders them with kit's `<ChildGrid from={block} fallback={3} />`. The author picks one with `grid:` in the parent's frontmatter (`grid: 3`, `grid: '40/60'`), a section key like `type`, not a param. Each child renders as a section, so its own `theme:` and `background:` apply. Don't declare a param named `grid`.
1596
+
1597
+ ### Concept blocks — naming *what* content is
1598
+
1599
+ A ` ```md:<tag> ` fence marks a run of prose as a named kind of thing. The author writes ordinary markdown; the tag says what it is:
1600
+
1601
+ ````markdown
1602
+ ```md:faq
1603
+ # What plans do you have?
1604
+ Three — Starter, Team and Enterprise.
1605
+
1606
+ # Can I change later?
1607
+ Any time, and we prorate the difference.
1608
+ ```
1609
+ ````
1610
+
1611
+ Every `#` heading starts an item, so this arrives as `content.data.faq`:
1612
+
1613
+ ```js
1614
+ {
1615
+ items: [ { title, paragraphs, links, … }, … ], // one per heading
1616
+ sequence: [ … ] // the same content, in document order
1617
+ }
1618
+ ```
1619
+
1620
+ **The shape is fixed by the fence, not by the tag.** Any tag works and none is special — the framework never learns what `faq` means. A body with **no** headings is the same block with a single titleless item, which is what a callout is:
1621
+
1622
+ ````markdown
1623
+ ```md:warning
1624
+ Back up your database **before** running this. It is not reversible.
1625
+ ```
1626
+ ````
1627
+
1628
+ **Why this instead of a component reference.** ` ```@Alert ` names *which component renders this*, which is a rendering decision sitting in content. `md:warning` names *what the content is* and leaves rendering to the foundation — so the same content works under a different foundation, and an editor can recognize the concept and offer a surface built for it.
1629
+
1630
+ **A data block whose value is itself a schema.** An author can design a form in the visual editor; it lands as a ` ```yaml:form ` block at `content.data.form`. A component that renders one is the inverse of every other component: it declares the key (`data: { form: … }`) but not the fields — it *receives* them and draws whatever it's given, since the field names are the author's and aren't knowable when you write `meta.js`.
1631
+
1632
+ That makes one distinction worth holding onto. You **may** declare a schema describing the form *definition's envelope* — `title`, `description`, `fields` as a map — and get build-time validation that an authored form is well-formed. What you can't declare is a schema whose fields are *the form's* fields (`name`, `email`, …); that's describing the visitor's answers, which arrive at runtime and belong to a form you've never seen. Declare the key either way — it is what delivers the block — and give it a schema only if you want it checked, and only of the envelope (`{}` declares the key with none).
1633
+
1634
+ **Reading one in a component.** Declare it the way the author writes the fence — `data: { 'md:faq': 'Questions and answers [3+]' }`. The value is the author's label (its count counts the entries), or `{ label, hint, content: { title: 'Question', paragraphs: 'Answer' } }` to say what each entry holds. The component still reads `content.data.faq`, which gives you both views: `items` for anything row-shaped (an accordion, a step list), `sequence` when you don't recognize the tag and want to render it faithfully in document order. Both are derived, so nothing is stored twice. (`faq: {}` also declares the key, saying nothing about the block.)
1635
+
1636
+ ```jsx
1637
+ function Faq({ content }) {
1638
+ const faq = content.data?.faq
1639
+ if (!faq) return null
1640
+ return faq.items.map((item, i) => <Disclosure key={i} q={item.title} a={item.paragraphs} />)
1641
+ }
1642
+ ```
1643
+
1644
+ **Two tags, not a tag plus a variant.** A warning and a note are different concepts, so they are `md:warning` and `md:note`. The fence takes no params.
1645
+
1646
+ **Insets — embedding components in content.** Many section types need a "visual" — a hero's illustration, a split-content section's media. Classically an image or video. But what if it's a JSX + SVG diagram, a ThreeJS animation, an interactive playground? Elsewhere you'd reach for MDX or prop-drilling. Here the author writes standard image syntax:
1647
+
1648
+ ```markdown
1649
+ ![Architecture overview](@NetworkDiagram){variant=compact}
1650
+ ```
1651
+
1652
+ The developer builds `NetworkDiagram` as an ordinary React component with `inset: true` in `meta.js`. The section that hosts it declares a `media` slot, and kit's `<Visual>` renders the author's item by its kind — so one section type works whether the author supplies an image, a video, or an interactive component:
1653
+
1654
+ ```jsx
1655
+ // meta.js: content: { title: 'Headline', media: 'Illustration [1]' }
1656
+ <Visual media={content.media} block={block} className="rounded-2xl" />
1657
+ ```
1658
+
1659
+ **Insets are full section types** — they receive `{ content, params, block }`. The alt text becomes `content.title` and attributes become `params`: `![npm create uniweb](@CommandBlock){note="Ready to go"}` → `content.title = "npm create uniweb"`, `params.note = "Ready to go"`.
1660
+
1661
+ **Don't use `hidden: true` on insets.** `hidden` means "don't export this component at all" (internal helpers); `inset: true` means "available for `@Component` references in markdown" — and a visual editor then offers it as an inset only. For a component placed both as a section and as an inset, add `section: true`.
1662
+
1663
+ **Block insets — a component that wraps content.** The image form is a leaf: it takes params but no body. When the component should surround authored prose — a callout, a disclosure, an admonition — use the fenced form instead. Same `@Component{params}` reference, written as a code fence's info string:
1664
+
1665
+ ````markdown
1666
+ ```@Alert{type=warning}
1667
+ Back up your database **before** running this.
1668
+
1669
+ - The migration is not reversible
1670
+ - Allow ten minutes of downtime
1671
+ ```
1672
+ ````
1673
+
1674
+ The body is ordinary content, not text: it is parsed exactly like the rest of the page, so headings, lists, tables, icons, inline styling, leaf insets and further containers all work inside one. To nest a code block or another container, open the outer fence with more backticks than the inner one.
1675
+
1676
+ **The component is yours, and it receives parsed content.** `@Alert` resolves against the foundation through the same lookup as `![](@NetworkDiagram)` — declare it with `inset: true` in `meta.js`. Where a leaf inset gets its alt text as `content.title` and nothing else, a container gets its whole body parsed: `title`, `paragraphs`, `items`, `sequence`. Render it with `<Prose content={content} block={block} />` or read the fields directly. A name the foundation doesn't define renders a visible `Component not found: <Name>` error in place of the inset — deliberately, so a typo or a missing declaration is obvious on the page instead of quietly looking like a design choice.
1677
+
1678
+ Prefer a **child section** when the author needs their own section type and independent editing; prefer a **block inset** when the wrapper is presentational and belongs inline in a single file's prose.
1679
+
1680
+ **Child sections.** You hit a complex layout — a 2:1 split with a panel and a main area. Your instinct says build a specialized component. Step back: the panel is a reusable section type, the main area is another, and the split is a Grid with `columns: "1fr 2fr"`. Your child components already adapt to narrow containers — container queries handle that. But hardcoding which components go where means the author can't rearrange or swap them. Child sections solve that:
1681
+
1682
+ ```
1683
+ pages/home/
1684
+ ├── page.yml
1685
+ ├── 2-dashboard.md # Parent section (type: Grid, columns: "1fr 2fr")
1686
+ ├── @sidebar-stats.md # Child (@ = not top-level)
1687
+ └── @main-chart.md
1688
+ ```
1689
+
1690
+ ```yaml
1691
+ # page.yml
1692
+ nest:
1693
+ dashboard: [sidebar-stats, main-chart]
1694
+ ```
1695
+
1696
+ **Rules:**
1697
+ - `@`-prefixed files are excluded from the top-level section list; `@@` nests deeper (grandchildren)
1698
+ - `nest:` maps parent name → child names, and is **flat**: `{ features: [a, b], a: [sub-1] }`
1699
+ - Children are ordered by position in the `nest:` array
1700
+
1701
+ ```jsx
1702
+ import { ChildBlocks } from '@uniweb/kit'
1703
+
1704
+ export default function Grid({ block, params }) {
1705
+ return <div className={`grid grid-cols-${params.columns || 2} gap-6`}><ChildBlocks from={block} /></div>
1706
+ }
1707
+ ```
1708
+
1709
+ `ChildBlocks` renders each child as a **bare component by default** — no wrapper, no context class, no background. That's right for grid cells, tab panels, carousel slides. For the rare case where children should be independent sections with their own theming and backgrounds, pass `wrapAs="div"`.
1710
+
1711
+ Each child is a regular section with its own type, params, and content — and you're in the middle: wrap each child, filter by type, reorder, add container classes. The author decides *what* goes in the grid; your component decides *how* it renders. Tomorrow the author can swap a child for a different section type with no code change, and your components stay reusable wherever child sections are accepted.
1712
+
1713
+ **Data and child blocks:** page-level `query:` (or `fetch:`) is available to all blocks including children, and each child resolves data independently through the page → parent page → site hierarchy (the site's only on a top-level page). If a child needs data no ancestor declares, give it its own in its frontmatter (`query: articles`, or `fetch:`). Its `meta.js` `data:` declares the keys it receives and their shapes, never where the data comes from — it fetches nothing.
1714
+
1715
+ **SSG:** insets, `<ChildBlocks>`, and `<Visual>` all render correctly during prerender. Inset components using React hooks internally trigger prerender warnings — expected and harmless; the page renders correctly client-side.
1716
+
1717
+ ### Dividers — content boundaries
1718
+
1719
+ `---` creates a boundary between content regions; the developer decides what each region means.
1720
+
1721
+ **UI regions.** `splitContent()` from `@uniweb/kit` splits parsed content at divider elements — e.g. lesson prose vs challenge content:
1722
+
1723
+ ```jsx
1724
+ const [lesson, challenge] = splitContent(content)
1725
+ ```
1726
+
1727
+ <!-- template:loom -->
1728
+ **Data-driven iteration (Loom).** Dividers separate header/body/footer in a repeated template. The split happens *before* Loom runs, because each segment gets a different variable context — the body contains item-level fields that don't exist on the top-level data. Header and footer are instantiated once against the full data; the body repeats per item.
1729
+
1730
+ ```markdown
1731
+ ---
1732
+ type: CvEntry
1733
+ source: education
1734
+ ---
1735
+ # Education
1736
+ {COUNT OF education} degrees.
1737
+ ---
1738
+ ## {degree}
1739
+ {institution} — {field} ({start}–{end})
1740
+ ```
1741
+
1742
+ **Which to use:** different data contexts per region → Loom pre-parse split (content handler). Same data, different UI treatment → kit post-parse split (`splitContent`). A foundation can use both — Loom splits and iterates to produce final content, then the component splits the result to route regions to different UI.
1743
+ <!-- /template:loom -->
1744
+
1745
+ ### Custom layouts
1746
+
1747
+ Layouts live in `layouts/` inside the foundation and are auto-discovered. Set `defaultLayout` in `main.js`, or name a layout `Default`. With neither, a page that names no layout gets the built-in layout — `header`, the page, `footer`, nothing else — and a `defaultLayout` that names none of your layouts stops the build.
1748
+
1749
+ ```jsx
1750
+ // layouts/DocsLayout/index.jsx
1751
+ export default function DocsLayout({ header, body, footer, left, right, params }) {
1752
+ return (
1753
+ <div className="min-h-screen flex flex-col">
1754
+ {header && <header>{header}</header>}
1755
+ <div className="flex-1 flex">
1756
+ {left && <aside className="w-64">{left}</aside>}
1757
+ <main className="flex-1">{body}</main>
1758
+ {right && <aside className="w-64">{right}</aside>}
1759
+ </div>
1760
+ {footer && <footer>{footer}</footer>}
1761
+ </div>
1762
+ )
1763
+ }
1764
+ ```
1765
+
1766
+ Layouts are full components with their own `params` in `meta.js`, not just structural wrappers — a header height or sidebar width is a layout param.
1767
+
1768
+ **Layout `meta.js`** declares areas and optional scroll behavior: `{ areas: ['header', 'footer', 'left'], scroll: 'self' }`. Area names are arbitrary. `areas` tells an editor what to offer; a page renders the areas its site's `layout/` folder fills. `scroll` controls scroll restoration: unset = runtime manages `window` (default), `'self'` = the layout scrolls itself, or a CSS selector (`'main'`) = runtime manages that element.
1769
+
1770
+ Two optional keys tune how areas behave across a navigation. `transitions` renames or opts regions out of per-area view transitions (`{ left: null }`, or `false` for the whole layout). `layers` sets which area paints on top — every area is stacked above the body by default, so a fixed header works without declaring anything, but areas are equal to each other, so a layout whose chrome overlaps says which wins: `layers: { header: 2, left: 1 }`. Both take an object to override per region, or `false` to opt out. **A `z-index` inside an area cannot lift it past another area** — each area is its own stacking context — so reach for `layers` rather than a bigger number, and for `<Overlay>` when a modal needs to escape the area entirely.
1771
+
1772
+ **Layout content** lives in `site/layout/` — `header.md`, `footer.md` for the default layout, or a named subdirectory (`site/layout/marketing/`) for named layouts. Named subdirectories are self-contained — no inheritance. Cascade: `page.yml` → `folder.yml` → `site.yml` → foundation `defaultLayout` → `"default"`. The structure alone decides what an entry is: every folder directly under `layout/` is a named layout (its name matched regardless of case and of a trailing `Layout` — `site/layout/docs/` serves the foundation's `DocsLayout`), and an area with several sections is a folder inside a layout's folder — `site/layout/default/header/1-topbar.md` for the default layout. An area has no `page.yml`; its sections render in filename order.
1773
+
1774
+ Layout sections are regular section types — they support the full content shape, including tagged data blocks, lists, links, and items. The only difference is they render on every page. Each content category takes a different role:
1775
+
1776
+ ````markdown
1777
+ ---
1778
+ type: Header
1779
+ ---
1780
+ # Acme Inc ← content.title — the logo
1781
+
1782
+ - ![](lu-search) [How It Works](/how) ← content.lists[0] — the nav
1783
+ - ![](lu-book) [Docs](/docs)
1784
+
1785
+ [Get Started](/docs/quickstart) ← content.links[0] — the CTA
1786
+
1787
+ ```yaml:config
1788
+ github: https://github.com/acme ← content.data.config — everything else
1789
+ ```
1790
+ ````
1791
+
1792
+ ```jsx
1793
+ function Header({ content }) {
1794
+ const logo = content.title
1795
+ const navItems = content.lists[0] || []
1796
+ const cta = content.links[0]
1797
+ const config = content.data?.config // with `data: { config: {} }` in Header's meta.js
1798
+ }
1799
+
1800
+ function Footer({ content }) {
1801
+ const tagline = content.paragraphs[0] // a plain paragraph
1802
+ const columns = (content.lists[0] || []).map(group => ({
1803
+ label: group.paragraphs[0], // nested list → columns
1804
+ links: group.lists[0]?.map(item => item.links[0])
1805
+ }))
1806
+ }
1807
+ ```
1808
+
1809
+ ### Cross-block communication
1810
+
1811
+ Section types sometimes coordinate — the typical case is a Header that needs to know whether the section below supports a floating translucent overlay (a Hero with a full-bleed background does, a plain text section doesn't). The section that **owns the capability declares it**; the section that **needs to adapt reads it**. `getNextBlockInfo()` — and `getPrevBlockInfo()`, `page.getFirstBodyBlockInfo()` — expose two channels:
1812
+
1813
+ - **`context`** — static capabilities from `meta.js`. Never changes.
1814
+ - **`state`** — dynamic runtime state via `useBlockState()`. Initial value from `initialState` in `meta.js`.
1815
+
1816
+ ```js
1817
+ // Hero/meta.js
1818
+ export default {
1819
+ context: { allowTranslucentTop: true }, // "I always support this"
1820
+ initialState: { allowTranslucentTop: true }, // …or start true and let logic change it
1821
+ }
1822
+ ```
1823
+
1824
+ ```jsx
1825
+ // Hero/index.jsx — dynamic case
1826
+ const [state, setState] = block.useBlockState(useState)
1827
+
1828
+ // Header/index.jsx — reads dynamic state, falls back to static context
1829
+ const info = block.getNextBlockInfo()
1830
+ const isFloating = info?.state?.allowTranslucentTop ?? info?.context?.allowTranslucentTop ?? false
1831
+ ```
1832
+
1833
+ The key names are yours to design — they're not framework fields.
1834
+
1835
+ ### block, website, and page
1836
+
1837
+ ```jsx
1838
+ const { website } = useWebsite()
1839
+ const page = website.activePage
1840
+ ```
1841
+
1842
+ | `block` property | Description |
1843
+ |----------|-------------|
1844
+ | `block.page` / `block.website` | Parent page / site-level data and navigation |
1845
+ | `block.type` | Component type name |
1846
+ | `block.childBlocks` / `block.insets` | Child sections / inline `@Component` references |
1847
+ | `block.getInset(refId)` | Lookup an inset by refId |
1848
+ | `block.properties` | Raw frontmatter |
1849
+ | `block.rawContent` | ProseMirror document — passed internally by `<Article block={block} />` |
1850
+ | `block.themeName` | `"light"`, `"medium"`, `"dark"` |
1851
+ | `block.stableId` / `block.key` | Stable ID from filename or `id:` / unique key across pages — use as React key |
1852
+ | `block.path` | Page route this block belongs to |
1853
+ | `block.dataLoading` | True while declared data is still resolving |
1854
+ | `block.dataError` | `{ <key>: message }` when the fetch filling a declared key FAILED, else `null`. A failed key is `null` in `content.data` — never `[]`, which means "no records" |
1855
+
1856
+ ```jsx
1857
+ // getPageHierarchy(options) →
1858
+ // [{ id, route, navigableRoute, translatedRoute, title, label, description, hasContent, version, children }]
1859
+ // Options: for: 'header'|'footer'|<area> (respects hideIn) · nested: true (default)
1860
+ // includeHidden: false · filter: (page) => bool · sort: (a, b) => number
1861
+ website.getPageHierarchy({ for: 'header' }) // = website.getHeaderPages()
1862
+ website.getPageHierarchy({ nested: false }) // = website.getAllPages()
1863
+
1864
+ website.name, website.basePath
1865
+ website.hasMultipleLocales(), website.getLocales(), website.getActiveLocale(), website.getLocaleUrl('es')
1866
+
1867
+ page.title, page.label, page.route, page.description
1868
+ page.isHidden(), page.showInHeader(), page.showInFooter()
1869
+ page.hasContent() // true if the page has its own content (not just a folder)
1870
+ page.hasChildren(), page.children, page.parent
1871
+ page.getNavigableRoute() // first descendant route with content (for linking)
1872
+ ```
1873
+
1874
+ Content-less containers appear as group nodes (`hasContent: false`) — use `navigableRoute` for links, `title` for display, `hasContent` to style differently.
1875
+
1876
+ ### Data
1877
+
1878
+ **A section receives the keys its component declares in `meta.js` `data:` — and nothing else** (plus any its foundation declares in `main.js` `data:`). A component that reads `content.data.articles` declares `articles`. Each declared key is filled by, in order: a tagged data block in the section; else the fetch that fills it, level by level from the section's own to the site's — a fetch whose `as` (its query's name by default) is the key, or else, **automatic `as`**, the first fetch under an undeclared key whose query's schema is the key's (`@/x` matches any scope's `x`); else `null`. So `data: { related: '@std/article' }` receives an `articles` query under `related`. `as:` on a fetch picks the key when two keys or two fetches share a schema. A fetch that fills none of a section's keys is not requested for it.
1879
+
1880
+ **A key says how many records it holds, and how much of each — two flags, independent.** A ref alone (`'@std/article'`) is a **list of briefs**: on the query's own page every record the fetch takes, on a parametric page (`[slug]/`) a single-element array, `[]` when nothing matches. Write the key long to change either:
1881
+
1882
+ ```js
1883
+ data: {
1884
+ articles: '@std/article', // a list of briefs
1885
+ article: { schema: '@std/article', single: true, whole: true }, // one record, whole
1886
+ card: { schema: '@std/article', single: true }, // one record's brief
1887
+ archive: { schema: '@std/article', whole: true }, // a list of whole records
1888
+ }
1889
+ ```
1890
+
1891
+ - **`single: true`** — the key holds **one record**: the first its binding selects (on a `[slug]` page, the record the URL names), or `null` when there is none. The runtime asks for just that one. A binding over many gives the first: sort the query, or give the binding `limit: 1`.
1892
+ - **`whole: true`** — each record **whole**, as stored: every section under its own name, the brief's included — `article.brief.title`, `article.body.content`. Without it, each record is its **brief**: the brief section's fields at the top, the section not named. The runtime asks for what the key declares; a `whole` key its source cannot answer whole is `null`. A `fields:` schema's brief is the whole record, so `whole` is only needed for a schema with more than one section.
1893
+ - The long form takes `schema`, `single` and `whole`, nothing else — the build stops on any other property, and on `single` or `whole` over a schema whose root is a list (`@std/nav`, `@std/form`: the key holds that list). ⛔ `'@std/article/*'` is retired and stops the build: write `whole: true`.
1894
+
1895
+ When nothing fills a key it is `null`. An external query's records have no schema and arrive as its source answers them. Reference: `reference/component-metadata.md` § *One record, or a list* and § *Briefs or whole records*.
1896
+
1897
+ ```jsx
1898
+ function Article({ content, block }) { // data: { article: { schema: '@std/article', single: true } }
1899
+ if (block.dataLoading) return <DataPlaceholder />
1900
+ if (block.dataError?.article) return <LoadFailed /> // the request failed — not "no records"
1901
+ const article = content.data.article // the record the [slug] page's URL names, or null
1902
+ if (!article) return <NotFound />
1903
+ return <ArticleView article={article} />
1904
+ }
1905
+ ```
1906
+
1907
+ Any other reshaping is the foundation's job — once, with a `handlers.data` hook.
1908
+
1909
+ **Declaring keys and schemas.** `meta.js` declares each `content.data` key the component receives, with its schema, in a single `data:` field — there is no separate `schemas:` key. Each value is a **named ref**, an **inline field map**, an **inline rich-form** (`{ fields: [...] }`, an editor form), or `{}` for a key with no schema (an external API's records). Refs resolve on disk at build time, never fetched: `@/name` (this foundation's `schemas/`), `@std/name` (shared standards, from `@uniweb/schemas`), `@org/name` (an org's own `@org/schemas` package). A schema drives the editor, checks your data (`uniweb validate`), and lets a fetch of another name fill the key — it fills no field: **a record reaches the component as it is**, so a field it lacks is absent, and what that renders as is your component's choice (`record.status ?? 'published'`). **A named schema declares no `default:`** — the build refuses one; a `default:` belongs only in an inline field map or form, where an editor pre-fills from it. ⛔ **Delivery was default-on until this change** — every fetched key reached every component, and `data:` was a hint; a component that reads a key it does not declare now receives nothing under it. `data: false` declares nothing, like no `data:`. Keys a foundation's handlers (or a shared hook) read go in `main.js` `data:`, in the same form, and every section receives them.
1910
+
1911
+ ```js
1912
+ // meta.js
1913
+ export default {
1914
+ data: {
1915
+ articles: '@/article', // named ref (this foundation)
1916
+ authors: '@std/person', // named ref (shared standard)
1917
+ pricing: { tier: { type: 'string' } }, // inline field map
1918
+ },
1919
+ }
1920
+ ```
1921
+
1922
+ A foundation can route a scope to a plain folder of schema files instead of a package via an optional `schemas.config.js` at its root — `export default { '@acme': '../shared/acme-schemas' }`. A routed scope wins over the package convention; `@/` and `@uniweb` are never routable; a routed scope has no package fallback for a missing schema (it errors rather than silently loading a different definition). Per-schema keys override single entries (most-specific wins: file › directory › package). Worked examples: `development/schemas-in-practice.md`.
1923
+
1924
+ **Authoring queries.** Queries and fetch declarations accept `where:` (a where-object predicate), `sort:` (ONE key, e.g. `date desc` — text sorts in the page's language, and a record with no value for the key sorts last either way), and `limit:` — on a query they select its records, on a fetch they take from those. The framework evaluates them in the browser over the records it fetched, the query's first; a host that answers queries evaluates the same language at the source; a foundation transport decides for itself — it receives the query's fields and the fetch's own under `narrow`. The declaration is identical in every case.
1925
+
1926
+ ```yaml
1927
+ # pages/blog/page.yml
1928
+ fetch:
1929
+ query: recent
1930
+ where: { tags: featured }
1931
+ sort: date desc
1932
+ limit: 3
1933
+ ```
1934
+
1935
+ **Lean lists.** A list carries each record's brief, so heavy parts belong in a section of their own — `@std/article`'s body is `body` — and never ride a list. A section that shows them declares its key `whole: true`; elsewhere components fetch the whole record on demand with `useWholeRecord(record, { query })`, which returns it as stored — `full.body.content`. Every file-based record gets its own file, `/data/<name>/<name of the record>.json`. An external query's whole record comes from its `record:` request, below. (`deferred:` is removed — the build stops on it.) The hook is safe to call on any query: when the query has no separate source for one record it returns the record you passed in.
1936
+
1937
+ **Component-side fetching.** When a component genuinely needs to fetch on its own (a search box, "load more", a lazy popover), use the kit hooks — `useFetched`, `useCacheEntry`, `useWholeRecord`. They share the framework's cache and dispatcher with declarative fetches; same-key requests dedupe automatically.
1938
+
1939
+ **Validate before shipping.** `uniweb validate` checks file-based data against your declared schemas — missing required fields, type/enum/format mismatches, nested fields. A violation fails it (`--lax` only reports); it checks every record file in `records/`, and `push` / `publish` refuse the same findings before sending anything. Distinct from `uniweb doctor` (project structure): `validate` checks your *data* against the schemas you *declared*. External queries (`url:`) are reported as deferred — their data isn't in the project; the value of a `ref`/`options` field is not checked against the entity it names.
1940
+
1941
+ ### Fetching from other sources (`fetcher:`)
1942
+
1943
+ A site isn't limited to its own records. A public, keyless JSON API is an **external query** — a query with `url:`, declared beside the others and named by pages the same way (`query: posts`):
1944
+
1945
+ ```yaml
1946
+ # queries.yml
1947
+ posts:
1948
+ url: https://api.example.com/posts
1949
+ transform: data.items # dot-path to the records in the response
1950
+ record: # one whole post, for a [id] page
1951
+ url: https://api.example.com/posts/{id}
1952
+ ```
1953
+
1954
+ It also takes `method: POST` with a `body`, `where` / `sort` / `limit` (evaluated over what arrived, after `transform`), and `queryable`. Its records are entries in no folder and have no `$name`, so a `[slug]` page over it names the field the URL is in the query's own `where` — `where: { slug: :slug }` — which drops on the query's own page. `record:` takes `url`, `method`, `body` and `transform`: `url` and `method` default to the query's, `body` and `transform` never carry over, and a placeholder named by the page's folder (`{id}` for `[id]`, `{slug}` for `[slug]`) is its URL segment. `schema`, `scope`, `deferred`, `excerpt` and `path` are refused beside `url:` — they describe the site's own records. The visitor's browser fetches an external query (a `fetch:` with `prerender: true` makes the build fetch it instead); it is never asked of a host's records service and compiles no `/data` file. On a `[id]` page the list still decides which records exist — `record:` fetches the one it found, whole. A site published to a Uniweb host reads the host's records with no configuration at all.
1955
+
1956
+ ⛔ A `fetch:` never carries a source: `path`, `url`, `method`, `body`, `transform`, `detail` and `scope` on a fetch stop the build. The `detail:` forms (`rest`, `query`, a URL pattern) are now an external query's `record:`, and `detailUrl:` is `record.url`.
1957
+
1958
+ A backend that needs a key, headers, paging, its own wire or query language is a **transport**: a named `{ resolve, cacheKey? }` exported by the foundation (or an extension), which the site selects per data key in `fetcher:` — the only thing that block is for:
1959
+
1960
+ ```yaml
1961
+ # site.yml
1962
+ fetcher:
1963
+ transports:
1964
+ articles: acme # a foundation-registered transport handles `query: articles`
1965
+ events: default # explicitly route back to the default fetcher
1966
+ acme: # binding config that transport reads
1967
+ apiKey: pk_public_123
1968
+ ```
1969
+
1970
+ **Selection is explicit and site-owned.** For each request: `fetcher.transports[as]` wins if set; otherwise `fetcher.transports.default` if set; otherwise the framework's default fetcher. No route-walking, no `match()` predicates, no silent foundation-owned routing — the site picks.
1971
+
1972
+ ⛔ `fetcher.baseUrl`, `headers`, `envelope`, `supports` and `request.*` are **retired**: a third party's conventions belong in a transport, not in the runtime every site loads. The build warns once and ignores them.
1973
+
1974
+ > **Never put secrets in `site.yml` or `queries.yml`** — every value in them is public to the browser. Sites needing private credentials proxy through the same origin at the deployment layer, so the site fetches `/api/…` and the proxy attaches the credential server-side.
1975
+
1976
+ **Failures are visible, not empty:** a fetch that failed leaves its key `null` in `content.data` and names the message on `block.dataError[key]`; it is never delivered as `[]`, which means "no records". The page still renders — a section reads `dataError` to tell the two apart.
1977
+
1978
+ When an external query is enough and when a transport is the answer: `development/data-sources.md`.
1979
+
1980
+ Full model: `reference/data-fetching.md`. Where-object format with examples: `authoring/predicates.md`.
1981
+
1982
+ ### Search (`services.search`)
1983
+
1984
+ Search follows the same arrangement as `fetcher:` — the **site** declares where results come from, and a search UI reads them the same way regardless. Never hardcode a search endpoint in a component; that couples the foundation to one host. Search is on by default; its entry under `services:` (see *Services*, below) turns it off, sets options, or — on a site you publish — asks the host for it:
1985
+
1986
+ ```yaml
1987
+ # site.yml
1988
+ services:
1989
+ search:
1990
+ provider: index # default — download an index, match in the browser
1991
+ ```
1992
+
1993
+ | Provider | Answers with | Trade-off |
1994
+ |---|---|---|
1995
+ | `index` (default) | `search-index.json`, ranked in the browser | Free, works on **any** host including a plain static one, tolerates typos. Contains only what existed at build time. |
1996
+ | `endpoint` | A server-side search API | Can cover records fetched from an API, and can be re-indexed without rebuilding the site. Requires a host that serves one. |
1997
+ | *any other name* | A foundation-supplied search transport | Fully open — Typesense, Meilisearch, Pagefind, a vendor API |
1998
+
1999
+ ```yaml
2000
+ services:
2001
+ search:
2002
+ provider: endpoint
2003
+ endpoint: _search # REQUIRED — there is no default
2004
+ ```
2005
+
2006
+ `endpoint:` is **required** with `provider: endpoint`; omit it and the provider refuses the query rather than guessing a path. It resolves against the site's base path — `/` → `/_search`, `base: /docs/` → `/docs/_search`, a subpath-served site follows its subpath. An absolute `https://…` URL points at another origin. A host that serves the site may offer search itself: `search: true` asks for it, and the host supplies the address. An endpoint of your own asks the host to leave its search off, so yours answers.
2007
+
2008
+ **Results have one shape, whatever the provider.** Always present: `id`, `type`, `route`, `href`, `title`, `pageTitle`, `excerpt`, `snippetHtml`. Provider-optional (`null` when absent): `sectionId`, `anchor`, `description`, `component`, `snippetText`, `matches`, `group`, `item`. `type` is `page`, `section` or `record`; on a record hit, `item` holds the record's fields and `group` names the set it came from. Whether an optional field arrives is a deployment fact, not a content fact — the same site yields `item` from a server provider and `null` from the local index — so guard them: `result.item?.image`.
2009
+
2010
+ `snippetHtml` is HTML with `<mark>`. Render it through `SafeHtml`, never as text.
2011
+
2012
+ **Failures degrade rather than break** — a failing provider falls back to the local index when one exists, otherwise returns no results with a console warning. A search box never throws at a visitor.
2013
+
2014
+ ```jsx
2015
+ import { useSearch } from '@uniweb/kit'
2016
+
2017
+ const { results, isLoading, query } = useSearch(website) // `query` is a function
2018
+ <input onChange={e => query(e.target.value)} /> // debounced internally
2019
+ ```
2020
+
2021
+ Full reference: `authoring/search.md`.
2022
+
2023
+ ### Forms (`services.submit`)
2024
+
2025
+ Drawing a form is a foundation's job; delivering what a visitor typed needs a
2026
+ server. Where that server is comes from the **site or its host** — never from a
2027
+ section type, same arrangement as `fetcher:` and search.
2028
+
2029
+ A form gets its destination from the first of these that applies:
2030
+
2031
+ 1. **One the host supplies** — `config.services.submit` in the payload it serves. Where the
2032
+ host handles submissions, that is the destination. A site published to Uniweb
2033
+ Cloud asks for it with `submit: true` under `services:` and names no address.
2034
+ 2. **An address of the site's own** — `submit: <url>` under `services:`, for a
2035
+ static site or a host that does not handle submissions. On a host that does, it
2036
+ asks the host to leave its own off, so this one answers.
2037
+ 3. **Neither** — there is no destination: render no form, or fall back to contact
2038
+ details the site already carries.
2039
+
2040
+ That is the general arrangement, not a forms-only one. A host states everything
2041
+ it offers in the served payload's `config.services`, keyed by name, and every service
2042
+ resolves by the same rule — the host's offer, then the site's own address, then
2043
+ neither.
2044
+
2045
+ ⭐ **Before you render UI for a service, ask whether the site has it** — one predicate per service,
2046
+ no arguments: `isSearchEnabled()`, `isSubmitEnabled()`, `isBackendEnabled()`, `isAssistantEnabled()`,
2047
+ `isTrackingEnabled()`.
2048
+
2049
+ ```jsx
2050
+ import { isSearchEnabled } from '@uniweb/kit'
2051
+
2052
+ if (!isSearchEnabled()) return null // false ⇒ draw nothing
2053
+ ```
2054
+
2055
+ Each answers the same question — *would UI for this service work on this site?* — and `false`
2056
+ always means the same thing: **draw nothing.** `isSearchEnabled()` is true whenever *any* provider
2057
+ answers, including the prebuilt index a static site ships, so a search box gated on it appears
2058
+ wherever search works. The hooks that draw a feature hand you the same answer as a field
2059
+ (`useSearch().isEnabled`, `useFormSubmit().canSubmit`), so a component already using one needs
2060
+ nothing extra.
2061
+
2062
+ When you need the **address** itself, not just whether one exists, ask for it:
2063
+
2064
+ ```jsx
2065
+ import { resolveService } from '@uniweb/kit'
2066
+
2067
+ const { url, source } = resolveService(website, 'assistant') // or 'search', or your own
2068
+ ```
2069
+
2070
+ **The name is open**: the framework ships clients for what it implements and
2071
+ resolution for anything, so a foundation can define a service the framework has
2072
+ never heard of and a host can fill it — ask for it with
2073
+ `website.isServiceEnabled('booking')`. Same escalation `fetcher.transports`
2074
+ offers for data.
2075
+
2076
+ **Building an "Ask AI" component?** The service name is `assistant`, and a host that runs an agent for a site typically serves it at the conventional path `/_agent`. **You should never need to write that path** — ask the runtime instead:
2077
+
2078
+ ```jsx
2079
+ const { url } = resolveService(website, 'assistant')
2080
+ if (!url) return null // this site has no agent — render nothing, or a static fallback
2081
+ ```
2082
+
2083
+ **This is a synchronous read, not a request.** Which services a site has was settled when the site was last published and travels in its metadata, so the runtime already holds the answer — there is no handshake, no probe, no await. Your component asks the runtime; the runtime does not ask anyone.
2084
+
2085
+ ⛔ **Absent is the answer, not a lookup that failed.** No `url` means the site has no agent — never enabled, or this host runs none. Render for that case; don't retry it. And don't hardcode `/_agent/chat` when nothing was declared: on a static host that turns "no agent here" into a 404 your component can't tell from a broken endpoint. The path is named above so you recognize the shape, not so you can construct it.
2086
+
2087
+ ⛔ **And don't tell the visitor.** `resolveService` returns `{ url, source }` and **deliberately nothing else** — there is no explanatory string and there was one, removed because it was a mistake. A visitor has no stake in which services the operator provisioned, and "this site has no assistant configured" reports someone's billing state to the public while reading like a breakage. It is neither. Worse, it is unfixably the wrong language: sites here are multilingual, or unilingual and not English, and a canned constant bypasses the site's whole localization pipeline. **Absence is a rendering decision, not a message**, and a generic component is expected to be smart about it. No assistant → no Ask-AI affordance. No submit endpoint → no form, or degrade to a `mailto:` the site already carries in its content. Any text a visitor should read is *site content* — authored and localized.
2088
+
2089
+ `source` is `'site'`, `'host'` or `null`, and it is a **diagnostic for you** while you wire a site up: it says which tier answered, which is the thing to check when a host's value appears not to be taking effect. `'host'` with a null `url` means the host answered and offered no address.
2090
+
2091
+ *(A live agent that errors mid-conversation is a different problem — that's ordinary request failure, handled where you make the request.)*
2092
+
2093
+ ### Declaring what your foundation supports
2094
+
2095
+ The predicates above, and `resolveService`, are how you ask at render time. The other direction — telling a
2096
+ host, *before* anything renders, which services your foundation is built to use —
2097
+ is one line in the foundation's `package.json`:
2098
+
2099
+ ```json
2100
+ {
2101
+ "uniweb": { "supports": ["search", "submit", "tracking"] }
2102
+ }
2103
+ ```
2104
+
2105
+ Service names, the same ones you pass to `resolveService`. It reaches a host when
2106
+ the foundation is registered, and it answers a question a host cannot otherwise
2107
+ answer: **a service only does something if the foundation renders something
2108
+ against it.** A host that offers search has no way to know whether your sections
2109
+ draw a search box, so without this it either offers a site something its code
2110
+ will ignore, or withholds something it would have used.
2111
+
2112
+ **What a host receives is one of three answers:**
2113
+
2114
+ | | |
2115
+ |---|---|
2116
+ | a list — `["search"]` | the services this foundation renders against, and only these |
2117
+ | `[]` | none — proven by the build, not assumed |
2118
+ | absent | *unknown* — the build could not tell, and nothing was declared |
2119
+
2120
+ ⭐ **The build reads the set off your code.** When the foundation is built, `supports` is derived
2121
+ from what the bundle actually reaches — `resolveService(website, 'search')`, or a predicate like
2122
+ `isSearchEnabled()` — so a foundation that never writes the key still publishes an accurate set.
2123
+ What you write in `package.json` is a **supplement**: the build publishes the union, so a
2124
+ declaration can add a service but never remove one the code reaches.
2125
+
2126
+ **Declare only what the build cannot see.** A service reached through a *computed* name —
2127
+ `resolveService(website, name)` where `name` is a variable — is invisible to it, and the build
2128
+ warns when that happens. List those. `uniweb doctor` compares what you declared with what the build
2129
+ found.
2130
+
2131
+ ⚖️ **Baseline behaviour is not yours to declare.** Some services do something for
2132
+ a site whether or not a foundation cooperates — the runtime reports page views
2133
+ wherever tracking is configured, with no help from your components. List
2134
+ `tracking` when you go *beyond* that (your own events on your own components);
2135
+ leaving it out does not switch the baseline off.
2136
+
2137
+ ```yaml
2138
+ # site.yml
2139
+ services:
2140
+ submit: true # your host's form handling (Uniweb Cloud)
2141
+ ```
2142
+
2143
+ ```yaml
2144
+ # site.yml — when YOU are providing the endpoint: `uniweb export`, most `deploy --host` targets
2145
+ services:
2146
+ submit: https://forms.example.com/intake # or /forms, base-relative like search's endpoint
2147
+ ```
2148
+
2149
+ ```jsx
2150
+ import { useFormSubmit } from '@uniweb/kit'
2151
+
2152
+ const { submit, status, error, canSubmit } = useFormSubmit({
2153
+ block, // supplies section + page context
2154
+ context: { formId: 'contact' },
2155
+ summary: (v) => ({ title: v.name, subtitle: v.email }),
2156
+ })
2157
+
2158
+ if (!canSubmit) return null // nowhere to send — render no form, or fall back
2159
+ // to contact details from the site's own content
2160
+
2161
+ <button type="submit" disabled={status === 'submitting'}>Send</button>
2162
+ ```
2163
+
2164
+ > **The framework never invents an endpoint — but a host may supply one.** Don't
2165
+ > write an address reflexively: on Uniweb Cloud an address asks the host to turn its
2166
+ > own form handling off — `submit: true` is the whole declaration there. Reach for an
2167
+ > address when you are the one hosting.
2168
+ >
2169
+ > `canSubmit` is false only when neither the host nor an address of the site's own
2170
+ > supplies a destination. **Check it when you render, not only on the button press** — a
2171
+ > form nobody can send should not be on the page at all. A read that 404s
2172
+ > degrades to `[]` and the page still renders; a write that 404s loses what a
2173
+ > person typed, so it gets no silent fallback.
2174
+ >
2175
+ > ⛔ **Don't tell the visitor why.** There is deliberately no explanatory string:
2176
+ > which services an operator bought is none of a visitor's business, "not enabled
2177
+ > for this site" reads like a breakage when nothing is broken, and any such
2178
+ > sentence would be one language on a site that is often multilingual and rarely
2179
+ > English. Text a visitor reads is **site content** — authored, and localized with
2180
+ > everything else. Degrade to something useful instead: a `mailto:`, a phone
2181
+ > number, an address the site already carries.
2182
+
2183
+ `status` runs `idle → submitting → success | error`; a non-2xx becomes a thrown
2184
+ `Error` carrying the endpoint's own `error` message when it sends one. For file
2185
+ fields, declare `fileSlots` and the endpoint replies with `uploadUrls` — the
2186
+ bytes never ride inside the JSON. `submitForm()` / `resolveSubmitTarget(website)`
2187
+ are the same behaviour without React.
2188
+
2189
+ **Rendering a form the AUTHOR designed** — a `yaml:form` block at
2190
+ `content.data.form` — is the inverse of every other component: you don't declare
2191
+ the fields, you receive them and draw whatever you're given. `useFormValues` owns
2192
+ the part that's identical in every such component, so you write only the controls:
2193
+
2194
+ ```jsx
2195
+ import { useFormValues, useFormSubmit, valueAt } from '@uniweb/kit'
2196
+
2197
+ const { controls, values, setValue, missing, formData, files } =
2198
+ useFormValues(content.data.form)
2199
+ const { submit, canSubmit, status } = useFormSubmit({ block })
2200
+
2201
+ {controls.map((c) => (
2202
+ <MyControl key={c.path} control={c} // branch on c.type — your design
2203
+ value={valueAt(values, c.path)}
2204
+ onChange={(v) => setValue(c.path, v)} />
2205
+ ))}
2206
+ <button disabled={!canSubmit || missing.length > 0} onClick={() => submit(formData, { files })}>
2207
+ ```
2208
+
2209
+ `valueAt(values, path)` reads one control's value back out. It is the companion
2210
+ of `setValue` and is exported for exactly this loop: `values` is **nested**, so a
2211
+ control at `address.street` lives at `values.address.street`, and `values[c.path]`
2212
+ returns `undefined` for every nested control — a form that silently renders blank
2213
+ inputs and submits empty answers. Use `valueAt`, never index by path.
2214
+
2215
+ It seeds each control's `default`, tracks edits, and reports `missing` — the
2216
+ `required` controls still empty — without enforcing anything, the same line
2217
+ `canSubmit` draws. **Submit `formData`, not `values`:** they differ because a
2218
+ `File` inside `formData` would be JSON-serialized to `{}`, so file controls are
2219
+ omitted from it and ride in `files` instead, each tagged with the control it came
2220
+ from. `values` keeps the `File` so your input can show its selection.
2221
+
2222
+ Full reference: `development/receiving-form-submissions.md`.
2223
+
2224
+ ### Tracking (`services.tracking`)
2225
+
2226
+ A site may declare one **tracking destination**, and everything worth counting
2227
+ goes there as an event on a single stream. A page visit is just the event the
2228
+ runtime emits by itself.
2229
+
2230
+ ```yaml
2231
+ # site.yml — your own collector, on any host
2232
+ services:
2233
+ tracking: https://collector.example.com/events
2234
+ ```
2235
+
2236
+ ```yaml
2237
+ # or, when it needs more than an address
2238
+ services:
2239
+ tracking:
2240
+ endpoint: /collect
2241
+ consent: required
2242
+ ```
2243
+
2244
+ A host may also supply one — `tracking: true` asks for it on a site you publish —
2245
+ and the usual precedence applies: the host's, then yours, then neither. An
2246
+ endpoint of your own asks the host to leave its collector off, so yours answers.
2247
+
2248
+ ⚠️ **The endpoint has to accept the framework's own format** — a batched
2249
+ `{ "events": [ … ] }` POST, documented in `reference/site-configuration.md`. It
2250
+ is not a third-party analytics product's public API, which expects that
2251
+ product's own shape.
2252
+
2253
+ A site may also name a vendor's own script under `services.tracking.scripts`, which the
2254
+ runtime loads once, after consent when a gate is declared, and never in a frame
2255
+ or during prerender. That is a **separate path with no connection to the stream
2256
+ below** — the vendor measures its own way, and nothing you `track()` reaches it.
2257
+
2258
+ **Some events you get for free.** These are reported for you, with no call from
2259
+ your foundation and regardless of how it renders:
2260
+
2261
+ | event | when |
2262
+ |---|---|
2263
+ | `page_view` | every route change, including the first |
2264
+ | `outbound_click` | a visitor follows a link off the site — the destination **host** only, never the full URL |
2265
+ | `section_view` | a section first becomes half-visible, on pages that ask for it (`trackSections`, below) |
2266
+
2267
+ ⛔ **So don't write your own link-click or scroll-into-view listener for these.**
2268
+ A second one double-counts against a collector that already has them.
2269
+
2270
+ Everything else is yours to report:
2271
+
2272
+ ```jsx
2273
+ // In a section type, the block is already in your props.
2274
+ block.track('video_milestone', { milestone: 50 })
2275
+ ```
2276
+
2277
+ `block.track` attaches the page path and the section type for you. For an event
2278
+ with no block in hand, use the hook:
2279
+
2280
+ ```jsx
2281
+ import { useTracker } from '@uniweb/kit'
2282
+
2283
+ const { track } = useTracker()
2284
+ <button onClick={() => track('brochure_download', { file: 'specs.pdf' })}>…</button>
2285
+ ```
2286
+
2287
+ The event name is yours — there is no list of permitted names. Five are already
2288
+ in use, so reach for these rather than inventing a synonym: the three automatic
2289
+ ones above — **`page_view`**, **`outbound_click`**, **`section_view`** — plus
2290
+ **`video_milestone`**, which kit's `<Media>` reports for any video you render
2291
+ through it, and **`read_depth`** from `useReadingDepth()` below. Put the
2292
+ varying part in a **field**, never in the name — four names for one event turn a
2293
+ collector's event dimension into a cardinality problem.
2294
+
2295
+ ### Choosing what a site sends
2296
+
2297
+ By default a site sends `page_view`, `outbound_click` and `section_view` — or,
2298
+ where a host supplies the collector, whatever that host declares it collects.
2299
+ Narrow or widen that with `emit`:
2300
+
2301
+ ```yaml
2302
+ # site.yml — your own collector
2303
+ services:
2304
+ tracking:
2305
+ endpoint: https://collector.example.com/events
2306
+ emit: standard # minimal | standard | all — or a list of event names
2307
+ ```
2308
+
2309
+ ```yaml
2310
+ # site.yml — a host that supplies the collector: say what to send, not where
2311
+ services:
2312
+ tracking:
2313
+ emit: minimal
2314
+ ```
2315
+
2316
+ ⭐ **`emit` needs no endpoint of its own.** Where a host provides one, the site
2317
+ declares only what it wants sent and the address comes from the host. The two
2318
+ are read key by key, so naming `emit` alone overrides nothing else the host
2319
+ declared. An `endpoint:` of your own means you collect yourself, and asks the host
2320
+ to leave its collector off.
2321
+
2322
+ `minimal` is `page_view` alone. `standard` is the default when the collector is
2323
+ your own. `all` is a standing yes, so an event added in a later framework
2324
+ release is included without you changing anything — which is exactly why
2325
+ `standard` exists as well: it is a curated set that a release cannot grow behind
2326
+ your back.
2327
+
2328
+ ⭐ **Saying nothing means two different things, and which one depends on who
2329
+ supplies the address.** A site sending to its own `endpoint:` gets `standard`. A site
2330
+ on a **host-supplied** collector gets **whatever that host declares it
2331
+ collects** — it has no address of its own, so the arrangement is that the host
2332
+ does analytics for it, and the set grows when the host starts collecting
2333
+ something new. **Naming `emit` always wins**, so pin it if you would rather not
2334
+ follow your host.
2335
+
2336
+ A site can also set **`flushIntervalMs`** to widen the batching window (default
2337
+ 5000, milliseconds — `30` is thirty *milliseconds*). A host that supplies your
2338
+ collector may set it for you and your own value wins; the trade is fewer
2339
+ requests against more tail loss at the end of a visit.
2340
+
2341
+ ⚠️ **`emit` never limits what YOU send.** `block.track()` and `useTracker()` are
2342
+ not filtered by it — the registry is open, and your events are yours. It governs
2343
+ only the ones the framework emits on its own. A host may narrow the list further
2344
+ if it will not store an event, and it can never widen past what you asked for.
2345
+
2346
+ ### Reading depth in a long section
2347
+
2348
+ `section_view` tells you a reader *arrived* at a section. For a long-form one —
2349
+ an article, a report, a case study — the question is how far they got:
2350
+
2351
+ ```jsx
2352
+ import { useRef } from 'react'
2353
+ import { useReadingDepth } from '@uniweb/kit'
2354
+
2355
+ export default function Article({ content, block }) {
2356
+ const ref = useRef(null)
2357
+ useReadingDepth({ ref, block })
2358
+ return <article ref={ref}>{/* … */}</article>
2359
+ }
2360
+ ```
2361
+
2362
+ That reports `read_depth` at 25 / 50 / 75 / 100% **of that element**, once each.
2363
+ Measuring the element rather than the page is the point: two long sections on
2364
+ one page report independently, and adding a section above them changes neither.
2365
+
2366
+ ⭐ **This one is a hook rather than automatic because only you know a section is
2367
+ long-form reading.** The framework cannot tell an essay from a row of logos, so
2368
+ it does not guess — and a foundation that never calls this pays nothing for it.
2369
+
2370
+ **`trackSections`** is one page's answer on `section_view`, and it overrides the
2371
+ site in **both** directions — instrument one page of a site that sends `minimal`,
2372
+ or exempt a noisy page of a site that sends `standard`. Say nothing and the
2373
+ site's `emit` decides.
2374
+
2375
+ ⭐ **This is the only event with a per-page control, and the asymmetry is the
2376
+ point.** A page view is one event whatever the page and an outbound click is
2377
+ bounded by what a visitor does, but a section view is **one per section** — so a
2378
+ long page costs many times what a short one does. The control sits at the
2379
+ granularity the cost actually varies at.
2380
+
2381
+ ```yaml
2382
+ # page.yml
2383
+ trackSections: true # or false to exempt this page
2384
+ ```
2385
+
2386
+ ⛔ **Never guard a `track()` call.** A site with **no** tracking destination is
2387
+ the default and the majority: the call returns having done nothing, opened no
2388
+ connection, and thrown nothing. Absent is the normal state, not an error — so
2389
+ don't check whether tracking is on, and never render differently because of it.
2390
+ There is no "is tracking enabled" question a component should be asking.
2391
+
2392
+ ⚠️ **If a site asks for consent**, tracking holds everything until a visitor
2393
+ answers; granting sends what was buffered, denying discards it. A consent banner
2394
+ is an ordinary component:
2395
+
2396
+ ```jsx
2397
+ import { useTrackingConsent } from '@uniweb/kit'
2398
+
2399
+ const { status, grant, deny } = useTrackingConsent()
2400
+ if (status !== 'pending') return null
2401
+ return <CookieBanner onAccept={grant} onReject={deny} />
2402
+ ```
2403
+
2404
+ Without `consent: required`, tracking starts immediately — declaring a
2405
+ destination is the site owner's decision to make, not the framework's.
2406
+
2407
+ Every event carries a **`visit`** key on the envelope — opaque, generated at page
2408
+ load, the same for every event of that page load — so a collector can order what
2409
+ happened into a journey.
2410
+
2411
+ > **What is never sent:** no visitor id, no fingerprint, no session spanning days
2412
+ > or tabs, and **nothing is written to the visitor's device** — no cookie, no
2413
+ > local storage. The `visit` key lives in memory and dies with the document, so
2414
+ > it identifies one page load rather than a person. A `page_view` carries the
2415
+ > path; two booleans about the page load — `first_of_load` on the view that
2416
+ > opened the document, and `continues` when the load came from one of your own
2417
+ > pages; and — captured once when the page first loads, then replayed on each
2418
+ > view — the external referrer and any `utm_*` the visitor arrived with. Nothing
2419
+ > else. **Both booleans describe the load, never the visitor**, and neither links
2420
+ > two loads to each other.
2421
+
2422
+ ### ⛔ Use kit. Never touch the `uniweb` global
2423
+
2424
+ Everything above reaches the runtime through `@uniweb/kit` or through something
2425
+ handed to your component as a prop. That is the rule, not a stylistic preference:
2426
+
2427
+ - ✅ `useWebsite()`, `useTracker()`, `isSearchEnabled()` and the other service
2428
+ predicates, `resolveService(website, …)` — kit hooks and utilities.
2429
+ - ✅ `block.track(…)`, `block.page`, `block.website` — the block **arrives in your
2430
+ props**, so calling methods on it is not reaching for a global.
2431
+ - ⛔ `globalThis.uniweb`, `window.uniweb` — never, in a foundation.
2432
+
2433
+ The singleton is framework internals: its shape is not part of the contract with
2434
+ foundations, and code that reads it directly breaks on changes that were never
2435
+ breaking changes. If you need something kit does not expose yet, that is worth
2436
+ reporting — not worth reaching around.
2437
+
2438
+ <!-- template:loom -->
2439
+ ### Content handlers
2440
+
2441
+ Content handlers are a transform layer between data assembly and the component, declared in `main.js` and applied to every section in the foundation. The standard content shape is the default; handlers reshape it. All three are optional, run per block, and are error-isolated — a failing handler logs a warning and falls back to default behavior.
2442
+
2443
+ | Handler | When it runs | Receives | Returns | Purpose |
2444
+ |---|---|---|---|---|
2445
+ | `data` | After data assembly, before content transform | `(data, block)` | New data object, or null | Filter, reshape, or augment assembled data |
2446
+ | `content` | After the data handler | `(data, block)` | ProseMirror document, or null | Transform raw content (Loom instantiation, template expansion) |
2447
+ | `props` | After parsing, defaults, and guarantees | `(content, params, block)` | `{ content, params }`, or null | Post-process the final shape before the component sees it |
2448
+
2449
+ The `content` handler receives `block.parsedContent.data` — the section's declared keys, its foundation's `main.js` `data:` included, so **declare there every key a handler reads** — and reads raw ProseMirror from `block.rawContent`, returning a new ProseMirror document that the framework re-parses through the semantic parser. Returning `null` — or the same reference as `block.rawContent` — signals no change. Anything that is not a document is ignored, with a warning once per handler: the first argument is the section's *data*, so a pass-through `(x) => x` returns the data, not the content.
2450
+
2451
+ > **`block.rawContent` may or may not be wrapped.** Unwrap it defensively — `const doc = block.rawContent?.doc ?? block.rawContent` — before passing it to `instantiateContent` / `instantiateRepeated`. This is the first thing a hand-written handler gets wrong.
2452
+
2453
+ **Loom integration** is the most common use: resolving `{placeholder}` expressions against live data. `@uniweb/loom` provides a factory:
2454
+
2455
+ ```js
2456
+ import { createLoomHandlers } from '@uniweb/loom'
2457
+
2458
+ export default {
2459
+ handlers: createLoomHandlers({ vars: (data) => data?.profile?.[0] }),
2460
+ }
2461
+ ```
2462
+
2463
+ `vars` extracts the Loom variable namespace. The returned `content` handler reads the `source` and `where` frontmatter params: without `source` it does simple substitution; with `source` it splits the markdown at `---` dividers and repeats the body per data item. `where` filters the source array first — `type = 'book'` (equality), `year > 1870` (comparison), `refereed` (truthy), `type = 'book' AND refereed` (combination). Aggregates like `{COUNT OF publications}` reflect the filtered set.
2464
+
2465
+ For cases the factory doesn't cover, write handlers directly using `Loom`, `instantiateContent`, and `instantiateRepeated` from `@uniweb/loom`.
2466
+
2467
+ > **`instantiateContent` resolves `{placeholders}` in text nodes only** — not in link `href`s or other node/mark attributes. So `[{email}](mailto:{email})` fills the visible label but leaves the `mailto:` URL literal. For dynamic URLs, emit the value as plain text and let the component linkify it, or build the href in the handler yourself.
2468
+
2469
+ **`source` and `where` are ordinary params** that the Loom handler reads — they reach both `block.properties` (for handler access) and `params` (visible to components). Components can ignore them. List them in `meta.js` params with descriptions so the editor and schema recognize them. (Unlike `theme` and `background`, which are the section's own settings and never reach `params` — see the table under *Frontmatter becomes `params`*.)
2470
+ <!-- /template:loom -->
2471
+
2472
+ ---
2473
+
2474
+ ## Part 4b — When the site is also an app
2475
+
2476
+ Everything above is a site: content the author writes, built into pages. Some sites
2477
+ also have **their own backend, the `backend` service** — accounts, per-visitor data, records their members
2478
+ create and edit. `@uniweb/api` is its client.
2479
+
2480
+ ⛔ **Only reach for this when the site actually has one.** A site without one is
2481
+ the normal case, and a foundation that assumes one breaks on every other site it is
2482
+ used with.
2483
+
2484
+ ```bash
2485
+ npm install @uniweb/api # in the FOUNDATION, beside @uniweb/kit
2486
+ ```
2487
+
2488
+ ### Ask before you draw
2489
+
2490
+ ```jsx
2491
+ import { isBackendEnabled } from '@uniweb/kit'
2492
+ import { useSession, SignedIn, SignedOut } from '@uniweb/api'
2493
+
2494
+ if (!isBackendEnabled()) return <StaticVersion /> // synchronous — nothing to await
2495
+ ```
2496
+
2497
+ ⛔ **When the site has no `backend` service, draw nothing** — not a disabled control, and not an
2498
+ explanation. Same rule as `services` in Part 4: which capabilities a site's operator
2499
+ set up is none of a visitor's business, and "sign-in unavailable" reads as breakage
2500
+ when it is simply a feature this site does not have. Render the version of your
2501
+ component that never needed one.
2502
+
2503
+ ### Reading
2504
+
2505
+ ```jsx
2506
+ const { status, records } = useRecords({ schema: '@/track' }) // summaries: record.brief, no items
2507
+ const { entity } = useEntity({ schema: '@/track', uuid: record.uuid })
2508
+ const sessions = entity?.hydrated?.items.filter((item) => item.section === 'sessions')
2509
+ ```
2510
+
2511
+ ⛔ **A listed record is a summary, not the entity.** `record.brief` holds the brief
2512
+ section's fields (`record.brief.name`, not `record.name`), and a record carries **no
2513
+ items**. Read one entity for its items. Each item carries `section`, its section's name
2514
+ (the word a write takes), and `id`, which is what the writer takes to update, move or
2515
+ remove it. `scope: 'mine'` lists only the viewer's own records; without it, a list also
2516
+ holds what was shared with the viewer.
2517
+
2518
+ ⭐ **`absent` and an empty `ready` are different answers, and confusing them is the
2519
+ mistake to avoid.** `absent` = there is no live source (no `backend` service, or nobody signed
2520
+ in) → render the site's authored content. `ready` with `records: []` = the service
2521
+ answered and there is nothing there → render your empty state. Showing "nothing yet"
2522
+ for the first tells a visitor their content is gone when it was never requested.
2523
+
2524
+ ### Writing
2525
+
2526
+ ```jsx
2527
+ const writer = useEntityWriter({ schema: '@/track', uuid: track.uuid })
2528
+
2529
+ await writer.create({ title: 'Keynote' }, { section: 'sessions', position: 'last' })
2530
+ await writer.update(itemId, { ...item.data, room: 'Hall A' }) // whole-data replace
2531
+ await writer.move(itemId, { after: previousItemId }) // never an index
2532
+ await writer.remove(itemId)
2533
+ ```
2534
+
2535
+ - **`section` is required on `create`.** An entity has several, and a rule declared on
2536
+ one — insert-only, say — does not reach an item that landed in another. Getting it
2537
+ wrong stores the item happily and quietly voids the rule.
2538
+ - **`update` replaces the item's data whole.** Spread what you are not editing.
2539
+ - **Ordering is the server's.** Say `'first'`, `'last'` or `{ after }` — never compute
2540
+ an order number, or two people arranging one list will produce an order neither
2541
+ chose.
2542
+ - ⛔ **`writer.conflict` is reported, not retried.** Someone else changed the item
2543
+ first; a retry would succeed by overwriting a change nobody looked at. Tell the
2544
+ person.
2545
+
2546
+ ### ⛔ Permissions are the SERVER'S, and your UI is only a courtesy
2547
+
2548
+ Gate your controls on what the viewer may do — a record's own `can_edit` from a
2549
+ single-entity read — but **never rely on that for safety**. A foundation runs with
2550
+ exactly the viewer's own authority, so hiding a button hides a button. ⛔ **Not on
2551
+ `viewer.roles`:** a session signed in on a site holds a member's rights — the operator's
2552
+ too — so its roles never name an administrator, and a gate on `system_admin` shows the
2553
+ operator nothing.
2554
+
2555
+ What protects content is the **entry**: a member writes their own entries and not
2556
+ another's, and the store enforces it. **Models are open** — anyone signed in may
2557
+ create entries of any Model, so a data schema does not restrict who creates
2558
+ (`creatable_by` is retired and refused). What a schema can protect is how an item
2559
+ changes:
2560
+
2561
+ ```yaml
2562
+ # foundation/schemas/attendance.yml
2563
+ sections:
2564
+ checkins:
2565
+ many: true
2566
+ append_only: true # may be added; never edited or removed, by anyone
2567
+ ```
2568
+
2569
+ `append_only` holds against the item's own author. That is the difference between a
2570
+ permission model and a CSS one.
2571
+
2572
+ ### The Admin link
2573
+
2574
+ ```jsx
2575
+ const { adminHref } = useSession()
2576
+ {adminHref && <a href={adminHref}>Admin</a>}
2577
+ ```
2578
+
2579
+ `adminHref` is set only for a signed-in account the backend's administration admits —
2580
+ the operator, and whoever else administers it — and is `null` for everyone else. It
2581
+ takes them there already signed in. ⛔ **Navigate to it, never `fetch` it**: the answer is
2582
+ a redirect carrying a one-time credential no script on the site is to hold. It decides
2583
+ what to offer, not what is allowed — on the site the operator acts as a member.
2584
+
2585
+ ### The links the backend mails
2586
+
2587
+ A sign-up's confirmation, a password reset and an invitation to an address with no
2588
+ account all arrive as a link to the site's root, a token in the query — `?verify=`,
2589
+ `?reset=`, `?offer=` — and the page it lands on finishes the step:
2590
+
2591
+ ```jsx
2592
+ const { step, status, result, error, confirm } = useMailedLink()
2593
+ // step: null | 'verify' | 'reset' | 'offer' status: 'idle' | 'working' | 'done' | 'error'
2594
+ ```
2595
+
2596
+ - `verify` finishes by itself; then the member signs in.
2597
+ - `reset` waits for `confirm({ new_password })`, and the hook supplies the token. A
2598
+ password the rule refuses is `error.extensions.reason === 'password_too_short'`, and
2599
+ the same link works again.
2600
+ - `offer` waits for a sign-in, then claims by itself. `result` is counts,
2601
+ `{ invitations, grants }`, never names. ⛔ `error.kind === 'absent'` means unknown,
2602
+ revoked or already used, alike: one sentence, no cause. It does not mean "no
2603
+ invitation": a member who confirmed the invited address already holds it.
2604
+
2605
+ Render it where the root renders it — a header or a layout is on every page. ⛔ Never
2606
+ copy the token anywhere: it is a credential, it stays in the URL until the backend has
2607
+ judged it, and then the hook takes it off.
2608
+
2609
+ ### Invitations
2610
+
2611
+ An invitation to join an organization waits for the member's answer, whether it was
2612
+ sent to their account or taken up from an `?offer=` link:
2613
+
2614
+ ```jsx
2615
+ const { status, invitations, accept, decline, answering } = useInvitations()
2616
+ // invitations: [{ id, unit_uuid, unit_handle, unit_name, invited_by_account_id, created_at }]
2617
+ ```
2618
+
2619
+ Draw it wherever a signed-in member lands, not only after a mailed link — an invitation
2620
+ sent to an account comes with no link to follow. `unit_name` may be `null`.
2621
+ `accept(id)` and `decline(id)` answer one, and the list reads again. One that no longer
2622
+ waits — answered in another tab, or withdrawn — is refused with `kind: 'rule'` and
2623
+ leaves the list; its `detail` is for people, so branch on `kind`.
2624
+
2625
+ ### A backend on your machine
2626
+
2627
+ You do not need a live backend to build against one. In `site.yml`:
2628
+
2629
+ ```yaml
2630
+ services:
2631
+ backend: true # ask your host for it when you publish
2632
+ $devBackend: ./mock/api.js # what answers it in `uniweb dev`; `$` keys are never published
2633
+ ```
2634
+
2635
+ ```js
2636
+ // site/mock/api.js
2637
+ import { createMockBackend } from '@uniweb/api/mock'
2638
+ export default createMockBackend({ seed }).fetch
2639
+ ```
2640
+
2641
+ `uniweb dev` answers your site's `backend` service with it, at an address the dev server
2642
+ supplies on its own origin, so cookies behave exactly as they will in production. Write
2643
+ no address of your own for it: under `services:` an address means a backend you run, and
2644
+ asks your host to leave its own off. It **enforces** who
2645
+ may edit an entry and `append_only`, so a permission you are relying on fails on your
2646
+ machine rather than in front of a user. The mail it would send is printed in the dev
2647
+ server's terminal, its link on your site's root — follow it to try your page. Seed
2648
+ `offers: [{ email, invitations: 1 }]` to try an invitation: mailed to an address no
2649
+ account holds, or waiting at once for the seeded account whose address it is. State is
2650
+ in memory; restart to reset.
2651
+
2652
+ `uniweb create my-event --template conference` is a worked example of all of this.
2653
+
2654
+ ---
2655
+
2656
+ ## Part 5 — Commands, shipping, and migration
2657
+
2658
+ ```bash
2659
+ uniweb dev # Start dev server (picks the site for you)
2660
+ pnpm build / pnpm preview # Build for production / preview the build (SSG + SPA)
2661
+
2662
+ uniweb deploy # Wizard: pick a destination, then deploy (or set up CI)
2663
+ uniweb deploy --host=<adapter> # Build + upload now, from this machine
2664
+ uniweb add ci --host=<adapter> # CI so every push deploys — usually best for a free host
2665
+ uniweb export # Build dist/ for any static host (no Uniweb account)
2666
+ uniweb publish # Ship to Uniweb hosting, foundation included (needs `uniweb login`)
2667
+
2668
+ uniweb add ci --target foundation # Publish a foundation for free at permanent versioned URLs
2669
+ # (GitHub Pages → foundations/<name>/<version>/entry.js)
2670
+
2671
+ uniweb push / pull / clone / status # Git-style content sync with the Uniweb backend
2672
+ uniweb refresh / sync # Catch up (git + backend, never pushes) / catch up, then push
2673
+ uniweb login --org @acme # The workspace you work in — new sites are created there (see below)
2674
+ uniweb register [--scope @scope] # Register a foundation + its data schemas to the registry
2675
+ uniweb login / logout # One backend, one workspace at a time: log in (--server, --org) or out
2676
+ uniweb org list / create <handle> # Your personal scope, and the orgs you belong to
2677
+ uniweb content export [dir] # Package a site (or a built foundation's schema) as .uwx
2678
+
2679
+ uniweb rename <foundation|site|extension> <old> <new> # Rename across the whole workspace
2680
+ uniweb i18n extract / init / sync / status / audit # Translation workflow (build first)
2681
+ uniweb i18n init-freeform / update-hash / move / rename / prune --freeform
2682
+
2683
+ uniweb -v # Installed CLI version — and whether a newer one exists
2684
+ uniweb doctor # Diagnose project configuration (--fix to auto-repair)
2685
+ uniweb validate # Check file-based data against declared schemas (fails on a violation; --lax)
2686
+ npx uniweb@latest update # Align @uniweb/* deps + AGENTS.md (--dry-run, --yes)
2687
+ # bare `uniweb update` aligns to the CLI you ALREADY have
2688
+ uniweb inspect <path> # Show parsed content for a section or page (--raw for the AST)
2689
+ uniweb snapshot # Compose site/public/preview.webp from the site; sets preview: if unset
2690
+ # needs `pnpm add -D -w @uniweb/snapshot` and Chrome or Edge
2691
+ uniweb snapshot --compare # Several looks on one sheet; take one with its flags + --save (site/snapshot.yml)
2692
+
2693
+ uniweb <command> --help # Per-command flags — no side effects. Prefer this over guessing.
2694
+ ```
2695
+
2696
+ **Three verbs dispatch but are deliberately not listed above.** `invite`, `handoff` and `template` are **reserved names with no implementation** — the flows they named ran against a backend the CLI no longer talks to, and the names are held so a future rebuild doesn't need a breaking change. Running any of them is not useful; their absence from this list is the answer, not an omission to fix.
2697
+
2698
+ ### Where a site can live
2699
+
2700
+ **There is no lock-in, and no default you're pushed toward.** Four independent paths, and a project can change its mind:
2701
+
2702
+ | Path | Command | Good for |
2703
+ |---|---|---|
2704
+ | Free static host, via CI | `uniweb add ci --host=<adapter>` | most self-hosted sites — one command, then every push deploys |
2705
+ | Free static host, from this machine | `uniweb deploy --host=<adapter>` | one-off or manual deploys |
2706
+ | Any static host at all | `uniweb export` | full control; no Uniweb account needed |
2707
+ | Uniweb Cloud | `uniweb publish` | teams with non-technical content authors, or client work |
2708
+
2709
+ `uniweb deploy` never assumes a host: with nothing configured it opens a picker listing only destinations it can act on, and records the choice in `deploy.yml` so later runs go straight there. On Cloudflare Pages / Netlify / Vercel, `add ci` also adds per-PR previews that comment the URL. Adapters: `github-pages`, `cloudflare-pages`, `netlify`, `vercel`, plus `s3-cloudfront` for `deploy`. That record is `deploy.yml`, beside `site.yml` — **the CLI writes it, you don't create it**: the first successful deploy scaffolds the whole file, and later deploys rewrite only its `deploys:` block. Edit `targets:` when you want to change where a site ships. Host credentials come from the environment, never from that committed file.
2710
+
2711
+ Foundations have their own free path too: `uniweb add ci --target foundation` publishes to permanent versioned URLs on GitHub Pages.
2712
+
2713
+ ### Uniweb Cloud and the two-sided workflow
2714
+
2715
+ `uniweb publish` ships to Uniweb Cloud, and it's worth understanding what that buys, because it isn't only hosting — it's how a team of technical and non-technical people works on one site.
2716
+
2717
+ **Content authors work visually in the Uniweb App.** They compose the same extended markdown and set the same component params you defined, through a visual editor — never touching code, git, or the CLI. They see exactly the section types your foundation offers and exactly the knobs each one exposes, because **every `meta.js` is registered as the foundation's schema** when you publish. Your `meta.js` is the app's UI (see *meta.js* in Part 4).
2718
+
2719
+ > ⚠️ **Every push, pull and publish works in ONE workspace — the one chosen at `uniweb login`.**
2720
+ > A workspace is your personal one or an organization's. A site a push or publish **creates** is
2721
+ > created — owned, and its storage billed — there, and there is no CLI command to move it
2722
+ > afterwards. A site the backend keeps in **another** workspace stops the command, which says how
2723
+ > to switch. With no organization, the login works in your personal workspace; with
2724
+ > organizations, the login asks, or you name it:
2725
+ >
2726
+ > ```bash
2727
+ > uniweb login --org @acme # work in an organization
2728
+ > uniweb login --personal # work in your personal workspace
2729
+ > uniweb push --org @acme # this command only
2730
+ > ```
2731
+ >
2732
+ > Already logged in, `uniweb login --org @other` (or `--personal`) switches the workspace on the
2733
+ > backend you are logged in to, without logging in again. **Without a
2734
+ > terminal — you, an agent — a login with organizations and no workspace named signs you in but
2735
+ > exits 2**: `uniweb login --org @acme` (or `--personal`) finishes it without signing in again.
2736
+ > Until then every command that works on a site refuses, and so does registering a foundation whose
2737
+ > name has no scope. A process logged in with `UNIWEB_TOKEN` names its workspace with
2738
+ > `UNIWEB_WORKSPACE=@acme` (or `personal`). **Ask the human which workspace to use** rather than
2739
+ > picking for them — a site created in the wrong one cannot be moved from here.
2740
+ >
2741
+ > Relatedly: these commands now **reject flags they do not recognize** instead of ignoring them.
2742
+ > If you get `Unknown flag`, read the suggestion — it is usually a near miss.
2743
+
2744
+ **Sync is developer-only and one-sided by design.** `uniweb push` and `uniweb pull` are your commands, not theirs:
2745
+
2746
+ - **The app is the live source of truth for content** — where authors work and where your push lands live.
2747
+ - **Git is the reviewed, durable record** — you `pull` content back, read it with `git diff`, and commit.
2748
+ - **Authors never push or pull.** For them, content simply updates, whether the change came from another author or from a developer's CLI.
2749
+
2750
+ **Nobody's work is overwritten without asking.** `uniweb push` is refused if the site changed on the backend since your last pull — usually an author editing in the app — and reports what changed; nothing is written. Edits to different sections never collide. `uniweb pull` overwrites rather than merges, so it refuses while you have uncommitted changes. The recovery for both is `uniweb pull --merge` (or `uniweb refresh`, which runs `git pull` first): changes to different parts of a file combine silently, and a genuine overlap leaves conflict markers and a non-zero exit — so `uniweb pull --merge && uniweb push` never ships markers. `--force` is the deliberate overwrite (on `push` it replaces the backend's changes, on `pull` it discards yours); don't use it to get past a refusal. `uniweb sync` is `refresh` then `push`; neither publishes.
2751
+
2752
+ **`sync.json` says which site this is, on each backend.** The first push to a backend records what that backend assigned — the site's id and owner, the ids of its records and uploaded files — in `sync.json` beside `site.yml`. Commit it; never edit it. **To make a new site from a copy of a project, run `uniweb forget --all` in the copy before its first push** — the copy carries the original's `sync.json`, so otherwise its push updates the original's site. When two projects in one workspace hold the same site, a push or publish from either is refused until that is done. `uniweb forget --server <url>` removes just one backend's records, such as a scratch server's. A record file's `$uuid` is its own id and stays in both cases. **`push`, `pull` and `publish` go to the backend you are logged in to** — the last `uniweb login --server <url>` — and never to one you are not logged in to. A bare `uniweb login` logs in to https://uniweb.app; for any other backend, name it — logging in is how you switch, and the backend commands take no `--server` of their own. When the project has no site on that backend but has one elsewhere, a push says so before creating a new one.
2753
+
2754
+ **`services:` in `site.yml` is where a site says which services it uses** — site search, form
2755
+ handling, analytics, an assistant, accounts — one entry per service:
2756
+
2757
+ ```yaml
2758
+ services:
2759
+ search: true # on — your host's, or the built-in index
2760
+ submit: false # off
2761
+ tracking: https://collector.example.com/events # a provider you bring
2762
+ backend: # on, with the service's own settings
2763
+ grade: pro
2764
+ ```
2765
+
2766
+ An entry is `true`, `false`, an address of your own (a string, or `endpoint:` in a map), or a map
2767
+ of options; anything else stops the build and the push. ⚠️ `yes`, `no`, `on` and `off` are text to
2768
+ YAML, not switches — write `true` or `false`. On a site you push or publish it is also **what you ask your host for**: `true` asks for
2769
+ its service, `false` asks it to turn its service off, and an address asks it to leave its own off so
2770
+ yours answers. **On a site your host publishes, a service is off unless `services:` asks for it** —
2771
+ list the ones the site uses (the templates list theirs). `records: true` asks for your records to be
2772
+ delivered live to the published pages: a query whose records have a data schema shows nothing there
2773
+ without it, and `publish` says so; a static build reads records from files and ignores it. An entry
2774
+ says everything about its service: a setting you remove from it is removed on the next push, and
2775
+ removing the entry switches the service off. `uniweb push` and `uniweb publish` send each service
2776
+ `site.yml` lists; a service the site has that this copy has never seen (turned on in the app since
2777
+ your last pull) is left as it is, and one changed both in the file and on the site since your last
2778
+ pull stops the push, naming it — `uniweb pull --merge`, then push. `uniweb pull` writes what the site
2779
+ has into `services:` (an off service with no settings only where the file already names it) — pull
2780
+ before you edit. A change that needs payment is settled in the app: `publish` opens it. **Everything in an entry but a
2781
+ credential is public** — it is built into the site, except `backend`'s settings, which only your host
2782
+ reads; a key or token is set in the app. ⛔ The top-level `search:` / `submit:` / `assistant:` /
2783
+ `tracking:` / `api:` keys are retired: the build stops on them and says where each one moves. So
2784
+ does `api` under `services:` — the site's own backend is the `backend` service — and `$devApi:`,
2785
+ which is `$devBackend:`.
2786
+
2787
+ **The Cloud also provides a real backend for structured data:** a database for every registered data schema, and a CMS that edits both static page content and dynamic data entities typed by those schemas. That's the piece that makes it viable for teams and client work — the client manages records, not markdown files.
2788
+
2789
+ Either side can publish. Nothing about this changes how you build: the same foundation and the same site run under `uniweb dev`, `uniweb export`, or a CI deploy with no account at all.
2790
+
2791
+ **Publishing vs registering.** Foundations on Uniweb Cloud live in the catalog as `@org/name@version` — `name` from the foundation's `main.js`. When a foundation powers a single site, **don't run `uniweb register` yourself** — `uniweb publish` from the site directory releases the local foundation to the catalog (when its code changed) and goes live in one step. A registered version is immutable, so when the foundation's code changed under a version already registered, `push` and `publish` release the change under the next version and write it into the foundation's `package.json` — commit that file (`--no-release` sends the content without the code). The version it picks is always the next patch: for a change that is not backwards compatible, set a higher minor or major version in the foundation's `package.json` yourself before you push. They stop when the registry holds a version newer than yours, released from another copy: pull that change first, or pass `--bump` to release yours above it. Register deliberately only when the foundation is a product meant for multiple sites; consuming sites then pin `foundation: '@org/name@1.2.3'`. **The catalog is private and access-segregated, not a public package registry** — people see only the foundations licensed to sites they own or edit. The *site* carries the license, and it rides along with site ownership when a developer hands a site to a client. Don't describe publishing as making a foundation publicly discoverable. Schemas can also be registered on their own from a schemas-only package (`@uniweb/schemas`, any `@org/schemas`, or a bare folder of `schemas/*.{yml,json,js}`) — that's how `@std` schemas are published. Auth via `uniweb login` (`uniweb login --server <url> --token <bearer>` without a terminal) or `UNIWEB_TOKEN`; preview with `--dry-run`.
2792
+
2793
+ ### Staying current
2794
+
2795
+ ```bash
2796
+ uniweb -v # installed CLI version, and whether a newer one is available
2797
+ uniweb doctor # report drift in this project, changing nothing
2798
+ npx uniweb@latest update --dry-run # preview exactly what update would change
2799
+ npx uniweb@latest update # apply: align @uniweb/* deps AND refresh AGENTS.md
2800
+ ```
2801
+
2802
+ **`npx uniweb@latest update` is the command for bringing a project up to date.** It aligns the project's `@uniweb/*` dependencies *and* this AGENTS.md to the version matrix of the CLI that runs it. Deps and documentation move together — that's the whole point of the verb.
2803
+
2804
+ **What the version numbers mean.** A patch brings fixes and features; a minor version is where Uniweb puts breaking changes — `update` lists each package that crosses one, so read those before shipping — and a major is kept for something really big. Dependencies are written as tilde ranges (`~1.0.1`), so `pnpm install` and `pnpm update` never cross a minor on their own. The move from 0.x to 1.0.1 asks nothing of a project that was on the latest 0.x versions — 1.0.1 carried every package's 0.x line over — though `update` marks each package `major · BREAKING`.
2805
+
2806
+ > ### ⛔ WRITE `@latest`. A BARE `uniweb update` IS USUALLY A NO-OP, AND IT SAYS SO CONVINCINGLY
2807
+ >
2808
+ > `update` reconciles this project against **the matrix of the CLI that runs it** — and in a project,
2809
+ > `uniweb` resolves to the copy in your own `node_modules`, pinned by your own `package.json`. That
2810
+ > copy has no way to know a newer release exists.
2811
+ >
2812
+ > ⇒ **It reports everything aligned and changes nothing — correctly.** It answered the question it
2813
+ > was asked, which is why the output looks like success rather than a mistake. The same applies to
2814
+ > `pnpm uniweb update` and to a stale global install.
2815
+ >
2816
+ > ⭐ **`@latest` is what makes it a question about the newest release** rather than about the one you
2817
+ > already have. It also bumps the pin, so the next bare invocation is no longer stale.
2818
+
2819
+ > **Don't run `npm update` or `pnpm update` on the `@uniweb/*` packages.** They're a matched set resolved by the CLI's version matrix, not independently versioned libraries you upgrade one at a time. Updating them directly gets you a combination nobody tested, and it won't refresh AGENTS.md — so this guide silently drifts out of sync with the code it describes, which is worse than being out of date, because nothing looks wrong.
2820
+
2821
+ Two ordering rules: `update` won't refresh AGENTS.md while declared deps still lag the CLI, or while edited deps haven't been installed — either would put the doc ahead of the code. And updating the CLI itself is your package manager's job (`npm i -g uniweb@latest`, `pnpm add -g uniweb@latest`); `uniweb update` does not do that.
2822
+
2823
+ ### `package.json` `uniweb` block
2824
+
2825
+ Platform-specific configuration that doesn't belong in npm-standard fields. All fields optional.
2826
+
2827
+ | Field | Where used | Default | Purpose |
2828
+ |---|---|---|---|
2829
+ | `runtimePolicy` | `dist/runtime-pin.json` | unset | Declares how far past the recorded runtime version a host may move a site. |
2830
+
2831
+ **The foundation's name is not here — it is `name` in `main.js`** (else `package.json`'s `name`), **and so is the scope it registers under: the scope is part of the name**, `name: '@acme/marketing'`. A scope is a namespace — your personal one, `@<your handle>`, which needs no org, or an org's. A name with no scope has not been registered yet: the first `uniweb register` takes `--scope`, else the workspace you work in — an org's scope, or your personal one; when you belong to orgs and chose no workspace, it asks at a terminal and refuses without one — and writes it into the name; a `--scope` naming another scope than the name's is refused. The foundation's own data schemas register under the same scope (`@/article` → `@acme/article`). ⛔ `uniweb.id` and `uniweb.scope` are no longer read for a foundation: `uniweb register` refuses them and prints the `main.js` line to write instead. (A schemas-only package has no `main.js`, and still records its scope in `uniweb.scope`.)
2832
+
2833
+ **Runtime updates — handled for you.** Your foundation's code links against the runtime: it externalizes `react`, `react-dom`, `react-dom/server`, both JSX runtimes and `@uniweb/core`, and the runtime supplies all of them at load time. So a build is bound to *that* React and *that* core API.
2834
+
2835
+ You don't declare anything for this. At build time your foundation records the `@uniweb/runtime` version it actually linked against (read from `node_modules`, not chosen) into `dist/runtime-pin.json`, and `uniweb register` sends it with your foundation, so the version you built against travels with it. **Whether two runtime versions are compatible is our determination, not a setting** — you have no way to know whether we changed anything your code can reach, so we don't ask you to guess.
2836
+
2837
+ ⚠️ **Nothing acts on the recorded version yet** — today it is stated and carried, not enforced. Don't design around it having an effect.
2838
+
2839
+ **The escape hatch,** for the one thing only you can know: if your foundation reaches into undocumented runtime internals, or you have audited it against exactly one runtime build and want no movement at all, set
2840
+
2841
+ ```json
2842
+ { "uniweb": { "runtimePolicy": "exact" } }
2843
+ ```
2844
+
2845
+ That freezes sites using your foundation on the recorded version. Only reach for it if one of those two things is actually true — a frozen foundation stops receiving runtime fixes, including security ones. Sites cannot override your choice.
2846
+
2847
+ > Older releases documented `auto-patch` and `auto-minor` here as well. They asked you to pick a compatibility rule out of our version numbers, which was never something you were in a position to evaluate — that determination is ours. Both values are still accepted so existing foundations keep working; neither is worth setting.
2848
+
2849
+ `@uniweb/runtime` arrives **transitively** through `@uniweb/build`, so your foundation pins a runtime version without declaring one — that's intentional. **Don't add `@uniweb/runtime` to your foundation's dependencies**; to bump the pinned version, bump `@uniweb/build`. If `@uniweb/runtime` isn't resolvable at build time the pin is simply not emitted, and the build still succeeds — nothing in the toolchain requires it.
2850
+
2851
+ ### Localization
2852
+
2853
+ **Content reaches components already translated.** There is no `t()`, no runtime lookup, no string wrapping — each language is a complete static build with its own HTML, `hreflang` tags, and search index. The default language has no URL prefix (`/about`); others are prefixed (`/es/about`).
2854
+
2855
+ **Translations live in `locales/`, a sibling of `pages/` at the site root — never inside `pages/`.** The markdown under `pages/` is one language: the default.
2856
+
2857
+ ```yaml
2858
+ # site.yml
2859
+ defaultLanguage: en
2860
+ languages: [en, es, fr] # codes only — a language's name comes from its code;
2861
+ # or '*' to auto-discover from the locales/ folder
2862
+ publishLanguages: [en, es] # optional — fr stays a dev-previewable draft,
2863
+ # excluded from production output entirely
2864
+ ```
2865
+
2866
+ ```bash
2867
+ uniweb build # FIRST — extract scans built content, not source
2868
+ uniweb i18n extract # → locales/manifest.json, keyed by 8-char content hash
2869
+ uniweb i18n init es fr # → locales/es.json, locales/fr.json, pre-filled with source
2870
+ # …translate the values…
2871
+ uniweb build # merges translations, emits dist/es/, dist/fr/
2872
+ ```
2873
+
2874
+ Running `extract` before a build is the usual first mistake — it reads the compiled content, so a stale build yields a stale manifest. After content changes, `uniweb i18n sync` updates the manifest and `init` refreshes the language files; `status` and `audit` report coverage and stale entries.
2875
+
2876
+ **A value is inline Markdown.** A sentence's links and bold go into its translation — `"Voir [Rôles](page:docs/roles)."` — or it renders without them, and coverage still counts it; `uniweb i18n status` lists these under *Loses markup*. A **button** — a line that is only a link — is the exception: translate its label alone and it keeps its link and options (`{role=primary}`). Buttons on consecutive lines are one string; give one label per line (`"Créer un groupe\nEn savoir plus"`).
2877
+
2878
+ **Two mechanisms.** *Hash-based strings* (the default) — `locales/{locale}.json` maps content hash → translation, so structure stays identical across languages. When one source string needs different translations depending on where it appears, the value takes an override form:
2879
+
2880
+ ```json
2881
+ { "e5f6g7h8": { "default": "Learn More", "overrides": { "/pricing:cta": "See Pricing" } } }
2882
+ ```
2883
+
2884
+ An override key is the page's route and the section's id (`cta` for `2-cta.md`); a page's own title, label, description and keywords are `/pricing:_meta`, and in `locales/records/` a record is named by its manifest context (`std/article/launch`).
2885
+
2886
+ *Free-form bodies* — when a language needs different structure, different images, or copy rewritten rather than translated. The file replaces one section's content:
2887
+
2888
+ ```
2889
+ locales/freeform/es/pages/about/hero.md # by page route
2890
+ locales/freeform/es/records/article/x.md # records work too
2891
+ ```
2892
+
2893
+ These are **body only — no frontmatter**; params and config still come from the source section. `uniweb i18n init-freeform es pages/about hero` creates one pre-filled and records a source hash, so `uniweb i18n status --freeform` can tell you when the original moved on (`update-hash` to acknowledge). `move`, `rename`, and `prune --freeform` keep them aligned when pages get reorganized.
2894
+
2895
+ **A tagged data block translates by the shape its key declares** in the component's `meta.js` `data:` — a data schema or an inline field map: its text fields, never an enum, a URL or a `translatable: false` field. A key declared `{}` leaves its blocks to a heuristic that offers any string whose field name and value don't look structural, so a `style: display-black` naming one of your component's looks gets translated too. Declare the shape of every block a translator will see — `{ name: 'string', style: { type: 'string', enum: [...] } }`.
2896
+
2897
+ **Records translate in the same `extract` run**, into their own manifest at `locales/records/manifest.json` — keyed by the RECORD, so a record translated once is found by every query that returns it. A record whose query has a data schema translates exactly the fields the schema says are text — never an enum, a URL, a date, a number, a file, a reference or a `translatable: false` field — which are the fields a push carries in each language; mark a string that is a code or a key `translatable: false` and no translator is asked for it. An excerpt the build derives is re-derived from each language's body.
2898
+
2899
+ **Component side.** Nothing to do: `content.title` arrives in the active language. The one thing a foundation builds is a switcher.
2900
+
2901
+ ```jsx
2902
+ import { useWebsite, Link, getLocaleLabel } from '@uniweb/kit'
2903
+
2904
+ function LanguageSwitcher() {
2905
+ const { website } = useWebsite()
2906
+ if (!website.hasMultipleLocales()) return null
2907
+
2908
+ return website.getLocales().map(locale => (
2909
+ <Link reload key={locale.code} href={website.getLocaleUrl(locale.code)}>
2910
+ {getLocaleLabel(locale)}
2911
+ </Link>
2912
+ ))
2913
+ }
2914
+ ```
2915
+
2916
+ > **Use `<Link reload>` — not a plain `<a>`, and not `window.location.href`.** `getLocaleUrl()` returns a root-relative path that does **not** include the deployment base path, so under a subdirectory deploy (`base: /docs/` in `site.yml`) a raw href sends the visitor outside the site. `<Link reload>` prepends `website.basePath` and forces the full page load a locale switch requires — an SPA `<Link>` won't do, because the other language is a different build.
2917
+
2918
+ Full model: `development/internationalization.md`. Author's view: `authoring/translating.md`.
2919
+
2920
+ ### Migrating from other frameworks
2921
+
2922
+ Don't port line-by-line. Study the source, then rebuild from first principles. Other frameworks produce far more components than Uniweb needs — expect consolidation, not 1:1 correspondence.
2923
+
2924
+ | React / conventional | Uniweb equivalent |
2925
+ |---|---|
2926
+ | Props with typed data | Frontmatter params + `meta.js` |
2927
+ | Component variants via props | `variant` param; Front Desk pattern for complex routing |
2928
+ | Context / ThemeProvider | `theme:` frontmatter + semantic tokens (automatic) |
2929
+ | Wrapper/layout components | Section nesting or custom layouts |
2930
+ | Prop-drilling visuals into containers | Insets — `![](@Component)` rendered via `<Visual>` |
2931
+ | Content in JSX or `.js` data files | Markdown → parser → `content` prop |
2932
+ | CSS color tokens / design systems | `theme.yml` → palette shades + semantic tokens |
2933
+ | `isDark ? … : …` conditionals | `text-heading` — context classes handle it |
2934
+ | Per-component backgrounds | `background:` in frontmatter |
2935
+ | Multiple near-identical components | One section type + `variant` param, or Front Desk |
2936
+ | i18n wrapping (`t()` / `<Trans>`) | Locale-specific content directories |
2937
+
2938
+ **Approach:** scaffold with `--template none` → use named layouts for different page groups → dump legacy components under `components/` (they're not section types; import them from section types during transition) → create section types one at a time.
2939
+
2940
+ **It's incremental, not a rewrite.** Level **0** — paste the original as one section type; routing and dev tooling work immediately, and you have a running site. Level **1** — decompose into section types, consolidating duplicates via `variant` params or Front Desk. Level **2** — move content from JSX to markdown, so authors can edit without code. Level **3** — replace hardcoded colors with semantic tokens, so components work in any context. Each level is shippable; stopping at 1 is a legitimate outcome.
2941
+
2942
+ **The most common mistake** is recreating source colors as CSS custom properties — that bypasses the token system. Instead: primary color → `colors.primary` in `theme.yml`, neutral tone → `colors.neutral`, context needs → `theme:` frontmatter.
2943
+
2944
+ Also: name by purpose, not content (`TheModel` → `SplitContent`, `WorkModes` → `FeatureColumns`), and put UI helpers (buttons, badges, cards) in `components/` with no `meta.js`.
2945
+
2946
+ Full guide: `development/converting-existing.md`.
2947
+
2948
+ ---
2949
+
2950
+ ## Part 6 — Troubleshooting
2951
+
2952
+ Most Uniweb failures are **silent** — the build succeeds and the page is wrong. Check these first, because nothing in the terminal will point you at them.
2953
+
2954
+ **A red `Component not found: <Type>` box where a section should be** — the `type:` in that section's frontmatter names a section type this foundation doesn't have. `ls <foundation>/sections/` for the real list. The build does not fail on this, so it only surfaces by looking at the page.
2955
+
2956
+ **A param has no effect** — it isn't declared in that section type's `meta.js` `params:`. Undeclared frontmatter is passed through and ignored rather than rejected. Read the `meta.js` for the real knobs; if the one you want doesn't exist, exposing it is a foundation change.
2957
+
2958
+ **A section doesn't appear at all** — the file is `@`-prefixed (a child section, only rendered via `nest:`), or `_`-prefixed (treated as a draft and skipped), or it's a section type nested below the root of `sections/` without a `meta.js`, which means it was never discovered.
2959
+
2960
+ **Content lands in the wrong field** — a heading became a `subtitle` when you wanted an item, or the reverse. That's the step rule (one step smaller and adjacent = the next headline line; two steps, a divider, or any body content first = items). Run `uniweb inspect <path>` rather than re-deriving it.
2961
+
2962
+ **A var in frontmatter does nothing** — component vars only apply when declared in that section type's `meta.js` `vars:`. Unknown names are ignored silently.
2963
+
2964
+ Loud failures:
2965
+
2966
+ **"Could not load foundation"** — check that the site's `package.json` depends on the foundation *by its workspace package name*. For the default layout that's `"src": "file:../src"`; for a co-located project (`docs/src` + `docs/site`) it's `"docs-src": "file:../src"`. The key must match the foundation's `package.json::name`, not the folder it happens to sit in.
2967
+
2968
+ **Component not appearing** — verify `meta.js` exists; check for `hidden: true`; rebuild the foundation.
2969
+
2970
+ **Styles not applying** — verify `@source` in `styles.css` includes your component paths.
2971
+
2972
+ **Prerender warnings about hooks** — components with `useState`/`useEffect` show SSG warnings during build in local symlinked mode. Expected and harmless.
2973
+
2974
+ **"document is not defined" during build** — your component touches `document`, `window`, or `localStorage` during render rather than inside `useEffect`. **Don't add `typeof document` guards** — use the kit hook instead: dark mode → `useAppearance()`, scroll detection → `useScrolled()`. Kit hooks are SSR-safe by design.
2975
+
2976
+ **A modal renders behind the page** — page content or a sidebar paints over your dialog and its backdrop, and raising the z-index changes nothing. Each layout area carries a `view-transition-name`, which makes it a stacking context your `fixed` element cannot escape. Render through `<Overlay>` from `@uniweb/kit` (see Part 4) instead of a bare `fixed inset-0 z-…` div.
2977
+
2978
+ **Content not parsing as expected** — `uniweb inspect pages/home/hero.md` (add `--raw` for the ProseMirror AST), or point it at a folder for a whole page.
2979
+
2980
+ ---
2981
+
2982
+ ## Documentation index
2983
+
2984
+ **Index of every page: https://www.uniweb.io/llms.txt** — start there when you don't know which page you need.
2985
+
2986
+ Source repo (public, cloneable): **https://github.com/uniweb/docs** · any page as raw markdown at `https://raw.githubusercontent.com/uniweb/docs/main/{section}/{page}.md`. See *Documentation* in Part 0 for when to fetch versus clone.
2987
+
2988
+ | Section | Covers |
2989
+ |---------|--------|
2990
+ | `architecture/` | Component Content Architecture — the why behind the patterns in this file |
2991
+ | `getting-started/` | What is Uniweb, quickstart, templates |
2992
+ | `authoring/` | Writing content, site setup, records, theming, translations, predicates |
2993
+ | `development/` | Foundations, component patterns, project structures, data, layouts, i18n, migration, schemas |
2994
+ | `reference/` | site.yml, page.yml, content structure, meta.js, kit API, navigation, records, queries, data fetching, parametric pages, CLI, deployment |
2995
+
2996
+ The by-task table is in Part 0. For CLI flags, prefer `uniweb <command> --help` over this file — it's always current.
2997
+
2998
+ ## Notes about this project
2999
+
3000
+ Conventions, decisions, where things are — keep them in a file of your own (`NOTES.md`, say). `uniweb update` rewrites this file for each new version, and the one part it keeps is a block between `<!-- project-notes:start -->` and `<!-- project-notes:end -->`, each on a line of its own, which it moves to the end of the file. That block is the place for a line pointing to your notes, or for short notes themselves.