Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/components/devtools/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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 (
<div className="min-h-screen bg-background">
Expand All @@ -73,6 +76,14 @@ export function AppShell({
the language, its eras, and the loop that runs it
</p>
<SiteSearch />
<button
onClick={toggleTheme}
aria-label={`Switch to ${nextTheme} mode`}
title={`Switch to ${nextTheme} mode`}
className="grid size-7 shrink-0 place-items-center rounded-sm border border-border text-muted-foreground transition-colors hover:border-secondary hover:text-foreground"
>
{theme === "dark" ? <Sun className="size-3.5" /> : <Moon className="size-3.5" />}
</button>
<p className="ml-auto hidden text-[11px] text-muted-foreground sm:block">
executes in a sandboxed iframe · never <code className="text-destructive">eval</code>{" "}
on this page
Expand Down
9 changes: 6 additions & 3 deletions src/components/devtools/CodeBlock.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { useEffect, useState } from "react";
import type { Highlighter } from "shiki";
import { CopyButton } from "./CopyButton";
import { useTheme } from "@/components/theme-provider";

let highlighterPromise: Promise<Highlighter> | null = null;

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"],
}),
);
Expand All @@ -24,20 +25,22 @@ 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<string | null>(null);

useEffect(() => {
let cancelled = false;
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";
Expand Down
66 changes: 63 additions & 3 deletions src/components/devtools/CodeEditor.tsx
Original file line number Diff line number Diff line change
@@ -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<number | null>();

Expand Down Expand Up @@ -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;
Expand All @@ -67,6 +119,7 @@ export default function CodeEditor({
const hostRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<EditorView | null>(null);
const lastActiveLineRef = useRef<number | null>(null);
const { theme } = useTheme();
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const onRunRef = useRef(onRun);
Expand All @@ -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([
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/components/devtools/Workbench.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<option value={0.25}>0.25×</option>
<option value={0.5}>0.5×</option>
<option value={1}>1×</option>
<option value={2}>2×</option>
Expand Down
60 changes: 60 additions & 0 deletions src/components/theme-provider.tsx
Original file line number Diff line number Diff line change
@@ -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<ThemeContextValue | null>(null);

/**
* Inline script injected into <head> so the stored theme is applied to the
* <html> 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<Theme>("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 <ThemeContext.Provider value={{ theme, toggleTheme }}>{children}</ThemeContext.Provider>;
}

export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within <ThemeProvider>");
return ctx;
}
14 changes: 9 additions & 5 deletions src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -114,8 +115,9 @@ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()(

function RootShell({ children }: { children: ReactNode }) {
return (
<html lang="en" className="dark">
<html lang="en">
<head>
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
<HeadContent />
</head>
<body>
Expand All @@ -135,9 +137,11 @@ function RootComponent() {
}, []);

return (
<QueryClientProvider client={queryClient}>
{/* Required: nested routes render here. Removing <Outlet /> breaks all child routes. */}
<Outlet />
</QueryClientProvider>
<ThemeProvider>
<QueryClientProvider client={queryClient}>
{/* Required: nested routes render here. Removing <Outlet /> breaks all child routes. */}
<Outlet />
</QueryClientProvider>
</ThemeProvider>
);
}
70 changes: 64 additions & 6 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 <html> 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:
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -132,18 +195,13 @@
--sidebar-ring: oklch(0.8 0.12 255.6);
}

.dark {
color-scheme: dark;
}


@layer base {
* {
border-color: var(--color-border);
}

html {
color-scheme: dark;
background-color: var(--color-background);
}

Expand Down
Loading