@zvndev/darkmown 1.3.0 → 1.5.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 (43) hide show
  1. package/AGENTS.md +2 -0
  2. package/CHANGELOG.md +19 -0
  3. package/README.md +114 -1
  4. package/docs/spec-alignment.md +30 -0
  5. package/package.json +1 -1
  6. package/site/_/base.skin +231 -18
  7. package/site/_/nav.wd +1 -1
  8. package/site/_/scoped-promo.skin +43 -0
  9. package/site/_/scoped-promo.wd +10 -0
  10. package/site/_/scoped-stat.skin +32 -0
  11. package/site/_/scoped-stat.wd +9 -0
  12. package/site/pages/app.skin +27 -5
  13. package/site/pages/blog/_schema.wd +15 -0
  14. package/site/pages/blog/collections-and-pagination.md +38 -0
  15. package/site/pages/blog/drafts-that-never-leak.md +2 -0
  16. package/site/pages/blog/index.skin +144 -0
  17. package/site/pages/blog/index.wd +73 -0
  18. package/site/pages/blog/shipping-feeds.md +1 -0
  19. package/site/pages/blog/typed-frontmatter.md +45 -0
  20. package/site/pages/blog/zero-js-by-default.md +31 -0
  21. package/site/pages/data.skin +50 -9
  22. package/site/pages/docs/index.wd +70 -0
  23. package/site/pages/loops.skin +43 -6
  24. package/site/pages/reactive.skin +38 -6
  25. package/site/pages/scoped/index.skin +23 -0
  26. package/site/pages/scoped/index.wd +47 -0
  27. package/site/pages/store.skin +43 -6
  28. package/src/builder.js +213 -32
  29. package/src/compiler/collections.js +316 -0
  30. package/src/compiler/context.js +34 -2
  31. package/src/compiler/directives.js +11 -5
  32. package/src/compiler/includes.js +48 -1
  33. package/src/compiler/index.js +1 -1
  34. package/src/compiler/loops.js +130 -12
  35. package/src/compiler/page.js +93 -9
  36. package/src/compiler.js +2 -1
  37. package/src/skin.js +147 -3
  38. package/src/templates/blog/site/pages/index.skin +84 -0
  39. package/src/templates/dashboard/site/pages/index.skin +84 -0
  40. package/src/templates/landing/site/pages/index.skin +113 -6
  41. package/src/templates/starter/site/pages/index.skin +81 -3
  42. package/src/templates/store/site/pages/index.skin +93 -11
  43. package/site/pages/blog/index.md +0 -13
package/AGENTS.md CHANGED
@@ -253,6 +253,8 @@ input:focus-visible
253
253
  ```
254
254
  Always include: a tokens block, a real type scale, generous spacing, a `max-width` centered `main`, styled `button`/`input` with `:hover`/`:focus-visible` states, and a responsive `@media` rule (`.skin` supports `@media` — its indented rules are wrapped automatically). The `font` property is the CSS shorthand when it leads with a size (`font 16px/1.6 system-ui`) and `font-family` otherwise. Prefer `.skin`; a plain `<style>` block in a `.wd` also works.
255
255
 
256
+ **Scoped styles — opt in with `scoped`.** A `.skin` is **global** by default (style site-wide; the right default for your design system). To stop a reusable component's classes from clashing with the rest of the page, make the **first line** of its `.skin` the word `scoped` — its selectors then only match the component it's colocated with (a page skin scopes that page; an include skin scopes just that include's subtree). You still write `class="card"`; the build stamps a `data-wd-scope` attribute and rewrites the selectors — zero runtime, a static page stays static. **Tokens stay global** (a `tokens`/`tokens dark` block still emits `:root` vars, so `$accent` and dark mode keep working). Escape a single rule with `:global(.x)` (whole-selector only). `page`/`*`/`html`/`body`/`::selection` are a compile error in a scoped skin — those belong in a global skin.
257
+
256
258
  **Layout gotcha — CTAs and link rows.** Markdown wraps adjacent links/buttons on consecutive lines into a single `<p>`, so putting `display:flex` on their container won't space them. To lay out a row of buttons or nav links, wrap them in explicit raw HTML and style that:
257
259
  ```wd
258
260
  <div class="actions">
package/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
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.5.0 — 2026-06-28
6
+
7
+ Content collections — query a folder of markdown as data, with optional typed validation and pagination. Plus a default-styling pass so buttons and form controls look good out of the box, and two fixes from a product review. The runtime is unchanged (still 7459 B gzipped); collections and styling are entirely build-time.
8
+
9
+ - **Content collections.** Any `site/pages/<name>/` subdirectory is a queryable collection, referenced by its **bare name** in the existing loop — no new concept, no config file. `@loop blog into post sort by date desc paginate 10` reads the entries under `site/pages/blog/`, each as a row of its frontmatter plus derived `url`, `slug`, and `excerpt`. The whole `where` / `sort` / format-pipe / `:if` machinery works unchanged, and a pure listing stays zero-JS (`runtime: false`). Drafts are excluded by default (and can never leak into a listing); `build --drafts` includes them.
10
+ - **Typed schemas (optional).** Drop a `_schema.wd` at a collection's root to validate every entry's frontmatter at build time — one `field: type` per line over a closed vocabulary (`string`, `number`, `boolean`, `date`, `string[]`, trailing `?` = optional). Mismatches fail the build with a `file:line` error naming the entry and field. Absent `_schema.wd` = no validation (opt-in, like everything else).
11
+ - **Pagination.** `paginate N` on a collection loop multiplies one listing source into static pages — page 1 stays at `/blog/`, the rest at `/blog/page/2/`, `/blog/page/3/`. A `{ page.current }` / `{ page.total }` / `{ page.prev }` / `{ page.next }` pager is available in the listing scope; every generated page is static HTML in `routes.json` + `sitemap.xml`.
12
+ - **Polished default styling.** `:button`/`:submit` compile to a bare classless `<button>`, so their look came entirely from the base skin — which previously left them as flat/unstyled. The base skin and **all five scaffold templates** now ship a token-driven default for buttons (filled-accent with hover/active/focus-visible/disabled states, plus `.secondary`/`.ghost` variants) and form controls (inputs, selects with a custom chevron, textareas, checkbox/radio/range) — consistent in light **and** dark, with `prefers-reduced-motion` honored. A freshly scaffolded site looks good immediately.
13
+ - **Fixes (from a product review).** (1) Scoped-style stamping no longer corrupts the contents of raw-text elements (`<script>`/`<style>`/`<textarea>`/`<title>`) — it stamps their opening tags but leaves their bodies byte-intact. (2) Nested static routes (e.g. `/blog/<slug>/`, `/blog/page/2/`) now correctly receive the strict (no `unsafe-eval`) CSP on Vercel; the header drift-guard now validates full route paths so the gap can't recur.
14
+
15
+ ## 1.4.0 — 2026-06-28
16
+
17
+ Scoped styles — the long-standing "`.skin` selectors are global" gap, closed. Opt-in, compile-time, and **zero runtime** (the shared runtime is still 7459 B gzipped; nothing new ships to the client but one HTML attribute and one attribute selector).
18
+
19
+ - **`scoped` skins.** Put `scoped` as the first line of a `.skin` file and its selectors apply only to the page or include it ships with — so two components can reuse the same class name (`.card`) without colliding. The compiler appends a per-file `[data-wd-scope="wd-xxxx"]` attribute to each selector's subject and stamps the matching subtree's HTML; the scope id is a stable hash of the skin's path. Selectors compose as you'd expect: descendant selectors scope only the subject, comma lists scope each branch, `@media`/`@supports` wrappers pass through, and `&`-nesting/pseudo-classes resolve before the attribute is attached.
20
+ - **Tokens stay global.** A `tokens` / `tokens dark` block in a `scoped` skin still emits global `:root` custom properties, so `var(--…)`, dark mode, `:theme`, and the `$code-*` highlight tokens keep working across the whole site — only your component's selector rules are scoped.
21
+ - **`:global(...)` escape hatch** for a whole selector that must stay global (e.g. a class you portal elsewhere). Page-level selectors (`page`, `*`, `html`, `body`, `::selection`) are rejected in a scoped skin with a corrective hint — those belong in a global design-system skin.
22
+ - **Fully backward compatible.** A `.skin` without the `scoped` marker is unchanged, byte-for-byte (covered by a golden test). Existing global skins — `base.skin`, `nav.skin`, every scaffolded project — keep styling the whole site exactly as before. Unused scoped selectors produce a build *warning* (never silently removed, since a colocated `.js` behavior may add the class at runtime).
23
+
5
24
  ## 1.3.0 — 2026-06-28
6
25
 
7
26
  The "table-stakes batteries" release — the build-time, zero-JS features a markdown site is expected to ship. The runtime is unchanged (still 7459 B gzipped, well under the 8 KB budget); everything here is compile-time HTML/CSS or build output.
package/README.md CHANGED
@@ -113,7 +113,7 @@ tags: [sales, revenue, "q1, q2"]
113
113
 
114
114
  Three keys also drive the document `<head>`: `title` sets `<title>`, `description` adds the meta description plus Open Graph / Twitter tags, and `image` (an absolute URL) sets the social-share preview (`og:image` / `twitter:image` and a `summary_large_image` card).
115
115
 
116
- A few reserved keys drive **drafts and feeds** (see [SEO & feeds](#seo--feeds-sitemap-rss-robots) below): `draft: true` excludes a page from production builds; `site_url` on the home page turns on `sitemap.xml` + `rss.xml`; `date:` marks a page as a blog post (it lands in `rss.xml` and sets the page's `<lastmod>`); `excerpt:` is the RSS summary.
116
+ A few reserved keys drive **drafts and feeds** (see [SEO & feeds](#seo--feeds-sitemap-rss-robots) below): `draft: true` excludes a page from production builds; `site_url` on the home page turns on `sitemap.xml` + `rss.xml`; `date:` marks a page as a blog post (it lands in `rss.xml` and sets the page's `<lastmod>`); `excerpt:` is the RSS summary. The same frontmatter is queryable when the page is an entry in a [content collection](#content-collections) — `{ post.date }`, `{ post.excerpt }`, `{ post.tags }`, and any custom key resolve in an `@loop` over the folder.
117
117
 
118
118
  `transitions: true` opts a page into **instant, flash-free navigation** — zero JavaScript, all declarative. It emits a directional fade+slide **view transition** for the page swap (old lifts up and out, new rises up and in — replacing the default cross-fade, which left both pages ghosted at ~50 % opacity mid-navigation), plus a **`<script type="speculationrules">` prerender** hint that renders the next same-origin page on hover/pointerdown so the click activates an already-painted page (no white render-gap flash). It honors `prefers-reduced-motion`. Only same-origin pages that both opt in transition; browsers without support — or with page-preloading disabled — navigate normally. Off by default; opt out with `transitions: false`. Mark a link `{.no-prefetch}` to exclude it from prerendering. (Chrome disables prerendering while DevTools is open, so test the built site with DevTools closed.)
119
119
 
@@ -351,6 +351,75 @@ That is a full add-to-cart / remove-line flow — and a to-do list with delete
351
351
  >
352
352
  > **Per-row actions inside an inner loop:** `cart += member` (append-row) works from an inner loop — it carries the inner item into a top-level `:state`/`:store` list. But a per-row **`remove`** needs a top-level list as its source, and an inner loop's source is a path off the outer row (`team.members`), so deleting an inner row in place is **not** supported — the compiler rejects it with a corrective message. Carry the row into a top-level list and remove it there.
353
353
 
354
+ ## Content collections
355
+
356
+ **Any folder under `site/pages/` is a queryable collection** — referenced in `@loop` by its bare name. There is no `content/` root to opt into and no marker file to add: a `site/pages/blog/` directory of posts *is* the `blog` collection.
357
+
358
+ ```wd
359
+ @loop blog into post sort by post.date desc
360
+ - [{ post.title }]({ post.url }) — { post.date }
361
+ @endloop
362
+ ```
363
+
364
+ It's the same one loop, with the same clauses (`where`, `sort by`, `reverse`, `offset`, `limit`, format pipes). The collection resolves at **build time**, so a pure listing ships **zero JavaScript** (`runtime: false`).
365
+
366
+ Each entry's frontmatter becomes a row, plus three fields the framework derives:
367
+
368
+ | Field | Value |
369
+ |---|---|
370
+ | `{ post.url }` | the entry's route, e.g. `/blog/hello/` |
371
+ | `{ post.slug }` | the filename without extension (an `index.md` uses its folder name) |
372
+ | `{ post.excerpt }` | the frontmatter `excerpt:`, else the first paragraph of a `.md` body |
373
+
374
+ Scalar frontmatter is coerced for querying, so `where post.featured == true` and numeric sorts behave like they do over a JSON file. **Drafts never leak:** a `draft: true` entry is excluded from a default build's listing (included only under `darkmown build --drafts`), exactly like routing and feeds.
375
+
376
+ ### Typed schema — `_schema.wd`
377
+
378
+ Drop a `_schema.wd` at a collection's root to validate every entry's frontmatter at build time. It's frontmatter-shaped — one `field: type` rule per line:
379
+
380
+ ```wd
381
+ ---
382
+ title: string
383
+ date: date
384
+ description: string
385
+ excerpt: string?
386
+ tags: string[]?
387
+ ---
388
+ ```
389
+
390
+ The vocabulary is small and closed: `string`, `number`, `boolean`, `date`, `string[]`, each with a trailing `?` to mark it optional. A missing required field, a wrong type, an unknown extra field (a typo guard), or an unknown type token in the schema itself each **fail the build** with a `file:line` and the offending field. Validation is **opt-in** — no `_schema.wd`, no validation.
391
+
392
+ ### Pagination — `paginate N`
393
+
394
+ Add `paginate N` (collections only) to split a listing into static pages: **page 1 keeps the listing's own route**, and pages 2+ live at `/<route>/page/2/`, `/<route>/page/3/`, … Every generated page is static HTML and appears in `routes.json` and `sitemap.xml`.
395
+
396
+ ```wd
397
+ @loop blog into post sort by post.date desc paginate 5
398
+ - [{ post.title }]({ post.url })
399
+ @endloop
400
+
401
+ Page { page.current } of { page.total }
402
+ :if page.prev
403
+ [← Newer]({ page.prev })
404
+ :endif
405
+ :if page.next
406
+ [Older →]({ page.next })
407
+ :endif
408
+ ```
409
+
410
+ A `page` pager is exposed to the whole page:
411
+
412
+ | Variable | Value |
413
+ |---|---|
414
+ | `{ page.current }` | 1-based current page number |
415
+ | `{ page.total }` | total number of pages |
416
+ | `{ page.prev }` | URL of the previous page, or `""` on page 1 |
417
+ | `{ page.next }` | URL of the next page, or `""` on the last page |
418
+
419
+ The pager is plain `<a href>` links to the generated routes — **zero JavaScript**. `paginate` is for collections only (route-multiplication only makes sense for a folder of entries) and can't combine with `offset`/`limit` (it owns the slice).
420
+
421
+ > See the [live blog demo](https://darkmown.com/blog/) — the whole index, paginated and sorted, is one `@loop` over `site/pages/blog/`, validated by a `_schema.wd`, shipping no framework JS.
422
+
354
423
  ## Sections
355
424
 
356
425
  ```wd
@@ -809,6 +878,50 @@ A single `tokens dark` block powers **both** theming paths: it compiles to `:roo
809
878
 
810
879
  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.)
811
880
 
881
+ ## Scoped styles — `scoped`
882
+
883
+ *New in 1.4.0.* By default a colocated `.skin` is **global** — its selectors match the whole page, exactly like a stylesheet. That's the right default for a design system. But when two components both want a class called `.card`, global CSS makes them fight. Opt a skin into **scoping** so its selectors only ever match the component it ships with: make the **first line** of the `.skin` file the word `scoped`.
884
+
885
+ ```skin
886
+ scoped
887
+
888
+ .card
889
+ padding 1.5rem
890
+ bg $panel
891
+ radius $radius
892
+ ```
893
+
894
+ Scoping is **pure compile time** — a short, path-derived id (e.g. `wd-7c21`) is stamped onto the component's HTML (`data-wd-scope="wd-7c21"`) and appended to each of the skin's selectors:
895
+
896
+ ```css
897
+ .card[data-wd-scope="wd-7c21"] { padding: 1.5rem; }
898
+ .card[data-wd-scope="wd-7c21"] { background: var(--panel); }
899
+ ```
900
+
901
+ A second component's `.card` gets a **different** id, so the two never collide. There is **no runtime cost and no class renaming in your markup** — you still write `class="card"`; the framework adds the attribute during the build. A static page with a scoped skin stays **zero-JS**.
902
+
903
+ **What scopes, what stays global:**
904
+
905
+ | In a `scoped` skin | Result |
906
+ |---|---|
907
+ | A selector rule (`.card`, `.card:hover`, `.card .title`, `h2, h3`) | Scoped — the attribute lands on the **subject** (rightmost) selector, before any `:hover`/`::before` so it stays valid CSS |
908
+ | A descendant selector (`.card .title`) | Only `.title` (the subject) is scoped; `.card` matches inside the subtree |
909
+ | `tokens` / `tokens dark` / `tokens [data-theme=…]` | **Global** — design tokens always emit on `:root`, so `$accent` and dark mode keep working site-wide |
910
+ | `:global(.toast)` (whole selector) | **Opts back out** — emits a plain, unscoped `.toast { … }` |
911
+ | `@media` / `@supports` wrappers | Untouched; only the inner rule's selector is scoped |
912
+ | `page` / `*` / `html` / `body` / `::selection` | **Compile error** — page-level styles belong in a global skin, not a scoped one |
913
+
914
+ Where the scope applies depends on **where the skin lives**:
915
+
916
+ - A skin colocated with a **page** (`about.skin` next to `about.wd`) scopes that page's body.
917
+ - A skin colocated with an **include** (`card.skin` next to `card.wd`) scopes just that include's subtree wherever it's `@include`d — so the same scoped component can appear many times on a page without leaking into its neighbours.
918
+
919
+ **Caveats (honest limits this release):**
920
+
921
+ - **Whole-selector `:global()` only.** A descendant `:global` (`.card :global(.x)`) is **not** supported yet — use a whole-selector `:global(.x)`.
922
+ - **Unused selectors warn, they aren't removed.** If a scoped selector's class/element/id never appears in the stamped subtree, the build prints `hint: scoped selector ".badge" in card.skin matches no element` — but the rule is **kept** (a colocated `.js` may add the class at runtime). It's a typo nudge, not a dead-code remover.
923
+ - **Scoping is opt-in.** Every existing `.skin` (no `scoped` marker) is byte-for-byte unchanged.
924
+
812
925
  ## The escape hatch
813
926
 
814
927
  Reactive pages expose `window.wd` so a colocated `.js` file can do anything the directives deliberately don't — keyboard, drag/touch, canvas, charts, maps:
@@ -134,6 +134,36 @@ Four progressive-disclosure capabilities plus an assets and a security change. T
134
134
  - **Page-colocated static assets:** any non-page file under `site/pages/` (anything but `.md`/`.wd` routes and colocated `.skin`/`.js`) is copied to `dist/` at its path (`site/pages/logo.svg` → `dist/logo.svg`). The shelf (`site/_/` → `/__wd/media`, `/__wd/data`) is unchanged; a name clash with an emitted route is skipped with a warning. As of v0.14.1, hidden path segments (`.`, `-`, `_`) and symlinked page assets are skipped. `.avif` was added to the served content-types.
135
135
  - **`:fetch` URL scheme hardening (security):** `:fetch`/`refresh=` URLs are validated at compile time — relative, `http(s)://`, or a leading `{ state }` interpolation are allowed; protocol-relative `//host` and non-http(s) schemes (`file:`, `data:`, `javascript:`, …) are rejected with a corrective hint. **This is scheme validation, not a host allowlist** — SSRF to arbitrary http(s) hosts remains the author's responsibility (see the trust boundary).
136
136
 
137
+ ## 1.5.0 additions — typed content collections + pagination (2026-06-29)
138
+
139
+ The 1.5.0 cycle ships the headline **content collections** feature: turning a folder of pages into a queryable, typed, paginated data source — entirely at build time. **Zero runtime impact** — it is build-time glue over the existing `@loop` pipeline; `src/runtime.js` and the 8 KB budget are untouched, and a pure collection listing stays `runtime: false`.
140
+
141
+ - **Zero-ceremony collections — bare folder name as `@loop` source.** ANY `site/pages/<name>/` subdirectory is a collection, referenced by its **bare name** (`@loop blog into post`). There is no `content/` root and no marker file; an existing `site/pages/blog/` becomes a collection with zero file moves. A build-time **collection index** (`src/compiler/collections.js`, `buildCollections`) is assembled from the router's already-parsed `route.meta` — frontmatter is **never re-parsed** — and threaded into the compilation as `comp.collections` (added to `context.js`). **Row shape:** every frontmatter field plus three derived fields — `url` (the entry route), `slug` (filename stem; an `index.*` entry uses its folder name), and `excerpt` (frontmatter `excerpt:` → first paragraph of a `.md` body via `feeds.js`'s `firstParagraph`; `.wd` bodies are not scanned). The derived keys are added **after** the frontmatter spread so a stray `url:`/`slug:` can't shadow real route data. Scalar frontmatter is **coerced** (`true`/`false` → boolean, numeric strings → number) **only** in the collection row (never in the global frontmatter parser), so `where post.featured == true` and numeric sorts behave like they do over a JSON file. **The same one loop** — `where`/`sort by`/`reverse`/`offset`/`limit`/format-pipes/`:if`/`:every` all work unchanged because a collection resolves to the same array of rows; a bare-name resolution sits **after** `:state` (a declared `:state` of the same name still wins) and a `where` that reads `:state` still makes the loop reactive (baked rows). An unresolved bare source errors with a `file:line` + the list of available collection names.
142
+
143
+ - **Drafts never leak into a default-build listing.** Collection rows are built from the **post-draft-filter** route list (the same `discoverRoutes` chokepoint routing/feeds use), so a `draft: true` entry is excluded from a default build's listing and included only under `darkmown build --drafts` — regression-tested on both paths.
144
+
145
+ - **Optional typed schema — `_schema.wd`.** A `_schema.wd` at a collection root opts the collection into build-time validation (absent = no validation, like `scoped`/`draft`). The DSL is **frontmatter-shaped** — one `field: type` rule per line inside the `---` block — written exactly like the frontmatter it validates. The type vocabulary is **small and closed**: `string`, `number`, `boolean`, `date`, `string[]`, each with a trailing `?` for optional. Validation throws on the first violation with a `file:line` and the offending field: a missing required field, a wrong type, or an **unknown extra field** (a typo guard — `titel:` is caught) all fail the build; framework-recognized keys (`url`/`slug`/`excerpt`/`draft`) are allowed implicitly. An **unknown type token in the schema itself** is a compile error pointing at the schema `file:line`.
146
+
147
+ - **Pagination — `@loop … paginate N`.** A new clause (collections only) multiplies the listing into static pages: **page 1 keeps the listing's own clean route**, pages 2+ live at `/<route>/page/<n>/`. The compiler records the pagination intent (`comp.pagination`, post-`where`/`sort` page count) during a discovery compile; `src/builder.js` then recompiles each page with the correct `page` pager seeded into the document scope and emits every generated route into `routes.json` + `sitemap.xml` (RSS is dated-posts-only, unaffected). The **pager** exposed to the template scope is `{ page.current }` / `{ page.total }` (numbers) and `{ page.prev }` / `{ page.next }` (URLs, empty string at the ends) — plain `<a href>` links to the generated routes, **zero JS**. `paginate` is a positive-integer literal, rejects a non-collection source (route-multiplication only makes sense for a folder of entries), and can't combine with `offset`/`limit` (it owns the slice) or `sortable`.
148
+
149
+ - **Demo (four-file rule):** the existing `site/pages/blog/` became a real collection — `blog/index.wd` is one paginated `@loop` over the folder (sorted date-desc, `paginate 3`, linking each entry by `{ post.url }`, with a styled `index.skin` and a `{ page.* }` pager), validated by `blog/_schema.wd`, across five genuine dated posts (so `/blog/` + `/blog/page/2/` both emit) plus a draft that proves exclusion. RSS/sitemap keep working over the collection. Tests in `tests/collections.test.js` (resolution, row shape incl. url/slug/excerpt, where/sort, draft exclusion + `--drafts` inclusion, schema pass+fail with `file:line`, pagination route-multiplication + pager vars + `/blog/page/2/` shape, `runtime: false` on a pure listing, every `paginate` guard). **Honest caveats:** `paginate N` is a build-time route split only — no client-side pagination; pagination is collections-only (a JSON-file/`:state` source is rejected); tag/taxonomy pages and image optimization are deferred to 1.6.0.
150
+
151
+ ## 1.4.0 additions — compile-time scoped styles (2026-06-28)
152
+
153
+ The 1.4.0 cycle ships one feature: **opt-in scoped styles** for colocated `.skin` files, closing the "no CSS scoping" gap an audit flagged (two components couldn't reuse a class name without collision). **Pure compile time, zero runtime** — `src/runtime.js` and the 8 KB budget are untouched, and a static page with a scoped skin stays `runtime: false`.
154
+
155
+ - **Authoring surface — the `scoped` marker:** a `.skin` scopes its selectors **only** when its first non-comment, non-blank, non-divider line is exactly `scoped`; colocation alone changes nothing. Every existing `.skin` (no marker) stays **global** and emits **byte-identical CSS** to before (golden-tested against `site/_/base.skin`). `scoped` anywhere but the first line is a compile error (`"scoped" must be the first line of a .skin file`). The only other new surface is `:global()` inside a scoped skin — no new `.wd` syntax.
156
+
157
+ - **Mechanism — id + selector rewrite + HTML stamp.** The scope id is a short, deterministic hash of the skin's **project-relative path** (e.g. `wd-7c21`) — path-based (stable across content edits, unique per file), computed in `src/builder.js` (the coordination point) and re-derived identically by the HTML stamp from the same path, so `src/skin.js` stays a pure string→CSS function (`compileSkin(src, { scope })`). In scoped mode `compileSkin` appends `[data-wd-scope="<id>"]` to the **subject** (rightmost compound) of each comma-separated selector, **after** `&`-nesting resolves and **before** any trailing pseudo-class/element (`.card[data-wd-scope="…"]:hover`, never `.card:hover[…]`; `::before` likewise) — so descendant selectors scope only the subject (`.card .title` → `.title` gets the attribute) and `@media`/`@supports` wrappers are untouched. The HTML half (`stampScope` in `src/compiler/page.js`, an `enhanceImages`-style regex post-pass) stamps `data-wd-scope="<id>"` onto every opening tag of the scoped subtree that doesn't already carry one (first stamp wins, so a nested include keeps its own id). A **page**-colocated scoped skin stamps the page body; an **include**-colocated scoped skin is stamped in `handleInclude` over just that include's returned subtree — so the same scoped component repeats on a page without its scope leaking to siblings.
158
+
159
+ - **Tokens stay global.** A `tokens` / `tokens dark` / `tokens [attr]` block in a scoped skin still emits a global `:root { --name: … }` (and the dark/manual-toggle variants) — never scoped — so `var(--x)`, dark mode, `:theme`, and the `$code-*` highlight tokens keep resolving site-wide. Enforced by only touching the selector-rule construction, leaving the token/`:root` path identical.
160
+
161
+ - **`:global()` escape + disallowed selectors.** A **whole-selector** `:global(.toast)` emits a plain, unscoped `.toast { … }`. Page-level selectors in a scoped skin — `page` (→ body), bare top-level `*`/`html`/`body`/`::selection`, and any bare top-level declaration that would land on `:root` — are a **compile error** with a hint pointing to a global skin (chosen over silently auto-`:global`-ing them).
162
+
163
+ - **Unused-selector handling — warn, never remove.** The builder has both the scoped subject set (collected by `compileSkin`) and the stamped subtree HTML, so it warns (`hint: scoped selector ".badge" in card.skin matches no element`) when a scoped subject's class/element/id never appears in the stamped subtree — but **keeps the rule** (a colocated `.js` may add the class at runtime). A typo nudge, not a dead-code remover.
164
+
165
+ - **Honest caveats:** only **whole-selector** `:global()` this release — descendant `:global` (`.card :global(.x)`) is deferred; unused selectors **warn, are not removed**. Demonstrated end-to-end by the [`/scoped/`](/scoped/) demo (two components reuse `.card` with no collision; a global skin still works alongside).
166
+
137
167
  ## 1.3.0 additions — table-stakes batteries (2026-06-28)
138
168
 
139
169
  The 1.3.0 cycle closes table-stakes gaps a new user expects out of the box. Tracks land in parallel; this entry covers the **syntax-highlighting** and **drafts + feeds** tracks.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvndev/darkmown",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
package/site/_/base.skin CHANGED
@@ -37,6 +37,13 @@ tokens
37
37
  radius 12px
38
38
  radius-sm 8px
39
39
  radius-lg 20px
40
+ field-bg #ffffff
41
+ field-line #d9d2c4
42
+ field-line-strong #c4baa8
43
+ control-shadow 0 1px 2px rgba(28,24,20,.06)
44
+ control-shadow-hover 0 6px 18px -8px rgba(28,24,20,.45)
45
+ accent-press #094339
46
+ on-accent #ffffff
40
47
 
41
48
  tokens dark
42
49
  ink #ece6db
@@ -66,6 +73,13 @@ tokens dark
66
73
  code-number #f2bb8a
67
74
  code-punctuation #b6c2bc
68
75
  ring #5ccab4
76
+ field-bg #1a1612
77
+ field-line #38301f
78
+ field-line-strong #4a3f2c
79
+ control-shadow 0 1px 2px rgba(0,0,0,.4)
80
+ control-shadow-hover 0 8px 22px -10px rgba(0,0,0,.7)
81
+ accent-press #b6ecdc
82
+ on-accent #0c1310
69
83
 
70
84
  /* ── Reset & base ───────────────────────────────────────────────── */
71
85
 
@@ -156,45 +170,234 @@ main
156
170
  color $accent
157
171
  margin 0 0 .35rem
158
172
 
159
- /* ── Buttons ────────────────────────────────────────────────────── */
160
-
161
- .btn
173
+ /* ── Buttons ────────────────────────────────────────────────────────
174
+ Two ways to get a button, ONE consistent look:
175
+ 1. The `.btn` class (for link-styled buttons: <a class="btn">).
176
+ 2. A bare <button> — what the `:button`/`:submit` directives emit with
177
+ no class. Both share the same sizing, states, and tokens so a demo,
178
+ a scaffold, or a docs page looks designed without per-page styling.
179
+ The bare-button default is the ACCENT (filled) treatment, because a
180
+ directive button is almost always the page's primary action. Opt down
181
+ to a quieter look with class="secondary" / class="ghost".
182
+ ─────────────────────────────────────────────────────────────────── */
183
+
184
+ /* Shared geometry + motion for every button-shaped thing. One-line comma
185
+ selector (multi-line comma lists silently drop all but the last). */
186
+ .btn, button, button[data-wd-action], button[data-wd-actions], [type=submit], [type=button], [type=reset]
162
187
  display inline-flex
163
188
  align-items center
189
+ justify-content center
164
190
  gap .5rem
165
191
  margin .4rem .5rem .4rem 0
166
- padding .7rem 1.15rem
167
- font-size .95rem
168
- font-weight 550
169
- line-height 1
192
+ padding .62rem 1.15rem
193
+ font-family system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif
194
+ font-size .94rem
195
+ font-weight 560
196
+ line-height 1.15
197
+ letter-spacing -.005em
170
198
  color $ink
171
199
  bg $panel
172
200
  border 1px solid $line
173
201
  radius 999px
174
202
  text-decoration none
175
203
  cursor pointer
176
- transition transform .12s ease, box-shadow .12s ease, background .12s ease
177
-
178
- .btn:hover
179
- transform translateY(-1px)
180
- box-shadow 0 6px 18px -8px rgba(28,24,20,.35)
204
+ -webkit-appearance none
205
+ appearance none
206
+ transition transform .14s cubic-bezier(.2,.7,.3,1), box-shadow .14s ease, background .14s ease, border-color .14s ease, color .14s ease
181
207
 
182
- .btn.primary
208
+ /* The filled accent default — applies to bare directive buttons and to
209
+ the opt-in `.btn.primary` / `.btn.warm` link variants. One-line list. */
210
+ button, button[data-wd-action], button[data-wd-actions], [type=submit], .btn.primary
183
211
  bg $accent
184
- color #fff
212
+ color $on-accent
185
213
  border-color $accent
214
+ box-shadow $control-shadow
186
215
 
187
- .btn.primary:hover
216
+ button:hover, button[data-wd-action]:hover, button[data-wd-actions]:hover, [type=submit]:hover, .btn.primary:hover
188
217
  bg $accent-deep
189
- color #fff
218
+ border-color $accent-deep
219
+ color $on-accent
220
+ transform translateY(-1px)
221
+ box-shadow $control-shadow-hover
222
+
223
+ button:active, button[data-wd-action]:active, button[data-wd-actions]:active, [type=submit]:active, .btn.primary:active
224
+ bg $accent-press
225
+ border-color $accent-press
226
+ transform translateY(0)
227
+ box-shadow $control-shadow
228
+
229
+ /* Visible keyboard focus on every interactive control — accessibility. */
230
+ .btn:focus-visible, button:focus-visible, [type=submit]:focus-visible, [type=button]:focus-visible, [type=reset]:focus-visible
231
+ outline 2px solid $ring
232
+ outline-offset 2px
233
+
234
+ button:disabled, [type=submit]:disabled, .btn[aria-disabled=true], [type=button]:disabled
235
+ opacity .5
236
+ cursor not-allowed
237
+ box-shadow none
238
+ transform none
239
+
240
+ /* Quieter, neutral button — a secondary/cancel action. One-line list. */
241
+ .btn, button.secondary, button.ghost, .btn.secondary, .btn.ghost
242
+ bg $panel
243
+ color $ink
244
+ border-color $line
245
+
246
+ .btn:hover
247
+ transform translateY(-1px)
248
+ box-shadow $control-shadow-hover
249
+
250
+ button.secondary:hover, .btn.secondary:hover
251
+ bg $sunk
252
+ border-color $line
253
+ color $ink
254
+ transform translateY(-1px)
255
+ box-shadow $control-shadow-hover
256
+
257
+ button.ghost, .btn.ghost
258
+ bg transparent
259
+ border-color transparent
260
+ box-shadow none
261
+
262
+ button.ghost:hover, .btn.ghost:hover
263
+ bg $sunk
264
+ box-shadow none
265
+ transform none
190
266
 
191
267
  .btn.warm
192
268
  bg $warm
193
269
  color #fff
194
270
  border-color $warm
195
271
 
196
- .btn.ghost
197
- bg transparent
272
+ .btn.warm:hover
273
+ bg $warm
274
+ color #fff
275
+
276
+ /* ── Form controls ──────────────────────────────────────────────────
277
+ `:input`/`:bind`/`:textarea`/`:select` emit bare <input>/<textarea>/
278
+ <select> — style them here so forms look designed by default. ─────── */
279
+
280
+ input, textarea, select
281
+ font-family system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif
282
+ font-size .95rem
283
+ line-height 1.4
284
+ color $ink
285
+ bg $field-bg
286
+ border 1px solid $field-line
287
+ radius $radius-sm
288
+ padding .58rem .8rem
289
+ margin .35rem 0
290
+ width 100%
291
+ max-width 28rem
292
+ -webkit-appearance none
293
+ appearance none
294
+ transition border-color .14s ease, box-shadow .14s ease, background .14s ease
295
+
296
+ input::placeholder, textarea::placeholder
297
+ color $faint
298
+
299
+ input:hover, textarea:hover, select:hover
300
+ border-color $field-line-strong
301
+
302
+ input:focus-visible, textarea:focus-visible, select:focus-visible
303
+ outline none
304
+ border-color $accent
305
+ box-shadow 0 0 0 3px $accent-soft
306
+
307
+ input:disabled, textarea:disabled, select:disabled
308
+ opacity .55
309
+ cursor not-allowed
310
+ bg $sunk
311
+
312
+ textarea
313
+ min-height 6.5rem
314
+ resize vertical
315
+
316
+ /* Native select chevron (the appearance reset hides the default arrow). */
317
+ select
318
+ background-image url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23756a5d' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E")
319
+ background-repeat no-repeat
320
+ background-position right .75rem center
321
+ padding-right 2.25rem
322
+ cursor pointer
323
+
324
+ /* Checkbox / radio — accent-tinted, comfortable hit target. The directive
325
+ emits <label><input type=checkbox|radio> Text</label> in .wd-checkboxes /
326
+ .wd-radios groups. One-line comma list. */
327
+ .wd-checkboxes, .wd-radios
328
+ display flex
329
+ flex-direction column
330
+ gap .55rem
331
+ margin .5rem 0
332
+
333
+ .wd-checkboxes label, .wd-radios label
334
+ display flex
335
+ align-items center
336
+ gap .55rem
337
+ cursor pointer
338
+
339
+ [type=checkbox], [type=radio]
340
+ width 1.1rem
341
+ height 1.1rem
342
+ min-width 1.1rem
343
+ margin 0
344
+ max-width none
345
+ padding 0
346
+ accent-color $accent
347
+ cursor pointer
348
+
349
+ [type=checkbox]:focus-visible, [type=radio]:focus-visible
350
+ outline 2px solid $ring
351
+ outline-offset 2px
352
+
353
+ label
354
+ font-size .95rem
355
+ color $ink-soft
356
+
357
+ /* The range slider (`:slider`) — tint the track to the brand accent. */
358
+ [type=range]
359
+ accent-color $accent
360
+ cursor pointer
361
+ padding 0
362
+
363
+ /* Framework control buttons (`:carousel` injects these at runtime). They
364
+ must NOT inherit the big accent pill above — reset them to a compact,
365
+ neutral round control. Page skins can still override. One-line lists. */
366
+ .wd-carousel-prev, .wd-carousel-next
367
+ width 2.25rem
368
+ height 2.25rem
369
+ padding 0
370
+ margin 0
371
+ bg $panel
372
+ color $ink
373
+ border 1px solid $line
374
+ radius 999px
375
+ box-shadow $control-shadow
376
+
377
+ .wd-carousel-prev:hover, .wd-carousel-next:hover
378
+ bg $panel
379
+ border-color $accent
380
+ color $accent
381
+ transform none
382
+ box-shadow $control-shadow-hover
383
+
384
+ .wd-carousel-dot
385
+ width .6rem
386
+ height .6rem
387
+ padding 0
388
+ margin 0
389
+ border 0
390
+ radius 999px
391
+ bg $line
392
+ box-shadow none
393
+
394
+ .wd-carousel-dot:hover
395
+ bg $faint
396
+ transform none
397
+ box-shadow none
398
+
399
+ .wd-carousel-dot[aria-current=true]
400
+ bg $accent
198
401
 
199
402
  /* ── Cards & surfaces ───────────────────────────────────────────── */
200
403
 
@@ -359,6 +562,16 @@ blockquote
359
562
  margin 0
360
563
  radius 0
361
564
 
565
+ /* ── Motion preference ──────────────────────────────────────────────
566
+ Honour users who ask for less motion: drop the button lift/transition.
567
+ (@media must be indented in a .skin — its rules wrap automatically.) ── */
568
+
569
+ @media (prefers-reduced-motion: reduce)
570
+ .btn, button, [type=submit], [type=button], input, textarea, select
571
+ transition none
572
+ .btn:hover, button:hover, [type=submit]:hover
573
+ transform none
574
+
362
575
  /* ── Footer ─────────────────────────────────────────────────────── */
363
576
 
364
577
  .sitefooter
package/site/_/nav.wd CHANGED
@@ -2,10 +2,10 @@
2
2
  <a class="brand" href="/">Darkmown</a>
3
3
  <span class="nav-links">
4
4
  <a href="/docs/">Docs</a>
5
+ <a href="/blog/">Blog</a>
5
6
  <a href="/showcase/">Showcase</a>
6
7
  <a href="/reactive/">Reactive</a>
7
8
  <a href="/data/">Data</a>
8
- <a href="/highlight/">Highlight</a>
9
9
  </span>
10
10
  <a class="nav-gh" href="https://github.com/ZVN-DEV/darkmown">GitHub ↗</a>
11
11
  </nav>
@@ -0,0 +1,43 @@
1
+ scoped
2
+
3
+ /* Component A — a warm promo card. Same class names as scoped-stat.skin
4
+ (.card / .title / .body), but scoped, so neither leaks. Tokens stay global,
5
+ so $accent / $warm / $radius still resolve from base.skin's :root. */
6
+
7
+ .card
8
+ display grid
9
+ gap .6rem
10
+ padding 1.5rem
11
+ bg $warm-soft
12
+ border 1px solid $warm
13
+ border-left 4px solid $warm
14
+ radius $radius
15
+
16
+ .badge
17
+ justify-self start
18
+ font-size .7rem
19
+ font-weight 700
20
+ text-transform uppercase
21
+ letter-spacing .08em
22
+ color #fff
23
+ bg $warm
24
+ padding .2rem .55rem
25
+ radius 999px
26
+
27
+ .title
28
+ margin 0
29
+ font-size 1.25rem
30
+ color $ink
31
+
32
+ .body
33
+ margin 0
34
+ color $ink-soft
35
+ max-width none
36
+
37
+ .cta
38
+ justify-self start
39
+ font-weight 600
40
+ color $warm
41
+
42
+ .cta:hover
43
+ color $ink
@@ -0,0 +1,10 @@
1
+ <!-- Scoped component A. Its colocated scoped-promo.skin opts into scoping, so the
2
+ `.card` here gets its OWN look — a warm, accent-bordered promo — without
3
+ clashing with the identically-named `.card` in scoped-stat.wd. handleInclude
4
+ stamps just this returned subtree with the component's scope id. -->
5
+ <article class="card">
6
+ <span class="badge">Promo</span>
7
+ <h3 class="title">Same class name, zero collision</h3>
8
+ <p class="body">This <code>.card</code> is a warm promo panel. The component beside it also calls its root <code>.card</code> — and they do not fight, because each scoped skin only matches inside its own stamped subtree.</p>
9
+ <a class="cta" href="/docs/">How scoping works →</a>
10
+ </article>
@@ -0,0 +1,32 @@
1
+ scoped
2
+
3
+ /* Component B — a dark stat tile. Identical class names to scoped-promo.skin,
4
+ but a completely different look. Scoping keeps them apart; tokens are global
5
+ so $accent-deep / $accent / $radius-lg come straight from base.skin's :root. */
6
+
7
+ .card
8
+ display grid
9
+ gap .35rem
10
+ justify-items center
11
+ text-align center
12
+ padding 2rem 1.5rem
13
+ bg $accent-deep
14
+ border 1px solid $accent-deep
15
+ radius $radius-lg
16
+
17
+ .title
18
+ font-size 3rem
19
+ font-weight 700
20
+ line-height 1
21
+ color #fff
22
+
23
+ .body
24
+ margin 0
25
+ max-width none
26
+ color $accent-soft
27
+
28
+ .badge
29
+ font-size .68rem
30
+ text-transform uppercase
31
+ letter-spacing .1em
32
+ color $accent
@@ -0,0 +1,9 @@
1
+ <!-- Scoped component B. Reuses the SAME class names as scoped-promo.wd
2
+ (.card / .title / .body) but its colocated scoped-stat.skin scopes them to a
3
+ dark, centered stat tile. Proof the leak is gone: two `.card` definitions on
4
+ one page, each only styling its own subtree. -->
5
+ <article class="card">
6
+ <span class="title">99.9%</span>
7
+ <p class="body">uptime, because there is no client framework to crash</p>
8
+ <span class="badge">measured</span>
9
+ </article>