Light/Dark Theme Toggle

mandatory

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

  1. *wo modes only* Light and Dark. No "system/device" third option in the toggle button itself.
  2. *nitial theme selection*(first load / cleared localStorage):
    • Honor prefers-color-scheme from the OS.
    • If OS prefers dark → open in dark. Otherwise → open in light.
  3. *ser choice persistence* When the user clicks the toggle, save the choice to localStorage under key "theme" with value "light" or "dark". The saved value overrides the OS preference on subsequent visits.
  4. *S preference change propagation* If no user choice is saved, follow OS changes live via matchMedia('(prefers-color-scheme:dark)').addEventListener('change', ...).
  5. *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 :root for the light theme.
  • Use [data-theme="dark"] selector for dark theme variable overrides.
  • Set color-scheme: light on :root and color-scheme: dark on [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-theme attribute on <html>, saves to localStorage, 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:

  1. <head> contains the antiflash script with localStorage.getItem('theme') and `datatheme` setter.
  2. CSS contains [data-theme="dark"] selector.
  3. CSS contains color-scheme: light in :root and color-scheme: dark in [data-theme="dark"].
  4. HTML contains a toggle button referencing toggleTheme() or equivalent.
  5. JS contains toggleTheme and applyTheme functions with the required behavior.
  6. Two SVG icons with ids icon-sun and icon-moon (or equivalent).
  7. Uses matchMedia('(prefers-color-scheme:dark)').
  8. Saves to localStorage under 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

  1. *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-coding ThemeMode.dark or ThemeMode.light as a fixed default is forbidden.

  2. *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.

  3. *S preference change propagation* if no user choice is saved, follow OS changes live via

    WidgetsBindingObserver.didChangePlatformBrightness.

  4. *o FOWT* do not apply a hard theme before reading the saved preference; read and apply

    synchronously before the first build() by resolving preference in main() before runApp.

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:

  1. MaterialApp(themeMode: — must not be ThemeMode.light or ThemeMode.dark as a literal

    constant with no user-preference logic above it.

  2. SharedPreferences (or Hive/flutter_secure_storage) present when themeMode is dynamic.
  3. No ThemeMode.dark / ThemeMode.light hardcoded in main() 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 (#000) reserved for OLEDaware presets (iOS dark, Material 2 dark #121212).

  • *ontrast* every textonsurface 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.preset fallback (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.

  • ../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

Source: ../home/koder/dev/koder/meta/docs/stack/specs/themes/light-dark.kmd