Light/Dark Theme Toggle
Tema claro/escuro para todas as UIs Koder (web, Flutter mobile/desktop, TV, landing pages): comportamento padrão pós-instalação (ThemeMode.system), persistência da escolha do usuário, anti-flash, CSS vars.
Spec — Light/Dark Theme Toggle
Applicability
All Koder apps and product landing pages: web apps, Flutter mobile/desktop apps, TV apps, and web landing pages.
Required Behavior
- *wo modes only* Light and Dark. No "system/device" third option in the toggle button itself.
- *nitial theme selection*(first load / cleared localStorage):
- Honor
prefers-color-schemefrom the OS. - If OS prefers dark → open in dark. Otherwise → open in light.
- Honor
- *ser choice persistence* When the user clicks the toggle, save the choice to
localStorageunder key"theme"with value"light"or"dark". The saved value overrides the OS preference on subsequent visits. - *S preference change propagation* If no user choice is saved, follow OS changes live via
matchMedia('(prefers-color-scheme:dark)').addEventListener('change', ...). - *nti-flash* The theme must be applied *efore*the first render via an inline script in the
<head>, before the CSS. This prevents the "flash of wrong theme" (FOWT) when the page first loads with dark mode saved but the default CSS is light.
Required CSS Structure
- Use *emantic CSS variables*in
:rootfor the light theme. - Use
[data-theme="dark"]selector for dark theme variable overrides. - Set
color-scheme: lighton:rootandcolor-scheme: darkon[data-theme="dark"]to adjust native browser controls (scrollbars, form elements).
:root {
--bg: #ffffff;
--text: #0f172a;
color-scheme: light;
}
[data-theme="dark"] {
--bg: #0b1120;
--text: #f1f5f9;
color-scheme: dark;
}Required HTML — Anti-Flash Script
Placed in <head> before the CSS link:
<script>
(function(){
const s = localStorage.getItem('theme');
const dark = s ? s === 'dark' : matchMedia('(prefers-color-scheme:dark)').matches;
if (dark) document.documentElement.setAttribute('data-theme','dark');
})();
</script>Required Toggle Button
- Lives in the navbar (for product landing pages, on the right side in
.nav-actions). - Two SVG icons inline: a sun (visible in light mode) and a moon (visible in dark mode).
- On click, toggles
data-themeattribute on<html>, saves tolocalStorage, and switches which icon is visible.
<button id="theme-toggle" onclick="toggleTheme()" aria-label="Toggle theme">
<svg id="icon-sun" ...><!-- sun --></svg>
<svg id="icon-moon" style="display:none" ...><!-- moon --></svg>
</button>Required JavaScript
function toggleTheme() {
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const next = isDark ? 'light' : 'dark';
localStorage.setItem('theme', next);
applyTheme();
}
function applyTheme() {
const saved = localStorage.getItem('theme');
const isDark = saved ? saved === 'dark' : matchMedia('(prefers-color-scheme:dark)').matches;
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
const sun = document.getElementById('icon-sun');
const moon = document.getElementById('icon-moon');
if (sun) sun.style.display = isDark ? 'none' : 'block';
if (moon) moon.style.display = isDark ? 'block' : 'none';
}
matchMedia('(prefers-color-scheme:dark)').addEventListener('change', () => {
if (!localStorage.getItem('theme')) applyTheme();
});
applyTheme();Deterministic Audit Checks
A /k-landing-audit or similar checker can verify compliance by grep/AST checks:
<head>contains the antiflash script withtheme` setter.localStorage.getItem('theme')and `data- CSS contains
[data-theme="dark"]selector. - CSS contains
color-scheme: lightin:rootandcolor-scheme: darkin[data-theme="dark"]. - HTML contains a toggle button referencing
toggleTheme()or equivalent. - JS contains
toggleThemeandapplyThemefunctions with the required behavior. - Two SVG icons with ids
icon-sunandicon-moon(or equivalent). - Uses
matchMedia('(prefers-color-scheme:dark)'). - Saves to
localStorageunder key"theme"with values"light"/"dark"only.
Flutter / Native Apps
The rules below apply to all Flutter apps (mobile, desktop) and any Koder native app.
Required behavior
- *hemeMode.system on first launch*(no stored preference): the app must open in the theme
that matches the OS setting (
Brightness.dark→ dark, otherwise → light). Hard-codingThemeMode.darkorThemeMode.lightas a fixed default is forbidden. - *ser choice persistence* when the user overrides the theme in Settings, save the choice
via
SharedPreferences(or equivalent) under key"theme"with value"light"or"dark". The saved value overrides the OS preference on subsequent launches. - *S preference change propagation* if no user choice is saved, follow OS changes live via
WidgetsBindingObserver.didChangePlatformBrightness. - *o FOWT* do not apply a hard theme before reading the saved preference; read and apply
synchronously before the first
build()by resolving preference inmain()beforerunApp.
SDK widget (approved implementation)
Use KoderTheme from engines/sdk/koder_kit (planned for v0.6.0). Until available, apply the pattern below — the SDK adoption ticket is engines/sdk/koder_kit/backlog/pending/NNN-koder-theme-widget.md.
Inline Flutter pattern (until SDK ships)
// main.dart
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getString('theme'); // 'light' | 'dark' | null
runApp(MyApp(initialTheme: saved));
}
class MyApp extends StatefulWidget {
final String? initialTheme;
const MyApp({super.key, this.initialTheme});
@override State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
late ThemeMode _mode;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_mode = _resolve(widget.initialTheme);
}
ThemeMode _resolve(String? saved) {
if (saved == 'dark') return ThemeMode.dark;
if (saved == 'light') return ThemeMode.light;
return ThemeMode.system; // follow OS when no preference saved
}
@override
void didChangePlatformBrightness() {
// Only reacts when no user preference is saved.
final prefs = SharedPreferences.getInstance(); // fire-and-forget check
prefs.then((p) { if (p.getString('theme') == null) setState(() {}); });
}
void setTheme(String choice) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('theme', choice);
setState(() { _mode = _resolve(choice); });
}
@override
Widget build(BuildContext context) => MaterialApp(
themeMode: _mode,
theme: ThemeData.light(),
darkTheme: ThemeData.dark(),
home: HomeScreen(onThemeChange: setTheme),
);
}Deterministic audit checks (Flutter)
/k-housekeep audit greps for:
MaterialApp(themeMode:— must not beThemeMode.lightorThemeMode.darkas a literalconstant with no user-preference logic above it.
SharedPreferences(orHive/flutter_secure_storage) present whenthemeModeis dynamic.- No
ThemeMode.dark/ThemeMode.lighthardcoded inmain()before preference is loaded.
Canonical Reference
platform/kdb/site/index.html — web reference implementation.
Per-preset dark variants
When a KDS preset (specs/themes/ui-style.kmd) defines a light token set, it MAY also define a themed-dark variant — applied via the class chain .themed-dark.preset.<slug> on the preset preview pane. Contract:
- *dentity preservation* accent color stays the same OS family
(Apple blue, Ubuntu orange, Breeze blue) unless the OS publishes a different dark-mode accent (Material 3 flips primary; Windows 11 brightens accent slightly).
- *urface layering* at least 2 distinct surface tones
(
--surface+--surface-2) so cards / nested panels remain separable. Trueblack backgrounds (aware presets (iOS dark, Material 2 dark#000) reserved for OLED#121212). - *ontrast* every text
onsurface pair MUST satisfy WCAG AA(4.5:1) at the default font size. The contrast checker (
/tools/contrast/) is the verification surface. - *allback* presets without an artisanal dark variant inherit
the generic
.themed-dark.presetfallback (sane defaults: dark surface, light text, accent unchanged). Not first-class but never a worsethanbroken render. - *aming* selector is
.themed-dark.preset.<slug>(NOT.preset.themed-dark.<slug>) to avoid creating phantom chunks in the css_split.go output.
Wave A (20260514, ticket tools/designgen#007): OSnative presets with externally documented dark palettes — macOS Sonoma, Windows 11, iOS Cupertino, Yaru, KDE Breeze, Material 3, Material 2 + the GNOME sample shipped earlier. Creative presets (cyberpunk_neon, synthwave, bauhaus, brutalist, …) wait for owner pick.
Related Specs
../landing-pages/products.kmd— product landing page structure (embeds this theme spec by reference)../landing-pages/areas.kmd— area landing page structure../koder-app/behaviors.kmd§7.2 — parent behaviors spec (references this file)ui-style.kmd— preset registry consumed by the per-preset dark contract above