This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Git commit 訊息絕對不可包含 Co-Authored-By: Claude 或任何 AI 署名資訊。 只寫功能描述,不附加任何尾行。
A zero-build, zero-dependency Plain Vanilla web app template. No package.json, no bundler, no transpiler, no node_modules. Everything is native ES Modules, native Custom Elements, and standard browser APIs loaded directly by the browser. The project doubles as an interactive showcase/lab for cutting-edge web platform APIs (WebGPU, WebRTC, WebAuthn, Web Bluetooth, WebCodecs, etc.).
Treat "use the platform, avoid abstractions" as a hard constraint: do not introduce build steps, npm dependencies, or framework runtimes. See docs/MANIFESTO.md and docs/decisions/ (ADRs) for the reasoning behind architectural choices.
Because it uses ES Modules + Service Worker, it must be served over HTTP (opening index.html via file:// will not work):
python3 -m http.server # then open http://localhost:8000/
# or
npx serve .Tests (Node's built-in test runner — no test framework installed):
node --test tests/*.test.js # run all tests
node --test tests/store.test.js # run a single test fileTests run in Node, not a browser, so they mock browser globals they depend on (localStorage, BroadcastChannel, etc. — see tests/store.test.js). When testing browser-coupled code, add the globals the module touches.
Lint / syntax check (also what CI runs):
find . -name "*.js" -not -path "./node_modules/*" | xargs -n 1 node -cScaffolding & maintenance scripts:
node scripts/new-page.js ProductInfo # generates components/pages/ProductInfo.js (see its output for follow-up wiring)
node scripts/update-adr-index.js # regenerates the ADR index in docs/decisions/CI (.github/workflows/deploy.yml) runs the unit tests and the syntax check on push/PR to master. scripts/sync.sh runs tests + syntax check then auto-commits and pushes to master (and references an external cleanup script that may not exist) — do not run it casually.
Two base classes underpin everything; learn them first.
- Extends
HTMLElement. Light DOM by default (static useShadow = false); setuseShadow = trueper-component to opt into Shadow DOM. - Reactive state: call
this.initReactiveState({...})in the constructor. State is a deep Proxy — any mutation schedules a re-render batched viarequestAnimationFrame(scheduleUpdate). update()does a fullinnerHTMLre-render of the component, then re-applies focus + cursor selection and preserves any element markeddata-persistent(matched to adata-persistent-placeholder). There is no virtual DOM / diffing.render()must return anhtml\`result (aSafeHTMLobject) or a string. OverrideafterFirstRender()to attach event listeners; re-attach them in an overriddenupdate()since the DOM is replaced (seeapp/App.js`).- Slots are simulated in Light DOM:
$slot(name)returns captured original child markup (captured once before first render).$t(key, params)is the i18n helper.
- Extends
EventTarget. Use.on(type, cb)(returns an unsubscribe fn),.off(type, cb), and.emit(type, detail).onauto-unwrapsCustomEvent.detail. - Everything in
lib/*-service.jsfollows this pattern and is exported as a singleton instance (e.g.appStore,router,i18n,docService,authService). Import the singleton; don'tnewit.
html\...`` tagged template auto-escapes all interpolated values and concatenates arrays. This is the safe default — always build markup with it.unsafe(str)wraps a string as trustedSafeHTMLto bypass escaping. Only use for content you control (e.g. already-rendered child component markup,$slotoutput). Never wrap user/remote input inunsafe().
appStoreis the global Proxy-backed store. SettingappStore.state.x = vpersists tolocalStorage, emitschange, and broadcasts to other tabs viabroadcastService(BroadcastChannel).applySnapshot()powers undo/redo;setCache/getCacheprovide TTL caching.
lib/router.js(routersingleton) emitsroute-changeonhashchange.<x-switch>(components/route/switch.js) renders only the first matching<x-route>, wrapped in a View Transition when supported.<x-route>(components/route/route.js) supportspath,exact,module(lazyimport()of the page component),auth-required(auth guard → redirects to/loginviaauthService), andmeta-title/meta-desc(drivesmetaServicefor SEO/a11y).- All routes and nav links are declared in
app/App.js'srender()— thenavSectionsarray (sidebar) and the<x-switch>block must be kept in sync.
- Create
components/pages/MyPage.jsextendingBaseComponent,customElements.define('page-my', MyPage)at the bottom (or usescripts/new-page.js). - Add an
<x-route ... module="./components/pages/MyPage.js"><page-my></page-my></x-route>inapp/App.js. - Add a matching entry to
navSectionsinapp/App.js. - Core/always-needed pages are statically imported in
index.js; everything else loads lazily via the route'smoduleattribute. (Static import of core pages is deliberate — it avoids dynamic-path resolution issues under GitHub Pages.)
- The app is served from a subpath;
index.htmlsets<base href="/plainvanillaweb/">. Use relative paths (./...) and resolve module/asset URLs againstdocument.baseURI(seeroute.js,doc-service.js). Hardcoded absolute paths will break the deployed site.
- Naming: custom element tags use prefixes —
x-*(app/framework),page-*(route pages),ui-*(reusable UI),app-*(app chrome). Service files arelib/<name>-service.jsexporting a singleton. - Styling: one global stylesheet
index.cssdrives theming via CSS variables (--primary-color,--surface-color,--bg-color, …) and[data-theme="dark"]selectors managed bythemeService. Component-scoped styles live in inline<style>withinrender(). When touching colors, verify both light and dark mode (a recurring source of bugs in git history). - Docs: the
docs/*.mdfiles are real runtime content —doc-service.jsfetches and parses them with a minimal Markdown parser at runtime for the/docspage. Significant architectural decisions are recorded as ADRs indocs/decisions/(runscripts/update-adr-index.jsafter adding one). - Lab demo pages live in
components/pages/lab/; Web Worker scripts inworkers/.