@zvndev/darkmown 0.20.0 → 1.0.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 +13 -0
  3. package/README.md +115 -5
  4. package/docs/spec-alignment.md +16 -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 +71 -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 +174 -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 +26 -1
  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 +35 -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,19 @@
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.0.0 — 2026-06-27
6
+
7
+ 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.
8
+
9
+ - **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.
10
+ - **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.
11
+ - **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.
12
+ - **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.
13
+ - **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.
14
+ - **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.
15
+ - **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.
16
+ - **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`.
17
+
5
18
  ## 0.20.0 — 2026-06-26
6
19
 
7
20
  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):
@@ -542,7 +602,7 @@ Fetched data and a form live happily on the same page:
542
602
  - `: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
603
  - 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
604
  - `: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).
605
+ - `: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
606
  - `: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
607
 
548
608
  ## Inline attributes
@@ -556,6 +616,21 @@ A trailing `{.class .class #id}` attaches classes / an id to the inline element
556
616
 
557
617
  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
618
 
619
+ ## Media — `:video`, `:audio`, `:embed`
620
+
621
+ *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.
622
+
623
+ ```wd
624
+ :video /clip.mp4 poster=/clip.jpg controls
625
+ :audio /track.mp3 controls
626
+ :embed https://youtu.be/aqz-KE-bpKQ title="Big Buck Bunny"
627
+ ```
628
+
629
+ - **`: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.
630
+ - **`: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.
631
+
632
+ 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.
633
+
559
634
  ## Dark mode — `tokens dark`
560
635
 
561
636
  *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 +649,46 @@ page
574
649
  color $ink
575
650
  ```
576
651
 
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.
652
+ 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.
653
+
654
+ ### Manual toggle — `:theme`
655
+
656
+ *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="…">`:
657
+
658
+ ```wd
659
+ :theme
660
+ :button "Auto" -> theme = "auto"
661
+ :button "Light" -> theme = "light"
662
+ :button "Dark" -> theme = "dark"
663
+ ```
664
+
665
+ 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
666
 
579
667
  ## The escape hatch
580
668
 
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`.
669
+ Reactive pages expose `window.wd` so a colocated `.js` file can do anything the directives deliberately don't — keyboard, drag/touch, canvas, charts, maps:
670
+
671
+ | Method | Purpose |
672
+ |---|---|
673
+ | `wd.get(key)` | read a state value |
674
+ | `wd.set(key, value)` | write it and re-render |
675
+ | `wd.subscribe(key, cb)` | run `cb(value)` now and on every settled change; returns an unsubscribe |
676
+ | `wd.state` | the live state object |
677
+ | `wd.render()` | force a render |
678
+
679
+ 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:
680
+
681
+ ```js
682
+ // index.js, colocated beside the page. Loads after the runtime, so wd is ready.
683
+ const track = document.querySelector("[data-track]");
684
+ wd.subscribe("slide", (i) => { // framework state → imperative view
685
+ track.style.transform = `translateX(${-i * 100}%)`;
686
+ });
687
+ document.querySelector("[data-next]")
688
+ .addEventListener("click", () => wd.set("slide", wd.get("slide") + 1));
689
+ ```
690
+
691
+ 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
692
 
583
693
  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
694
 
@@ -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,19 @@ 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 16 additions (2026-06-27, v1.0.0)
97
+
98
+ 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.
99
+
100
+ - **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).
101
+ - **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.
102
+ - **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.
103
+ - **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.
104
+ - **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.
105
+ - **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`.
106
+ - **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.
107
+ - **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.
108
+
96
109
  ## Stage 13 additions (2026-06-23, v0.17.0)
97
110
 
98
111
  - **`: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 +129,7 @@ Four progressive-disclosure capabilities plus an assets and a security change. T
116
129
 
117
130
  ## Demo-only directives (not part of the public surface)
118
131
 
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`).
132
+ 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
133
 
121
134
  ## Still missing from the full vision
122
135
 
@@ -128,4 +141,4 @@ The compiler recognizes three directives — `:note`, `:try`, and `:sprint` —
128
141
 
129
142
  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
143
 
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.
144
+ 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.0.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 -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: 8192)'); if (size >= 8192) process.exit(1)\"",
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",
@@ -0,0 +1,372 @@
1
+ /* ===================================================================
2
+ Darkmown design system — the refreshed brand foundation.
3
+ Warm, literary, premium. Serif for prose, system sans for chrome,
4
+ a refined mono for code. Ships zero JS; dark mode is OS-driven via
5
+ `tokens dark` (a pure CSS media query — no flash, no toggle script).
6
+ Every refreshed page and demo app includes this via /base.wd.
7
+ =================================================================== */
8
+
9
+ tokens
10
+ ink #1c1814
11
+ ink-soft #4b4239
12
+ muted #756a5d
13
+ faint #9b9082
14
+ paper #faf8f3
15
+ paper-2 #f4f0e7
16
+ sunk #efe9dc
17
+ panel #ffffff
18
+ line #e7e1d4
19
+ line-soft #f0ebe0
20
+ accent #0f6b5e
21
+ accent-deep #0a4f45
22
+ accent-soft #e3f0ec
23
+ warm #b8462e
24
+ warm-soft #f8eae3
25
+ gold #b6822b
26
+ code-bg #1b2420
27
+ code-ink #e9efe7
28
+ code-line #2c3b34
29
+ ring #0f6b5e
30
+ radius 12px
31
+ radius-sm 8px
32
+ radius-lg 20px
33
+
34
+ tokens dark
35
+ ink #ece6db
36
+ ink-soft #c3bbab
37
+ muted #9a9081
38
+ faint #6f6657
39
+ paper #14110d
40
+ paper-2 #1a1612
41
+ sunk #221d17
42
+ panel #1e1913
43
+ line #2f281f
44
+ line-soft #261f18
45
+ accent #5ccab4
46
+ accent-deep #8fe0cf
47
+ accent-soft #16302a
48
+ warm #e88a6f
49
+ warm-soft #2c1f18
50
+ gold #d6a24e
51
+ code-bg #100d0a
52
+ code-ink #e4ebe2
53
+ code-line #2a231b
54
+ ring #5ccab4
55
+
56
+ /* ── Reset & base ───────────────────────────────────────────────── */
57
+
58
+ *
59
+ box-sizing border-box
60
+
61
+ page
62
+ margin 0
63
+ font-family ui-serif, "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, Cambria, serif
64
+ font-size 18px
65
+ line-height 1.65
66
+ color $ink
67
+ bg $paper
68
+ -webkit-font-smoothing antialiased
69
+ text-rendering optimizeLegibility
70
+
71
+ ::selection
72
+ bg $accent-soft
73
+ color $accent-deep
74
+
75
+ /* ── Typography ─────────────────────────────────────────────────── */
76
+
77
+ h1, h2, h3, h4
78
+ line-height 1.1
79
+ letter-spacing -.012em
80
+ color $ink
81
+ font-weight 600
82
+
83
+ h1
84
+ font-size clamp(2.5rem, 6vw, 4.5rem)
85
+ letter-spacing -.025em
86
+ margin .2em 0 .35em
87
+
88
+ h2
89
+ font-size clamp(1.6rem, 3vw, 2.25rem)
90
+ margin 2.4em 0 .6em
91
+
92
+ h3
93
+ font-size 1.3rem
94
+ margin 1.8em 0 .4em
95
+
96
+ p
97
+ margin 1em 0
98
+ max-width 68ch
99
+
100
+ a
101
+ color $accent
102
+ text-underline-offset 2px
103
+ text-decoration-thickness 1px
104
+
105
+ a:hover
106
+ color $accent-deep
107
+
108
+ strong
109
+ font-weight 650
110
+ color $ink
111
+
112
+ hr
113
+ border 0
114
+ border-top 1px solid $line
115
+ margin 3rem 0
116
+
117
+ small, .small
118
+ font-size .82rem
119
+ color $muted
120
+
121
+ /* Chrome (nav, buttons, labels, badges) leans on a clean sans for a
122
+ modern editorial contrast against the serif prose. */
123
+ .topnav, .btn, .eyebrow, .pill, .tag, .badge, .kbd, .sitefooter, .source-reveal summary
124
+ font-family system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif
125
+
126
+ /* ── Layout ─────────────────────────────────────────────────────── */
127
+
128
+ main
129
+ max-width 940px
130
+ margin 0 auto
131
+ padding 0 1.5rem 5rem
132
+
133
+ .wide
134
+ max-width 1140px
135
+
136
+ .eyebrow
137
+ display inline-block
138
+ text-transform uppercase
139
+ letter-spacing .14em
140
+ font-size .74rem
141
+ font-weight 600
142
+ color $accent
143
+ margin 0 0 .35rem
144
+
145
+ /* ── Buttons ────────────────────────────────────────────────────── */
146
+
147
+ .btn
148
+ display inline-flex
149
+ align-items center
150
+ gap .5rem
151
+ margin .4rem .5rem .4rem 0
152
+ padding .7rem 1.15rem
153
+ font-size .95rem
154
+ font-weight 550
155
+ line-height 1
156
+ color $ink
157
+ bg $panel
158
+ border 1px solid $line
159
+ radius 999px
160
+ text-decoration none
161
+ cursor pointer
162
+ transition transform .12s ease, box-shadow .12s ease, background .12s ease
163
+
164
+ .btn:hover
165
+ transform translateY(-1px)
166
+ box-shadow 0 6px 18px -8px rgba(28,24,20,.35)
167
+
168
+ .btn.primary
169
+ bg $accent
170
+ color #fff
171
+ border-color $accent
172
+
173
+ .btn.primary:hover
174
+ bg $accent-deep
175
+ color #fff
176
+
177
+ .btn.warm
178
+ bg $warm
179
+ color #fff
180
+ border-color $warm
181
+
182
+ .btn.ghost
183
+ bg transparent
184
+
185
+ /* ── Cards & surfaces ───────────────────────────────────────────── */
186
+
187
+ .card
188
+ bg $panel
189
+ border 1px solid $line
190
+ radius $radius
191
+ padding 1.4rem
192
+ box-shadow 0 1px 2px rgba(28,24,20,.04)
193
+
194
+ .grid
195
+ display grid
196
+ gap 1.1rem
197
+
198
+ .grid.cols-2
199
+ grid-template-columns repeat(2, 1fr)
200
+
201
+ .grid.cols-3
202
+ grid-template-columns repeat(3, 1fr)
203
+
204
+ .grid.auto
205
+ grid-template-columns repeat(auto-fit, minmax(240px, 1fr))
206
+
207
+ @media (max-width: 760px)
208
+ .grid.cols-2, .grid.cols-3
209
+ grid-template-columns 1fr
210
+
211
+ /* ── Pills, tags, badges ────────────────────────────────────────── */
212
+
213
+ .pill, .tag
214
+ display inline-flex
215
+ align-items center
216
+ gap .35rem
217
+ font-size .72rem
218
+ font-weight 600
219
+ text-transform uppercase
220
+ letter-spacing .06em
221
+ padding .28rem .6rem
222
+ radius 999px
223
+
224
+ .pill
225
+ bg $accent-soft
226
+ color $accent-deep
227
+
228
+ .tag
229
+ bg $sunk
230
+ color $muted
231
+
232
+ .kbd
233
+ font-family ui-monospace, "SF Mono", Menlo, monospace
234
+ font-size .8rem
235
+ padding .15rem .4rem
236
+ radius 6px
237
+ bg $panel
238
+ border 1px solid $line
239
+ color $ink-soft
240
+
241
+ /* ── Callout (the demo :note directive renders <aside class="note">) ─ */
242
+
243
+ .note, .callout
244
+ margin 1.6rem 0
245
+ padding 1rem 1.25rem
246
+ border 1px solid $line
247
+ border-left 3px solid $accent
248
+ bg $panel
249
+ radius $radius-sm
250
+ color $ink-soft
251
+ font-size .96rem
252
+
253
+ /* ── Code ───────────────────────────────────────────────────────── */
254
+
255
+ code
256
+ font-family ui-monospace, "SF Mono", Menlo, Consolas, monospace
257
+ font-size .88em
258
+ bg $sunk
259
+ color $accent-deep
260
+ padding .12rem .36rem
261
+ radius 6px
262
+
263
+ h1 code, h2 code, h3 code
264
+ font-size .82em
265
+
266
+ pre
267
+ bg $code-bg
268
+ color $code-ink
269
+ padding 1.2rem 1.35rem
270
+ margin 1.5rem 0
271
+ radius $radius
272
+ overflow-x auto
273
+ line-height 1.62
274
+ font-size .87rem
275
+ box-shadow inset 0 0 0 1px $code-line
276
+
277
+ pre code
278
+ bg transparent
279
+ color inherit
280
+ padding 0
281
+ font-size 1em
282
+
283
+ /* ── Tables ─────────────────────────────────────────────────────── */
284
+
285
+ table
286
+ border-collapse collapse
287
+ width 100%
288
+ margin 1.5rem 0
289
+ font-size .95rem
290
+
291
+ th
292
+ text-align left
293
+ padding .6rem .9rem
294
+ border-bottom 2px solid $line
295
+ color $muted
296
+ font-weight 600
297
+ font-size .82rem
298
+ text-transform uppercase
299
+ letter-spacing .04em
300
+
301
+ td
302
+ padding .6rem .9rem
303
+ border-bottom 1px solid $line-soft
304
+
305
+ blockquote
306
+ margin 1.6rem 0
307
+ padding .6rem 1.3rem
308
+ border-left 3px solid $accent
309
+ color $ink-soft
310
+ font-style italic
311
+
312
+ /* ── "View source" reveal — the two-beat trick: a slick app, then the
313
+ Markdown it's made of. Wrap a fenced ```wd block in <details>. ── */
314
+
315
+ .source-reveal
316
+ margin 1.5rem 0
317
+ border 1px solid $line
318
+ radius $radius
319
+ bg $panel
320
+ overflow hidden
321
+
322
+ .source-reveal summary
323
+ cursor pointer
324
+ padding .85rem 1.1rem
325
+ font-size .9rem
326
+ font-weight 600
327
+ color $accent-deep
328
+ list-style none
329
+ display flex
330
+ align-items center
331
+ gap .5rem
332
+
333
+ .source-reveal summary::-webkit-details-marker
334
+ display none
335
+
336
+ .source-reveal summary::before
337
+ content "›"
338
+ font-size 1.1rem
339
+ transition transform .15s ease
340
+
341
+ .source-reveal[open] summary::before
342
+ transform rotate(90deg)
343
+
344
+ .source-reveal pre
345
+ margin 0
346
+ radius 0
347
+
348
+ /* ── Footer ─────────────────────────────────────────────────────── */
349
+
350
+ .sitefooter
351
+ border-top 1px solid $line
352
+ margin-top 4rem
353
+ padding 2.5rem 1.5rem
354
+ color $muted
355
+ font-size .9rem
356
+
357
+ .sitefooter .foot-inner
358
+ max-width 940px
359
+ margin 0 auto
360
+ display flex
361
+ flex-wrap wrap
362
+ gap 1rem 2rem
363
+ align-items center
364
+ justify-content space-between
365
+
366
+ .sitefooter a
367
+ color $muted
368
+ text-decoration none
369
+ margin-right 1.1rem
370
+
371
+ .sitefooter a:hover
372
+ color $accent