@zvndev/darkmown 1.4.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
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
+
5
15
  ## 1.4.0 — 2026-06-28
6
16
 
7
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).
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
@@ -134,6 +134,20 @@ 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
+
137
151
  ## 1.4.0 additions — compile-time scoped styles (2026-06-28)
138
152
 
139
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`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvndev/darkmown",
3
- "version": "1.4.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>
@@ -57,20 +57,42 @@ h1
57
57
  color #9a2a2a
58
58
 
59
59
  button
60
+ display inline-flex
61
+ align-items center
62
+ justify-content center
63
+ gap .4rem
60
64
  background $accent
61
65
  color white
62
- border 0
63
- padding .55rem .9rem
66
+ border 1px solid $accent
67
+ padding .55rem 1rem
64
68
  margin .25rem .4rem .25rem 0
65
- border-radius 8px
66
- font-size .95rem
69
+ border-radius 999px
70
+ font-size .92rem
71
+ font-weight 560
67
72
  cursor pointer
73
+ appearance none
74
+ transition transform .14s cubic-bezier(.2,.7,.3,1), box-shadow .14s ease
75
+
76
+ button:hover
77
+ transform translateY(-1px)
78
+ box-shadow 0 8px 18px -8px rgba(15,107,94,.5)
79
+
80
+ button:focus-visible
81
+ outline 2px solid $accent
82
+ outline-offset 2px
68
83
 
69
84
  input
70
85
  font-size 1rem
71
- padding .6rem .7rem
86
+ padding .55rem .75rem
72
87
  border 1px solid $line
73
88
  border-radius 8px
74
89
  width 100%
75
90
  max-width 22rem
76
91
  margin .3rem 0
92
+ appearance none
93
+ transition border-color .14s ease, box-shadow .14s ease
94
+
95
+ input:focus-visible
96
+ outline none
97
+ border-color $accent
98
+ box-shadow 0 0 0 3px rgba(15,107,94,.18)
@@ -0,0 +1,15 @@
1
+ ---
2
+ # Typed schema for the blog collection. Every post under site/pages/blog/ is
3
+ # validated against these rules at build time — a missing required field, a
4
+ # wrong type, or an unknown extra field fails the build with a file:line. Append
5
+ # "?" to a type to make the field optional. Remove this file to turn validation
6
+ # off (it is opt-in).
7
+ title: string
8
+ date: date
9
+ description: string
10
+ excerpt: string?
11
+ tags: string[]?
12
+ ---
13
+
14
+ The blog collection schema. Posts are plain `.md` files next to this file;
15
+ their frontmatter must match the rules above.
@@ -0,0 +1,38 @@
1
+ ---
2
+ title: Collections and pagination, with zero ceremony
3
+ description: Any folder under site/pages is a queryable collection. Loop it by name, sort it, paginate it — all at build time, all zero-JS.
4
+ date: 2026-06-28
5
+ excerpt: This very page is an entry in a collection. The list you came from is one @loop over a folder — no content root, no marker file, no config.
6
+ tags: [collections, content]
7
+ ---
8
+
9
+ # Collections and pagination, with zero ceremony
10
+
11
+ The page you came from is not a hand-maintained list of links. It is one
12
+ `@loop` over a *collection* — and a collection is just a folder. Every `.md`
13
+ and `.wd` file under `site/pages/blog/` is an entry, queryable by the folder's
14
+ bare name:
15
+
16
+ ```wd
17
+ @loop blog where draft == false sort by date desc paginate 3 into post
18
+ - [{ post.title }]({ post.url }) — { post.date }
19
+ @endloop
20
+ ```
21
+
22
+ No `content/` root to opt into, no marker file to drop in, no config object.
23
+ A folder of posts *is* the collection. Each entry's frontmatter becomes a row,
24
+ plus three fields the framework derives for you: `url` (the entry's route),
25
+ `slug` (its filename), and `excerpt` (the post's first paragraph when you don't
26
+ write one).
27
+
28
+ ## It is the same loop you already know
29
+
30
+ There is no second loop syntax. `where`, `sort by`, `reverse`, `offset`,
31
+ `limit`, and the format pipes all work over a collection exactly as they do over
32
+ a JSON file — because under the hood a collection resolves to the same array of
33
+ rows. The only new word is `paginate N`, which slices the listing into static
34
+ pages: page one keeps this listing's clean route, and pages two and up live at
35
+ `/blog/page/2/`, `/blog/page/3/`, and so on. Every one is plain HTML.
36
+
37
+ A pure listing like this ships **zero** framework JavaScript. The pager links —
38
+ newer, older, numbered — are just `<a href>`s to the generated routes.
@@ -2,6 +2,8 @@
2
2
  title: Drafts that never leak
3
3
  description: draft true excludes a page everywhere in a production build — dist, routes.json, sitemap, and rss.
4
4
  date: 2026-06-27
5
+ excerpt: Set draft true on a post and it vanishes from a production build — dist, routes.json, the sitemap, and the feed — even with a date.
6
+ tags: [drafts, workflow]
5
7
  ---
6
8
 
7
9
  # Drafts that never leak