@jant/core 0.6.3 → 0.6.4

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.
@@ -3419,9 +3419,9 @@ function normalizeThemeColorForMeta(color) {
3419
3419
  * internal paths (e.g. `/_assets/client-HASH.js`) embedded by the Worker build
3420
3420
  * from the Vite client manifest. Used only in production (IS_VITE_DEV=false).
3421
3421
  */ var IS_VITE_DEV = typeof __JANT_DEV__ !== "undefined" && __JANT_DEV__ === true;
3422
- var CORE_VERSION = "0.6.3-2de57b4c4be1aa1c";
3423
- var CLIENT_JS_FILE = "/_assets/client-CTrEFM5W.js";
3424
- var CLIENT_AUTH_JS_FILE = "/_assets/client-auth-LBSZxqNC.js";
3422
+ var CORE_VERSION = "0.6.4-619b1a90eba61395";
3423
+ var CLIENT_JS_FILE = "/_assets/client-B1XjvRqE.js";
3424
+ var CLIENT_AUTH_JS_FILE = "/_assets/client-auth-B9T2QFl2.js";
3425
3425
  var CLIENT_CSS_FILE = "/_assets/client-BMPMuwvV.css";
3426
3426
  var CLIENT_CJK_CSS_FILE = "/_assets/client-cjk-B7Z0snDu.css";
3427
3427
  var CLIENT_CJK_TC_CSS_FILE = "/_assets/client-cjk-tc-BesJYrb2.css";
@@ -3740,7 +3740,7 @@ var IconSprite = () => {
3740
3740
  const cjkSerifFont = appConfig?.cjkSerifFont ?? "off";
3741
3741
  const cjkStylesheetPath = cjkSerifFont === "zh-Hans" ? IS_VITE_DEV ? assetPath("/src/style-cjk.css") : toPublicAssetPath(CLIENT_CJK_CSS_FILE, assetBasePath) : cjkSerifFont === "zh-Hant" ? IS_VITE_DEV ? assetPath("/src/style-cjk-tc.css") : toPublicAssetPath(CLIENT_CJK_TC_CSS_FILE, assetBasePath) : cjkSerifFont === "ja" ? IS_VITE_DEV ? assetPath("/src/style-cjk-jp.css") : toPublicAssetPath(CLIENT_CJK_JP_CSS_FILE, assetBasePath) : cjkSerifFont === "ko" ? IS_VITE_DEV ? assetPath("/src/style-cjk-kr.css") : toPublicAssetPath(CLIENT_CJK_KR_CSS_FILE, assetBasePath) : null;
3742
3742
  const clientScriptPath = IS_VITE_DEV ? resolvedClientBundle === "full" ? assetPath("/src/client-auth.ts") : assetPath("/src/client.ts") : toPublicAssetPath(resolvedClientBundle === "full" ? CLIENT_AUTH_JS_FILE : CLIENT_JS_FILE, assetBasePath);
3743
- const faviconAssetVersion = resolvedFaviconVersion || "0.6.3-2de57b4c4be1aa1c";
3743
+ const faviconAssetVersion = resolvedFaviconVersion || "0.6.4-619b1a90eba61395";
3744
3744
  const resolvedFaviconHref = faviconHref ?? (faviconAssetVersion ? toPublicPath(`/favicon.ico?v=${faviconAssetVersion}`, sitePathPrefix) : toPublicPath("/favicon.ico", sitePathPrefix));
3745
3745
  const resolvedAppleTouchHref = appleTouchHref ?? (faviconAssetVersion ? toPublicPath(`/apple-touch-icon.png?v=${faviconAssetVersion}`, sitePathPrefix) : toPublicPath("/apple-touch-icon.png", sitePathPrefix));
3746
3746
  const socialImageHref = resolvedSocialImagePath ? toAbsoluteAssetUrl(resolvedSocialImagePath, appConfig?.siteUrl || "", sitePathPrefix) : "";
@@ -10112,6 +10112,35 @@ function getThreadPreviewState({ secondReply, penultimateReply, latestReply, tot
10112
10112
  ].filter((post) => post !== void 0).map((post) => post.id));
10113
10113
  return { hiddenCount: Math.max(0, totalReplyCount - visibleReplyIds.size) };
10114
10114
  }
10115
+ /**
10116
+ * A lone root post shorter than this (plain-text code points) comfortably
10117
+ * fits the thread-context height cap. Pairs with the
10118
+ * `--site-thread-context-max-height` token (160px) in tokens.css — revisit
10119
+ * both together if that cap changes.
10120
+ */ var SHORT_ROOT_CHAR_LIMIT = 120;
10121
+ /**
10122
+ * First-paint guess for whether the thread-context shell will overflow its
10123
+ * height cap, used to pick the initial "Show more" toggle visibility.
10124
+ *
10125
+ * The client (thread-context.ts) always re-measures and corrects this, so a
10126
+ * wrong guess costs at most a one-time flash on load — never a wrong final
10127
+ * state. Kept deliberately coarse:
10128
+ *
10129
+ * - 3+ post threads stack 2+ cards in the shell — effectively always overflow.
10130
+ * - A lone root carrying media is tall regardless of its text length.
10131
+ * - Otherwise fall back to a plain-text length threshold. Only clearly short
10132
+ * roots flip to "fits"; anything at or above the limit keeps the historical
10133
+ * "assume overflow" default, so this can only remove flashes, never add them.
10134
+ *
10135
+ * @param rootPost - the thread's root post (the only post in a 2-post thread's
10136
+ * shell)
10137
+ * @param totalReplyCount - replies in the thread; `>= 2` means 3+ posts total
10138
+ * @returns whether to render the toggle visible on first paint
10139
+ */ function threadContextAssumesOverflow({ rootPost, totalReplyCount }) {
10140
+ if (totalReplyCount >= 2) return true;
10141
+ if (rootPost.media.length > 0 || Boolean(rootPost.previewImageUrl)) return true;
10142
+ return [...rootPost.summary ?? ""].length >= SHORT_ROOT_CHAR_LIMIT;
10143
+ }
10115
10144
  //#endregion
10116
10145
  //#region src/ui/feed/ThreadPreview.tsx
10117
10146
  /**
@@ -10133,6 +10162,10 @@ var ThreadPreview = ({ rootPost, secondReply, penultimateReply, latestReply, tot
10133
10162
  latestReply,
10134
10163
  totalReplyCount
10135
10164
  });
10165
+ const assumeOverflow = threadContextAssumesOverflow({
10166
+ rootPost,
10167
+ totalReplyCount
10168
+ });
10136
10169
  const hiddenPostsLabel = i18n._({ id: "oO0hKx" }, { count: hiddenCount });
10137
10170
  const showMoreLabel = i18n._({ id: "fMPkxb" });
10138
10171
  const showLessLabel = i18n._({ id: "6lGV3K" });
@@ -10192,6 +10225,7 @@ var ThreadPreview = ({ rootPost, secondReply, penultimateReply, latestReply, tot
10192
10225
  "data-label-more": showMoreLabel,
10193
10226
  "data-label-less": showLessLabel,
10194
10227
  "aria-expanded": "false",
10228
+ hidden: !assumeOverflow,
10195
10229
  children: [/* @__PURE__ */ jsxDEV$1("span", {
10196
10230
  class: "thread-context-toggle-label",
10197
10231
  children: showMoreLabel
@@ -27844,6 +27878,55 @@ manifestRoutes.get("/manifest.webmanifest", (c) => {
27844
27878
  };
27845
27879
  }
27846
27880
  //#endregion
27881
+ //#region src/middleware/cache-control.ts
27882
+ /**
27883
+ * Cache-Control Middleware
27884
+ *
27885
+ * Sets a safe default `Cache-Control` on responses that don't declare one.
27886
+ *
27887
+ * Almost every Jant page is auth-variant: the same URL renders differently
27888
+ * for the signed-in author (nav, the "more" menu, edit affordances) than for
27889
+ * an anonymous visitor. A shared/CDN cache keyed only by URL must therefore
27890
+ * never store these pages — otherwise it serves a stale or wrong-audience
27891
+ * snapshot, which both breaks the UI ("you still look signed out", "your edit
27892
+ * didn't take effect") and can leak the authenticated dashboard to the public.
27893
+ *
27894
+ * Jant is self-hosted software that runs behind whatever reverse proxy or CDN
27895
+ * the operator chooses, so it cannot rely on infrastructure config to get
27896
+ * this right — it must declare its own cache policy. The critical mistake is
27897
+ * emitting `Cache-Control: public`: that word is an explicit invitation for
27898
+ * any shared cache to store the response.
27899
+ *
27900
+ * Routes that serve genuinely public, auth-invariant resources (media, feeds,
27901
+ * sitemaps, favicons, manifests, static assets) set their own `Cache-Control`
27902
+ * explicitly; this middleware leaves those untouched and only fills in the
27903
+ * default for the un-annotated dynamic responses.
27904
+ */ /**
27905
+ * Default cache directive for dynamic, potentially auth-variant responses.
27906
+ * `private` forbids shared/CDN caches from storing the response; `no-store`
27907
+ * prevents any cache (including the browser) from keeping a copy.
27908
+ */ var DEFAULT_CACHE_CONTROL = "private, no-store";
27909
+ /**
27910
+ * Middleware that defaults a missing `Cache-Control` header to
27911
+ * `private, no-store`.
27912
+ *
27913
+ * Runs after the route handler: if the handler (or an inner middleware)
27914
+ * already set `Cache-Control`, that explicit value wins. Only responses that
27915
+ * declare nothing receive the safe default.
27916
+ *
27917
+ * @returns Hono middleware enforcing the default cache policy.
27918
+ *
27919
+ * @example
27920
+ * ```ts
27921
+ * app.use("*", defaultCacheControl());
27922
+ * ```
27923
+ */ function defaultCacheControl() {
27924
+ return async (c, next) => {
27925
+ await next();
27926
+ if (!c.res.headers.has("Cache-Control")) c.res.headers.set("Cache-Control", DEFAULT_CACHE_CONTROL);
27927
+ };
27928
+ }
27929
+ //#endregion
27847
27930
  //#region src/middleware/onboarding.ts
27848
27931
  /**
27849
27932
  * Onboarding Middleware
@@ -34129,6 +34212,7 @@ async function servePublicStorage(c) {
34129
34212
  await next();
34130
34213
  });
34131
34214
  app.use("*", attachSession());
34215
+ app.use("*", defaultCacheControl());
34132
34216
  app.use("*", async (c, next) => {
34133
34217
  const redirectUrl = await getHostedCanonicalRedirect({
34134
34218
  currentSite: c.var.currentSite,
@@ -1,5 +1,5 @@
1
1
  import "./url-XF0GbKGO.js";
2
- import { t as createApp } from "./app-CyysIxj_.js";
2
+ import { t as createApp } from "./app-B-wKZB8f.js";
3
3
  import "./export-CzuQyg5h.js";
4
4
  import "./env-CoSe-1y4.js";
5
5
  import "./github-sync-Dbrb1DS5.js";
@@ -146,7 +146,7 @@
146
146
  "name": "url"
147
147
  },
148
148
  "src/client-auth.ts": {
149
- "file": "_assets/client-auth-LBSZxqNC.js",
149
+ "file": "_assets/client-auth-B9T2QFl2.js",
150
150
  "name": "client-auth",
151
151
  "src": "src/client-auth.ts",
152
152
  "isEntry": true,
@@ -163,7 +163,7 @@
163
163
  ]
164
164
  },
165
165
  "src/client.ts": {
166
- "file": "_assets/client-CTrEFM5W.js",
166
+ "file": "_assets/client-B1XjvRqE.js",
167
167
  "name": "client",
168
168
  "src": "src/client.ts",
169
169
  "isEntry": true,
@@ -272,4 +272,4 @@ ${c}`:c;break;case`event`:r.event=c;break;case`id`:e(r.id=c);break;case`retry`:{
272
272
  </div>`}
273
273
  </div>
274
274
  </dialog>
275
- `:J}};customElements.define(`jant-text-preview`,Xi);var Zi=8;function Qi(e,t){let n=Number.parseFloat(e);return Number.isFinite(n)?n:t}function $i(e){return Qi(getComputedStyle(e).getPropertyValue(`--site-thread-context-max-height`).trim(),240)}function ea(e){if(e.dataset.threadContextToggleBound===`1`)return;e.dataset.threadContextToggleBound=`1`;let t=e.previousElementSibling;if(!(t instanceof HTMLElement)||t.dataset.threadContext===void 0)return;let n=e.querySelector(`.thread-context-toggle-label`),r=e.dataset.labelMore??`Show more`,i=e.dataset.labelLess??`Show less`,a=!1,o=t=>{e.setAttribute(`aria-expanded`,t?`true`:`false`),n&&(n.textContent=t?i:r)},s=()=>Array.from(t.querySelectorAll(`img`)).every(e=>e.complete),c=()=>{if(a)return;let n=$i(t),r=t.scrollHeight>n+Zi;t.dataset.collapsed===void 0&&(t.dataset.collapsed=``),r?(e.hidden=!1,o(!1)):s()&&(e.hidden=!0)};if(c(),t.querySelectorAll(`img`).forEach(e=>{e.complete||(e.addEventListener(`load`,c,{once:!0}),e.addEventListener(`error`,c,{once:!0}))}),`ResizeObserver`in globalThis){let e=0;new globalThis.ResizeObserver(()=>{cancelAnimationFrame(e),e=requestAnimationFrame(c)}).observe(t)}let l=()=>{let e=!1,n=r=>{e||r&&r.propertyName!==`max-height`||(e=!0,t.removeEventListener(`transitionend`,n),t.dataset.collapsed===void 0&&(t.style.maxHeight=``))};t.addEventListener(`transitionend`,n),window.setTimeout(n,600)};e.addEventListener(`click`,()=>{a=!0,t.dataset.collapsed===void 0?(t.style.maxHeight=`${t.scrollHeight}px`,t.offsetHeight,requestAnimationFrame(()=>{t.dataset.collapsed=``,t.style.maxHeight=``}),o(!1)):(t.style.maxHeight=`${t.scrollHeight}px`,t.offsetHeight,delete t.dataset.collapsed,o(!0)),l()})}function ta(e=document){e.querySelectorAll(`[data-thread-context-toggle]`).forEach(ea)}function na(e){let t=e.closest(`.thread-group-detail`);return t?t.querySelector(`.thread-detail-item`)===e:!1}function ra(){return globalThis.location.hash===`#continue`}function ia(e=document){let t=e.querySelector(`[data-post-current]`);if(!(t instanceof HTMLElement))return;let n=ra();if(globalThis.location.hash&&!n)return;let r=n?`auto`:`smooth`,i=na(t);requestAnimationFrame(()=>{i&&!n||t.scrollIntoView({behavior:r,block:`start`})})}document.addEventListener(`DOMContentLoaded`,()=>{ta(document),ia(document)}),document.querySelectorAll(`.archive-chip-dropdown`).forEach(e=>{let t=e.querySelector(`:scope > button`),n=e.querySelector(`:scope > [data-popover]`),r=n?n.querySelector(`[role="listbox"]`):null;if(!t||!n||!r)return;let i=Array.from(r.querySelectorAll(`[role="option"]`)),a=e.classList.contains(`archive-chip-media`),o=e.dataset.filterKey,s=new Set;a&&i.forEach(e=>{!e.dataset.navigate&&e.getAttribute(`aria-selected`)===`true`&&s.add(e.dataset.value)});let c=new Set(s),l=()=>{document.dispatchEvent(new CustomEvent(`basecoat:popover`,{detail:{source:e}})),n.setAttribute(`aria-hidden`,`false`),t.setAttribute(`aria-expanded`,`true`),a&&(c=new Set(s))},u=(e=!0)=>{if(n.getAttribute(`aria-hidden`)!==`true`&&(n.setAttribute(`aria-hidden`,`true`),t.setAttribute(`aria-expanded`,`false`),e&&t.focus(),a&&o&&(s.size!==c.size||[...s].some(e=>!c.has(e))))){let e=new URL(window.location.href),t=[...s];t.length>0?e.searchParams.set(o,t.join(`,`)):e.searchParams.delete(o),e.searchParams.delete(`hasMedia`),e.searchParams.delete(`page`),window.location.href=e.pathname+(e.search||``)}};t.addEventListener(`click`,e=>{e.target.closest(`.archive-chip-clear`)||(t.getAttribute(`aria-expanded`)===`true`?u():l())}),document.addEventListener(`click`,t=>{e.contains(t.target)||u(!1)}),document.addEventListener(`basecoat:popover`,t=>{t.detail.source!==e&&u(!1)}),r.addEventListener(`click`,e=>{let t=e.target.closest(`[role="option"]`);if(t){if(t.dataset.navigate){let e=t.dataset.value;typeof e==`string`&&e.startsWith(`/`)&&(window.location.href=e);return}if(a){let e=t.dataset.value;s.has(e)?(s.delete(e),t.removeAttribute(`aria-selected`)):(s.add(e),t.setAttribute(`aria-selected`,`true`))}else{let e=t.dataset.value;typeof e==`string`&&e.startsWith(`/`)&&(window.location.href=e)}}});let d=-1,f=e=>{d>-1&&i[d]&&i[d].classList.remove(`active`),d=e,d>-1&&i[d]&&(i[d].classList.add(`active`),i[d].scrollIntoView({block:`nearest`}))};r.addEventListener(`mousemove`,e=>{let t=e.target.closest(`[role="option"]`);if(t){let e=i.indexOf(t);e!==-1&&e!==d&&f(e)}}),r.addEventListener(`mouseleave`,()=>{f(-1)}),t.addEventListener(`keydown`,e=>{let t=n.getAttribute(`aria-hidden`)===`false`;if(e.key===`Escape`){t&&(e.preventDefault(),u());return}if(!t&&[`ArrowDown`,`ArrowUp`].includes(e.key)){e.preventDefault(),l();return}t&&(e.preventDefault(),e.key===`ArrowDown`?f(Math.min(d+1,i.length-1)):e.key===`ArrowUp`?f(Math.max(d-1,0)):e.key===`Enter`&&d>-1&&(i[d].click(),!a&&!i[d].dataset.navigate&&u()))})});function aa(e){let t=e.querySelector(`.site-header-more-btn`),n=e.querySelector(`.site-header-more-popover`);if(!t||!n||t.dataset.moreInitialized===`true`)return;t.dataset.moreInitialized=`true`;function r(){n.setAttribute(`aria-hidden`,`false`),t.setAttribute(`aria-expanded`,`true`),document.dispatchEvent(new CustomEvent(`basecoat:popover`,{detail:{source:t.parentElement}}))}function i(e=!1){n.setAttribute(`aria-hidden`,`true`),t.setAttribute(`aria-expanded`,`false`),e&&t.focus()}t.addEventListener(`click`,e=>{e.preventDefault(),e.stopPropagation(),t.getAttribute(`aria-expanded`)===`true`?i():r()}),document.addEventListener(`click`,e=>{e.target instanceof Node&&(t.parentElement?.contains(e.target)||i())}),document.addEventListener(`keydown`,e=>{e.key===`Escape`&&n.getAttribute(`aria-hidden`)===`false`&&i(!0)}),document.addEventListener(`basecoat:popover`,e=>{e.detail?.source!==t.parentElement&&i()})}var oa=`jant:nav-fresh-visits`;function sa(){let e=document.createElement(`span`);return e.className=`site-header-link-fresh`,e.setAttribute(`aria-hidden`,`true`),e.textContent=`*`,e}function ca(e){try{let t=JSON.parse(localStorage.getItem(oa)||`{}`),n=location.pathname,r=e.querySelectorAll(`[data-fresh-at]`);for(let e of r){let r=new URL(e.href).pathname,i=parseInt(e.dataset.freshAt,10);if(r===n)t[r]=Math.floor(Date.now()/1e3);else{let n=t[r];if(!n||n<i){let t=e.querySelector(`svg`);e.insertBefore(sa(),t)}}}localStorage.setItem(oa,JSON.stringify(t))}catch{}}function la(e=document){let t=e.querySelector(`.site-header-hamburger`),n=e.querySelector(`#site-nav-drawer`),r=e.querySelector(`.site-nav-drawer-backdrop`),i=n?.querySelector(`.site-nav-drawer-close`);if(ca(e),aa(e),!t||!n||!r||t.dataset.drawerInitialized===`true`)return;t.dataset.drawerInitialized=`true`;function a(){n.setAttribute(`aria-hidden`,`false`),n.removeAttribute(`inert`),r.setAttribute(`aria-hidden`,`false`),t.setAttribute(`aria-expanded`,`true`),document.documentElement.classList.add(`drawer-open`);let e=n.querySelector(`.site-nav-drawer-close`)??n.querySelector(`a[href], button`);e&&e.focus()}function o(e=!0){n.setAttribute(`aria-hidden`,`true`),r.setAttribute(`aria-hidden`,`true`),t.setAttribute(`aria-expanded`,`false`),document.documentElement.classList.remove(`drawer-open`),n.addEventListener(`transitionend`,()=>{n.getAttribute(`aria-hidden`)===`true`&&n.setAttribute(`inert`,``)},{once:!0}),e&&t.focus()}t.addEventListener(`click`,()=>{t.getAttribute(`aria-expanded`)===`true`?o():a()}),i?.addEventListener(`click`,()=>o()),r.addEventListener(`click`,()=>o()),n.addEventListener(`click`,e=>{e.target instanceof Element&&e.target.closest(`a[href]`)&&o(!1)}),n.addEventListener(`keydown`,e=>{e.key===`Escape`&&(e.preventDefault(),o())})}la();function ua(e=document){e.querySelectorAll(`.collection-sort-menu`).forEach(e=>{if(e.dataset.collectionSortMenuInitialized===`true`)return;let t=e.querySelector(`:scope > .collection-sort-trigger`),n=e.querySelector(`:scope > [data-popover]`),r=n?.querySelector(`[data-collection-sort-options]`);if(!(t instanceof HTMLButtonElement)||!n||!r)return;let i=(e=!1)=>{n.getAttribute(`aria-hidden`)!==`true`&&(n.setAttribute(`aria-hidden`,`true`),t.setAttribute(`aria-expanded`,`false`),e&&t.focus())},a=()=>{document.dispatchEvent(new CustomEvent(`basecoat:popover`,{detail:{source:e}})),n.setAttribute(`aria-hidden`,`false`),t.setAttribute(`aria-expanded`,`true`)};t.addEventListener(`click`,e=>{e.preventDefault(),e.stopPropagation(),t.getAttribute(`aria-expanded`)===`true`?i(!1):a()}),t.addEventListener(`keydown`,e=>{if(e.key===`Escape`){e.preventDefault(),i(!0);return}if(e.key===`ArrowDown`){e.preventDefault(),t.getAttribute(`aria-expanded`)!==`true`&&a();let n=r.querySelector(`a[href]`);n instanceof HTMLElement&&n.focus()}}),n.addEventListener(`keydown`,e=>{e.key===`Escape`&&(e.preventDefault(),i(!0))}),document.addEventListener(`click`,t=>{t.target instanceof Node&&(e.contains(t.target)||i(!1))}),document.addEventListener(`basecoat:popover`,t=>{t.detail?.source!==e&&i(!1)}),e.dataset.collectionSortMenuInitialized=`true`})}ua(),rn(),document.documentElement.dataset.jantVersion=`0.6.3-2de57b4c4be1aa1c`;export{tn as _,Gi as a,Ai as c,hi as d,Z as f,ui as g,K as h,$ as i,ji as l,q as m,Ki as n,Mi as o,J as p,Wi as r,Ni as s,qi as t,ki as u};
275
+ `:J}};customElements.define(`jant-text-preview`,Xi);var Zi=8;function Qi(e,t){let n=Number.parseFloat(e);return Number.isFinite(n)?n:t}function $i(e){return Qi(getComputedStyle(e).getPropertyValue(`--site-thread-context-max-height`).trim(),240)}function ea(e){if(e.dataset.threadContextToggleBound===`1`)return;e.dataset.threadContextToggleBound=`1`;let t=e.previousElementSibling;if(!(t instanceof HTMLElement)||t.dataset.threadContext===void 0)return;let n=e.querySelector(`.thread-context-toggle-label`),r=e.dataset.labelMore??`Show more`,i=e.dataset.labelLess??`Show less`,a=!1,o=t=>{e.setAttribute(`aria-expanded`,t?`true`:`false`),n&&(n.textContent=t?i:r)},s=()=>Array.from(t.querySelectorAll(`img`)).every(e=>e.complete),c=()=>{if(a)return;let n=$i(t),r=t.scrollHeight>n+Zi;t.dataset.collapsed===void 0&&(t.dataset.collapsed=``),r?(e.hidden=!1,o(!1)):s()&&(e.hidden=!0)};if(c(),t.querySelectorAll(`img`).forEach(e=>{e.complete||(e.addEventListener(`load`,c,{once:!0}),e.addEventListener(`error`,c,{once:!0}))}),`ResizeObserver`in globalThis){let e=0;new globalThis.ResizeObserver(()=>{cancelAnimationFrame(e),e=requestAnimationFrame(c)}).observe(t)}let l=()=>{let e=!1,n=r=>{e||r&&r.propertyName!==`max-height`||(e=!0,t.removeEventListener(`transitionend`,n),t.dataset.collapsed===void 0&&(t.style.maxHeight=``))};t.addEventListener(`transitionend`,n),window.setTimeout(n,600)};e.addEventListener(`click`,()=>{a=!0,t.dataset.collapsed===void 0?(t.style.maxHeight=`${t.scrollHeight}px`,t.offsetHeight,requestAnimationFrame(()=>{t.dataset.collapsed=``,t.style.maxHeight=``}),o(!1)):(t.style.maxHeight=`${t.scrollHeight}px`,t.offsetHeight,delete t.dataset.collapsed,o(!0)),l()})}function ta(e=document){e.querySelectorAll(`[data-thread-context-toggle]`).forEach(ea)}function na(e){let t=e.closest(`.thread-group-detail`);return t?t.querySelector(`.thread-detail-item`)===e:!1}function ra(){return globalThis.location.hash===`#continue`}function ia(e=document){let t=e.querySelector(`[data-post-current]`);if(!(t instanceof HTMLElement))return;let n=ra();if(globalThis.location.hash&&!n)return;let r=n?`auto`:`smooth`,i=na(t);requestAnimationFrame(()=>{i&&!n||t.scrollIntoView({behavior:r,block:`start`})})}document.addEventListener(`DOMContentLoaded`,()=>{ta(document),ia(document)}),document.querySelectorAll(`.archive-chip-dropdown`).forEach(e=>{let t=e.querySelector(`:scope > button`),n=e.querySelector(`:scope > [data-popover]`),r=n?n.querySelector(`[role="listbox"]`):null;if(!t||!n||!r)return;let i=Array.from(r.querySelectorAll(`[role="option"]`)),a=e.classList.contains(`archive-chip-media`),o=e.dataset.filterKey,s=new Set;a&&i.forEach(e=>{!e.dataset.navigate&&e.getAttribute(`aria-selected`)===`true`&&s.add(e.dataset.value)});let c=new Set(s),l=()=>{document.dispatchEvent(new CustomEvent(`basecoat:popover`,{detail:{source:e}})),n.setAttribute(`aria-hidden`,`false`),t.setAttribute(`aria-expanded`,`true`),a&&(c=new Set(s))},u=(e=!0)=>{if(n.getAttribute(`aria-hidden`)!==`true`&&(n.setAttribute(`aria-hidden`,`true`),t.setAttribute(`aria-expanded`,`false`),e&&t.focus(),a&&o&&(s.size!==c.size||[...s].some(e=>!c.has(e))))){let e=new URL(window.location.href),t=[...s];t.length>0?e.searchParams.set(o,t.join(`,`)):e.searchParams.delete(o),e.searchParams.delete(`hasMedia`),e.searchParams.delete(`page`),window.location.href=e.pathname+(e.search||``)}};t.addEventListener(`click`,e=>{e.target.closest(`.archive-chip-clear`)||(t.getAttribute(`aria-expanded`)===`true`?u():l())}),document.addEventListener(`click`,t=>{e.contains(t.target)||u(!1)}),document.addEventListener(`basecoat:popover`,t=>{t.detail.source!==e&&u(!1)}),r.addEventListener(`click`,e=>{let t=e.target.closest(`[role="option"]`);if(t){if(t.dataset.navigate){let e=t.dataset.value;typeof e==`string`&&e.startsWith(`/`)&&(window.location.href=e);return}if(a){let e=t.dataset.value;s.has(e)?(s.delete(e),t.removeAttribute(`aria-selected`)):(s.add(e),t.setAttribute(`aria-selected`,`true`))}else{let e=t.dataset.value;typeof e==`string`&&e.startsWith(`/`)&&(window.location.href=e)}}});let d=-1,f=e=>{d>-1&&i[d]&&i[d].classList.remove(`active`),d=e,d>-1&&i[d]&&(i[d].classList.add(`active`),i[d].scrollIntoView({block:`nearest`}))};r.addEventListener(`mousemove`,e=>{let t=e.target.closest(`[role="option"]`);if(t){let e=i.indexOf(t);e!==-1&&e!==d&&f(e)}}),r.addEventListener(`mouseleave`,()=>{f(-1)}),t.addEventListener(`keydown`,e=>{let t=n.getAttribute(`aria-hidden`)===`false`;if(e.key===`Escape`){t&&(e.preventDefault(),u());return}if(!t&&[`ArrowDown`,`ArrowUp`].includes(e.key)){e.preventDefault(),l();return}t&&(e.preventDefault(),e.key===`ArrowDown`?f(Math.min(d+1,i.length-1)):e.key===`ArrowUp`?f(Math.max(d-1,0)):e.key===`Enter`&&d>-1&&(i[d].click(),!a&&!i[d].dataset.navigate&&u()))})});function aa(e){let t=e.querySelector(`.site-header-more-btn`),n=e.querySelector(`.site-header-more-popover`);if(!t||!n||t.dataset.moreInitialized===`true`)return;t.dataset.moreInitialized=`true`;function r(){n.setAttribute(`aria-hidden`,`false`),t.setAttribute(`aria-expanded`,`true`),document.dispatchEvent(new CustomEvent(`basecoat:popover`,{detail:{source:t.parentElement}}))}function i(e=!1){n.setAttribute(`aria-hidden`,`true`),t.setAttribute(`aria-expanded`,`false`),e&&t.focus()}t.addEventListener(`click`,e=>{e.preventDefault(),e.stopPropagation(),t.getAttribute(`aria-expanded`)===`true`?i():r()}),document.addEventListener(`click`,e=>{e.target instanceof Node&&(t.parentElement?.contains(e.target)||i())}),document.addEventListener(`keydown`,e=>{e.key===`Escape`&&n.getAttribute(`aria-hidden`)===`false`&&i(!0)}),document.addEventListener(`basecoat:popover`,e=>{e.detail?.source!==t.parentElement&&i()})}var oa=`jant:nav-fresh-visits`;function sa(){let e=document.createElement(`span`);return e.className=`site-header-link-fresh`,e.setAttribute(`aria-hidden`,`true`),e.textContent=`*`,e}function ca(e){try{let t=JSON.parse(localStorage.getItem(oa)||`{}`),n=location.pathname,r=e.querySelectorAll(`[data-fresh-at]`);for(let e of r){let r=new URL(e.href).pathname,i=parseInt(e.dataset.freshAt,10);if(r===n)t[r]=Math.floor(Date.now()/1e3);else{let n=t[r];if(!n||n<i){let t=e.querySelector(`svg`);e.insertBefore(sa(),t)}}}localStorage.setItem(oa,JSON.stringify(t))}catch{}}function la(e=document){let t=e.querySelector(`.site-header-hamburger`),n=e.querySelector(`#site-nav-drawer`),r=e.querySelector(`.site-nav-drawer-backdrop`),i=n?.querySelector(`.site-nav-drawer-close`);if(ca(e),aa(e),!t||!n||!r||t.dataset.drawerInitialized===`true`)return;t.dataset.drawerInitialized=`true`;function a(){n.setAttribute(`aria-hidden`,`false`),n.removeAttribute(`inert`),r.setAttribute(`aria-hidden`,`false`),t.setAttribute(`aria-expanded`,`true`),document.documentElement.classList.add(`drawer-open`);let e=n.querySelector(`.site-nav-drawer-close`)??n.querySelector(`a[href], button`);e&&e.focus()}function o(e=!0){n.setAttribute(`aria-hidden`,`true`),r.setAttribute(`aria-hidden`,`true`),t.setAttribute(`aria-expanded`,`false`),document.documentElement.classList.remove(`drawer-open`),n.addEventListener(`transitionend`,()=>{n.getAttribute(`aria-hidden`)===`true`&&n.setAttribute(`inert`,``)},{once:!0}),e&&t.focus()}t.addEventListener(`click`,()=>{t.getAttribute(`aria-expanded`)===`true`?o():a()}),i?.addEventListener(`click`,()=>o()),r.addEventListener(`click`,()=>o()),n.addEventListener(`click`,e=>{e.target instanceof Element&&e.target.closest(`a[href]`)&&o(!1)}),n.addEventListener(`keydown`,e=>{e.key===`Escape`&&(e.preventDefault(),o())})}la();function ua(e=document){e.querySelectorAll(`.collection-sort-menu`).forEach(e=>{if(e.dataset.collectionSortMenuInitialized===`true`)return;let t=e.querySelector(`:scope > .collection-sort-trigger`),n=e.querySelector(`:scope > [data-popover]`),r=n?.querySelector(`[data-collection-sort-options]`);if(!(t instanceof HTMLButtonElement)||!n||!r)return;let i=(e=!1)=>{n.getAttribute(`aria-hidden`)!==`true`&&(n.setAttribute(`aria-hidden`,`true`),t.setAttribute(`aria-expanded`,`false`),e&&t.focus())},a=()=>{document.dispatchEvent(new CustomEvent(`basecoat:popover`,{detail:{source:e}})),n.setAttribute(`aria-hidden`,`false`),t.setAttribute(`aria-expanded`,`true`)};t.addEventListener(`click`,e=>{e.preventDefault(),e.stopPropagation(),t.getAttribute(`aria-expanded`)===`true`?i(!1):a()}),t.addEventListener(`keydown`,e=>{if(e.key===`Escape`){e.preventDefault(),i(!0);return}if(e.key===`ArrowDown`){e.preventDefault(),t.getAttribute(`aria-expanded`)!==`true`&&a();let n=r.querySelector(`a[href]`);n instanceof HTMLElement&&n.focus()}}),n.addEventListener(`keydown`,e=>{e.key===`Escape`&&(e.preventDefault(),i(!0))}),document.addEventListener(`click`,t=>{t.target instanceof Node&&(e.contains(t.target)||i(!1))}),document.addEventListener(`basecoat:popover`,t=>{t.detail?.source!==e&&i(!1)}),e.dataset.collectionSortMenuInitialized=`true`})}ua(),rn(),document.documentElement.dataset.jantVersion=`0.6.4-619b1a90eba61395`;export{tn as _,Gi as a,Ai as c,hi as d,Z as f,ui as g,K as h,$ as i,ji as l,q as m,Ki as n,Mi as o,J as p,Wi as r,Ni as s,qi as t,ki as u};
@@ -1,4 +1,4 @@
1
- import{a as e,n as t,o as n,t as r}from"./chunks/url-pLre2DM_.js";import{_ as i,a,c as o,d as s,f as c,g as l,h as u,i as d,l as f,m as p,n as m,o as h,p as g,r as _,s as v,t as y,u as b}from"./client-CTrEFM5W.js";var x={maxShortSide:1920,maxLongSide:8192,quality:.85,mimeType:`image/webp`};function S(e){return new Promise((t,n)=>{let r=new Image;r.onload=()=>{URL.revokeObjectURL(r.src),t(r)},r.onerror=()=>n(Error(`Failed to load image`)),r.src=URL.createObjectURL(e)})}function C(e,t,n){if(Math.max(e,t)>n.maxLongSide)return{passthrough:!0,width:e,height:t};let r=Math.min(e,t);if(r<=n.maxShortSide)return{passthrough:!1,width:e,height:t};let i=n.maxShortSide/r;return{passthrough:!1,width:Math.round(e*i),height:Math.round(t*i)}}async function ee(e,t,n){let r=await new Promise((r,i)=>{e.toBlob(e=>e?r(e):i(Error(`Failed to create blob`)),t,n)});return t!==`image/png`&&r.type===`image/png`?new Promise((t,r)=>{e.toBlob(e=>e?t(e):r(Error(`Failed to create blob`)),`image/jpeg`,n)}):r}async function te(e,t={}){let n={...x,...t},r=await S(e),i=C(r.width,r.height,n);if(i.passthrough)return{blob:e,width:i.width,height:i.height,processed:!1};let a=document.createElement(`canvas`);a.width=i.width,a.height=i.height;let o=a.getContext(`2d`);if(!o)throw Error(`Failed to get canvas context`);return o.drawImage(r,0,0,i.width,i.height),{blob:await ee(a,n.mimeType,n.quality),width:i.width,height:i.height,processed:!0}}async function ne(e,t={}){let n=await te(e,t);if(!n.processed)return{file:e,width:n.width,height:n.height};let r={"image/webp":`webp`,"image/jpeg":`jpg`,"image/png":`png`}[n.blob.type]??`png`,i=`${e.name.replace(/\.[^.]+$/,``)}.${r}`;return{file:new File([n.blob],i,{type:n.blob.type}),width:n.width,height:n.height}}var re={process:te,processToFile:ne},ie={ICO_16:16,ICO_32:32,APPLE_TOUCH:512};function ae(e){let t=e.length*16,n=6+t,r=new ArrayBuffer(6+t),i=new DataView(r);i.setUint16(0,0,!0),i.setUint16(2,1,!0),i.setUint16(4,e.length,!0);let a=[];for(let t=0;t<e.length;t++){let r=e[t],o=6+t*16;i.setUint8(o+0,r.size<256?r.size:0),i.setUint8(o+1,r.size<256?r.size:0),i.setUint8(o+2,0),i.setUint8(o+3,0),i.setUint16(o+4,1,!0),i.setUint16(o+6,32,!0),i.setUint32(o+8,r.png.byteLength,!0),i.setUint32(o+12,n,!0),n+=r.png.byteLength,a.push(r.png)}return new Blob([r,...a],{type:`image/x-icon`})}function oe(e){return typeof e==`object`&&!!e}function w(e,t){if(!oe(e))return;let n=e[t];return typeof n==`string`?n:void 0}function se(e,t){if(!oe(e))return;let n=e[t];return typeof n==`boolean`?n:void 0}function ce(e,t){if(!oe(e))return;let n=e[t];return typeof n==`number`?n:void 0}async function le(e){let t=await e.json();return oe(t)?t:{}}var ue=180;function de(e){return new Promise((t,n)=>{let r=new Image;r.onload=()=>{URL.revokeObjectURL(r.src),t(r)},r.onerror=()=>n(Error(`Failed to load image`)),r.src=URL.createObjectURL(e)})}function fe(e,t){let n=document.createElement(`canvas`);n.width=t,n.height=t;let r=n.getContext(`2d`);if(!r)throw Error(`Failed to get canvas context`);r.imageSmoothingEnabled=!0,r.imageSmoothingQuality=`high`;let i=Math.max(t/e.width,t/e.height),a=t/i,o=t/i,s=(e.width-a)/2,c=(e.height-o)/2;return r.drawImage(e,s,c,a,o,0,0,t,t),new Promise((e,t)=>{n.toBlob(n=>{n?e(n):t(Error(`Failed to create PNG blob`))},`image/png`)})}function pe(e,t){return e.type===`image/png`&&t.width===t.height&&t.width>=ue&&t.height>=ue}async function me(e,t){let n=e.closest(`form`)?.querySelector(`label`),r=n?.textContent??``;try{n&&(n.textContent=e.dataset.textProcessing||`Processing...`);let r=await de(t),a=t,o=t.name;if(t.type!==`image/svg+xml`){let e=await fe(r,512);a=new File([e],t.name.replace(/\.[^.]+$/,`.png`),{type:`image/png`}),o=a.name}let s=pe(t,r)?Promise.resolve(t):fe(r,ie.APPLE_TOUCH),[c,l,u]=await Promise.all([fe(r,16),fe(r,32),s]),[d,f]=await Promise.all([c.arrayBuffer(),l.arrayBuffer()]),p=ae([{size:16,png:d},{size:32,png:f}]);n&&(n.textContent=e.dataset.textUploading||`Uploading...`);let m=new FormData;m.append(`file`,a,o),m.append(`favicon`,p,`favicon.ico`),m.append(`appleTouch`,u,`apple-touch-icon.png`);let h=await fetch(i(`/settings/avatar`),{method:`POST`,headers:{Accept:`application/json`},body:m});if(!h.ok){let t=e.dataset.textError||`Upload failed. Please try again.`;try{let e=await le(h);t=w(e,`error`)??w(e,`message`)??t}catch{}throw Error(t)}let g=await le(h),_=w(g,`status`),v=w(g,`url`);if(_===`redirect`&&v){window.location.href=v;return}window.location.href=i(`/settings/avatar?saved`)}catch(t){n&&(n.textContent=r),d(t instanceof Error&&t.message?t.message:e.dataset.textError||`Upload failed. Please try again.`,`error`)}e.value=``}function he(){document.addEventListener(`change`,e=>{let t=e.target.closest(`[data-avatar-upload]`);t?.files?.[0]&&(e.stopPropagation(),me(t,t.files[0]))})}he();var ge=`default`,_e=class extends c{static properties={_open:{state:!0},_title:{state:!0},_message:{state:!0},_confirmLabel:{state:!0},_cancelLabel:{state:!0},_tone:{state:!0}};#e=[];#t=null;createRenderRoot(){return this.innerHTML=``,this}constructor(){super(),this._open=!1,this._title=``,this._message=``,this._confirmLabel=``,this._cancelLabel=``,this._tone=ge}disconnectedCallback(){super.disconnectedCallback();let e=this.#t;this.#t=null,e?.resolve(!1);for(let e of this.#e.splice(0))e.resolve(!1)}async confirm(e){return await new Promise(t=>{this.#e.push({...e,tone:e.tone??ge,resolve:t}),this.#t||this.#n()})}async#n(){if(this.#t||this.#e.length===0)return;let e=this.#e.shift();if(!e)return;this.#t=e,this._title=e.title??``,this._message=e.message,this._confirmLabel=e.confirmLabel,this._cancelLabel=e.cancelLabel,this._tone=e.tone??ge,this._open=!0,await this.updateComplete;let t=this.querySelector(`.confirm-dialog`);t&&(t.open||t.showModal(),t.querySelector(`.confirm-dialog-panel`)?.focus())}#r(e){let t=this.#t;if(!t)return;this.#t=null;let n=this.querySelector(`.confirm-dialog`);n?.open&&n.close(),this._open=!1,this._title=``,this._message=``,this._confirmLabel=``,this._cancelLabel=``,this._tone=ge,t.resolve(e),queueMicrotask(()=>void this.#n())}#i=e=>{e.preventDefault(),this.#r(!1)};#a=e=>{e.target===e.currentTarget&&this.#r(!1)};#o=e=>{if(e.key===`Escape`){e.preventDefault(),e.stopPropagation(),this.#r(!1);return}let t=e.target,n=t instanceof HTMLButtonElement||t instanceof HTMLInputElement||t instanceof HTMLSelectElement||t instanceof HTMLTextAreaElement||t instanceof HTMLAnchorElement;e.key===`Enter`&&!e.defaultPrevented&&!e.metaKey&&!e.ctrlKey&&!e.altKey&&!e.shiftKey&&!n&&(e.preventDefault(),this.#r(!0))};#s(){if(this._title)return{title:this._title,message:this._message};let e=this._message.match(/^(.+?[??])\s+(.+)$/u);return e?{title:e[1],message:e[2]}:{title:this._message,message:``}}render(){if(!this._open)return g;let{title:e,message:t}=this.#s(),n=this._tone===`danger`?`btn-destructive`:`btn`;return u`
1
+ import{a as e,n as t,o as n,t as r}from"./chunks/url-pLre2DM_.js";import{_ as i,a,c as o,d as s,f as c,g as l,h as u,i as d,l as f,m as p,n as m,o as h,p as g,r as _,s as v,t as y,u as b}from"./client-B1XjvRqE.js";var x={maxShortSide:1920,maxLongSide:8192,quality:.85,mimeType:`image/webp`};function S(e){return new Promise((t,n)=>{let r=new Image;r.onload=()=>{URL.revokeObjectURL(r.src),t(r)},r.onerror=()=>n(Error(`Failed to load image`)),r.src=URL.createObjectURL(e)})}function C(e,t,n){if(Math.max(e,t)>n.maxLongSide)return{passthrough:!0,width:e,height:t};let r=Math.min(e,t);if(r<=n.maxShortSide)return{passthrough:!1,width:e,height:t};let i=n.maxShortSide/r;return{passthrough:!1,width:Math.round(e*i),height:Math.round(t*i)}}async function ee(e,t,n){let r=await new Promise((r,i)=>{e.toBlob(e=>e?r(e):i(Error(`Failed to create blob`)),t,n)});return t!==`image/png`&&r.type===`image/png`?new Promise((t,r)=>{e.toBlob(e=>e?t(e):r(Error(`Failed to create blob`)),`image/jpeg`,n)}):r}async function te(e,t={}){let n={...x,...t},r=await S(e),i=C(r.width,r.height,n);if(i.passthrough)return{blob:e,width:i.width,height:i.height,processed:!1};let a=document.createElement(`canvas`);a.width=i.width,a.height=i.height;let o=a.getContext(`2d`);if(!o)throw Error(`Failed to get canvas context`);return o.drawImage(r,0,0,i.width,i.height),{blob:await ee(a,n.mimeType,n.quality),width:i.width,height:i.height,processed:!0}}async function ne(e,t={}){let n=await te(e,t);if(!n.processed)return{file:e,width:n.width,height:n.height};let r={"image/webp":`webp`,"image/jpeg":`jpg`,"image/png":`png`}[n.blob.type]??`png`,i=`${e.name.replace(/\.[^.]+$/,``)}.${r}`;return{file:new File([n.blob],i,{type:n.blob.type}),width:n.width,height:n.height}}var re={process:te,processToFile:ne},ie={ICO_16:16,ICO_32:32,APPLE_TOUCH:512};function ae(e){let t=e.length*16,n=6+t,r=new ArrayBuffer(6+t),i=new DataView(r);i.setUint16(0,0,!0),i.setUint16(2,1,!0),i.setUint16(4,e.length,!0);let a=[];for(let t=0;t<e.length;t++){let r=e[t],o=6+t*16;i.setUint8(o+0,r.size<256?r.size:0),i.setUint8(o+1,r.size<256?r.size:0),i.setUint8(o+2,0),i.setUint8(o+3,0),i.setUint16(o+4,1,!0),i.setUint16(o+6,32,!0),i.setUint32(o+8,r.png.byteLength,!0),i.setUint32(o+12,n,!0),n+=r.png.byteLength,a.push(r.png)}return new Blob([r,...a],{type:`image/x-icon`})}function oe(e){return typeof e==`object`&&!!e}function w(e,t){if(!oe(e))return;let n=e[t];return typeof n==`string`?n:void 0}function se(e,t){if(!oe(e))return;let n=e[t];return typeof n==`boolean`?n:void 0}function ce(e,t){if(!oe(e))return;let n=e[t];return typeof n==`number`?n:void 0}async function le(e){let t=await e.json();return oe(t)?t:{}}var ue=180;function de(e){return new Promise((t,n)=>{let r=new Image;r.onload=()=>{URL.revokeObjectURL(r.src),t(r)},r.onerror=()=>n(Error(`Failed to load image`)),r.src=URL.createObjectURL(e)})}function fe(e,t){let n=document.createElement(`canvas`);n.width=t,n.height=t;let r=n.getContext(`2d`);if(!r)throw Error(`Failed to get canvas context`);r.imageSmoothingEnabled=!0,r.imageSmoothingQuality=`high`;let i=Math.max(t/e.width,t/e.height),a=t/i,o=t/i,s=(e.width-a)/2,c=(e.height-o)/2;return r.drawImage(e,s,c,a,o,0,0,t,t),new Promise((e,t)=>{n.toBlob(n=>{n?e(n):t(Error(`Failed to create PNG blob`))},`image/png`)})}function pe(e,t){return e.type===`image/png`&&t.width===t.height&&t.width>=ue&&t.height>=ue}async function me(e,t){let n=e.closest(`form`)?.querySelector(`label`),r=n?.textContent??``;try{n&&(n.textContent=e.dataset.textProcessing||`Processing...`);let r=await de(t),a=t,o=t.name;if(t.type!==`image/svg+xml`){let e=await fe(r,512);a=new File([e],t.name.replace(/\.[^.]+$/,`.png`),{type:`image/png`}),o=a.name}let s=pe(t,r)?Promise.resolve(t):fe(r,ie.APPLE_TOUCH),[c,l,u]=await Promise.all([fe(r,16),fe(r,32),s]),[d,f]=await Promise.all([c.arrayBuffer(),l.arrayBuffer()]),p=ae([{size:16,png:d},{size:32,png:f}]);n&&(n.textContent=e.dataset.textUploading||`Uploading...`);let m=new FormData;m.append(`file`,a,o),m.append(`favicon`,p,`favicon.ico`),m.append(`appleTouch`,u,`apple-touch-icon.png`);let h=await fetch(i(`/settings/avatar`),{method:`POST`,headers:{Accept:`application/json`},body:m});if(!h.ok){let t=e.dataset.textError||`Upload failed. Please try again.`;try{let e=await le(h);t=w(e,`error`)??w(e,`message`)??t}catch{}throw Error(t)}let g=await le(h),_=w(g,`status`),v=w(g,`url`);if(_===`redirect`&&v){window.location.href=v;return}window.location.href=i(`/settings/avatar?saved`)}catch(t){n&&(n.textContent=r),d(t instanceof Error&&t.message?t.message:e.dataset.textError||`Upload failed. Please try again.`,`error`)}e.value=``}function he(){document.addEventListener(`change`,e=>{let t=e.target.closest(`[data-avatar-upload]`);t?.files?.[0]&&(e.stopPropagation(),me(t,t.files[0]))})}he();var ge=`default`,_e=class extends c{static properties={_open:{state:!0},_title:{state:!0},_message:{state:!0},_confirmLabel:{state:!0},_cancelLabel:{state:!0},_tone:{state:!0}};#e=[];#t=null;createRenderRoot(){return this.innerHTML=``,this}constructor(){super(),this._open=!1,this._title=``,this._message=``,this._confirmLabel=``,this._cancelLabel=``,this._tone=ge}disconnectedCallback(){super.disconnectedCallback();let e=this.#t;this.#t=null,e?.resolve(!1);for(let e of this.#e.splice(0))e.resolve(!1)}async confirm(e){return await new Promise(t=>{this.#e.push({...e,tone:e.tone??ge,resolve:t}),this.#t||this.#n()})}async#n(){if(this.#t||this.#e.length===0)return;let e=this.#e.shift();if(!e)return;this.#t=e,this._title=e.title??``,this._message=e.message,this._confirmLabel=e.confirmLabel,this._cancelLabel=e.cancelLabel,this._tone=e.tone??ge,this._open=!0,await this.updateComplete;let t=this.querySelector(`.confirm-dialog`);t&&(t.open||t.showModal(),t.querySelector(`.confirm-dialog-panel`)?.focus())}#r(e){let t=this.#t;if(!t)return;this.#t=null;let n=this.querySelector(`.confirm-dialog`);n?.open&&n.close(),this._open=!1,this._title=``,this._message=``,this._confirmLabel=``,this._cancelLabel=``,this._tone=ge,t.resolve(e),queueMicrotask(()=>void this.#n())}#i=e=>{e.preventDefault(),this.#r(!1)};#a=e=>{e.target===e.currentTarget&&this.#r(!1)};#o=e=>{if(e.key===`Escape`){e.preventDefault(),e.stopPropagation(),this.#r(!1);return}let t=e.target,n=t instanceof HTMLButtonElement||t instanceof HTMLInputElement||t instanceof HTMLSelectElement||t instanceof HTMLTextAreaElement||t instanceof HTMLAnchorElement;e.key===`Enter`&&!e.defaultPrevented&&!e.metaKey&&!e.ctrlKey&&!e.altKey&&!e.shiftKey&&!n&&(e.preventDefault(),this.#r(!0))};#s(){if(this._title)return{title:this._title,message:this._message};let e=this._message.match(/^(.+?[??])\s+(.+)$/u);return e?{title:e[1],message:e[2]}:{title:this._message,message:``}}render(){if(!this._open)return g;let{title:e,message:t}=this.#s(),n=this._tone===`danger`?`btn-destructive`:`btn`;return u`
2
2
  <dialog
3
3
  class="dialog confirm-dialog"
4
4
  @cancel=${this.#i}
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { v as url_exports } from "./url-XF0GbKGO.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-CyysIxj_.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-B-wKZB8f.js";
3
3
  import { T as time_exports, a as markdown_exports } from "./export-CzuQyg5h.js";
4
4
  import "./env-CoSe-1y4.js";
5
5
  import "./github-sync-Dbrb1DS5.js";
package/dist/node.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import "./url-XF0GbKGO.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-CyysIxj_.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-B-wKZB8f.js";
3
3
  import { t as createExportService } from "./export-CzuQyg5h.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-CoSe-1y4.js";
5
5
  import "./github-sync-Dbrb1DS5.js";
@@ -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-BX2XKxq0.js")).createApp()
532
+ app: async () => app ?? (await import("./app-qwMcaTML.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.3",
3
+ "version": "0.6.4",
4
4
  "description": "A modern, open-source microblogging platform built on Cloudflare Workers",
5
5
  "type": "module",
6
6
  "exports": {
package/src/app.tsx CHANGED
@@ -69,6 +69,7 @@ import { manifestRoutes } from "./routes/feed/manifest.js";
69
69
  // Middleware
70
70
  import { requireAuth } from "./middleware/auth.js";
71
71
  import { attachSession } from "./middleware/session.js";
72
+ import { defaultCacheControl } from "./middleware/cache-control.js";
72
73
  import { requireOnboarding } from "./middleware/onboarding.js";
73
74
  import { errorHandler } from "./middleware/error-handler.js";
74
75
  import { withConfig } from "./middleware/config.js";
@@ -320,6 +321,12 @@ export function createApp(): App {
320
321
  // downstream handlers don't each call auth.api.getSession themselves.
321
322
  app.use("*", attachSession());
322
323
 
324
+ // Default every response without an explicit Cache-Control to
325
+ // `private, no-store`. Jant pages are auth-variant, so a shared/CDN cache
326
+ // must never store them; routes serving genuinely public resources (media,
327
+ // feeds, sitemaps, favicons) set their own Cache-Control and are untouched.
328
+ app.use("*", defaultCacheControl());
329
+
323
330
  app.use("*", async (c, next) => {
324
331
  const redirectUrl = await getHostedCanonicalRedirect({
325
332
  currentSite: c.var.currentSite,
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { Hono } from "hono";
3
+ import type { Bindings } from "../../types.js";
4
+ import type { AppVariables } from "../../types/app-context.js";
5
+ import { defaultCacheControl } from "../cache-control.js";
6
+
7
+ type Env = { Bindings: Bindings; Variables: AppVariables };
8
+
9
+ function buildApp(): Hono<Env> {
10
+ const app = new Hono<Env>();
11
+ app.use("*", defaultCacheControl());
12
+
13
+ // Un-annotated dynamic page — the common case.
14
+ app.get("/", (c) => c.html("<h1>home</h1>"));
15
+
16
+ // Route that declares its own public cache policy (e.g. a feed).
17
+ app.get("/feed", (c) =>
18
+ c.body("<feed/>", 200, { "Cache-Control": "public, max-age=180" }),
19
+ );
20
+
21
+ // Route that already opts out explicitly.
22
+ app.get("/api/thing", (c) =>
23
+ c.json({ ok: true }, 200, { "Cache-Control": "no-store" }),
24
+ );
25
+
26
+ return app;
27
+ }
28
+
29
+ describe("defaultCacheControl", () => {
30
+ it("defaults un-annotated responses to private, no-store", async () => {
31
+ const response = await buildApp().request("/");
32
+ expect(response.headers.get("Cache-Control")).toBe("private, no-store");
33
+ });
34
+
35
+ it("leaves an explicit public cache policy untouched", async () => {
36
+ const response = await buildApp().request("/feed");
37
+ expect(response.headers.get("Cache-Control")).toBe("public, max-age=180");
38
+ });
39
+
40
+ it("leaves an explicit opt-out untouched", async () => {
41
+ const response = await buildApp().request("/api/thing");
42
+ expect(response.headers.get("Cache-Control")).toBe("no-store");
43
+ });
44
+
45
+ it("defaults not-found responses too", async () => {
46
+ const response = await buildApp().request("/missing");
47
+ expect(response.status).toBe(404);
48
+ expect(response.headers.get("Cache-Control")).toBe("private, no-store");
49
+ });
50
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Cache-Control Middleware
3
+ *
4
+ * Sets a safe default `Cache-Control` on responses that don't declare one.
5
+ *
6
+ * Almost every Jant page is auth-variant: the same URL renders differently
7
+ * for the signed-in author (nav, the "more" menu, edit affordances) than for
8
+ * an anonymous visitor. A shared/CDN cache keyed only by URL must therefore
9
+ * never store these pages — otherwise it serves a stale or wrong-audience
10
+ * snapshot, which both breaks the UI ("you still look signed out", "your edit
11
+ * didn't take effect") and can leak the authenticated dashboard to the public.
12
+ *
13
+ * Jant is self-hosted software that runs behind whatever reverse proxy or CDN
14
+ * the operator chooses, so it cannot rely on infrastructure config to get
15
+ * this right — it must declare its own cache policy. The critical mistake is
16
+ * emitting `Cache-Control: public`: that word is an explicit invitation for
17
+ * any shared cache to store the response.
18
+ *
19
+ * Routes that serve genuinely public, auth-invariant resources (media, feeds,
20
+ * sitemaps, favicons, manifests, static assets) set their own `Cache-Control`
21
+ * explicitly; this middleware leaves those untouched and only fills in the
22
+ * default for the un-annotated dynamic responses.
23
+ */
24
+
25
+ import type { MiddlewareHandler } from "hono";
26
+ import type { Bindings } from "../types.js";
27
+ import type { AppVariables } from "../types/app-context.js";
28
+
29
+ type Env = { Bindings: Bindings; Variables: AppVariables };
30
+
31
+ /**
32
+ * Default cache directive for dynamic, potentially auth-variant responses.
33
+ * `private` forbids shared/CDN caches from storing the response; `no-store`
34
+ * prevents any cache (including the browser) from keeping a copy.
35
+ */
36
+ const DEFAULT_CACHE_CONTROL = "private, no-store";
37
+
38
+ /**
39
+ * Middleware that defaults a missing `Cache-Control` header to
40
+ * `private, no-store`.
41
+ *
42
+ * Runs after the route handler: if the handler (or an inner middleware)
43
+ * already set `Cache-Control`, that explicit value wins. Only responses that
44
+ * declare nothing receive the safe default.
45
+ *
46
+ * @returns Hono middleware enforcing the default cache policy.
47
+ *
48
+ * @example
49
+ * ```ts
50
+ * app.use("*", defaultCacheControl());
51
+ * ```
52
+ */
53
+ export function defaultCacheControl(): MiddlewareHandler<Env> {
54
+ return async (c, next) => {
55
+ await next();
56
+ if (!c.res.headers.has("Cache-Control")) {
57
+ c.res.headers.set("Cache-Control", DEFAULT_CACHE_CONTROL);
58
+ }
59
+ };
60
+ }
@@ -11,7 +11,10 @@ import { useLingui } from "../../i18n/context.js";
11
11
  import type { ThreadPreviewProps } from "../../types.js";
12
12
  import { TimelineItem } from "./TimelineItem.js";
13
13
  import { TimelineItemFromPost } from "./TimelineItem.js";
14
- import { getThreadPreviewState } from "./thread-preview-state.js";
14
+ import {
15
+ getThreadPreviewState,
16
+ threadContextAssumesOverflow,
17
+ } from "./thread-preview-state.js";
15
18
 
16
19
  const ROOT_CONTEXT_DISPLAY = {
17
20
  footer: {
@@ -42,6 +45,10 @@ export const ThreadPreview: FC<ThreadPreviewProps> = ({
42
45
  latestReply,
43
46
  totalReplyCount,
44
47
  });
48
+ const assumeOverflow = threadContextAssumesOverflow({
49
+ rootPost,
50
+ totalReplyCount,
51
+ });
45
52
  const hiddenPostsLabel = i18n._(
46
53
  msg({
47
54
  message: "{count, plural, one {# more post} other {# more posts}}",
@@ -75,14 +82,14 @@ export const ThreadPreview: FC<ThreadPreviewProps> = ({
75
82
  : undefined;
76
83
  const gapHref = renderedSecondReply?.permalink ?? latestReply.permalink;
77
84
 
78
- // Always render the collapsible shell for thread previews. Structural
79
- // signals (how many ancestor posts exist) aren't a reliable proxy for
80
- // visual height — a single long root article will push the hero far
81
- // off-screen just as much as several short replies would. The server
82
- // assumes overflow (the common case) and renders the cap + fade + toggle
83
- // immediately; client-side measurement removes the affordance when the
84
- // content actually fits inside the cap, so users never see a no-op
85
- // "Show more" button.
85
+ // Always render the collapsible shell + toggle: the cap and fade are a
86
+ // constant "this is context" affordance. The toggle's *initial* visibility
87
+ // is a server-side guess (threadContextAssumesOverflow) the real rendered
88
+ // height is unknown until the client measures. Guessing matters because a
89
+ // 2-post thread's shell holds only the root, and a short root genuinely
90
+ // fits the cap; rendering the toggle visible anyway makes it flash in then
91
+ // out on load. Client-side measurement (thread-context.ts) re-measures and
92
+ // corrects, so the guess only ever affects that first paint.
86
93
 
87
94
  const rootItem = (
88
95
  <div class="thread-item thread-item-context">
@@ -138,6 +145,7 @@ export const ThreadPreview: FC<ThreadPreviewProps> = ({
138
145
  data-label-more={showMoreLabel}
139
146
  data-label-less={showLessLabel}
140
147
  aria-expanded="false"
148
+ hidden={!assumeOverflow}
141
149
  >
142
150
  <span class="thread-context-toggle-label">{showMoreLabel}</span>
143
151
  <svg
@@ -7,7 +7,10 @@ import { createI18n } from "../../../i18n/i18n.js";
7
7
  import type { PostView, TimelineItemView } from "../../../types.js";
8
8
  import { CuratedThreadPreview } from "../CuratedThreadPreview.js";
9
9
  import { ThreadPreview } from "../ThreadPreview.js";
10
- import { getThreadPreviewState } from "../thread-preview-state.js";
10
+ import {
11
+ getThreadPreviewState,
12
+ threadContextAssumesOverflow,
13
+ } from "../thread-preview-state.js";
11
14
 
12
15
  function createPostView(overrides: Partial<PostView> = {}): PostView {
13
16
  return {
@@ -48,6 +51,13 @@ function renderWithI18n(
48
51
  return renderToString(render());
49
52
  }
50
53
 
54
+ /** The opening tag of the show-more toggle button, for attribute assertions. */
55
+ function toggleTag(html: string): string {
56
+ return (
57
+ html.match(/<button[^>]*\bdata-thread-context-toggle\b[^>]*>/)?.[0] ?? ""
58
+ );
59
+ }
60
+
51
61
  describe("getThreadPreviewState", () => {
52
62
  it("has no hidden posts for a 2-post thread", () => {
53
63
  const latestReply = createPostView({
@@ -236,13 +246,17 @@ describe("getThreadPreviewState", () => {
236
246
  expect(html).toMatch(/data-label-less="[^"]+"/);
237
247
  });
238
248
 
239
- it("still wraps a root-only preview in the shell so the cap applies to long single posts", () => {
240
- // root + hero no second/penultimate/gap. The server can't know the
241
- // root's rendered height, so it always renders the shell + toggle and
242
- // lets client-side measurement strip them when the root actually fits.
249
+ it("hides the show-more toggle on first paint for a short lone root that fits the cap", () => {
250
+ // 2-post thread: the shell holds only the root. A short root genuinely
251
+ // fits the height cap, so the toggle is rendered hidden to avoid flashing
252
+ // it in then out. Client-side measurement (thread-context.ts) re-reveals
253
+ // it if the rendered height actually overflows.
243
254
  const html = renderWithI18n(() =>
244
255
  ThreadPreview({
245
- rootPost: createPostView({ bodyHtml: "<p>Root</p>" }),
256
+ rootPost: createPostView({
257
+ bodyHtml: "<p>Root</p>",
258
+ summary: "Root",
259
+ }),
246
260
  latestReply: createPostView({
247
261
  id: "post-2",
248
262
  permalink: "/post-2",
@@ -257,10 +271,35 @@ describe("getThreadPreviewState", () => {
257
271
  expect(html).toContain("thread-context-shell");
258
272
  expect(html).toContain("data-collapsed");
259
273
  expect(html).toContain("data-thread-context-toggle");
274
+ expect(toggleTag(html)).toContain("hidden");
260
275
  expect(html).toContain("<p>Root</p>");
261
276
  expect(html).toContain("<p>Latest</p>");
262
277
  });
263
278
 
279
+ it("shows the show-more toggle on first paint for a long lone root", () => {
280
+ // 2-post thread with a long root — the shell almost certainly overflows
281
+ // the cap, so the toggle is rendered visible immediately (no flash).
282
+ const html = renderWithI18n(() =>
283
+ ThreadPreview({
284
+ rootPost: createPostView({
285
+ bodyHtml: "<p>Root</p>",
286
+ summary: "word ".repeat(60),
287
+ }),
288
+ latestReply: createPostView({
289
+ id: "post-2",
290
+ permalink: "/post-2",
291
+ slug: "post-2",
292
+ bodyHtml: "<p>Latest</p>",
293
+ isLastInThread: true,
294
+ }),
295
+ totalReplyCount: 1,
296
+ }),
297
+ );
298
+
299
+ expect(html).toContain("data-thread-context-toggle");
300
+ expect(toggleTag(html)).not.toContain("hidden");
301
+ });
302
+
264
303
  it("renders the collapsible shell even when just one extra item precedes the latest reply", () => {
265
304
  // root + secondReply + hero — one extra context item. The server can't
266
305
  // measure actual height, so it renders the shell assuming overflow (the
@@ -292,6 +331,9 @@ describe("getThreadPreviewState", () => {
292
331
  expect(html).toContain("<p>Root</p>");
293
332
  expect(html).toContain("<p>Second</p>");
294
333
  expect(html).toContain("<p>Latest</p>");
334
+ // 3-post thread (totalReplyCount 2): the shell stacks 2 cards, so the
335
+ // toggle is rendered visible on first paint.
336
+ expect(toggleTag(html)).not.toContain("hidden");
295
337
  });
296
338
 
297
339
  it("points the hidden-posts gap link to the second reply so the detail page opens just above the hidden range", () => {
@@ -352,6 +394,22 @@ describe("getThreadPreviewState", () => {
352
394
  );
353
395
  });
354
396
 
397
+ it("renders curated thread previews without a collapsible context shell", () => {
398
+ // Curated previews render each segment in flow — no shell, no toggle —
399
+ // so the overflow heuristic never applies to them.
400
+ const post = createPostView({ summary: "Curated note" });
401
+ const html = renderWithI18n(() =>
402
+ CuratedThreadPreview({
403
+ curatedThread: {
404
+ rootPost: post,
405
+ segments: [{ post, hiddenBeforeCount: 0, highlighted: true }],
406
+ },
407
+ }),
408
+ );
409
+
410
+ expect(html).not.toContain("data-thread-context-toggle");
411
+ });
412
+
355
413
  it("renders article summaries in curated thread previews", () => {
356
414
  const articlePost = createPostView({
357
415
  title: "Curated article",
@@ -381,3 +439,70 @@ describe("getThreadPreviewState", () => {
381
439
  expect(html).not.toContain('id="continue"');
382
440
  });
383
441
  });
442
+
443
+ describe("threadContextAssumesOverflow", () => {
444
+ it("assumes overflow for 3+ post threads (the shell stacks 2+ cards)", () => {
445
+ expect(
446
+ threadContextAssumesOverflow({
447
+ rootPost: createPostView({ summary: "short" }),
448
+ totalReplyCount: 2,
449
+ }),
450
+ ).toBe(true);
451
+ });
452
+
453
+ it("assumes a short lone root fits the cap (2-post thread)", () => {
454
+ expect(
455
+ threadContextAssumesOverflow({
456
+ rootPost: createPostView({
457
+ summary: "Took the long way home because the light was good.",
458
+ }),
459
+ totalReplyCount: 1,
460
+ }),
461
+ ).toBe(false);
462
+ });
463
+
464
+ it("assumes overflow for a long lone root", () => {
465
+ expect(
466
+ threadContextAssumesOverflow({
467
+ rootPost: createPostView({ summary: "x".repeat(200) }),
468
+ totalReplyCount: 1,
469
+ }),
470
+ ).toBe(true);
471
+ });
472
+
473
+ it("assumes overflow for a short lone root that carries media", () => {
474
+ expect(
475
+ threadContextAssumesOverflow({
476
+ rootPost: createPostView({
477
+ summary: "short",
478
+ media: [{} as PostView["media"][number]],
479
+ }),
480
+ totalReplyCount: 1,
481
+ }),
482
+ ).toBe(true);
483
+ });
484
+
485
+ it("assumes overflow for a short lone root with a link preview image", () => {
486
+ expect(
487
+ threadContextAssumesOverflow({
488
+ rootPost: createPostView({
489
+ format: "link",
490
+ summary: "short",
491
+ previewImageUrl: "https://example.com/cover.jpg",
492
+ }),
493
+ totalReplyCount: 1,
494
+ }),
495
+ ).toBe(true);
496
+ });
497
+
498
+ it("counts plain-text code points, not UTF-16 units, against the limit", () => {
499
+ // 130 CJK code points — comfortably over the 120 limit even though each
500
+ // is a single BMP character. Confirms the threshold reads real length.
501
+ expect(
502
+ threadContextAssumesOverflow({
503
+ rootPost: createPostView({ summary: "字".repeat(130) }),
504
+ totalReplyCount: 1,
505
+ }),
506
+ ).toBe(true);
507
+ });
508
+ });
@@ -22,3 +22,46 @@ export function getThreadPreviewState({
22
22
  hiddenCount,
23
23
  };
24
24
  }
25
+
26
+ /**
27
+ * A lone root post shorter than this (plain-text code points) comfortably
28
+ * fits the thread-context height cap. Pairs with the
29
+ * `--site-thread-context-max-height` token (160px) in tokens.css — revisit
30
+ * both together if that cap changes.
31
+ */
32
+ const SHORT_ROOT_CHAR_LIMIT = 120;
33
+
34
+ /**
35
+ * First-paint guess for whether the thread-context shell will overflow its
36
+ * height cap, used to pick the initial "Show more" toggle visibility.
37
+ *
38
+ * The client (thread-context.ts) always re-measures and corrects this, so a
39
+ * wrong guess costs at most a one-time flash on load — never a wrong final
40
+ * state. Kept deliberately coarse:
41
+ *
42
+ * - 3+ post threads stack 2+ cards in the shell — effectively always overflow.
43
+ * - A lone root carrying media is tall regardless of its text length.
44
+ * - Otherwise fall back to a plain-text length threshold. Only clearly short
45
+ * roots flip to "fits"; anything at or above the limit keeps the historical
46
+ * "assume overflow" default, so this can only remove flashes, never add them.
47
+ *
48
+ * @param rootPost - the thread's root post (the only post in a 2-post thread's
49
+ * shell)
50
+ * @param totalReplyCount - replies in the thread; `>= 2` means 3+ posts total
51
+ * @returns whether to render the toggle visible on first paint
52
+ */
53
+ export function threadContextAssumesOverflow({
54
+ rootPost,
55
+ totalReplyCount,
56
+ }: {
57
+ rootPost: PostView;
58
+ totalReplyCount: number;
59
+ }): boolean {
60
+ if (totalReplyCount >= 2) return true;
61
+ if (rootPost.media.length > 0 || Boolean(rootPost.previewImageUrl)) {
62
+ return true;
63
+ }
64
+ // `summary` is format-normalized at the viewmodel layer: quote text for
65
+ // quotes, body text for notes, the URL for bare links.
66
+ return [...(rootPost.summary ?? "")].length >= SHORT_ROOT_CHAR_LIMIT;
67
+ }