Theme & Storage¶
Two small but load-bearing core modules: theme.ts (colors + UI vars → CSS variables) and storageKey.ts (namespaced localStorage). The user-facing side of theming is on the Theme page.
Theme — core/theme.ts + core/themes.ts¶
applyTheme(settings.theme)¶
Takes the user's app/theme.ts ({ preset, colors, widgetTitleColor }) and writes every color token to a --color-<token> CSS variable on :root. Each token's value is a Tailwind color name ('indigo-700') resolved to that palette's variable (var(--color-indigo-700)), so both Tailwind utilities (bg-primary, bg-panelBg) and direct var(--color-*) CSS read the live value.
Resolution: { ...DEFAULT_COLORS, ...THEMES[preset].colors, ...theme.colors } — kit default ← preset ← user override, so nothing is required.
DEFAULT_COLORS(theme.ts) is theconnesspreset — it isn't duplicated in the registry.THEMES(themes.ts) holds every other preset as{ mode, colors }, each a complete palette rather than a partial patch.themes.tsdeliberately never importstheme.ts— an early version did, and the circular import could leaveDEFAULT_COLORSundefined at load.
Two sentinels keep relationships live:
- A token whose value is another token name (
'primary','surface','panelBg','text') points at that token's var. Sosave: 'primary'tracks whateverprimaryis set to, and thedarkpreset'stopBarBg: 'panelBg'makes the bar match the panels. 'primaryDarker'emits acolor-mix(in srgb, var(--color-primary) 85%, black)— a hover shade that's "primary, ~15% darker" regardless of which shade primary is. Used for action/save hovers.
applyTheme also writes the preset's declared mode to <html data-theme="light|dark"> and to color-scheme. Plain CSS keys off it for anything a token can't reach — MapLibre's control glyphs get filter: invert(1) hue-rotate(180deg) in dark mode, measure's data-URI dropdown arrow swaps to a light one, and --floating-icon-color softens. color-scheme makes browser-native controls (scrollbars, <select> menus) render dark for free. Keying off mode rather than the preset's name means any new dark preset gets this behavior automatically.
applyTheme runs in main.tsx before the boot try/catch exists, so it never throws — an unknown preset silently falls back to conness there. The real error comes a moment later from validateThemeConfig, called from AppShell.tsx alongside the other config validators.
Adding a token means updating four places: ThemeColors (types.ts), DEFAULT_COLORS, every preset in THEMES, and the @theme block in index.css. @theme provides the boot defaults (so the first paint is correct) and is what makes Tailwind generate the utility — a token missing from @theme has no bg-<token> class.
Adding a preset is one complete { mode, colors } entry in THEMES plus the ThemeName union in types.ts; the validator's list of valid names is built from THEMES' keys. Keep primary, danger, success, save, actionActive and any colored topBarBg dark enough for white text.
applyUiVars(ui, isMobile)¶
Writes non-color layout variables, kept separate from applyTheme to make clear these aren't brand colors:
--floating-control-sizeand--top-bar-button-size(+ a derived--top-bar-icon-sizeat 50%) — both switch to theirmobileSizebelow the breakpoint, which is why this is re-called whenisMobileflips.--widget-button-cursor— fromui.buttonCursor.--top-bar-shadow-height/--top-bar-shadow-opacity— the map-window drop shadow;strength(0–1, clamped) scales both. Height collapses to 0 when disabled.
Both are called synchronously in main.tsx before the first React render, so there's no color/size flash on boot.
Why map paint is different¶
MapLibre style values (layer paint) can't read CSS variables, so the token system doesn't reach the map canvas. Draw and Measure's geometry colors and their amber hover highlight are deliberately fixed hex values — map content that needs many distinct colors and must read on any basemap. Where a map color should follow the theme, read the token's computed value at runtime instead of var(): the coordinates widget does this for its marker (getComputedStyle(document.documentElement).getPropertyValue('--color-primary')).
Widget launcher icons¶
shell/WidgetIcon.tsx renders each widget's SVG as a CSS mask-image filled by background-color, so the file's own color is irrelevant (image masks use alpha) and one file works on every surface and preset. Two non-obvious details are commented in the code:
- The URL is quoted —
url("${src}"). Vite inlines SVGs under 4 KB as data URIs and rewrites"to'; single quotes are illegal in an unquoted CSSurl(), which silently drops the mask. - The span is
display: block— an inline box sits on the text baseline and reserves descender space, pushing the icon off-center in a non-flex button.
Storage — core/storageKey.ts¶
Every localStorage key the app writes routes through one helper so the namespace can't drift between modules and one setting controls all of it.
<appId> is branding.appId if set, else a slug of branding.title. Consumers:
| Consumer | Key |
|---|---|
| Bookmarks | storageKey('bookmarks') |
| Drawing sets | storageKey('drawings') |
| Info-dismiss | storageKey('info-dismissed', <content-hash>) |
The info-dismiss key embeds a hash of info.md's content as the suffix, so editing the file invalidates a prior "don't show again" and re-greets returning users.
This matters when several kit apps are served from one origin (e.g. multiple demos on one Pages site) — distinct appIds keep their saved state from colliding.