@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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +13 -0
- package/README.md +115 -5
- package/docs/spec-alignment.md +16 -3
- package/package.json +3 -3
- package/site/_/base.skin +372 -0
- package/site/_/base.wd +4 -0
- package/site/_/footer.wd +14 -0
- package/site/_/metrics.json +13 -0
- package/site/_/nav.skin +35 -12
- package/site/_/nav.wd +8 -10
- package/site/_/products.json +10 -0
- package/site/pages/app.wd +1 -1
- package/site/pages/carousel/index.js +82 -0
- package/site/pages/carousel/index.skin +185 -0
- package/site/pages/carousel/index.wd +109 -0
- package/site/pages/carousel/slide-1.jpg +0 -0
- package/site/pages/carousel/slide-2.jpg +0 -0
- package/site/pages/carousel/slide-3.jpg +0 -0
- package/site/pages/carousel/slide-4.jpg +0 -0
- package/site/pages/carousel/slide-5.jpg +0 -0
- package/site/pages/compass/index.skin +247 -0
- package/site/pages/compass/index.wd +194 -0
- package/site/pages/docs/index.skin +48 -28
- package/site/pages/docs/index.wd +71 -5
- package/site/pages/folio/-style.skin +375 -0
- package/site/pages/folio/-style.wd +6 -0
- package/site/pages/folio/cart.skin +309 -0
- package/site/pages/folio/cart.wd +201 -0
- package/site/pages/folio/index.skin +24 -0
- package/site/pages/folio/index.wd +167 -0
- package/site/pages/forge/index.skin +676 -0
- package/site/pages/forge/index.wd +330 -0
- package/site/pages/index.skin +135 -144
- package/site/pages/index.wd +17 -5
- package/site/pages/ledger/index.skin +257 -0
- package/site/pages/ledger/index.wd +174 -0
- package/site/pages/media/clip-poster.jpg +0 -0
- package/site/pages/media/clip.mp4 +0 -0
- package/site/pages/media/index.skin +82 -0
- package/site/pages/media/index.wd +84 -0
- package/site/pages/media/tone.mp3 +0 -0
- package/site/pages/pulse/index.skin +434 -0
- package/site/pages/pulse/index.wd +178 -0
- package/site/pages/showcase/index.skin +130 -0
- package/site/pages/showcase/index.wd +168 -0
- package/src/compiler/body.js +26 -1
- package/src/compiler/context.js +1 -1
- package/src/compiler/directives.js +199 -3
- package/src/compiler/format.js +244 -0
- package/src/compiler/interpolation.js +35 -1
- package/src/compiler/loops.js +95 -24
- package/src/compiler/markdown.js +16 -7
- package/src/compiler/predicates.js +35 -2
- package/src/headers.js +5 -0
- package/src/runtime.js +168 -14
- 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 ~
|
|
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 ~
|
|
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 ~
|
|
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 `` — 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
|
|
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
|
-
|
|
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`
|
|
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
|
|
package/docs/spec-alignment.md
CHANGED
|
@@ -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` (~
|
|
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
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
|
47
|
+
"!site/_/og*.png",
|
|
48
48
|
"docs/cli.md",
|
|
49
49
|
"docs/spec-alignment.md",
|
|
50
50
|
"AGENTS.md",
|
package/site/_/base.skin
ADDED
|
@@ -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
|