@zvndev/darkmown 0.15.0 → 0.17.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 CHANGED
@@ -156,13 +156,16 @@ Your `.skin` selectors must match the **real** output. The emitted HTML is:
156
156
  | `::: name .card … :::` (non-`section`) | `<div class="card">…</div>` |
157
157
  | `:button "x" -> …` | `<button>x</button>` |
158
158
  | `:input email …` | `<input type="…">` |
159
+ | `:textarea note …` | `<textarea>` |
160
+ | `:select name` + `- Label` lines | `<select>` of `<option>`s |
161
+ | `:checkbox name` / `:radio name` + `- Label` lines | labelled `<input type=checkbox\|radio>` group (`:checkbox` captures an **array**, `:radio` one value) |
159
162
  | `:bind q placeholder="…"` | `<input>` bound two-way to `:state q` |
160
163
  | `:submit "Go"` | `<button type="submit">Go</button>` |
161
164
  | `:form …` | `<form>…</form>` |
162
165
  | reactive `@loop` of a list | `<ul>`/`<ol>` (or `<div>`) of items |
163
166
  | `{ name }` | a text node |
164
167
 
165
- So style with real selectors: `section`, `.card`, `button`, `input`, `form`, `h1`. **To add a class to a container, use `::: name .class`. For any other custom element, write raw HTML `<div class="…">`. There is NO `{.class}` attribute syntax.** A container class can also be reactive: `::: card .on-sale when p.price < 50` toggles the class from a predicate (same grammar as `:if`).
168
+ So style with real selectors: `section`, `.card`, `button`, `input`, `form`, `h1`. **To add a class to a container, use `::: name .class`; to add one to a single inline element (link, image, emphasis, inline code), use a trailing `{.class .class #id}` — e.g. `[Get started](/start/){.btn}`.** A container class can also be reactive: `::: card .on-sale when p.price < 50` toggles the class from a predicate (same grammar as `:if`).
166
169
 
167
170
  ## Styling with `.skin` — ship this on every page
168
171
 
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
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
+ ## 0.17.0 — 2026-06-23
6
+
7
+ Multi-select and single-choice form groups: **`:checkbox` and `:radio`**.
8
+
9
+ - **`:checkbox name` and `:radio name`** (each followed by `- Label` option lines) render a labelled group of `<input>`s that share one `name`, wrapped in `<label>`s inside a `role="group"` / `role="radiogroup"` container with a derived aria-label. They join `:input`/`:textarea`/`:select` as `:form into` field directives.
10
+ - **Checkbox groups capture an array.** A `:checkbox` group is marked `data-wd-multi`, and the runtime's submit handler now collects every checked value via `FormData.getAll` instead of collapsing duplicate names to the last one. A `:radio` group captures a single value like `:input`. This is the only runtime change (shipped reactive runtime is 5836 B gzipped, still under the 6 KB budget); everything else is compile-time.
11
+ - Live demo on `/data/` (the inquiry form), and the AGENTS.md inline-`{.class}` note is corrected (it had stale "no `{.class}` syntax" copy from before 0.15.0).
12
+
13
+ ## 0.16.0 — 2026-06-23
14
+
15
+ Cross-document **view transitions** return as a per-page opt-in.
16
+
17
+ - **`transitions: true` frontmatter** emits the CSS-only `@view-transition { navigation: auto; }` rule, giving a smooth same-origin cross-fade on navigation with zero JavaScript. It only animates between same-origin pages that both opt in, and browsers without support silently skip the fade (no hang, no layout change). Default-off; opt out explicitly with `transitions: false`. Re-enabled after the original cross-document render-blocking regression (stalled rAF / hanging navigation on deployed pages) was verified resolved on modern Chrome. Compile-time only — no change to the shipped runtime size.
18
+
19
+ ## 0.15.0 — 2026-06-23
20
+
21
+ Form controls and inline styling reach the authoring layer.
22
+
23
+ - **`:textarea` and `:select` field directives.** Forms gain a multi-line `:textarea name [rows=N]` and a `:select name` (with `- Label` option lines). Both capture into `:form into` state exactly like `:input`, and derive a non-visual accessible name from their placeholder or humanized field name.
24
+ - **Inline attributes.** A trailing `{.class .class #id}` (no leading space) attaches classes / an id to the inline element directly before it — link, image, emphasis, or inline code — most often to style a link as a button without a wrapping container. It never collides with `{ name }` interpolation, which always begins with a name.
25
+ - **Interpolated link destinations.** `{ name }` now resolves inside markdown link and image destinations, so `@loop` / state values can data-drive an `href` or `src`.
26
+
5
27
  ## 0.14.1 — 2026-06-19
6
28
 
7
29
  Patch-release hardening for page assets, form actions, and release metadata.
package/README.md CHANGED
@@ -82,6 +82,8 @@ tags: [sales, revenue, "q1, q2"]
82
82
 
83
83
  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).
84
84
 
85
+ `transitions: true` opts a page into **cross-document view transitions** — a smooth same-origin cross-fade on navigation, emitted as the CSS-only `@view-transition { navigation: auto; }` rule. It ships zero JavaScript, only animates between same-origin pages that both opt in, and is silently ignored by browsers without support (the navigation just doesn't fade). Off by default; opt out explicitly with `transitions: false`.
86
+
85
87
  ## Static assets
86
88
 
87
89
  Images, fonts, icons, and other non-page files live on the include shelf at `site/_/` and are copied into the build untouched:
@@ -493,6 +495,12 @@ Fetched data and a form live happily on the same page:
493
495
  :select topic
494
496
  - General
495
497
  - Billing
498
+ :checkbox channels
499
+ - Email
500
+ - SMS
501
+ :radio plan
502
+ - Basic
503
+ - Pro
496
504
  :textarea note placeholder="Anything else?"
497
505
  :submit "Save"
498
506
  :endform
@@ -502,7 +510,7 @@ Fetched data and a form live happily on the same page:
502
510
 
503
511
  - `:form into name` captures submits straight into state (no backend). `:form action="/url"` emits a plain native form instead — zero JS, full progressive enhancement. Form actions use the same URL scheme guard as `:fetch`: relative paths, explicit `http(s)://`, or leading `{ state }` interpolation; protocol-relative and non-http(s) schemes are compile errors.
504
512
  - `: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.
505
- - Field directives: `:input`, `:textarea name [rows=N]`, and `:select name` (with `- Label` option lines) all capture into `:form into` state the same way. Each derives a non-visual `aria-label` from its placeholder, else a humanized field name, unless you supply `aria-label`/`aria-describedby`.
513
+ - 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`.
506
514
  - `: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.)
507
515
  - `:computed total = items.length * 4` derives state from state with a compile-time-checked expression (names, numbers, arithmetic, comparisons — nothing else).
508
516
  - `:if item.path` works inside reactive loops for per-row branches, and nests — an inner `:if` resolves after the outer branch and stays reactive.
@@ -33,7 +33,7 @@ This audit compares the current Darkmown implementation with the original MARROW
33
33
 
34
34
  ## Stage 4 additions (2026-06-11, same day)
35
35
 
36
- - **View transitions (currently disabled):** `transitions: true` in frontmatter was wired to emit `@view-transition { navigation: auto; }` — the original doc's zero-JS MPA navigation polish. **This is turned off as of now:** cross-document `@view-transition` render-blocked deployed pages (stalled rAF, hanging navigation), so `transitions` is hardcoded to `""` in `src/compiler.js`. The frontmatter key is accepted but has no effect today. Reintroduction is tracked pending proper activation fallbacks (see "Still missing from the full vision").
36
+ - **View transitions (re-enabled in 0.16.0, opt-in):** `transitions: true` in frontmatter emits `@view-transition { navigation: auto; }` — the original doc's zero-JS MPA navigation polish (a same-origin cross-fade on navigation). Previously pulled back because cross-document `@view-transition` render-blocked deployed pages (stalled rAF, hanging navigation); verified resolved on modern Chrome (CSS-only, same-origin, graceful no-support fallback, no nav hang), so it returns as a per-page opt-in. Default-off; opt out with `transitions: false`.
37
37
  - **Lazy loading:** `:fetch … when=visible` defers the request until the marker scrolls into view (IntersectionObserver) — the adapted form of `:lazy`.
38
38
  - **Computed state:** `:computed name = expr` with a compile-time-validated grammar (state refs, numbers, strings, arithmetic, comparisons; assignment, calls, and prototype walks rejected). Initial values are evaluated at build time.
39
39
  - **Dev self-reload:** `darkmown dev` rebuilds in a child process, so changes to the framework's own `src/` always compile with fresh modules.
@@ -79,6 +79,10 @@ Three capability gaps from the 2026-06-16 audit, closed together over a shared `
79
79
  - **Interpolation in link/image destinations:** `{ name.path }` now resolves inside a markdown destination — `[{ it.label }]({ it.url })`, `![{ p.alt }]({ p.src })` — so a build-time `@loop` can drive `href`/`src` and the page stays static. Only build-time (static-scope) values substitute; reactive `:state` is left untouched (it cannot live in a destination).
80
80
  - **Inline attributes (`{.class #id}`):** a trailing attribute block attaches classes / an id to the inline element before it (link, image, emphasis, code), so a link can be styled as a button without a wrapper container. The block must follow with no space; it never collides with `{ name }` interpolation (which always starts with a name, not `.`/`#`).
81
81
 
82
+ ## Stage 13 additions (2026-06-23, v0.17.0)
83
+
84
+ - **`: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.
85
+
82
86
  ## Stage 11 additions (2026-06-19, v0.14.1)
83
87
 
84
88
  - **Release hardening — page asset privacy:** page-colocated assets now honor the same hidden-path convention as routes. A file under `site/pages` is emitted only when no relative path segment starts with `.`, `-`, or `_`; symlinked page assets are skipped too. This preserves normal colocated media (`logo.svg`, `blog/cover.png`) while preventing accidental publication of `.env`, `_private/*`, `-draft/*`, or symlink-to-outside files.
@@ -103,7 +107,7 @@ The compiler recognizes three directives — `:note`, `:try`, and `:sprint` —
103
107
  ## Still missing from the full vision
104
108
 
105
109
  - First-party server runtime (`site/api/`), HTML-fragment `swap` semantics, and cart server sync — parked behind the adapter decision until a real project needs them.
106
- - **View transitions:** re-enabling `transitions: true` with proper activation fallbacks (disabled today — see Stage 4), plus `@starting-style` emission and browser fallbacks for older engines.
110
+ - **View transitions:** named/typed transitions (`view-transition-name`, per-element morphs) and `@starting-style` emission on top of the opt-in cross-document fade shipped in 0.16.0 (see Stage 4).
107
111
  - Richer diagnostics and package-consumer QA in regular CI. Editor tooling exists as a source-installable VS Code extension; public Marketplace/Open VSX distribution remains a launch/distribution follow-up.
108
112
 
109
113
  ## Current claim
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvndev/darkmown",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -150,7 +150,7 @@ Scroll me into view and I will load.
150
150
 
151
151
  ## Richer form controls
152
152
 
153
- `:textarea` and `:select` join `:input`, all captured into the same `:form into` state with no backend:
153
+ `:textarea`, `:select`, `:checkbox`, and `:radio` join `:input`, all captured into the same `:form into` state with no backend. A `:checkbox` group captures every checked value as an **array**; a `:radio` group captures a single value:
154
154
 
155
155
  :form into inquiry
156
156
  :input fullname placeholder="Your full name"
@@ -158,12 +158,18 @@ Scroll me into view and I will load.
158
158
  - General question
159
159
  - New case
160
160
  - Billing
161
+ :checkbox services
162
+ - Cleaning
163
+ - Whitening
164
+ :radio urgency
165
+ - Routine
166
+ - Urgent
161
167
  :textarea message placeholder="How can we help?"
162
168
  :submit "Send inquiry"
163
169
  :endform
164
170
 
165
171
  :if inquiry
166
- Thanks **{ inquiry.fullname }** — we logged your "{ inquiry.topic }" note.
172
+ Thanks **{ inquiry.fullname }** — we logged your "{ inquiry.topic }" note. Services: { inquiry.services }. Urgency: { inquiry.urgency }.
167
173
  :else
168
174
  Pick a topic and send — it is captured client-side, no backend.
169
175
  :endif
package/src/compiler.js CHANGED
@@ -134,10 +134,13 @@ export function compilePage(file, context) {
134
134
  const cssLinks = [...compiled.assets.skins].map((href) => `<link rel="stylesheet" href="${href}">`).join("\n");
135
135
  const scriptSrcs = compiled.assets.runtime ? ["/__wd/runtime.js", ...compiled.assets.scripts] : [...compiled.assets.scripts];
136
136
  const scripts = scriptSrcs.map((src) => `<script type="module" src="${src}"></script>`).join("\n");
137
- // View transitions are temporarily disabled: cross-document @view-transition
138
- // render-blocked deployed pages (rAF stalled, navigation hangs). Tracked for
139
- // reintroduction with proper activation fallbacks.
140
- const transitions = "";
137
+ // Cross-document view transitions: opt in per page with `transitions: true` in
138
+ // frontmatter to emit the CSS-only @view-transition rule — a smooth same-origin
139
+ // cross-fade on navigation, with zero JavaScript. Ignored by browsers without
140
+ // support (graceful fade-free fallback) and only applies to same-origin
141
+ // navigations where both the outgoing and incoming page opt in.
142
+ const wantTransitions = compiled.meta.transitions === true || compiled.meta.transitions === "true";
143
+ const transitions = wantTransitions ? `\n <style>@view-transition { navigation: auto; }</style>` : "";
141
144
 
142
145
  return {
143
146
  meta: compiled.meta,
@@ -410,6 +413,16 @@ function compileBody(lines, ctx) {
410
413
  i = j - 1;
411
414
  continue;
412
415
  }
416
+ const choiceMatch = line.match(/^:(checkbox|radio)\s/);
417
+ if (choiceMatch) {
418
+ flush();
419
+ const opts = [];
420
+ let j = i + 1;
421
+ while (j < lines.length && /^\s*-\s+/.test(lines[j])) { opts.push(lines[j]); j++; }
422
+ out.push(handleChoiceGroup(line, opts, ctx, /** @type {"checkbox" | "radio"} */ (choiceMatch[1])));
423
+ i = j - 1;
424
+ continue;
425
+ }
413
426
  if (/^:bind\s/.test(line)) {
414
427
  flush();
415
428
  out.push(handleBind(line, ctx));
@@ -1206,6 +1219,66 @@ function handleSelect(line, optionLines, ctx) {
1206
1219
  return `<select ${attrs.join(" ")}>${opts}</select>`;
1207
1220
  }
1208
1221
 
1222
+ /**
1223
+ * `:checkbox name [required]` / `:radio name [required]` followed by `- Label`
1224
+ * lines → a labelled group of `<input type=checkbox|radio>`, each sharing `name`
1225
+ * with value === label and wrapped in its own `<label>`. The group is a
1226
+ * `role="group"` / `role="radiogroup"` container with an aria-label (explicit or
1227
+ * humanized from the name). Checkbox groups are marked `data-wd-multi="name"` so
1228
+ * the runtime captures every checked value as an array (`FormData.getAll`); radio
1229
+ * groups capture a single value like `:input`. Flags: `required` (radio → the
1230
+ * group; not emitted on checkboxes, where "at least one" has no native HTML form),
1231
+ * `disabled` (whole group), `autofocus` (first control).
1232
+ * @param {string} line
1233
+ * @param {string[]} optionLines The following `- Label` lines consumed by dispatch.
1234
+ * @param {Ctx} ctx
1235
+ * @param {"checkbox" | "radio"} kind
1236
+ * @returns {string}
1237
+ */
1238
+ function handleChoiceGroup(line, optionLines, ctx, kind) {
1239
+ const directive = `:${kind}`;
1240
+ const match = line.match(kind === "checkbox" ? /^:checkbox\s+([A-Za-z_][\w-]*)\s*(.*)$/ : /^:radio\s+([A-Za-z_][\w-]*)\s*(.*)$/);
1241
+ if (!match) throw new Error(`Malformed ${directive} in ${ctx.file}: ${line}. Use: ${directive} name [required] then "- Label" lines`);
1242
+ const name = match[1];
1243
+ const flags = [];
1244
+ let ariaLabel = null;
1245
+ let ariaDescribedby = null;
1246
+ const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
1247
+ for (const token of (match[2] || "").matchAll(re)) {
1248
+ if (token[3]) {
1249
+ if (!["required", "disabled", "autofocus"].includes(token[3])) {
1250
+ throw new Error(`Unknown ${directive} flag "${token[3]}" in ${ctx.file}`);
1251
+ }
1252
+ flags.push(token[3]);
1253
+ continue;
1254
+ }
1255
+ const value = stripQuotes(token[2]);
1256
+ if (token[1] === "aria-label") ariaLabel = value;
1257
+ else if (token[1] === "aria-describedby") ariaDescribedby = value;
1258
+ else throw new Error(`Unknown ${directive} attribute "${token[1]}" in ${ctx.file}`);
1259
+ }
1260
+ const options = optionLines.map((l) => l.replace(/^\s*-\s+/, "").trim()).filter(Boolean);
1261
+ if (!options.length) {
1262
+ throw new Error(`${directive} "${name}" in ${ctx.file} has no options. Add "- Label" lines beneath it.`);
1263
+ }
1264
+ const isCheckbox = kind === "checkbox";
1265
+ const groupAttrs = [`class="wd-${isCheckbox ? "checkboxes" : "radios"}"`, `role="${isCheckbox ? "group" : "radiogroup"}"`];
1266
+ if (isCheckbox) groupAttrs.push(`data-wd-multi="${escapeHtml(name)}"`);
1267
+ groupAttrs.push(`aria-label="${escapeHtml(ariaLabel || humanizeName(name))}"`);
1268
+ if (ariaDescribedby) groupAttrs.push(`aria-describedby="${escapeHtml(ariaDescribedby)}"`);
1269
+ const disabled = flags.includes("disabled");
1270
+ const required = flags.includes("required");
1271
+ const autofocus = flags.includes("autofocus");
1272
+ const controls = options.map((opt, idx) => {
1273
+ const a = [`type="${kind}"`, `name="${escapeHtml(name)}"`, `value="${escapeHtml(opt)}"`];
1274
+ if (disabled) a.push("disabled");
1275
+ if (required && !isCheckbox) a.push("required");
1276
+ if (autofocus && idx === 0) a.push("autofocus");
1277
+ return `<label><input ${a.join(" ")}> ${escapeHtml(opt)}</label>`;
1278
+ }).join("");
1279
+ return `<div ${groupAttrs.join(" ")}>${controls}</div>`;
1280
+ }
1281
+
1209
1282
  /**
1210
1283
  * @param {string} line
1211
1284
  * @param {Ctx} ctx
package/src/runtime.js CHANGED
@@ -557,7 +557,14 @@ document.addEventListener("submit", (event) => {
557
557
  const action = form.getAttribute("action");
558
558
 
559
559
  if (!action) {
560
- state[key] = Object.fromEntries(new FormData(form));
560
+ const fd = new FormData(form);
561
+ const data = /** @type {Record<string, any>} */ (Object.fromEntries(fd));
562
+ // :checkbox groups share one name; collect every checked value as an array.
563
+ for (const group of form.querySelectorAll("[data-wd-multi]")) {
564
+ const field = group.getAttribute("data-wd-multi");
565
+ if (field) data[field] = fd.getAll(field);
566
+ }
567
+ state[key] = data;
561
568
  savePersisted();
562
569
  render();
563
570
  return;