@jant/core 0.6.8 → 0.6.9

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 (62) hide show
  1. package/bin/commands/uploads/cleanup.js +1 -0
  2. package/dist/{app-9P4rVCe2.js → app-C-jxWmAV.js} +12324 -12157
  3. package/dist/app-DqHzOwL5.js +6 -0
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/{client-C6peCkkD.css → client-CGf2m3qp.css} +1 -1
  6. package/dist/client/_assets/{client-CXnEhyyv.js → client-DWy1LEEk.js} +1 -1
  7. package/dist/client/_assets/{client-auth-CSItbyU8.js → client-auth-Blg-a5Ep.js} +180 -162
  8. package/dist/{export-Be082J0n.js → export-C2DIB7mm.js} +2 -2
  9. package/dist/{github-sync-_kPWM4m9.js → github-sync-7XQ5ZM6z.js} +2 -2
  10. package/dist/{github-sync-D1Cw8mOY.js → github-sync-BEFCfLKK.js} +1 -1
  11. package/dist/index.js +3 -3
  12. package/dist/node.js +4 -4
  13. package/package.json +1 -1
  14. package/src/client/components/__tests__/jant-settings-avatar.test.ts +5 -2
  15. package/src/client/components/__tests__/jant-settings-general.test.ts +55 -8
  16. package/src/client/components/jant-compose-dialog.ts +12 -0
  17. package/src/client/components/jant-settings-general.ts +56 -18
  18. package/src/client/components/settings-types.ts +11 -0
  19. package/src/client/settings-bridge.ts +3 -0
  20. package/src/client/tiptap/__tests__/mark-exit.test.ts +99 -0
  21. package/src/client/tiptap/bubble-menu.ts +37 -4
  22. package/src/db/migrations/0026_absent_rhodey.sql +14 -0
  23. package/src/db/migrations/meta/0026_snapshot.json +2511 -0
  24. package/src/db/migrations/meta/_journal.json +7 -0
  25. package/src/db/migrations/pg/0024_high_violations.sql +14 -0
  26. package/src/db/migrations/pg/meta/0024_snapshot.json +3204 -0
  27. package/src/db/migrations/pg/meta/_journal.json +7 -0
  28. package/src/db/pg/schema.ts +36 -0
  29. package/src/db/schema.ts +36 -0
  30. package/src/i18n/__tests__/middleware.test.ts +46 -0
  31. package/src/i18n/locales/settings/en.po +25 -10
  32. package/src/i18n/locales/settings/en.ts +1 -1
  33. package/src/i18n/locales/settings/zh-Hans.po +25 -10
  34. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  35. package/src/i18n/locales/settings/zh-Hant.po +25 -10
  36. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  37. package/src/i18n/middleware.ts +17 -8
  38. package/src/i18n/supported-locales.ts +5 -4
  39. package/src/lib/ids.ts +1 -0
  40. package/src/lib/resolve-config.ts +1 -0
  41. package/src/lib/upload.ts +14 -0
  42. package/src/routes/api/__tests__/settings.test.ts +1 -4
  43. package/src/routes/api/__tests__/upload.test.ts +2 -0
  44. package/src/routes/api/internal/__tests__/uploads.test.ts +19 -1
  45. package/src/routes/api/settings.ts +2 -1
  46. package/src/routes/auth/__tests__/setup.test.ts +14 -0
  47. package/src/routes/dash/__tests__/settings-avatar.test.ts +35 -17
  48. package/src/routes/dash/settings.tsx +15 -2
  49. package/src/services/__tests__/media.test.ts +191 -30
  50. package/src/services/__tests__/settings.test.ts +55 -0
  51. package/src/services/bootstrap.ts +7 -0
  52. package/src/services/export-theme/layouts/_default/baseof.html +2 -1
  53. package/src/services/media.ts +169 -42
  54. package/src/services/settings.ts +49 -15
  55. package/src/services/upload-session.ts +13 -3
  56. package/src/styles/tokens.css +6 -4
  57. package/src/types/bindings.ts +1 -0
  58. package/src/types/config.ts +13 -0
  59. package/src/ui/dash/settings/GeneralContent.tsx +38 -4
  60. package/src/ui/layouts/BaseLayout.tsx +1 -0
  61. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +13 -0
  62. package/dist/app-DaxS_Cz-.js +0 -6
@@ -3295,7 +3295,7 @@ function serializeMarkdownDocument(doc) {
3295
3295
  }
3296
3296
  //#endregion
3297
3297
  //#region src/styles/tokens.css?raw
3298
- var tokens_default = "/**\n * Design Tokens\n *\n * CSS custom properties for all visual aspects of the UI.\n * These are the stable customization API — override in custom CSS\n * to change typography, layout, surfaces, and element sizing.\n */\n\n:root {\n /* Typography — Font families */\n --font-cjk-serif-fallback:\n \"Songti SC\", STSong, SimSun, \"Songti TC\", PMingLiU, MingLiU,\n \"Noto Serif SC\", \"Noto Serif CJK SC\", \"Noto Serif TC\", \"Noto Serif CJK TC\";\n --font-body:\n system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto,\n \"Helvetica Neue\", Helvetica, Arial, \"PingFang TC\", \"PingFang SC\",\n \"Hiragino Sans CNS\", \"Hiragino Sans GB\", \"Microsoft JhengHei\",\n \"Microsoft YaHei\", \"Noto Sans CJK TC\", \"Noto Sans CJK SC\", sans-serif;\n --font-heading:\n \"New York Small\", \"New York\", \"Iowan Old Style\", Charter,\n \"Bitstream Charter\", \"Source Serif 4\", Cambria, \"Sitka Text\", Georgia,\n var(--font-cjk-serif-fallback), ui-serif, serif;\n --font-site-title:\n \"New York Small\", \"New York\", \"Iowan Old Style\", Charter,\n \"Bitstream Charter\", \"Source Serif 4\", Cambria, \"Sitka Text\", Georgia,\n var(--font-cjk-serif-fallback), ui-serif, serif;\n --font-serif:\n var(--font-cjk-serif-fallback), ui-serif, \"New York Small\", \"New York\",\n \"Iowan Old Style\", Charter, Georgia, \"Times New Roman\", Times, serif;\n --font-ui:\n system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto,\n \"Helvetica Neue\", Helvetica, Arial, \"PingFang TC\", \"PingFang SC\",\n \"Hiragino Sans CNS\", \"Hiragino Sans GB\", \"Microsoft JhengHei\",\n \"Microsoft YaHei\", \"Noto Sans CJK TC\", \"Noto Sans CJK SC\", sans-serif;\n --font-mono:\n ui-monospace, Menlo, Monaco, Consolas, \"Cascadia Code\", \"Courier New\",\n monospace;\n /*\n * Blockquote font family.\n *\n * Defaults to `inherit` so blockquotes follow the body font in sans-only\n * themes (Clean, Friendly, Bold) and serif-only themes (Tufte, Bookish).\n * Mixed themes that want a distinct blockquote voice — e.g. Classic, which\n * pairs a sans body with serif headings — override this token in their\n * `cssVariables` to point at a serif stack. This also restores a type-level\n * distinction for CJK, where italic is disabled.\n */\n --font-blockquote: inherit;\n\n /* Typography — Font weights */\n --fw-light: 300;\n --fw-regular: 400;\n --fw-medium: 500;\n --fw-semibold: 600;\n --fw-bold: 700;\n --fw-extrabold: 800;\n /*\n * Unified type scale.\n *\n * Every font-size in the system — reading content AND UI controls —\n * must reference one of these tokens. No raw rem/px values elsewhere.\n *\n * --type-display 2.7rem (40.5px) — page titles, h1\n * --type-title 2.1rem (31.5px) — h2, feed card titles\n * --type-subtitle 1.8rem (27px) — h3\n * --type-body 1.4rem (21px) — running text, form inputs\n * --type-secondary 1.1rem (16.5px) — meta, nav, sidenotes, UI labels\n * --type-base 1.0rem (15px) — code, UI buttons, controls\n * --type-sm 0.9rem (13.5px) — code blocks, small UI text\n * --type-xs 0.8rem (12px) — captions, descriptions, chips\n * --type-2xs 0.65rem (9.75px) — tiny labels, file sizes, badges\n */\n --type-display: 2.7rem;\n --type-title: 2.1rem;\n --type-subtitle: 1.8rem;\n --type-body: 1.4rem;\n --type-secondary: 1.1rem;\n --type-base: 1rem;\n --type-sm: 0.9rem;\n --type-xs: 0.8rem;\n --type-2xs: 0.65rem;\n\n /* Content reading tokens — desktop maps to core scale,\n mobile overrides below cap sizes for small screens.\n --type-content-scale uniformly scales all content sizes\n without affecting UI controls. */\n --type-content-scale: 0.78;\n --type-content-display: calc(var(--type-display) * var(--type-content-scale));\n --type-content-title: calc(var(--type-title) * var(--type-content-scale));\n --type-content-subtitle: calc(\n var(--type-subtitle) * var(--type-content-scale)\n );\n --type-content-body: calc(var(--type-body) * var(--type-content-scale));\n\n /* Semantic aliases */\n --type-code: var(--type-base);\n --type-code-block: var(--type-sm);\n --type-body-size: var(--type-content-body);\n --type-body-tracking: 0;\n --type-ui-title: var(--type-secondary);\n --type-ui-control: var(--type-base);\n --type-ui-meta: var(--type-base);\n --type-ui-hint: var(--type-sm);\n --type-ui-caption: var(--type-xs);\n --type-ui-micro: var(--type-2xs);\n --type-ui-input: var(--type-secondary);\n --type-thread-context: var(--type-base);\n --type-thread-context-title: var(--type-secondary);\n --type-thread-context-meta: var(--type-sm);\n --feed-note-title-size: var(--type-content-title);\n --feed-note-title-leading: var(--type-heading-leading);\n --type-body-leading: 1.5;\n --type-display-leading: 1.15;\n --type-heading-leading: 1.15;\n --type-heading-weight: var(--fw-regular);\n --type-heading-tracking: 0;\n --type-display-weight: var(--fw-regular);\n --type-display-tracking: 0;\n --type-label-weight: var(--fw-medium);\n --type-label-tracking: 0.08em;\n\n /* Layout — Tufte proportional model */\n --content-max-width: 42rem;\n --form-max-width: 42rem;\n /* compose dialogs + feed content cap */\n --layout-body-max-width: 1088px;\n --layout-content-width: 55%;\n --layout-sidenote-width: 50%;\n --layout-sidenote-margin: -60%;\n --site-padding: 1.5rem;\n --content-gap: 1rem;\n --space-xl: 2rem;\n --space-2xl: 4rem;\n /*\n * Home timeline vertical rhythm — the single source of truth for the gaps\n * between stacked sections on the home page:\n *\n * site description → separator → first post → divider → post → ...\n *\n * where \"separator\" is the description hairline (logged out) or the compose\n * prompt (logged in). Every one of those gaps derives from this token, and\n * the individual pieces (compose prompt, description hairline, post cards)\n * carry no rhythm margin of their own — the structural wrappers\n * (.site-home-header, hr.feed-divider) own the spacing. Retune the whole\n * feed cadence by changing this one value.\n *\n * Note: per-format card padding (.feed-quote-post, thread previews) is the\n * card's own internal spacing and is intentionally NOT part of this rhythm.\n *\n * Kept as a free-standing value (not pinned to the --space-* scale) so the\n * feed cadence can be tuned independently.\n */\n --site-feed-rhythm: 4.4rem;\n /*\n * The post footer's action row (reply / menu buttons) is a taller tap\n * target than its visible content, leaving a few px of dead space below\n * the last visible element of every post (~5.9px logged out from the\n * footer min-height, ~6.8px logged in from the menu button). hr.feed-divider\n * subtracts this from its top margin so the VISIBLE gap between posts equals\n * --site-feed-rhythm — the same as the header spacing. Re-measure if the\n * footer button size or meta font changes.\n */\n --post-footer-overshoot: 6px;\n /*\n * The site intro (description) block is deliberately tighter than\n * --site-feed-rhythm so it reads as a compact header unit rather than a\n * full feed section. -top: gap from the header nav down to the intro.\n * -bottom: gap from the intro down to its separator — the hairline (logged\n * out) or the compose prompt text (logged in). Same values in both states.\n */\n --site-intro-gap-top: 24px;\n --site-intro-gap-bottom: 36px;\n\n /* Sidebar layout (admin + site sidebar pages) */\n --sidebar-width: 12rem;\n --sidebar-gap: 2rem;\n\n /* Surfaces */\n --card-bg: var(--card);\n --card-radius: 0;\n --card-padding: 1rem;\n --card-border-width: 0;\n --card-shadow: none;\n\n /* Elements */\n --avatar-size: 28px;\n --avatar-radius: 50%;\n --media-radius: 0.5rem;\n\n /* Icons */\n --icon-stroke: 2;\n --icon-stroke-fine: 1.5;\n\n /* Derived color tokens (from BaseCoat variables) */\n --site-accent: var(--primary);\n --site-accent-text: var(--primary-foreground);\n --site-column-outline: var(--border);\n --site-border-light: color-mix(\n in srgb,\n var(--site-column-outline) 52%,\n transparent\n );\n --site-threadline: var(--border);\n --site-page-bg: var(--background);\n --site-elevated-bg: var(--background);\n --site-nav-hover-bg: var(--accent);\n --site-text-primary: var(--foreground);\n --site-text-secondary: var(--muted-foreground);\n --site-reading-title: color-mix(\n in oklch,\n var(--site-text-primary) 81%,\n black\n );\n --site-reading-heading: color-mix(\n in oklch,\n var(--site-text-primary) 86%,\n black\n );\n --site-reading-body: color-mix(in oklch, var(--site-text-primary) 90%, black);\n --site-reading-quote: color-mix(\n in oklch,\n var(--site-text-primary) 95%,\n black\n );\n --site-reading-meta: color-mix(\n in srgb,\n var(--site-text-secondary) 72%,\n var(--site-text-primary)\n );\n --site-reading-caption: color-mix(\n in srgb,\n var(--site-text-secondary) 88%,\n var(--site-text-primary)\n );\n --site-content-link: inherit;\n --site-content-link-hover: var(--site-text-primary);\n --site-content-link-underline: color-mix(\n in srgb,\n var(--site-text-secondary) 58%,\n transparent\n );\n --site-reading-link: var(--site-reading-body);\n --site-reading-link-hover: var(--site-reading-heading);\n --site-reading-link-underline: color-mix(\n in srgb,\n var(--site-reading-meta) 58%,\n transparent\n );\n --site-text-placeholder: oklch(from var(--muted-foreground) l c h / 0.5);\n --site-media-outline: var(--border);\n --site-divider: var(--border);\n --site-feed-card-bg: color-mix(\n in srgb,\n var(--site-elevated-bg) 88%,\n var(--site-nav-hover-bg)\n );\n --site-feed-card-border: color-mix(\n in srgb,\n var(--site-divider) 78%,\n transparent\n );\n --site-feed-card-shadow: color-mix(\n in srgb,\n var(--site-text-primary) 12%,\n transparent\n );\n --site-feed-divider-color: color-mix(\n in srgb,\n var(--site-text-secondary) 30%,\n transparent\n );\n --site-feed-link-tint: color-mix(in srgb, var(--site-accent) 7%, transparent);\n --site-feed-quote-tint: color-mix(\n in srgb,\n var(--site-accent) 10%,\n transparent\n );\n --site-blockquote-rail: color-mix(\n in srgb,\n var(--site-accent) 22%,\n var(--site-divider)\n );\n --site-blockquote-bg: color-mix(\n in srgb,\n var(--site-feed-quote-tint) 62%,\n var(--site-page-bg)\n );\n --site-blockquote-text: color-mix(\n in oklch,\n var(--site-text-primary) 92%,\n black\n );\n --site-summary-blockquote-bg: color-mix(\n in srgb,\n var(--site-feed-quote-tint) 42%,\n var(--site-page-bg)\n );\n --site-reading-blockquote-rail: color-mix(\n in srgb,\n var(--site-reading-link) 18%,\n var(--site-reading-meta)\n );\n --site-reading-blockquote-bg: color-mix(\n in srgb,\n var(--site-accent) 6%,\n var(--site-page-bg)\n );\n --site-thread-context-bg: color-mix(\n in srgb,\n var(--site-nav-hover-bg) 58%,\n transparent\n );\n --site-thread-context-border: color-mix(\n in srgb,\n var(--site-divider) 74%,\n transparent\n );\n --site-thread-gap-bg: color-mix(\n in srgb,\n var(--site-nav-hover-bg) 42%,\n transparent\n );\n --site-thread-item-spacing: 32px;\n --site-thread-context-max-height: 160px;\n --site-thread-dot-ring: color-mix(\n in srgb,\n var(--site-accent) 16%,\n transparent\n );\n --compose-paper-bg: var(--site-page-bg);\n --compose-control-bg: color-mix(\n in srgb,\n var(--site-nav-hover-bg) 72%,\n var(--compose-paper-bg)\n );\n --compose-control-bg-strong: color-mix(\n in srgb,\n var(--site-nav-hover-bg) 88%,\n var(--compose-paper-bg)\n );\n --compose-control-border: color-mix(\n in srgb,\n var(--site-column-outline) 68%,\n transparent\n );\n --compose-blockquote-rail: color-mix(\n in srgb,\n var(--site-accent) 24%,\n var(--compose-control-border)\n );\n --compose-blockquote-bg: color-mix(\n in srgb,\n var(--compose-control-bg) 56%,\n var(--compose-paper-bg)\n );\n --compose-blockquote-bg-focus: color-mix(\n in srgb,\n var(--compose-control-bg-strong) 70%,\n var(--compose-paper-bg)\n );\n --compose-blockquote-text: color-mix(\n in srgb,\n var(--site-text-primary) 88%,\n var(--site-text-secondary)\n );\n --compose-floating-bg: color-mix(\n in srgb,\n var(--compose-paper-bg) 94%,\n var(--site-nav-hover-bg) 6%\n );\n\n /* Search highlight */\n --search-mark-bg: oklch(0.92 0.14 90 / 0.55);\n --search-mark-color: oklch(0.35 0.09 70);\n\n /* Admin */\n --dash-bg: oklch(0.97 0.005 80);\n --dash-card-radius: 10px;\n}\n\n@media (max-width: 760px) {\n :root {\n --site-padding: 1.875rem;\n --layout-content-width: 100%;\n }\n}\n\n/*\n * Dark-mode reading color overrides.\n *\n * These rules must beat the active color theme's light-mode block, which\n * uses `:root:root { ... }` (specificity 0,0,2,0) with no media query and\n * therefore applies in both light and dark modes. Most themes do not\n * redefine `--site-reading-*` in their dark block, so without higher\n * specificity here the light reading-body color would leak into dark mode\n * (resulting in near-invisible body text on a dark background).\n *\n * We repeat `:root:root` to reach specificity 0,0,3,0, which outranks the\n * theme's light `:root:root` (0,0,2,0). The theme's dark blocks use\n * `:root:root[data-theme-mode=\"dark\"]` or `:root:root:not([data-theme-mode=\"light\"])`\n * (also 0,0,3,0), so a theme that explicitly defines dark reading colors\n * still wins via source order.\n */\n@media (prefers-color-scheme: dark) {\n :root:root:not([data-theme-mode=\"light\"]) {\n --site-reading-title: var(--site-text-primary);\n --site-reading-heading: color-mix(\n in oklch,\n var(--site-text-primary) 94%,\n var(--site-text-secondary)\n );\n --site-reading-body: color-mix(\n in oklch,\n var(--site-text-primary) 96%,\n black\n );\n --site-reading-quote: color-mix(\n in oklch,\n var(--site-text-primary) 98%,\n black\n );\n --site-reading-meta: color-mix(\n in srgb,\n var(--site-text-secondary) 92%,\n var(--site-text-primary)\n );\n --site-reading-caption: color-mix(\n in srgb,\n var(--site-text-secondary) 96%,\n var(--site-text-primary)\n );\n --site-reading-link: var(--site-reading-body);\n --search-mark-bg: oklch(0.45 0.1 85 / 0.5);\n --search-mark-color: oklch(0.92 0.08 90);\n --dash-bg: oklch(0.2 0.005 80);\n }\n}\n\n:root:root[data-theme-mode=\"dark\"] {\n --site-reading-title: var(--site-text-primary);\n --site-reading-heading: color-mix(\n in oklch,\n var(--site-text-primary) 94%,\n var(--site-text-secondary)\n );\n --site-reading-body: color-mix(in oklch, var(--site-text-primary) 96%, black);\n --site-reading-quote: color-mix(\n in oklch,\n var(--site-text-primary) 98%,\n black\n );\n --site-reading-meta: color-mix(\n in srgb,\n var(--site-text-secondary) 92%,\n var(--site-text-primary)\n );\n --site-reading-caption: color-mix(\n in srgb,\n var(--site-text-secondary) 96%,\n var(--site-text-primary)\n );\n --site-reading-link: var(--site-reading-body);\n --search-mark-bg: oklch(0.45 0.1 85 / 0.5);\n --search-mark-color: oklch(0.92 0.08 90);\n --dash-bg: oklch(0.2 0.005 80);\n}\n\n@media (max-width: 760px), (hover: none) and (pointer: coarse) {\n :root {\n /* Content layer — tighter scale for mobile reading\n Ratio ≈ 1.9 : 1.4 : 1.15 : 1 (display:title:subtitle:body)\n 13px base → 31.9 / 24 / 19.5 / 16.9 */\n --type-content-display: calc(2.45rem * var(--type-content-scale));\n --type-content-title: calc(1.85rem * var(--type-content-scale));\n --type-content-subtitle: calc(1.5rem * var(--type-content-scale));\n --type-content-body: calc(1.3rem * var(--type-content-scale));\n\n /* UI layer — pixel floors for touch targets */\n --type-ui-title: max(16px, var(--type-secondary));\n --type-ui-control: max(15px, var(--type-base));\n --type-ui-meta: max(15px, var(--type-secondary));\n --type-ui-hint: max(13px, var(--type-sm));\n --type-ui-caption: max(13px, var(--type-xs));\n --type-ui-micro: max(12px, var(--type-2xs));\n --type-ui-input: max(16px, var(--type-secondary));\n --type-thread-context: max(15px, var(--type-base));\n --type-thread-context-title: max(16px, var(--type-secondary));\n --type-thread-context-meta: max(14px, var(--type-sm));\n }\n}\n";
3298
+ var tokens_default = "/**\n * Design Tokens\n *\n * CSS custom properties for all visual aspects of the UI.\n * These are the stable customization API — override in custom CSS\n * to change typography, layout, surfaces, and element sizing.\n */\n\n:root {\n /* Typography — Font families */\n --font-cjk-serif-fallback:\n \"Songti SC\", STSong, SimSun, \"Songti TC\", PMingLiU, MingLiU,\n \"Noto Serif SC\", \"Noto Serif CJK SC\", \"Noto Serif TC\", \"Noto Serif CJK TC\";\n --font-body:\n system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto,\n \"Helvetica Neue\", Helvetica, Arial, \"PingFang TC\", \"PingFang SC\",\n \"Hiragino Sans CNS\", \"Hiragino Sans GB\", \"Microsoft JhengHei\",\n \"Microsoft YaHei\", \"Noto Sans CJK TC\", \"Noto Sans CJK SC\", sans-serif;\n --font-heading:\n \"New York Small\", \"New York\", \"Iowan Old Style\", Charter,\n \"Bitstream Charter\", \"Source Serif 4\", Cambria, \"Sitka Text\", Georgia,\n var(--font-cjk-serif-fallback), ui-serif, serif;\n --font-site-title:\n \"New York Small\", \"New York\", \"Iowan Old Style\", Charter,\n \"Bitstream Charter\", \"Source Serif 4\", Cambria, \"Sitka Text\", Georgia,\n var(--font-cjk-serif-fallback), ui-serif, serif;\n --font-serif:\n var(--font-cjk-serif-fallback), ui-serif, \"New York Small\", \"New York\",\n \"Iowan Old Style\", Charter, Georgia, \"Times New Roman\", Times, serif;\n --font-ui:\n system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto,\n \"Helvetica Neue\", Helvetica, Arial, \"PingFang TC\", \"PingFang SC\",\n \"Hiragino Sans CNS\", \"Hiragino Sans GB\", \"Microsoft JhengHei\",\n \"Microsoft YaHei\", \"Noto Sans CJK TC\", \"Noto Sans CJK SC\", sans-serif;\n --font-mono:\n ui-monospace, Menlo, Monaco, Consolas, \"Cascadia Code\", \"Courier New\",\n monospace;\n /*\n * Blockquote font family.\n *\n * Defaults to `inherit` so blockquotes follow the body font in sans-only\n * themes (Clean, Friendly, Bold) and serif-only themes (Tufte, Bookish).\n * Mixed themes that want a distinct blockquote voice — e.g. Classic, which\n * pairs a sans body with serif headings — override this token in their\n * `cssVariables` to point at a serif stack. This also restores a type-level\n * distinction for CJK, where italic is disabled.\n */\n --font-blockquote: inherit;\n\n /* Typography — Font weights */\n --fw-light: 300;\n --fw-regular: 400;\n --fw-medium: 500;\n --fw-semibold: 600;\n --fw-bold: 700;\n --fw-extrabold: 800;\n /*\n * Unified type scale.\n *\n * Every font-size in the system — reading content AND UI controls —\n * must reference one of these tokens. No raw rem/px values elsewhere.\n *\n * --type-display 2.7rem (40.5px) — page titles, h1\n * --type-title 2.1rem (31.5px) — h2, feed card titles\n * --type-subtitle 1.8rem (27px) — h3\n * --type-body 1.4rem (21px) — running text, form inputs\n * --type-secondary 1.1rem (16.5px) — meta, nav, sidenotes, UI labels\n * --type-base 1.0rem (15px) — code, UI buttons, controls\n * --type-sm 0.9rem (13.5px) — code blocks, small UI text\n * --type-xs 0.8rem (12px) — captions, descriptions, chips\n * --type-2xs 0.65rem (9.75px) — tiny labels, file sizes, badges\n */\n --type-display: 2.7rem;\n --type-title: 2.1rem;\n --type-subtitle: 1.8rem;\n --type-body: 1.4rem;\n --type-secondary: 1.1rem;\n --type-base: 1rem;\n --type-sm: 0.9rem;\n --type-xs: 0.8rem;\n --type-2xs: 0.65rem;\n\n /* Content reading tokens — desktop maps to core scale,\n mobile overrides below cap sizes for small screens.\n --type-content-scale uniformly scales all content sizes\n without affecting UI controls. */\n --type-content-scale: 0.78;\n --type-content-display: calc(var(--type-display) * var(--type-content-scale));\n --type-content-title: calc(var(--type-title) * var(--type-content-scale));\n --type-content-subtitle: calc(\n var(--type-subtitle) * var(--type-content-scale)\n );\n --type-content-body: calc(var(--type-body) * var(--type-content-scale));\n\n /* Semantic aliases */\n --type-code: var(--type-base);\n --type-code-block: var(--type-sm);\n --type-body-size: var(--type-content-body);\n --type-body-tracking: 0;\n --type-ui-title: var(--type-secondary);\n --type-ui-control: var(--type-base);\n --type-ui-meta: var(--type-base);\n --type-ui-hint: var(--type-sm);\n --type-ui-caption: var(--type-xs);\n --type-ui-micro: var(--type-2xs);\n --type-ui-input: var(--type-secondary);\n --type-thread-context: var(--type-base);\n --type-thread-context-title: var(--type-secondary);\n --type-thread-context-meta: var(--type-sm);\n --feed-note-title-size: var(--type-content-title);\n --feed-note-title-leading: var(--type-heading-leading);\n --type-body-leading: 1.5;\n --type-display-leading: 1.15;\n --type-heading-leading: 1.15;\n --type-heading-weight: var(--fw-regular);\n --type-heading-tracking: 0;\n --type-display-weight: var(--fw-regular);\n --type-display-tracking: 0;\n --type-label-weight: var(--fw-medium);\n --type-label-tracking: 0.08em;\n\n /* Layout — Tufte proportional model */\n --content-max-width: 42rem;\n --form-max-width: 42rem;\n /* compose dialogs + feed content cap */\n --layout-body-max-width: 1088px;\n --layout-content-width: 55%;\n --layout-sidenote-width: 50%;\n --layout-sidenote-margin: -60%;\n --site-padding: 1.5rem;\n --content-gap: 1rem;\n --space-xl: 2rem;\n --space-2xl: 4rem;\n /*\n * Home timeline vertical rhythm — the single source of truth for the gaps\n * between stacked sections on the home page:\n *\n * site description → separator → first post → divider → post → ...\n *\n * where \"separator\" is the description hairline (logged out) or the compose\n * prompt (logged in). Every one of those gaps derives from this token, and\n * the individual pieces (compose prompt, description hairline, post cards)\n * carry no rhythm margin of their own — the structural wrappers\n * (.site-home-header, hr.feed-divider) own the spacing. Retune the whole\n * feed cadence by changing this one value.\n *\n * Note: per-format card padding (.feed-quote-post, thread previews) is the\n * card's own internal spacing and is intentionally NOT part of this rhythm.\n *\n * Kept as a free-standing value (not pinned to the --space-* scale) so the\n * feed cadence can be tuned independently.\n */\n --site-feed-rhythm: 4.4rem;\n /*\n * The post footer's action row (reply / menu buttons) is a taller tap\n * target than its visible content, leaving a few px of dead space below\n * the last visible element of every post (~5.9px logged out from the\n * footer min-height, ~6.8px logged in from the menu button). hr.feed-divider\n * subtracts this from its top margin so the VISIBLE gap between posts equals\n * --site-feed-rhythm — the same as the header spacing. Re-measure if the\n * footer button size or meta font changes.\n */\n --post-footer-overshoot: 6px;\n /*\n * The site intro (description) block is deliberately tighter than\n * --site-feed-rhythm so it reads as a compact header unit rather than a\n * full feed section. -top: gap from the header nav down to the intro.\n * -bottom: gap from the intro down to its separator — the hairline (logged\n * out) or the compose prompt text (logged in). Same values in both states.\n */\n --site-intro-gap-top: 24px;\n --site-intro-gap-bottom: 36px;\n\n /* Sidebar layout (admin + site sidebar pages) */\n --sidebar-width: 12rem;\n --sidebar-gap: 2rem;\n\n /* Surfaces */\n --card-bg: var(--card);\n --card-radius: 0;\n --card-padding: 1rem;\n --card-border-width: 0;\n --card-shadow: none;\n\n /* Elements */\n --avatar-size: 28px;\n --avatar-radius: 50%;\n --media-radius: 0.5rem;\n\n /* Icons */\n --icon-stroke: 2;\n --icon-stroke-fine: 1.5;\n\n /* Derived color tokens (from BaseCoat variables) */\n --site-accent: var(--primary);\n --site-accent-text: var(--primary-foreground);\n --site-column-outline: var(--border);\n --site-border-light: color-mix(\n in srgb,\n var(--site-column-outline) 52%,\n transparent\n );\n --site-threadline: var(--border);\n --site-page-bg: var(--background);\n --site-elevated-bg: var(--background);\n --site-nav-hover-bg: var(--accent);\n --site-text-primary: var(--foreground);\n --site-text-secondary: var(--muted-foreground);\n --site-reading-title: color-mix(\n in oklch,\n var(--site-text-primary) 81%,\n black\n );\n --site-reading-heading: color-mix(\n in oklch,\n var(--site-text-primary) 86%,\n black\n );\n --site-reading-body: color-mix(in oklch, var(--site-text-primary) 90%, black);\n --site-reading-quote: color-mix(\n in oklch,\n var(--site-text-primary) 95%,\n black\n );\n --site-reading-meta: color-mix(\n in srgb,\n var(--site-text-secondary) 72%,\n var(--site-text-primary)\n );\n --site-reading-caption: color-mix(\n in srgb,\n var(--site-text-secondary) 88%,\n var(--site-text-primary)\n );\n --site-content-link: inherit;\n --site-content-link-hover: var(--site-text-primary);\n --site-content-link-underline: color-mix(\n in srgb,\n var(--site-text-secondary) 58%,\n transparent\n );\n --site-reading-link: var(--site-reading-body);\n --site-reading-link-hover: var(--site-reading-heading);\n --site-reading-link-underline: color-mix(\n in srgb,\n var(--site-reading-meta) 58%,\n transparent\n );\n --site-text-placeholder: oklch(from var(--muted-foreground) l c h / 0.5);\n --site-media-outline: var(--border);\n --site-divider: var(--border);\n --site-feed-card-bg: color-mix(\n in srgb,\n var(--site-elevated-bg) 88%,\n var(--site-nav-hover-bg)\n );\n --site-feed-card-border: color-mix(\n in srgb,\n var(--site-divider) 78%,\n transparent\n );\n --site-feed-card-shadow: color-mix(\n in srgb,\n var(--site-text-primary) 12%,\n transparent\n );\n --site-feed-divider-color: color-mix(\n in srgb,\n var(--site-text-secondary) 30%,\n transparent\n );\n --site-feed-link-tint: color-mix(in srgb, var(--site-accent) 7%, transparent);\n --site-feed-quote-tint: color-mix(\n in srgb,\n var(--site-accent) 10%,\n transparent\n );\n --site-blockquote-rail: color-mix(\n in srgb,\n var(--site-accent) 22%,\n var(--site-divider)\n );\n --site-blockquote-bg: color-mix(\n in srgb,\n var(--site-feed-quote-tint) 62%,\n var(--site-page-bg)\n );\n --site-blockquote-text: color-mix(\n in oklch,\n var(--site-text-primary) 92%,\n black\n );\n --site-summary-blockquote-bg: color-mix(\n in srgb,\n var(--site-feed-quote-tint) 42%,\n var(--site-page-bg)\n );\n --site-reading-blockquote-rail: color-mix(\n in srgb,\n var(--site-reading-link) 18%,\n var(--site-reading-meta)\n );\n --site-reading-blockquote-bg: color-mix(\n in srgb,\n var(--site-accent) 6%,\n var(--site-page-bg)\n );\n --site-thread-context-bg: color-mix(\n in srgb,\n var(--site-nav-hover-bg) 58%,\n transparent\n );\n --site-thread-context-border: color-mix(\n in srgb,\n var(--site-divider) 74%,\n transparent\n );\n --site-thread-gap-bg: color-mix(\n in srgb,\n var(--site-nav-hover-bg) 42%,\n transparent\n );\n --site-thread-item-spacing: 32px;\n --site-thread-context-max-height: 160px;\n --site-thread-dot-ring: color-mix(\n in srgb,\n var(--site-accent) 16%,\n transparent\n );\n --compose-paper-bg: var(--site-page-bg);\n --compose-control-bg: color-mix(\n in srgb,\n var(--site-nav-hover-bg) 72%,\n var(--compose-paper-bg)\n );\n --compose-control-bg-strong: color-mix(\n in srgb,\n var(--site-nav-hover-bg) 88%,\n var(--compose-paper-bg)\n );\n --compose-control-border: color-mix(\n in srgb,\n var(--site-column-outline) 68%,\n transparent\n );\n --compose-blockquote-rail: color-mix(\n in srgb,\n var(--site-accent) 24%,\n var(--compose-control-border)\n );\n --compose-blockquote-bg: color-mix(\n in srgb,\n var(--compose-control-bg) 56%,\n var(--compose-paper-bg)\n );\n --compose-blockquote-bg-focus: color-mix(\n in srgb,\n var(--compose-control-bg-strong) 70%,\n var(--compose-paper-bg)\n );\n --compose-blockquote-text: color-mix(\n in srgb,\n var(--site-text-primary) 88%,\n var(--site-text-secondary)\n );\n --compose-floating-bg: color-mix(\n in srgb,\n var(--compose-paper-bg) 94%,\n var(--site-nav-hover-bg) 6%\n );\n\n /* Search highlight */\n --search-mark-bg: oklch(0.92 0.14 90 / 0.55);\n --search-mark-color: oklch(0.35 0.09 70);\n\n /* Admin */\n --dash-bg: oklch(0.97 0.005 80);\n --dash-card-radius: 10px;\n}\n\n@media (max-width: 760px) {\n :root {\n --site-padding: 1.875rem;\n --layout-content-width: 100%;\n }\n}\n\n/*\n * Dark-mode reading color overrides.\n *\n * These rules must beat the active color theme's light-mode block, which\n * uses `:root:root { ... }` (specificity 0,0,2,0) with no media query and\n * therefore applies in both light and dark modes. Most themes do not\n * redefine `--site-reading-*` in their dark block, so without higher\n * specificity here the light reading-body color would leak into dark mode\n * (resulting in near-invisible body text on a dark background).\n *\n * We repeat `:root:root` to reach specificity 0,0,3,0, which outranks the\n * theme's light `:root:root` (0,0,2,0). The theme's dark blocks use\n * `:root:root[data-theme-mode=\"dark\"]` or `:root:root:not([data-theme-mode=\"light\"])`\n * (also 0,0,3,0), so a theme that explicitly defines dark reading colors\n * still wins via source order.\n */\n@media (prefers-color-scheme: dark) {\n :root:root:not([data-theme-mode=\"light\"]) {\n --site-reading-title: var(--site-text-primary);\n --site-reading-heading: color-mix(\n in oklch,\n var(--site-text-primary) 94%,\n var(--site-text-secondary)\n );\n --site-reading-body: color-mix(\n in oklch,\n var(--site-text-primary) 96%,\n black\n );\n --site-reading-quote: color-mix(\n in oklch,\n var(--site-text-primary) 98%,\n black\n );\n --site-reading-meta: color-mix(\n in srgb,\n var(--site-text-secondary) 92%,\n var(--site-text-primary)\n );\n --site-reading-caption: color-mix(\n in srgb,\n var(--site-text-secondary) 96%,\n var(--site-text-primary)\n );\n --site-reading-link: var(--site-reading-body);\n --search-mark-bg: oklch(0.45 0.1 85 / 0.5);\n --search-mark-color: oklch(0.92 0.08 90);\n --dash-bg: oklch(0.2 0.005 80);\n }\n}\n\n:root:root[data-theme-mode=\"dark\"] {\n --site-reading-title: var(--site-text-primary);\n --site-reading-heading: color-mix(\n in oklch,\n var(--site-text-primary) 94%,\n var(--site-text-secondary)\n );\n --site-reading-body: color-mix(in oklch, var(--site-text-primary) 96%, black);\n --site-reading-quote: color-mix(\n in oklch,\n var(--site-text-primary) 98%,\n black\n );\n --site-reading-meta: color-mix(\n in srgb,\n var(--site-text-secondary) 92%,\n var(--site-text-primary)\n );\n --site-reading-caption: color-mix(\n in srgb,\n var(--site-text-secondary) 96%,\n var(--site-text-primary)\n );\n --site-reading-link: var(--site-reading-body);\n --search-mark-bg: oklch(0.45 0.1 85 / 0.5);\n --search-mark-color: oklch(0.92 0.08 90);\n --dash-bg: oklch(0.2 0.005 80);\n}\n\n@media (max-width: 760px), (hover: none) and (pointer: coarse) {\n :root {\n /* Content layer — tighter scale for mobile reading.\n Ratio ≈ 1.88 : 1.42 : 1.15 : 1 (display:title:subtitle:body).\n At 15px root × 0.78 content-scale: 28.7 / 21.6 / 17.6 / 15.2px.\n Body is floored to a 16px minimum below (max()) for readability;\n the floor still yields to calc() when the user zooms the root up. */\n --type-content-display: calc(2.45rem * var(--type-content-scale));\n --type-content-title: calc(1.85rem * var(--type-content-scale));\n --type-content-subtitle: calc(1.5rem * var(--type-content-scale));\n --type-content-body: max(16px, calc(1.3rem * var(--type-content-scale)));\n\n /* UI layer — pixel floors for touch targets */\n --type-ui-title: max(16px, var(--type-secondary));\n --type-ui-control: max(15px, var(--type-base));\n --type-ui-meta: max(15px, var(--type-secondary));\n --type-ui-hint: max(13px, var(--type-sm));\n --type-ui-caption: max(13px, var(--type-xs));\n --type-ui-micro: max(12px, var(--type-2xs));\n --type-ui-input: max(16px, var(--type-secondary));\n --type-thread-context: max(15px, var(--type-base));\n --type-thread-context-title: max(16px, var(--type-secondary));\n --type-thread-context-meta: max(14px, var(--type-sm));\n }\n}\n";
3299
3299
  //#endregion
3300
3300
  //#region src/services/export-theme/theme.toml?raw
3301
3301
  var theme_default = "name = \"jant\"\nlicense = \"MIT\"\nlicenselink = \"https://github.com/jant-me/jant/blob/main/LICENSE\"\ndescription = \"Default theme packaged with Jant exports.\"\nhomepage = \"https://jant.so\"\ntags = [\"blog\", \"microblog\", \"minimal\"]\nfeatures = [\"pagination\", \"aliases\"]\nmin_version = \"0.160.1\"\n\n[author]\n name = \"Jant\"\n homepage = \"https://jant.so\"\n";
@@ -3310,7 +3310,7 @@ var client_site_default$1 = "var e=null,t=0,n=!1,r=null,i=null,a=0;function o(e)
3310
3310
  var client_site_default = "@keyframes lightbox-fade-in{0%{opacity:0}to{opacity:1}}@keyframes lightbox-scale-in{0%{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}.media-lightbox{background:0 0;border:none;outline:none;width:100%;max-width:100%;height:100%;max-height:100%;padding:0}.media-lightbox[open]{animation:.2s both lightbox-fade-in}.media-lightbox::backdrop{background-color:#000}.media-lightbox-content{outline:none;width:100%;height:100dvh;position:relative}.media-lightbox-stage{box-sizing:border-box;justify-content:center;align-items:center;width:100%;height:100%;padding:64px 96px;display:flex;overflow:hidden}.media-lightbox-stage-scroll{overscroll-behavior:contain;scrollbar-gutter:stable both-edges;-webkit-overflow-scrolling:touch;align-items:flex-start;overflow:hidden auto}.media-lightbox-img{object-fit:contain;border-radius:4px;max-width:100%;max-height:100%;animation:.28s cubic-bezier(.22,1,.36,1) both lightbox-scale-in;display:block}.media-lightbox-img-zoomable{cursor:zoom-in}.media-lightbox-img-scroll{width:min(100%,44rem);max-width:none;height:auto;max-height:none;margin:0 auto}.media-lightbox-img-zoomable.media-lightbox-img-scroll{cursor:zoom-out}.media-lightbox-close{z-index:10;-webkit-backdrop-filter:blur(8px);color:#fff;cursor:pointer;background-color:#00000080;border:none;border-radius:50%;justify-content:center;align-items:center;width:40px;height:40px;transition:background-color .15s;display:flex;position:fixed;top:16px;left:16px}.media-lightbox-close:hover{background-color:#000000b3}.media-lightbox-nav{z-index:10;-webkit-backdrop-filter:blur(8px);color:#fff;cursor:pointer;background-color:#00000080;border:none;border-radius:50%;justify-content:center;align-items:center;width:44px;height:44px;transition:background-color .15s;display:flex;position:fixed;top:50%;transform:translateY(-50%)}.media-lightbox-nav:hover{background-color:#000000b3}.media-lightbox-nav-prev{left:16px}.media-lightbox-nav-next{right:16px}.media-lightbox-counter{z-index:10;font-size:var(--type-xs);color:#ffffffb3;font-variant-numeric:tabular-nums;-webkit-user-select:none;user-select:none;position:fixed;top:20px;left:50%;transform:translate(-50%)}.media-lightbox-short-frame{max-width:100%;max-height:100%;display:block;position:relative}.media-lightbox-short-controls{z-index:2;pointer-events:none;height:86px;position:absolute;bottom:0;left:0;right:0}.media-lightbox-short-progress{--media-progress:0%;z-index:1;appearance:none;pointer-events:auto;cursor:pointer;background:0 0;width:auto;height:34px;margin:0;padding:0;display:block;position:absolute;bottom:0;left:16px;right:16px}.media-lightbox-short-progress::-webkit-slider-runnable-track{background:linear-gradient(to right, #ffffffeb 0, #ffffffeb var(--media-progress), #ffffff2e var(--media-progress), #ffffff2e 100%);border-radius:999px;height:2px;transition:height .15s,background .15s}.media-lightbox-short-progress::-webkit-slider-thumb{appearance:none;opacity:.82;background-color:#fff;border:none;border-radius:999px;width:10px;height:10px;margin-top:-4px;transition:opacity .15s,transform .15s;box-shadow:0 1px 4px #00000052}.media-lightbox-short-progress::-moz-range-track{background:#ffffff2e;border:none;border-radius:999px;height:2px}.media-lightbox-short-progress::-moz-range-progress{background:#ffffffeb;border-radius:999px;height:2px}.media-lightbox-short-progress::-moz-range-thumb{opacity:.82;background-color:#fff;border:none;border-radius:999px;width:10px;height:10px;transition:opacity .15s,transform .15s;box-shadow:0 1px 4px #00000052}.media-lightbox-short-controls-portrait .media-lightbox-short-progress{width:calc(100% - 48px);right:auto}.media-lightbox-short-progress:hover::-webkit-slider-runnable-track{height:4px}.media-lightbox-short-progress:focus-visible::-webkit-slider-runnable-track{height:4px}.media-lightbox-short-progress:hover::-moz-range-track{height:4px}.media-lightbox-short-progress:focus-visible::-moz-range-track{height:4px}.media-lightbox-short-progress:hover::-moz-range-progress{height:4px}.media-lightbox-short-progress:focus-visible::-moz-range-progress{height:4px}.media-lightbox-short-progress:hover::-webkit-slider-thumb{opacity:1;transform:scale(1.05)}.media-lightbox-short-progress:focus-visible::-webkit-slider-thumb{opacity:1;transform:scale(1.05)}.media-lightbox-short-progress:hover::-moz-range-thumb{opacity:1;transform:scale(1.05)}.media-lightbox-short-progress:focus-visible::-moz-range-thumb{opacity:1;transform:scale(1.05)}.media-lightbox-short-mute{z-index:2;color:#fff;pointer-events:auto;cursor:pointer;background-color:#777;border:none;border-radius:999px;justify-content:center;align-items:center;width:44px;height:44px;transition:background-color .15s,transform .15s;display:inline-flex;position:absolute;bottom:30px;right:24px}.media-lightbox-short-mute svg{flex-shrink:0;width:16px;height:16px;display:block}.media-lightbox-short-mute:hover{background-color:#686868;transform:scale(1.03)}.media-lightbox-short-mute:focus-visible{outline:none;box-shadow:0 0 0 3px #fff3}@media (max-width:640px){.media-lightbox-stage{padding:48px 16px}.media-lightbox-img{border-radius:0}.media-lightbox-img-scroll{width:100%}.media-lightbox-close{width:36px;height:36px;top:12px;left:12px}.media-lightbox-nav{width:36px;height:36px}.media-lightbox-nav-prev{left:8px}.media-lightbox-nav-next{right:8px}.media-lightbox-short-mute{bottom:26px;right:24px}}.media-visual-frame{border-radius:var(--media-radius,.5rem);background-color:var(--color-muted);display:block;overflow:hidden}.media-visual{background-position:50%;background-repeat:no-repeat;display:block}.media-video-wrap{position:relative}.media-video-link{cursor:pointer;display:block}.media-video-wrap video{object-fit:contain;background-color:var(--color-muted);width:100%;max-height:24rem}.media-video-wrap-short video{background-color:#000}.media-feed-video-mute{z-index:1;color:#fff;cursor:pointer;background-color:#00000080;border:none;border-radius:999px;justify-content:center;align-items:center;width:28px;height:28px;transition:background-color .15s,transform .15s;display:inline-flex;position:absolute;bottom:16px;right:16px}.media-feed-video-mute svg{width:12px;height:12px}.media-feed-video-mute:hover{background-color:#0000009e;transform:scale(1.03)}.media-feed-video-mute:focus-visible{outline:none;box-shadow:0 0 0 3px #fff3}.media-feed-video-icon{transition:opacity .15s,transform .15s;position:absolute}.media-feed-video-mute[data-muted=true] .media-feed-video-icon-muted,.media-feed-video-mute[data-muted=false] .media-feed-video-icon-unmuted{opacity:1;transform:scale(1)}.media-feed-video-mute[data-muted=true] .media-feed-video-icon-unmuted,.media-feed-video-mute[data-muted=false] .media-feed-video-icon-muted{opacity:0;transform:scale(.92)}.media-video-play-overlay{pointer-events:none;justify-content:center;align-items:center;transition:opacity .15s;display:flex;position:absolute;inset:0}.media-video-play-overlay svg{filter:drop-shadow(0 2px 6px #0006);opacity:.85;width:48px;height:48px}.media-gallery-card.media-audio-card{flex-direction:column;display:flex}.media-audio-card .media-audio-el{opacity:0;pointer-events:none;width:0;height:0;position:absolute}.media-audio-card .media-audio-artwork{background:linear-gradient(160deg,#8080801f 0%,#80808008 100%);flex:1;justify-content:center;align-items:center;width:100%;min-height:0;display:flex}.media-audio-card .media-audio-artwork svg{width:32px;height:32px;color:var(--site-text-secondary);opacity:.3}.media-audio-card .media-audio-waveform{cursor:pointer;touch-action:none;width:100%;height:24px;display:none}.media-audio-card.has-waveform .media-audio-waveform{display:block}.media-audio-card.has-waveform .media-audio-range{clip:rect(0, 0, 0, 0);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.media-audio-card .media-audio-controls{flex-direction:column;flex-shrink:0;padding:0 0 6px;display:flex}.media-audio-card .media-audio-range{appearance:none;cursor:pointer;touch-action:none;background:0 0;width:100%;height:20px;margin:0;padding:0}.media-audio-card .media-audio-range::-webkit-slider-runnable-track{background:#80808026;border-radius:1.5px;height:3px}.media-audio-card .media-audio-range::-moz-range-track{background:#80808026;border:none;border-radius:1.5px;height:3px}.media-audio-card .media-audio-range::-moz-range-progress{background:var(--site-text-primary);border-radius:1.5px;height:3px}.media-audio-card .media-audio-range::-webkit-slider-thumb{-webkit-appearance:none;background:var(--site-text-primary);opacity:0;border:none;border-radius:50%;width:10px;height:10px;margin-top:-3.5px;transition:opacity .15s}.media-audio-card .media-audio-range:hover::-webkit-slider-thumb{opacity:1}.media-audio-card.is-playing .media-audio-range::-webkit-slider-thumb{opacity:1}.media-audio-card .media-audio-range::-moz-range-thumb{background:var(--site-text-primary);opacity:0;border:none;border-radius:50%;width:10px;height:10px;transition:opacity .15s}.media-audio-card .media-audio-range:hover::-moz-range-thumb{opacity:1}.media-audio-card.is-playing .media-audio-range::-moz-range-thumb{opacity:1}.media-audio-card .media-audio-range:focus-visible{outline:2px solid var(--site-text-primary);outline-offset:2px;border-radius:2px}.media-audio-card .media-audio-row{align-items:center;gap:6px;min-width:0;padding:6px 8px 0;display:flex}.media-audio-card .media-audio-info{flex-direction:column;flex:1;gap:1px;min-width:0;display:flex}.media-audio-card .media-audio-title{font-size:var(--type-2xs);font-weight:var(--fw-medium,500);color:var(--site-text-primary);text-overflow:ellipsis;white-space:nowrap;line-height:1.3;overflow:hidden}.media-audio-card .media-audio-time{font-size:var(--type-2xs);color:var(--site-text-secondary);font-variant-numeric:tabular-nums;line-height:1}.media-audio-card .media-audio-play-btn{cursor:pointer;background:var(--site-text-primary);width:28px;height:28px;color:var(--background,#fff);border:none;border-radius:50%;flex-shrink:0;justify-content:center;align-items:center;transition:transform .12s;display:flex}.media-audio-card .media-audio-play-btn:hover{transform:scale(1.1)}.media-audio-card .media-audio-play-btn:active{transform:scale(.92)}.media-audio-card .media-audio-play-btn svg{width:14px;height:14px}.media-audio-card .media-audio-icon-play{margin-left:2px}.media-audio-card .media-audio-icon-pause,.media-audio-card.is-playing .media-audio-icon-play{display:none}.media-audio-card.is-playing .media-audio-icon-pause{display:block}.media-gallery-card{color:var(--site-text-primary);border-radius:var(--media-radius,.5rem);background-color:var(--site-nav-hover-bg);border:1px solid var(--site-divider);text-decoration:none;transition:background-color .15s;display:block;overflow:hidden}a.media-gallery-card:hover,button.media-gallery-card:hover{background-color:var(--site-divider)}button.media-gallery-card{cursor:pointer;font:inherit;text-align:inherit}.media-gallery-card-inner{text-align:center;flex-direction:column;justify-content:center;align-items:center;gap:8px;width:100%;height:100%;padding:16px 12px;display:flex}.media-gallery-card-icon{color:var(--site-text-secondary);opacity:.6}.media-gallery-card-summary{font-size:var(--type-xs);color:var(--site-text-secondary);-webkit-line-clamp:2;word-break:break-word;-webkit-box-orient:vertical;line-height:1.4;display:-webkit-box;overflow:hidden}.media-gallery-card-meta{font-size:var(--type-xs);color:var(--site-text-secondary)}.media-lightbox-video{background-color:#000;border-radius:4px;outline:none;max-width:100%;max-height:100%;animation:.28s cubic-bezier(.22,1,.36,1) both lightbox-scale-in}.media-lightbox-video:focus,.media-lightbox-video:focus-visible{outline:none}.media-lightbox-video-short{object-fit:contain;width:100%;max-width:none;height:100%;max-height:none;display:block}@media (max-width:640px){.media-lightbox-video{border-radius:0}}[data-post-media] img{background:0 0}.media-gallery-scroll-wrap{position:relative}.media-gallery-nav{z-index:2;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);color:#fff;cursor:pointer;opacity:0;pointer-events:none;background-color:#00000080;border:none;border-radius:50%;justify-content:center;align-items:center;width:34px;height:34px;padding:0;transition:opacity .15s,background-color .15s;display:flex;position:absolute;top:50%;transform:translateY(-50%)}.media-gallery-nav:hover{background-color:#000000b3}.media-gallery-nav svg{width:18px;height:18px;display:block}.media-gallery-nav-prev{left:8px}.media-gallery-nav-next{right:8px}@media (hover:hover) and (pointer:fine){.media-gallery-scroll-wrap.can-scroll-start:hover .media-gallery-nav-prev,.media-gallery-scroll-wrap.can-scroll-end:hover .media-gallery-nav-next{opacity:1;pointer-events:auto}}.media-gallery-scroll-wrap>[data-post-media]:focus-visible{outline:2px solid var(--site-text-primary);outline-offset:2px;border-radius:4px}\n/*$vite$:1*/";
3311
3311
  //#endregion
3312
3312
  //#region src/services/export-theme/layouts/_default/baseof.html?raw
3313
- var baseof_default = "<!doctype html>\n{{- $lang := .Site.LanguageCode | default \"en\" -}}\n{{- $themeMode := .Site.Params.theme_mode | default \"auto\" -}}\n<html lang=\"{{ $lang }}\"{{ if ne $themeMode \"auto\" }} data-theme-mode=\"{{ $themeMode }}\"{{ end }}>\n <head>\n {{ partial \"head.html\" . }}\n </head>\n <body>\n <div class=\"site-page\">\n {{ partial \"header.html\" . }}\n <main class=\"site-main\" id=\"main\">\n {{ block \"main\" . }}{{ end }}\n </main>\n {{ partial \"footer.html\" . }}\n </div>\n {{/* Mount the shared media lightbox web component once per page.\n The component installs a document-level click listener on connect\n and intercepts clicks on [data-post-media] a[data-lightbox-index]. */}}\n <jant-media-lightbox></jant-media-lightbox>\n </body>\n</html>\n";
3313
+ var baseof_default = "<!doctype html>\n{{- $lang := .Site.LanguageCode | default \"en\" -}}\n{{- $themeMode := .Site.Params.theme_mode | default \"auto\" -}}\n{{- $themeId := .Site.Params.theme_id -}}\n<html lang=\"{{ $lang }}\"{{ with $themeId }} data-theme=\"{{ . }}\"{{ end }}{{ if ne $themeMode \"auto\" }} data-theme-mode=\"{{ $themeMode }}\"{{ end }}>\n <head>\n {{ partial \"head.html\" . }}\n </head>\n <body>\n <div class=\"site-page\">\n {{ partial \"header.html\" . }}\n <main class=\"site-main\" id=\"main\">\n {{ block \"main\" . }}{{ end }}\n </main>\n {{ partial \"footer.html\" . }}\n </div>\n {{/* Mount the shared media lightbox web component once per page.\n The component installs a document-level click listener on connect\n and intercepts clicks on [data-post-media] a[data-lightbox-index]. */}}\n <jant-media-lightbox></jant-media-lightbox>\n </body>\n</html>\n";
3314
3314
  //#endregion
3315
3315
  //#region src/services/export-theme/layouts/_default/single.html?raw
3316
3316
  var single_default$1 = "{{ define \"main\" }}\n <article class=\"page\">\n {{- with .Title -}}\n <header class=\"page-header\">\n <h1 class=\"page-title\">{{ . }}</h1>\n </header>\n {{- end -}}\n {{- with .Params.summary_text -}}\n <p class=\"page-summary\">{{ . }}</p>\n {{- end -}}\n <div class=\"page-body\">\n {{ .Content }}\n </div>\n </article>\n{{ end }}\n";
@@ -1,4 +1,4 @@
1
1
  import "./url-BMYO-Zlt.js";
2
- import "./export-Be082J0n.js";
3
- import { i as classifyRepoForSync, o as createGitHubSyncService } from "./github-sync-D1Cw8mOY.js";
2
+ import "./export-C2DIB7mm.js";
3
+ import { i as classifyRepoForSync, o as createGitHubSyncService } from "./github-sync-BEFCfLKK.js";
4
4
  export { classifyRepoForSync, createGitHubSyncService };
@@ -1,4 +1,4 @@
1
- import { c as parseMarkdownDocument, r as parseFrontMatter, t as createExportService } from "./export-Be082J0n.js";
1
+ import { c as parseMarkdownDocument, r as parseFrontMatter, t as createExportService } from "./export-C2DIB7mm.js";
2
2
  import { r as getInstallationToken } from "./github-app-BbklkFmU.js";
3
3
  import { r as parseRepoSlug, t as createGitHubClient } from "./github-api-BgSiE71w.js";
4
4
  //#region src/lib/markdown-to-tiptap.ts
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { y as url_exports } from "./url-BMYO-Zlt.js";
2
- import { A as MAX_MEDIA_ATTACHMENTS, C as toMediaView, D as toPostViews, E as toPostView, F as STATUSES, I as TEXT_ATTACHMENT_CONTENT_FORMATS, M as MEDIA_KINDS, N as NAV_ITEM_TYPES, O as toSearchResultView, P as SORT_ORDERS, S as toArchiveGroupsWithMedia, T as toNavItemViews, b as createMediaContext, h as defaultFeedRenderer, j as MAX_PINNED_POSTS, k as FORMATS, t as createApp, w as toNavItemView, x as toArchiveGroups } from "./app-9P4rVCe2.js";
3
- import { T as time_exports, a as markdown_exports } from "./export-Be082J0n.js";
2
+ import { A as MAX_MEDIA_ATTACHMENTS, C as toMediaView, D as toPostViews, E as toPostView, F as STATUSES, I as TEXT_ATTACHMENT_CONTENT_FORMATS, M as MEDIA_KINDS, N as NAV_ITEM_TYPES, O as toSearchResultView, P as SORT_ORDERS, S as toArchiveGroupsWithMedia, T as toNavItemViews, b as createMediaContext, j as MAX_PINNED_POSTS, k as FORMATS, m as defaultFeedRenderer, t as createApp, w as toNavItemView, x as toArchiveGroups } from "./app-C-jxWmAV.js";
3
+ import { T as time_exports, a as markdown_exports } from "./export-C2DIB7mm.js";
4
4
  import "./env-OHRKGcMj.js";
5
- import "./github-sync-D1Cw8mOY.js";
5
+ import "./github-sync-BEFCfLKK.js";
6
6
  export { FORMATS, MAX_MEDIA_ATTACHMENTS, MAX_PINNED_POSTS, MEDIA_KINDS, NAV_ITEM_TYPES, SORT_ORDERS, STATUSES, TEXT_ATTACHMENT_CONTENT_FORMATS, createApp, createMediaContext, defaultFeedRenderer, markdown_exports as markdown, time_exports as time, toArchiveGroups, toArchiveGroupsWithMedia, toMediaView, toNavItemView, toNavItemViews, toPostView, toPostViews, toSearchResultView, url_exports as url };
package/dist/node.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import "./url-BMYO-Zlt.js";
2
- import { B as isAssetPath, L as buildThemeStyle, R as BUILTIN_COLOR_THEMES, _ as sqliteSchemaBundle, a as resolveDatabaseDialect, c as resolveConfig, d as setWebhook, f as BUILTIN_FONT_THEMES, g as pgSchemaBundle, i as createSiteService, l as getWebhookUrl, m as getFontThemeCssVariables, n as createNodeCliRuntime, o as getHostBasedStartupConfigurationIssues, p as getCjkSerifCssVariables, r as createNodeRequestRuntime, s as createStorageDriver, t as createApp, u as setMyCommands, v as createNodeDatabase, y as schema_exports, z as getPublicAssetBasePath } from "./app-9P4rVCe2.js";
3
- import { t as createExportService } from "./export-Be082J0n.js";
2
+ import { B as isAssetPath, L as buildThemeStyle, R as BUILTIN_COLOR_THEMES, _ as sqliteSchemaBundle, a as resolveDatabaseDialect, c as getWebhookUrl, d as BUILTIN_FONT_THEMES, f as getCjkSerifCssVariables, g as pgSchemaBundle, h as createStorageDriver, i as createSiteService, l as setMyCommands, n as createNodeCliRuntime, o as getHostBasedStartupConfigurationIssues, p as getFontThemeCssVariables, r as createNodeRequestRuntime, s as resolveConfig, t as createApp, u as setWebhook, v as createNodeDatabase, y as schema_exports, z as getPublicAssetBasePath } from "./app-C-jxWmAV.js";
3
+ import { t as createExportService } from "./export-C2DIB7mm.js";
4
4
  import { C as shouldTrustProxy, S as getTelegramWebhookSecret, b as getSiteResolutionMode, d as getHostedControlPlaneBaseUrl, i as getConfiguredSingleSitePathPrefix, l as getEnvString, r as getConfiguredSingleSiteOrigin, x as getTelegramBotPool, y as getPort } from "./env-OHRKGcMj.js";
5
- import "./github-sync-D1Cw8mOY.js";
5
+ import "./github-sync-BEFCfLKK.js";
6
6
  import { drizzle } from "drizzle-orm/better-sqlite3";
7
7
  import { serve } from "@hono/node-server";
8
8
  import Database from "better-sqlite3";
@@ -529,7 +529,7 @@ async function createNodeRequestHandler(options) {
529
529
  async function start(env = process.env, app) {
530
530
  const handler = await createNodeRequestHandler({
531
531
  env,
532
- app: async () => app ?? (await import("./app-DaxS_Cz-.js")).createApp()
532
+ app: async () => app ?? (await import("./app-DqHzOwL5.js")).createApp()
533
533
  });
534
534
  const hostname = resolveHost(env);
535
535
  const port = resolvePort(env);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jant/core",
3
- "version": "0.6.8",
3
+ "version": "0.6.9",
4
4
  "description": "A modern, open-source microblogging platform built on Cloudflare Workers",
5
5
  "type": "module",
6
6
  "exports": {
@@ -38,10 +38,13 @@ const labels: SettingsLabels = {
38
38
  siteName: "Site Name",
39
39
  aboutBlog: "About this blog",
40
40
  aboutBlogHelp: "Displayed above your blog posts.",
41
- siteLanguage: "Site Language",
42
- siteLanguageHelp: "Language used for the site UI.",
41
+ siteLanguage: "Content language",
42
+ siteLanguageHelp: "The language your posts are written in.",
43
43
  siteLanguageSearchPlaceholder: "Search…",
44
44
  siteLanguageNoMatches: "No matches.",
45
+ contentLanguagePreview: "Readers and search engines see",
46
+ dashboardLanguage: "Dashboard language",
47
+ dashboardLanguageHelp: "The language this admin dashboard shows in.",
45
48
  cjkFont: "CJK Font",
46
49
  cjkFontHelp:
47
50
  "Load a serif font optimized for Chinese, Japanese, or Korean content.",
@@ -5,6 +5,7 @@ import type {
5
5
  SettingsLabels,
6
6
  SettingsTimezone,
7
7
  SettingsCjkFont,
8
+ SettingsDashboardLanguage,
8
9
  SettingsSaveDetail,
9
10
  } from "../settings-types.js";
10
11
  import { MAX_SITE_NAME_LENGTH } from "../../../types.js";
@@ -81,10 +82,13 @@ const labels: SettingsLabels = {
81
82
  siteName: "Site Name",
82
83
  aboutBlog: "About this blog",
83
84
  aboutBlogHelp: "Displayed above your blog posts.",
84
- siteLanguage: "Site Language",
85
- siteLanguageHelp: "Language used for the site UI.",
85
+ siteLanguage: "Content language",
86
+ siteLanguageHelp: "The language your posts are written in.",
86
87
  siteLanguageSearchPlaceholder: "Search…",
87
88
  siteLanguageNoMatches: "No matches.",
89
+ contentLanguagePreview: "Readers and search engines see",
90
+ dashboardLanguage: "Dashboard language",
91
+ dashboardLanguageHelp: "The language this admin dashboard shows in.",
88
92
  cjkFont: "CJK Font",
89
93
  cjkFontHelp:
90
94
  "Load a serif font optimized for Chinese, Japanese, or Korean content.",
@@ -127,10 +131,17 @@ const cjkFonts: SettingsCjkFont[] = [
127
131
  { value: "zh-Hans", label: "简体中文 (Simplified Chinese)" },
128
132
  ];
129
133
 
134
+ const dashboardLanguages: SettingsDashboardLanguage[] = [
135
+ { value: "en", label: "English" },
136
+ { value: "zh-Hans", label: "简体中文" },
137
+ { value: "zh-Hant", label: "繁體中文" },
138
+ ];
139
+
130
140
  const initialData = {
131
141
  siteName: "My Blog",
132
142
  siteDescription: "A test blog",
133
143
  siteLanguage: "en",
144
+ dashboardLanguage: "en",
134
145
  cjkSerifFont: "off",
135
146
  timeZone: "UTC",
136
147
  mainRssFeed: "featured",
@@ -161,6 +172,7 @@ async function createElement(
161
172
  el.labels = labels;
162
173
  el.timezones = timezones;
163
174
  el.cjkFonts = cjkFonts;
175
+ el.dashboardLanguages = dashboardLanguages;
164
176
  el.siteNameFallback = "Fallback Name";
165
177
  el.siteDescriptionFallback = "Fallback Description";
166
178
  el.mainFeedUrl = "/feed";
@@ -239,10 +251,11 @@ describe("JantSettingsGeneral", () => {
239
251
  expect(trigger.getAttribute("aria-expanded")).toBe("true");
240
252
 
241
253
  const options = el.querySelectorAll<HTMLButtonElement>('[role="option"]');
254
+ // The content-language picker lists the full BCP 47 catalog so any public
255
+ // content language is reachable. Coverage / raw tags are not shown here.
242
256
  expect(options.length).toBeGreaterThanOrEqual(20);
243
- // Each option carries the universal "translated" coverage suffix.
244
257
  for (const option of options) {
245
- expect(option.textContent).toMatch(/% translated/);
258
+ expect(option.textContent).not.toMatch(/% translated/);
246
259
  }
247
260
 
248
261
  const search = requireElement(
@@ -258,7 +271,7 @@ describe("JantSettingsGeneral", () => {
258
271
  expect(filtered[0]?.textContent).toMatch(/Suomi|Finnish/);
259
272
  });
260
273
 
261
- it("selects a non-catalog locale and reports 0% translated coverage on it", async () => {
274
+ it("selects a non-catalog content language and shows its native name", async () => {
262
275
  const el = await createElement();
263
276
  const trigger = requireElement(
264
277
  el.querySelector<HTMLButtonElement>(
@@ -285,9 +298,43 @@ describe("JantSettingsGeneral", () => {
285
298
 
286
299
  // Picker closes after selection.
287
300
  expect(trigger.getAttribute("aria-expanded")).toBe("false");
288
- // Trigger reflects the new tag and shows 0% coverage.
289
- expect(trigger.textContent).toMatch(/fi/);
290
- expect(trigger.textContent).toMatch(/0% translated/);
301
+ // Trigger shows the selected language's native name only — no raw BCP 47
302
+ // tag, no coverage metric.
303
+ expect(trigger.textContent).toMatch(/suomi|finnish/i);
304
+ expect(trigger.textContent).not.toMatch(/% translated/);
305
+ expect(trigger.textContent).not.toMatch(/\bfi\b/);
306
+ });
307
+
308
+ it("renders dashboard language options and saves the selection", async () => {
309
+ const el = await createElement();
310
+ const select = requireElement(
311
+ el.querySelector(
312
+ 'select[aria-labelledby="dashboard-language-label"]',
313
+ ) as globalThis.HTMLSelectElement | null,
314
+ "expected dashboard language select",
315
+ );
316
+ const values = Array.from(select.options).map((o) => o.value);
317
+ expect(values).toEqual(["en", "zh-Hans", "zh-Hant"]);
318
+ expect(select.value).toBe("en");
319
+
320
+ const saves: SettingsSaveDetail[] = [];
321
+ el.addEventListener("jant:settings-save", (e) => {
322
+ saves.push((e as CustomEvent<SettingsSaveDetail>).detail);
323
+ });
324
+
325
+ select.value = "zh-Hant";
326
+ select.dispatchEvent(new Event("change", { bubbles: true }));
327
+ await el.updateComplete;
328
+
329
+ const saveButton = requireElement(
330
+ findSaveButtonByHeading(el, labels.languageAndTime),
331
+ "expected language & time save button",
332
+ );
333
+ saveButton.click();
334
+
335
+ expect(saves).toHaveLength(1);
336
+ expect(saves[0]?.endpoint).toBe("/settings/general/language-time");
337
+ expect(saves[0]?.data.dashboardLanguage).toBe("zh-Hant");
291
338
  });
292
339
 
293
340
  it("renders CJK font options", async () => {
@@ -5350,6 +5350,18 @@ export class JantComposeDialog extends LitElement {
5350
5350
  i === index ? { ...it, format: e.detail.format } : it,
5351
5351
  );
5352
5352
  this._format = e.detail.format;
5353
+ // Move focus to the new format's input, mirroring the single-post
5354
+ // composer's `_switchFormat`. The editor re-renders its fields only
5355
+ // after both this dialog and the editor itself finish updating, so
5356
+ // wait for both before routing focus to the now-visible control.
5357
+ if (this._shouldAutofocusFormatInput()) {
5358
+ this.updateComplete.then(() => {
5359
+ const editor = this.querySelectorAll<JantComposeEditor>(
5360
+ "jant-compose-editor",
5361
+ )[index];
5362
+ editor?.updateComplete.then(() => editor.focusInput());
5363
+ });
5364
+ }
5353
5365
  }}
5354
5366
  @jant:thread-remove=${(e: Event) => {
5355
5367
  e.stopPropagation();
@@ -20,6 +20,7 @@ import type {
20
20
  SettingsLabels,
21
21
  SettingsTimezone,
22
22
  SettingsCjkFont,
23
+ SettingsDashboardLanguage,
23
24
  } from "./settings-types.js";
24
25
  import { showToast } from "../toast.js";
25
26
  import {
@@ -37,6 +38,7 @@ export class JantSettingsGeneral extends LitElement {
37
38
  type: String,
38
39
  attribute: "sitedescription-fallback",
39
40
  },
41
+ dashboardLanguages: { type: Array, attribute: "dashboard-languages" },
40
42
  demoMode: { type: Boolean, attribute: "demo-mode" },
41
43
  mainFeedUrl: { type: String, attribute: "main-feed-url" },
42
44
  latestFeedUrl: { type: String, attribute: "latest-feed-url" },
@@ -52,6 +54,7 @@ export class JantSettingsGeneral extends LitElement {
52
54
 
53
55
  // Language, CJK & time group
54
56
  _siteLanguage: { state: true },
57
+ _dashboardLanguage: { state: true },
55
58
  _localeOpen: { state: true },
56
59
  _localeQuery: { state: true },
57
60
  _cjkSerifFont: { state: true },
@@ -80,6 +83,7 @@ export class JantSettingsGeneral extends LitElement {
80
83
  declare labels: SettingsLabels;
81
84
  declare timezones: SettingsTimezone[];
82
85
  declare cjkFonts: SettingsCjkFont[];
86
+ declare dashboardLanguages: SettingsDashboardLanguage[];
83
87
  declare siteNameFallback: string;
84
88
  declare siteDescriptionFallback: string;
85
89
  declare demoMode: boolean;
@@ -101,6 +105,8 @@ export class JantSettingsGeneral extends LitElement {
101
105
 
102
106
  // Language, CJK & time
103
107
  declare _siteLanguage: string;
108
+ /** Admin dashboard UI locale (one of the translated catalog locales). */
109
+ declare _dashboardLanguage: string;
104
110
  /** Whether the locale combobox dropdown is currently open. */
105
111
  declare _localeOpen: boolean;
106
112
  /** Search query inside the locale combobox. */
@@ -109,6 +115,7 @@ export class JantSettingsGeneral extends LitElement {
109
115
  declare _timeZone: string;
110
116
  declare _origLocale: {
111
117
  siteLanguage: string;
118
+ dashboardLanguage: string;
112
119
  cjkSerifFont: string;
113
120
  timeZone: string;
114
121
  };
@@ -145,6 +152,7 @@ export class JantSettingsGeneral extends LitElement {
145
152
  this.labels = {} as SettingsLabels;
146
153
  this.timezones = [];
147
154
  this.cjkFonts = [];
155
+ this.dashboardLanguages = [];
148
156
  this.siteNameFallback = "";
149
157
  this.siteDescriptionFallback = "";
150
158
  this.demoMode = false;
@@ -164,12 +172,14 @@ export class JantSettingsGeneral extends LitElement {
164
172
  this._siteLoading = false;
165
173
 
166
174
  this._siteLanguage = "en";
175
+ this._dashboardLanguage = "en";
167
176
  this._localeOpen = false;
168
177
  this._localeQuery = "";
169
178
  this._cjkSerifFont = "off";
170
179
  this._timeZone = "UTC";
171
180
  this._origLocale = {
172
181
  siteLanguage: "en",
182
+ dashboardLanguage: "en",
173
183
  cjkSerifFont: "off",
174
184
  timeZone: "UTC",
175
185
  };
@@ -213,10 +223,12 @@ export class JantSettingsGeneral extends LitElement {
213
223
  this._siteFooter = data.siteFooter;
214
224
 
215
225
  this._siteLanguage = data.siteLanguage;
226
+ this._dashboardLanguage = data.dashboardLanguage;
216
227
  this._cjkSerifFont = data.cjkSerifFont;
217
228
  this._timeZone = data.timeZone;
218
229
  this._origLocale = {
219
230
  siteLanguage: data.siteLanguage,
231
+ dashboardLanguage: data.dashboardLanguage,
220
232
  cjkSerifFont: data.cjkSerifFont,
221
233
  timeZone: data.timeZone,
222
234
  };
@@ -255,6 +267,7 @@ export class JantSettingsGeneral extends LitElement {
255
267
  } else if (section === "language-time") {
256
268
  this._origLocale = {
257
269
  siteLanguage: this._siteLanguage,
270
+ dashboardLanguage: this._dashboardLanguage,
258
271
  cjkSerifFont: this._cjkSerifFont,
259
272
  timeZone: this._timeZone,
260
273
  };
@@ -381,6 +394,7 @@ export class JantSettingsGeneral extends LitElement {
381
394
  private _syncLocaleDirty() {
382
395
  this._localeDirty =
383
396
  this._siteLanguage !== this._origLocale.siteLanguage ||
397
+ this._dashboardLanguage !== this._origLocale.dashboardLanguage ||
384
398
  this._cjkSerifFont !== this._origLocale.cjkSerifFont ||
385
399
  this._timeZone !== this._origLocale.timeZone;
386
400
  }
@@ -395,6 +409,7 @@ export class JantSettingsGeneral extends LitElement {
395
409
  endpoint: "/settings/general/language-time",
396
410
  data: {
397
411
  siteLanguage: this._siteLanguage,
412
+ dashboardLanguage: this._dashboardLanguage,
398
413
  cjkSerifFont: this._cjkSerifFont,
399
414
  timeZone: this._timeZone,
400
415
  },
@@ -465,28 +480,22 @@ export class JantSettingsGeneral extends LitElement {
465
480
  const noMatches = this.labels.siteLanguageNoMatches || "No matches.";
466
481
 
467
482
  return html`
468
- <div class="relative" data-locale-picker>
483
+ <div class="relative w-fit max-w-full" data-locale-picker>
469
484
  <button
470
485
  type="button"
471
- class="input flex w-full items-center justify-between text-left"
486
+ class="flex h-9 w-full cursor-pointer items-center rounded-md border border-input bg-transparent bg-[image:var(--chevron-down-icon-50)] bg-position-[center_right_0.75rem] bg-size-[1rem] bg-no-repeat py-2 pl-3 pr-9 text-left text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30 dark:hover:bg-input/50"
472
487
  aria-expanded=${this._localeOpen ? "true" : "false"}
473
488
  aria-haspopup="listbox"
474
489
  aria-labelledby="site-language-label"
475
490
  @click=${this._toggleLocalePicker}
476
491
  >
477
- <span class="truncate">
478
- ${current.native}
479
- <span class="ml-2 text-xs text-muted-foreground">
480
- ${current.tag} · ${Math.round(current.coverage * 100)}% translated
481
- </span>
482
- </span>
483
- <span class="ml-2 text-muted-foreground" aria-hidden="true">▾</span>
492
+ <span class="min-w-0 truncate">${current.native}</span>
484
493
  </button>
485
494
 
486
495
  ${this._localeOpen
487
496
  ? html`
488
497
  <div
489
- class="absolute left-0 right-0 top-full z-10 mt-1 max-h-72 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md"
498
+ class="absolute left-0 top-full z-10 mt-1 w-80 min-w-full max-w-[calc(100vw-2rem)] max-h-72 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md"
490
499
  >
491
500
  <div class="border-b p-2">
492
501
  <input
@@ -518,21 +527,16 @@ export class JantSettingsGeneral extends LitElement {
518
527
  ? "true"
519
528
  : "false"}
520
529
  class=${[
521
- "flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-accent",
530
+ "flex w-full flex-col px-3 py-2 text-left text-sm hover:bg-accent",
522
531
  entry.tag === this._siteLanguage
523
532
  ? "bg-accent/60"
524
533
  : "",
525
534
  ].join(" ")}
526
535
  @click=${() => this._selectLocale(entry.tag)}
527
536
  >
528
- <span class="flex flex-col">
529
- <span>${entry.native}</span>
530
- <span class="text-xs text-muted-foreground">
531
- ${entry.tag} · ${entry.english}
532
- </span>
533
- </span>
537
+ <span>${entry.native}</span>
534
538
  <span class="text-xs text-muted-foreground">
535
- ${Math.round(entry.coverage * 100)}% translated
539
+ ${entry.english}
536
540
  </span>
537
541
  </button>
538
542
  `,
@@ -819,6 +823,40 @@ export class JantSettingsGeneral extends LitElement {
819
823
  <p class="text-sm text-muted-foreground mt-1">
820
824
  ${this.labels.siteLanguageHelp}
821
825
  </p>
826
+ <p class="text-sm text-muted-foreground mt-1">
827
+ ${this.labels.contentLanguagePreview}
828
+ <code class="rounded bg-muted px-1.5 py-0.5 text-xs"
829
+ >${`<html lang="${this._siteLanguage || "en"}">`}</code
830
+ >
831
+ </p>
832
+ </div>
833
+
834
+ <div class="field">
835
+ <label id="dashboard-language-label" class="label"
836
+ >${this.labels.dashboardLanguage}</label
837
+ >
838
+ <select
839
+ class="select"
840
+ aria-labelledby="dashboard-language-label"
841
+ @change=${(e: Event) => {
842
+ this._dashboardLanguage = (e.target as HTMLSelectElement).value;
843
+ this._syncLocaleDirty();
844
+ }}
845
+ >
846
+ ${this.dashboardLanguages.map(
847
+ (lang) => html`
848
+ <option
849
+ value=${lang.value}
850
+ ?selected=${this._dashboardLanguage === lang.value}
851
+ >
852
+ ${lang.label}
853
+ </option>
854
+ `,
855
+ )}
856
+ </select>
857
+ <p class="text-sm text-muted-foreground mt-1">
858
+ ${this.labels.dashboardLanguageHelp}
859
+ </p>
822
860
  </div>
823
861
 
824
862
  <div class="field">
@@ -47,6 +47,10 @@ export interface SettingsLabels {
47
47
  siteLanguageSearchPlaceholder: string;
48
48
  /** Empty-state message when the search filters out every option. */
49
49
  siteLanguageNoMatches: string;
50
+ /** Lead text before the live `<html lang>` preview. */
51
+ contentLanguagePreview: string;
52
+ dashboardLanguage: string;
53
+ dashboardLanguageHelp: string;
50
54
  cjkFont: string;
51
55
  cjkFontHelp: string;
52
56
  timeZone: string;
@@ -75,10 +79,17 @@ export interface SettingsCjkFont {
75
79
  label: string;
76
80
  }
77
81
 
82
+ /** Dashboard UI language option for the select dropdown */
83
+ export interface SettingsDashboardLanguage {
84
+ value: string;
85
+ label: string;
86
+ }
87
+
78
88
  export interface SettingsInitialData {
79
89
  siteName: string;
80
90
  siteDescription: string;
81
91
  siteLanguage: string;
92
+ dashboardLanguage: string;
82
93
  cjkSerifFont: string;
83
94
  mainRssFeed: string;
84
95
  timeZone: string;
@@ -20,6 +20,8 @@ function parseSettingsInitialData(data: unknown): SettingsInitialData | null {
20
20
  const siteName = getJsonString(data, "siteName");
21
21
  const siteDescription = getJsonString(data, "siteDescription");
22
22
  const siteLanguage = getJsonString(data, "siteLanguage");
23
+ // Tolerate older payloads without the key: empty = follow content language.
24
+ const dashboardLanguage = getJsonString(data, "dashboardLanguage") ?? "";
23
25
  const cjkSerifFont = getJsonString(data, "cjkSerifFont");
24
26
  const mainRssFeed = getJsonString(data, "mainRssFeed");
25
27
  const timeZone = getJsonString(data, "timeZone");
@@ -45,6 +47,7 @@ function parseSettingsInitialData(data: unknown): SettingsInitialData | null {
45
47
  siteName,
46
48
  siteDescription,
47
49
  siteLanguage,
50
+ dashboardLanguage,
48
51
  cjkSerifFont,
49
52
  mainRssFeed,
50
53
  timeZone,