Appearance
Core UI Features: Dark Mode, Split-Pane & Search
Part 3 of 5 in the Core UI Features series.
Three UI mechanics that look unrelated share one implementation habit worth naming up front: each stores its own small piece of persistent client state — a theme name, a split ratio, a search query — and each is implemented once and shared identically between web and desktop.
Dark and light mode
ThemeContext holds one string, "light" or "dark", persisted under the localStorage key ediky:theme. On first visit with nothing stored yet, it falls back to window.matchMedia("(prefers-color-scheme: dark)") — after that, the student's explicit choice always wins over the OS setting. The chosen theme is applied as a data-theme attribute on <html>, and every themed color in the app is a CSS custom property keyed off that attribute rather than a conditional class per component.
javascript
// Real shape of ThemeProvider
function ThemeProvider({ children }) {
const [theme, setTheme] = useState(() => {
const saved = localStorage.getItem("ediky:theme");
if (saved) return saved;
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark" : "light";
});
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("ediky:theme", theme);
}, [theme]);
const toggle = () => setTheme((t) => (t === "dark" ? "light" : "dark"));
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
}CSS variables beat conditional classes here for one concrete reason: a component never needs to know which theme is active. .card { background: var(--surface); color: var(--text); } is correct in both themes automatically — the alternative, className={theme === "dark" ? "card-dark" : "card-light"} on every styled element, would double the class surface of the app for no behavioral gain. One deliberate wrinkle: color-scheme is hardcoded to light in the base stylesheet, so native form controls (scrollbars, checkboxes) always follow the in-app toggle instead of the OS theme — without that pin, a user with OS dark mode and in-app light mode would see dark scrollbars on a light page.
rendering diagram…
drag to pan · ctrl / ⌘ + scroll to zoom
Notice step E: because theming is a CSS variable cascade, not React state threaded into every component, only the elements whose props actually depend on theme (like the toggle icon itself) re-render — the repaint is free.
Split-pane layout
The IDE and question-shell pages split the screen between a code editor and the problem statement using a fully custom SplitPane component built on useResizable — no react-resizable-panels, no split.js. The divider listens for pointerdown, then pointermove while dragging, computing the new ratio from cursor position relative to the container:
javascript
// Conceptual drag handler, based on the real useResizable hook
function onPointerMove(e, containerRect, orientation, min, max) {
const raw = orientation === "horizontal"
? (e.clientX - containerRect.left) / containerRect.width
: (e.clientY - containerRect.top) / containerRect.height;
return Math.min(max, Math.max(min, raw)); // clamp
} rendering diagram…
drag to pan · ctrl / ⌘ + scroll to zoom
The ratio persists to localStorage under a storageKey prop scoped to that specific pane — ediky.ide.split.dock.bottom and ediky.ide.split.tree are two different keys in the same IDE page, so the file-tree width and the console-dock height remember their positions independently rather than sharing one global "split preference." A double-click on the divider resets to that pane's initial ratio. The drag handle itself carries role="separator" and aria-orientation so a screen reader announces it as a resizable boundary, not decoration. Desktop reuses the identical component and, notably, persists to plain localStorage too — not SQLite — since a split ratio is view state, not data worth syncing across devices.
Full-text search
Two different search experiences exist for two different jobs. The navbar SearchBar is instant-as-you-type: a plain setTimeout debounce (120ms on web, 150ms on desktop) delays the query until typing pauses, and desktop's version additionally tags each request with a sequence number so a slow, stale response can never overwrite a newer one's results on screen. The full question-bank list page instead uses React's useDeferredValue on the filter input — keystrokes commit at full priority while the (potentially large) filtered list re-renders at background priority, so typing never stutters even over hundreds of rows.
javascript
// Debounced instant search (navbar)
function useDebouncedSearch(delayMs) {
const timer = useRef(null);
const [results, setResults] = useState([]);
const search = (query) => {
clearTimeout(timer.current);
timer.current = setTimeout(async () => {
setResults(await runSearch(query));
}, delayMs);
};
return { results, search };
}Where the two platforms genuinely diverge is underneath the debounce: web's search reads from Firestore, desktop's runs a Rust command (db_search) against a SQLite FTS5 virtual table, using snippet() to return pre-highlighted match fragments.
| Web (Firestore) | Desktop (SQLite FTS5) | |
|---|---|---|
| Typical latency | 100–500ms (network round trip) | ~5ms (local index) |
| Debounce | 120ms | 150ms |
| Works offline | No | Yes |
Desktop Features & Data Flows covers why FTS5 wins that comparison at the storage-engine level; this page is the client-side half of the same story.
Self-assessment
Word count for this page: 881 words.
← Previous: Notes System & Annotations→ Next: Notifications & Responsive Design