Theme toggle
Light, dark, or follow the system — the one drawn form of useGdTheme. It shows where you are rather than where you would go, and it is deliberately only half the job: a pinned theme also needs a stamp in the document head.
Three states, cycled
System → Light → Dark → System. The order belongs to useGdTheme; the button draws it and does not get a vote.
Three rather than two, because System is a real answer and it is the default. It follows the operating system; only choosing light or dark stamps data-gd-theme and pins it. A two-state switch has no way to say "follow the OS", so it silently overrides that preference on first paint — which is the exact thing someone who set their machine to dark at 11pm is trying to avoid.
The composable shipped first, and every surface that wanted a switch re-typed the same four lines around it: a ghost button, the composable's icon, its label, and a media query to hide that label on a phone. Four lines is exactly the amount of code that drifts.
It shows where you are
The caption is the current theme, never the next one. A control captioned "Dark" that puts you in light mode is the oldest bug in this pattern.
The destination rides in the accessible name instead, which is where it belongs: Theme: System. Switch to light reads as a state plus a consequence, and it is re-read on every press because the name changes with the state.
The narrow bar
The label is the first thing to go. A navbar at 380px is fighting for room with the brand and the account, and the icon carries the meaning on its own.
- The breakpoint is not a prop. 767px lives in the component so it is one number for every app — the docs chassis used to hide its label below 900px, and the next app to add a switch would have picked its own number.
compactforces the icon-only form at every width, for a bar that is tight whatever the window is doing — the map's, which also carries a council chip.sizetakesxs,sm(the default) ormd: it is a ghost button underneath, so it sizes like one.- The caption is hidden, not dropped. What the button announces is its accessible name, which does not change either way — so losing the visible label costs a screen reader nothing.
The half that lives in the head
Stamping a pinned theme before first paint is a document-head concern, and the component deliberately does not pretend otherwise.
// app/app.vue — once, for the whole app.
// GD_THEME_BOOT comes from @gridd/ui, auto-imported with the layer.
useHead({
script: [{ innerHTML: GD_THEME_BOOT, tagPriority: "critical" }]
}) A toggle mounted in the body is already too late. The choice is read on mount, and on mount is after hydration — which is after paint — so a dark-pinned page renders its light HTML first, every single refresh. GD_THEME_BOOT is the inline script that closes that gap: tiny, run at critical priority so it lands ahead of the stylesheets, and wrapped in a try/catch because localStorage can throw in a private window and a theme is never worth an error.
Only a pin needs it. System needs no script at all — the media query in the stylesheet decides before paint by construction, and the attribute is the only thing that overrides it.
Sites on the docs layout already have it — the layout injects the script, which is why every page here survives a refresh in dark. For what the pin actually switches — the surface ladder, the re-derived status hues — see Dark mode.