diff --git a/src/components/devtools/AppShell.tsx b/src/components/devtools/AppShell.tsx
index 34d9851..1c5d71e 100644
--- a/src/components/devtools/AppShell.tsx
+++ b/src/components/devtools/AppShell.tsx
@@ -1,6 +1,7 @@
import { useRef, type KeyboardEvent, type ReactNode } from "react";
-import { Github, Instagram, Linkedin, Send } from "lucide-react";
+import { Github, Instagram, Linkedin, Moon, Send, Sun } from "lucide-react";
import { SiteSearch } from "./SiteSearch";
+import { useTheme } from "@/components/theme-provider";
export type Tab = "timeline" | "loop" | "console";
@@ -54,8 +55,10 @@ export function AppShell({
children: ReactNode;
}) {
const { navRef, onKeyDown } = useTablistKeyboard(tab, onTabChange);
+ const { theme, toggleTheme } = useTheme();
const activeId = `tab-${tab}`;
const panelId = "panel-main";
+ const nextTheme = theme === "dark" ? "light" : "dark";
return (
@@ -73,6 +76,14 @@ export function AppShell({
the language, its eras, and the loop that runs it
+
executes in a sandboxed iframe · never eval{" "}
on this page
diff --git a/src/components/devtools/CodeBlock.tsx b/src/components/devtools/CodeBlock.tsx
index d677168..8692f5e 100644
--- a/src/components/devtools/CodeBlock.tsx
+++ b/src/components/devtools/CodeBlock.tsx
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import type { Highlighter } from "shiki";
import { CopyButton } from "./CopyButton";
+import { useTheme } from "@/components/theme-provider";
let highlighterPromise: Promise | null = null;
@@ -8,7 +9,7 @@ async function getHighlighter() {
if (!highlighterPromise) {
highlighterPromise = import("shiki").then((shiki) =>
shiki.createHighlighter({
- themes: ["one-dark-pro"],
+ themes: ["one-dark-pro", "github-light"],
langs: ["javascript", "typescript", "json", "bash"],
}),
);
@@ -24,6 +25,8 @@ type Props = {
/** Static snippet rendered with real Shiki tokenization (no fake colored spans). */
export function CodeBlock({ code, lang = "javascript", className }: Props) {
+ const { theme } = useTheme();
+ const shikiTheme = theme === "dark" ? "one-dark-pro" : "github-light";
const [html, setHtml] = useState(null);
useEffect(() => {
@@ -31,13 +34,13 @@ export function CodeBlock({ code, lang = "javascript", className }: Props) {
getHighlighter()
.then((hl) => {
if (cancelled) return;
- setHtml(hl.codeToHtml(code.trim(), { lang, theme: "one-dark-pro" }));
+ setHtml(hl.codeToHtml(code.trim(), { lang, theme: shikiTheme }));
})
.catch(() => setHtml(null));
return () => {
cancelled = true;
};
- }, [code, lang]);
+ }, [code, lang, shikiTheme]);
const base =
"relative overflow-x-auto rounded-sm border border-border bg-panel p-4 text-[12.5px] leading-relaxed [&_pre]:!bg-transparent [&_pre]:m-0";
diff --git a/src/components/devtools/CodeEditor.tsx b/src/components/devtools/CodeEditor.tsx
index bf1f65d..a8a749d 100644
--- a/src/components/devtools/CodeEditor.tsx
+++ b/src/components/devtools/CodeEditor.tsx
@@ -1,12 +1,14 @@
import { useEffect, useRef } from "react";
import { EditorView, keymap, lineNumbers, highlightActiveLine } from "@codemirror/view";
-import { EditorState, StateEffect, StateField, type Extension } from "@codemirror/state";
+import { EditorState, StateEffect, StateField, Compartment, type Extension } from "@codemirror/state";
import { Decoration, type DecorationSet } from "@codemirror/view";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { javascript } from "@codemirror/lang-javascript";
import { oneDark } from "@codemirror/theme-one-dark";
-import { bracketMatching, indentOnInput, syntaxHighlighting, defaultHighlightStyle } from "@codemirror/language";
+import { bracketMatching, indentOnInput, syntaxHighlighting, defaultHighlightStyle, HighlightStyle } from "@codemirror/language";
import { closeBrackets, closeBracketsKeymap, autocompletion } from "@codemirror/autocomplete";
+import { tags as t } from "@lezer/highlight";
+import { useTheme } from "@/components/theme-provider";
const setActiveLine = StateEffect.define();
@@ -47,6 +49,56 @@ const baseTheme = EditorView.theme({
"&.cm-focused": { outline: "none" },
});
+/** Light-mode editor chrome, tuned to the app's light palette. */
+const lightEditorTheme = EditorView.theme({
+ "&": { color: "var(--color-foreground)" },
+ ".cm-gutters": { color: "color-mix(in oklab, var(--color-foreground) 55%, transparent)" },
+ ".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
+ backgroundColor: "color-mix(in oklab, var(--color-secondary) 26%, transparent)",
+ },
+ ".cm-activeLine": {
+ backgroundColor: "color-mix(in oklab, var(--color-primary) 7%, transparent)",
+ },
+ ".cm-activeLineGutter": { backgroundColor: "transparent" },
+ ".cm-cursor": { borderLeftColor: "var(--color-foreground)" },
+ ".cm-matchingBracket": {
+ backgroundColor: "color-mix(in oklab, var(--color-primary) 20%, transparent)",
+ outline: "none",
+ },
+});
+
+/** Light-mode syntax colors, matching the light accent palette. */
+const lightHighlight = HighlightStyle.define([
+ { tag: t.keyword, color: "var(--color-secondary)" },
+ { tag: [t.name, t.deleted, t.character, t.propertyName, t.macroName], color: "var(--color-foreground)" },
+ { tag: [t.function(t.variableName), t.labelName], color: "var(--color-secondary)" },
+ { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: "var(--color-warning)" },
+ { tag: [t.definition(t.name), t.separator], color: "var(--color-foreground)" },
+ {
+ tag: [t.typeName, t.className, t.number, t.changed, t.annotation, t.modifier, t.self, t.namespace],
+ color: "var(--color-microtask)",
+ },
+ {
+ tag: [t.operator, t.operatorKeyword, t.url, t.escape, t.regexp, t.link, t.special(t.string)],
+ color: "var(--color-warning)",
+ },
+ { tag: [t.meta, t.comment], color: "var(--color-muted-foreground)" },
+ { tag: t.strong, fontWeight: "bold" },
+ { tag: t.emphasis, fontStyle: "italic" },
+ { tag: t.strikethrough, textDecoration: "line-through" },
+ { tag: t.link, textDecoration: "underline" },
+ { tag: t.heading, fontWeight: "bold", color: "var(--color-secondary)" },
+ { tag: [t.atom, t.bool, t.special(t.variableName)], color: "var(--color-api)" },
+ { tag: [t.processingInstruction, t.string, t.inserted], color: "var(--color-microtask)" },
+ { tag: t.invalid, color: "var(--color-destructive)" },
+]);
+
+const themeCompartment = new Compartment();
+
+function editorTheme(theme: "dark" | "light"): Extension {
+ return theme === "dark" ? oneDark : [lightEditorTheme, syntaxHighlighting(lightHighlight)];
+}
+
type Props = {
value: string;
onChange?: (value: string) => void;
@@ -67,6 +119,7 @@ export default function CodeEditor({
const hostRef = useRef(null);
const viewRef = useRef(null);
const lastActiveLineRef = useRef(null);
+ const { theme } = useTheme();
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const onRunRef = useRef(onRun);
@@ -84,7 +137,7 @@ export default function CodeEditor({
highlightActiveLine(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
javascript({ typescript: false }),
- oneDark,
+ themeCompartment.of(editorTheme(theme)),
baseTheme,
activeLineField,
keymap.of([
@@ -118,6 +171,13 @@ export default function CodeEditor({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [readOnly]);
+ // Swap the CodeMirror theme without rebuilding the editor or losing state.
+ useEffect(() => {
+ const view = viewRef.current;
+ if (!view) return;
+ view.dispatch({ effects: themeCompartment.reconfigure(editorTheme(theme)) });
+ }, [theme]);
+
useEffect(() => {
const view = viewRef.current;
if (!view) return;
diff --git a/src/components/devtools/Workbench.tsx b/src/components/devtools/Workbench.tsx
index 38255da..4b2ee8b 100644
--- a/src/components/devtools/Workbench.tsx
+++ b/src/components/devtools/Workbench.tsx
@@ -266,6 +266,7 @@ export default function Workbench({ code, onCodeChange, view }: Props) {
onChange={(e) => setSpeed(Number(e.target.value))}
className="rounded-sm border border-border bg-card px-1.5 py-1 text-[11px] text-muted-foreground outline-none focus:border-secondary"
>
+
diff --git a/src/components/theme-provider.tsx b/src/components/theme-provider.tsx
new file mode 100644
index 0000000..0003809
--- /dev/null
+++ b/src/components/theme-provider.tsx
@@ -0,0 +1,60 @@
+import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
+
+export type Theme = "dark" | "light";
+
+const STORAGE_KEY = "runtimejs-theme";
+
+type ThemeContextValue = {
+ theme: Theme;
+ toggleTheme: () => void;
+};
+
+const ThemeContext = createContext(null);
+
+/**
+ * Inline script injected into so the stored theme is applied to the
+ * element before first paint (prevents a flash of the wrong theme).
+ * Dark is the app's default identity; light only applies when explicitly
+ * stored in localStorage.
+ */
+export const themeInitScript = `(function(){var el=document.documentElement;var dark=true;try{dark=localStorage.getItem("${STORAGE_KEY}")!=="light";}catch(e){}el.classList.toggle("dark",dark);})();`;
+
+export function ThemeProvider({ children }: { children: ReactNode }) {
+ const [theme, setTheme] = useState("dark");
+
+ // Sync state with any stored preference on mount (the init script already
+ // painted the right theme; this keeps React's state in agreement with it).
+ useEffect(() => {
+ let stored: string | null = null;
+ try {
+ stored = window.localStorage.getItem(STORAGE_KEY);
+ } catch {
+ // Storage unavailable (private mode) — keep the dark default.
+ }
+ if (stored === "light") setTheme("light");
+ }, []);
+
+ useEffect(() => {
+ const dark = theme === "dark";
+ document.documentElement.classList.toggle("dark", dark);
+ try {
+ window.localStorage.setItem(STORAGE_KEY, theme);
+ } catch {
+ // Storage unavailable — the toggle still works for this visit.
+ }
+ const meta = document.querySelector('meta[name="theme-color"]');
+ if (meta) meta.setAttribute("content", dark ? "#1e1e1e" : "#eef0f4");
+ }, [theme]);
+
+ const toggleTheme = useCallback(() => {
+ setTheme((t) => (t === "dark" ? "light" : "dark"));
+ }, []);
+
+ return {children};
+}
+
+export function useTheme(): ThemeContextValue {
+ const ctx = useContext(ThemeContext);
+ if (!ctx) throw new Error("useTheme must be used within ");
+ return ctx;
+}
diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx
index cf1e818..de05449 100644
--- a/src/routes/__root.tsx
+++ b/src/routes/__root.tsx
@@ -12,6 +12,7 @@ import { useEffect, type ReactNode } from "react";
import appCss from "../styles.css?url";
import { reportLovableError } from "../lib/lovable-error-reporting";
import { initAnalytics } from "../lib/analytics";
+import { ThemeProvider, themeInitScript } from "../components/theme-provider";
function NotFoundComponent() {
return (
@@ -114,8 +115,9 @@ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()(
function RootShell({ children }: { children: ReactNode }) {
return (
-
+
+
@@ -135,9 +137,11 @@ function RootComponent() {
}, []);
return (
-
- {/* Required: nested routes render here. Removing breaks all child routes. */}
-
-
+
+
+ {/* Required: nested routes render here. Removing breaks all child routes. */}
+
+
+
);
}
diff --git a/src/styles.css b/src/styles.css
index 35d7fd4..f60eb48 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -11,6 +11,9 @@
* classes (e.g. --color-primary -> bg-primary, text-primary).
*
* The :root and .dark blocks define the actual color values using oklch.
+ * :root holds the light palette; .dark holds the dark palette (the app's
+ * default — the element gets the .dark class unless the user opts
+ * into light mode via the header toggle).
* All colors MUST use oklch format.
*
* To add a new semantic color:
@@ -72,9 +75,69 @@
--font-sans: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
}
-
:root {
--radius: 0.25rem;
+ color-scheme: light;
+
+ /* Light DevTools surfaces — cool blue-gray neutrals */
+ --background: oklch(0.955 0.006 260);
+ --foreground: oklch(0.27 0.014 260);
+ --card: oklch(0.985 0.004 260);
+ --card-foreground: oklch(0.27 0.014 260);
+ --popover: oklch(0.985 0.004 260);
+ --popover-foreground: oklch(0.27 0.014 260);
+
+ /* JavaScript gold #F7DF1E — darkened to a goldenrod that holds WCAG AA on
+ light surfaces (the pure yellow only passes on dark) */
+ --primary: oklch(0.6 0.16 85);
+ --primary-foreground: oklch(0.97 0.005 90);
+
+ /* TypeScript blue #3178C6 */
+ --secondary: oklch(0.55 0.15 255);
+ --secondary-foreground: oklch(0.97 0.005 255);
+
+ --muted: oklch(0.93 0.008 260);
+ --muted-foreground: oklch(0.48 0.02 258);
+ --accent: oklch(0.91 0.012 260);
+ --accent-foreground: oklch(0.3 0.016 260);
+
+ --destructive: oklch(0.55 0.22 27);
+ --destructive-foreground: oklch(0.97 0.005 20);
+
+ --border: oklch(0.74 0.012 260);
+ --input: oklch(0.74 0.012 260);
+ --ring: oklch(0.55 0.15 255);
+
+ /* Event-loop lane colors + syntax accents — all ≥4.5:1 on the light
+ surfaces (3:1 for the border-like UI tokens) */
+ --stack: oklch(0.6 0.16 85);
+ --microtask: oklch(0.5 0.14 155);
+ --macrotask: oklch(0.55 0.15 255);
+ --api: oklch(0.52 0.16 320);
+ --warning: oklch(0.55 0.15 70);
+ --success: oklch(0.5 0.14 155);
+ --panel: oklch(0.92 0.008 260);
+ --panel-foreground: oklch(0.3 0.016 260);
+ --grid-line: oklch(0.74 0.012 260);
+
+ --chart-1: oklch(0.6 0.16 85);
+ --chart-2: oklch(0.55 0.15 255);
+ --chart-3: oklch(0.5 0.14 155);
+ --chart-4: oklch(0.55 0.22 27);
+ --chart-5: oklch(0.52 0.16 320);
+
+ --sidebar: oklch(0.97 0.004 260);
+ --sidebar-foreground: oklch(0.27 0.014 260);
+ --sidebar-primary: oklch(0.6 0.16 85);
+ --sidebar-primary-foreground: oklch(0.97 0.005 90);
+ --sidebar-accent: oklch(0.92 0.012 260);
+ --sidebar-accent-foreground: oklch(0.3 0.016 260);
+ --sidebar-border: oklch(0.74 0.012 260);
+ --sidebar-ring: oklch(0.55 0.15 255);
+}
+
+.dark {
+ color-scheme: dark;
/* VS Code Dark Modern / One Dark Pro inspired surfaces */
--background: oklch(0.2 0.004 260);
@@ -132,10 +195,6 @@
--sidebar-ring: oklch(0.8 0.12 255.6);
}
-.dark {
- color-scheme: dark;
-}
-
@layer base {
* {
@@ -143,7 +202,6 @@
}
html {
- color-scheme: dark;
background-color: var(--color-background);
}