Skip to content

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:

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.ts exports:
  • ThemeId, a union type of the available preset ids ("fantasy" | "hacker")
  • DEFAULT_THEME: ThemeId set to "fantasy"
  • a registry of { id: ThemeId; label: string } entries
  • listThemes(): { id: ThemeId; label: string }[]
  • MapProject (in projects.server.ts) gains a theme: ThemeId field.
  • readProjectsFile() and normalizeProject() default-fill project.theme to DEFAULT_THEME when absent, alongside the existing poiTypes/ lineTypes/areaTypes fallback blocks.
  • createProject sets theme: DEFAULT_THEME on newly created projects.
  • updateProjectSettings's updates param gains an optional theme?: ThemeId, applied to the project when provided (omitted leaves the current value untouched, matching how previewImage is handled today).
  • Existing entries in data/projects.json remain valid with no migration.
  • pnpm typecheck passes.

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.css gains a .theme-fantasy { ... } rule, matching the existing .dark rule's variable values property-for-property (selecting "Fantasy" changes nothing visually).
  • app/app.css gains 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/.dark blocks, outside @layer base and @theme inline.
  • pnpm format passes.
  • Manual check: applying .theme-hacker to an element nested under the .dark body 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, and add-map.tsx, the existing outermost wrapper element has `theme-${project.theme}` appended via cn() from ~/lib/utils.
  • Each of the three loaders already returns project (or fetches it via getProject); no new data fetch is introduced to get project.theme.
  • No change to app/root.tsx or protected-layout.tsx — home.tsx, create.tsx, and login.tsx keep the plain .dark look.
  • 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/new pages only; / and /create are unaffected.
  • pnpm typecheck passes.

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/ SelectItem field named theme, populated from listThemes() and defaulting to project.theme — matching the existing tile-set picker pattern in add-map.tsx.
  • The read-only (non-editing) Settings view shows the current theme's label.
  • The route action reads theme from formData, validates it against listThemes() (an unrecognized value is rejected the same way a blank name is today, via { error: ... }), and passes it to updateProjectSettings.
  • Saving redirects to /project/:projectId as today, and the page reflects the new theme (via T3's class) with no extra manual reload.
  • pnpm typecheck passes.

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.