From e4db6015e2ee720c71052bd88734a2b47c6e3f74 Mon Sep 17 00:00:00 2001 From: hoce1n Date: Sat, 8 Aug 2026 15:14:34 +0000 Subject: [PATCH] feat: add 0.25x replay speed and a light theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 1 — replay speed: - Add 0.25x option to the speed select in Workbench. No timing-logic changes; the existing Math.max(40, 150/speed) already produces a valid 600ms interval. Task 2 — light theme: - Restructure styles.css so :root holds the light palette and .dark holds the dark palette (previously :root was the dark palette). Dark remains the default: the html element gets the .dark class unless the user opts into light. - Design a light palette from scratch, verified with an oklch->sRGB WCAG contrast script: every token meets AA on its paired light surface (4.5:1 text, 3:1 UI). The JS-yellow accent is darkened to a goldenrod that holds contrast on light backgrounds; TS-blue stays at #3178C6. - Add a ThemeProvider + useTheme hook that toggles the .dark class on html, persists to localStorage, and injects an inline pre-paint script in the shell head (no FOUC, no hydration mismatch). - Add a sun/moon toggle button in the AppShell header. - Theme-aware syntax highlighting: CodeMirror swaps oneDark for a light editor theme + light highlight style; Shiki swaps one-dark-pro for github-light. Event-loop lanes, charts, and DevTools panels all inherit the semantic tokens. Co-authored-by: monkeycode-ai --- src/components/devtools/AppShell.tsx | 13 ++++- src/components/devtools/CodeBlock.tsx | 9 ++-- src/components/devtools/CodeEditor.tsx | 66 ++++++++++++++++++++++-- src/components/devtools/Workbench.tsx | 1 + src/components/theme-provider.tsx | 60 ++++++++++++++++++++++ src/routes/__root.tsx | 14 ++++-- src/styles.css | 70 +++++++++++++++++++++++--- 7 files changed, 215 insertions(+), 18 deletions(-) create mode 100644 src/components/theme-provider.tsx 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 ( - + +