diff --git a/.github/actions/node-setup/action.yaml b/.github/actions/node-setup/action.yaml
new file mode 100644
index 0000000..02797c9
--- /dev/null
+++ b/.github/actions/node-setup/action.yaml
@@ -0,0 +1,21 @@
+name: "node setup"
+description: "node setup"
+runs:
+ using: "composite"
+ steps:
+ - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # 4.0.1
+ with:
+ install_args: "node"
+ - run: |
+ corepack enable
+ corepack prepare --activate
+ shell: bash
+ - id: yarn-cache-dir
+ run: echo "YARN_CACHE_DIR=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
+ shell: bash
+ - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # 5.0.3
+ with:
+ path: ${{ steps.yarn-cache-dir.outputs.YARN_CACHE_DIR }}
+ key: ${{ runner.os }}-node-yarn-${{ hashFiles('**/yarn.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-node-yarn-
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
new file mode 100644
index 0000000..9e0139f
--- /dev/null
+++ b/.github/workflows/ci.yaml
@@ -0,0 +1,31 @@
+name: ci
+on:
+ pull_request:
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
+ cancel-in-progress: true
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ if: "!contains(github.event.head_commit.message, '[skip ci]')"
+ steps:
+ - name: Git checkout
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # 6.0.1
+ - name: Setup Node.js
+ uses: ./.github/actions/node-setup
+ - run: yarn install --immutable
+ - name: Run biome
+ run: yarn check
+ - name: Run typecheck
+ run: yarn typecheck
+ build:
+ runs-on: ubuntu-latest
+ if: "!contains(github.event.head_commit.message, '[skip ci]')"
+ steps:
+ - name: Git checkout
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # 6.0.1
+ - name: Setup Node.js
+ uses: ./.github/actions/node-setup
+ - run: yarn install --immutable
+ - name: Run build
+ run: yarn workspaces foreach -Apt run build
diff --git a/.gitignore b/.gitignore
index f03eaec..8de6bd0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,3 +15,13 @@ node_modules
#!.yarn/cache
.pnp.*
+
+# IDE
+.idea/
+.vscode/
+
+# outputs
+dist/
+dist-ssr/
+.tanstack/
+.wvb/
diff --git a/.yarnrc.yml b/.yarnrc.yml
new file mode 100644
index 0000000..caa687d
--- /dev/null
+++ b/.yarnrc.yml
@@ -0,0 +1,16 @@
+nodeLinker: node-modules
+
+preferReuse: true
+
+supportedArchitectures:
+ cpu:
+ - x64
+ - ia32
+ - arm64
+ libc:
+ - glibc
+ - musl
+ os:
+ - darwin
+ - linux
+ - win32
diff --git a/biome.json b/biome.json
new file mode 100644
index 0000000..f9299c9
--- /dev/null
+++ b/biome.json
@@ -0,0 +1,66 @@
+{
+ "$schema": "https://biomejs.dev/schemas/2.5.0/schema.json",
+ "assist": { "actions": { "source": { "organizeImports": "on" } } },
+ "vcs": {
+ "enabled": true,
+ "clientKind": "git",
+ "useIgnoreFile": true
+ },
+ "files": {
+ "includes": [
+ "**/*.ts",
+ "**/*.cts",
+ "**/*.tsx",
+ "**/*.js",
+ "**/*.cjs",
+ "**/*.json",
+ "**/*.mjs",
+ "!**/*.js",
+ "!**/*.cjs",
+ "!**/*.d.ts",
+ "!**/*.d.cts",
+ "!**/routeTree.gen.ts",
+ "!**/dist",
+ "!**/dist-ssr"
+ ]
+ },
+ "formatter": {
+ "enabled": true,
+ "indentStyle": "space",
+ "indentWidth": 2,
+ "lineWidth": 100,
+ "trailingNewline": true
+ },
+ "linter": {
+ "enabled": true,
+ "rules": {
+ "preset": "recommended",
+ "suspicious": {
+ "noExplicitAny": "off",
+ "noEmptyInterface": "off",
+ "noArrayIndexKey": "off",
+ "noConfusingVoidType": "off"
+ },
+ "style": {
+ "noNonNullAssertion": "off",
+ "noCommonJs": "error"
+ },
+ "a11y": {
+ "noSvgWithoutTitle": "off"
+ },
+ "security": {
+ "noDangerouslySetInnerHtml": "off"
+ },
+ "correctness": {
+ "noUnusedImports": "error"
+ }
+ }
+ },
+ "javascript": {
+ "formatter": {
+ "quoteStyle": "single",
+ "trailingCommas": "es5",
+ "arrowParentheses": "asNeeded"
+ }
+ }
+}
diff --git a/mise.toml b/mise.toml
new file mode 100644
index 0000000..4581550
--- /dev/null
+++ b/mise.toml
@@ -0,0 +1,2 @@
+[tools]
+node = "24.15.0"
diff --git a/package.json b/package.json
index b6a3b22..a50d858 100644
--- a/package.json
+++ b/package.json
@@ -1,4 +1,17 @@
{
"name": "webview-bundle-playground",
- "packageManager": "yarn@4.14.1"
+ "private": true,
+ "workspaces": [
+ "webviews/*"
+ ],
+ "packageManager": "yarn@4.16.0",
+ "scripts": {
+ "check": "biome check",
+ "check:fix": "biome check --write --unsafe",
+ "typecheck": "yarn workspaces foreach -Apt run typecheck"
+ },
+ "devDependencies": {
+ "@biomejs/biome": "2.5.0",
+ "typescript": "6.0.3"
+ }
}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..c36fb48
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compileOnSave": false,
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "strict": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "noUnusedParameters": true,
+ "forceConsistentCasingInFileNames": true,
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
+ "allowSyntheticDefaultImports": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "resolveJsonModule": true,
+ "declaration": true
+ }
+}
diff --git a/webviews/hacker-news/index.html b/webviews/hacker-news/index.html
new file mode 100644
index 0000000..2d58b33
--- /dev/null
+++ b/webviews/hacker-news/index.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+ BUNDLE // news
+
+
+
+
+
+
+
diff --git a/webviews/hacker-news/package.json b/webviews/hacker-news/package.json
new file mode 100644
index 0000000..8822493
--- /dev/null
+++ b/webviews/hacker-news/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "@wvb-playground-webview/hacker-news",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "yarn build:client && yarn build:ssr && yarn prerender",
+ "build:client": "vite build",
+ "build:ssr": "vite build --ssr src/entry-server.tsx --outDir dist-ssr --emptyOutDir",
+ "prerender": "node scripts/prerender.mjs",
+ "preview": "vite preview",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@fontsource-variable/inter": "^5.2.8",
+ "@fontsource-variable/jetbrains-mono": "^5.2.8",
+ "@tanstack/react-router": "1.170.15",
+ "react": "19.2.7",
+ "react-dom": "19.2.7"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "4.3.1",
+ "@tanstack/router-plugin": "1.168.18",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "@vitejs/plugin-react": "6.0.2",
+ "tailwindcss": "4.3.1",
+ "typescript": "6.0.3",
+ "vite": "8.0.16"
+ }
+}
diff --git a/webviews/hacker-news/scripts/prerender.mjs b/webviews/hacker-news/scripts/prerender.mjs
new file mode 100644
index 0000000..e9c58d3
--- /dev/null
+++ b/webviews/hacker-news/scripts/prerender.mjs
@@ -0,0 +1,37 @@
+// Static-site generation step. Runs after the client + SSR builds:
+// 1. client build -> dist/ (hashed assets + index.html template)
+// 2. ssr build -> dist-ssr/entry-server.js
+// 3. this script -> writes dist//index.html for every known route
+import { mkdir, readFile, writeFile } from 'node:fs/promises';
+import { dirname, join, resolve } from 'node:path';
+import { getStaticPaths, render } from '../dist-ssr/entry-server.js';
+
+const distDir = resolve(process.cwd(), 'dist');
+const template = await readFile(join(distDir, 'index.html'), 'utf8');
+
+function escapeHtml(value) {
+ return value.replace(/&/g, '&').replace(//g, '>');
+}
+
+function buildPage(appHtml, title) {
+ let html = template;
+ html = html.includes('')
+ ? html.replace('', appHtml)
+ : html.replace(/()(<\/div>)/, `$1${appHtml}$2`);
+ html = html.replace(/
[\s\S]*?<\/title>/, `${escapeHtml(title)}`);
+ return html;
+}
+
+const paths = getStaticPaths();
+for (const { url, title } of paths) {
+ const appHtml = await render(url);
+ const outPath = url === '/' ? join(distDir, 'index.html') : join(distDir, url, 'index.html');
+ await mkdir(dirname(outPath), { recursive: true });
+ await writeFile(outPath, buildPage(appHtml, title), 'utf8');
+}
+
+// SPA fallback for any deep link that wasn't prerendered: ship an empty shell
+// that the client boots and routes on its own.
+await writeFile(join(distDir, '404.html'), buildPage('', 'BUNDLE // news'), 'utf8');
+
+console.log(`✓ prerendered ${paths.length} routes + 404.html → dist/`);
diff --git a/webviews/hacker-news/src/components/CommentTree.tsx b/webviews/hacker-news/src/components/CommentTree.tsx
new file mode 100644
index 0000000..d869fad
--- /dev/null
+++ b/webviews/hacker-news/src/components/CommentTree.tsx
@@ -0,0 +1,99 @@
+import { Link } from '@tanstack/react-router';
+import type { FlatComment } from '../data';
+import { ageLabel, flattenComments } from '../data';
+import { cn } from '../lib/cn';
+import { useAppState, useVote } from '../lib/store';
+
+export function CommentTree() {
+ const { collapsed, toggleCollapse } = useAppState();
+ const list = flattenComments(collapsed);
+ return (
+
+ {list.map(c => (
+ toggleCollapse(c.id)}
+ />
+ ))}
+
+ );
+}
+
+function CommentItem({
+ c,
+ collapsed,
+ onToggle,
+}: {
+ c: FlatComment;
+ collapsed: boolean;
+ onToggle: () => void;
+}) {
+ const v = useVote(`c:${c.id}`, c.base);
+ return (
+ 0 ? 12 : 0,
+ borderLeft: c.depth > 0 ? '1px solid var(--border-1)' : 'none',
+ }}
+ >
+
+
+
+ {c.author}
+
+ {c.op && (
+
+ OP
+
+ )}
+
+ {v.score} pts · {ageLabel(c.age)}
+
+ {collapsed && c.childCount > 0 && (
+ · +{c.childCount} hidden
+ )}
+
+ {!collapsed && (
+ <>
+
+ {c.body}
+
+
+
+
+ reply
+
+ >
+ )}
+
+ );
+}
diff --git a/webviews/hacker-news/src/components/Composer.tsx b/webviews/hacker-news/src/components/Composer.tsx
new file mode 100644
index 0000000..96c6534
--- /dev/null
+++ b/webviews/hacker-news/src/components/Composer.tsx
@@ -0,0 +1,44 @@
+import { useNavigate } from '@tanstack/react-router';
+
+const fieldClass =
+ 'w-full rounded-md border border-border-2 bg-bg-1 px-2.5 py-2 text-[13px] text-fg-1 outline-none transition focus:border-accent focus:ring-[3px] focus:ring-accent/15';
+
+/** Inline "create a post" form, opened via the `?compose=true` search param. */
+export function Composer() {
+ const navigate = useNavigate();
+ const close = () => navigate({ to: '/', search: prev => ({ ...prev, compose: undefined }) });
+
+ return (
+
+ );
+}
diff --git a/webviews/hacker-news/src/components/Header.tsx b/webviews/hacker-news/src/components/Header.tsx
new file mode 100644
index 0000000..2ceab54
--- /dev/null
+++ b/webviews/hacker-news/src/components/Header.tsx
@@ -0,0 +1,118 @@
+import { Link, useNavigate, useRouterState } from '@tanstack/react-router';
+import { useState } from 'react';
+import { CURRENT_USER, monogram } from '../data';
+import { useAppState } from '../lib/store';
+
+const avatarClass =
+ 'flex h-8 w-8 items-center justify-center rounded-md border border-border-1 bg-accent-subtle font-sans text-[12px] font-bold text-accent';
+
+function ThemeButton({ className }: { className?: string }) {
+ const { theme, toggleTheme } = useAppState();
+ return (
+
+ );
+}
+
+export function Header() {
+ const pathname = useRouterState({ select: s => s.location.pathname });
+ const search = useRouterState({ select: s => s.location.search as { q?: string } });
+ const navigate = useNavigate();
+
+ const isFeed = pathname === '/';
+ const mobileTitle = pathname.startsWith('/post')
+ ? 'thread'
+ : pathname.startsWith('/u/')
+ ? decodeURIComponent(pathname.slice(3))
+ : 'BUNDLE';
+
+ const [q, setQ] = useState(search.q ?? '');
+ const onSearch = (value: string) => {
+ setQ(value);
+ navigate({ to: '/', search: prev => ({ ...prev, q: value || undefined }) });
+ };
+
+ return (
+
+ {/* ---------- desktop ---------- */}
+
+
+
+ BUNDLE
+
+
{'// news'}
+
+
+
+
+
+
+
+
({ ...prev, compose: true })}
+ className="flex h-8 items-center gap-1.5 rounded-md bg-accent px-3.5 text-[13px] font-medium text-white transition-colors hover:bg-accent-hover"
+ >
+ + submit
+
+
+ {monogram(CURRENT_USER)}
+
+
+
+ {/* ---------- mobile ---------- */}
+
+ {isFeed ? (
+
+
+ BUNDLE
+
+
+ ) : (
+ <>
+
+ {mobileTitle}
+ >
+ )}
+
+
+ {monogram(CURRENT_USER)}
+
+
+
+ );
+}
diff --git a/webviews/hacker-news/src/components/LeftSidebar.tsx b/webviews/hacker-news/src/components/LeftSidebar.tsx
new file mode 100644
index 0000000..cfcd5a0
--- /dev/null
+++ b/webviews/hacker-news/src/components/LeftSidebar.tsx
@@ -0,0 +1,61 @@
+import { Link, useRouterState } from '@tanstack/react-router';
+import { TAGS, tagCount } from '../data';
+import { cn } from '../lib/cn';
+
+const rowBase =
+ 'flex w-full items-center justify-between gap-2 rounded-md border border-transparent px-2 py-1.5 text-left text-[12.5px] transition-colors';
+const activeRow = 'bg-accent-subtle font-semibold text-accent';
+const idleRow = 'text-fg-2 hover:bg-bg-2';
+
+export function LeftSidebar() {
+ const pathname = useRouterState({ select: s => s.location.pathname });
+ const tag = useRouterState({ select: s => (s.location.search as { tag?: string }).tag });
+ const sort = useRouterState({ select: s => (s.location.search as { sort?: string }).sort });
+
+ const onFeed = pathname === '/';
+ const activeTag = onFeed ? tag : undefined;
+ const homeActive = onFeed && !activeTag && sort !== 'top';
+ const popActive = onFeed && !activeTag && sort === 'top';
+
+ return (
+
+ );
+}
diff --git a/webviews/hacker-news/src/components/MobileNav.tsx b/webviews/hacker-news/src/components/MobileNav.tsx
new file mode 100644
index 0000000..48edefa
--- /dev/null
+++ b/webviews/hacker-news/src/components/MobileNav.tsx
@@ -0,0 +1,45 @@
+import { Link, useRouterState } from '@tanstack/react-router';
+import { CURRENT_USER } from '../data';
+import { cn } from '../lib/cn';
+
+const itemBase =
+ 'flex flex-1 flex-col items-center gap-[3px] py-[9px] pb-[11px] text-[10px] transition-colors';
+
+export function MobileNav() {
+ const pathname = useRouterState({ select: s => s.location.pathname });
+ const isFeed = pathname === '/';
+ const isProfile = pathname.startsWith('/u/');
+
+ return (
+
+ );
+}
diff --git a/webviews/hacker-news/src/components/PostRow.tsx b/webviews/hacker-news/src/components/PostRow.tsx
new file mode 100644
index 0000000..ca9f81f
--- /dev/null
+++ b/webviews/hacker-news/src/components/PostRow.tsx
@@ -0,0 +1,105 @@
+import { Link } from '@tanstack/react-router';
+import { ageLabel } from '../data';
+import type { Post } from '../data/types';
+import { useVote } from '../lib/store';
+import { TagBadge } from './TagBadge';
+import { VoteColumn } from './VoteColumn';
+
+/** Compact "dense" feed row — the default, matching the primary screenshots. */
+export function PostRow({ post, rank }: { post: Post; rank: number }) {
+ const v = useVote(`p:${post.id}`, post.base);
+ const postId = String(post.id);
+ return (
+
+
+ {rank.toString().padStart(2, '0')}
+
+
+
+
+
+
+
+ {post.title}
+
+ {post.url && ({post.url})}
+
+
+
+ {v.score} pts
+
+ by{' '}
+
+ {post.author}
+
+
+ {ageLabel(post.age)}
+
+ ✦ {post.comments} comments
+
+
+
+
+ );
+}
+
+/** Roomier "cards" feed item — toggled via the feed view switcher. */
+export function PostCard({ post }: { post: Post }) {
+ const v = useVote(`p:${post.id}`, post.base);
+ const postId = String(post.id);
+ return (
+
+
+
+
+
+
+
+
+ posted by{' '}
+
+ {post.author}
+ {' '}
+ · {ageLabel(post.age)}
+
+
+
+ {post.title}
+
+ {post.url &&
→ {post.url}
}
+
{post.body}
+
+
+ ▭ {post.comments} comments
+
+ ↗ share
+ ✦ save
+
+
+
+ );
+}
diff --git a/webviews/hacker-news/src/components/RightRail.tsx b/webviews/hacker-news/src/components/RightRail.tsx
new file mode 100644
index 0000000..0d96c1f
--- /dev/null
+++ b/webviews/hacker-news/src/components/RightRail.tsx
@@ -0,0 +1,69 @@
+import { Link } from '@tanstack/react-router';
+import { TAGS } from '../data';
+
+const guidelines = [
+ '01 · Be terse and technical',
+ '02 · Link the spec, not the hype',
+ '03 · WIP-honest > marketing',
+ '04 · No reposts of the magic number',
+];
+
+export function RightRail() {
+ return (
+
+ );
+}
diff --git a/webviews/hacker-news/src/components/StatusBar.tsx b/webviews/hacker-news/src/components/StatusBar.tsx
new file mode 100644
index 0000000..e168b5c
--- /dev/null
+++ b/webviews/hacker-news/src/components/StatusBar.tsx
@@ -0,0 +1,15 @@
+/** Webview-flavored status bar — evokes a native app mounting the .wvb bundle. */
+export function StatusBar() {
+ return (
+
+
+
+ connected
+
+ remote Source · news.wvb.dev
+ builtin fallback ready
+ ⟳ synced 2m ago
+ @wvb/web v1.4.0
+
+ );
+}
diff --git a/webviews/hacker-news/src/components/TagBadge.tsx b/webviews/hacker-news/src/components/TagBadge.tsx
new file mode 100644
index 0000000..8c658d6
--- /dev/null
+++ b/webviews/hacker-news/src/components/TagBadge.tsx
@@ -0,0 +1,19 @@
+import { Link } from '@tanstack/react-router';
+import type { TagId } from '../data/types';
+import { cn } from '../lib/cn';
+
+/** A `#tag` chip that links to the feed filtered by that community. */
+export function TagBadge({ tag, className }: { tag: TagId; className?: string }) {
+ return (
+
+ #{tag}
+
+ );
+}
diff --git a/webviews/hacker-news/src/components/VoteColumn.tsx b/webviews/hacker-news/src/components/VoteColumn.tsx
new file mode 100644
index 0000000..1ccb3d3
--- /dev/null
+++ b/webviews/hacker-news/src/components/VoteColumn.tsx
@@ -0,0 +1,54 @@
+import { cn } from '../lib/cn';
+import type { VoteDir } from '../lib/store';
+
+export function VoteColumn({
+ dir,
+ score,
+ onUp,
+ onDown,
+ big = false,
+}: {
+ dir: VoteDir;
+ score: number;
+ onUp: () => void;
+ onDown: () => void;
+ big?: boolean;
+}) {
+ return (
+
+
+
+ {score}
+
+
+
+ );
+}
diff --git a/webviews/hacker-news/src/data/comments.ts b/webviews/hacker-news/src/data/comments.ts
new file mode 100644
index 0000000..d4cfa44
--- /dev/null
+++ b/webviews/hacker-news/src/data/comments.ts
@@ -0,0 +1,80 @@
+import type { CommentNode } from './types';
+
+/** Threaded comments for the post-detail view (post #3, the streaming RFC). */
+export const comments: CommentNode[] = [
+ {
+ id: 'c1',
+ author: 'core_dev',
+ base: 240,
+ age: 2,
+ body: `We prototyped this. The trick is that the Index is fully read first, so you can seek and inflate individual blocks lazily. First paint dropped from 180ms to 22ms on our largest bundle.`,
+ children: [
+ {
+ id: 'c2',
+ author: 'tauri_andy',
+ op: true,
+ base: 88,
+ age: 2,
+ body: `Right — since the lz4 block format is independently decodable per block, you don't need the whole stream. Did you hit any alignment issues on the last block?`,
+ children: [
+ {
+ id: 'c3',
+ author: 'core_dev',
+ base: 54,
+ age: 1,
+ body: `Only on the last block. Padded it to 4 bytes and the reader stopped complaining.`,
+ children: [
+ {
+ id: 'c4',
+ author: 'byte_poet',
+ base: 12,
+ age: 1,
+ body: `this is exactly the kind of detail that belongs in the spec README`,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ id: 'c5',
+ author: 'hashbrown',
+ base: 31,
+ age: 1,
+ body: `How does checksum verification interact with lazy inflate — do you verify per-block or whole-file?`,
+ children: [
+ {
+ id: 'c6',
+ author: 'core_dev',
+ base: 29,
+ age: 1,
+ body: `Per-block xxHash-32 stored in the Index, with a whole-file fallback if the Index is legacy.`,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ id: 'c7',
+ author: 'offgrid',
+ base: 44,
+ age: 3,
+ body: `Counterpoint: for our offline kiosks the full inflate happens once at boot and never again. Streaming would add complexity we don't actually need.`,
+ children: [
+ {
+ id: 'c8',
+ author: 'tauri_andy',
+ op: true,
+ base: 20,
+ age: 2,
+ body: `Fair. This is mostly a win for large bundles on a cold start / very first launch.`,
+ },
+ ],
+ },
+ {
+ id: 'c9',
+ author: 'determinist',
+ base: 9,
+ age: 4,
+ body: `Please keep this deterministic — streaming output must not reorder block boundaries or we lose byte-identical packs.`,
+ },
+];
diff --git a/webviews/hacker-news/src/data/index.ts b/webviews/hacker-news/src/data/index.ts
new file mode 100644
index 0000000..1cd3616
--- /dev/null
+++ b/webviews/hacker-news/src/data/index.ts
@@ -0,0 +1,154 @@
+import { comments } from './comments';
+import { posts, TAGS } from './posts';
+import type { CommentNode, Post, Sort, TagId, User } from './types';
+import { users } from './users';
+
+export type { CommentNode, Post, Sort, TagId, User, Variant } from './types';
+export { comments, posts, TAGS, users };
+
+/** The signed-in user (the "CD" avatar in the header). */
+export const CURRENT_USER = 'core_dev';
+
+/** Format an age in hours as `Nh` / `Nd`. */
+export function ageLabel(hours: number): string {
+ return hours < 24 ? `${hours}h` : `${Math.floor(hours / 24)}d`;
+}
+
+/** Number of posts carrying each community tag. */
+export function tagCount(tag: TagId): number {
+ return posts.filter(p => p.tag === tag).length;
+}
+
+export interface FeedQuery {
+ tag?: TagId | null;
+ q?: string;
+ sort?: Sort;
+}
+
+export function selectFeed({ tag, q, sort = 'hot' }: FeedQuery): Post[] {
+ let list = posts.slice();
+ if (tag) list = list.filter(p => p.tag === tag);
+ const needle = (q ?? '').trim().toLowerCase();
+ if (needle) {
+ list = list.filter(
+ p =>
+ p.title.toLowerCase().includes(needle) ||
+ p.tag.includes(needle) ||
+ p.author.toLowerCase().includes(needle)
+ );
+ }
+ if (sort === 'new') list.sort((a, b) => a.age - b.age);
+ else if (sort === 'top') list.sort((a, b) => b.base - a.base);
+ else list.sort((a, b) => hotScore(b) - hotScore(a));
+ return list;
+}
+
+function hotScore(p: Post): number {
+ return p.base / (p.age + 2) ** 0.35;
+}
+
+export function getPost(id: number): Post | undefined {
+ return posts.find(p => p.id === id);
+}
+
+export function getUser(name: string): User | undefined {
+ return users[name];
+}
+
+export function postsByAuthor(name: string): Post[] {
+ return posts.filter(p => p.author === name);
+}
+
+export interface AuthorComment {
+ id: string;
+ body: string;
+ score: number;
+ age: number;
+}
+
+export function commentsByAuthor(name: string): AuthorComment[] {
+ const out: AuthorComment[] = [];
+ const walk = (nodes: CommentNode[]) => {
+ for (const n of nodes) {
+ if (n.author === name) out.push({ id: n.id, body: n.body, score: n.base, age: n.age });
+ if (n.children) walk(n.children);
+ }
+ };
+ walk(comments);
+ return out;
+}
+
+/** All distinct authors across posts + comments — used to prerender profiles. */
+export function allAuthors(): string[] {
+ const set = new Set();
+ for (const p of posts) set.add(p.author);
+ const walk = (nodes: CommentNode[]) => {
+ for (const n of nodes) {
+ set.add(n.author);
+ if (n.children) walk(n.children);
+ }
+ };
+ walk(comments);
+ return [...set];
+}
+
+export function countDescendants(node: CommentNode): number {
+ if (!node.children) return 0;
+ return node.children.reduce((acc, c) => acc + 1 + countDescendants(c), 0);
+}
+
+export function totalCommentCount(): number {
+ let n = 0;
+ const walk = (nodes: CommentNode[]) => {
+ for (const c of nodes) {
+ n++;
+ if (c.children) walk(c.children);
+ }
+ };
+ walk(comments);
+ return n;
+}
+
+export interface FlatComment {
+ id: string;
+ depth: number;
+ author: string;
+ op: boolean;
+ body: string;
+ base: number;
+ age: number;
+ childCount: number;
+}
+
+/**
+ * Flatten the comment tree to a render list, honoring a set of collapsed ids:
+ * a collapsed node is shown but its descendants are omitted.
+ */
+export function flattenComments(collapsed: Set): FlatComment[] {
+ const out: FlatComment[] = [];
+ const walk = (nodes: CommentNode[], depth: number) => {
+ for (const n of nodes) {
+ const isCollapsed = collapsed.has(n.id);
+ out.push({
+ id: n.id,
+ depth,
+ author: n.author,
+ op: !!n.op,
+ body: n.body,
+ base: n.base,
+ age: n.age,
+ childCount: countDescendants(n),
+ });
+ if (!isCollapsed && n.children) walk(n.children, depth + 1);
+ }
+ };
+ walk(comments, 0);
+ return out;
+}
+
+export function monogram(user: string): string {
+ const parts = user.split('_');
+ const a = parts[0]?.[0] ?? '';
+ const b = (parts[1] ?? parts[0] ?? '')[0] ?? '';
+ return (a + b).toUpperCase();
+}
diff --git a/webviews/hacker-news/src/data/posts.ts b/webviews/hacker-news/src/data/posts.ts
new file mode 100644
index 0000000..ae2b3f2
--- /dev/null
+++ b/webviews/hacker-news/src/data/posts.ts
@@ -0,0 +1,134 @@
+import type { Post, TagId } from './types';
+
+export const TAGS: TagId[] = ['core', 'rfc', 'showcase', 'tauri', 'electron', 'cli', 'help'];
+
+export const posts: Post[] = [
+ {
+ id: 1,
+ title: 'Show: a 2.3 MB .wvb hot-swaps our entire Electron UI — no App Store review',
+ url: 'github.com',
+ tag: 'showcase',
+ base: 412,
+ author: 'lz4_maxi',
+ age: 3,
+ comments: 88,
+ body: `We bundle the whole web layer into a single signed .wvb and swap it at runtime from a remote Source. Ship a fix in minutes, not days. The builtin bundle is the offline fallback on first launch.`,
+ },
+ {
+ id: 2,
+ title: 'xxHash-32 vs CRC32 for bundle integrity — benchmarks across 10k files',
+ url: 'bench.wvb.dev',
+ tag: 'core',
+ base: 287,
+ author: 'hashbrown',
+ age: 5,
+ comments: 41,
+ body: `xxHash-32 verified 10k files about 4x faster than CRC32 with zero collisions in our corpus. The checksum lives in the Index, so you can verify a block before inflating it.`,
+ },
+ {
+ id: 3,
+ title: "RFC: stream bundle decompression so first paint doesn't wait for full lz4 inflate",
+ tag: 'rfc',
+ base: 196,
+ author: 'tauri_andy',
+ age: 2,
+ comments: 134,
+ body: `Because the lz4 block format is independently decodable per block, we can seek and inflate individual blocks lazily. First paint drops from ~180ms to ~22ms on large bundles. Looking for feedback on block alignment and per-block checksums.`,
+ },
+ {
+ id: 4,
+ title: 'Tauri integration merged — app:// interception now works on Linux, Windows, macOS',
+ url: 'github.com',
+ tag: 'tauri',
+ base: 530,
+ author: 'core_dev',
+ age: 7,
+ comments: 72,
+ body: `@wvb/tauri intercepts app:// requests and serves them straight from a Source — same API as the Electron package. Mobile (Android / iOS) is next on the roadmap.`,
+ },
+ {
+ id: 5,
+ title: 'Why we moved from a CDN of loose files to one signed .wvb per release',
+ url: 'eng.acme.io',
+ tag: 'showcase',
+ base: 158,
+ author: 'ship_it',
+ age: 9,
+ comments: 53,
+ body: `Loose files meant partial deploys and torn states. A single atomic .wvb is verified end-to-end, so a release is all-or-nothing. Rollback is just pointing at the previous bundle.`,
+ },
+ {
+ id: 6,
+ title: 'Help: HEAD /bundles/app/latest returns 200 but GET 404s on first launch',
+ tag: 'help',
+ base: 34,
+ author: 'newbie_dev',
+ age: 1,
+ comments: 19,
+ body: `My remote Source answers HEAD fine but the GET 404s before the first deploy completes. Is the builtin fallback supposed to cover this window? What am I missing in the manifest?`,
+ },
+ {
+ id: 7,
+ title: "The magic number is 🌐🎁 (0xf09f8c90 0xf09f8e81) and I think that's beautiful",
+ url: 'github.com',
+ tag: 'core',
+ base: 244,
+ author: 'byte_poet',
+ age: 12,
+ comments: 27,
+ body: `Two emoji encoded as UTF-8 sit at the very top of every .wvb — a globe and a gift. Offline web, delivered. Small detail, but it makes me smile every time I hexdump a bundle.`,
+ },
+ {
+ id: 8,
+ title: '@wvb/cli 1.4 — `wvb pack` is 3x faster with parallel lz4 blocks',
+ url: 'npmjs.com',
+ tag: 'cli',
+ base: 173,
+ author: 'cli_maintainer',
+ age: 6,
+ comments: 22,
+ body: `Packing now splits the Data region into independent lz4 blocks compressed across all cores. Deterministic output is preserved when you pass --sorted.`,
+ },
+ {
+ id: 9,
+ title: "Offline-first is underrated: our field techs haven't seen a spinner in 6 months",
+ tag: 'showcase',
+ base: 121,
+ author: 'offgrid',
+ age: 14,
+ comments: 38,
+ body: `Every device ships with a builtin bundle and updates opportunistically when it finds a network. The UI never blocks on the wire. Reliability went up, support tickets went down.`,
+ },
+ {
+ id: 10,
+ title: 'Reproducible bundles: deterministic lz4 + sorted Index = byte-identical .wvb',
+ tag: 'rfc',
+ base: 99,
+ author: 'determinist',
+ age: 8,
+ comments: 16,
+ body: `If you sort the Index by path and pin the lz4 parameters, two packs of the same input produce identical bytes. That makes bundles cacheable and auditable. Proposing we make --sorted the default.`,
+ },
+ {
+ id: 11,
+ title: 'Comparing builtin vs remote Sources for a kiosk fleet of 4,000 devices',
+ url: 'eng.acme.io',
+ tag: 'electron',
+ base: 87,
+ author: 'kiosk_ops',
+ age: 16,
+ comments: 29,
+ body: `Builtin guarantees a working boot; remote keeps them current. We pin a known-good builtin and gate remote swaps behind a health check so a bad bundle can never brick a kiosk.`,
+ },
+ {
+ id: 12,
+ title: 'TIL the Index is just length-prefixed paths → offsets. Wrote a 40-line parser.',
+ url: 'gist.github.com',
+ tag: 'core',
+ base: 156,
+ author: 'minimalist',
+ age: 10,
+ comments: 11,
+ body: `Read the Magic Number, read the Index region as (len, path, offset, size) tuples, then slice the Data region. That is the whole format. Refreshingly small.`,
+ },
+];
diff --git a/webviews/hacker-news/src/data/types.ts b/webviews/hacker-news/src/data/types.ts
new file mode 100644
index 0000000..91d1123
--- /dev/null
+++ b/webviews/hacker-news/src/data/types.ts
@@ -0,0 +1,37 @@
+export type TagId = 'core' | 'rfc' | 'showcase' | 'tauri' | 'electron' | 'cli' | 'help';
+
+export type Sort = 'hot' | 'new' | 'top';
+
+export type Variant = 'dense' | 'cards';
+
+export interface Post {
+ id: number;
+ title: string;
+ /** Source domain, when the post links out. */
+ url?: string;
+ tag: TagId;
+ /** Base score before client-side votes are applied. */
+ base: number;
+ author: string;
+ /** Age in hours (kept static so server + client render identically). */
+ age: number;
+ comments: number;
+ body: string;
+}
+
+export interface CommentNode {
+ id: string;
+ author: string;
+ base: number;
+ age: number;
+ body: string;
+ /** Marks the original poster. */
+ op?: boolean;
+ children?: CommentNode[];
+}
+
+export interface User {
+ bio: string;
+ karma: string;
+ joined: string;
+}
diff --git a/webviews/hacker-news/src/data/users.ts b/webviews/hacker-news/src/data/users.ts
new file mode 100644
index 0000000..ce734cb
--- /dev/null
+++ b/webviews/hacker-news/src/data/users.ts
@@ -0,0 +1,39 @@
+import type { User } from './types';
+
+export const users: Record = {
+ core_dev: {
+ bio: 'Maintainer of @wvb/core. Rust, lz4, and offline-first evangelism.',
+ karma: '12.4k',
+ joined: '2y ago',
+ },
+ tauri_andy: {
+ bio: 'Tauri contributor. Making app:// interception work everywhere.',
+ karma: '8.1k',
+ joined: '1y ago',
+ },
+ hashbrown: {
+ bio: 'Checksums, hashing, and benchmarks. xxHash apologist.',
+ karma: '3.7k',
+ joined: '1y ago',
+ },
+ lz4_maxi: {
+ bio: 'Compression nerd. Shipping smaller bundles every release.',
+ karma: '5.2k',
+ joined: '8mo ago',
+ },
+ byte_poet: {
+ bio: 'I read hexdumps for fun. The magic number is art.',
+ karma: '2.1k',
+ joined: '1y ago',
+ },
+ offgrid: {
+ bio: 'Field deployments where the network is a rumor.',
+ karma: '1.9k',
+ joined: '10mo ago',
+ },
+ determinist: {
+ bio: "Reproducible builds or it didn't happen.",
+ karma: '2.8k',
+ joined: '1y ago',
+ },
+};
diff --git a/webviews/hacker-news/src/entry-server.tsx b/webviews/hacker-news/src/entry-server.tsx
new file mode 100644
index 0000000..58b03b2
--- /dev/null
+++ b/webviews/hacker-news/src/entry-server.tsx
@@ -0,0 +1,30 @@
+import { createMemoryHistory, RouterProvider } from '@tanstack/react-router';
+import { renderToString } from 'react-dom/server';
+import { allAuthors, posts } from './data';
+import { createRouter } from './router';
+
+/** Render a single route URL to an HTML string (used by scripts/prerender.mjs). */
+export async function render(url: string): Promise {
+ const router = createRouter({ history: createMemoryHistory({ initialEntries: [url] }) });
+ await router.load();
+ return renderToString();
+}
+
+export interface StaticPath {
+ url: string;
+ title: string;
+}
+
+const SITE = 'BUNDLE // news';
+
+/** Every route to prerender: the feed, each post, and each author profile. */
+export function getStaticPaths(): StaticPath[] {
+ const paths: StaticPath[] = [{ url: '/', title: SITE }];
+ for (const p of posts) {
+ paths.push({ url: `/post/${p.id}`, title: `${p.title} · ${SITE}` });
+ }
+ for (const author of allAuthors()) {
+ paths.push({ url: `/u/${author}`, title: `${author} · ${SITE}` });
+ }
+ return paths;
+}
diff --git a/webviews/hacker-news/src/lib/cn.ts b/webviews/hacker-news/src/lib/cn.ts
new file mode 100644
index 0000000..7358f1c
--- /dev/null
+++ b/webviews/hacker-news/src/lib/cn.ts
@@ -0,0 +1,4 @@
+/** Tiny classnames joiner. */
+export function cn(...parts: Array): string {
+ return parts.filter(Boolean).join(' ');
+}
diff --git a/webviews/hacker-news/src/lib/store.tsx b/webviews/hacker-news/src/lib/store.tsx
new file mode 100644
index 0000000..f203d36
--- /dev/null
+++ b/webviews/hacker-news/src/lib/store.tsx
@@ -0,0 +1,92 @@
+import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from 'react';
+
+export type Theme = 'light' | 'dark';
+export type VoteDir = 1 | -1 | 0;
+
+const THEME_KEY = 'wvb-theme';
+
+interface AppState {
+ theme: Theme;
+ toggleTheme: () => void;
+ votes: Record;
+ vote: (key: string, dir: 1 | -1) => void;
+ collapsed: Set;
+ toggleCollapse: (id: string) => void;
+}
+
+const AppStateContext = createContext(null);
+
+export function AppStateProvider({ children }: { children: ReactNode }) {
+ // Server and the first client render are always `light`, so prerendered HTML
+ // hydrates without a mismatch; the stored preference is applied in an effect.
+ const [theme, setTheme] = useState('light');
+ const [votes, setVotes] = useState>({});
+ const [collapsed, setCollapsed] = useState>(() => new Set());
+
+ useEffect(() => {
+ try {
+ const saved = localStorage.getItem(THEME_KEY);
+ if (saved === 'dark' || saved === 'light') setTheme(saved);
+ } catch {
+ /* localStorage unavailable (e.g. sandboxed webview) — keep default */
+ }
+ }, []);
+
+ const toggleTheme = useCallback(() => {
+ setTheme(prev => {
+ const next = prev === 'dark' ? 'light' : 'dark';
+ try {
+ localStorage.setItem(THEME_KEY, next);
+ } catch {
+ /* ignore */
+ }
+ return next;
+ });
+ }, []);
+
+ const vote = useCallback((key: string, dir: 1 | -1) => {
+ setVotes(prev => ({ ...prev, [key]: prev[key] === dir ? 0 : dir }));
+ }, []);
+
+ const toggleCollapse = useCallback((id: string) => {
+ setCollapsed(prev => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useAppState(): AppState {
+ const ctx = useContext(AppStateContext);
+ if (!ctx) throw new Error('useAppState must be used within ');
+ return ctx;
+}
+
+export interface VoteState {
+ dir: VoteDir;
+ score: number;
+ up: () => void;
+ down: () => void;
+}
+
+/** Resolve the current vote direction + adjusted score for a votable item. */
+export function useVote(key: string, base: number): VoteState {
+ const { votes, vote } = useAppState();
+ const dir = votes[key] ?? 0;
+ return {
+ dir,
+ score: base + dir,
+ up: () => vote(key, 1),
+ down: () => vote(key, -1),
+ };
+}
diff --git a/webviews/hacker-news/src/main.tsx b/webviews/hacker-news/src/main.tsx
new file mode 100644
index 0000000..76fdc4b
--- /dev/null
+++ b/webviews/hacker-news/src/main.tsx
@@ -0,0 +1,28 @@
+import '@fontsource-variable/inter';
+import '@fontsource-variable/jetbrains-mono';
+import './styles.css';
+
+import { RouterProvider } from '@tanstack/react-router';
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import { createRouter } from './router';
+
+const router = createRouter();
+const rootEl = document.getElementById('root');
+
+if (rootEl) {
+ // Each route is prerendered to static HTML (see scripts/prerender.mjs), which
+ // gives an instant, crawlable first paint. We then resolve the initial matches
+ // and mount React over it. We render (not hydrate) on purpose: standalone
+ // TanStack Router — without TanStack Start — doesn't emit the dehydration
+ // payload that clean hydration needs, and these pages are tiny, so a fresh
+ // client render is effectively free and avoids any hydration mismatch. React
+ // swaps in identical markup in a single commit, so there is no visible flash.
+ router.load().then(() => {
+ createRoot(rootEl).render(
+
+
+
+ );
+ });
+}
diff --git a/webviews/hacker-news/src/routeTree.gen.ts b/webviews/hacker-news/src/routeTree.gen.ts
new file mode 100644
index 0000000..d3b7dd3
--- /dev/null
+++ b/webviews/hacker-news/src/routeTree.gen.ts
@@ -0,0 +1,95 @@
+/* eslint-disable */
+
+// @ts-nocheck
+
+// noinspection JSUnusedGlobalSymbols
+
+// This file was automatically generated by TanStack Router.
+// You should NOT make any changes in this file as it will be overwritten.
+// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
+
+import { Route as rootRouteImport } from './routes/__root'
+import { Route as IndexRouteImport } from './routes/index'
+import { Route as UUsernameRouteImport } from './routes/u.$username'
+import { Route as PostPostIdRouteImport } from './routes/post.$postId'
+
+const IndexRoute = IndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const UUsernameRoute = UUsernameRouteImport.update({
+ id: '/u/$username',
+ path: '/u/$username',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const PostPostIdRoute = PostPostIdRouteImport.update({
+ id: '/post/$postId',
+ path: '/post/$postId',
+ getParentRoute: () => rootRouteImport,
+} as any)
+
+export interface FileRoutesByFullPath {
+ '/': typeof IndexRoute
+ '/post/$postId': typeof PostPostIdRoute
+ '/u/$username': typeof UUsernameRoute
+}
+export interface FileRoutesByTo {
+ '/': typeof IndexRoute
+ '/post/$postId': typeof PostPostIdRoute
+ '/u/$username': typeof UUsernameRoute
+}
+export interface FileRoutesById {
+ __root__: typeof rootRouteImport
+ '/': typeof IndexRoute
+ '/post/$postId': typeof PostPostIdRoute
+ '/u/$username': typeof UUsernameRoute
+}
+export interface FileRouteTypes {
+ fileRoutesByFullPath: FileRoutesByFullPath
+ fullPaths: '/' | '/post/$postId' | '/u/$username'
+ fileRoutesByTo: FileRoutesByTo
+ to: '/' | '/post/$postId' | '/u/$username'
+ id: '__root__' | '/' | '/post/$postId' | '/u/$username'
+ fileRoutesById: FileRoutesById
+}
+export interface RootRouteChildren {
+ IndexRoute: typeof IndexRoute
+ PostPostIdRoute: typeof PostPostIdRoute
+ UUsernameRoute: typeof UUsernameRoute
+}
+
+declare module '@tanstack/react-router' {
+ interface FileRoutesByPath {
+ '/': {
+ id: '/'
+ path: '/'
+ fullPath: '/'
+ preLoaderRoute: typeof IndexRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/u/$username': {
+ id: '/u/$username'
+ path: '/u/$username'
+ fullPath: '/u/$username'
+ preLoaderRoute: typeof UUsernameRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/post/$postId': {
+ id: '/post/$postId'
+ path: '/post/$postId'
+ fullPath: '/post/$postId'
+ preLoaderRoute: typeof PostPostIdRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ }
+}
+
+const rootRouteChildren: RootRouteChildren = {
+ IndexRoute: IndexRoute,
+ PostPostIdRoute: PostPostIdRoute,
+ UUsernameRoute: UUsernameRoute,
+}
+export const routeTree = rootRouteImport
+ ._addFileChildren(rootRouteChildren)
+ ._addFileTypes()
diff --git a/webviews/hacker-news/src/router.tsx b/webviews/hacker-news/src/router.tsx
new file mode 100644
index 0000000..c54e405
--- /dev/null
+++ b/webviews/hacker-news/src/router.tsx
@@ -0,0 +1,28 @@
+import { createRouter as createTanStackRouter } from '@tanstack/react-router';
+import { routeTree } from './routeTree.gen';
+
+type RouterOptions = Parameters[0];
+
+/**
+ * Single router factory used by both entries:
+ * - the client (browser history, the default) hydrates the prerendered HTML
+ * - the prerender (memory history, passed in `opts`) renders each route to a
+ * string at build time.
+ */
+export function createRouter(opts?: Pick) {
+ return createTanStackRouter({
+ routeTree,
+ defaultPreload: 'intent',
+ // NOTE: scrollRestoration is intentionally left off. It injects an inline
+ //