@cfbender/cesium 0.3.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.3.6 — 2026-05-11
4
+
5
+ Adds a periodic-table-themed favicon for the cesium HTTP server.
6
+
7
+ - **feat:** Element-55 ("Cs") periodic-table tile favicon in the claret-dark
8
+ palette. Source SVG at `assets/favicon.svg`.
9
+ - **feat:** `writeFaviconSvg` drops `<stateDir>/favicon.svg` next to
10
+ `theme.css` on every publish/ask (and on `cesium theme apply`). The static
11
+ HTTP server then serves it at `/favicon.svg`.
12
+ - **feat:** `<link rel="icon" type="image/svg+xml">` is now emitted in
13
+ artifact pages, project index pages, and the global index page (paths
14
+ derived from the existing theme.css href so suppression behavior matches).
15
+ - **feat:** Inline favicon emblem rendered next to the "cesium" / "cesium ·
16
+ project" eyebrow on both index pages.
17
+ - **feat:** `/favicon.ico` shim — server pre-handler serves the SVG bytes
18
+ with `image/svg+xml` content type so browsers that auto-request the legacy
19
+ `.ico` path don't see a 404.
20
+
3
21
  ## v0.3.5 — 2026-05-11
4
22
 
5
23
  Fixes the `Publish to npm` GitHub Action.
package/README.md CHANGED
@@ -1,4 +1,7 @@
1
- # Cesium
1
+ <h1>
2
+ <img src="assets/favicon.svg" alt="" width="48" height="48" align="left" style="margin-right: 12px; vertical-align: middle;">
3
+ Cesium
4
+ </h1>
2
5
 
3
6
  Cesium publishes substantive opencode agent responses — plans, code reviews,
4
7
  comparisons, explainers, audits, RFCs — as self-contained beautiful HTML artifacts
@@ -6,10 +9,6 @@ on disk, instead of dumping markdown into the terminal. The browser becomes the
6
9
  reading surface; the terminal stays the control surface. Each artifact is a single
7
10
  `.html` file: portable, archivable, viewable offline, shareable as a URL over SSH.
8
11
 
9
- v0.3.0 adds **interactive Q&A artifacts** — the agent can now publish a question
10
- form, wait for the user to answer in their browser, and receive the structured
11
- responses before continuing work.
12
-
13
12
  <video src="assets/cesium.mp4" autoplay loop muted playsinline width="720">
14
13
  Demo video — see <a href="assets/cesium.mp4">assets/cesium.mp4</a> if it
15
14
  doesn't play inline (some markdown viewers strip <code>&lt;video&gt;</code>).
@@ -51,19 +50,37 @@ unreleased changes).
51
50
 
52
51
  ### CLI
53
52
 
53
+ The CLI puts a `cesium` binary on your `PATH` for browsing, opening, and
54
+ managing artifacts (`cesium ls`, `cesium open`, `cesium serve`, `cesium prune`,
55
+ `cesium theme`).
56
+
57
+ **Recommended: install with [mise](https://mise.jdx.dev/)** so cesium is pinned
58
+ in your config and tracks with the rest of your toolchain. Add to your
59
+ `~/.config/mise/config.toml` (or a project-local `mise.toml`):
60
+
61
+ ```toml
62
+ [tools]
63
+ "npm:@cfbender/cesium" = "latest"
64
+ ```
65
+
66
+ Then run `mise install` (or `mise use -g npm:@cfbender/cesium@latest` for the
67
+ one-liner equivalent). Pin to a specific release with `"0.3.6"` instead of
68
+ `"latest"`. Upgrade with `mise upgrade npm:@cfbender/cesium`.
69
+
70
+ **Alternative: install with bun directly:**
71
+
54
72
  ```bash
55
73
  bun install -g @cfbender/cesium
56
74
  ```
57
75
 
58
- This puts a `cesium` binary on your `PATH` (at `~/.bun/bin/cesium`). If
59
- `which cesium` returns nothing, add `~/.bun/bin` to your shell rc:
76
+ This puts the binary at `~/.bun/bin/cesium`. If `which cesium` returns nothing,
77
+ add `~/.bun/bin` to your shell rc:
60
78
 
61
79
  ```bash
62
80
  export PATH="$HOME/.bun/bin:$PATH"
63
81
  ```
64
82
 
65
- Upgrade later with `bun update -g @cfbender/cesium` (or via your version
66
- manager — e.g. `mise use -g npm:@cfbender/cesium@latest`). To uninstall:
83
+ Upgrade with `bun update -g @cfbender/cesium`. Uninstall with
67
84
  `bun remove -g @cfbender/cesium`.
68
85
 
69
86
  ### Developing on cesium itself
@@ -329,6 +329,89 @@
329
329
  color: var(--muted);
330
330
  text-transform: lowercase;
331
331
  }
332
+ .pill.accent {
333
+ background: color-mix(in srgb, var(--accent) 18%, var(--surface));
334
+ color: var(--accent);
335
+ font-weight: 600;
336
+ }
337
+
338
+ /* ranked list */
339
+ .ranked-list {
340
+ display: flex;
341
+ flex-direction: column;
342
+ gap: 1em;
343
+ margin: 0 0 1.5em;
344
+ padding: 0;
345
+ list-style: none;
346
+ }
347
+ .ranked-item {
348
+ background: var(--surface);
349
+ border: 1.5px solid var(--rule);
350
+ border-radius: 12px;
351
+ padding: 22px 26px;
352
+ display: grid;
353
+ grid-template-columns: 64px 1fr;
354
+ gap: 6px 24px;
355
+ align-items: start;
356
+ }
357
+ .ranked-item .rank-num {
358
+ font-family: var(--serif);
359
+ font-size: 2.4rem;
360
+ font-weight: 500;
361
+ color: var(--oat);
362
+ line-height: 1;
363
+ letter-spacing: -0.02em;
364
+ padding-top: 2px;
365
+ }
366
+ .ranked-item .rank-title {
367
+ font-family: var(--serif);
368
+ font-size: 1.2rem;
369
+ font-weight: 600;
370
+ color: var(--ink);
371
+ margin: 0 0 6px;
372
+ line-height: 1.3;
373
+ }
374
+ .ranked-item .rank-meta {
375
+ display: flex;
376
+ align-items: center;
377
+ gap: 8px;
378
+ flex-wrap: wrap;
379
+ margin-bottom: 14px;
380
+ }
381
+ .ranked-item .rank-body > p {
382
+ color: var(--ink-soft);
383
+ font-size: 0.95rem;
384
+ line-height: 1.65;
385
+ margin: 0 0 0.85em;
386
+ }
387
+ .ranked-item .rank-body > p:last-child {
388
+ margin-bottom: 0;
389
+ }
390
+ .ranked-item .rank-body > ul,
391
+ .ranked-item .rank-body > ol {
392
+ color: var(--ink-soft);
393
+ font-size: 0.95rem;
394
+ line-height: 1.65;
395
+ margin: 0 0 0.85em;
396
+ padding-left: 1.2em;
397
+ }
398
+ .ranked-item .rank-aside {
399
+ color: var(--muted);
400
+ font-size: 0.9rem;
401
+ line-height: 1.6;
402
+ padding-left: 14px;
403
+ border-left: 2px solid var(--rule);
404
+ margin: 0;
405
+ }
406
+ .ranked-item .rank-aside-label {
407
+ font-family: var(--mono);
408
+ font-size: 0.7rem;
409
+ font-weight: 600;
410
+ letter-spacing: 0.1em;
411
+ text-transform: uppercase;
412
+ color: var(--accent);
413
+ margin-right: 6px;
414
+ }
332
415
 
333
416
  /* byline */
334
417
  .byline {
@@ -845,6 +928,72 @@
845
928
  </div>
846
929
  </section>
847
930
 
931
+ <!-- ============================================================
932
+ Section 13 · Ranked list
933
+ ============================================================ -->
934
+ <section>
935
+ <p class="eyebrow">13 · ranked-list</p>
936
+ <h2 class="h-section"><span class="section-num">13</span>Ranked list</h2>
937
+ <p>
938
+ Use <code>.ranked-list</code> + <code>.ranked-item</code> for ordered recommendations,
939
+ findings, or rankings — anything that would otherwise be a heavy numbered
940
+ <code>&lt;h3&gt;</code> sequence. Each item gets a soft serif numeral on the left and a
941
+ calm title + meta + body on the right. Pair <code>.pill.accent</code> +
942
+ <code>.tag</code> in the meta row for impact and savings; use <code>.rank-aside</code> for
943
+ a quieter "bonus" addendum.
944
+ </p>
945
+
946
+ <ol class="ranked-list">
947
+ <li class="ranked-item">
948
+ <div class="rank-num">01</div>
949
+ <div class="rank-body">
950
+ <h3 class="rank-title">Replace the styleguide payload with a compact catalog</h3>
951
+ <div class="rank-meta">
952
+ <span class="pill accent">High impact</span>
953
+ <span class="tag">~6–8k tokens / call</span>
954
+ </div>
955
+ <p>
956
+ Return a structured Markdown reference of <em>class → purpose → minimal example</em>
957
+ instead of the full HTML framework. Realistic target: 3–5 KB / ~1k tokens, down from
958
+ ~7–9k.
959
+ </p>
960
+ <p class="rank-aside">
961
+ <span class="rank-aside-label">Bonus</span>
962
+ Derive the catalog from a single source so it can't drift from the framework.
963
+ </p>
964
+ </div>
965
+ </li>
966
+ <li class="ranked-item">
967
+ <div class="rank-num">02</div>
968
+ <div class="rank-body">
969
+ <h3 class="rank-title">Trim the system prompt fragment</h3>
970
+ <div class="rank-meta">
971
+ <span class="pill">Low impact</span>
972
+ <span class="tag">~400 tokens / session</span>
973
+ </div>
974
+ <p>
975
+ Remove redundancy with in-tool <code>description</code> strings. Nice but not
976
+ transformative.
977
+ </p>
978
+ </div>
979
+ </li>
980
+ <li class="ranked-item">
981
+ <div class="rank-num">03</div>
982
+ <div class="rank-body">
983
+ <h3 class="rank-title">Stop double-emitting CSS in <code>wrapDocument()</code></h3>
984
+ <div class="rank-meta">
985
+ <span class="pill">Disk only</span>
986
+ <span class="tag">~17 KB / artifact</span>
987
+ </div>
988
+ <p>
989
+ Inline the framework only when the linked stylesheet is unavailable; emit tokens
990
+ inline always.
991
+ </p>
992
+ </div>
993
+ </li>
994
+ </ol>
995
+ </section>
996
+
848
997
  <!-- ============================================================
849
998
  Footer / byline
850
999
  ============================================================ -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfbender/cesium",
3
- "version": "0.3.5",
3
+ "version": "0.4.0",
4
4
  "description": "Beautiful self-contained HTML artifacts from your opencode agent.",
5
5
  "license": "MIT",
6
6
  "author": "Cody Bender",
@@ -10,6 +10,7 @@ import {
10
10
  type ThemePalette,
11
11
  } from "../../render/theme.ts";
12
12
  import { writeThemeCss, themeCssPath } from "../../storage/theme-write.ts";
13
+ import { writeFaviconSvg } from "../../storage/favicon-write.ts";
13
14
  import { themeTokensCss } from "../../render/theme.ts";
14
15
  import { atomicWrite } from "../../storage/write.ts";
15
16
  import { readdir } from "node:fs/promises";
@@ -307,6 +308,7 @@ async function themeApplyCommand(argv: string[], ctx: ThemeContext): Promise<num
307
308
  const cfg = (ctx.loadConfig ?? loadConfig)();
308
309
  const { theme, presetLabel } = resolveTheme(cfg);
309
310
  const cssPath = await writeThemeCss(cfg.stateDir, theme);
311
+ await writeFaviconSvg(cfg.stateDir);
310
312
 
311
313
  if (values["rewrite-artifacts"]) {
312
314
  const { artifacts, indexes } = await retrofitAll(cfg.stateDir, ctx.stdout);
@@ -39,7 +39,7 @@ export interface CritiqueResult {
39
39
  const HTTP_RE = /^https?:\/\//i;
40
40
 
41
41
  /** The only cesium-* class the framework ships with. All others are unknown. */
42
- const KNOWN_CESIUM_CLASSES = new Set(["cesium-back"]);
42
+ const KNOWN_CESIUM_CLASSES = new Set(["cesium-back", "cesium-eyebrow"]);
43
43
 
44
44
  /** Callout severity modifiers — a callout needs at least one of these. */
45
45
  const CALLOUT_MODIFIERS = new Set(["note", "warn", "risk"]);
@@ -0,0 +1,56 @@
1
+ // Cesium favicon — periodic-table tile, element 55 ("Cs"), claret-dark palette.
2
+ //
3
+ // The SVG is theme-independent (always claret-dark wine + rose) so it stays
4
+ // recognizable as the cesium emblem regardless of the user's chosen theme.
5
+ //
6
+ // Two consumers:
7
+ // 1. `writeFaviconSvg` writes this string to <stateDir>/favicon.svg, which
8
+ // is then referenced by <link rel="icon"> in artifact + index pages and
9
+ // auto-served by the cesium HTTP server.
10
+ // 2. `faviconEmblemSvg` returns inline SVG markup suitable for placing next
11
+ // to the "cesium" eyebrow on index pages (no <?xml?> declaration, no
12
+ // role/aria — those are decorative chrome).
13
+
14
+ /** The full standalone favicon SVG written to <stateDir>/favicon.svg. */
15
+ export const FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Cesium — element 55">
16
+ <!-- Periodic table tile, claret-dark palette -->
17
+ <rect x="2" y="2" width="60" height="60" rx="10" ry="10" fill="#180810"/>
18
+ <rect x="2.75" y="2.75" width="58.5" height="58.5" rx="9.25" ry="9.25"
19
+ fill="none" stroke="#C75B7A" stroke-width="1.5"/>
20
+
21
+ <!-- Atomic number, top-left -->
22
+ <text x="8" y="18" fill="#C75B7A"
23
+ font-family="ui-monospace, 'SF Mono', Menlo, Monaco, monospace"
24
+ font-size="13" font-weight="700" letter-spacing="0.5">55</text>
25
+
26
+ <!-- Element symbol, centered -->
27
+ <text x="32" y="48" fill="#DDD3C7" text-anchor="middle"
28
+ font-family="Georgia, 'Times New Roman', serif"
29
+ font-size="36" font-weight="700" letter-spacing="-1">Cs</text>
30
+ </svg>
31
+ `;
32
+
33
+ /** Inline SVG emblem for placing next to the "cesium" eyebrow text.
34
+ * Decorative — uses aria-hidden so screen readers skip it (the text label
35
+ * next to it already says "cesium").
36
+ *
37
+ * @param size — pixel size for both width and height. Defaults to 18.
38
+ */
39
+ export function faviconEmblemSvg(size = 18): string {
40
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="${size}" height="${size}" aria-hidden="true" focusable="false" style="display:inline-block;vertical-align:-3px;flex-shrink:0;">
41
+ <rect x="2" y="2" width="60" height="60" rx="10" ry="10" fill="#180810"/>
42
+ <rect x="2.75" y="2.75" width="58.5" height="58.5" rx="9.25" ry="9.25" fill="none" stroke="#C75B7A" stroke-width="1.5"/>
43
+ <text x="8" y="18" fill="#C75B7A" font-family="ui-monospace, 'SF Mono', Menlo, Monaco, monospace" font-size="13" font-weight="700" letter-spacing="0.5">55</text>
44
+ <text x="32" y="48" fill="#DDD3C7" text-anchor="middle" font-family="Georgia, 'Times New Roman', serif" font-size="36" font-weight="700" letter-spacing="-1">Cs</text>
45
+ </svg>`;
46
+ }
47
+
48
+ /** Returns the <link rel="icon"> tag for an HTML head, given a relative href.
49
+ *
50
+ * - Artifact pages (3 levels deep): href = "../../../favicon.svg"
51
+ * - Project index (2 levels deep): href = "../../favicon.svg"
52
+ * - Global index (root): href = "favicon.svg"
53
+ */
54
+ export function faviconLinkTag(href: string): string {
55
+ return `<link rel="icon" type="image/svg+xml" href="${href}">`;
56
+ }
@@ -456,6 +456,87 @@ h1, h2, h3, h4, h5, h6 {
456
456
  color: var(--muted);
457
457
  text-transform: lowercase;
458
458
  }
459
+ .pill.accent {
460
+ background: color-mix(in srgb, var(--accent) 18%, var(--surface));
461
+ color: var(--accent);
462
+ font-weight: 600;
463
+ }
464
+
465
+ /* ranked list — numbered cards for ordered recommendations / findings */
466
+ .ranked-list {
467
+ display: flex;
468
+ flex-direction: column;
469
+ gap: 1em;
470
+ margin: 0 0 1.5em;
471
+ padding: 0;
472
+ list-style: none;
473
+ }
474
+ .ranked-item {
475
+ background: var(--surface);
476
+ border: 1.5px solid var(--rule);
477
+ border-radius: 12px;
478
+ padding: 22px 26px;
479
+ display: grid;
480
+ grid-template-columns: 64px 1fr;
481
+ gap: 6px 24px;
482
+ align-items: start;
483
+ }
484
+ .ranked-item .rank-num {
485
+ font-family: var(--serif);
486
+ font-size: 2.4rem;
487
+ font-weight: 500;
488
+ color: var(--oat);
489
+ line-height: 1;
490
+ letter-spacing: -0.02em;
491
+ padding-top: 2px;
492
+ }
493
+ .ranked-item .rank-title {
494
+ font-family: var(--serif);
495
+ font-size: 1.2rem;
496
+ font-weight: 600;
497
+ color: var(--ink);
498
+ margin: 0 0 6px;
499
+ line-height: 1.3;
500
+ }
501
+ .ranked-item .rank-meta {
502
+ display: flex;
503
+ align-items: center;
504
+ gap: 8px;
505
+ flex-wrap: wrap;
506
+ margin-bottom: 14px;
507
+ }
508
+ .ranked-item .rank-body > p {
509
+ color: var(--ink-soft);
510
+ font-size: 0.95rem;
511
+ line-height: 1.65;
512
+ margin: 0 0 0.85em;
513
+ }
514
+ .ranked-item .rank-body > p:last-child { margin-bottom: 0; }
515
+ .ranked-item .rank-body > ul,
516
+ .ranked-item .rank-body > ol {
517
+ color: var(--ink-soft);
518
+ font-size: 0.95rem;
519
+ line-height: 1.65;
520
+ margin: 0 0 0.85em;
521
+ padding-left: 1.2em;
522
+ }
523
+ .ranked-item .rank-aside {
524
+ color: var(--muted);
525
+ font-size: 0.9rem;
526
+ line-height: 1.6;
527
+ padding-left: 14px;
528
+ border-left: 2px solid var(--rule);
529
+ margin: 0;
530
+ }
531
+ .ranked-item .rank-aside-label {
532
+ font-family: var(--mono);
533
+ font-size: 0.7rem;
534
+ font-weight: 600;
535
+ letter-spacing: 0.1em;
536
+ text-transform: uppercase;
537
+ color: var(--accent);
538
+ margin-right: 6px;
539
+ }
459
540
 
460
541
  /* byline */
461
542
  .byline {
@@ -3,6 +3,7 @@
3
3
  import { frameworkRulesCss, themeTokensCss, type ThemeTokens } from "./theme.ts";
4
4
  import { renderControl, renderAnswered } from "./controls.ts";
5
5
  import { getClientJs } from "./client-js.ts";
6
+ import { faviconLinkTag } from "./favicon.ts";
6
7
  import type { InteractiveData, Question } from "./validate.ts";
7
8
 
8
9
  export interface ArtifactMeta {
@@ -47,6 +48,20 @@ function escapeHtml(str: string): string {
47
48
  .replace(/"/g, "&quot;");
48
49
  }
49
50
 
51
+ /** Derives the favicon href from the theme.css href.
52
+ *
53
+ * Both files live in the stateDir root, so the relative depth is identical:
54
+ * swap the trailing "theme.css" segment for "favicon.svg". For atypical
55
+ * themeCssHref values (absolute URLs, paths without "theme.css" suffix), fall
56
+ * back to the artifact-context default.
57
+ */
58
+ function deriveFaviconHref(themeCssHref: string): string {
59
+ if (themeCssHref.endsWith("theme.css")) {
60
+ return themeCssHref.slice(0, -"theme.css".length) + "favicon.svg";
61
+ }
62
+ return "../../../favicon.svg";
63
+ }
64
+
50
65
  function safeJsonForScript(obj: unknown): string {
51
66
  return JSON.stringify(obj, null, 2).replace(/<\/script>/gi, "<\\/script>");
52
67
  }
@@ -145,6 +160,10 @@ export function wrapDocument(opts: WrapOptions): string {
145
160
  : "";
146
161
 
147
162
  const linkTag = suppressLink ? "" : `\n <link rel="stylesheet" href="${href}">`;
163
+ // Favicon path mirrors the theme.css path: artifacts live three levels deep
164
+ // in <stateDir>/projects/<slug>/artifacts/, so favicon.svg is "../../../".
165
+ // When suppressed (standalone/test mode), we suppress favicon too.
166
+ const faviconTag = suppressLink ? "" : `\n ${faviconLinkTag(deriveFaviconHref(href ?? ""))}`;
148
167
 
149
168
  return `<!doctype html>
150
169
  <html lang="en">
@@ -154,7 +173,7 @@ export function wrapDocument(opts: WrapOptions): string {
154
173
  <title>${titleEsc} · cesium</title>
155
174
  <style>${rules}
156
175
  /* fallback theme tokens — used when theme.css is missing or unreachable */
157
- ${tokens}</style>${linkTag}
176
+ ${tokens}</style>${linkTag}${faviconTag}
158
177
  <script type="application/json" id="cesium-meta">${metaJson}</script>
159
178
  </head>
160
179
  <body>
@@ -0,0 +1,28 @@
1
+ // Favicon shim handler — serves the cesium favicon at /favicon.ico (the legacy
2
+ // path browsers request automatically) by returning the in-memory SVG bytes
3
+ // with image/svg+xml content type. All evergreen browsers accept SVG favicons
4
+ // regardless of the URL extension.
5
+ //
6
+ // The static server already serves /favicon.svg from <stateDir>/favicon.svg
7
+ // (written by writeFaviconSvg on every publish). This shim covers the .ico
8
+ // fallback so users don't see a 404 in DevTools.
9
+
10
+ import { FAVICON_SVG } from "../render/favicon.ts";
11
+
12
+ const SVG_RESPONSE_HEADERS: Record<string, string> = {
13
+ "Content-Type": "image/svg+xml; charset=utf-8",
14
+ "Cache-Control": "public, max-age=86400",
15
+ };
16
+
17
+ export function createFaviconHandler(): (req: Request) => Promise<Response | undefined> {
18
+ return async (req: Request): Promise<Response | undefined> => {
19
+ const url = new URL(req.url);
20
+ if (url.pathname !== "/favicon.ico") {
21
+ return undefined;
22
+ }
23
+ if (req.method !== "GET" && req.method !== "HEAD") {
24
+ return undefined;
25
+ }
26
+ return new Response(FAVICON_SVG, { status: 200, headers: SVG_RESPONSE_HEADERS });
27
+ };
28
+ }
@@ -6,6 +6,7 @@ import { unlink, writeFile } from "node:fs/promises";
6
6
  import { startServer, type ServerHandle } from "./http.ts";
7
7
  import { acquireLock } from "../storage/lock.ts";
8
8
  import { createApiHandler } from "./api.ts";
9
+ import { createFaviconHandler } from "./favicon.ts";
9
10
 
10
11
  export interface LifecycleConfig {
11
12
  stateDir: string;
@@ -260,6 +261,9 @@ export async function ensureRunning(cfg: LifecycleConfig): Promise<RunningInfo>
260
261
 
261
262
  // Wire API handler before static file fallback
262
263
  handle.addHandler(createApiHandler({ stateDir }));
264
+ // /favicon.ico shim — browsers auto-request this even when the page
265
+ // declares an SVG favicon. Serve the SVG bytes inline so we don't 404.
266
+ handle.addHandler(createFaviconHandler());
263
267
 
264
268
  const startedAt = new Date().toISOString();
265
269
 
@@ -0,0 +1,23 @@
1
+ // Writes favicon.svg to the state directory.
2
+ //
3
+ // Mirrors theme-write.ts: produces a single static asset alongside theme.css
4
+ // that artifact pages and index pages reference via relative <link rel="icon">.
5
+ // The cesium HTTP server serves it automatically because the state dir is the
6
+ // server's static root.
7
+
8
+ import { join } from "node:path";
9
+ import { FAVICON_SVG } from "../render/favicon.ts";
10
+ import { atomicWrite } from "./write.ts";
11
+
12
+ /** Returns the absolute path to favicon.svg in the given stateDir. */
13
+ export function faviconSvgPath(stateDir: string): string {
14
+ return join(stateDir, "favicon.svg");
15
+ }
16
+
17
+ /** Writes <stateDir>/favicon.svg. Atomic. Returns the absolute path.
18
+ * Idempotent — content is theme-independent so writing twice is a no-op. */
19
+ export async function writeFaviconSvg(stateDir: string): Promise<string> {
20
+ const path = faviconSvgPath(stateDir);
21
+ await atomicWrite(path, FAVICON_SVG);
22
+ return path;
23
+ }
@@ -3,6 +3,7 @@
3
3
  import type { IndexEntry } from "./index-cache.ts";
4
4
  import type { ThemeTokens } from "../render/theme.ts";
5
5
  import { frameworkRulesCss, themeTokensCss } from "../render/theme.ts";
6
+ import { faviconLinkTag, faviconEmblemSvg } from "../render/favicon.ts";
6
7
 
7
8
  export interface RenderProjectIndexArgs {
8
9
  projectSlug: string;
@@ -88,6 +89,11 @@ function formatDate(iso: string): string {
88
89
  function indexCss(): string {
89
90
  return `
90
91
  /* index-page chrome */
92
+ .cesium-eyebrow {
93
+ display: inline-flex; align-items: center; gap: 8px;
94
+ /* the eyebrow text is uppercased + tracked; the emblem sits flush left */
95
+ }
96
+ .cesium-eyebrow svg { display: block; }
91
97
  .filter-row { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 24px; align-items: center; }
92
98
  .filter-chip {
93
99
  display: inline-block; font-family: var(--sans); font-size: 0.8em; font-weight: 500;
@@ -274,6 +280,12 @@ export function renderProjectIndex(args: RenderProjectIndexArgs): string {
274
280
  const iJs = indexJs();
275
281
 
276
282
  const linkTag = suppressLink ? "" : `\n <link rel="stylesheet" href="${href}">`;
283
+ // Favicon sits next to theme.css in the stateDir root, so swap the suffix.
284
+ const faviconHref =
285
+ href !== null && href.endsWith("theme.css")
286
+ ? href.slice(0, -"theme.css".length) + "favicon.svg"
287
+ : "../../favicon.svg";
288
+ const faviconTag = suppressLink ? "" : `\n ${faviconLinkTag(faviconHref)}`;
277
289
 
278
290
  // Sort entries newest-first
279
291
  const sorted = [...entries].toSorted(
@@ -348,11 +360,11 @@ ${cardsHtml}
348
360
  <title>${esc(projectName)} · cesium</title>
349
361
  <style>${rules}
350
362
  /* fallback theme tokens — used when theme.css is missing or unreachable */
351
- ${tokens}${iCss}</style>${linkTag}
363
+ ${tokens}${iCss}</style>${linkTag}${faviconTag}
352
364
  </head>
353
365
  <body>
354
366
  <div class="page">
355
- <p class="eyebrow">cesium · project</p>
367
+ <p class="eyebrow cesium-eyebrow">${faviconEmblemSvg(18)}<span>cesium · project</span></p>
356
368
  <h1 class="h-display">${esc(projectName)}</h1>
357
369
  ${subhead}
358
370
  ${filterRow}
@@ -383,6 +395,12 @@ export function renderGlobalIndex(args: RenderGlobalIndexArgs): string {
383
395
  const iCss = indexCss();
384
396
 
385
397
  const linkTag = suppressLink ? "" : `\n <link rel="stylesheet" href="${href}">`;
398
+ // Favicon sits next to theme.css; default href is "theme.css" → "favicon.svg".
399
+ const faviconHref =
400
+ href !== null && href.endsWith("theme.css")
401
+ ? href.slice(0, -"theme.css".length) + "favicon.svg"
402
+ : "favicon.svg";
403
+ const faviconTag = suppressLink ? "" : `\n ${faviconLinkTag(faviconHref)}`;
386
404
 
387
405
  const sorted = [...projects].toSorted(
388
406
  (a, b) => new Date(b.latestCreatedAt).getTime() - new Date(a.latestCreatedAt).getTime(),
@@ -432,11 +450,11 @@ export function renderGlobalIndex(args: RenderGlobalIndexArgs): string {
432
450
  <title>All projects · cesium</title>
433
451
  <style>${rules}
434
452
  /* fallback theme tokens — used when theme.css is missing or unreachable */
435
- ${tokens}${iCss}</style>${linkTag}
453
+ ${tokens}${iCss}</style>${linkTag}${faviconTag}
436
454
  </head>
437
455
  <body>
438
456
  <div class="page">
439
- <p class="eyebrow">cesium</p>
457
+ <p class="eyebrow cesium-eyebrow">${faviconEmblemSvg(18)}<span>cesium</span></p>
440
458
  <h1 class="h-display">All projects</h1>
441
459
  ${subhead}
442
460
  ${bodyContent}
package/src/tools/ask.ts CHANGED
@@ -14,6 +14,7 @@ import type { InteractiveData } from "../render/validate.ts";
14
14
  import { deriveProjectIdentity, artifactFilename, pathsFor } from "../storage/paths.ts";
15
15
  import { atomicWrite } from "../storage/write.ts";
16
16
  import { writeThemeCss } from "../storage/theme-write.ts";
17
+ import { writeFaviconSvg } from "../storage/favicon-write.ts";
17
18
  import { loadIndex, writeIndex, appendEntry, type IndexEntry } from "../storage/index-cache.ts";
18
19
  import { withLock } from "../storage/lock.ts";
19
20
  import { renderProjectIndex, renderGlobalIndex } from "../storage/index-gen.ts";
@@ -227,8 +228,9 @@ export function createAskTool(
227
228
  // 14. Build theme + wrap document
228
229
  const theme = mergeTheme(themeFromPreset(config.themePreset), config.theme);
229
230
 
230
- // 14a. Write theme.css (idempotent, outside index lock — separate file)
231
+ // 14a. Write theme.css + favicon.svg (idempotent, outside index lock — separate files)
231
232
  await writeThemeCss(config.stateDir, theme);
233
+ await writeFaviconSvg(config.stateDir);
232
234
 
233
235
  const fullHtml = wrapDocument({
234
236
  body: scrubbed.html,
@@ -14,6 +14,7 @@ import { wrapDocument, type ArtifactMeta } from "../render/wrap.ts";
14
14
  import { deriveProjectIdentity, artifactFilename, pathsFor } from "../storage/paths.ts";
15
15
  import { atomicWrite, patchEmbeddedMetadata } from "../storage/write.ts";
16
16
  import { writeThemeCss } from "../storage/theme-write.ts";
17
+ import { writeFaviconSvg } from "../storage/favicon-write.ts";
17
18
  import {
18
19
  loadIndex,
19
20
  writeIndex,
@@ -224,8 +225,9 @@ export function createPublishTool(
224
225
  // 12. Build theme + wrap document
225
226
  const theme = mergeTheme(themeFromPreset(config.themePreset), config.theme);
226
227
 
227
- // 12a. Write theme.css (idempotent, outside index lock — separate file)
228
+ // 12a. Write theme.css + favicon.svg (idempotent, outside index lock — separate files)
228
229
  await writeThemeCss(config.stateDir, theme);
230
+ await writeFaviconSvg(config.stateDir);
229
231
 
230
232
  const fullHtml = wrapDocument({
231
233
  body: scrubbed.html,