@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.
@@ -15,6 +15,7 @@ import {
15
15
  escapeHtml,
16
16
  getPath,
17
17
  lookupPath,
18
+ lookupVar,
18
19
  resolveStateKey,
19
20
  stripQuotes,
20
21
  unescapeHtml
@@ -30,15 +31,15 @@ import { compilePredicate, evalPredicate } from "./predicates.js";
30
31
 
31
32
  // The fixed corrective suggestion shown for any malformed @loop header.
32
33
  const LOOP_USAGE =
33
- "Use: @loop src into item [where …] [sort by …] [reverse] [offset N] [limit N] [sortable]";
34
+ "Use: @loop src into item [where …] [sort by …] [reverse] [offset N] [limit N] [paginate N] [sortable]";
34
35
 
35
36
  /**
36
37
  * Parse the optional clause tail of a `@loop` header in FIXED order:
37
- * `[where P] [sort by key [asc|desc]] [reverse] [offset N] [limit N] [sortable]`.
38
+ * `[where P] [sort by key [asc|desc]] [reverse] [offset N] [limit N] [paginate N] [sortable]`.
38
39
  * @param {string} tail Everything after `@loop src into item`.
39
40
  * @param {string} itemName
40
41
  * @param {Ctx} ctx
41
- * @returns {{ where: string|null, sort: import("./context.js").LoopOpts["sort"], reverse: boolean, offset: NumArg|null, limit: NumArg|null, refsState: boolean, sortable: boolean }}
42
+ * @returns {{ where: string|null, sort: import("./context.js").LoopOpts["sort"], reverse: boolean, offset: NumArg|null, limit: NumArg|null, refsState: boolean, sortable: boolean, paginate: number|null }}
42
43
  */
43
44
  function parseLoopClauses(tail, itemName, ctx) {
44
45
  // Peel clauses off the END in reverse fixed-order (limit, offset, reverse,
@@ -53,6 +54,7 @@ function parseLoopClauses(tail, itemName, ctx) {
53
54
  /** @type {NumArg|null} */ let limit = null;
54
55
  let refsState = false;
55
56
  let sortable = false;
57
+ /** @type {number|null} */ let paginate = null;
56
58
  /** @returns {never} */
57
59
  const bad = () => {
58
60
  throw new Error(`Malformed @loop clause in ${ctx.file}: "${tail.trim()}". ${LOOP_USAGE}`);
@@ -69,6 +71,20 @@ function parseLoopClauses(tail, itemName, ctx) {
69
71
  .trim();
70
72
  }
71
73
 
74
+ // `paginate N` slices the listing into static pages. Peel it position-
75
+ // independently (like `sortable`) so `paginate 2 limit 1` parses and handleLoop
76
+ // can reject the combination with a precise message rather than failing the
77
+ // fixed-order parse. The count is a positive integer literal — paginating "by a
78
+ // state value" makes no sense for a build-time route split.
79
+ const pm = s.match(/(^|\s)paginate\s+(\d+)(?=\s|$)/);
80
+ if (pm) {
81
+ paginate = Number(pm[2]);
82
+ if (paginate < 1) bad();
83
+ s = (s.slice(0, pm.index) + s.slice((pm.index ?? 0) + pm[0].length))
84
+ .replace(/\s+/g, " ")
85
+ .trim();
86
+ }
87
+
72
88
  let m = s.match(/(^|\s)limit\s+(\d+|[A-Za-z_$][\w$]*)$/);
73
89
  if (m) {
74
90
  limit = parseNumArg(m[2], ctx);
@@ -109,7 +125,7 @@ function parseLoopClauses(tail, itemName, ctx) {
109
125
  // predicate means a clause was written before `where` finished (wrong order).
110
126
  if (/(^|\s)(sort\s+by\s|reverse(\s|$))/.test(where)) bad();
111
127
  }
112
- return { where, sort, reverse, offset, limit, refsState, sortable };
128
+ return { where, sort, reverse, offset, limit, refsState, sortable, paginate };
113
129
  }
114
130
 
115
131
  /**
@@ -213,18 +229,32 @@ export function handleLoop(line, bodyLines, emptyLines, ctx) {
213
229
  offset: null,
214
230
  limit: null,
215
231
  refsState: false,
216
- sortable: false
232
+ sortable: false,
233
+ paginate: null
217
234
  };
218
235
  const where = clauses.where ? compilePredicate(clauses.where, itemName, ctx) : null;
219
236
  // `sortable` reorders the list directly, so it can't combine with a clause that
220
237
  // re-derives the visible order (where/sort/reverse) or shows a partial view
221
- // (offset/limit) — those would break the 1:1 DOM↔array index mapping.
238
+ // (offset/limit/paginate) — those would break the 1:1 DOM↔array index mapping.
222
239
  if (
223
240
  clauses.sortable &&
224
- (where || clauses.sort || clauses.reverse || clauses.offset || clauses.limit)
241
+ (where ||
242
+ clauses.sort ||
243
+ clauses.reverse ||
244
+ clauses.offset ||
245
+ clauses.limit ||
246
+ clauses.paginate)
225
247
  ) {
226
248
  throw new Error(
227
- `@loop sortable cannot combine with where/sort/reverse/offset/limit in ${ctx.file}. ${LOOP_USAGE}`
249
+ `@loop sortable cannot combine with where/sort/reverse/offset/limit/paginate in ${ctx.file}. ${LOOP_USAGE}`
250
+ );
251
+ }
252
+ // `paginate N` OWNS the page slice, so an explicit offset/limit alongside it is
253
+ // a conflict (which one wins?). Reject it with a clear hint rather than silently
254
+ // letting one override the other.
255
+ if (clauses.paginate && (clauses.offset || clauses.limit)) {
256
+ throw new Error(
257
+ `@loop paginate cannot combine with offset/limit in ${ctx.file} — paginate already slices each page. ${LOOP_USAGE}`
228
258
  );
229
259
  }
230
260
  /** @type {LoopOpts} */
@@ -236,7 +266,8 @@ export function handleLoop(line, bodyLines, emptyLines, ctx) {
236
266
  limit: clauses.limit,
237
267
  empty: emptyLines,
238
268
  clauseRefsState: clauses.refsState,
239
- sortable: clauses.sortable
269
+ sortable: clauses.sortable,
270
+ paginate: clauses.paginate
240
271
  };
241
272
 
242
273
  if (
@@ -245,6 +276,7 @@ export function handleLoop(line, bodyLines, emptyLines, ctx) {
245
276
  source.startsWith("../") ||
246
277
  source.endsWith(".json")
247
278
  ) {
279
+ if (opts.paginate) throw new Error(paginateOnlyCollections(ctx));
248
280
  const dataFile = resolveInclude(source, ctx.file, ctx.context, true);
249
281
  const rows = JSON.parse(fs.readFileSync(dataFile, "utf8"));
250
282
  if (!Array.isArray(rows)) throw new Error(`@loop data must be a JSON array: ${dataFile}`);
@@ -253,6 +285,7 @@ export function handleLoop(line, bodyLines, emptyLines, ctx) {
253
285
 
254
286
  const resolved = lookupPath(source, ctx);
255
287
  if (resolved.found) {
288
+ if (opts.paginate) throw new Error(paginateOnlyCollections(ctx));
256
289
  if (!Array.isArray(resolved.value)) {
257
290
  throw new Error(
258
291
  `@loop ${source} in ${ctx.file} found an in-scope value, but it is not a list`
@@ -269,9 +302,15 @@ export function handleLoop(line, bodyLines, emptyLines, ctx) {
269
302
  }
270
303
  const key = resolveStateKey(segs[0], ctx);
271
304
  if (key) {
305
+ if (opts.paginate) throw new Error(paginateOnlyCollections(ctx));
272
306
  const fullKey = segs.length > 1 ? `${key}.${segs.slice(1).join(".")}` : key;
273
307
  return reactiveLoop(fullKey, itemName, bodyLines, ctx, opts);
274
308
  }
309
+ // A bare name matching a collection (any `site/pages/<name>/` subdir) resolves
310
+ // to its entry rows at build time and static-unrolls — zero JS. This is checked
311
+ // after state so a declared `:state` of the same name still wins.
312
+ const collection = segs.length === 1 ? ctx.comp.collections.get(source) : undefined;
313
+ if (collection) return loopOverCollection(collection, source, itemName, bodyLines, ctx, opts);
275
314
  // Inside a reactive loop, `@loop <outerItem>.<path> into x` loops a field of
276
315
  // the enclosing row item — an ITEM-RELATIVE loop the runtime fills per row.
277
316
  if (ctx.loopItem && segs[0] === ctx.loopItem && segs.length > 1) {
@@ -280,15 +319,93 @@ export function handleLoop(line, bodyLines, emptyLines, ctx) {
280
319
  `@loop sortable is not supported on a nested (item-relative) loop in ${ctx.file}. ${LOOP_USAGE}`
281
320
  );
282
321
  }
322
+ if (opts.paginate) throw new Error(paginateOnlyCollections(ctx));
283
323
  return itemRelativeLoop(segs.slice(1).join("."), itemName, bodyLines, ctx, opts);
284
324
  }
285
325
  }
286
326
 
287
- throw new Error(
288
- `@loop source "${source}" in ${ctx.file} was not found. Loop over a JSON file (@loop /data.json into row), an in-scope value, or a :state list.`
327
+ throw new Error(unresolvedSourceError(source, ctx));
328
+ }
329
+
330
+ /**
331
+ * The "source not found" error for an unresolved `@loop` bare name. Lists the
332
+ * valid collection names (the `site/pages/<name>/` subdirs) so a typo'd
333
+ * collection reference is immediately actionable.
334
+ * @param {string} source
335
+ * @param {Ctx} ctx
336
+ * @returns {string}
337
+ */
338
+ function unresolvedSourceError(source, ctx) {
339
+ const names = [...ctx.comp.collections.keys()].sort();
340
+ const collectionsHint = names.length ? ` Available collections: ${names.join(", ")}.` : "";
341
+ return (
342
+ `@loop source "${source}" in ${ctx.file} was not found. Loop over a collection ` +
343
+ `(a site/pages/<name>/ subdirectory, by its bare name), a JSON file ` +
344
+ `(@loop /data.json into row), an in-scope value, or a :state list.${collectionsHint}`
289
345
  );
290
346
  }
291
347
 
348
+ /**
349
+ * The error for `paginate` on a non-collection source: pagination multiplies
350
+ * static routes, which only makes sense for a build-time collection listing.
351
+ * @param {Ctx} ctx
352
+ * @returns {string}
353
+ */
354
+ function paginateOnlyCollections(ctx) {
355
+ return `@loop paginate requires a collection source (a site/pages/<name>/ subdirectory) in ${ctx.file}. ${LOOP_USAGE}`;
356
+ }
357
+
358
+ /**
359
+ * Loop over a build-time collection's entry rows. With no `paginate`, this is a
360
+ * plain static unroll (filter/sort/reverse/offset/limit at build time, zero JS).
361
+ * With `paginate N`, it records the pagination intent (so the builder multiplies
362
+ * routes), then renders only the current page's slice using the injected `page`
363
+ * pager — `offset = (page.current - 1) * N`, `limit = N`.
364
+ * @param {import("./collections.js").CollectionRow[]} rows
365
+ * @param {string} name Collection name, for the pagination record.
366
+ * @param {string} itemName
367
+ * @param {string[]} bodyLines
368
+ * @param {Ctx} ctx
369
+ * @param {LoopOpts} opts
370
+ * @returns {string}
371
+ */
372
+ function loopOverCollection(rows, name, itemName, bodyLines, ctx, opts) {
373
+ if (!opts.paginate) {
374
+ // No pagination → behave exactly like a JSON-file/in-scope source: a pure
375
+ // listing static-unrolls (zero JS); a `where` that reads :state turns it into
376
+ // a reactive baked-rows loop. `loopOverData` makes that call.
377
+ return loopOverData(rows, itemName, bodyLines, ctx, opts);
378
+ }
379
+ // Count rows AFTER where/sort/reverse but BEFORE the page slice, so `total`
380
+ // reflects what the reader will actually page through. Reuse pipelineRows with
381
+ // offset/limit stripped for the count.
382
+ const filtered = pipelineRows(rows, opts.where, { ...opts, offset: null, limit: null }, ctx);
383
+ const perPage = opts.paginate;
384
+ const total = Math.max(1, Math.ceil(filtered.length / perPage));
385
+ ctx.comp.pagination = { perPage, total, collection: name };
386
+ // `page.current` is the 1-based page the builder is rendering (it seeds the
387
+ // document scope). Slice this page out of the already-filtered/sorted list.
388
+ const current = currentPage(ctx);
389
+ const start = (current - 1) * perPage;
390
+ return staticUnroll(filtered.slice(start, start + perPage), itemName, bodyLines, ctx, opts.empty);
391
+ }
392
+
393
+ /**
394
+ * The 1-based page the builder is currently rendering, read from the injected
395
+ * `page.current` scope var. Defaults to page 1 (the discovery compile, before the
396
+ * builder knows the total, runs with `page.current = 1`).
397
+ * @param {Ctx} ctx
398
+ * @returns {number}
399
+ */
400
+ function currentPage(ctx) {
401
+ const page = lookupVar(ctx.scope, "page");
402
+ if (page.found && page.value && typeof page.value === "object") {
403
+ const current = /** @type {Record<string, unknown>} */ (page.value).current;
404
+ if (typeof current === "number" && current >= 1) return current;
405
+ }
406
+ return 1;
407
+ }
408
+
292
409
  // A static source (JSON file / in-scope value). No `where`, or a `where` that
293
410
  // only reads the loop item → filter at build time and stay zero-JS. A `where`
294
411
  // that reads :state → becomes a reactive filtered loop with the rows baked in.
@@ -440,7 +557,8 @@ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
440
557
  : limit,
441
558
  empty: null,
442
559
  clauseRefsState: false,
443
- sortable: false
560
+ sortable: false,
561
+ paginate: null
444
562
  },
445
563
  ctx
446
564
  );
@@ -30,13 +30,16 @@ import { selectMd } from "./markdown.js";
30
30
  * Compile a page source file into a full HTML document plus its assets.
31
31
  * @param {string} file Absolute path to the source `.md`/`.wd` file.
32
32
  * @param {Paths} context Resolved project paths.
33
- * @param {{ feed?: { href: string, title: string } }} [options] When a site-wide
34
- * RSS feed is emitted (the home page set `site_url` and the site has dated
35
- * posts), `feed` carries its absolute href + title so every page links it.
33
+ * @param {{ feed?: { href: string, title: string }, collections?: Map<string, import("./collections.js").CollectionRow[]>, vars?: Record<string, unknown> }} [options]
34
+ * When a site-wide RSS feed is emitted (the home page set `site_url` and the
35
+ * site has dated posts), `feed` carries its absolute href + title so every page
36
+ * links it. `collections` is the build-time collection index a bare-name
37
+ * `@loop` resolves against; `vars` seeds the document scope (the pager `page`
38
+ * object on a paginated route).
36
39
  * @returns {CompiledPage}
37
40
  */
38
41
  export function compilePage(file, context, options = {}) {
39
- const compiled = compileDocument(file, context);
42
+ const compiled = compileDocument(file, context, [], options.vars, options.collections);
40
43
  const title = compiled.meta.title || "Darkmown";
41
44
  const description = compiled.meta.description || "";
42
45
  // Optional `image:` frontmatter sets the social-share preview (absolute URL).
@@ -158,7 +161,8 @@ ${scripts}
158
161
  </body>
159
162
  </html>`,
160
163
  assets: compiled.assets,
161
- warnings: compiled.warnings
164
+ warnings: compiled.warnings,
165
+ pagination: compiled.pagination
162
166
  };
163
167
  }
164
168
 
@@ -199,28 +203,68 @@ export function enhanceImages(html, paths) {
199
203
  });
200
204
  }
201
205
 
206
+ /**
207
+ * Raw-text (`script`, `style`) and escapable-raw-text (`textarea`, `title`)
208
+ * elements per the HTML spec: their CONTENT is not markup, so `<`/`>` inside them
209
+ * are plain characters and must never be treated as tags. The content runs to the
210
+ * first matching close tag.
211
+ * @type {Set<string>}
212
+ */
213
+ const RAW_TEXT_ELEMENTS = new Set(["script", "style", "textarea", "title"]);
214
+
202
215
  /**
203
216
  * Stamp `data-wd-scope="<id>"` onto every opening tag of a subtree — the HTML
204
- * half of compile-time scoped styles, mirroring {@link enhanceImages}: a regex
205
- * post-pass over assembled HTML, zero runtime JS. Every element start tag that
206
- * doesn't already carry a scope attribute gets one, so the scoped stylesheet
207
- * (whose selectors end in `[data-wd-scope="<id>"]`) matches inside this subtree
208
- * and nowhere else. Void/self-closing tags are stamped too (the attribute is
209
- * valid on any element). Closing tags, comments, doctype, and the `<!`/`<?`
210
- * families are left alone. A tag that ALREADY has a `data-wd-scope` (e.g. a
211
- * nested include with its own scope) keeps its own — first stamp wins.
217
+ * half of compile-time scoped styles, mirroring {@link enhanceImages}: a
218
+ * compile-time post-pass over assembled HTML, zero runtime JS. Every element
219
+ * start tag that doesn't already carry a scope attribute gets one, so the scoped
220
+ * stylesheet (whose selectors end in `[data-wd-scope="<id>"]`) matches inside
221
+ * this subtree and nowhere else. Void/self-closing tags are stamped too (the
222
+ * attribute is valid on any element). Closing tags, comments, doctype, and the
223
+ * `<!`/`<?` families are left alone. A tag that ALREADY has a `data-wd-scope`
224
+ * (e.g. a nested include with its own scope) keeps its own — first stamp wins.
225
+ *
226
+ * Raw-text / escapable-raw-text elements (`script`, `style`, `textarea`,
227
+ * `title`) are special-cased: their OPENING tag is stamped (valid), but their
228
+ * BODY is left byte-intact — `<`/`>` inside a `<script>` are JavaScript, not
229
+ * markup, so stamping there would corrupt the content. A small tokenizer walks
230
+ * tag-by-tag (quote-aware in attribute lists) and, on entering a raw-text
231
+ * element, copies through verbatim until the matching close tag.
212
232
  * @param {string} html Subtree HTML to scope.
213
233
  * @param {string} scopeId Scope id (from {@link import("../skin.js").scopeIdFor}).
214
234
  * @returns {string}
215
235
  */
216
236
  export function stampScope(html, scopeId) {
217
- return html.replace(
218
- /<([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g,
219
- (tag, name, attrs, slash) => {
220
- if (/\bdata-wd-scope\s*=/.test(attrs)) return tag;
221
- return `<${name}${attrs} data-wd-scope="${scopeId}"${slash ? " /" : ""}>`;
237
+ // Opening tag: name + (quote-aware) attribute list + optional self-close slash.
238
+ const openTag = /<([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g;
239
+ let out = "";
240
+ let cursor = 0;
241
+ for (let m = openTag.exec(html); m; m = openTag.exec(html)) {
242
+ const [tag, name, attrs, slash] = m;
243
+ out += html.slice(cursor, m.index); // text/markup before this opening tag
244
+ const stamped = /\bdata-wd-scope\s*=/.test(attrs)
245
+ ? tag // first stamp wins — a pre-stamped tag keeps its own scope
246
+ : `<${name}${attrs} data-wd-scope="${scopeId}"${slash ? " /" : ""}>`;
247
+ out += stamped;
248
+ cursor = m.index + tag.length;
249
+
250
+ // Raw-text element (not self-closed): copy its body through untouched and
251
+ // resume scanning after the matching close tag, so nothing inside is stamped.
252
+ if (!slash && RAW_TEXT_ELEMENTS.has(name.toLowerCase())) {
253
+ const rest = html.slice(cursor);
254
+ const closeMatch = new RegExp(`</${name}\\s*>`, "i").exec(rest);
255
+ if (!closeMatch) {
256
+ out += rest; // unterminated raw-text element — copy the remainder verbatim
257
+ cursor = html.length;
258
+ break;
259
+ }
260
+ const through = closeMatch.index + closeMatch[0].length; // body + close tag
261
+ out += rest.slice(0, through); // byte-intact
262
+ cursor += through;
263
+ openTag.lastIndex = cursor; // skip the body the scanner would otherwise re-walk
222
264
  }
223
- );
265
+ }
266
+ out += html.slice(cursor); // trailing text after the last opening tag
267
+ return out;
224
268
  }
225
269
 
226
270
  /**
@@ -266,12 +310,21 @@ function measureImage(src, paths) {
266
310
  * @param {Paths} context Resolved project paths.
267
311
  * @param {string[]} [stack] Include stack for cycle detection.
268
312
  * @param {Record<string, unknown>} [vars] Initial static scope variables.
313
+ * @param {Map<string, import("./collections.js").CollectionRow[]>} [collections]
314
+ * Build-time collection index a bare-name `@loop` resolves against.
269
315
  * @returns {CompiledDocument}
270
316
  */
271
- export function compileDocument(file, context, stack = [], vars = {}) {
317
+ export function compileDocument(file, context, stack = [], vars = {}, collections = new Map()) {
272
318
  const comp = createCompilation();
319
+ comp.collections = collections;
273
320
  const result = compileFile(file, context, stack, createScope(null, vars), comp, [], null);
274
- return { meta: result.meta, html: result.html, assets: comp.assets, warnings: comp.warnings };
321
+ return {
322
+ meta: result.meta,
323
+ html: result.html,
324
+ assets: comp.assets,
325
+ warnings: comp.warnings,
326
+ pagination: comp.pagination
327
+ };
275
328
  }
276
329
 
277
330
  /**
@@ -1,8 +1,23 @@
1
1
  tokens
2
2
  ink #1d2530
3
3
  paper #fbfaf7
4
+ panel #ffffff
5
+ line #e7e3da
4
6
  muted #6b7785
5
7
  accent #2f6df0
8
+ accent-deep #2356cc
9
+ ring #2f6df0
10
+ radius 10px
11
+
12
+ tokens dark
13
+ ink #e7ecf3
14
+ paper #0f141b
15
+ panel #161d27
16
+ line #28303c
17
+ muted #93a0b2
18
+ accent #5b8cff
19
+ accent-deep #80a6ff
20
+ ring #5b8cff
6
21
 
7
22
  page
8
23
  margin 0
@@ -10,6 +25,7 @@ page
10
25
  color $ink
11
26
  bg $paper
12
27
  line-height 1.6
28
+ -webkit-font-smoothing antialiased
13
29
 
14
30
  main
15
31
  max-width 720px
@@ -49,3 +65,71 @@ nav a
49
65
  margin 0 0 .5rem
50
66
  color $muted
51
67
  font-size .9rem
68
+
69
+ /* Buttons & form controls — for newsletter signups, :button actions, etc. */
70
+ button
71
+ display inline-flex
72
+ align-items center
73
+ justify-content center
74
+ gap .5rem
75
+ bg $accent
76
+ color white
77
+ border 1px solid $accent
78
+ padding .6rem 1.15rem
79
+ margin .4rem .5rem .4rem 0
80
+ radius 999px
81
+ font-size .94rem
82
+ font-weight 560
83
+ line-height 1.15
84
+ cursor pointer
85
+ appearance none
86
+ box-shadow 0 1px 2px rgba(29,37,48,.08)
87
+ transition transform .14s cubic-bezier(.2,.7,.3,1), box-shadow .14s ease, background .14s ease, border-color .14s ease
88
+
89
+ button:hover
90
+ bg $accent-deep
91
+ border-color $accent-deep
92
+ transform translateY(-1px)
93
+ box-shadow 0 8px 18px -8px rgba(47,109,240,.55)
94
+
95
+ button:active
96
+ transform translateY(0)
97
+ box-shadow 0 1px 2px rgba(29,37,48,.08)
98
+
99
+ button:focus-visible
100
+ outline 2px solid $ring
101
+ outline-offset 2px
102
+
103
+ button:disabled
104
+ opacity .5
105
+ cursor not-allowed
106
+ box-shadow none
107
+ transform none
108
+
109
+ input, textarea, select
110
+ font inherit
111
+ font-size .95rem
112
+ color $ink
113
+ bg $panel
114
+ border 1px solid $line
115
+ radius $radius
116
+ padding .58rem .8rem
117
+ margin .35rem 0
118
+ width 100%
119
+ max-width 24rem
120
+ appearance none
121
+ transition border-color .14s ease, box-shadow .14s ease
122
+
123
+ input:hover, textarea:hover, select:hover
124
+ border-color $accent
125
+
126
+ input:focus-visible, textarea:focus-visible, select:focus-visible
127
+ outline none
128
+ border-color $accent
129
+ box-shadow 0 0 0 3px rgba(47,109,240,.18)
130
+
131
+ @media (prefers-reduced-motion: reduce)
132
+ button
133
+ transition none
134
+ button:hover
135
+ transform none
@@ -2,9 +2,13 @@ tokens
2
2
  ink #0f1722
3
3
  paper #0b1018
4
4
  panel #131c28
5
+ field-bg #0e1620
5
6
  line #243245
6
7
  muted #8aa0b8
7
8
  accent #4f9cff
9
+ accent-deep #6fb0ff
10
+ ring #4f9cff
11
+ radius 12px
8
12
 
9
13
  page
10
14
  margin 0
@@ -12,6 +16,7 @@ page
12
16
  color #e7eefc
13
17
  bg $paper
14
18
  line-height 1.55
19
+ -webkit-font-smoothing antialiased
15
20
 
16
21
  main
17
22
  max-width 880px
@@ -72,3 +77,82 @@ nav a
72
77
  color $muted
73
78
  font-size .85rem
74
79
  margin-top .5rem
80
+
81
+ /* Buttons & controls — refresh, filters, range pickers, etc. */
82
+ button
83
+ display inline-flex
84
+ align-items center
85
+ justify-content center
86
+ gap .5rem
87
+ bg $accent
88
+ color #07101d
89
+ border 1px solid $accent
90
+ padding .55rem 1.05rem
91
+ margin .4rem .5rem .4rem 0
92
+ radius 999px
93
+ font-size .92rem
94
+ font-weight 600
95
+ line-height 1.15
96
+ cursor pointer
97
+ appearance none
98
+ transition transform .14s cubic-bezier(.2,.7,.3,1), box-shadow .14s ease, background .14s ease, border-color .14s ease
99
+
100
+ button:hover
101
+ bg $accent-deep
102
+ border-color $accent-deep
103
+ transform translateY(-1px)
104
+ box-shadow 0 8px 20px -8px rgba(79,156,255,.6)
105
+
106
+ button:active
107
+ transform translateY(0)
108
+ box-shadow none
109
+
110
+ button:focus-visible
111
+ outline 2px solid $ring
112
+ outline-offset 2px
113
+
114
+ button:disabled
115
+ opacity .5
116
+ cursor not-allowed
117
+ transform none
118
+
119
+ button.ghost
120
+ bg transparent
121
+ color #e7eefc
122
+ border-color $line
123
+
124
+ button.ghost:hover
125
+ bg $panel
126
+ border-color $accent
127
+ box-shadow none
128
+
129
+ input, select, textarea
130
+ font inherit
131
+ font-size .95rem
132
+ color #e7eefc
133
+ bg $field-bg
134
+ border 1px solid $line
135
+ radius 10px
136
+ padding .55rem .8rem
137
+ margin .35rem 0
138
+ width 100%
139
+ max-width 24rem
140
+ appearance none
141
+ transition border-color .14s ease, box-shadow .14s ease
142
+
143
+ input::placeholder, textarea::placeholder
144
+ color $muted
145
+
146
+ input:hover, select:hover, textarea:hover
147
+ border-color #2f4763
148
+
149
+ input:focus-visible, select:focus-visible, textarea:focus-visible
150
+ outline none
151
+ border-color $accent
152
+ box-shadow 0 0 0 3px rgba(79,156,255,.22)
153
+
154
+ @media (prefers-reduced-motion: reduce)
155
+ button
156
+ transition none
157
+ button:hover
158
+ transform none