Project themes: implementation plan¶
Goal¶
Let each map project select a named visual theme (color palette) from a fixed registry, applied whenever that project's own pages are viewed — without affecting the home page, project creation, or any other project.
Source: project-themes.md.
Decisions¶
| Decision | Choice | Why |
|---|---|---|
| Theme count for v1 | fantasy (default, identical to today's look) + one distinct second theme (hacker) |
Confirmed with user — proves the mechanism without designing a real "space" palette, since the doc's space example is a verbatim copy of fantasy |
| Registry shape | Fixed, static list of named presets (app/features/themes/themes.ts) |
Confirmed with user — mirrors the existing static registry in tile-sets.ts; no per-project custom colors in this task |
| Scope of application | Only the three routes nested under /project/:projectId (project.tsx, project-map.tsx, add-map.tsx) |
Confirmed with user — home.tsx, create.tsx, login.tsx keep the current default look |
| How the class is applied | theme-{id} class added to each route's own outer wrapper <div> |
There is no layout scoped to just the project routes — all three sit as flat siblings under protected-layout.tsx alongside home.tsx/create.tsx, so a shared wrapper would be new infrastructure beyond this task's scope |
Relationship to .dark |
app/root.tsx keeps its unconditional className="dark" on <body>, unchanged |
.dark remains the app-wide base; a nested .theme-{id} class overrides inherited CSS custom properties for that subtree only, which is enough for fantasy (identical values) and hacker (overridden values) — no cookie/session toggle needed since this is per-project data, not a user preference |
| Picker location | Existing Settings tab form in project.tsx | Confirmed with user |
| Default for existing projects | theme defaults to "fantasy" when absent |
Matches the doc; follows the same default-fill pattern already used for poiTypes/lineTypes/areaTypes in readProjectsFile/normalizeProject |
Prerequisites¶
None. MapProject is stored as an opaque JSON blob (map_projects.data in
Directus, or data/projects.json locally) — no schema migration is
required to add a field.
Scope¶
In:
- app/features/themes/themes.ts (new) — theme registry
- app/lib/projects.server.ts —
themefield, default-fill,updateProjectSettings - app/app.css —
.theme-fantasyand.theme-hackervariable blocks - app/routes/project.tsx — theme class on wrapper + Settings picker
- app/routes/project-map.tsx, app/routes/add-map.tsx — theme class on wrapper
Out:
- Arbitrary/custom per-project color editing
- Any theme beyond
fantasy/hacker - Changes to
app/root.tsx,protected-layout.tsx,home.tsx,create.tsx,login.tsx - A light/dark user preference toggle (unrelated to this task)
Tasks¶
Ordered so each step type-checks and is shippable on its own; later steps depend on earlier ones.
T1 — Theme registry and data model¶
Goal: Introduce a fixed, named set of theme presets and a theme
field on MapProject, defaulting existing and new projects to fantasy.
Acceptance criteria:
app/features/themes/themes.tsexports:ThemeId, a union type of the available preset ids ("fantasy" | "hacker")DEFAULT_THEME: ThemeIdset to"fantasy"- a registry of
{ id: ThemeId; label: string }entries listThemes(): { id: ThemeId; label: string }[]MapProject(inprojects.server.ts) gains atheme: ThemeIdfield.readProjectsFile()andnormalizeProject()default-fillproject.themetoDEFAULT_THEMEwhen absent, alongside the existingpoiTypes/lineTypes/areaTypesfallback blocks.createProjectsetstheme: DEFAULT_THEMEon newly created projects.updateProjectSettings'supdatesparam gains an optionaltheme?: ThemeId, applied to the project when provided (omitted leaves the current value untouched, matching howpreviewImageis handled today).- Existing entries in
data/projects.jsonremain valid with no migration. pnpm typecheckpasses.
T2 — Theme CSS palettes¶
Goal: Author the CSS custom-property overrides for each registry
theme, scoped under a .theme-{id} class, so nesting one inside the
existing .dark subtree recolors it.
Acceptance criteria:
app/app.cssgains a.theme-fantasy { ... }rule, matching the existing.darkrule's variable values property-for-property (selecting "Fantasy" changes nothing visually).app/app.cssgains a.theme-hacker { ... }rule defining the same set of variable names as.dark, differing at minimum in--background,--foreground,--primary,--card, and--border(a black/terminal aesthetic, per the doc's example) so no variable falls back unstyled.- Both rules are placed near the existing
:root/.darkblocks, outside@layer baseand@theme inline. pnpm formatpasses.- Manual check: applying
.theme-hackerto an element nested under the.darkbody class visibly overrides its colors in devtools.
T3 — Apply the theme class on project-scoped routes¶
Goal: Each of the three routes nested under /project/:projectId
renders its outer wrapper with a theme-{project.theme} class.
Acceptance criteria:
- In
project.tsx,project-map.tsx, andadd-map.tsx, the existing outermost wrapper element has`theme-${project.theme}`appended viacn()from~/lib/utils. - Each of the three loaders already returns
project(or fetches it viagetProject); no new data fetch is introduced to getproject.theme. - No change to
app/root.tsxorprotected-layout.tsx—home.tsx,create.tsx, andlogin.tsxkeep the plain.darklook. - Manual check: once T4 ships, switching a project's theme visibly
recolors that project's
/project/:id,/project/:id/map/:mapId, and/project/:id/map/newpages only;/and/createare unaffected. pnpm typecheckpasses.
T4 — Settings UI: theme picker¶
Goal: The Settings tab in project.tsx lets a user pick a theme from
the registry and persist it.
Acceptance criteria:
- The Settings edit form gains a
Select/SelectTrigger/SelectContent/SelectItemfield namedtheme, populated fromlistThemes()and defaulting toproject.theme— matching the existing tile-set picker pattern inadd-map.tsx. - The read-only (non-editing) Settings view shows the current theme's label.
- The route
actionreadsthemefromformData, validates it againstlistThemes()(an unrecognized value is rejected the same way a blanknameis today, via{ error: ... }), and passes it toupdateProjectSettings. - Saving redirects to
/project/:projectIdas today, and the page reflects the new theme (via T3's class) with no extra manual reload. pnpm typecheckpasses.
Verification¶
pnpm typecheck and pnpm format after each step; pnpm build once all
four are in place. There is no test suite — the manual checks called out
above are the acceptance mechanism for visual behavior.