Status: accepted (open questions resolved 2026-04-24) Date: 2026-04-24 Tracked in: TODO.md — Theme + Customizations redesign
ODS Pages today carries two adjacent concepts for visual style:
light and dark color variants and a
design block (radius, sizes, border, depth).This split has accumulated over time and now causes three concrete problems:
Two boxes in the wizard ask similar-looking questions. “Pick a theme” and “Customize branding” are not distinct enough that the user knows when to do which. The boundary is invisible to anyone who didn’t help write it.
Font family lives on OdsBranding, not on the theme. So a “theme”
can’t carry typography — switching from Abyss (atmospheric) to Acid
(bold) gives you new colors but the same font. Real themes carry
typography; ours don’t.
The font field is also a freeform text input (SettingsDialog.tsx:461) with placeholder “e.g., Inter, Georgia” — the user has to know font names by heart and there’s no preview.
Two flows save customizations differently:
localStorage under ods_branding_<appName> → per-browser,
per-user-on-device, lost on cache clear, not shared with teammates.For multi-user apps where an admin wants to set company branding once and have all users see it, there’s no path that doesn’t involve hand- editing the spec JSON.
Drop OdsBranding as a distinct shape. Everything visual lives on
theme + customizations (one concept the builder learns):
{
"theme": {
"base": "abyss", // named theme from catalog
"mode": "system", // light | dark | system
"headerStyle": "light", // moves here from branding
"overrides": { // per-token overrides
"primary": "#5B21B6", // colors
"fontSans": "Inter" // fonts (new)
}
}
}
logo and favicon aren’t visual style — they’re which app is
this. Lift them to the top level alongside the existing appName /
appIcon field:
{
"appName": "Sales Tracker",
"appIcon": "📊", // already exists
"logo": "...", // moves out of branding
"favicon": "..." // moves out of branding
}
Theme JSON files gain an optional fonts block:
{
"name": "abyss",
"design": { "radiusBox": ".5rem", ... },
"fonts": { // new
"sans": "Inter",
"serif": "Source Serif",
"mono": "JetBrains Mono"
},
"light": { "colors": { ... } }
}
Most catalog themes leave fonts unset (system default). A few
signature themes (e.g., business → professional grotesk; retro →
mono; abyss → atmospheric serif) ship with matching typography.
Customizations override theme fonts via
theme.overrides.fontSans etc. — same mechanism as colors.
The settings UI for fonts becomes a curated dropdown of system-safe
| Context | Where customizations persist |
|---|---|
| Single-user app | Spec |
| Multi-user app, admin signed in | Spec (all users see) |
| Multi-user app, regular user signed in | localStorage (personal view) |
| Wizard at create-time | Spec (already does this) |
Admin writes mutate the stored app spec via the existing data layer
(PocketBase on React, SQLite on Flutter). Regular-user writes use
the existing ods_branding_<appName> localStorage key (renamed to
ods_theme_<appName> for consistency).
theme object replaces two
competing top-level concepts.branding; each needs a hand-rewrite to use theme. No
parser shim — the legacy shape is dropped entirely (we’re pre-1.0,
so no external specs to break). ods-schema.json
and Themes/README.md
also need updates.branding needs to be updated. Currently only the
OdsBranding parser tests reference it directly, but the
appIcon reorganization will ripple through.OdsTheme JSON catalog files at
Specification/Themes/ need an
fonts field added (optional, defaults to nothing). Old theme
files keep working.ods_branding_* to ods_theme_* —
one-time read-and-rewrite on first load to avoid losing user
customizations.What I originally proposed. Cheaper but doesn’t fix the builder- confusion problem — the wizard still has two boxes asking similar questions, and the localStorage-only persistence gap stays open.
Drop OdsTheme as a name; make everything OdsBranding. Less
disruptive to existing code. Rejected because “theme” is the
better builder-facing word (“pick a theme” is more natural than
“pick a branding”), and the named-catalog concept is centered on
themes today.
Let any user pick from the theme catalog independently — admin sets the company palette but Bob can switch his to dark Abyss. Rejected for v1 of this redesign — adds complexity without a clear ask, and the localStorage tier already supports it implicitly (a user who overrides every token effectively switches themes).
{
"theme": "abyss",
"themeOverrides": { "primary": "#...", "fontSans": "Inter" }
}
Two adjacent fields rather than nested. Marginally more verbose to read; nesting feels right because the customization is conceptually “on top of” the chosen theme.
Resolved 2026-04-24 before acceptance.
Not pursuing. If it ever becomes a real ask, fold into the broader role-aware-config conversation rather than retrofitting onto themes.
Live-preview-while-editing is the better UX and we keep it. Concrete shape: edits apply to the running app immediately (DOM only); the “Save” action commits — to spec for admins, to localStorage for regular users. Discarding (closing without save) reverts to the last committed state.
mode: 'system' stays the recommended default.Mode follows OS preference by default. Per-user override stays
possible via localStorage (same tier as other regular-user
customizations). No special-casing needed — mode is just another
field in theme that follows the admin/user persistence rules.
Confirmed. Every model / parser / writer / UI change on the React side gets a mirror-edit on the Flutter side. The conformance scenario for theme customization runs against both drivers.
branding and rewrite all specs.Pre-1.0; no external specs to break; the in-repo specs are countable
(19 files: 4 examples, 13 templates, the JSON schema, the themes
README). Hand-rewriting them is faster than maintaining a parser
shim long-term and gives us cleaner code on day one. The wizard and
settings dialog stop emitting branding immediately.
| Piece | Where | Size |
|---|---|---|
New OdsTheme model with overrides field; drop OdsBranding |
Both frameworks’ models | ~1 day |
Theme JSON schema gains fonts; update Themes README |
Specification/Themes | ~half day |
| Update bundled themes that should ship with custom typography | Specification/Themes | ~few hours |
Move logo/favicon to top-level OdsApp |
Both frameworks’ models | ~half day |
| Font picker UI (curated dropdown + custom escape hatch) | React only | small |
| Admin-saves-to-spec write path | Both frameworks | ~1 day each |
Rewrite 4 examples + 13 templates to use theme block |
Specification/Examples + Templates | ~half day |
| Update ods-schema.json | Specification | small |
| Conformance: theme-customization scenario | TS + Dart | ~half day |
| Update spec.md, README, GLOSSARY | Docs | small |
Total: ~3-4 sessions across both frameworks. Lands as a single breaking spec change — no parser shim. Bumps to spec v0.2 (or whatever versioning convention we land on first).