@zvndev/darkmown 2.6.1-next.11.g82bb8d7 → 2.7.0

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 (73) hide show
  1. package/AGENTS.md +392 -77
  2. package/CHANGELOG.md +47 -0
  3. package/README.md +316 -49
  4. package/docs/cli.md +51 -17
  5. package/docs/spec-alignment.md +25 -4
  6. package/grammar/wd-directives.gbnf +13 -5
  7. package/package.json +7 -3
  8. package/site/_/base.skin +1 -1
  9. package/site/_/footer.wd +1 -1
  10. package/site/pages/api-errors/index.wd +81 -0
  11. package/site/pages/app.wd +1 -1
  12. package/site/pages/attr-binding/index.wd +84 -0
  13. package/site/pages/blog/darkmown-2-7-0.md +176 -0
  14. package/site/pages/blog/darkmown-2-7-0.skin +127 -0
  15. package/site/pages/blog/no-unsafe-eval.md +66 -0
  16. package/site/pages/blog/no-unsafe-eval.skin +127 -0
  17. package/site/pages/blog/zero-js-by-default.md +1 -1
  18. package/site/pages/branch-state/index.wd +68 -0
  19. package/site/pages/docs/index.wd +320 -19
  20. package/site/pages/index.wd +3 -1
  21. package/site/pages/playground/index.js +63 -56
  22. package/site/pages/pulse/index.wd +1 -1
  23. package/site/pages/settings/index.wd +97 -0
  24. package/site/pages/showcase/index.wd +37 -1
  25. package/site/pages/upload/index.wd +63 -0
  26. package/site/pages/url-state/index.wd +70 -0
  27. package/src/builder.js +222 -34
  28. package/src/catalog.js +217 -21
  29. package/src/cli.js +6 -3
  30. package/src/compiler/actions.js +80 -12
  31. package/src/compiler/body.js +104 -28
  32. package/src/compiler/collections.js +29 -13
  33. package/src/compiler/context.js +9 -2
  34. package/src/compiler/expr-ast.js +14 -0
  35. package/src/compiler/fetch.js +8 -4
  36. package/src/compiler/forms.js +133 -14
  37. package/src/compiler/frontmatter.js +28 -7
  38. package/src/compiler/includes.js +46 -6
  39. package/src/compiler/interpolation.js +56 -1
  40. package/src/compiler/loops.js +489 -35
  41. package/src/compiler/markdown.js +457 -44
  42. package/src/compiler/page.js +8 -3
  43. package/src/compiler/predicates.js +84 -14
  44. package/src/compiler/state.js +167 -18
  45. package/src/compiler/structure.js +159 -29
  46. package/src/errors.js +111 -3
  47. package/src/feeds.js +140 -7
  48. package/src/grammar.js +53 -10
  49. package/src/runtime.js +509 -130
  50. package/src/runtime.min.js +2 -0
  51. package/src/runtime.min.js.map +1 -0
  52. package/src/statics.js +46 -2
  53. package/src/templates/blog/README.md +2 -0
  54. package/src/templates/dashboard/README.md +2 -0
  55. package/src/templates/landing/README.md +2 -0
  56. package/src/templates/starter/README.md +1 -1
  57. package/src/templates/store/README.md +2 -0
  58. package/src/tools/validate.js +14 -4
  59. package/types/builder.d.ts +6 -2
  60. package/types/catalog.d.ts +12 -0
  61. package/types/compiler/actions.d.ts +1 -1
  62. package/types/compiler/body.d.ts +0 -3
  63. package/types/compiler/forms.d.ts +12 -1
  64. package/types/compiler/frontmatter.d.ts +8 -1
  65. package/types/compiler/includes.d.ts +16 -2
  66. package/types/compiler/interpolation.d.ts +23 -0
  67. package/types/compiler/loops.d.ts +47 -0
  68. package/types/compiler/markdown.d.ts +63 -1
  69. package/types/compiler/predicates.d.ts +7 -1
  70. package/types/compiler/state.d.ts +8 -2
  71. package/types/compiler/structure.d.ts +32 -1
  72. package/types/feeds.d.ts +68 -2
  73. package/types/statics.d.ts +25 -0
package/README.md CHANGED
@@ -7,19 +7,9 @@
7
7
 
8
8
  **[darkmown.com](https://darkmown.com)** · Markdown that runs.
9
9
 
10
- Darkmown is a Markdown-native web framework. Two formats, one rule: `.md` stays plain CommonMark forever, and renaming a file to `.wd` ("whateverdown") is what unlocks directives — includes, loops, state, conditionals, and sections. Static pages ship **zero** framework JavaScript; reactive pages share one runtime around ~7.7 KB gzipped, CI-enforced under 8 KB.
10
+ Darkmown is a Markdown-native web framework. Two formats, one rule: `.md` stays plain CommonMark forever, and renaming a file to `.wd` ("whateverdown") is what unlocks directives — includes, loops, state, conditionals, and sections. Static pages ship **zero** framework JavaScript; reactive pages share one runtime around ~6.6 KB gzipped, CI-enforced under 8 KB.
11
11
 
12
- ## Showcase
13
-
14
- Five complete apps, each a readable `.wd` file — see them at **[darkmown.com/showcase](https://darkmown.com/showcase/)**:
15
-
16
- - **[Folio](https://darkmown.com/folio/)** — a boutique storefront whose cart persists across pages, reloads, and browser tabs (`:store`), with live search and a checkout form.
17
- - **[Pulse](https://darkmown.com/pulse/)** — a service dashboard driven by `:fetch`: loading / error / empty states, live refresh (`:every`), and status badges, with no hand-written JavaScript.
18
- - **[Forge](https://darkmown.com/forge/)** — a plan configurator where `:computed` recomputes the price as you toggle features and seats, with conditional upsell hints.
19
- - **[Compass](https://darkmown.com/compass/)** — a product-finder quiz: a branching state machine expressed entirely as `:if` steps over scored answers.
20
- - **[Ledger](https://darkmown.com/ledger/)** — a spreadsheet-grade expense table: clickable-header reactive sort, a live filter, running totals via `sum`/`avg`/`max`, `| money`/`| date` format pipes, and a `:theme` toggle.
21
-
22
- Plus focused feature demos: a draggable, keyboard-navigable **[Swiper](https://darkmown.com/carousel/)** (the `wd.subscribe` escape hatch) and a zero-JS **[Media](https://darkmown.com/media/)** page (`:video` / `:audio` / `:embed`).
12
+ **Who it is for.** Content sites that need a few interactive islands: documentation, blogs, marketing sites, changelogs, small storefronts and dashboards. You get real Markdown files, real static HTML, and a handful of directives when a page needs to react. It is not a general-purpose application framework, and the [Limits](#limits) section says exactly where the line is before you build against it.
23
13
 
24
14
  ## Quick start
25
15
 
@@ -47,6 +37,27 @@ Prefer to see it run before you install? The [browser playground](https://darkmo
47
37
  compiles `.wd`/`.md` live in your browser — the same compiler the CLI ships — with
48
38
  no install and no build step.
49
39
 
40
+ **On this page**
41
+
42
+ - [Quick start](#quick-start) · [Showcase](#showcase) · [Working from this repo](#working-from-this-repo) · [Commands](#commands)
43
+ - **Authoring:** [Authoring model](#authoring-model) · [Directive lines](#directive-lines) · [Interpolation](#interpolation) · [Frontmatter](#frontmatter) · [Static assets](#static-assets) · [SEO & feeds](#seo--feeds-sitemap-rss-robots) · [Loops](#loops) · [Content collections](#content-collections) · [Sections](#sections)
44
+ - **Reactivity:** [Reactive directives](#reactive-directives) · [Fetching data](#fetching-data) · [Global state](#global-state--store) · [URL as state](#url-as-state-from-url) · [Forms and persistence](#forms-and-persistence) · [Backends & deploy](#backends--deploy) · [Interactions](#interactions--slider-sortable-carousel)
45
+ - **Presentation:** [Inline attributes](#inline-attributes) · [Media](#media--video-audio-embed) · [Syntax highlighting](#syntax-highlighting) · [Dark mode](#dark-mode--tokens-dark) · [Scoped styles](#scoped-styles--scoped)
46
+ - **Extending:** [The escape hatch](#the-escape-hatch) · [Programmatic compile](#programmatic-compile--compilefrommemory) · [Compile error codes](#compile-error-codes) · [AI authoring](#ai-authoring) · [Editor support](#editor-support)
47
+ - **Before you commit:** [Accessibility](#accessibility) · [Security](#security) · [**Limits**](#limits) · [Spec status](#spec-status)
48
+
49
+ ## Showcase
50
+
51
+ Five complete apps, each a readable `.wd` file — see them at **[darkmown.com/showcase](https://darkmown.com/showcase/)**:
52
+
53
+ - **[Folio](https://darkmown.com/folio/)** — a boutique storefront whose cart persists across pages, reloads, and browser tabs (`:store`), with live search and a checkout form.
54
+ - **[Pulse](https://darkmown.com/pulse/)** — a service dashboard driven by `:fetch`: loading / error / empty states, live refresh (`:every`), and status badges, with no hand-written JavaScript.
55
+ - **[Forge](https://darkmown.com/forge/)** — a plan configurator where `:computed` recomputes the price as you toggle features and seats, with conditional upsell hints.
56
+ - **[Compass](https://darkmown.com/compass/)** — a product-finder quiz: a branching state machine expressed entirely as `:if` steps over scored answers.
57
+ - **[Ledger](https://darkmown.com/ledger/)** — a spreadsheet-grade expense table: clickable-header reactive sort, a live filter, running totals via `sum`/`avg`/`max`, `| money`/`| date` format pipes, and a `:theme` toggle.
58
+
59
+ Plus focused feature demos: a draggable, keyboard-navigable **[Swiper](https://darkmown.com/carousel/)** (the `wd.subscribe` escape hatch) and a zero-JS **[Media](https://darkmown.com/media/)** page (`:video` / `:audio` / `:embed`).
60
+
50
61
  ## Working from this repo
51
62
 
52
63
  ```sh
@@ -63,7 +74,7 @@ npm run dev # live demo site — the same site that runs darkmown.com
63
74
  - `darkmown build [--target cloudflare] [--drafts]` writes static output to `dist` (plus `sitemap.xml`/`rss.xml`/`robots.txt`); `--drafts` includes `draft: true` pages for staging.
64
75
  - `darkmown deploy <vercel|cloudflare> [--prod]` builds and deploys via the platform CLI.
65
76
  - `darkmown serve` previews the built `dist` locally.
66
- - `darkmown catalog [--llms]` prints the machine-readable directive catalog (JSON), or with `--llms` a compact cheatsheet — see [AI authoring](#ai-authoring).
77
+ - `darkmown catalog [--llms|--llms-full]` prints the machine-readable directive catalog (JSON), a compact cheatsheet with `--llms`, or the complete reference including every error code with `--llms-full`. See [AI authoring](#ai-authoring).
67
78
  - `darkmown version` prints the installed package version.
68
79
  - `darkmown help` prints CLI usage.
69
80
 
@@ -74,9 +85,18 @@ npm run dev # live demo site — the same site that runs darkmown.com
74
85
  - Files or folders starting with `.`, `-`, or `_` are hidden from routing.
75
86
  - `site/_` is the include shelf for `@include /name.wd`.
76
87
  - Matching `page.skin` and `page.js` colocate styling and behavior by basename.
77
- - Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~7.7 KB gzipped, CI-enforced under 8 KB).
88
+ - Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js`, a minified build of the readable `src/runtime.js` (currently ~6.6 KB gzipped, CI-enforced under 8 KB). An external sourcemap ships beside it at `/__wd/runtime.js.map` with the original source inlined, so DevTools shows real names and real comments. Development serves the same bytes as production.
78
89
  - Shelf `.json` files are published at `/__wd/data/` so `:fetch` works on any static host.
79
90
 
91
+ ## Directive lines
92
+
93
+ A directive is a whole line, written flush against the left margin. Four rules cover everything the compiler will and will not accept there.
94
+
95
+ - **One directive per line, unindented.** An indented `:state` (inside a list item, say) is prose, and a directive inside a fenced code block is code.
96
+ - **A bare keyword is an error.** A line that is exactly `:state`, `:button`, `@loop`, or any other directive that needs arguments used to render as literal text with no warning at all. It now reaches that directive's own handler and fails with that directive's coded message and `Use:` hint, so `:state` alone gives `[WD201]` and `:button` alone gives `[WD301]`. Three keywords stay valid bare, because they mean something bare: `:::` opens or closes a container, `:theme` declares the theme store, and `:carousel` opens a carousel with no autoplay.
97
+ - **To show a directive name as text, escape it.** `\:fetch` renders the literal text `:fetch`. That is the ordinary CommonMark backslash escape, so it works anywhere Markdown does, including inside a container.
98
+ - **There is no comment syntax.** A trailing `#` or arrow note on a directive line is parsed as part of the value, not as a comment. `html: true ← required` seeds the *string* `true ← required`, which compiles clean while doing the opposite of what it looks like. Put explanations in the prose around the line.
99
+
80
100
  ## Interpolation
81
101
 
82
102
  One syntax everywhere: `{ name }` or `{ name.path }`.
@@ -85,7 +105,37 @@ One syntax everywhere: `{ name }` or `{ name.path }`.
85
105
  - Declared `:state` becomes a live binding.
86
106
  - The page's own frontmatter is in scope as `meta` — `{ meta.title }` prints a field.
87
107
  - Anything else stays literal text — braces in prose never break a page or pull in the runtime.
88
- - Build-time values also resolve **inside a link or image destination** — `[{ item.label }]({ item.url })` or `![{ p.alt }]({ p.src })` — so an `@loop` can drive an `href`/`src` and the page stays static. (Reactive `:state` cannot live in a destination.)
108
+ - Values resolve **inside a link or image destination** too (`[{ item.label }]({ item.url })`, `![{ p.alt }]({ p.src })`), so an `@loop` can drive an `href`/`src` and the page stays static. Every brace in the destination resolves, not just one filling the whole value: `[Docs](/docs/{ region }/)` works. A reactive value there becomes a live binding; see [Destinations that bind](#destinations-that-bind) below.
109
+ - Values resolve in **raw HTML** too (attributes and html blocks, HTML-escaped) on an `html: true` page. Those are painted once at build time, not bound. Fenced code blocks and inline code spans are never rewritten, so a page can still show `` `[x](/p/{ p.slug }/)` `` as syntax.
110
+
111
+ ### Destinations that bind
112
+
113
+ A destination that reads `:state`, `:store`, `:computed`, a reactive loop row, or a loop meta variable is a **live binding**: the compiler emits the destination as a small template and the runtime rebuilds the value on every render.
114
+
115
+ ```wd
116
+ :state region = "eu"
117
+
118
+ :button "EU" -> region = "eu"
119
+ :button "US" -> region = "us"
120
+
121
+ [Open the docs for this region](/docs/{ region }/)
122
+ ```
123
+
124
+ Clicking a button rewrites the `href`. The build-time paint is still the seed (`/docs/eu/`), so the link works before the runtime loads and a crawler sees a real URL. Per-row destinations work the same way inside a reactive `@loop`:
125
+
126
+ ```wd
127
+ :state products = [{"slug": "aurora", "name": "Aurora Lamp"}]
128
+
129
+ @loop products into p
130
+ - [{ p.name }](/products/{ p.slug }/)
131
+ @endloop
132
+ ```
133
+
134
+ Three rules worth knowing:
135
+
136
+ - **Values in URL position are percent-encoded.** A `)`, a space, or an angle bracket coming from your data cannot end the link and hand the remainder back to the Markdown parser.
137
+ - **Dangerous schemes are refused twice.** The compiler vets what you wrote; the runtime re-checks what it assembled (control characters stripped first) and applies an **empty** attribute rather than a half-applied one if the value resolves to `javascript:`, `data:`, or `vbscript:`.
138
+ - **Raw HTML attributes do not bind.** `<a href="{ url }">` is painted once with a build warning that names the position that does bind. Move the value into a Markdown destination, or drive the attribute from a colocated `.js` with `wd.subscribe`.
89
139
 
90
140
  ### Format pipes — `{ value | name:arg }`
91
141
 
@@ -126,7 +176,7 @@ tags: [sales, revenue, "q1, q2"]
126
176
 
127
177
  Three keys also drive the document `<head>`: `title` sets `<title>`, `description` adds the meta description plus Open Graph / Twitter tags, and `image` (an absolute URL) sets the social-share preview (`og:image` / `twitter:image` and a `summary_large_image` card). `lang:` sets the document language on `<html lang>` (`lang: fr`, `lang: pt-BR`, …) — it defaults to `en`.
128
178
 
129
- A few reserved keys drive **drafts and feeds** (see [SEO & feeds](#seo--feeds-sitemap-rss-robots) below): `draft: true` excludes a page from production builds; `site_url` on the home page turns on `sitemap.xml` + `rss.xml`; `date:` marks a page as a blog post (it lands in `rss.xml` and sets the page's `<lastmod>`); `excerpt:` is the RSS summary. The same frontmatter is queryable when the page is an entry in a [content collection](#content-collections) — `{ post.date }`, `{ post.excerpt }`, `{ post.tags }`, and any custom key resolve in an `@loop` over the folder.
179
+ A few reserved keys drive **drafts and feeds** (see [SEO & feeds](#seo--feeds-sitemap-rss-robots) below): `draft: true` excludes a page from production builds; `site_url` on the home page turns on `sitemap.xml` + `rss.xml`; `rss_limit:` on the home page caps how many posts the feed carries; `date:` marks a page as a blog post (it lands in `rss.xml` and sets the page's `<lastmod>`); `excerpt:` is the RSS summary. The same frontmatter is queryable when the page is an entry in a [content collection](#content-collections): `{ post.date }`, `{ post.excerpt }`, `{ post.tags }`, and any custom key resolve in an `@loop` over the folder.
130
180
 
131
181
  `transitions: true` opts a page into **instant, flash-free navigation** — zero JavaScript, all declarative. It emits a directional fade+slide **view transition** for the page swap (old lifts up and out, new rises up and in — replacing the default cross-fade, which left both pages ghosted at ~50 % opacity mid-navigation), plus a **`<script type="speculationrules">` prerender** hint that renders the next same-origin page on hover/pointerdown so the click activates an already-painted page (no white render-gap flash). It honors `prefers-reduced-motion`. Only same-origin pages that both opt in transition; browsers without support — or with page-preloading disabled — navigate normally. Off by default; opt out with `transitions: false`. Mark a link `{.no-prefetch}` to exclude it from prerendering. (Chrome disables prerendering while DevTools is open, so test the built site with DevTools closed.)
132
182
 
@@ -166,6 +216,20 @@ site_url: https://example.com
166
216
  - **`robots.txt`** is *always* emitted; the `Sitemap:` line is added only when `site_url` is set. See [AI crawlers](#ai-crawlers) below for the explicit per-crawler groups.
167
217
  - **`sitemap.xml`** lists every built page (reactive pages included — they're indexable HTML). Each `<lastmod>` is the page's frontmatter `date:` if set, else its git last-commit date, else the file's mtime. No `<priority>`/`<changefreq>`.
168
218
  - **`rss.xml`** syndicates your **posts** — any page with a `date:` in its frontmatter. Newest first, capped at the 20 most recent. Each item's `<description>` is the page's `excerpt:`, else its `description:`, else (for a plain `.md` post) its first paragraph. Every page links the feed with `<link rel="alternate" type="application/rss+xml">` so readers can autodiscover it.
219
+ - **`rss_limit:`** on the home page changes that cap: `rss_limit: 50` carries the 50 newest posts. It takes digits only (a positive whole number of items), so `1e3`, `0x10`, and `7.0` are refused with `[WD950]` rather than silently coerced. Leave it out for the default of 20.
220
+
221
+ A post is just a page that carries a `date:`. Add `excerpt:` to control the summary the feed shows for it:
222
+
223
+ ```md
224
+ ---
225
+ title: Hello, Darkmown
226
+ date: 2026-01-15
227
+ excerpt: Why I rewrote my blog as plain Markdown files.
228
+ ---
229
+
230
+ # Hello, Darkmown
231
+ …
232
+ ```
169
233
 
170
234
  ### Canonical URLs
171
235
 
@@ -234,17 +298,6 @@ ai_crawlers: deny
234
298
 
235
299
  `allow` is the default (and matches what `User-agent: *` already grants). A value that is neither `allow` nor `deny` **fails the build** rather than defaulting: the intent behind a typo like `ai_crawlers: block` is almost always to opt *out*, and silently allowing would be the one unrecoverable outcome.
236
300
 
237
- ```md
238
- ---
239
- title: Hello, Darkmown
240
- date: 2026-01-15
241
- excerpt: Why I rewrote my blog as plain Markdown files.
242
- ---
243
-
244
- # Hello, Darkmown
245
- …
246
- ```
247
-
248
301
  Without `site_url`, `robots.txt` still emits and the build prints a one-line hint telling you which field to set; it never crashes.
249
302
 
250
303
  ### Drafts
@@ -333,6 +386,7 @@ Shape a loop without writing JavaScript. Clauses come in a fixed order after `in
333
386
  - `offset <N>` / `limit <N>` — `<N>` is a non-negative integer **or** a `:state`/`:store` key, which makes pagination reactive:
334
387
 
335
388
  ```wd
389
+ :state products = [{"name": "Aurora Lamp"}, {"name": "Briza Fan"}]
336
390
  :state pageSize = 10
337
391
 
338
392
  @loop products into product limit pageSize
@@ -353,6 +407,8 @@ Each row exposes five meta variables, relative to the rendered slice:
353
407
  They work in interpolation and in `:if`:
354
408
 
355
409
  ```wd
410
+ :state products = [{"name": "Aurora Lamp"}, {"name": "Briza Fan"}]
411
+
356
412
  @loop products into product
357
413
  :if $first
358
414
  **Top pick:**
@@ -366,6 +422,8 @@ They work in interpolation and in `:if`:
366
422
  Add an `@empty` branch to show a fallback when the loop renders no rows (after `where`, `limit`, and the rest):
367
423
 
368
424
  ```wd
425
+ :state todos = []
426
+
369
427
  @loop todos into todo
370
428
  - { todo.title }
371
429
  @empty
@@ -377,6 +435,25 @@ A **missing** in-scope source is an empty list, not an error: `@loop meta.tags i
377
435
 
378
436
  > **Note:** All of these clauses stay build-time when the source and every clause argument are static — a sorted, limited loop over a JSON file ships **zero JavaScript**. The loop becomes reactive only when the source is `:state`/`:store`/`:fetch` data, or a clause reads reactive state (like `limit pageSize`).
379
437
 
438
+ ### Table rows from a loop
439
+
440
+ A **static** loop whose body is bare `| … |` cells fills a Markdown table. Write the header in prose and let the loop supply the rows:
441
+
442
+ ```wd
443
+ | Item | Price |
444
+ | --- | --- |
445
+ @loop /products.json into row
446
+ | **{ row.name }** | { row.price } |
447
+ @endloop
448
+ ```
449
+
450
+ That renders one `<table>` with one `<tr>` per row, and each cell holds ordinary inline Markdown, links included (`| [buy](/buy/{ row.id }/) |`). A format pipe works too, but its `|` has to be escaped inside a table cell: `{ row.price \| money }`. Put the whole table inside the loop instead and you get a headerless `<table>` of the same shape; give the loop body its own `| --- |` separator and each row is a complete table, collapsed into one when the headers match.
451
+
452
+ Two limits to know before you build on it:
453
+
454
+ - A pipe row written in prose **after** `@endloop` (a totals row) does not join the table. It stays a paragraph.
455
+ - A **reactive** loop over pipe rows is `[WD191]`, not silent breakage. A reactive row is cloned into a `<div>`, which is not a legal child of `<table>`, so there is no correct HTML to emit. Loop a static source for a Markdown table, or build reactive rows out of containers (`::: trow` / `::: td`) and style them with `display: table-row` / `table-cell`.
456
+
380
457
  ### Editable lists — per-row actions
381
458
 
382
459
  A `:button` inside a reactive `@loop` can act on its own row. `cart += product` carries the current row into another list; `cart remove line` drops the current row from the looped list:
@@ -407,7 +484,7 @@ That is a full add-to-cart / remove-line flow — and a to-do list with delete
407
484
 
408
485
  ### Nested reactive loops
409
486
 
410
- *New in 0.19.0.* A reactive `@loop` may contain an inner reactive `@loop` over one of the outer row's fields — one level of nesting. The inner source is a dotted path rooted at the outer item (`order.lines`), so each row renders its own list and both stay live as state changes:
487
+ A reactive `@loop` may contain an inner reactive `@loop` over one of the outer row's fields — one level of nesting. The inner source is a dotted path rooted at the outer item (`order.lines`), so each row renders its own list and both stay live as state changes:
411
488
 
412
489
  ```wd
413
490
  :state orders = [
@@ -518,11 +595,39 @@ State declared inside a section is scoped to it — two sections can both own a
518
595
 
519
596
  A container named `nav` or `main` emits the real landmark element (`<nav class="nav">`, `<main class="main">`) instead of a `<div>`, so scaffolded pages keep proper landmarks — a skip link skips a `::: nav`, and `::: main` becomes the page's `<main id="main">`. Any other name stays a `<div>` with that class.
520
597
 
598
+ ### Accessibility attributes
599
+
600
+ Darkmown has no general attribute syntax on purpose: styling is `.class` tokens and behavior is `->` actions, which is what keeps output predictable and static pages script-free. That left one thing genuinely unreachable, the ARIA vocabulary a screen reader needs. Exactly three attribute names now compile, on exactly two directives (`:::` and `:button`), always with a **double-quoted static value**:
601
+
602
+ ```wd
603
+ :state open = false
604
+
605
+ ::: card .note role="region" aria-label="Release notes" title="What changed"
606
+ Notes go here.
607
+ :::
608
+
609
+ ::: nav .menu role="navigation" aria-label="Main"
610
+ [Docs](/docs/)
611
+ :::
612
+
613
+ :button "Menu" aria-expanded="false" aria-controls="m" -> open toggle
614
+ ```
615
+
616
+ - On a container, attributes interleave freely with `.class` and `#id` tokens and coexist with `.class when <predicate>`.
617
+ - On a `:button`, they sit between the label and the `->`. An arrow inside a quoted value is safe: attributes are peeled before the action arrow is looked for.
618
+ - Values are HTML-escaped on emit, so nothing you write can close the attribute or open another.
619
+ - Anything outside the whitelist is `[WD650]`: `onclick=`, `style=`, `href=`, `class=`, `id=`, `data-*` are all refused, and so is `ARIA-LABEL` (the match is case-sensitive) or a single-quoted value. A name on the whitelist with no double-quoted value is `[WD651]`.
620
+ - **Values are static text.** There is no `{ state }` interpolation inside an aria value in this release: `aria-label="{ who }"` emits the literal braces. For a live accessible name, write the element in raw HTML on an `html: true` page, or set it from a colocated `.js`.
621
+
622
+ This is compile-time only. It costs zero runtime bytes, and a static page carrying attributes stays `runtime: false`.
623
+
521
624
  ### Reactive classes — `.class when <predicate>`
522
625
 
523
626
  A container class can be toggled by a predicate. Static `.class` tokens are unchanged; add `when <predicate>` to make one reactive:
524
627
 
525
628
  ```wd
629
+ :state products = [{"id": 1, "name": "Aurora", "price": 49, "featured": true}]
630
+
526
631
  @loop products into p
527
632
  ::: card .product .on-sale when p.price < 50 .featured when p.featured
528
633
  **{ p.name }** — ${ p.price }
@@ -552,7 +657,7 @@ Count is still zero.
552
657
 
553
658
  A condition reads the same predicate grammar as `.class when`: a bare path (truthy), or the comparisons `==` `!=` `<` `<=` `>` `>=` `contains`, joined with `and`, `or`, and `not`. (`@loop … where` is the comparison-only subset — operators with `and`/`or`.) Chain with `:else if` (any number; an optional bare `:else` must be the last branch):
554
659
 
555
- ```wd
660
+ ```wd-fragment
556
661
  :if plan == "pro" or seats >= 5
557
662
  Pro plan
558
663
  :else if trialDays > 0 and not expired
@@ -564,6 +669,8 @@ Free plan
564
669
 
565
670
  A whole chain compiles to nested conditional regions, so it stays reactive (or folds at build time when every value is static) exactly like a single `:if`.
566
671
 
672
+ **Declarations inside a closed branch are live.** A `:state`, `:store`, or `:theme` written inside an `:if` branch that starts closed is hydrated the moment the branch opens, `persist` included. The seed is claimed once per **key**, not per node, so closing and re-opening the branch does not reset the value the reader set, and the claimed seed is that key's `reset` baseline. (A `:computed` inside a closed branch stays dormant until the branch opens, by design.)
673
+
567
674
  Directive actions are intentionally narrow and compile-time checked. Arbitrary JavaScript belongs in colocated `.js` files.
568
675
 
569
676
  ### Button actions
@@ -599,15 +706,17 @@ Values are literals: a `"string"`, number, `true`/`false`/`null`, or inline JSON
599
706
 
600
707
  **One button can run several actions** with `;`. They apply in order, then the page renders once:
601
708
 
602
- ```wd
709
+ ```wd-fragment
603
710
  :button "Add to cart" -> cart.count++ ; cart.total += 9
604
711
  ```
605
712
 
713
+ A `:button` also accepts the three [accessibility attributes](#accessibility-attributes), written between the label and the `->`: `:button "Menu" aria-expanded="false" -> open toggle`.
714
+
606
715
  > **Pitfall:** `list toggle v` and `list remove v` match members by value (`===`). That is exact for strings, numbers, and booleans, but not reliable for object members — two equal-looking objects are different values. To remove a row object, loop the list and use the per-row `remove` action below.
607
716
 
608
717
  ### Computed values — `:computed`
609
718
 
610
- `:computed name = <expression>` derives state from other state with a compile-time-checked expression — names, numbers, arithmetic (`+ - * /`), comparisons, and the five **aggregates** over a list: `sum(list, field)`, `avg(list, field)`, `min(list, field)`, `max(list, field)`, and `count(list)`. It recomputes whenever an input changes and reads like any other binding (so it pairs naturally with [format pipes](#format-pipes--value--namearg)):
719
+ `:computed name = <expression>` derives state from other state with a compile-time-checked expression — names, numbers, arithmetic (`+ - * /`), comparisons, and the five **aggregates** over a list: `sum(list, field)`, `avg(list, field)`, `min(list, field)`, `max(list, field)`, and `count(list)`. It recomputes whenever an input changes and reads like any other binding (so it pairs naturally with [format pipes](#format-pipes---value--namearg-)):
611
720
 
612
721
  ```wd
613
722
  :store cart = [{"price": 89}, {"price": 12}]
@@ -626,14 +735,18 @@ The aggregate's field argument is a bare key on each row (`price`, not `item.pri
626
735
 
627
736
  ```wd
628
737
  :fetch board from "/status.json"
629
- :every 10s -> board refetch # live-refresh a dashboard
738
+ :every 10s -> board refetch
630
739
 
631
740
  :state secs = 0
632
- :every 1s -> secs++ # a ticking counter
741
+ :every 1s -> secs++
633
742
  ```
634
743
 
744
+ The first line live-refreshes a dashboard; the second is a ticking counter. There is no comment syntax on a directive line, so a trailing `#` note would be parsed as part of the action.
745
+
635
746
  Intervals **pause while the tab is hidden** (via `visibilitychange`) and resume on return, so a backgrounded dashboard stops firing requests and draining battery.
636
747
 
748
+ **`:every` is a page-level registration, not a per-row one.** A reactive `@loop` compiles its body once into a template and the runtime clones it per row, so a timer written inside that body would be registered once per row (three rows, three intervals), and a removed row's interval would keep firing. That placement is `[WD315]`. Declare the timer once outside the loop, at page level or inside the `::: section`, and act on the whole list (`:every 5s -> rows refetch`). It stays legal at page level, inside a `:::`, inside a **static** loop (which produces N literal copies that never churn), and in a reactive loop's `@empty` branch. `:button` inside a reactive loop is unaffected.
749
+
637
750
  ### Effects — `:effect`
638
751
 
639
752
  `:effect <watched> -> <actions>` runs actions whenever a watched state path changes. The actions are the same `:button` vocabulary (`;`-chained) — this is the escape hatch for side effects beyond `:computed` (which derives state) and `:fetch` deps (which auto-refetch):
@@ -647,6 +760,8 @@ Intervals **pause while the tab is hidden** (via `visibilitychange`) and resume
647
760
 
648
761
  Effects run after a render, against settled state, and an effect that mutates state triggers another pass — bounded by a 10-pass settle cap that warns (and stops) if an effect never settles. They do not fire on the initial load, only on a real change.
649
762
 
763
+ Like `:every`, `:effect` is page-level: written inside a reactive `@loop` body it is `[WD315]`, because an effect watches a top-level state key and its actions target one, so there is no per-row meaning to give it. Declare it once outside the loop.
764
+
650
765
  ## Fetching data
651
766
 
652
767
  `:fetch name from "url"` declares state and fills it from JSON over the network:
@@ -662,6 +777,7 @@ Each fetch automatically declares four state keys you can branch on:
662
777
  | `name` | the data | `null` until the response arrives |
663
778
  | `name_loading` | boolean | `true` while the request is in flight |
664
779
  | `name_error` | string | the error message, or `null` |
780
+ | `name_error_body` | object | the parsed JSON error body, or `null` |
665
781
  | `name_empty` | boolean | `true` when the data is `null`, `[]`, or `{}` |
666
782
 
667
783
  ### The four-state pattern
@@ -686,6 +802,28 @@ No team members yet.
686
802
 
687
803
  The lifecycle regions announce themselves: a bare `:if name_loading` compiles with `role="status" aria-live="polite"` and `:if name_error` with `role="alert"`, so assistive tech hears the flips with no extra markup. Author-supplied `role`/`aria-live` inside a region always wins.
688
804
 
805
+ ### What the server said: `name_error` and `name_error_body`
806
+
807
+ A real API explains why it refused, and Darkmown surfaces that instead of a status line. When the failing response has a JSON body:
808
+
809
+ - **`name_error`** is the body's own `error` field, then its `message` field, falling back to `HTTP <status>` when the body carries neither or is not JSON at all.
810
+ - **`name_error_body`** is the whole parsed body (`null` when the response was not JSON), so per-field messages render without a line of your own JavaScript.
811
+
812
+ Given a `422` whose body is `{"error": "Pick a file first.", "fields": {"photo": "No file was attached."}}`:
813
+
814
+ ```wd
815
+ :fetch signup from "/api/signup" method=POST
816
+
817
+ :if signup_error
818
+ **{ signup_error }**
819
+ :if signup_error_body
820
+ Photo: { signup_error_body.fields.photo }
821
+ :endif
822
+ :endif
823
+ ```
824
+
825
+ The first line renders *Pick a file first.*, not *Error: HTTP 422*. Both keys are declared automatically by `:fetch` and by a round-trip `:form` (one with both `into` and `action=`), and both are cleared at the start of the next request, so a stale message never outlives the failure that produced it.
826
+
689
827
  ### Options
690
828
 
691
829
  - `method=` — `GET` (default), `POST`, `PUT`, `PATCH`, or `DELETE`.
@@ -700,17 +838,28 @@ The lifecycle regions announce themselves: a bare `:if name_loading` compiles wi
700
838
 
701
839
  ### Authenticated requests and token refresh
702
840
 
703
- `headers=<key>` sends a state object as request headers — pair it with `:store` to persist a bearer token across reloads:
841
+ `headers=<key>` sends a state object as request headers; pair it with `:store` to keep the token across reloads.
842
+
843
+ **Seed the store empty.** A `:state`/`:store` value is a *build-time* seed: it is inlined into the page's public HTML, so a literal token written into a `.wd` file ships to every visitor. The token has to arrive at runtime, from your own `api/` endpoint:
704
844
 
705
845
  ```wd
706
- :store session = { "Authorization": "Bearer …" }
846
+ :store session = {}
847
+
848
+ :form action="/api/login" into login
849
+ :input email type=email required
850
+ :input password type=password required
851
+ :submit "Sign in"
852
+ :endform
853
+
854
+ :effect login -> session merge login
855
+
707
856
  :fetch feed from "/api/feed" headers=session
708
857
  ```
709
858
 
710
859
  Add `refresh="<url>"` and Darkmown manages the token lifecycle: when a request comes back `401`, it POSTs the current `headers` object to the refresh URL, writes the returned headers (the new token) back into the `session` state — persisting it, since `session` is a `:store` — and retries the original request once. Concurrent `401`s sharing a refresh URL are de-duplicated into a single in-flight refresh.
711
860
 
712
861
  ```wd
713
- :store session = { "Authorization": "Bearer …" }
862
+ :store session = {}
714
863
 
715
864
  :fetch feed from "/api/feed" headers=session refresh="/auth/refresh"
716
865
  ```
@@ -729,7 +878,7 @@ A URL can interpolate state with `{ }`. The fetch re-runs automatically when tha
729
878
 
730
879
  Trigger a reload by hand with the `refetch` action:
731
880
 
732
- ```wd
881
+ ```wd-fragment
733
882
  :button "Reload" -> roster refetch
734
883
  ```
735
884
 
@@ -797,10 +946,49 @@ The declared value is a **seed**: it is used only the first time, when the store
797
946
 
798
947
  Writing the token that matches the default is redundant but never wrong, so `:store cart = [] persist` compiles and means exactly what it says. If a value genuinely ends in one of these words, quote it: `:state note = "0 persist"` seeds the string.
799
948
 
949
+ There is a third word, [`from-url`](#url-as-state-from-url), which says the value also lives in the query string. It composes with `persist` and belongs to `:state` only. Writing `persist` and `ephemeral` on one line (or the same word twice) is `[WD261]` rather than a token silently folded into the value.
950
+
800
951
  `:computed` takes neither: computed values are derived rather than stored, so persist the state they derive from instead (that mistake is a compile error, `WD211`).
801
952
 
802
953
  > **Pitfall:** A store name must be unique. Declaring the same name as both a `:store` and a `:state` on one page is a compile error.
803
954
 
955
+ ## URL as state: `from-url`
956
+
957
+ A filter nobody can link to is half a feature. Add `from-url` to a `:state` and the value lives in the query string as well as in memory: a reload keeps it, a shared link arrives with it applied, and the back button walks through it.
958
+
959
+ ```wd
960
+ :state q = "" from-url
961
+ :state tier = "all" from-url
962
+
963
+ :bind q placeholder="Search products"
964
+
965
+ :radio tier
966
+ - all
967
+ - budget
968
+ - premium
969
+
970
+ :state products = [{"name": "Aurora Lamp", "tier": "budget"}]
971
+
972
+ @loop products into p where p.name contains q
973
+ - **{ p.name }** ({ p.tier })
974
+ @empty
975
+ Nothing matches that search.
976
+ @endloop
977
+ ```
978
+
979
+ Type in the box and the address bar becomes `?q=aurora`. Reload, and the search comes back. Copy the URL into a new tab and it opens on the same view.
980
+
981
+ The rules:
982
+
983
+ - **The parameter is named after the state key.** A section-scoped key like `cart:items` becomes the parameter `cart.items`, so the name stays readable and stays unique.
984
+ - **A value equal to its declared seed drops its parameter,** so the default page keeps a clean URL.
985
+ - **Writes go through `history.replaceState`,** so filtering never fills the back button with one entry per keystroke. `popstate` re-reads on back and forward, and a parameter that is gone restores the seed.
986
+ - **It composes with `persist`,** and the boot precedence is URL, then stored value, then seed. A link somebody sent you beats what this browser remembers. When back navigation lands on a clean URL, the stored value follows the seed too.
987
+ - **Strings stay strings.** For any other seed type the parameter is JSON-parsed, falling back to the raw string.
988
+ - **`from-url` is `:state` only.** A `:store` is shared by every page and every tab while a query parameter belongs to one page's address, so `from-url` on a `:store` or a `:theme` is `[WD260]` rather than a guess. On a `:computed` it is `[WD211]`: derive the value from a state key that does come from the URL.
989
+
990
+ Live demo: [darkmown.com/url-state/](https://darkmown.com/url-state/).
991
+
804
992
  ## Forms and persistence
805
993
 
806
994
  Fetched data and a form live happily on the same page:
@@ -837,6 +1025,55 @@ Fetched data and a form live happily on the same page:
837
1025
  - `:computed total = items.length * 4` derives state from state with a compile-time-checked expression — names, numbers, arithmetic, comparisons, and list aggregates (`sum`/`avg`/`min`/`max`/`count`). See [Computed values](#computed-values--computed).
838
1026
  - `:if item.path` works inside reactive loops for per-row branches, and nests — an inner `:if` resolves after the outer branch and stays reactive.
839
1027
 
1028
+ ### Bound controls outside a form
1029
+
1030
+ `:select`, `:radio`, and `:checkbox` mean two different things, and **where they sit decides which**. Inside a `:form` they are form fields, submitted by name (unchanged). Outside one they bind to a declared `:state` or `:store` of that name, exactly like `:bind` and `:slider`: move the control and the state changes, change the state and the control moves.
1031
+
1032
+ ```wd
1033
+ :state density = "Comfortable"
1034
+ :state previews = true
1035
+
1036
+ :select density
1037
+ - Compact
1038
+ - Comfortable
1039
+ - Spacious
1040
+
1041
+ :checkbox previews
1042
+ - Show image previews
1043
+
1044
+ :if density == "Compact"
1045
+ Rows sit tight together.
1046
+ :endif
1047
+ ```
1048
+
1049
+ - **The state has to exist first.** A bound field naming state that is not declared is `[WD450]`, which spells out both readings: declare the state, or move the field inside a `:form`.
1050
+ - **A bound `:checkbox` is a single boolean,** so it takes exactly one `- Label` line (the label shown beside it). Several options is `[WD451]`; for a set of choices use a `:radio` group. The multi-value checkbox group is the in-form behavior and is unchanged.
1051
+ - **A bound `:radio` group keeps its shared `name`,** which is what makes the browser treat it as mutually exclusive, and carries the chosen option's text.
1052
+
1053
+ ### File upload
1054
+
1055
+ A `:form` that contains a file field posts **multipart**, so the file itself travels:
1056
+
1057
+ ```wd
1058
+ :form into reply action="/api/upload/"
1059
+ :input photo type=file required
1060
+ :input caption placeholder="A caption (optional)"
1061
+ :submit "Upload"
1062
+ :endform
1063
+
1064
+ :if reply
1065
+ Uploaded **{ reply.name }**, { reply.size } bytes.
1066
+ :endif
1067
+
1068
+ :if reply_error
1069
+ **{ reply_error }**
1070
+ :endif
1071
+ ```
1072
+
1073
+ The compiler writes `enctype="multipart/form-data"` for the browser's native submit, and the runtime sends real `FormData` with **no** content type of its own so the browser writes the boundary. A raw `<input type="file">` on an `html: true` page counts as a file field too.
1074
+
1075
+ Bound controls inside the form carry no `name`, so `FormData` would never see them; on the multipart path they are appended by their state key instead of being silently dropped. A file field on a `method="get"` form is `[WD452]`: a GET request has no body, so only the file's name would ever travel, which is the kind of failure that looks like it worked.
1076
+
840
1077
  ## Backends & deploy
841
1078
 
842
1079
  Darkmown builds **100% static, CDN-cacheable HTML** — never per-request server rendering. Reactive pages hydrate from data-attributes client-side; dynamic data arrives via `:fetch`. When you need a backend, you don't learn a new syntax — you write a plain serverless function, and *it just comes with wherever you deploy*.
@@ -860,9 +1097,9 @@ export default async function (request, context) {
860
1097
 
861
1098
  Templates get you to a running, deployable app fast: `darkmown init shop --template store` ships a cart **and** an `api/checkout.js`; `--template dashboard` ships a `:fetch` view **and** an `api/metrics.js`; `--template blog` ships a typed posts collection (`_schema.wd` + one `@loop` over the folder) — adding a post is adding a `.md` file.
862
1099
 
863
- ## Interactions — `:slider`, `:sortable`, `:carousel`
1100
+ ## Interactions — `:slider`, `sortable`, `:carousel`
864
1101
 
865
- Rich interactions are **pay-for-what-you-use**: `:sortable`/`:carousel` compile to a tiny `/__wd/behaviors/<name>.js` module injected **only** on pages that use them, budgeted separately from the ≤8 KB core runtime. `:slider` is compile-time only — zero extra JS.
1102
+ Rich interactions are **pay-for-what-you-use**: `sortable`/`:carousel` compile to a tiny `/__wd/behaviors/<name>.js` module injected **only** on pages that use them, budgeted separately from the ≤8 KB core runtime. `:slider` is compile-time only — zero extra JS.
866
1103
 
867
1104
  ```wd
868
1105
  :slider volume = 50 min=0 max=100 step=5
@@ -884,7 +1121,7 @@ Second slide
884
1121
  ```
885
1122
 
886
1123
  - **`:slider name = v min max step`** renders a range input two-way bound through `:bind`; range values coerce to Number so `:computed`/math see a number. Ships no behavior module.
887
- - **`:sortable`** (a `@loop` clause) drag-reorders the underlying `:state`/`:store` list via Pointer Events (mouse + touch), with full keyboard support (Arrow Up/Down on a focused row, screen-reader instructions + a live "Moved to position N of M" announcement), rewriting the list through the public `window.wd` API so the keyed loop repaints. Valid only on a plain reactive loop (no `where`/`sort`/`reverse`/`offset`/`limit`).
1124
+ - **`sortable`** (a bare `@loop` clause, written without a leading colon) drag-reorders the underlying `:state`/`:store` list via Pointer Events (mouse + touch), with full keyboard support (Arrow Up/Down on a focused row, screen-reader instructions + a live "Moved to position N of M" announcement), rewriting the list through the public `window.wd` API so the keyed loop repaints. Valid only on a plain reactive loop (no `where`/`sort`/`reverse`/`offset`/`limit`).
888
1125
  - **`:carousel [autoplay=N]`** treats **each direct child block as one slide** (wrap each in its own block, e.g. `::: slide`, and size it in your skin), using native CSS scroll-snap (touch swipe is free) plus prev/next buttons, dot navigation, and mouse drag. `autoplay` is suppressed under `prefers-reduced-motion`.
889
1126
 
890
1127
  ## Inline attributes
@@ -896,13 +1133,13 @@ A trailing `{.class .class #id}` attaches classes / an id to the inline element
896
1133
  ![logo](/logo.svg){.brand}
897
1134
  ```
898
1135
 
899
- The block must follow the element with no space, and works on links, images, emphasis, and inline code. It never collides with `{ name }` interpolation — an interpolation always starts with a name, never a `.` or `#`.
1136
+ The block must follow the element with no space, and works on links, images, and emphasis. It does **not** attach to inline code: `` `x` ``{.hl} leaves the braces in the output as literal text. It never collides with `{ name }` interpolation, because an interpolation always starts with a name, never a `.` or `#`.
900
1137
 
901
1138
  **Headings get anchors for free.** Every markdown heading carries a stable, GitHub-style slug `id` at build time (lowercased, punctuation stripped, whitespace → hyphens; duplicates dedupe with `-1`/`-2` suffixes across the whole document), so any section of any page is deep-linkable with a plain `#the-slug` fragment — zero JS. The [docs page's](https://darkmown.com/docs/) "On this page" table of contents is just markdown links to those anchors.
902
1139
 
903
1140
  ## Media — `:video`, `:audio`, `:embed`
904
1141
 
905
- *New in 1.0.* Three one-line directives replace hand-written `<video>` / `<iframe>` markup. They are **compile-time only** — they emit no `data-wd-*`, so a media-only page still ships **zero** framework JavaScript.
1142
+ Three one-line directives replace hand-written `<video>` / `<iframe>` markup. They are **compile-time only** — they emit no `data-wd-*`, so a media-only page still ships **zero** framework JavaScript.
906
1143
 
907
1144
  ```wd
908
1145
  :video /clip.mp4 poster=/clip.jpg controls
@@ -917,7 +1154,7 @@ Darkmown's shipped CSP pre-authorizes exactly the two embed origins (`youtube-no
917
1154
 
918
1155
  ## Syntax highlighting
919
1156
 
920
- *New in 1.3.* Fenced code blocks with a language are highlighted at **build time** — HTML and CSS only, no client JavaScript. Tag the fence with a language and it just works:
1157
+ Fenced code blocks with a language are highlighted at **build time** — HTML and CSS only, no client JavaScript. Tag the fence with a language and it just works:
921
1158
 
922
1159
  ````md
923
1160
  ```js
@@ -950,7 +1187,7 @@ See it recolor live on the [Syntax highlighting demo](https://darkmown.com/highl
950
1187
 
951
1188
  ## Dark mode — `tokens dark`
952
1189
 
953
- *New in 0.19.0.* A colocated `.skin` file already declares its palette in a `tokens` block (`name value` pairs referenced elsewhere as `$name`). Add a second `tokens dark` block to override any of those tokens under the visitor's OS dark preference — it compiles to a `@media (prefers-color-scheme: dark) :root { … }` rule, so the page follows the system theme with **zero JavaScript**.
1190
+ A colocated `.skin` file already declares its palette in a `tokens` block (`name value` pairs referenced elsewhere as `$name`). Add a second `tokens dark` block to override any of those tokens under the visitor's OS dark preference — it compiles to a `@media (prefers-color-scheme: dark) :root { … }` rule, so the page follows the system theme with **zero JavaScript**.
954
1191
 
955
1192
  ```skin
956
1193
  tokens
@@ -970,7 +1207,7 @@ A single `tokens dark` block powers **both** theming paths: it compiles to `:roo
970
1207
 
971
1208
  ### Manual toggle — `:theme`
972
1209
 
973
- *New in 1.0.* For an explicit light/dark switch alongside (or instead of) the OS preference, declare `:theme` once and drive it with ordinary buttons. It registers a durable `theme` store and reflects its value onto `<html data-theme="…">`:
1210
+ For an explicit light/dark switch alongside (or instead of) the OS preference, declare `:theme` once and drive it with ordinary buttons. It registers a durable `theme` store and reflects its value onto `<html data-theme="…">`:
974
1211
 
975
1212
  ```wd
976
1213
  :theme
@@ -983,7 +1220,7 @@ Because `tokens dark` already emits the `[data-theme="dark"]` rule, **no extra s
983
1220
 
984
1221
  ## Scoped styles — `scoped`
985
1222
 
986
- *New in 1.4.0.* By default a colocated `.skin` is **global** — its selectors match the whole page, exactly like a stylesheet. That's the right default for a design system. But when two components both want a class called `.card`, global CSS makes them fight. Opt a skin into **scoping** so its selectors only ever match the component it ships with: make the **first line** of the `.skin` file the word `scoped`.
1223
+ By default a colocated `.skin` is **global** — its selectors match the whole page, exactly like a stylesheet. That's the right default for a design system. But when two components both want a class called `.card`, global CSS makes them fight. Opt a skin into **scoping** so its selectors only ever match the component it ships with: make the **first line** of the `.skin` file the word `scoped`.
987
1224
 
988
1225
  ```skin
989
1226
  scoped
@@ -1097,7 +1334,7 @@ Every author-facing compile error opens with a stable `WDxxx` code, so it is sea
1097
1334
  Use: :state name = value [persist|ephemeral] — e.g. :state count = 0
1098
1335
  ```
1099
1336
 
1100
- The full list lives in [`docs/errors.md`](docs/errors.md) — code, cause, fix, and a compilable example each. Codes are grouped by subsystem:
1337
+ The full list lives in [`docs/errors.md`](https://github.com/ZVN-DEV/darkmown/blob/master/docs/errors.md): code, cause, fix, and a compilable example each. (It is generated into the repository, not the published tarball; `darkmown catalog --llms-full` prints the same content offline.) Codes are grouped by subsystem:
1101
1338
 
1102
1339
  | Range | Subsystem |
1103
1340
  | --- | --- |
@@ -1168,7 +1405,7 @@ session.call("validate", {});
1168
1405
 
1169
1406
  ## Editor support
1170
1407
 
1171
- A VS Code extension in [`editors/vscode`](editors/vscode) gives `.wd` and `.skin` files syntax highlighting, snippets, and folding — so a `.wd` file reads as Markdown-plus-directives, never as broken Markdown.
1408
+ A VS Code extension in [`editors/vscode`](https://github.com/ZVN-DEV/darkmown/tree/master/editors/vscode) gives `.wd` and `.skin` files syntax highlighting, snippets, and folding — so a `.wd` file reads as Markdown-plus-directives, never as broken Markdown.
1172
1409
 
1173
1410
  Install from a `.vsix` today:
1174
1411
 
@@ -1177,7 +1414,7 @@ npm run pack:extension # builds editors/vscode/darkmown-<version>.vsix
1177
1414
  code --install-extension editors/vscode/darkmown-*.vsix
1178
1415
  ```
1179
1416
 
1180
- Or inside VS Code: Extensions panel → `…` menu → **Install from VSIX…** and pick the built file. A Visual Studio Marketplace listing is pending (see [`editors/vscode/PUBLISHING.md`](editors/vscode/PUBLISHING.md)); until it lands, the `.vsix` route above is the supported install.
1417
+ Or inside VS Code: Extensions panel → `…` menu → **Install from VSIX…** and pick the built file. A Visual Studio Marketplace listing is pending (see [`editors/vscode/PUBLISHING.md`](https://github.com/ZVN-DEV/darkmown/blob/master/editors/vscode/PUBLISHING.md)); until it lands, the `.vsix` route above is the supported install.
1181
1418
 
1182
1419
  ## Accessibility
1183
1420
 
@@ -1187,6 +1424,7 @@ Every compiled page ships with landmark-and-announcement basics baked in at buil
1187
1424
  - **Document language.** `lang:` frontmatter sets `<html lang>` per page (default `en`).
1188
1425
  - **Live `:fetch` regions.** A bare `:if name_loading` region over a `:fetch` key compiles to `role="status" aria-live="polite"` and `:if name_error` to `role="alert"`, so screen readers announce loading and error flips for free. Write your own `role`/`aria-live` inside the region and Darkmown adds nothing.
1189
1426
  - **Accessible names on form controls.** Generated `:input`/`:bind`/`:textarea`/`:select` controls without an author-supplied `aria-label`/`aria-describedby` get an `aria-label` derived from the placeholder or field name; `:slider` and choice groups do the same.
1427
+ - **`role`, `aria-*`, and `title` on containers and buttons.** `::: card role="region" aria-label="Notes"` and `:button "Menu" aria-expanded="false" -> open toggle` compile without raw HTML. Values are static, double-quoted, and escaped on emit. See [Accessibility attributes](#accessibility-attributes) for the whole rule.
1190
1428
 
1191
1429
  ## Security
1192
1430
 
@@ -1211,6 +1449,35 @@ Builds emit security response headers so a deployed site gets sane defaults with
1211
1449
 
1212
1450
  **If you use `:fetch` or `:form action=` against another host, widen `connect-src`.** The shipped CSP allows same-origin connections; calling a third-party API is otherwise blocked by the policy. Add the host to `connect-src` in your deploy config (it is not auto-derived from your page sources). The CSP is a defense-in-depth layer — it does not replace the trust-boundary rules above; see [SECURITY.md](SECURITY.md) for tightening guidance.
1213
1451
 
1452
+ ## Limits
1453
+
1454
+ Darkmown is small on purpose. Here is what it deliberately refuses and what it genuinely cannot do today, collected in one place so you can decide before you build rather than after.
1455
+
1456
+ **Refusals that will not change:**
1457
+
1458
+ - **`.md` never gets directives.** The extension is the feature gate, and it holds transitively: an `.md` page that `@include`s a `.wd` still ships `runtime: false` and inert text.
1459
+ - **No `eval`, and a closed expression vocabulary.** Predicates, `:computed`, and directive actions are compile-time whitelists interpreted by a closed evaluator. There are no custom format pipes and no user-supplied functions anywhere in the language. (That is also why reactive pages need no `'unsafe-eval'`.)
1460
+ - **No backend DSL.** `api/` is plain Web-standard JavaScript. Darkmown owns no server and never will.
1461
+ - **No built-in sanitizer.** The model is trusted-author; see [Security](#security).
1462
+
1463
+ **Structural limits, true today:**
1464
+
1465
+ - **No per-request rendering.** Everything is build time. A `:state`-seeded `@loop` *does* bake its rows into the initial HTML, so that content is indexable, but anything behind `:fetch` is invisible to crawlers and to your own `sitemap.xml`. No personalization, no draft preview URLs.
1466
+ - **Auth is a public shell with `api/`-gated data.** A `:if session` gate ships *both* branches into the HTML, and a `:state`/`:store` seed is inlined into public HTML, so a token must never be written into a page (see [Authenticated requests](#authenticated-requests-and-token-refresh)). The workable architecture is a public page whose data is gated behind your own endpoint.
1467
+ - **No layout or shell inheritance.** Every page includes its own nav and footer. There is no template a page extends and no slot to fill.
1468
+ - **No arbitrary `<head>` content.** The document head is what frontmatter drives; you cannot inject your own tags.
1469
+ - **No route generation from data.** Collections are folders of files. A JSON array cannot become routes. `paginate N` is the one route multiplier, and only over an existing collection listing.
1470
+ - **Only accessibility attributes on containers and buttons.** `:::` and `:button` accept `role="…"`, `aria-…="…"`, and `title="…"`, and nothing else: `class=`, `id=`, `style=`, `data-*`, and event handlers are compile errors by design. Style with `.class` tokens, act with `->` actions, and reach for raw HTML on an `html: true` page when you genuinely need another attribute.
1471
+ - **Aria values are static text.** There is no `{ state }` interpolation inside `role`/`aria-*`/`title`, so a live accessible name means raw HTML or a colocated `.js`.
1472
+ - **Raw HTML attributes do not bind.** A Markdown link or image destination does ([Destinations that bind](#destinations-that-bind)), but `<a href="{ url }">` on an `html: true` page is painted once at build time and warned about. There is no element for the compiler to mark up.
1473
+ - **Reactive rows cannot fill a Markdown table.** A reactive row is cloned into a `<div>`, which is not a legal child of `<table>`, so `[WD191]` refuses it. Static loops fill tables fine; for reactive rows, build them from containers (`::: trow` / `::: td`) and style them as a table.
1474
+ - **No i18n beyond `lang:`.** No `hreflang`, no message catalog, no ambient locale, no locale routing. A multilingual site means duplicating the tree per locale.
1475
+ - **Every framework asset URL is absolute** (`/__wd/...`), so a Darkmown site cannot be mounted at a subpath. It is all-or-nothing per origin.
1476
+ - **Includes are macros, not components.** No children, no slots, no default arguments, and a missing argument renders `{ title }` literally rather than erroring.
1477
+ - **No `darkmown test`.** There is no built-in way to assert your own site's behavior. Use [`compileFromMemory`](#programmatic-compile--compilefrommemory) against your own pages, or a browser test runner.
1478
+
1479
+ **Migrating out is easy, and that is on purpose.** `dist/` is portable static HTML plus one runtime file of ~6.6 KB gzipped. There is no server to port, no proprietary component format, no framework runtime baked through the output, and nothing to un-eject. Point any host at the folder, or hand it to whatever you move to next, and the site keeps working. The lock-in is a directory of Markdown files you already own.
1480
+
1214
1481
  ## Spec status
1215
1482
 
1216
1483
  See [docs/spec-alignment.md](docs/spec-alignment.md) for the deep alignment audit against the original vision.