@jant/core 0.6.9 → 0.6.10

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.
Files changed (43) hide show
  1. package/dist/{app-C-jxWmAV.js → app-CGHkOdme.js} +396 -234
  2. package/dist/app-D24n0DoH.js +6 -0
  3. package/dist/client/.vite/manifest.json +3 -3
  4. package/dist/client/_assets/{client-DWy1LEEk.js → client-DYrWuaIk.js} +1 -1
  5. package/dist/client/_assets/{client-auth-Blg-a5Ep.js → client-auth-B5Re0uCd.js} +26 -24
  6. package/dist/client/_assets/client-xWDl78yi.css +2 -0
  7. package/dist/{export-C2DIB7mm.js → export-DY1v5Iqu.js} +1 -1
  8. package/dist/{github-sync-BEFCfLKK.js → github-sync-2_T7nbOv.js} +1 -1
  9. package/dist/{github-sync-7XQ5ZM6z.js → github-sync-LefaslGJ.js} +2 -2
  10. package/dist/index.js +3 -3
  11. package/dist/node.js +4 -4
  12. package/package.json +1 -1
  13. package/src/client/components/__tests__/jant-settings-avatar.test.ts +3 -0
  14. package/src/client/components/__tests__/jant-settings-general.test.ts +9 -4
  15. package/src/client/components/jant-settings-general.ts +18 -3
  16. package/src/client/components/settings-types.ts +2 -0
  17. package/src/client/tiptap/__tests__/link-toolbar.test.ts +41 -0
  18. package/src/client/tiptap/link-toolbar.ts +63 -1
  19. package/src/i18n/locales/settings/en.po +258 -18
  20. package/src/i18n/locales/settings/en.ts +1 -1
  21. package/src/i18n/locales/settings/zh-Hans.po +258 -18
  22. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  23. package/src/i18n/locales/settings/zh-Hant.po +258 -18
  24. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  25. package/src/lib/__tests__/feed.test.ts +5 -1
  26. package/src/lib/feed.ts +6 -3
  27. package/src/routes/dash/settings.tsx +7 -2
  28. package/src/routes/feed/__tests__/feed.test.ts +58 -19
  29. package/src/routes/feed/feed.ts +37 -28
  30. package/src/routes/pages/featured.tsx +17 -0
  31. package/src/routes/pages/latest.tsx +25 -0
  32. package/src/services/post.ts +1 -1
  33. package/src/styles/tokens.css +15 -0
  34. package/src/styles/ui.css +44 -1
  35. package/src/ui/__tests__/color-themes.test.ts +2 -2
  36. package/src/ui/color-themes.ts +32 -0
  37. package/src/ui/dash/appearance/ColorThemeContent.tsx +264 -29
  38. package/src/ui/dash/settings/GeneralContent.tsx +16 -0
  39. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +3 -2
  40. package/src/ui/layouts/BaseLayout.tsx +2 -2
  41. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +4 -4
  42. package/dist/app-DqHzOwL5.js +0 -6
  43. package/dist/client/_assets/client-CGf2m3qp.css +0 -2
@@ -15,6 +15,8 @@ import { createMediaService } from "../../../services/media.js";
15
15
  import { DEFAULT_APP_PORT } from "../../../lib/env.js";
16
16
  import { resolveConfig } from "../../../lib/resolve-config.js";
17
17
  import { feedRoutes } from "../feed.js";
18
+ import { latestRoutes } from "../../pages/latest.js";
19
+ import { featuredRoutes } from "../../pages/featured.js";
18
20
  import type { Database } from "../../../db/index.js";
19
21
 
20
22
  type Env = { Bindings: Bindings; Variables: AppVariables };
@@ -58,6 +60,9 @@ function createFeedTestApp(envOverrides: Partial<Bindings> = {}) {
58
60
  });
59
61
 
60
62
  app.route("/feed", feedRoutes);
63
+ // The canonical latest/featured feeds live in their page route groups.
64
+ app.route("/latest", latestRoutes);
65
+ app.route("/featured", featuredRoutes);
61
66
 
62
67
  return { app, services, db: db as unknown as Database };
63
68
  }
@@ -166,7 +171,7 @@ describe("Atom Feed Routes", () => {
166
171
  });
167
172
  });
168
173
 
169
- describe("/feed/latest — latest public posts", () => {
174
+ describe("/latest/feed — latest public posts", () => {
170
175
  it("returns public published posts and excludes hidden, private, and draft posts", async () => {
171
176
  const { app, services } = createFeedTestApp();
172
177
 
@@ -204,7 +209,7 @@ describe("Atom Feed Routes", () => {
204
209
  status: "draft",
205
210
  });
206
211
 
207
- const res = await app.request("/feed/latest");
212
+ const res = await app.request("/latest/feed");
208
213
  expect(res.status).toBe(200);
209
214
 
210
215
  const xml = await res.text();
@@ -237,7 +242,7 @@ describe("Atom Feed Routes", () => {
237
242
  status: "published",
238
243
  });
239
244
 
240
- const res = await app.request("/feed/latest?format=note");
245
+ const res = await app.request("/latest/feed?format=note");
241
246
  expect(res.status).toBe(200);
242
247
 
243
248
  const xml = await res.text();
@@ -262,7 +267,7 @@ describe("Atom Feed Routes", () => {
262
267
  status: "published",
263
268
  });
264
269
 
265
- const res = await app.request("/feed/latest?format=invalid");
270
+ const res = await app.request("/latest/feed?format=invalid");
266
271
  expect(res.status).toBe(200);
267
272
 
268
273
  const xml = await res.text();
@@ -273,14 +278,14 @@ describe("Atom Feed Routes", () => {
273
278
  it("returns Atom content type", async () => {
274
279
  const { app } = createFeedTestApp();
275
280
 
276
- const res = await app.request("/feed/latest");
281
+ const res = await app.request("/latest/feed");
277
282
  expect(res.headers.get("Content-Type")).toBe(
278
283
  "application/atom+xml; charset=utf-8",
279
284
  );
280
285
  });
281
286
  });
282
287
 
283
- describe("/feed/featured — featured posts", () => {
288
+ describe("/featured/feed — featured posts", () => {
284
289
  it("returns only featured posts", async () => {
285
290
  const { app, services } = createFeedTestApp();
286
291
 
@@ -298,7 +303,7 @@ describe("Atom Feed Routes", () => {
298
303
  featured: true,
299
304
  });
300
305
 
301
- const res = await app.request("/feed/featured");
306
+ const res = await app.request("/featured/feed");
302
307
  expect(res.status).toBe(200);
303
308
 
304
309
  const xml = await res.text();
@@ -307,6 +312,24 @@ describe("Atom Feed Routes", () => {
307
312
  });
308
313
  });
309
314
 
315
+ describe("legacy feed path redirects", () => {
316
+ it("redirects /feed/latest to /latest/feed, preserving ?format=", async () => {
317
+ const { app } = createFeedTestApp();
318
+
319
+ const res = await app.request("/feed/latest?format=note");
320
+ expect(res.status).toBe(308);
321
+ expect(res.headers.get("Location")).toBe("/latest/feed?format=note");
322
+ });
323
+
324
+ it("redirects /feed/featured to /featured/feed", async () => {
325
+ const { app } = createFeedTestApp();
326
+
327
+ const res = await app.request("/feed/featured");
328
+ expect(res.status).toBe(308);
329
+ expect(res.headers.get("Location")).toBe("/featured/feed");
330
+ });
331
+ });
332
+
310
333
  describe("legacy atom.xml redirects", () => {
311
334
  it("redirects /feed/atom.xml to /feed", async () => {
312
335
  const { app } = createFeedTestApp();
@@ -316,38 +339,54 @@ describe("Atom Feed Routes", () => {
316
339
  expect(res.headers.get("Location")).toBe("/feed");
317
340
  });
318
341
 
319
- it("redirects /feed/latest/atom.xml to /feed/latest", async () => {
342
+ it("redirects /feed/latest/atom.xml to /latest/feed", async () => {
320
343
  const { app } = createFeedTestApp();
321
344
 
322
345
  const res = await app.request("/feed/latest/atom.xml");
323
346
  expect(res.status).toBe(308);
324
- expect(res.headers.get("Location")).toBe("/feed/latest");
347
+ expect(res.headers.get("Location")).toBe("/latest/feed");
325
348
  });
326
349
 
327
- it("redirects /feed/featured/atom.xml to /feed/featured", async () => {
350
+ it("redirects /feed/featured/atom.xml to /featured/feed", async () => {
328
351
  const { app } = createFeedTestApp();
329
352
 
330
353
  const res = await app.request("/feed/featured/atom.xml");
331
354
  expect(res.status).toBe(308);
332
- expect(res.headers.get("Location")).toBe("/feed/featured");
355
+ expect(res.headers.get("Location")).toBe("/featured/feed");
356
+ });
357
+
358
+ it("redirects /latest/feed/atom.xml to /latest/feed", async () => {
359
+ const { app } = createFeedTestApp();
360
+
361
+ const res = await app.request("/latest/feed/atom.xml");
362
+ expect(res.status).toBe(308);
363
+ expect(res.headers.get("Location")).toBe("/latest/feed");
364
+ });
365
+
366
+ it("redirects /featured/feed/atom.xml to /featured/feed", async () => {
367
+ const { app } = createFeedTestApp();
368
+
369
+ const res = await app.request("/featured/feed/atom.xml");
370
+ expect(res.status).toBe(308);
371
+ expect(res.headers.get("Location")).toBe("/featured/feed");
333
372
  });
334
373
  });
335
374
 
336
375
  describe("legacy feed aliases", () => {
337
- it("redirects /feed/all to /feed/latest", async () => {
376
+ it("redirects /feed/all to /latest/feed", async () => {
338
377
  const { app } = createFeedTestApp();
339
378
 
340
379
  const res = await app.request("/feed/all?format=note");
341
380
  expect(res.status).toBe(308);
342
- expect(res.headers.get("Location")).toBe("/feed/latest?format=note");
381
+ expect(res.headers.get("Location")).toBe("/latest/feed?format=note");
343
382
  });
344
383
 
345
- it("redirects /feed/all/atom.xml to /feed/latest", async () => {
384
+ it("redirects /feed/all/atom.xml to /latest/feed", async () => {
346
385
  const { app } = createFeedTestApp();
347
386
 
348
387
  const res = await app.request("/feed/all/atom.xml?format=link");
349
388
  expect(res.status).toBe(308);
350
- expect(res.headers.get("Location")).toBe("/feed/latest");
389
+ expect(res.headers.get("Location")).toBe("/latest/feed");
351
390
  });
352
391
  });
353
392
 
@@ -388,7 +427,7 @@ describe("Atom Feed Routes", () => {
388
427
  });
389
428
  }
390
429
 
391
- const res = await app.request("/feed/latest");
430
+ const res = await app.request("/latest/feed");
392
431
  expect(res.status).toBe(200);
393
432
 
394
433
  const xml = await res.text();
@@ -413,7 +452,7 @@ describe("Atom Feed Routes", () => {
413
452
  });
414
453
  }
415
454
 
416
- const res = await app.request("/feed/latest");
455
+ const res = await app.request("/latest/feed");
417
456
  expect(res.status).toBe(200);
418
457
 
419
458
  const xml = await res.text();
@@ -436,7 +475,7 @@ describe("Atom Feed Routes", () => {
436
475
  });
437
476
  }
438
477
 
439
- const res = await app.request("/feed/featured");
478
+ const res = await app.request("/featured/feed");
440
479
  expect(res.status).toBe(200);
441
480
 
442
481
  const xml = await res.text();
@@ -463,7 +502,7 @@ describe("Atom Feed Routes", () => {
463
502
  replyToId: root.id,
464
503
  });
465
504
 
466
- const res = await app.request("/feed/latest");
505
+ const res = await app.request("/latest/feed");
467
506
  expect(res.status).toBe(200);
468
507
 
469
508
  const xml = await res.text();
@@ -1,12 +1,17 @@
1
1
  /**
2
2
  * Atom Feed Routes
3
3
  *
4
- * Feed hierarchy:
5
- * - /feed — site main feed (latest or featured, site-configurable)
6
- * - /feed/latest — latest public posts
7
- * - /feed/featured featured posts only
8
- * - /{slug}/feed single-collection feed (handled in page routes)
4
+ * Feed hierarchy (resource-first: a feed is a sub-resource of the page it
5
+ * represents, so it lives at `{page}/feed`):
6
+ * - /feed site main feed (latest or featured, site-configurable; feed of `/`)
7
+ * - /latest/feed latest public posts (handled in pages/latest)
8
+ * - /featured/feed featured posts only (handled in pages/featured)
9
+ * - /archive/feed — full archive incl. Hidden-from-Latest (handled in pages/archive)
10
+ * - /{slug}/feed — single-collection feed (handled in page routes)
9
11
  * - /collections/{slug}/feed — combined collection feed (handled in collection routes)
12
+ *
13
+ * Legacy: /feed/latest and /feed/featured 308-redirect to the canonical
14
+ * /latest/feed and /featured/feed. Kept indefinitely for old subscribers.
10
15
  */
11
16
 
12
17
  import { msg } from "@lingui/core/macro";
@@ -255,11 +260,15 @@ async function buildFeaturedFeedData(
255
260
  /**
256
261
  * Build FeedData from the Hono context.
257
262
  *
263
+ * Exported so the canonical latest/featured feeds (served from the
264
+ * `/latest/feed` and `/featured/feed` page route groups) can reuse the
265
+ * same feed-building logic.
266
+ *
258
267
  * @param c - Hono context
259
268
  * @param opts - Filter options for the feed
260
269
  * @returns Feed data ready for rendering
261
270
  */
262
- async function buildFeedData(
271
+ export async function buildFeedData(
263
272
  c: Context<Env>,
264
273
  opts: FeedOptions,
265
274
  ): Promise<FeedData> {
@@ -311,7 +320,7 @@ async function buildFeedData(
311
320
  * Parse and validate the `format` query parameter.
312
321
  * Returns a valid Format or undefined if missing/invalid.
313
322
  */
314
- function parseFormatQuery(c: Context<Env>): Format | undefined {
323
+ export function parseFormatQuery(c: Context<Env>): Format | undefined {
315
324
  const raw = c.req.query("format");
316
325
  if (raw && (FORMATS as readonly string[]).includes(raw)) {
317
326
  return raw as Format;
@@ -319,7 +328,7 @@ function parseFormatQuery(c: Context<Env>): Format | undefined {
319
328
  return undefined;
320
329
  }
321
330
 
322
- function renderFeed(xml: string) {
331
+ export function renderFeed(xml: string) {
323
332
  return new Response(xml, {
324
333
  headers: {
325
334
  "Content-Type": "application/atom+xml; charset=utf-8",
@@ -335,24 +344,24 @@ feedRoutes.get("/", async (c) => {
335
344
  return renderFeed(defaultFeedRenderer(feedData));
336
345
  });
337
346
 
338
- // Atom — /feed/latest
339
- feedRoutes.get("/latest", async (c) => {
340
- const format = parseFormatQuery(c);
341
- const feedData = await buildFeedData(c, {
342
- kind: "latest",
343
- selfPath: "/feed/latest",
344
- format,
345
- });
346
- return renderFeed(defaultFeedRenderer(feedData));
347
+ // Legacy — /feed/latest moved to the canonical /latest/feed. Kept
348
+ // indefinitely as a 308 so old subscribers don't break; preserves the
349
+ // ?format= query string.
350
+ feedRoutes.get("/latest", (c) => {
351
+ const sitePathPrefix = c.var.appConfig.sitePathPrefix;
352
+ const qs = c.req.url.includes("?")
353
+ ? c.req.url.slice(c.req.url.indexOf("?"))
354
+ : "";
355
+ return c.redirect(
356
+ `${toPublicPath("/latest/feed", sitePathPrefix)}${qs}`,
357
+ 308,
358
+ );
347
359
  });
348
360
 
349
- // Atom — /feed/featured
350
- feedRoutes.get("/featured", async (c) => {
351
- const feedData = await buildFeedData(c, {
352
- kind: "featured",
353
- selfPath: "/feed/featured",
354
- });
355
- return renderFeed(defaultFeedRenderer(feedData));
361
+ // Legacy — /feed/featured moved to the canonical /featured/feed.
362
+ feedRoutes.get("/featured", (c) => {
363
+ const sitePathPrefix = c.var.appConfig.sitePathPrefix;
364
+ return c.redirect(toPublicPath("/featured/feed", sitePathPrefix), 308);
356
365
  });
357
366
 
358
367
  // Legacy aliases
@@ -362,7 +371,7 @@ feedRoutes.get("/all", (c) => {
362
371
  ? c.req.url.slice(c.req.url.indexOf("?"))
363
372
  : "";
364
373
  return c.redirect(
365
- `${toPublicPath("/feed/latest", sitePathPrefix)}${qs}`,
374
+ `${toPublicPath("/latest/feed", sitePathPrefix)}${qs}`,
366
375
  308,
367
376
  );
368
377
  });
@@ -374,13 +383,13 @@ feedRoutes.get("/atom.xml", (c) => {
374
383
  });
375
384
  feedRoutes.get("/latest/atom.xml", (c) => {
376
385
  const sitePathPrefix = c.var.appConfig.sitePathPrefix;
377
- return c.redirect(toPublicPath("/feed/latest", sitePathPrefix), 308);
386
+ return c.redirect(toPublicPath("/latest/feed", sitePathPrefix), 308);
378
387
  });
379
388
  feedRoutes.get("/featured/atom.xml", (c) => {
380
389
  const sitePathPrefix = c.var.appConfig.sitePathPrefix;
381
- return c.redirect(toPublicPath("/feed/featured", sitePathPrefix), 308);
390
+ return c.redirect(toPublicPath("/featured/feed", sitePathPrefix), 308);
382
391
  });
383
392
  feedRoutes.get("/all/atom.xml", (c) => {
384
393
  const sitePathPrefix = c.var.appConfig.sitePathPrefix;
385
- return c.redirect(toPublicPath("/feed/latest", sitePathPrefix), 308);
394
+ return c.redirect(toPublicPath("/latest/feed", sitePathPrefix), 308);
386
395
  });
@@ -15,6 +15,8 @@ import { buildPageTitle } from "../../lib/page-title.js";
15
15
  import { renderPublicPage } from "../../lib/render.js";
16
16
  import { assembleFeaturedTimeline } from "../../lib/timeline.js";
17
17
  import { toPublicPath } from "../../lib/url.js";
18
+ import { defaultFeedRenderer } from "../../lib/feed.js";
19
+ import { buildFeedData, renderFeed } from "../feed/feed.js";
18
20
  import { FeaturedPage } from "../../ui/pages/FeaturedPage.js";
19
21
 
20
22
  type Env = { Bindings: Bindings; Variables: AppVariables };
@@ -58,3 +60,18 @@ featuredRoutes.get("/", async (c) => {
58
60
  ),
59
61
  });
60
62
  });
63
+
64
+ // Atom — /featured/feed (canonical featured feed)
65
+ featuredRoutes.get("/feed", async (c) => {
66
+ const feedData = await buildFeedData(c, {
67
+ kind: "featured",
68
+ selfPath: "/featured/feed",
69
+ });
70
+ return renderFeed(defaultFeedRenderer(feedData));
71
+ });
72
+
73
+ // Legacy atom.xml suffix → canonical /featured/feed
74
+ featuredRoutes.get("/feed/atom.xml", (c) => {
75
+ const sitePathPrefix = c.var.appConfig.sitePathPrefix;
76
+ return c.redirect(toPublicPath("/featured/feed", sitePathPrefix), 308);
77
+ });
@@ -18,6 +18,8 @@ import { buildPageTitle } from "../../lib/page-title.js";
18
18
  import { renderPublicPage } from "../../lib/render.js";
19
19
  import { assembleTimeline } from "../../lib/timeline.js";
20
20
  import { toPublicPath } from "../../lib/url.js";
21
+ import { defaultFeedRenderer } from "../../lib/feed.js";
22
+ import { buildFeedData, parseFormatQuery, renderFeed } from "../feed/feed.js";
21
23
  import { HomePage } from "../../ui/pages/HomePage.js";
22
24
 
23
25
  type Env = { Bindings: Bindings; Variables: AppVariables };
@@ -65,3 +67,26 @@ latestRoutes.get("/", async (c) => {
65
67
  ),
66
68
  });
67
69
  });
70
+
71
+ // Atom — /latest/feed (canonical latest feed; accepts ?format=note|link|quote)
72
+ latestRoutes.get("/feed", async (c) => {
73
+ const format = parseFormatQuery(c);
74
+ const feedData = await buildFeedData(c, {
75
+ kind: "latest",
76
+ selfPath: "/latest/feed",
77
+ format,
78
+ });
79
+ return renderFeed(defaultFeedRenderer(feedData));
80
+ });
81
+
82
+ // Legacy atom.xml suffix → canonical /latest/feed (preserves ?format=)
83
+ latestRoutes.get("/feed/atom.xml", (c) => {
84
+ const sitePathPrefix = c.var.appConfig.sitePathPrefix;
85
+ const qs = c.req.url.includes("?")
86
+ ? c.req.url.slice(c.req.url.indexOf("?"))
87
+ : "";
88
+ return c.redirect(
89
+ `${toPublicPath("/latest/feed", sitePathPrefix)}${qs}`,
90
+ 308,
91
+ );
92
+ });
@@ -2483,7 +2483,7 @@ export function createPostService(
2483
2483
  .select()
2484
2484
  .from(posts)
2485
2485
  .where(and(eq(posts.siteId, siteId), eq(posts.threadId, rootId)))
2486
- .orderBy(posts.createdAt);
2486
+ .orderBy(posts.createdAt, posts.id);
2487
2487
 
2488
2488
  return hydratePosts(rows);
2489
2489
  },
@@ -381,6 +381,21 @@
381
381
  @media (max-width: 760px) {
382
382
  :root {
383
383
  --site-padding: 1.875rem;
384
+ }
385
+ }
386
+
387
+ /* Tufte two-column → single-column collapse.
388
+ *
389
+ * Below this width the post column is already narrowed to `min(100%, 35rem)`
390
+ * (preset.css) and there is no room for a 45% sidenote gutter, so all content
391
+ * goes full-width. Single images (MediaGallery getSingleVisualWidth) and link
392
+ * previews (.link-preview) read this token directly, so they collapse here too.
393
+ *
394
+ * Keep this breakpoint in sync with the post-column collapse (preset.css /
395
+ * ui.css feed-divider, both `max-width: 1024px`) and the sidenote float→inline
396
+ * collapse (ui.css). They are one layout switch; do not let them drift apart. */
397
+ @media (max-width: 1024px) {
398
+ :root {
384
399
  --layout-content-width: 100%;
385
400
  }
386
401
  }
package/src/styles/ui.css CHANGED
@@ -2433,7 +2433,12 @@
2433
2433
  display: none;
2434
2434
  }
2435
2435
 
2436
- @media (max-width: 760px) {
2436
+ /* Collapse sidenotes from floated margin notes to inline tap-to-toggle at the
2437
+ same width the post column drops to `min(100%, 35rem)` (preset.css) and the
2438
+ Tufte gutter disappears — see --layout-content-width in tokens.css. Below
2439
+ 1024px a floated `width: 50%; margin-right: -60%` note has no gutter to land
2440
+ in and overflows the right edge, so it must inline instead. */
2441
+ @media (max-width: 1024px) {
2437
2442
  label.margin-toggle:not(.sidenote-number) {
2438
2443
  display: inline;
2439
2444
  cursor: pointer;
@@ -11110,6 +11115,24 @@
11110
11115
  line-height: 1.28;
11111
11116
  }
11112
11117
 
11118
+ .theme-preview-accent {
11119
+ display: inline-flex;
11120
+ align-items: center;
11121
+ gap: 0.3rem;
11122
+ white-space: nowrap;
11123
+ color: var(--preview-muted-light);
11124
+ }
11125
+
11126
+ .theme-preview-swatch {
11127
+ flex: none;
11128
+ width: 0.62rem;
11129
+ height: 0.62rem;
11130
+ border-radius: 3px;
11131
+ background: var(--preview-primary-light);
11132
+ box-shadow: 0 0 0 1px
11133
+ color-mix(in srgb, var(--preview-fg-light) 18%, transparent);
11134
+ }
11135
+
11113
11136
  .theme-preview-meta {
11114
11137
  color: var(--preview-muted-light);
11115
11138
  line-height: 1.55;
@@ -11143,6 +11166,16 @@
11143
11166
  color: var(--preview-muted-dark);
11144
11167
  }
11145
11168
 
11169
+ .theme-preview-panel[data-theme-preview-mode="dark"] .theme-preview-accent {
11170
+ color: var(--preview-muted-dark);
11171
+ }
11172
+
11173
+ .theme-preview-panel[data-theme-preview-mode="dark"] .theme-preview-swatch {
11174
+ background: var(--preview-primary-dark);
11175
+ box-shadow: 0 0 0 1px
11176
+ color-mix(in srgb, var(--preview-fg-dark) 18%, transparent);
11177
+ }
11178
+
11146
11179
  .theme-preview-panel[data-theme-preview-mode="dark"] .theme-preview-divider {
11147
11180
  border-color: var(--preview-border-dark);
11148
11181
  }
@@ -11671,6 +11704,16 @@
11671
11704
  color: var(--preview-muted-dark);
11672
11705
  }
11673
11706
 
11707
+ .theme-preview-panel[data-theme-preview-mode="auto"] .theme-preview-accent {
11708
+ color: var(--preview-muted-dark);
11709
+ }
11710
+
11711
+ .theme-preview-panel[data-theme-preview-mode="auto"] .theme-preview-swatch {
11712
+ background: var(--preview-primary-dark);
11713
+ box-shadow: 0 0 0 1px
11714
+ color-mix(in srgb, var(--preview-fg-dark) 18%, transparent);
11715
+ }
11716
+
11674
11717
  .theme-preview-panel[data-theme-preview-mode="auto"]
11675
11718
  .theme-preview-divider {
11676
11719
  border-color: var(--preview-border-dark);
@@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest";
2
2
  import { BUILTIN_COLOR_THEMES } from "../color-themes.js";
3
3
 
4
4
  describe("BUILTIN_COLOR_THEMES", () => {
5
- it("contains 13 themes", () => {
6
- expect(BUILTIN_COLOR_THEMES).toHaveLength(14);
5
+ it("contains 15 themes", () => {
6
+ expect(BUILTIN_COLOR_THEMES).toHaveLength(15);
7
7
  });
8
8
 
9
9
  it("keeps Tufte as the first theme", () => {
@@ -624,6 +624,38 @@ export const BUILTIN_COLOR_THEMES: ColorTheme[] = [
624
624
  },
625
625
  }),
626
626
 
627
+ // Pure white with neutral grays — zero tint, clean and clinical
628
+ defineTheme({
629
+ id: "snow",
630
+ name: "Snow",
631
+ light: {
632
+ bg: "oklch(1 0 0)",
633
+ fg: "oklch(0.205 0 0)",
634
+ primary: "oklch(0.25 0 0)",
635
+ primaryFg: "oklch(0.99 0 0)",
636
+ siteAccent: "oklch(0.37 0 0)",
637
+ muted: "oklch(0.965 0 0)",
638
+ mutedFg: "oklch(0.5 0 0)",
639
+ border: "oklch(0.91 0 0)",
640
+ readingTitle: "oklch(0.17 0 0)",
641
+ readingHeading: "oklch(0.205 0 0)",
642
+ readingBody: "oklch(0.24 0 0)",
643
+ readingQuote: "oklch(0.4 0 0)",
644
+ dashBg: "oklch(0.975 0 0)",
645
+ },
646
+ dark: {
647
+ bg: "oklch(0.17 0 0)",
648
+ fg: "oklch(0.92 0 0)",
649
+ primary: "oklch(0.85 0 0)",
650
+ primaryFg: "oklch(0.17 0 0)",
651
+ siteAccent: "oklch(0.78 0 0)",
652
+ muted: "oklch(0.235 0 0)",
653
+ mutedFg: "oklch(0.65 0 0)",
654
+ border: "oklch(0.3 0 0)",
655
+ dashBg: "oklch(0.15 0 0)",
656
+ },
657
+ }),
658
+
627
659
  // Deep coffee brown — rich and grounded
628
660
  defineTheme({
629
661
  id: "espresso",