@cfbender/cesium 0.3.5 → 0.3.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfbender/cesium",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
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
+ }
@@ -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,