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
29 changes: 29 additions & 0 deletions apps/web/src/app/[locale]/app/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,14 +142,17 @@ vi.mock("@/app/components/MonacoPseudocodeEditor", async () => {
MonacoPseudocodeEditor: memo(function MockEditor({
value,
onChange,
colorScheme,
}: {
value: string;
onChange: (value: string) => void;
colorScheme?: string;
}) {
editorRenderMock();
return (
<textarea
aria-label="Mock editor"
data-color-scheme={colorScheme}
value={value}
onChange={(event) => onChange(event.target.value)}
/>
Expand Down Expand Up @@ -521,6 +524,32 @@ describe("HomePage workspace flow", () => {
});
});

it("lets the user pick syntax colours from settings and remembers them", async () => {
mockLoadedWorkspace(createWorkspaceFixture());
const { unmount } = render(<HomePage />);
const editor = await screen.findByRole("textbox", { name: "Mock editor" });
expect(editor).toHaveAttribute("data-color-scheme", "classic");

fireEvent.click(screen.getByRole("button", { name: "Open settings" }));
const dialog = await screen.findByRole("dialog", { name: "Settings" });
const picker = within(dialog).getByRole("radiogroup", { name: "Syntax colours" });
expect(within(picker).getAllByRole("radio")).toHaveLength(6);
expect(within(picker).getByRole("radio", { name: "Classic" })).toBeChecked();

fireEvent.click(within(picker).getByRole("radio", { name: "Ocean" }));

expect(within(picker).getByRole("radio", { name: "Ocean" })).toBeChecked();
expect(editor).toHaveAttribute("data-color-scheme", "ocean");
expect(localStore.get("pseudoeditor-syntax-color-scheme")).toBe("ocean");

unmount();
render(<HomePage />);
expect(await screen.findByRole("textbox", { name: "Mock editor" })).toHaveAttribute(
"data-color-scheme",
"ocean",
);
});

it("reorders documents through workspace controls", async () => {
mockLoadedWorkspace(createWorkspaceFixture());
render(<HomePage />);
Expand Down
45 changes: 44 additions & 1 deletion apps/web/src/app/[locale]/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { Dialog } from "@/app/components/Dialog";
import { EditorLoader } from "@/app/components/EditorLoader";
import { PracticeQuestionBanner } from "@/app/components/PracticeQuestionBanner";
import { SettingsSelect } from "@/app/components/SettingsSelect";
import { SyntaxColorSchemePicker } from "@/app/components/SyntaxColorSchemePicker";
import { WorkspaceSidebar } from "@/app/components/WorkspaceSidebar";
import { usePracticeQuestion } from "@/app/hooks/usePracticeQuestion";
import { useWorkspaceSession } from "@/app/hooks/useWorkspaceSession";
Expand Down Expand Up @@ -81,6 +82,11 @@ import {
type ThemeMode,
} from "@/lib/theme";
import { SYNTAX_OPTIONS, resolveSyntax } from "@/lib/pseudocodeLanguages";
import {
loadSyntaxColorScheme,
saveSyntaxColorScheme,
type SyntaxColorSchemeId,
} from "@/lib/syntaxColorSchemes";
import type { WorkspaceSyntaxId } from "@pseudoeditor/workspace";

/* ── constants ── */
Expand Down Expand Up @@ -373,6 +379,9 @@ export default function HomePage() {
const [showFlowchartPrompt, setShowFlowchartPrompt] = useState(false);
const [flowchartFileName, setFlowchartFileName] = useState("");
const [themeMode, setThemeMode] = useState<ThemeMode>(() => loadThemeMode());
const [syntaxColorScheme, setSyntaxColorScheme] = useState<SyntaxColorSchemeId>(() =>
loadSyntaxColorScheme(),
);
const [manualSaveStatus, setManualSaveStatus] = useState<ManualSaveStatus>("idle");
const manualSaveStatusTimerRef = useRef<number | null>(null);

Expand Down Expand Up @@ -805,6 +814,12 @@ export default function HomePage() {
setThemeMode(mode);
}, []);

const handleSyntaxColorSchemeChange = useCallback((scheme: SyntaxColorSchemeId) => {
track("syntax_colors_changed", { scheme });
setSyntaxColorScheme(scheme);
saveSyntaxColorScheme(scheme);
}, []);

const handleAutoSaveIntervalChange = useCallback((value: number) => {
const nextValue = clampAutoSaveIntervalMinutes(value);
setAutoSaveIntervalMinutes(nextValue);
Expand Down Expand Up @@ -985,6 +1000,28 @@ export default function HomePage() {
</div>
);

const renderSyntaxColorSettings = (compact = false) => (
<div className={compact ? "mt-6 space-y-4" : "space-y-4"}>
<div>
<p className="text-[11px] font-semibold tracking-[0.18em] text-[var(--text3)]">
{t.settings.appearance}
</p>
<h3 className="mt-2 text-[22px] font-semibold text-[var(--text)]">{t.settings.syntaxColors}</h3>
<p className="mt-2 max-w-md text-sm leading-6 text-[var(--text2)]">
{t.settings.syntaxColorsDescription}
</p>
</div>

<SyntaxColorSchemePicker
value={syntaxColorScheme}
theme={resolvedTheme}
label={t.settings.syntaxColors}
names={t.settings.syntaxColorSchemes}
onChange={handleSyntaxColorSchemeChange}
/>
</div>
);

const renderSaveSettings = (compact = false) => (
<div className={compact ? "mt-6 space-y-4" : "space-y-4"}>
<div>
Expand Down Expand Up @@ -1081,6 +1118,7 @@ export default function HomePage() {
<div className="p-2">
{renderSyntaxSettings()}
{renderThemeSettings()}
{renderSyntaxColorSettings()}
</div>
</UserButton.UserProfilePage>
<UserButton.UserProfilePage
Expand Down Expand Up @@ -1476,6 +1514,7 @@ export default function HomePage() {
<div className="mt-5 space-y-6">
{renderSyntaxSettings()}
{renderThemeSettings()}
{renderSyntaxColorSettings()}
{renderSaveSettings()}
</div>
<div className="mt-6 border-t border-[var(--separator)] pt-4 text-center">
Expand Down Expand Up @@ -1882,6 +1921,7 @@ export default function HomePage() {
}
theme={resolvedTheme}
syntaxId={syntaxId}
colorScheme={syntaxColorScheme}
/>
) : (
renderStarterPanel(true)
Expand Down Expand Up @@ -1984,6 +2024,7 @@ export default function HomePage() {
}
theme={resolvedTheme}
syntaxId={syntaxId}
colorScheme={syntaxColorScheme}
/>
) : (
renderStarterPanel(true)
Expand All @@ -2007,7 +2048,7 @@ export default function HomePage() {
{touchTab === "output" ? renderTouchOutputSurface(t.terminal.output) : null}

{touchTab === "settings" ? (
<div className="flex h-full min-h-0 flex-col bg-[var(--bg)] px-6 py-8">
<div className="flex h-full min-h-0 flex-col overflow-y-auto bg-[var(--bg)] px-6 py-8">
<p className="text-[11px] font-semibold tracking-[0.18em] text-[var(--text3)]">
{t.settings.heading}
</p>
Expand All @@ -2019,6 +2060,7 @@ export default function HomePage() {
</p>
{renderSyntaxSettings(true)}
{renderThemeSettings(true)}
{renderSyntaxColorSettings(true)}
{renderSaveSettings(true)}
<button
type="button"
Expand Down Expand Up @@ -2339,6 +2381,7 @@ export default function HomePage() {
}
theme={resolvedTheme}
syntaxId={syntaxId}
colorScheme={syntaxColorScheme}
/>
) : (
renderStarterPanel()
Expand Down
144 changes: 75 additions & 69 deletions apps/web/src/app/components/MonacoPseudocodeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ import {
syntaxKeywords,
type SyntaxId,
} from "@/lib/pseudocodeLanguages";
import {
DEFAULT_SYNTAX_COLOR_SCHEME,
SYNTAX_COLOR_SCHEMES,
monacoThemeName,
type SyntaxColorSchemeId,
} from "@/lib/syntaxColorSchemes";
import type { ResolvedTheme } from "@/lib/theme";
import { useDictionary } from "@/i18n/context";
import { editorEn } from "@/i18n/messages/editor.en";
Expand All @@ -29,6 +35,7 @@ interface MonacoPseudocodeEditorProps {
theme: ResolvedTheme;
documentKey?: string;
syntaxId?: SyntaxId | string;
colorScheme?: SyntaxColorSchemeId;
}

const TYPE_KEYWORDS = [
Expand Down Expand Up @@ -149,13 +156,73 @@ if (typeof window !== "undefined") {
void ensureMonacoNullCaretHitTestGuard();
}

const EDITOR_SURFACE_COLORS = {
dark: {
"editor.background": "#1C1C1E",
"editor.foreground": "#E5E5EA",
"editorLineNumber.foreground": "#48484A",
"editorLineNumber.activeForeground": "#98989D",
"editor.lineHighlightBackground": "#2C2C2E",
"editorCursor.foreground": "#E5E5EA",
"editor.selectionBackground": "#0A84FF30",
"editor.inactiveSelectionBackground": "#38383A",
"editorIndentGuide.background1": "#38383A",
"editorIndentGuide.activeBackground1": "#48484A",
"editorWidget.background": "#2C2C2E",
"editorSuggestWidget.background": "#2C2C2E",
"editorSuggestWidget.selectedBackground": "#0A84FF30",
},
light: {
"editor.background": "#FFFFFF",
"editor.foreground": "#111111",
"editorLineNumber.foreground": "#CFCFCF",
"editorLineNumber.activeForeground": "#6B7280",
"editor.lineHighlightBackground": "#F5F5F5",
"editorCursor.foreground": "#111111",
"editor.selectionBackground": "#0B6E4F2A",
"editor.inactiveSelectionBackground": "#E5E5E5",
"editorIndentGuide.background1": "#E5E5E5",
"editorIndentGuide.activeBackground1": "#D1D5DB",
"editorWidget.background": "#FFFFFF",
"editorSuggestWidget.background": "#FFFFFF",
"editorSuggestWidget.selectedBackground": "#0B6E4F1F",
},
} as const;

// Defines a light and a dark Monaco theme for every syntax colour scheme.
function defineSyntaxThemes(monaco: typeof import("monaco-editor")) {
const hex = (color: string) => color.replace("#", "");
for (const scheme of SYNTAX_COLOR_SCHEMES) {
for (const mode of ["dark", "light"] as const) {
const colors = scheme[mode];
monaco.editor.defineTheme(monacoThemeName(scheme.id, mode), {
base: mode === "dark" ? "vs-dark" : "vs",
inherit: true,
rules: [
{ token: "keyword", foreground: hex(colors.keyword), fontStyle: scheme.boldKeywords ? "bold" : "" },
{ token: "type", foreground: hex(colors.type) },
{ token: "predefined", foreground: hex(colors.routine) },
{ token: "string", foreground: hex(colors.string) },
{ token: "comment", foreground: hex(colors.comment), fontStyle: "italic" },
{ token: "number", foreground: hex(colors.number) },
{ token: "operator", foreground: hex(colors.operator) },
{ token: "identifier", foreground: hex(colors.identifier) },
{ token: "delimiter", foreground: hex(colors.operator) },
],
colors: EDITOR_SURFACE_COLORS[mode],
});
}
}
}

export const MonacoPseudocodeEditor = memo(function MonacoPseudocodeEditor({
value,
onChange,
diagnostics,
theme,
documentKey,
syntaxId = "cambridge-igcse",
colorScheme = DEFAULT_SYNTAX_COLOR_SCHEME,
}: MonacoPseudocodeEditorProps) {
const autocomplete = useDictionary().editor.autocomplete;
useEffect(() => {
Expand All @@ -168,6 +235,9 @@ export const MonacoPseudocodeEditor = memo(function MonacoPseudocodeEditor({
const monacoRef = useRef<typeof import("monaco-editor") | null>(null);
const editorRef = useRef<import("monaco-editor").editor.IStandaloneCodeEditor | null>(null);
const touchFocusCleanupRef = useRef<(() => void) | null>(null);
// The editor keeps the first onMount it is given, so mount reads the current theme through a ref.
const themeName = monacoThemeName(colorScheme, theme);
const themeNameRef = useRef(themeName);
const appleTouchDevice = useMemo(() => isAppleTouchDevice(typeof navigator === "undefined" ? undefined : navigator), []);
const editorOptions = useMemo(() => getPseudocodeEditorOptions(appleTouchDevice), [appleTouchDevice]);

Expand Down Expand Up @@ -509,69 +579,8 @@ export const MonacoPseudocodeEditor = memo(function MonacoPseudocodeEditor({
isProgrammaticEdit = false;
});

monaco.editor.defineTheme("examLabThemeDark", {
base: "vs-dark",
inherit: true,
rules: [
{ token: "keyword", foreground: "FC5FA3" },
{ token: "type", foreground: "5DD8FF" },
{ token: "predefined", foreground: "A167E6" },
{ token: "string", foreground: "FC6A5D" },
{ token: "comment", foreground: "6C7986", fontStyle: "italic" },
{ token: "number", foreground: "D0BF69" },
{ token: "operator", foreground: "E5E5EA" },
{ token: "identifier", foreground: "67B7A4" },
{ token: "delimiter", foreground: "E5E5EA" },
],
colors: {
"editor.background": "#1C1C1E",
"editor.foreground": "#E5E5EA",
"editorLineNumber.foreground": "#48484A",
"editorLineNumber.activeForeground": "#98989D",
"editor.lineHighlightBackground": "#2C2C2E",
"editorCursor.foreground": "#E5E5EA",
"editor.selectionBackground": "#0A84FF30",
"editor.inactiveSelectionBackground": "#38383A",
"editorIndentGuide.background1": "#38383A",
"editorIndentGuide.activeBackground1": "#48484A",
"editorWidget.background": "#2C2C2E",
"editorSuggestWidget.background": "#2C2C2E",
"editorSuggestWidget.selectedBackground": "#0A84FF30",
},
});

monaco.editor.defineTheme("examLabThemeLight", {
base: "vs",
inherit: true,
rules: [
{ token: "keyword", foreground: "9B2D63" },
{ token: "type", foreground: "0F7A94" },
{ token: "predefined", foreground: "7856B3" },
{ token: "string", foreground: "B45438" },
{ token: "comment", foreground: "8C7B6A", fontStyle: "italic" },
{ token: "number", foreground: "8D6A12" },
{ token: "operator", foreground: "2A241F" },
{ token: "identifier", foreground: "2D7A6D" },
{ token: "delimiter", foreground: "2A241F" },
],
colors: {
"editor.background": "#FFFFFF",
"editor.foreground": "#111111",
"editorLineNumber.foreground": "#CFCFCF",
"editorLineNumber.activeForeground": "#6B7280",
"editor.lineHighlightBackground": "#F5F5F5",
"editorCursor.foreground": "#111111",
"editor.selectionBackground": "#0B6E4F2A",
"editor.inactiveSelectionBackground": "#E5E5E5",
"editorIndentGuide.background1": "#E5E5E5",
"editorIndentGuide.activeBackground1": "#D1D5DB",
"editorWidget.background": "#FFFFFF",
"editorSuggestWidget.background": "#FFFFFF",
"editorSuggestWidget.selectedBackground": "#0B6E4F1F",
},
});

monaco.editor.setTheme(theme === "dark" ? "examLabThemeDark" : "examLabThemeLight");
defineSyntaxThemes(monaco);
monaco.editor.setTheme(themeNameRef.current);

if (appleTouchDevice) {
const editorElement = editor.getDomNode();
Expand Down Expand Up @@ -617,12 +626,9 @@ export const MonacoPseudocodeEditor = memo(function MonacoPseudocodeEditor({
}, [markers]);

useEffect(() => {
if (!monacoRef.current) {
return;
}

monacoRef.current.editor.setTheme(theme === "dark" ? "examLabThemeDark" : "examLabThemeLight");
}, [theme]);
themeNameRef.current = themeName;
monacoRef.current?.editor.setTheme(themeName);
}, [themeName]);

useEffect(() => {
keywordLookupRef.current = keywordLookup;
Expand Down
Loading
Loading