cbn-guide is a Svelte 5 + Vite 7 application that renders Cataclysm: Bright Nights data from external JSON snapshots. The app is mostly a reader of a very large truth that lives elsewhere. Its job is to load that truth, index it once, and let the URL decide what should be visible.
Core constraints:
- The main data blob is large (
all.jsonis roughly 30 MB), so the app prefers coarse reload boundaries over clever incremental mutation. - The URL is the source of truth for routing and user-visible configuration.
- Svelte 5 runes are the reactive model.
Useful companion docs:
- Node.js: 24 recommended.
- pnpm: 10.x
- Python: 3.x for image/font generation scripts such as
gen-ogimage.pyandgen-unifont.py jq/jaq: strongly recommended for inspecting_test/all.jsonwithout grepping the void
-
Install dependencies:
pnpm install
-
Fetch fixtures for local testing and data inspection:
pnpm fetch:fixtures
For nightly fixtures:
pnpm fetch:fixtures:nightly
-
Start the development server:
pnpm dev
-
Long-lived shell
App.sveltestays mounted and owns startup, routing sync, search input state, metadata updates, mod selector UI, and tileset persistence. -
Route-keyed detail/catalog views
Thing.svelteandCatalog.svelteare rendered behind a{#key item}block inApp.svelte. When the route changes, they are destroyed and recreated. They should treat props as mount-time inputs, not as a stream to diff against. -
Fine-grained search results
SearchResults.svelteis intentionally not wrapped in a{#key}block. Search updates are frequent, and preserving DOM state is cheaper than remounting while the user types.
The core data flow, including bootstrap sequencing and runtime asset loading, is documented in detail in docs/architecture.md#core-data-flow.
The app reads route state, preferences, and builds data to form an effective navigation context, which in turn triggers data loads and UI updates.
- Version, language, tileset, and mod changes can imply a different dataset or asset universe. The code accepts this and uses hard reload boundaries where needed.
- Item and catalog pages are keyed so they can stay simple. The route change is the reset mechanism.
- Search stays unkeyed because remounting on every keystroke would waste work and disrupt the interface.
This codebase is on Svelte 5 runes.
Use $state for mutable local UI state.
Real examples:
App.svelte:scrollY,builds,resolvedVersion, modal state, metadataLimitedList.svelte:expandedsearch-state.svelte.ts: internal reactive search state object
Use $derived for pure computed values with no side effects.
Real examples:
App.svelte:itemprojected from navigation contextSearchResults.svelte:resultsandmatchingObjectsListLimitedList.svelte:initialLimitandrealLimit
Do not write to stores, touch the DOM, or mutate state inside $derived.
Use effects only for imperative synchronization.
Real examples:
App.svelte: sync route changes into localsearchApp.svelte: update document title and meta descriptionApp.svelte: callsearchState.sync(search, $data)App.svelte: schedule derived-cache prewarming withrequestIdleCallback
Component props should be typed explicitly.
<script lang="ts">
import type { CBNData } from "./data";
interface Props {
data: CBNData;
search: string;
}
let { data, search }: Props = $props();
</script>Do not use untyped $props() destructuring.
Svelte 5 snippets are the preferred way to pass list/item rendering behavior.
Real examples:
LimitedList.svelteCatalog.svelteSearchResults.svelte
Pattern:
<LimitedList items={results} limit={25}>
{#snippet children({ item })}
<ItemLink type="item" id={item.id} />
{/snippet}
</LimitedList>Inside the reusable component:
<li>{@render children?.({ item })}</li>Thing.svelte, Catalog.svelte, and several type views use untrack(...) to freeze props at mount time. This is deliberate. In keyed pages, the route remount is the update boundary.
Use untrack when:
- the component is mounted under a
{#key}route boundary - you want a stable local value or context input for that mount
Do not add effects that mirror props back into local state inside those keyed pages.
Avoid these:
- Svelte 4-style
$:prop mirroring - side effects inside
$derived setContext(...)inside a reactive effect- assuming all route-driven views are keyed;
SearchResults.svelteis intentionally not
| File | Responsibility | Important side effects |
|---|---|---|
App.svelte |
Holds long-lived UI state, chooses top-level view to render based on navigation context | Reacts to navigation changes by loading data, updates document metadata, syncs search |
routing.svelte.ts |
URL parsing, building URLs, raw history synchronization | uses history.pushState and history.replaceState |
navigation.svelte.ts |
Effective navigation context and app link policy | Integrates routing, preferences, and builds; exposes navigation actions |
data.ts |
Orchestrates data load, locale fallback, mod merge, and flattening for CBNData |
Calls data-loader.ts to fetch external JSON, replaces the global data store |
search-state.svelte.ts |
Search indexing and debounced result production | rebuilds index when CBNData changes, debounces search by 150ms outside tests |
Thing.svelte |
Renders a single object view | sets data context once per mount |
Catalog.svelte |
Renders a type catalog grouped by domain-specific rules | sets data context once per mount |
SearchResults.svelte |
Renders grouped search results without route-keyed remounting | derives from searchState.results or injected results |
LimitedList.svelte |
Reusable truncated-list UI using snippets | expands to full list in tests by using Infinity |
| State | Type | Owner | Scope | Notes |
|---|---|---|---|---|
| Navigation Context | derived state | navigation.svelte.ts |
global | Combines route, prefs, and builds logic |
data |
writable Svelte store | data.ts |
global | replaced wholesale when a new dataset is loaded |
tileData |
store/helper module | tile-data.ts |
global | updated from App.svelte when tileset changes |
searchState |
rune-based singleton | search-state.svelte.ts |
global | owns debounced query results |
search |
local rune state | App.svelte |
shell | synced from URL and user input |
item |
$derived |
App.svelte |
shell | projected from navigation context via helper |
| Build Metadata | local rune state | builds.svelte.ts |
global | Resolves aliases like stable and nightly |
expanded |
local rune state | LimitedList.svelte |
component | UI-only disclosure state |
Routing is deeply layered integrating URL state, browser preferences, build metadata, and a derived navigation context. For comprehensive details on navigation rules, link policy, and ownership, see docs/routing.md.
SPA navigation happens when the destination keeps the same active data context (version, locale, mods).
Mechanisms, exposed primarily by navigation.svelte.ts:
navigateTo(...): move to an item, catalog or search view.updateSearchRoute(...): updates query string without full reload for immediate search UX.- Internal link clicks are intercepted where possible if data context doesn't change.
Full reloads are used when the navigation intent changes the required dataset:
- version changes
- language changes
- mod changes
Such changes are handled by dedicated actions in navigation.svelte.ts which ensure a full navigation trigger.
Never grep _test/all.json. Use jq.
Examples:
# Inspect one object
jq '.data[] | select(.id=="rock" and .type=="item")' _test/all.json
# List IDs for a type
jq '.data[] | select(.type=="item") | .id' -r _test/all.json- Raw game JSON often uses
copy-from; missing fields may live in a parent object. CBNDatahandles flattening and indexing after fetch.- Locale fallback is explicit: if a requested locale is missing,
data.loadData(...)falls back to English andApp.svelteshows a warning. - Active mods come from the URL, but unknown mod IDs are removed after the loaded dataset resolves the real active mod list.
Prefer targeted tests first. Full render regressions are expensive and should be chosen because the change deserves them, not because anxiety asked for a sacrifice.
-
Tiny/localized change:
pnpm test:changed --maxWorkers=50% --bail 1
-
Normal feature or bugfix:
pnpm lint pnpm check pnpm test:fast
-
Cross-cutting data-model, routing, or rendering change:
pnpm lint pnpm check pnpm gen:mod-tests pnpm vitest run src --maxWorkers=50% --bail 1
pnpm lint: runsprettier -c .pnpm lint:fix: runsprettier -w .pnpm check: runspnpm check:typespnpm check:types: runssvelte-check && tsc --noEmit
pnpm test: runslint,check,gen:mod-tests, thentest:fullpnpm test:full: runsvitest run srcpnpm test:fast: excludesall.*.test.tsand__mod_tests__/**pnpm test:render:core: runs only the core render regression filespnpm test:render:mods: runs only generated mod render testspnpm test:changed: runslint,check, thenvitest run --changed --run
all.*.test.ts: renders large slices of the dataset to catch runtime/template failuresrouting.test.ts: routing and URL behaviorschema.test.ts: schema validation against upstream data changesdata.test.ts:CBNDatabehaviorsearch.test.ts: search rendering and behavior__mod_tests__/mod.*.test.ts: generated per-mod render isolation tests
Why generated mod tests exist:
- rendering the mod matrix in a single worker is memory-heavy
pnpm gen:mod-testscreates one Vitest file per mod- isolated workers give memory a chance to die with dignity between runs
pnpm fetch:fixtures: fetch default fixtures for local dev and testspnpm fetch:fixtures:nightly: fetch nightly fixturespnpm fetch:builds: fetchbuilds.jsonpnpm fetch:icons: fetch or render icon assetspnpm gen:css: generate palette CSSpnpm gen:sitemap: generatepublic/sitemap.xmlpnpm gen:ogimage: generate the Open Graph imagepnpm gen:unifont: subset Unifont for the current data
pnpm bench:nodepnpm bench:browserpnpm bench:browser:batchpnpm bench:report
pnpm i18n:push: push extracted UI stringspnpm i18n:download: download existing translations to local JSONpnpm i18n:upload: upload updated translations from local JSON
Example workflow:
TRANSIFEX_API_TOKEN='1/...' pnpm i18n:download --out='./tmp/transifex-download'
# translate JSON files with your workflow
TRANSIFEX_API_TOKEN='1/...' pnpm i18n:upload --dir='./tmp/transifex-download'Important boundary:
- Transifex extraction only sees literal
t("...")calls - dynamic expressions such as
t(variable)do not create new extractable keys
Use $state in the component that owns the interaction.
Good:
- disclosure state
- modal open/closed state
- loading spinners for a local async action
Bad:
- mirroring route props into local state inside
Thing.svelteorCatalog.svelte
Use $derived when the value is a pure function of other state.
Good:
- filtered lists
- grouped search results
- derived limits or labels
Bad:
- DOM writes
- store writes
- async work
- Decide whether the behavior belongs to the long-lived shell or a keyed route page.
- If it belongs to
ThingorCatalog, prefer mount-time setup anduntrack(...). - If it belongs to the shell, react to
$page,search, or$datawith$effect.
- Put indexing/search logic in
search-engine.tsorsearch-state.svelte.ts. - Keep
SearchResults.sveltefocused on grouping and rendering. - Use snippets and
LimitedList.sveltefor repeated item rendering. - Do not wrap the whole search results tree in a
{#key search}block.
Use actions from navigation.svelte.ts rather than touching history directly or hardcoding href changes. This ensures data context and history state are managed correctly according to the rules in docs/routing.md.
- Use
tfrom@transifex/nativefor UI strings - Use
i18n/game-locale.tsfor game-data translations - Keep extraction constraints in mind: literal
t("...")strings are safest
If the change alters reload boundaries, routing authority, data lifetime, or mod resolution semantics, add or update an ADR in docs/adr/.
SearchResults.svelteis not keyed. Advice that assumes all top-level route views are remounted is wrong.datais replaced wholesale when a new dataset is loaded. Code that assumes incremental mutation of the active dataset will eventually lie to you.- Search is debounced by
150msoutside tests and by0msin tests. LimitedList.svelteexpands toInfinityduring tests so hidden render failures do not evade the suite.- Malformed URLs are canonicalized and rewritten using
history.replaceStatebefore the app consumes them, managed byrouting.svelte.tsalongside build metadata. - Local storage access for tileset preference is wrapped in
try/catchbecause browser security modes can deny it.