All files / src/ui ThemeToggle.ts

92.95% Statements 66/71
83.33% Branches 25/30
100% Functions 9/9
92.95% Lines 66/71

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148                                              1x 1x 1x 1x       1x   1x 1x 1x 1x 1x                 105x 105x       89x 89x 68x 68x 89x 89x     89x   11x 11x 11x 8x 11x     11x               1x 18x 18x 11x 18x     1x 6x 6x 6x 6x   1x 1x 83x   1x 83x 83x 83x 83x 83x 83x 83x         14x 11x 11x 11x 11x 83x 83x 83x                                     1x 105x 105x 105x 105x 105x   1x 94x 94x         94x 94x 1x  
/**
 * Light / dark / system theme toggle.
 *
 * The stylesheet already answers `prefers-color-scheme`, so "system" is the
 * absence of an override rather than a third palette: the toggle writes
 * `data-theme` on `<html>` for the two explicit choices and removes it for
 * system. That keeps one source of truth for the colours, and a user who never
 * touches this control still gets the right one.
 *
 * The choice is a preference, not a train of thought, so it lives in
 * `localStorage` — unlike the table filters in `state/UiMemory`, which are
 * deliberately per-session.
 *
 * **An embed is fixed to light**, in `read()` rather than only in the button
 * that is absent from its bar (`embed.ts`, `app/App.ts`): a figure inside
 * somebody else's page has to render the same way for every reader, not go
 * dark because the reader's OS does, or because a stray keyboard shortcut
 * reaches a control that was never mounted. Guarding `read()` covers the
 * startup paint; the click handler below is the second half, for the one
 * remaining path to a change — the command palette's "theme" entry, which
 * still runs in an embed like every other bound command.
 */
 
import { el } from './dom'
import { KEYS, withKey } from './commands'
import { embedded } from '../embed'
import { icon, type IconName } from './icons'
 
export type ThemeChoice = 'light' | 'dark' | 'system'
 
const KEY = 'chromosomix:theme'
 
const ICON: Record<ThemeChoice, IconName> = {
  system: 'monitor',
  light: 'sun',
  dark: 'moon',
}
 
/**
 * What the machine is doing, where it can be asked.
 *
 * `false` where there is no `matchMedia` — a test environment, an old engine —
 * which makes the cycle `system → dark → light`, the order for the light
 * default the stylesheet itself falls back to.
 */
function osPrefersDark(): boolean {
  if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
  return window.matchMedia('(prefers-color-scheme: dark)').matches
}
 
function read(): ThemeChoice {
  if (embedded()) return 'light'
  try {
    const raw = window.localStorage.getItem(KEY)
    return raw === 'light' || raw === 'dark' ? raw : 'system'
  } catch {
    return 'system'
  }
}
 
function write(choice: ThemeChoice): void {
  try {
    if (choice === 'system') window.localStorage.removeItem(KEY)
    else window.localStorage.setItem(KEY, choice)
  } catch {
    /* a browser that refuses storage still themes correctly for this session */
  }
}
 
/**
 * Apply a choice to the document.
 *
 * Called once at startup *before* the app mounts, so a user who chose dark does
 * not get a flash of light while the module graph loads.
 */
export function applyTheme(choice: ThemeChoice): void {
  const root = document.documentElement
  if (choice === 'system') root.removeAttribute('data-theme')
  else root.setAttribute('data-theme', choice)
}
 
/** Restore the stored choice. Safe to call before the app mounts. */
export function initTheme(): ThemeChoice {
  const choice = read()
  applyTheme(choice)
  return choice
}
 
export class ThemeToggle {
  readonly el: HTMLButtonElement
  private choice: ThemeChoice
 
  constructor() {
    this.choice = read()
    this.el = el('button', {
      class: 'btn btn-icon',
      type: 'button',
      attrs: { 'aria-label': 'colour theme' },
    })
    this.el.addEventListener('click', () => {
      // Never mounted in an embed's own bar, but still reachable through the
      // "theme" command — which, like every other bound command, still runs
      // there. Without this the palette or its shortcut could flip a figure
      // dark despite `read()` seeding it light.
      if (embedded()) return
      this.choice = this.next()
      write(this.choice)
      applyTheme(this.choice)
      this.paint()
    })
    this.paint()
  }
 
  /**
   * The next choice in the cycle, ordered against the *machine's* scheme.
   *
   * A fixed `system → light → dark` order spends its first press on a choice
   * that changes nothing: on a light OS — which is most of them, and every
   * screenshot this app's gallery was taken on — pressing once selects `light`,
   * the page does not move, and the icon changes to a sun that now reads as a
   * claim that the page is *already* light. A reader presses the button, sees
   * nothing happen, and concludes it is broken or that there is no dark theme.
   *
   * So the first press goes to the *opposite* of what the machine is doing, the
   * second to the same as the machine (explicitly, which is what pins a figure
   * light on a dark laptop), and the third back to following it. Every
   * three-state cycle on a fixed OS has one visually silent step; this puts it
   * last, where the monitor icon coming back reads as "follow the OS again"
   * rather than as a control that did not work.
   */
  private next(): ThemeChoice {
    const dark = osPrefersDark()
    const opposite: ThemeChoice = dark ? 'light' : 'dark'
    if (this.choice === 'system') return opposite
    return this.choice === opposite ? (dark ? 'dark' : 'light') : 'system'
  }
 
  private paint(): void {
    this.el.replaceChildren(icon(ICON[this.choice]))
    this.el.title = withKey(`Theme: ${this.choice}. Click for ${this.next()}`, KEYS.theme)
    // The name a screen reader announces, not only the description: it was the
    // constant "colour theme", so three presses said the same four syllables
    // and the state was never spoken. `MapPanel`'s lock button mirrors its
    // title for the same reason.
    this.el.setAttribute('aria-label', this.el.title)
  }
}