@jant/core 0.6.8 → 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 (77) hide show
  1. package/bin/commands/uploads/cleanup.js +1 -0
  2. package/dist/{app-9P4rVCe2.js → app-CGHkOdme.js} +3450 -3121
  3. package/dist/app-D24n0DoH.js +6 -0
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/{client-CXnEhyyv.js → client-DYrWuaIk.js} +1 -1
  6. package/dist/client/_assets/{client-auth-CSItbyU8.js → client-auth-B5Re0uCd.js} +187 -167
  7. package/dist/client/_assets/client-xWDl78yi.css +2 -0
  8. package/dist/{export-Be082J0n.js → export-DY1v5Iqu.js} +2 -2
  9. package/dist/{github-sync-D1Cw8mOY.js → github-sync-2_T7nbOv.js} +1 -1
  10. package/dist/{github-sync-_kPWM4m9.js → github-sync-LefaslGJ.js} +2 -2
  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 +8 -2
  15. package/src/client/components/__tests__/jant-settings-general.test.ts +64 -12
  16. package/src/client/components/jant-compose-dialog.ts +12 -0
  17. package/src/client/components/jant-settings-general.ts +74 -21
  18. package/src/client/components/settings-types.ts +13 -0
  19. package/src/client/settings-bridge.ts +3 -0
  20. package/src/client/tiptap/__tests__/link-toolbar.test.ts +41 -0
  21. package/src/client/tiptap/__tests__/mark-exit.test.ts +99 -0
  22. package/src/client/tiptap/bubble-menu.ts +37 -4
  23. package/src/client/tiptap/link-toolbar.ts +63 -1
  24. package/src/db/migrations/0026_absent_rhodey.sql +14 -0
  25. package/src/db/migrations/meta/0026_snapshot.json +2511 -0
  26. package/src/db/migrations/meta/_journal.json +7 -0
  27. package/src/db/migrations/pg/0024_high_violations.sql +14 -0
  28. package/src/db/migrations/pg/meta/0024_snapshot.json +3204 -0
  29. package/src/db/migrations/pg/meta/_journal.json +7 -0
  30. package/src/db/pg/schema.ts +36 -0
  31. package/src/db/schema.ts +36 -0
  32. package/src/i18n/__tests__/middleware.test.ts +46 -0
  33. package/src/i18n/locales/settings/en.po +282 -27
  34. package/src/i18n/locales/settings/en.ts +1 -1
  35. package/src/i18n/locales/settings/zh-Hans.po +282 -27
  36. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  37. package/src/i18n/locales/settings/zh-Hant.po +282 -27
  38. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  39. package/src/i18n/middleware.ts +17 -8
  40. package/src/i18n/supported-locales.ts +5 -4
  41. package/src/lib/__tests__/feed.test.ts +5 -1
  42. package/src/lib/feed.ts +6 -3
  43. package/src/lib/ids.ts +1 -0
  44. package/src/lib/resolve-config.ts +1 -0
  45. package/src/lib/upload.ts +14 -0
  46. package/src/routes/api/__tests__/settings.test.ts +1 -4
  47. package/src/routes/api/__tests__/upload.test.ts +2 -0
  48. package/src/routes/api/internal/__tests__/uploads.test.ts +19 -1
  49. package/src/routes/api/settings.ts +2 -1
  50. package/src/routes/auth/__tests__/setup.test.ts +14 -0
  51. package/src/routes/dash/__tests__/settings-avatar.test.ts +35 -17
  52. package/src/routes/dash/settings.tsx +22 -4
  53. package/src/routes/feed/__tests__/feed.test.ts +58 -19
  54. package/src/routes/feed/feed.ts +37 -28
  55. package/src/routes/pages/featured.tsx +17 -0
  56. package/src/routes/pages/latest.tsx +25 -0
  57. package/src/services/__tests__/media.test.ts +191 -30
  58. package/src/services/__tests__/settings.test.ts +55 -0
  59. package/src/services/bootstrap.ts +7 -0
  60. package/src/services/export-theme/layouts/_default/baseof.html +2 -1
  61. package/src/services/media.ts +169 -42
  62. package/src/services/post.ts +1 -1
  63. package/src/services/settings.ts +49 -15
  64. package/src/services/upload-session.ts +13 -3
  65. package/src/styles/tokens.css +21 -4
  66. package/src/styles/ui.css +44 -1
  67. package/src/types/bindings.ts +1 -0
  68. package/src/types/config.ts +13 -0
  69. package/src/ui/__tests__/color-themes.test.ts +2 -2
  70. package/src/ui/color-themes.ts +32 -0
  71. package/src/ui/dash/appearance/ColorThemeContent.tsx +264 -29
  72. package/src/ui/dash/settings/GeneralContent.tsx +54 -4
  73. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +3 -2
  74. package/src/ui/layouts/BaseLayout.tsx +3 -2
  75. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +17 -4
  76. package/dist/app-DaxS_Cz-.js +0 -6
  77. package/dist/client/_assets/client-C6peCkkD.css +0 -2
@@ -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 }\n}\n\n/* Tufte two-column → single-column collapse.\n *\n * Below this width the post column is already narrowed to `min(100%, 35rem)`\n * (preset.css) and there is no room for a 45% sidenote gutter, so all content\n * goes full-width. Single images (MediaGallery getSingleVisualWidth) and link\n * previews (.link-preview) read this token directly, so they collapse here too.\n *\n * Keep this breakpoint in sync with the post-column collapse (preset.css /\n * ui.css feed-divider, both `max-width: 1024px`) and the sidenote float→inline\n * collapse (ui.css). They are one layout switch; do not let them drift apart. */\n@media (max-width: 1024px) {\n :root {\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
- 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-DY1v5Iqu.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
@@ -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-DY1v5Iqu.js";
3
+ import { i as classifyRepoForSync, o as createGitHubSyncService } from "./github-sync-2_T7nbOv.js";
4
4
  export { classifyRepoForSync, createGitHubSyncService };
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-CGHkOdme.js";
3
+ import { T as time_exports, a as markdown_exports } from "./export-DY1v5Iqu.js";
4
4
  import "./env-OHRKGcMj.js";
5
- import "./github-sync-D1Cw8mOY.js";
5
+ import "./github-sync-2_T7nbOv.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-CGHkOdme.js";
3
+ import { t as createExportService } from "./export-DY1v5Iqu.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-2_T7nbOv.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-D24n0DoH.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.10",
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.",
@@ -56,6 +59,9 @@ const labels: SettingsLabels = {
56
59
  mainFeedUrl: "Main feed",
57
60
  latestFeedUrl: "Latest feed",
58
61
  featuredFeedUrl: "Featured feed",
62
+ archiveFeedUrl: "Archive feed",
63
+ archiveFeedUrlHelp:
64
+ "Every published post, including ones hidden from Latest.",
59
65
  latestFeedOption: "Latest",
60
66
  latestFeedOptionDescription: "Uses the latest public posts for /feed.",
61
67
  featuredFeedOption: "Featured",
@@ -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.",
@@ -99,6 +103,9 @@ const labels: SettingsLabels = {
99
103
  mainFeedUrl: "Main feed",
100
104
  latestFeedUrl: "Latest feed",
101
105
  featuredFeedUrl: "Featured feed",
106
+ archiveFeedUrl: "Archive feed",
107
+ archiveFeedUrlHelp:
108
+ "Every published post, including ones hidden from Latest.",
102
109
  latestFeedOption: "Latest",
103
110
  latestFeedOptionDescription: "Uses the latest public posts for /feed.",
104
111
  featuredFeedOption: "Featured",
@@ -127,10 +134,17 @@ const cjkFonts: SettingsCjkFont[] = [
127
134
  { value: "zh-Hans", label: "简体中文 (Simplified Chinese)" },
128
135
  ];
129
136
 
137
+ const dashboardLanguages: SettingsDashboardLanguage[] = [
138
+ { value: "en", label: "English" },
139
+ { value: "zh-Hans", label: "简体中文" },
140
+ { value: "zh-Hant", label: "繁體中文" },
141
+ ];
142
+
130
143
  const initialData = {
131
144
  siteName: "My Blog",
132
145
  siteDescription: "A test blog",
133
146
  siteLanguage: "en",
147
+ dashboardLanguage: "en",
134
148
  cjkSerifFont: "off",
135
149
  timeZone: "UTC",
136
150
  mainRssFeed: "featured",
@@ -161,11 +175,13 @@ async function createElement(
161
175
  el.labels = labels;
162
176
  el.timezones = timezones;
163
177
  el.cjkFonts = cjkFonts;
178
+ el.dashboardLanguages = dashboardLanguages;
164
179
  el.siteNameFallback = "Fallback Name";
165
180
  el.siteDescriptionFallback = "Fallback Description";
166
181
  el.mainFeedUrl = "/feed";
167
- el.latestFeedUrl = "/feed/latest";
168
- el.featuredFeedUrl = "/feed/featured";
182
+ el.latestFeedUrl = "/latest/feed";
183
+ el.featuredFeedUrl = "/featured/feed";
184
+ el.archiveFeedUrl = "/archive/feed";
169
185
  el.demoMode = opts.demoMode ?? false;
170
186
  document.body.appendChild(el);
171
187
  await el.updateComplete;
@@ -239,10 +255,11 @@ describe("JantSettingsGeneral", () => {
239
255
  expect(trigger.getAttribute("aria-expanded")).toBe("true");
240
256
 
241
257
  const options = el.querySelectorAll<HTMLButtonElement>('[role="option"]');
258
+ // The content-language picker lists the full BCP 47 catalog so any public
259
+ // content language is reachable. Coverage / raw tags are not shown here.
242
260
  expect(options.length).toBeGreaterThanOrEqual(20);
243
- // Each option carries the universal "translated" coverage suffix.
244
261
  for (const option of options) {
245
- expect(option.textContent).toMatch(/% translated/);
262
+ expect(option.textContent).not.toMatch(/% translated/);
246
263
  }
247
264
 
248
265
  const search = requireElement(
@@ -258,7 +275,7 @@ describe("JantSettingsGeneral", () => {
258
275
  expect(filtered[0]?.textContent).toMatch(/Suomi|Finnish/);
259
276
  });
260
277
 
261
- it("selects a non-catalog locale and reports 0% translated coverage on it", async () => {
278
+ it("selects a non-catalog content language and shows its native name", async () => {
262
279
  const el = await createElement();
263
280
  const trigger = requireElement(
264
281
  el.querySelector<HTMLButtonElement>(
@@ -285,9 +302,43 @@ describe("JantSettingsGeneral", () => {
285
302
 
286
303
  // Picker closes after selection.
287
304
  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/);
305
+ // Trigger shows the selected language's native name only — no raw BCP 47
306
+ // tag, no coverage metric.
307
+ expect(trigger.textContent).toMatch(/suomi|finnish/i);
308
+ expect(trigger.textContent).not.toMatch(/% translated/);
309
+ expect(trigger.textContent).not.toMatch(/\bfi\b/);
310
+ });
311
+
312
+ it("renders dashboard language options and saves the selection", async () => {
313
+ const el = await createElement();
314
+ const select = requireElement(
315
+ el.querySelector(
316
+ 'select[aria-labelledby="dashboard-language-label"]',
317
+ ) as globalThis.HTMLSelectElement | null,
318
+ "expected dashboard language select",
319
+ );
320
+ const values = Array.from(select.options).map((o) => o.value);
321
+ expect(values).toEqual(["en", "zh-Hans", "zh-Hant"]);
322
+ expect(select.value).toBe("en");
323
+
324
+ const saves: SettingsSaveDetail[] = [];
325
+ el.addEventListener("jant:settings-save", (e) => {
326
+ saves.push((e as CustomEvent<SettingsSaveDetail>).detail);
327
+ });
328
+
329
+ select.value = "zh-Hant";
330
+ select.dispatchEvent(new Event("change", { bubbles: true }));
331
+ await el.updateComplete;
332
+
333
+ const saveButton = requireElement(
334
+ findSaveButtonByHeading(el, labels.languageAndTime),
335
+ "expected language & time save button",
336
+ );
337
+ saveButton.click();
338
+
339
+ expect(saves).toHaveLength(1);
340
+ expect(saves[0]?.endpoint).toBe("/settings/general/language-time");
341
+ expect(saves[0]?.data.dashboardLanguage).toBe("zh-Hant");
291
342
  });
292
343
 
293
344
  it("renders CJK font options", async () => {
@@ -323,8 +374,9 @@ describe("JantSettingsGeneral", () => {
323
374
  expect(el.textContent).toContain(labels.latestFeedOptionDescription);
324
375
  expect(Array.from(feedUrlInputs, (input) => input.value)).toEqual([
325
376
  "/feed",
326
- "/feed/latest",
327
- "/feed/featured",
377
+ "/latest/feed",
378
+ "/featured/feed",
379
+ "/archive/feed",
328
380
  ]);
329
381
  });
330
382
 
@@ -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,10 +38,12 @@ 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" },
43
45
  featuredFeedUrl: { type: String, attribute: "featured-feed-url" },
46
+ archiveFeedUrl: { type: String, attribute: "archive-feed-url" },
44
47
 
45
48
  // Site group
46
49
  _siteName: { state: true },
@@ -52,6 +55,7 @@ export class JantSettingsGeneral extends LitElement {
52
55
 
53
56
  // Language, CJK & time group
54
57
  _siteLanguage: { state: true },
58
+ _dashboardLanguage: { state: true },
55
59
  _localeOpen: { state: true },
56
60
  _localeQuery: { state: true },
57
61
  _cjkSerifFont: { state: true },
@@ -80,12 +84,14 @@ export class JantSettingsGeneral extends LitElement {
80
84
  declare labels: SettingsLabels;
81
85
  declare timezones: SettingsTimezone[];
82
86
  declare cjkFonts: SettingsCjkFont[];
87
+ declare dashboardLanguages: SettingsDashboardLanguage[];
83
88
  declare siteNameFallback: string;
84
89
  declare siteDescriptionFallback: string;
85
90
  declare demoMode: boolean;
86
91
  declare mainFeedUrl: string;
87
92
  declare latestFeedUrl: string;
88
93
  declare featuredFeedUrl: string;
94
+ declare archiveFeedUrl: string;
89
95
 
90
96
  // Site
91
97
  declare _siteName: string;
@@ -101,6 +107,8 @@ export class JantSettingsGeneral extends LitElement {
101
107
 
102
108
  // Language, CJK & time
103
109
  declare _siteLanguage: string;
110
+ /** Admin dashboard UI locale (one of the translated catalog locales). */
111
+ declare _dashboardLanguage: string;
104
112
  /** Whether the locale combobox dropdown is currently open. */
105
113
  declare _localeOpen: boolean;
106
114
  /** Search query inside the locale combobox. */
@@ -109,6 +117,7 @@ export class JantSettingsGeneral extends LitElement {
109
117
  declare _timeZone: string;
110
118
  declare _origLocale: {
111
119
  siteLanguage: string;
120
+ dashboardLanguage: string;
112
121
  cjkSerifFont: string;
113
122
  timeZone: string;
114
123
  };
@@ -145,12 +154,14 @@ export class JantSettingsGeneral extends LitElement {
145
154
  this.labels = {} as SettingsLabels;
146
155
  this.timezones = [];
147
156
  this.cjkFonts = [];
157
+ this.dashboardLanguages = [];
148
158
  this.siteNameFallback = "";
149
159
  this.siteDescriptionFallback = "";
150
160
  this.demoMode = false;
151
161
  this.mainFeedUrl = "/feed";
152
- this.latestFeedUrl = "/feed/latest";
153
- this.featuredFeedUrl = "/feed/featured";
162
+ this.latestFeedUrl = "/latest/feed";
163
+ this.featuredFeedUrl = "/featured/feed";
164
+ this.archiveFeedUrl = "/archive/feed";
154
165
 
155
166
  this._siteName = "";
156
167
  this._siteDescription = "";
@@ -164,12 +175,14 @@ export class JantSettingsGeneral extends LitElement {
164
175
  this._siteLoading = false;
165
176
 
166
177
  this._siteLanguage = "en";
178
+ this._dashboardLanguage = "en";
167
179
  this._localeOpen = false;
168
180
  this._localeQuery = "";
169
181
  this._cjkSerifFont = "off";
170
182
  this._timeZone = "UTC";
171
183
  this._origLocale = {
172
184
  siteLanguage: "en",
185
+ dashboardLanguage: "en",
173
186
  cjkSerifFont: "off",
174
187
  timeZone: "UTC",
175
188
  };
@@ -213,10 +226,12 @@ export class JantSettingsGeneral extends LitElement {
213
226
  this._siteFooter = data.siteFooter;
214
227
 
215
228
  this._siteLanguage = data.siteLanguage;
229
+ this._dashboardLanguage = data.dashboardLanguage;
216
230
  this._cjkSerifFont = data.cjkSerifFont;
217
231
  this._timeZone = data.timeZone;
218
232
  this._origLocale = {
219
233
  siteLanguage: data.siteLanguage,
234
+ dashboardLanguage: data.dashboardLanguage,
220
235
  cjkSerifFont: data.cjkSerifFont,
221
236
  timeZone: data.timeZone,
222
237
  };
@@ -255,6 +270,7 @@ export class JantSettingsGeneral extends LitElement {
255
270
  } else if (section === "language-time") {
256
271
  this._origLocale = {
257
272
  siteLanguage: this._siteLanguage,
273
+ dashboardLanguage: this._dashboardLanguage,
258
274
  cjkSerifFont: this._cjkSerifFont,
259
275
  timeZone: this._timeZone,
260
276
  };
@@ -381,6 +397,7 @@ export class JantSettingsGeneral extends LitElement {
381
397
  private _syncLocaleDirty() {
382
398
  this._localeDirty =
383
399
  this._siteLanguage !== this._origLocale.siteLanguage ||
400
+ this._dashboardLanguage !== this._origLocale.dashboardLanguage ||
384
401
  this._cjkSerifFont !== this._origLocale.cjkSerifFont ||
385
402
  this._timeZone !== this._origLocale.timeZone;
386
403
  }
@@ -395,6 +412,7 @@ export class JantSettingsGeneral extends LitElement {
395
412
  endpoint: "/settings/general/language-time",
396
413
  data: {
397
414
  siteLanguage: this._siteLanguage,
415
+ dashboardLanguage: this._dashboardLanguage,
398
416
  cjkSerifFont: this._cjkSerifFont,
399
417
  timeZone: this._timeZone,
400
418
  },
@@ -465,28 +483,22 @@ export class JantSettingsGeneral extends LitElement {
465
483
  const noMatches = this.labels.siteLanguageNoMatches || "No matches.";
466
484
 
467
485
  return html`
468
- <div class="relative" data-locale-picker>
486
+ <div class="relative w-fit max-w-full" data-locale-picker>
469
487
  <button
470
488
  type="button"
471
- class="input flex w-full items-center justify-between text-left"
489
+ 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
490
  aria-expanded=${this._localeOpen ? "true" : "false"}
473
491
  aria-haspopup="listbox"
474
492
  aria-labelledby="site-language-label"
475
493
  @click=${this._toggleLocalePicker}
476
494
  >
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>
495
+ <span class="min-w-0 truncate">${current.native}</span>
484
496
  </button>
485
497
 
486
498
  ${this._localeOpen
487
499
  ? html`
488
500
  <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"
501
+ 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
502
  >
491
503
  <div class="border-b p-2">
492
504
  <input
@@ -518,21 +530,16 @@ export class JantSettingsGeneral extends LitElement {
518
530
  ? "true"
519
531
  : "false"}
520
532
  class=${[
521
- "flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-accent",
533
+ "flex w-full flex-col px-3 py-2 text-left text-sm hover:bg-accent",
522
534
  entry.tag === this._siteLanguage
523
535
  ? "bg-accent/60"
524
536
  : "",
525
537
  ].join(" ")}
526
538
  @click=${() => this._selectLocale(entry.tag)}
527
539
  >
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>
540
+ <span>${entry.native}</span>
534
541
  <span class="text-xs text-muted-foreground">
535
- ${Math.round(entry.coverage * 100)}% translated
542
+ ${entry.english}
536
543
  </span>
537
544
  </button>
538
545
  `,
@@ -714,10 +721,17 @@ export class JantSettingsGeneral extends LitElement {
714
721
  }
715
722
  }
716
723
 
717
- private _renderFeedInfoRow(label: string, value: string) {
724
+ private _renderFeedInfoRow(
725
+ label: string,
726
+ value: string,
727
+ description?: string,
728
+ ) {
718
729
  return html`
719
730
  <div class="flex min-w-0 flex-col gap-1">
720
731
  <p class="text-sm font-medium">${label}</p>
732
+ ${description
733
+ ? html`<p class="text-sm text-muted-foreground">${description}</p>`
734
+ : ""}
721
735
  <div class="relative">
722
736
  <input
723
737
  type="text"
@@ -819,6 +833,40 @@ export class JantSettingsGeneral extends LitElement {
819
833
  <p class="text-sm text-muted-foreground mt-1">
820
834
  ${this.labels.siteLanguageHelp}
821
835
  </p>
836
+ <p class="text-sm text-muted-foreground mt-1">
837
+ ${this.labels.contentLanguagePreview}
838
+ <code class="rounded bg-muted px-1.5 py-0.5 text-xs"
839
+ >${`<html lang="${this._siteLanguage || "en"}">`}</code
840
+ >
841
+ </p>
842
+ </div>
843
+
844
+ <div class="field">
845
+ <label id="dashboard-language-label" class="label"
846
+ >${this.labels.dashboardLanguage}</label
847
+ >
848
+ <select
849
+ class="select"
850
+ aria-labelledby="dashboard-language-label"
851
+ @change=${(e: Event) => {
852
+ this._dashboardLanguage = (e.target as HTMLSelectElement).value;
853
+ this._syncLocaleDirty();
854
+ }}
855
+ >
856
+ ${this.dashboardLanguages.map(
857
+ (lang) => html`
858
+ <option
859
+ value=${lang.value}
860
+ ?selected=${this._dashboardLanguage === lang.value}
861
+ >
862
+ ${lang.label}
863
+ </option>
864
+ `,
865
+ )}
866
+ </select>
867
+ <p class="text-sm text-muted-foreground mt-1">
868
+ ${this.labels.dashboardLanguageHelp}
869
+ </p>
822
870
  </div>
823
871
 
824
872
  <div class="field">
@@ -929,6 +977,11 @@ export class JantSettingsGeneral extends LitElement {
929
977
  this.labels.featuredFeedUrl,
930
978
  this.featuredFeedUrl,
931
979
  )}
980
+ ${this._renderFeedInfoRow(
981
+ this.labels.archiveFeedUrl,
982
+ this.archiveFeedUrl,
983
+ this.labels.archiveFeedUrlHelp,
984
+ )}
932
985
  </div>
933
986
  </div>
934
987