@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.
- package/AGENTS.md +2 -0
- package/CHANGELOG.md +19 -0
- package/README.md +114 -1
- package/docs/spec-alignment.md +30 -0
- package/package.json +1 -1
- package/site/_/base.skin +231 -18
- package/site/_/nav.wd +1 -1
- package/site/_/scoped-promo.skin +43 -0
- package/site/_/scoped-promo.wd +10 -0
- package/site/_/scoped-stat.skin +32 -0
- package/site/_/scoped-stat.wd +9 -0
- package/site/pages/app.skin +27 -5
- package/site/pages/blog/_schema.wd +15 -0
- package/site/pages/blog/collections-and-pagination.md +38 -0
- package/site/pages/blog/drafts-that-never-leak.md +2 -0
- package/site/pages/blog/index.skin +144 -0
- package/site/pages/blog/index.wd +73 -0
- package/site/pages/blog/shipping-feeds.md +1 -0
- package/site/pages/blog/typed-frontmatter.md +45 -0
- package/site/pages/blog/zero-js-by-default.md +31 -0
- package/site/pages/data.skin +50 -9
- package/site/pages/docs/index.wd +70 -0
- package/site/pages/loops.skin +43 -6
- package/site/pages/reactive.skin +38 -6
- package/site/pages/scoped/index.skin +23 -0
- package/site/pages/scoped/index.wd +47 -0
- package/site/pages/store.skin +43 -6
- package/src/builder.js +213 -32
- package/src/compiler/collections.js +316 -0
- package/src/compiler/context.js +34 -2
- package/src/compiler/directives.js +11 -5
- package/src/compiler/includes.js +48 -1
- package/src/compiler/index.js +1 -1
- package/src/compiler/loops.js +130 -12
- package/src/compiler/page.js +93 -9
- package/src/compiler.js +2 -1
- package/src/skin.js +147 -3
- package/src/templates/blog/site/pages/index.skin +84 -0
- package/src/templates/dashboard/site/pages/index.skin +84 -0
- package/src/templates/landing/site/pages/index.skin +113 -6
- package/src/templates/starter/site/pages/index.skin +81 -3
- package/src/templates/store/site/pages/index.skin +93 -11
- 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:
|
package/docs/spec-alignment.md
CHANGED
|
@@ -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
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 .
|
|
167
|
-
font-
|
|
168
|
-
font-
|
|
169
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
.
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
197
|
-
bg
|
|
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>
|