@zvndev/darkmown 0.20.0 → 1.1.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 (57) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +29 -0
  3. package/README.md +125 -5
  4. package/docs/spec-alignment.md +23 -3
  5. package/package.json +3 -3
  6. package/site/_/base.skin +372 -0
  7. package/site/_/base.wd +4 -0
  8. package/site/_/footer.wd +14 -0
  9. package/site/_/metrics.json +13 -0
  10. package/site/_/nav.skin +35 -12
  11. package/site/_/nav.wd +8 -10
  12. package/site/_/products.json +10 -0
  13. package/site/pages/app.wd +1 -1
  14. package/site/pages/carousel/index.js +82 -0
  15. package/site/pages/carousel/index.skin +185 -0
  16. package/site/pages/carousel/index.wd +109 -0
  17. package/site/pages/carousel/slide-1.jpg +0 -0
  18. package/site/pages/carousel/slide-2.jpg +0 -0
  19. package/site/pages/carousel/slide-3.jpg +0 -0
  20. package/site/pages/carousel/slide-4.jpg +0 -0
  21. package/site/pages/carousel/slide-5.jpg +0 -0
  22. package/site/pages/compass/index.skin +247 -0
  23. package/site/pages/compass/index.wd +194 -0
  24. package/site/pages/docs/index.skin +48 -28
  25. package/site/pages/docs/index.wd +72 -5
  26. package/site/pages/folio/-style.skin +375 -0
  27. package/site/pages/folio/-style.wd +6 -0
  28. package/site/pages/folio/cart.skin +309 -0
  29. package/site/pages/folio/cart.wd +201 -0
  30. package/site/pages/folio/index.skin +24 -0
  31. package/site/pages/folio/index.wd +167 -0
  32. package/site/pages/forge/index.skin +676 -0
  33. package/site/pages/forge/index.wd +330 -0
  34. package/site/pages/index.skin +135 -144
  35. package/site/pages/index.wd +17 -5
  36. package/site/pages/ledger/index.skin +257 -0
  37. package/site/pages/ledger/index.wd +183 -0
  38. package/site/pages/media/clip-poster.jpg +0 -0
  39. package/site/pages/media/clip.mp4 +0 -0
  40. package/site/pages/media/index.skin +82 -0
  41. package/site/pages/media/index.wd +84 -0
  42. package/site/pages/media/tone.mp3 +0 -0
  43. package/site/pages/pulse/index.skin +434 -0
  44. package/site/pages/pulse/index.wd +178 -0
  45. package/site/pages/showcase/index.skin +130 -0
  46. package/site/pages/showcase/index.wd +168 -0
  47. package/src/compiler/body.js +89 -3
  48. package/src/compiler/context.js +1 -1
  49. package/src/compiler/directives.js +199 -3
  50. package/src/compiler/format.js +244 -0
  51. package/src/compiler/interpolation.js +36 -1
  52. package/src/compiler/loops.js +95 -24
  53. package/src/compiler/markdown.js +16 -7
  54. package/src/compiler/predicates.js +35 -2
  55. package/src/headers.js +5 -0
  56. package/src/runtime.js +168 -14
  57. package/src/skin.js +9 -1
package/AGENTS.md CHANGED
@@ -7,7 +7,7 @@ Darkmown is a Markdown-native web framework. You build sites by writing `.md` an
7
7
  - `.md` files are plain CommonMark. Directives stay inert text.
8
8
  - `.wd` files are the same Markdown **plus directives** (state, loops, conditionals, includes, forms).
9
9
  - To make a `.md` page interactive, **rename it to `.wd`**. ⚠️ Renaming means the old `.md` is **gone** — never leave both `index.md` and `index.wd`, that is a fatal `Duplicate route` build error. Upgrade = delete the `.md`, create the `.wd`.
10
- - Static pages ship **zero** JavaScript; a page loads the ~5.7 KB gzipped runtime (CI-enforced under 6 KB) only if it declares reactive behavior.
10
+ - Static pages ship **zero** JavaScript; a page loads the ~7.4 KB gzipped runtime (CI-enforced under 8 KB) only if it declares reactive behavior.
11
11
 
12
12
  ## Project layout & routing
13
13
 
package/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  All notable changes to Darkmown are documented here. Versions follow [semver](https://semver.org); pre-1.0 minor versions may contain breaking changes.
4
4
 
5
+ ## 1.1.0 — 2026-06-27
6
+
7
+ Follow-up to 1.0: turn the two rough edges flagged at release into real fixes. No runtime change (still ~7.4 KB gzipped).
8
+
9
+ - **Multi-line `:state` / `:store` / `:theme` values.** An array or object seed may now span several lines for readability — open the `[`/`{` on the directive line and let it run until it balances:
10
+
11
+ ```wd
12
+ :store rows = [
13
+ {"id": 1, "label": "One"},
14
+ {"id": 2, "label": "Two"}
15
+ ]
16
+ ```
17
+
18
+ 1.0 made the old silent-corruption case (the literal stored verbatim as the string `"["`) a compile error; 1.1 makes the readable multi-line form actually *work*. The compiler joins the balanced literal (string- and escape-aware bracket counting) into one line before parsing. The literal must balance with no blank line inside it; an unterminated literal stays a clear compile error. Quote genuinely literal bracket text (`:state tag = "[draft]"`). The Ledger demo's seed data is now written this way.
19
+ - **Single source of truth for the runtime-size budget (internal).** The gzip budget had drifted into four hardcoded copies (`package.json`, `scripts/size-check.mjs`, `.github/workflows/ci.yml`, `tests/size.test.js`) — a mismatch in `ci.yml` failed the 1.0 PR. The budget now lives only in `.size-snapshot.json` (`runtime.budget`); `scripts/size-check.mjs` reads it and everything else delegates to that script. A `tests/size.test.js` drift guard fails if any config reintroduces a hardcoded budget or an inline size gate.
20
+
21
+ ## 1.0.0 — 2026-06-27
22
+
23
+ The 1.0 release. The directive surface is now stable, and this version closes the biggest "I'd have to drop to raw HTML/JS for that" gaps an audit found — display formatting, time, an explicit theme toggle, live table sort, media, and a real escape hatch — while holding the zero-JS-by-default promise. Six additions, all either compile-time or sharing the one runtime, which moves from ~5.8 KB to **~7.4 KB gzipped**; the CI budget is raised 6 KB → **8 KB** to match (still un-minified, fully commented). No source-level breaking changes: existing `.wd` pages compile and behave identically.
24
+
25
+ - **Format pipes — `{ value | name:arg }`.** Shape a value for display with a Liquid/Angular-style pipe, chainable and argument-taking: `{ price | money }`, `{ joinedAt | date:"medium" }`, `{ bio | truncate:80 }`. The whitelist covers `money` / `number` / `percent` / `round`, `date` / `time` / `datetime` (Intl-backed), `upper` / `lower` / `capitalize`, `truncate` / `trim`, `pluralize`, and `default`. Pipes fold at build time on static values (zero-JS) and re-apply on every render for `:state`/`:store` bindings — same syntax. There is deliberately **no relative "time ago"** formatter, so builds stay reproducible. An unknown pipe is a compile error with the valid list.
26
+ - **Aggregates — `sum` / `avg` / `min` / `max` / `count`.** Usable both in `:computed` (`:computed total = sum(cart, price)`) and as pipes over a list (`{ cart | count }`, `{ cart | sum:"price" | money }`). The field argument is a bare row key. This is the first time `:computed` can roll up a list — previously a hard gap that forced hand-written JS.
27
+ - **Timers — `:every <duration> -> <actions>`.** The one piece of time the framework owns: `:every 10s -> board refetch`, `:every 1s -> secs++`. Durations are `ms`/`s`/`m`; actions are the `:button`/`:effect` vocabulary. Intervals **pause while the tab is hidden** and resume on return, so a backgrounded dashboard stops firing requests.
28
+ - **Manual theme toggle — `:theme`.** Declare `:theme` once and drive it with buttons (`:button "Dark" -> theme = "dark"`); it registers a durable `theme` store and reflects onto `<html data-theme>`. A single `tokens dark` block now compiles to **both** `:root[data-theme="dark"]` (the toggle) and the OS `prefers-color-scheme` media query (scoped to `:not([data-theme="light"])`), so one palette powers OS-auto and the manual switch. `"auto"` clears the attribute and follows the OS again.
29
+ - **Reactive table sort.** `@loop … sort by { sortKey } { sortDir }` — the sort field *and* direction can each be a `{ state }` reference, so a clickable column header re-sorts the table live with no JavaScript. Drives the new Ledger demo.
30
+ - **Media — `:video` / `:audio` / `:embed`.** Three compile-time-only directives (zero `data-wd-*`, so a media page stays zero-JS). `:video`/`:audio` emit a hardened HTML5 player with whitelisted flags/attrs and sane defaults (`preload="metadata"`, `controls`, `autoplay`⇒`muted`); `:embed` rewrites a YouTube/Vimeo URL to its no-cookie/player form in a responsive lazy `16/9` iframe. The shipped CSP now pre-authorizes exactly those two embed origins plus `media-src 'self' https:`, so embeds and remote media work out of the box on the bundled server, Cloudflare `_headers`, and Vercel.
31
+ - **A real escape hatch — `wd.subscribe`.** `window.wd` gains `subscribe(key, cb)` (primes immediately, then fires on every settled change, returns an unsubscribe), joining `get`/`set`/`state`/`render`. This is the bridge for colocated `.js` "behaviors" — the gestural layer the framework deliberately omits (keyboard, drag/touch, canvas). The new Swiper demo is a draggable, keyboard-navigable carousel built entirely on it: the framework owns the `slide` state, dots, and readout declaratively; the behavior owns the gestures and transition.
32
+ - **Hardening.** A multi-line `:state`/`:store` value (e.g. an array opened on the directive line and continued below) is now a clear compile error instead of silently storing the partial `"["` string — these directives take a single-line value. New demos: **Ledger** (sortable expense table), **Swiper** (escape-hatch carousel), and **Media**. The Folio cart now totals with `sum` + `| money`; Pulse live-refreshes with `:every`.
33
+
5
34
  ## 0.20.0 — 2026-06-26
6
35
 
7
36
  Engineering hardening: a real programmatic API, plus a gold-standard pass on tooling, tests, and internals. No behavior change for existing sites — the runtime is byte-for-byte the same (~5.8 KB gzipped).
package/README.md CHANGED
@@ -7,7 +7,19 @@
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 ~5.8 KB gzipped, CI-enforced under 6 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 ~7.4 KB gzipped, CI-enforced under 8 KB.
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`).
11
23
 
12
24
  ## Quick start
13
25
 
@@ -52,7 +64,7 @@ npm run dev # live demo site — the same site that runs darkmown.com
52
64
  - Files or folders starting with `.`, `-`, or `_` are hidden from routing.
53
65
  - `site/_` is the include shelf for `@include /name.wd`.
54
66
  - Matching `page.skin` and `page.js` colocate styling and behavior by basename.
55
- - Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~5.8 KB gzipped, CI-enforced under 6 KB).
67
+ - Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~7.4 KB gzipped, CI-enforced under 8 KB).
56
68
  - Shelf `.json` files are published at `/__wd/data/` so `:fetch` works on any static host.
57
69
 
58
70
  ## Interpolation
@@ -65,6 +77,24 @@ One syntax everywhere: `{ name }` or `{ name.path }`.
65
77
  - Anything else stays literal text — braces in prose never break a page or pull in the runtime.
66
78
  - 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.)
67
79
 
80
+ ### Format pipes — `{ value | name:arg }`
81
+
82
+ Shape a value for display with a pipe, Liquid/Angular style: `{ price | money }`, `{ joinedAt | date:"medium" }`, `{ ratio | percent }`. Pipes **chain** left to right (`{ name | trim | capitalize }`) and take literal arguments (`{ total | money:"EUR" }`, `{ bio | truncate:80 }`). They work in static prose (folded at build time, zero-JS) and on live `:state`/`:store` bindings (re-applied on every render) — identical syntax either way. The pipe list is a fixed whitelist compiled to safe calls; there is no custom-function hook and nothing is `eval`'d.
83
+
84
+ | Pipe | Example | Result |
85
+ |---|---|---|
86
+ | `money[:currency[:locale]]` | `{ 89 \| money }` | `$89.00` |
87
+ | `number[:decimals]` | `{ 1234.5 \| number }` | `1,234.5` |
88
+ | `percent[:decimals]` | `{ 0.082 \| percent }` | `8%` |
89
+ | `round[:decimals]` | `{ 3.14159 \| round:2 }` | `3.14` |
90
+ | `date` / `time` / `datetime` `[:style]` | `{ "2026-06-22" \| date:"medium" }` | `Jun 22, 2026` |
91
+ | `upper` / `lower` / `capitalize` | `{ "draft" \| capitalize }` | `Draft` |
92
+ | `truncate:n` / `trim` | `{ bio \| truncate:80 }` | `…the first 80 chars…` |
93
+ | `pluralize:"item"[:"plural"]` | `{ 3 \| pluralize:"item" }` | `3 items` |
94
+ | `default:"—"` | `{ missing \| default:"—" }` | `—` |
95
+
96
+ The five **aggregates** double as pipes over a list — `{ cart \| count }`, `{ cart \| sum:"price" \| money }`, plus `avg` / `min` / `max` — and `join:", ":"name"` flattens a list of rows to a string. `date`/`time`/`datetime` are `Intl`-backed (`short` / `medium` / `long`); there is deliberately **no relative "time ago"** formatter, so builds stay byte-for-byte reproducible. An unknown pipe name is a compile error with the valid list.
97
+
68
98
  ## Frontmatter
69
99
 
70
100
  YAML-style `key: value` frontmatter between `---` fences. Values are strings, plus inline arrays:
@@ -166,6 +196,7 @@ Shape a loop without writing JavaScript. Clauses come in a fixed order after `in
166
196
  ```
167
197
 
168
198
  - `sort by <key> [asc|desc]` — `<key>` must start with the loop item (`post.date`, not `date`). Numbers sort numerically; everything else sorts as text. `asc` is the default.
199
+ - **Reactive sort** — the field *and* the direction can each be a `{ state }` reference, so a clickable column header re-sorts the table live without any JavaScript: `sort by { sortKey } { sortDir }`. Drive them with `:state sortKey = "amount"` / `:state sortDir = "desc"` and `:button "Amount" -> sortKey = "amount" ; sortDir = "desc"`. The bare `{ sortKey }` resolves to a field on the loop item at render time.
169
200
  - `reverse` — reverse the (already sorted) order.
170
201
  - `offset <N>` / `limit <N>` — `<N>` is a non-negative integer **or** a `:state`/`:store` key, which makes pagination reactive:
171
202
 
@@ -369,6 +400,35 @@ Values are literals: a `"string"`, number, `true`/`false`/`null`, or inline JSON
369
400
 
370
401
  > **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.
371
402
 
403
+ ### Computed values — `:computed`
404
+
405
+ `: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)):
406
+
407
+ ```wd
408
+ :store cart = [{"price": 89}, {"price": 12}]
409
+ :computed subtotal = sum(cart, price)
410
+ :computed tax = subtotal * 0.08
411
+ :computed total = subtotal + tax
412
+
413
+ Subtotal: { subtotal | money } · Tax: { tax | money } · **{ total | money }**
414
+ ```
415
+
416
+ The aggregate's field argument is a bare key on each row (`price`, not `item.price`). There are no function calls beyond the five aggregates and no property access beyond dotted state paths — anything richer belongs in a colocated `.js` behavior.
417
+
418
+ ### Timers — `:every`
419
+
420
+ `:every <duration> -> <actions>` runs a `:button`-style action on an interval — the one piece of time the framework owns. Durations are `<n>ms` / `<n>s` / `<n>m`, and the actions are the same `;`-chained vocabulary as `:button`/`:effect` (including `name refetch` to re-run a `:fetch`):
421
+
422
+ ```wd
423
+ :fetch board from "/status.json"
424
+ :every 10s -> board refetch # live-refresh a dashboard
425
+
426
+ :state secs = 0
427
+ :every 1s -> secs++ # a ticking counter
428
+ ```
429
+
430
+ Intervals **pause while the tab is hidden** (via `visibilitychange`) and resume on return, so a backgrounded dashboard stops firing requests and draining battery.
431
+
372
432
  ### Effects — `:effect`
373
433
 
374
434
  `: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):
@@ -496,6 +556,16 @@ You have { cart } items.
496
556
  - **Shared across tabs.** Change a store in one tab and every other tab on the same site updates live.
497
557
  - **Global by name.** A bare `{ cart }` reads the same store everywhere — stores are never section-scoped.
498
558
  - **Same value grammar as `:state`** — string, number, boolean, `null`, array, or object — and the **same** button actions (`cart += …`, `count++`, `theme = "dark"`, and so on).
559
+ - **Multi-line seeds.** An array or object literal may span several lines for readability — open the `[`/`{` on the `:store`/`:state` line and let it run until it closes:
560
+
561
+ ```wd
562
+ :store rows = [
563
+ {"id": 1, "label": "One"},
564
+ {"id": 2, "label": "Two"}
565
+ ]
566
+ ```
567
+
568
+ The literal must balance with no blank line inside it (a blank line ends the value); an unterminated literal is a compile error. Quote genuinely literal bracket text — `:state tag = "[draft]"`.
499
569
 
500
570
  The declared value is a **seed**: it is used only the first time, when the store is absent from storage. After that the persisted value wins, so visitors keep their data.
501
571
 
@@ -542,7 +612,7 @@ Fetched data and a form live happily on the same page:
542
612
  - `:form action="/url" into reply` does both: with JS the submit posts urlencoded via fetch and the JSON reply lands in state `reply` (`reply_error` on failure); without JS it is the same native POST. Darkmown adapts to any backend — it does not own one.
543
613
  - Field directives: `:input`, `:textarea name [rows=N]`, `:select name`, `:checkbox name`, and `:radio name` (the last three take `- Label` option lines) all capture into `:form into` state the same way. A `:checkbox` group captures **every checked value as an array**; a `:radio` group captures a single value. Each derives a non-visual `aria-label` from its placeholder, else a humanized field name, unless you supply `aria-label`/`aria-describedby`.
544
614
  - `:state x = [] persist` keeps a single page's state in localStorage across reloads. (For state that is shared across pages and tabs, reach for [`:store`](#global-state--store) instead.)
545
- - `:computed total = items.length * 4` derives state from state with a compile-time-checked expression (names, numbers, arithmetic, comparisons — nothing else).
615
+ - `: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).
546
616
  - `:if item.path` works inside reactive loops for per-row branches, and nests — an inner `:if` resolves after the outer branch and stays reactive.
547
617
 
548
618
  ## Inline attributes
@@ -556,6 +626,21 @@ A trailing `{.class .class #id}` attaches classes / an id to the inline element
556
626
 
557
627
  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 `#`.
558
628
 
629
+ ## Media — `:video`, `:audio`, `:embed`
630
+
631
+ *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.
632
+
633
+ ```wd
634
+ :video /clip.mp4 poster=/clip.jpg controls
635
+ :audio /track.mp3 controls
636
+ :embed https://youtu.be/aqz-KE-bpKQ title="Big Buck Bunny"
637
+ ```
638
+
639
+ - **`:video` / `:audio`** compile to a hardened HTML5 player. `preload="metadata"` and `controls` are added by default; flags (`controls`, `autoplay`, `loop`, `muted`, `playsinline`) and attributes (`poster`, `width`, `height`, `preload`) are validated against a per-element whitelist, and `autoplay` silently implies `muted` (browsers block sound-on autoplay). The `src`/`poster` URLs run through the same scheme guard as `:fetch` — relative or `http(s)` only.
640
+ - **`:embed`** rewrites a YouTube or Vimeo URL to its **no-cookie / player** form, wraps it in a responsive `16/9` box, and marks the iframe `loading="lazy"` with a locked-down `referrerpolicy`. Any other `http(s)` URL becomes a generic lazy iframe. Add `title="…"` for the accessible name.
641
+
642
+ Darkmown's shipped CSP pre-authorizes exactly the two embed origins (`youtube-nocookie.com`, `player.vimeo.com`) and `media-src 'self' https:`, so embeds and remote media work out of the box on the bundled server, Cloudflare `_headers`, and Vercel.
643
+
559
644
  ## Dark mode — `tokens dark`
560
645
 
561
646
  *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**.
@@ -574,11 +659,46 @@ page
574
659
  color $ink
575
660
  ```
576
661
 
577
- The `tokens dark` values override the matching base tokens only when the OS reports a dark preference; the rules below keep referencing the same `$paper` / `$ink`. The base `tokens` block stays the light default, so a skin with no `tokens dark` block is unchanged.
662
+ A single `tokens dark` block powers **both** theming paths: it compiles to `:root[data-theme="dark"] { … }` (the manual toggle below) **and** `@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { … } }` (the OS preference, unless a visitor has explicitly forced light). The base `tokens` block stays the light default, so a skin with no `tokens dark` block is unchanged.
663
+
664
+ ### Manual toggle — `:theme`
665
+
666
+ *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="…">`:
667
+
668
+ ```wd
669
+ :theme
670
+ :button "Auto" -> theme = "auto"
671
+ :button "Light" -> theme = "light"
672
+ :button "Dark" -> theme = "dark"
673
+ ```
674
+
675
+ Because `tokens dark` already emits the `[data-theme="dark"]` rule, **no extra skin block is needed** — the same palette drives the toggle. `"light"` forces the light palette even under OS dark; `"auto"` clears the attribute and follows the OS again; `theme` persists across reloads and tabs like any `:store`. (`:theme name = "light"` renames the store and seeds a different default. The older `tokens [data-theme=dark]` block — a manual-only override — still works for bespoke setups.)
578
676
 
579
677
  ## The escape hatch
580
678
 
581
- Reactive pages expose `window.wd` — `wd.get(key)`, `wd.set(key, value)`, `wd.state`, `wd.render()` — so colocated `.js` can do anything the directives can't. Section-scoped keys are addressed as `sectionId:name`.
679
+ Reactive pages expose `window.wd` so a colocated `.js` file can do anything the directives deliberately don't — keyboard, drag/touch, canvas, charts, maps:
680
+
681
+ | Method | Purpose |
682
+ |---|---|
683
+ | `wd.get(key)` | read a state value |
684
+ | `wd.set(key, value)` | write it and re-render |
685
+ | `wd.subscribe(key, cb)` | run `cb(value)` now and on every settled change; returns an unsubscribe |
686
+ | `wd.state` | the live state object |
687
+ | `wd.render()` | force a render |
688
+
689
+ Section-scoped keys are addressed as `sectionId:name`. The bridge for "behaviors" is `subscribe` — it primes the callback immediately, then fires whenever the value settles, so an imperative widget stays in lockstep with declarative state:
690
+
691
+ ```js
692
+ // index.js, colocated beside the page. Loads after the runtime, so wd is ready.
693
+ const track = document.querySelector("[data-track]");
694
+ wd.subscribe("slide", (i) => { // framework state → imperative view
695
+ track.style.transform = `translateX(${-i * 100}%)`;
696
+ });
697
+ document.querySelector("[data-next]")
698
+ .addEventListener("click", () => wd.set("slide", wd.get("slide") + 1));
699
+ ```
700
+
701
+ That is the whole contract: the framework reconciles state, text, classes, and loops; your behavior owns the gestures; they meet at one shared key. (See the [Swiper demo](https://darkmown.com/carousel/) — a draggable, keyboard-navigable carousel built exactly this way.)
582
702
 
583
703
  Set `window.wd.debug = true` (it defaults to `false`) to log any `:computed` or `@loop … where` expression that fails to evaluate to the console — useful while authoring reactive pages.
584
704
 
@@ -6,7 +6,7 @@ This audit compares the current Darkmown implementation with the original MARROW
6
6
 
7
7
  - **Markdown-first authoring:** `.md` is strict CommonMark rendered by a real parser (markdown-it); `.wd` is the same Markdown plus first-party directives. Renaming the file is the upgrade path — extensions genuinely gate functionality.
8
8
  - **Content tree over components:** routing, includes, and repeated fragments are file/document based instead of React-style components.
9
- - **Tiered output:** static routes emit HTML/CSS and no Darkmown runtime; reactive routes opt into `/__wd/runtime.js` (~5.7 KB gzipped, CI-enforced below the 6 KB target).
9
+ - **Tiered output:** static routes emit HTML/CSS and no Darkmown runtime; reactive routes opt into `/__wd/runtime.js` (~7.4 KB gzipped, CI-enforced below the 8 KB target).
10
10
  - **No virtual DOM:** state changes patch direct DOM bindings, conditional regions, and keyed loop regions.
11
11
  - **Keyed list patching:** reactive loops reconcile by item key (`id`/`key`/value) instead of re-rendering the region.
12
12
  - **Sections with scoped state:** `::: section #id .class` containers scope `:state`; bindings and actions resolve through the nearest scope chain.
@@ -93,6 +93,26 @@ The post-8/10-review sprint: one reactive-loop capability, a deploy-hardening la
93
93
  - **Dark-mode theming — `tokens dark`:** a `.skin` file may add a `tokens dark` block alongside its base `tokens`; the dark tokens compile to `@media (prefers-color-scheme: dark) :root { … }`, overriding the matching base tokens under the visitor's OS dark preference. Rules keep referencing the same `$name` tokens, so dark mode is a palette swap with **zero JavaScript** — consistent with the existing zero-JS `.skin` output. A skin with no `tokens dark` block is unchanged.
94
94
  - **Test backfill + compile-error line numbers:** the sprint backfilled runtime/compiler tests for the new surface, and compile errors now carry the offending **line number** alongside the file path and corrective suggestion. **Honest caveat:** this entry documents the intended sprint scope; the exact assertion count and the precise error-message format are owned by the test/compiler tracks and were not verified from this docs track.
95
95
 
96
+ ## Stage 17 additions (2026-06-27, v1.1.0)
97
+
98
+ Follow-up hardening of the two rough edges flagged at the 1.0 release. No runtime change.
99
+
100
+ - **Multi-line `:state`/`:store`/`:theme` JSON values:** an array/object seed may span lines. `body.js` detects an unbalanced `[`/`{` after `=` on the directive line and gathers contiguous continuation lines (stopping at a blank line, which delimits the literal) until the brackets balance — string- and escape-aware so a `]`/`}` or `\"` inside a JSON string is not miscounted — then collapses them to one line the existing single-line handler parses. 1.0 turned the silent `"["` corruption into a compile error; 1.1 makes the readable multi-line form work. An unterminated literal stays a clear compile error; quote literal bracket text. **Honest caveat:** no blank lines inside the literal (a blank line ends the scan), and the opening bracket must be on the directive line.
101
+ - **Single source of truth for the runtime-size budget (internal):** the gzip ceiling lives only in `.size-snapshot.json` (`runtime.budget`); `scripts/size-check.mjs` reads it and `package.json` + `ci.yml` delegate to that script. A `tests/size.test.js` drift guard fails if any config reintroduces a hardcoded budget or inline gate — closing the class of bug that failed the 1.0 PR (a stale `6144` left in `ci.yml`).
102
+
103
+ ## Stage 16 additions (2026-06-27, v1.0.0)
104
+
105
+ The 1.0 release: a value layer, a time primitive, an explicit theme toggle, reactive table sort, media, and a first-class escape hatch — the gaps an audit flagged as "drop to raw HTML/JS." All compile-time or shared-runtime; the runtime moved from ~5.8 KB to ~7.4 KB gzipped and the CI budget was raised 6 KB → **8 KB** to match (still un-minified). No source-level breaking changes.
106
+
107
+ - **Format pipes — `{ value | name:arg }`:** a fixed formatter whitelist (`money`, `number`, `percent`, `round`, `date`/`time`/`datetime`, `upper`/`lower`/`capitalize`, `truncate`/`trim`, `pluralize`, `default`) applied via chainable, argument-taking pipes. Static-value pipes fold at build time (zero-JS); `:state`/`:store` bindings re-apply on every render via a compact runtime mirror that is **parity-tested** against the compiler's formatter table. Validated to a whitelist, never `eval`'d; an unknown pipe is a compile error. **Honest caveat:** there is deliberately **no** relative/"time ago" formatter — it would make builds non-reproducible — and pipes shape values for display only (no arbitrary expressions).
108
+ - **Aggregates — `sum`/`avg`/`min`/`max`/`count`:** usable in `:computed` (`sum(list, field)`) and as list pipes (`{ cart | count }`, `{ cart | sum:"price" | money }`). In `:computed` they are pre-parsed to a whitelisted `A("name", S("key"), "field")` call before the existing function-call rejection, so the expression grammar stays closed. The field argument is a bare row key.
109
+ - **Timers — `:every <duration> -> <actions>`:** an interval driver (`ms`/`s`/`m`) running the `:button`/`:effect` action vocabulary; supports `name refetch`. Intervals pause on `visibilitychange` (hidden tab) and resume on return. **Honest caveat:** it is wall-clock interval only — no cron syntax, no animation-frame scheduling, no per-element timers.
110
+ - **Manual theme toggle — `:theme`:** registers a durable `theme` store and a `data-wd-theme` reflect marker; the runtime writes the value onto `<html data-theme>`. A single `tokens dark` block now emits **both** `:root[data-theme="dark"]` and `@media (prefers-color-scheme: dark) :root:not([data-theme="light"])`, so one palette powers OS-auto and the manual toggle; `"auto"` clears the attribute. The older bracketed `tokens [data-theme=dark]` manual-only override still compiles.
111
+ - **Reactive table sort:** `@loop … sort by { sortKey } { sortDir }` accepts a `{ state }` reference for the sort field and/or direction (emitted as a `key:` prefix the runtime resolves per render), so a clickable header re-sorts live. Build-time `sort by item.field [asc|desc]` is unchanged.
112
+ - **Media — `:video` / `:audio` / `:embed`:** compile-time only (no `data-wd-*`; a media-only page stays `runtime: false`). `:video`/`:audio` emit a hardened HTML5 player with a per-element flag/attr whitelist and defaults (`preload="metadata"`, `controls`, `autoplay`⇒`muted`); URLs use the `:fetch` scheme guard. `:embed` rewrites YouTube/Vimeo to the no-cookie/player origin in a lazy responsive `16/9` iframe with locked `referrerpolicy`. The shipped CSP gains `media-src 'self' https:` and a `frame-src` scoped to exactly `youtube-nocookie.com` + `player.vimeo.com` (no wildcard), mirrored across `serve`, `dist/_headers`, and `vercel.json`.
113
+ - **Escape hatch — `window.wd.subscribe(key, cb)`:** primes the callback immediately, then fires on every settled change, returning an unsubscribe; joins `get`/`set`/`state`/`render`. The bridge for colocated `.js` "behaviors" (keyboard, drag/touch, canvas) — the Swiper demo is a draggable, keyboard-navigable carousel where the framework owns `slide`/dots/readout declaratively and the behavior owns the gestures. **Honest caveat:** this is an imperative bridge, not a reactive-templating extension; a behavior reaching past `wd.*` into private runtime internals is unsupported.
114
+ - **Hardening:** a multi-line `:state`/`:store`/`:theme` value (a literal opened on the directive line, continued below) is now a located compile error instead of silently storing the partial `"["` string — these directives take a single-line value. Quote bracketed literal text to bypass the guard.
115
+
96
116
  ## Stage 13 additions (2026-06-23, v0.17.0)
97
117
 
98
118
  - **`:checkbox` and `:radio` field groups:** `:checkbox name` / `:radio name` (with `- Label` option lines) render a labelled `role="group"` / `role="radiogroup"` of `<input>`s that share one `name`, each wrapped in its own `<label>` and deriving an aria-label like the other fields. A `:checkbox` group is marked `data-wd-multi` so the runtime's submit handler collects **every** checked value into an array (`FormData.getAll`) instead of collapsing to the last — the one runtime change (compile-time only otherwise; shipped runtime stays ~5.8 KB). A `:radio` group captures a single value like `:input`. Closes the multi-select / single-choice gap that previously forced raw HTML and lost `:form into` capture.
@@ -116,7 +136,7 @@ Four progressive-disclosure capabilities plus an assets and a security change. T
116
136
 
117
137
  ## Demo-only directives (not part of the public surface)
118
138
 
119
- The compiler recognizes three directives — `:note`, `:try`, and `:sprint` — that exist **only to power the demo site** (darkmown.com). They are **not** part of the supported public directive set, are not documented for authors, and may change or be removed without notice. Do not build pages that depend on them. The supported public directives are the ones catalogued above: `:state`, `:store`, `:computed`, `:effect`, `:button`, `:bind`, `:if`/`:else if`/`:else`/`:endif`, `:fetch` (with `headers`/`refresh`), `:form`/`:input`/`:submit`, `@loop`/`@empty`/`@endloop` (with `where`, `sort by`, `reverse`, `offset`, `limit`), `@include`, and `::: section` (with reactive `.class when`).
139
+ The compiler recognizes three directives — `:note`, `:try`, and `:sprint` — that exist **only to power the demo site** (darkmown.com). They are **not** part of the supported public directive set, are not documented for authors, and may change or be removed without notice. Do not build pages that depend on them. The supported public directives are the ones catalogued above: `:state`, `:store`, `:computed`, `:effect`, `:every`, `:theme`, `:button`, `:bind`, `:if`/`:else if`/`:else`/`:endif`, `:fetch` (with `headers`/`refresh`), `:form`/`:input`/`:submit`, `:video`/`:audio`/`:embed`, `@loop`/`@empty`/`@endloop` (with `where`, `sort by` — including reactive `{ state }` keys —, `reverse`, `offset`, `limit`), `@include`, and `::: section` (with reactive `.class when`). Interpolation supports **format pipes** (`{ value | name:arg }`) and `:computed`/list-pipe **aggregates** (`sum`/`avg`/`min`/`max`/`count`). The runtime escape hatch is `window.wd` (`get`/`set`/`subscribe`/`state`/`render`).
120
140
 
121
141
  ## Still missing from the full vision
122
142
 
@@ -128,4 +148,4 @@ The compiler recognizes three directives — `:note`, `:try`, and `:sprint` —
128
148
 
129
149
  Darkmown is faithful to the core thesis: Markdown remains the authoring center, static pages ship zero framework JavaScript, and reactive behavior compiles into tiny direct-DOM islands only where declared. The extension is the feature gate, the loop is one concept with progressive disclosure, and state scopes to document structure.
130
150
 
131
- The public npm package is published as `@zvndev/darkmown`, the project is MIT licensed, and the remaining gaps are explicitly scoped as post-launch product/distribution work rather than missing v0.9 claims.
151
+ As of **v1.0.0** the public npm package is published as `@zvndev/darkmown`, the project is MIT licensed, and the directive surface is stable. The 1.0 release closes the audit-flagged display/time/theme/sort/media/escape-hatch gaps; the remaining items above are explicitly scoped as post-launch product/distribution work rather than missing core claims.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvndev/darkmown",
3
- "version": "0.20.0",
3
+ "version": "1.1.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -18,7 +18,7 @@
18
18
  "types": "./types/index.d.ts",
19
19
  "scripts": {
20
20
  "build": "node src/cli.js build",
21
- "runtime:size": "node -e \"const fs=require('fs'),zlib=require('zlib'); const size=zlib.gzipSync(fs.readFileSync('dist/__wd/runtime.js')).length; console.log('shipped runtime gzipped: '+size+' bytes (budget: 6144)'); if (size >= 6144) process.exit(1)\"",
21
+ "runtime:size": "node scripts/size-check.mjs",
22
22
  "dev": "node src/cli.js dev",
23
23
  "test": "node --test tests/*.test.js",
24
24
  "test:cov": "node scripts/test-coverage-gate.mjs 100",
@@ -44,7 +44,7 @@
44
44
  "src",
45
45
  "types",
46
46
  "site",
47
- "!site/_/og.png",
47
+ "!site/_/og*.png",
48
48
  "docs/cli.md",
49
49
  "docs/spec-alignment.md",
50
50
  "AGENTS.md",