Dark mode
The Portal uses class-based dark mode with .dark
on the <html> element,
persisted to localStorage
and a cookie. A circle-reveal animation uses the View Transitions API.
Toggle pattern
The dark mode toggle lives in the navbar's Alpine.js x-data
object. It reads the initial state from localStorage,
falls back to the cookie, then falls back to the system preference. On toggle, it updates
localStorage, the cookie, and the
.dark class.
// Navbar x-data initialisation
darkMode: (function() {
var ls = localStorage.getItem('color-theme');
if (ls) return ls === 'dark';
var m = document.cookie.match(/(?:^|;\s*)color-theme=(\w+)/);
if (m) return m[1] === 'dark';
return window.matchMedia('(prefers-color-scheme: dark)').matches;
})(),
toggleDarkMode(event) {
var self = this;
var apply = function() {
self.darkMode = !self.darkMode;
if (self.darkMode) {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
document.cookie = 'color-theme=dark;path=/;max-age=31536000;secure;samesite=lax';
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
document.cookie = 'color-theme=light;path=/;max-age=31536000;secure;samesite=lax';
}
};
if (!document.startViewTransition || !event) {
apply();
return;
}
// Circle reveal animation (see below)
var btn = event.currentTarget || event.target;
var rect = btn.getBoundingClientRect();
var cx = rect.left + rect.width / 2;
var cy = rect.top + rect.height / 2;
var maxX = Math.max(cx, window.innerWidth - cx);
var maxY = Math.max(cy, window.innerHeight - cy);
var maxRadius = Math.ceil(Math.sqrt(maxX * maxX + maxY * maxY));
document.documentElement.style.setProperty('--toggle-x', cx + 'px');
document.documentElement.style.setProperty('--toggle-y', cy + 'px');
document.documentElement.style.setProperty('--toggle-r', maxRadius + 'px');
document.startViewTransition(function() { apply(); });
}
Circle reveal animation
When document.startViewTransition
is available (Chromium browsers), the dark mode toggle triggers a circle-reveal animation.
The animation expands a clip-path: circle()
from the toggle button's position outward to fill the viewport. It falls back to an instant switch in unsupported browsers.
/* Assets/app.css - View Transitions API circle reveal */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-new(root) {
animation: dark-mode-reveal 0.5s ease-in-out;
}
@keyframes dark-mode-reveal {
from {
clip-path: circle(0px at var(--toggle-x) var(--toggle-y));
}
to {
clip-path: circle(var(--toggle-r) at var(--toggle-x) var(--toggle-y));
}
}
Tailwind CSS custom variant
Tailwind CSS v4 uses a custom variant to enable class-based dark mode instead of the default
prefers-color-scheme media query.
This is defined in Assets/app.css.
/* Assets/app.css */ @custom-variant dark (&:where(.dark, .dark *)); /* This means dark: utilities activate when .dark is on the html element */ /* or on any ancestor element. */ /* Usage: always pair light + dark classes */ <div class="bg-white dark:bg-gray-800">...</div> <p class="text-gray-900 dark:text-white">...</p>
Flash prevention
A blocking <script>
in the <head>
applies the .dark class
before the first paint to prevent a flash of light mode content.
<!-- In <head> before any CSS/content -->
<script>
if (localStorage.getItem('color-theme') === 'dark' ||
(!localStorage.getItem('color-theme') &&
window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
</script>
Writing dark mode classes
Every visual property that changes between light and dark mode must have both a light and
dark: variant.
Never write a light-only background, border, or text colour on interactive or structural elements.
Card in current theme
Secondary text adapts too.
Subtle background variant
Used for sidebars and table headers.
Common light/dark class pairs
Reference table of the most common class pairs used across the app.
| Purpose | Light class | Dark class |
|---|---|---|
| Page background | bg-[#FAF8F3] |
dark:bg-gray-900 |
| Card background | bg-white |
dark:bg-gray-800 |
| Input background | bg-white |
dark:bg-gray-700 |
| Primary border | border-gray-200 |
dark:border-gray-700 |
| Input border | border-gray-200 |
dark:border-gray-600 |
| Heading text | text-gray-900 |
dark:text-white |
| Body text | text-gray-900 |
dark:text-gray-300 |
| Secondary text | text-gray-500 |
dark:text-gray-400 |
| Muted text | text-gray-400 |
dark:text-gray-500 |
| Sidebar / table header bg | bg-gray-50 |
dark:bg-gray-800/50 |
| Hover background | hover:bg-gray-100 |
dark:hover:bg-gray-700 |
| Even row stripe | even:bg-gray-50 |
dark:even:bg-gray-800/50 |
Chrome extension sync
The Portal supports syncing dark mode with a Chrome extension. When the extension sets a
koala-extension-active cookie,
the navbar polls the color-theme
cookie every 2 seconds and updates the UI if it differs from the current state.
// Navbar x-init — polls cookie for extension changes
setInterval(() => {
if (!document.cookie.includes('koala-extension-active')) return;
var m = document.cookie.match(/(?:^|;\s*)color-theme=(\w+)/);
var cookieTheme = m ? m[1] === 'dark' : null;
if (cookieTheme !== null && cookieTheme !== darkMode) {
darkMode = cookieTheme;
if (darkMode) {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
}
}
}, 2000);