{
  "about": "An entry of UpFork's ledger. Check it by hand as https://upfork.dev/docs/ledger says, or at https://upfork.dev/verify/E-163",
  "entry": {
    "seq": 163,
    "kind": "article-version",
    "record": "{\"ai\":{\"apps\":[\"Claude\"],\"models\":[\"Claude Opus 5.5\"]},\"article\":\"wisp-an-ai-chat-template-for-next-js-shiki-in-a-worker-an-indexeddb-store-an\",\"author\":{\"account\":\"a_9d470b566acab5f65bdc\",\"handle\":\"ramin-valadan\"},\"body\":\"## What it is\\n\\nWisp is a finished AI chat app as a Next.js and shadcn/ui template: a home with an animated orb, one composer for every mode, streaming answers with reasoning, deep research with citations, image generation, code blocks that open in a Canvas, projects and branches, a command palette, settings, an Upgrade page and a Code page. 13 themes in light and dark, 20 fonts, and six languages including Persian and Arabic, right to left. The AI is a local mock behind one async generator, `respond()` in `src/lib/ai/mock.ts`, which you swap for your own API. Try it at [wisp.upfork.dev](https://wisp.upfork.dev).\\n\\nThis article is for anyone, person or AI, building a chat interface that has to stay smooth with long conversations: code highlighting that never blocks scrolling, a store that survives reloads, and eight traps we hit, each with its fix.\\n\\n## Long chats: a virtual list and highlighting off the main thread\\n\\nA long conversation is a virtual list: only the rows near the screen are in the DOM. Our test chat has 520 messages, and scrolling it on the production build never had more than 21 rows mounted. It still froze.\\n\\n:::lesson{title=\\\"A virtualised chat re-highlighted every code block that scrolled back into view: cache it and run Shiki in a worker\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\"}\\n**I assumed:** with a virtual list, a long chat would scroll as lightly as a short one.\\n**What happened:** scrolling the 520-message chat averaged 51 fps but froze two or three times for about 450 ms. A virtual list mounts a row again each time it scrolls back into view, and every code block was then highlighted by Shiki from scratch, on the main thread. A cache alone didn't remove the freezes, and warming the highlighter up ahead of time made it worse, because that work competed with the scroll itself.\\n**The fix:** highlighting runs in a Web Worker, so scrolling never waits for Shiki, and the page keeps the last 300 results, so a block that comes back paints in colour at once. The worst freeze fell from about 430 ms to about 260 ms, and the profile showed the rest came from elsewhere: our own test, and the blur below.\\n**Evidence:** src/lib/highlight.ts; the build notes' scroll measurements.\\n:::\\n\\n```ts\\n// Long chats are virtualised, so a block mounts again every time it scrolls back into view:\\n// keep recent results so it never pays for highlighting twice.\\nconst cache = new Map<string, string>()\\nconst CACHE_MAX = 300\\n```\\n\\n```ts\\nfunction getWorker(): Worker | null {\\n  if (worker !== undefined) return worker\\n  try {\\n    worker = new Worker(new URL(\\\"./highlight.worker.ts\\\", import.meta.url), { type: \\\"module\\\" })\\n    worker.onmessage = (e: MessageEvent<{ id: number; html: string | null }>) => {\\n      waiting.get(e.data.id)?.(e.data.html)\\n      waiting.delete(e.data.id)\\n    }\\n    worker.onerror = () => {\\n      // The worker couldn't start (or died): finish what it held on the main thread from now on.\\n      worker = null\\n      for (const done of waiting.values()) done(null)\\n```\\n\\nMuch of the remaining profile was our scroll test's own overhead: it forced a layout on every frame. Real mouse-wheel scrolling gave the honest numbers.\\n\\n:::lesson{title=\\\"A backdrop blur over content that scrolls is repainted every frame: switching it off took scrolling from 46 to 56 fps\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\"}\\n**I assumed:** the frosted sticky header and the other glass surfaces along the scroll path were cheap.\\n**What happened:** a blur over moving content has to be computed again on every frame. With the suspects switched off one by one, turning off `backdrop-filter` alone took real wheel scrolling from 46 to 56 fps.\\n**The fix:** the surfaces on the scroll path are solid. The sticky header turns solid, with an edge, once the page scrolls.\\n**Evidence:** the build notes' A/B; the comment on `PageFrame` in src/components/shell/page-frame.tsx.\\n:::\\n\\n```ts\\n/**\\n * A page inside the main card: a sticky header (solid and edged once the page scrolls; a blur over moving text\\n * would cost frames) over the page's own scroll area. `scrollRef` lets the conversation drive its virtual list\\n * from the same element.\\n */\\n```\\n\\n:::lesson{title=\\\"A code block's plain first paint without Shiki's line structure collapsed, then jumped: give it the same shape\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\"}\\n**I assumed:** showing the code as plain text until the highlighted HTML arrived was a harmless placeholder.\\n**What happened:** the plain version didn't have the structure Shiki produces, so the block first collapsed to a single line and then jumped to its full height when the colours arrived, moving what was below it.\\n**The fix:** the placeholder has Shiki's own shape (a `pre.shiki` with one `span.line` per line, the newlines between the line boxes, not inside them), so it has the same height and the same line positions, and swapping in the highlighted HTML moves nothing.\\n**Evidence:** commit 3e8b277; src/components/chat/code-block.tsx.\\n:::\\n\\n```tsx\\n{current ? (\\n  // Shiki escapes the code; the markup is its own spans.\\n  <div dangerouslySetInnerHTML={{ __html: current }} />\\n) : (\\n  // Same shape as Shiki's output (the newline between the line boxes, not inside them), so swapping in the\\n  // highlighted HTML doesn't move anything.\\n  <pre className=\\\"shiki\\\">\\n    <code>\\n      {shown.split(\\\"\\\\n\\\").map((l, i) => (\\n        <React.Fragment key={i}>\\n          {i > 0 && \\\"\\\\n\\\"}\\n          <span className=\\\"line\\\">{l}</span>\\n        </React.Fragment>\\n      ))}\\n    </code>\\n  </pre>\\n)}\\n```\\n\\n## A store that survives reloads\\n\\nProjects, chats, messages, Canvas documents and settings live in one zustand store persisted to IndexedDB. A streaming answer updates the store many times a second, so writes are coalesced.\\n\\n:::lesson{title=\\\"A zustand store persisted to IndexedDB crashed next start: the module also runs on the server, with no indexedDB\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\" packages=\\\"zustand@5\\\"}\\n**I assumed:** the store only ran in the browser, since every component that uses it is a client component.\\n**What happened:** the store's module is also loaded while the server renders a page, and there `indexedDB` doesn't exist. `next start` threw \\\"indexedDB is not defined\\\".\\n**The fix:** the storage checks for IndexedDB on every call and does nothing without it, so the store simply doesn't persist on the server.\\n**Evidence:** commit 1f4324a; src/lib/chat/store.ts.\\n:::\\n\\n:::lesson{title=\\\"Coalescing IndexedDB writes for 600 ms lost the last change on a reload: flush the pending write on pagehide\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\"}\\n**I assumed:** writing the store at most once every 600 ms was safe, since the next write would always come.\\n**What happened:** a reload or a closed tab inside that window never got its next write, so the last change was lost.\\n**The fix:** the pending write is flushed on `pagehide` as well as after 600 ms.\\n**Evidence:** commit 3e8b277; src/lib/chat/store.ts.\\n:::\\n\\n```ts\\n// The store module is also loaded while the server renders a page, where there is no IndexedDB: it only persists in the browser.\\nconst hasIdb = () => typeof indexedDB !== \\\"undefined\\\"\\n\\nconst flush = () => {\\n  if (pending === undefined) return\\n  clearTimeout(pending)\\n  pending = undefined\\n  void idbSet(lastName, lastValue).catch(() => {})\\n}\\n// Reloading or closing the tab inside the coalescing window would lose the last change: write it now.\\nif (typeof window !== \\\"undefined\\\") window.addEventListener(\\\"pagehide\\\", flush)\\n\\n/** IndexedDB, with writes coalesced: streaming updates the store many times a second. */\\nconst idbStorage: StateStorage = {\\n  getItem: async (name) => (hasIdb() ? ((await get<string>(name)) ?? null) : null),\\n  setItem: (name, value) => {\\n    if (!hasIdb()) return\\n    lastName = name\\n    lastValue = value\\n    clearTimeout(pending)\\n    pending = setTimeout(flush, 600)\\n  },\\n  removeItem: (name) => (hasIdb() ? del(name) : undefined),\\n}\\n```\\n\\n## Scrollbars that stay out of the way\\n\\nOn Windows, every scroll area showed a wide native scrollbar. Wisp now draws a faint 4 px rounded thumb everywhere, and getting there took three lessons.\\n\\n```css\\n/*\\n * Scrollbars: no track and no arrows, just a faint 4 px rounded thumb in the text colour that brightens under the\\n * pointer. (Revealing it only while the area is hovered doesn't work: Chromium doesn't repaint a scrollbar when its\\n * box's :hover changes.) Chromium and Safari read the ::-webkit-scrollbar rules; Firefox, which doesn't, gets its own\\n * thin scrollbar in the same colour. Setting scrollbar-width anywhere would make Chromium ignore these.\\n */\\n@supports not selector(::-webkit-scrollbar) {\\n  * {\\n    scrollbar-width: thin;\\n    scrollbar-color: var(--scrollbar-thumb) transparent;\\n  }\\n}\\n```\\n\\n:::lesson{title=\\\"Setting scrollbar-width makes Chromium ignore your ::-webkit-scrollbar rules: keep it for browsers without them\\\" kind=\\\"newer\\\" by=\\\"ai\\\"}\\n**I assumed:** the standard `scrollbar-width` and the `::-webkit-scrollbar` rules could sit side by side, each read by the browsers that know it.\\n**What happened:** Chromium now supports the standard properties too, and an element that sets `scrollbar-width` (or `scrollbar-color`) no longer uses its `::-webkit-scrollbar` styling, so the custom thumb is lost.\\n**The fix:** the standard properties are set only inside `@supports not selector(::-webkit-scrollbar)`, which today means Firefox. Chromium and Safari get the `::-webkit-scrollbar` rules alone.\\n**Evidence:** src/app/globals.css, the scrollbar block.\\n:::\\n\\n:::lesson{title=\\\"Chromium doesn't repaint a scrollbar when its box's :hover changes: a scrollbar shown only on hover won't appear\\\" kind=\\\"not-in-docs\\\" by=\\\"ai\\\"}\\n**I assumed:** like macOS, the thumb could stay hidden until the pointer was over the scroll area, with a `:hover` rule on the area.\\n**What happened:** Chromium doesn't repaint a scrollbar when its box's `:hover` state changes, so the thumb didn't show up when the pointer arrived.\\n**The fix:** the faint thumb is always there, and it brightens under the pointer through `::-webkit-scrollbar-thumb:hover` and `:active`, rules on the thumb itself.\\n**Evidence:** src/app/globals.css, the scrollbar block.\\n:::\\n\\n:::lesson{title=\\\"Playwright's headless Chromium hides scrollbars, so screenshots can't show them: launch it without --hide-scrollbars\\\" kind=\\\"tools\\\" by=\\\"ai\\\"}\\n**I assumed:** screenshots from the test browser would show the new scrollbars, the way a visitor sees them.\\n**What happened:** Playwright starts headless Chromium with `--hide-scrollbars`, so every screenshot came out without a scrollbar and couldn't show whether the styling worked.\\n**The fix:** to check scrollbars, launch with `ignoreDefaultArgs: [\\\"--hide-scrollbars\\\"]`. For the listing's tour video, where scrollbars would only distract, the flag stays on.\\n**Evidence:** the build notes; scripts/listing-video.mjs launches with `--hide-scrollbars` on purpose.\\n:::\\n\\n## Checked\\n\\nESLint and TypeScript clean, a script that checks the contrast of all 13 themes, and 18 end-to-end scenarios that pass on the production build and on the live demo.\\n\\n## Make it yours\\n\\nWisp comes as a release ZIP of the whole Next.js project. To connect a real model, replace `respond()` in `src/lib/ai/mock.ts`: it receives the request and an `AbortSignal` and yields the stream's events, and the rest of the app follows. The README covers running it, connecting a model and changing themes, fonts and languages.\",\"bodySha256\":\"cc7b67749113dc771b57b2f2b3e174baa0a73338c2389d55bbfae7461557e59a\",\"kind\":\"behind-the-build\",\"product\":\"nextjs-ai-chat-app-template\",\"productRevision\":null,\"publishedAt\":\"2026-10-26T12:07:21.950Z\",\"revision\":1,\"site\":\"https://upfork.dev\",\"summary\":\"How Wisp keeps a 520-message chat scrolling with Shiki in a Web Worker, persists to IndexedDB without crashing next start, and eight traps with blur, scrollbars and code blocks.\",\"title\":\"Wisp, an AI chat template for Next.js: Shiki in a worker, an IndexedDB store and eight traps\",\"type\":\"article-version\",\"url\":\"https://upfork.dev/articles/wisp-an-ai-chat-template-for-next-js-shiki-in-a-worker-an-indexeddb-store-an\"}",
    "recordHash": "c922719b4092a1d1cb39befc49acb14e44be1563de2d8930786ccb14c91bfcc5",
    "prevHash": "d573338d10615e53863065965a7a08b9fc88c4b635f20dfed617536b810a8bcb",
    "entryHash": "69a02dfa8b91bd4007dfc92ff385b296518d7197467a39ee3e275e520d706f4a",
    "keyId": "k1",
    "signature": "qj1hLUCfZ8mL7kVhMIbJWfbgb6XALgwUW0s8BBS8R9yu5FhmThtGuAidoGiVJRNqMC6CqpNjg+0Fx0VzSFF5Dg==",
    "recordedAt": "2026-10-26T12:08:08.780662+00:00",
    "publicKey": "VuHuHXAtO3XAqgnwvMenp0f/3vTV061hnAIizYvwh40=",
    "algorithm": "Ed25519"
  },
  "path": [],
  "checkpoint": null,
  "keys": "https://upfork.dev/ledger/keys.json"
}