feat(studio): the code editor follows studio's light and dark theme (#4862)

* feat(studio): the code editor follows studio's light and dark theme

Replaces One Dark with a Studio editor theme whose colours are theme.css variables: eight syntax role tokens (One Light and One Dark hues held at 4.5:1), editor surfaces from Studio's roles, all measured by the contrast ratchet.

* test(studio): the code editor theme takes every colour from a theme.css token

* fix(studio): code stays at 4.5:1 when a selection or search match sits on the active line

Syntax tokens are tuned for the selection over the active line, which the ratchet now measures; matches are outlined rather than stacking another wash; the search panel's pressed button and special characters follow the theme; unused tooltip rules go.

* fix(studio): code matches carry no background, and types read apart from constants

Search and selection matches are outlined only, so CodeMirror's lime match wash and stacked washes are gone; type and constant tokens split by hue in both themes.

* fix(studio): the current search match reads apart by width, not colour alone

A 2px accent outline on the current match and a muted 1px outline on other copies of the selected word.
This commit is contained in:
Miguel Ángel
2026-10-01 10:13:48 -07:00
committed by GitHub
parent 9c19327e38
commit e666a8a2a3
8 changed files with 200 additions and 12 deletions
+1 -3
View File
@@ -329,7 +329,6 @@
"@codemirror/language": "^6.12.2",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.2",
"@codemirror/view": "6.40.0",
"@hyperframes/core": "workspace:*",
"@hyperframes/parsers": "workspace:*",
@@ -337,6 +336,7 @@
"@hyperframes/sdk": "workspace:*",
"@hyperframes/shader-transitions": "workspace:*",
"@hyperframes/studio-server": "workspace:*",
"@lezer/highlight": "^1.2.3",
"@mcp-b/global": "^5.0.1",
"@phosphor-icons/react": "^2.1.10",
"@tanstack/react-virtual": "^3.14.13",
@@ -552,8 +552,6 @@
"@codemirror/state": ["@codemirror/state@6.6.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ=="],
"@codemirror/theme-one-dark": ["@codemirror/theme-one-dark@6.1.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/highlight": "^1.0.0" } }, "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA=="],
"@codemirror/view": ["@codemirror/view@6.40.0", "", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-WA0zdU7xfF10+5I3HhUUq3kqOx3KjqmtQ9lqZjfK7jtYk4G72YW9rezcSywpaUMCWOMlq+6E0pO1IWg1TNIhtg=="],
"@commitlint/cli": ["@commitlint/cli@20.5.3", "", { "dependencies": { "@commitlint/format": "^20.5.0", "@commitlint/lint": "^20.5.3", "@commitlint/load": "^20.5.3", "@commitlint/read": "^20.5.0", "@commitlint/types": "^20.5.0", "tinyexec": "^1.0.0", "yargs": "^17.0.0" }, "bin": { "commitlint": "./cli.js" } }, "sha512-OJdL0EXWD5y9LPa0nr/geOwzaS8BsdaybKkcloB0JgsguGxNv2R+hC2FTPqrAcprg35zF33KOQerY0x8W1aesA=="],
+1 -1
View File
@@ -93,7 +93,6 @@
"@codemirror/language": "^6.12.2",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.2",
"@codemirror/view": "6.40.0",
"@hyperframes/core": "workspace:*",
"@hyperframes/parsers": "workspace:*",
@@ -101,6 +100,7 @@
"@hyperframes/sdk": "workspace:*",
"@hyperframes/shader-transitions": "workspace:*",
"@hyperframes/studio-server": "workspace:*",
"@lezer/highlight": "^1.2.3",
"@mcp-b/global": "^5.0.1",
"@phosphor-icons/react": "^2.1.10",
"@tanstack/react-virtual": "^3.14.13",
@@ -11,12 +11,12 @@ import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import { bracketMatching, foldGutter, indentOnInput } from "@codemirror/language";
import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete";
import { highlightSelectionMatches, searchKeymap } from "@codemirror/search";
import { oneDark } from "@codemirror/theme-one-dark";
import type { Extension } from "@codemirror/state";
import { html } from "@codemirror/lang-html";
import { css } from "@codemirror/lang-css";
import { javascript } from "@codemirror/lang-javascript";
import { markdown } from "@codemirror/lang-markdown";
import { studioCodeTheme } from "./studioCodeTheme";
// Marks a programmatic doc sync (external content push — e.g. a manual-edit
// commit writing the source) so the update listener doesn't mistake it for a
@@ -114,13 +114,9 @@ export const SourceEditor = memo(function SourceEditor({
highlightSelectionMatches(),
keymap.of([...closeBracketsKeymap, ...defaultKeymap, ...searchKeymap, ...historyKeymap]),
getLanguageExtension(lang),
oneDark,
studioCodeTheme,
updateListener,
EditorState.readOnly.of(readOnly),
EditorView.theme({
"&": { height: "100%" },
".cm-scroller": { overflow: "auto" },
}),
],
});
@@ -0,0 +1,20 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const read = (file: string) => readFileSync(new URL(file, import.meta.url), "utf8");
const source = read("./studioCodeTheme.ts");
const themeCss = read("../../styles/theme.css");
describe("the code editor theme", () => {
it("takes every colour from a token theme.css declares", () => {
const used = [...source.matchAll(/var\((--color-[\w-]+)\)/g)].map((match) => match[1]!);
expect(used.length).toBeGreaterThan(20);
for (const token of new Set(used)) expect(themeCss, token).toContain(`${token}:`);
});
it("paints no literal colour of its own", () => {
expect(source).not.toMatch(
/#[\da-f]{3,8}\b|\b(rgba?|hsla?|hwb|lab|lch|oklab|oklch|color|color-mix)\(|"(white|black|red|orange|yellow|green|blue|purple|pink|gray|grey|silver|lime|cyan|magenta)"/i,
);
});
});
@@ -0,0 +1,99 @@
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { EditorView } from "@codemirror/view";
import { tags as t } from "@lezer/highlight";
// One Dark's layout on Studio's tokens: every colour is a theme.css variable, so the editor
// follows the light or dark theme the page paints with no editor reconfiguration.
const editorTheme = EditorView.theme({
"&": { height: "100%", color: "var(--color-text-0)", backgroundColor: "var(--color-surface)" },
".cm-scroller": { overflow: "auto" },
".cm-content": { caretColor: "var(--color-text-0)" },
".cm-cursor, .cm-dropCursor": { borderLeftColor: "var(--color-text-0)" },
"&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
{ backgroundColor: "var(--color-on)" },
".cm-activeLine": { backgroundColor: "var(--color-hover)" },
".cm-selectionMatch": {
backgroundColor: "transparent",
outline: "1px solid var(--color-text-muted)",
},
".cm-searchMatch": {
backgroundColor: "transparent",
outline: "1px solid var(--color-text-muted)",
},
".cm-searchMatch.cm-searchMatch-selected": {
backgroundColor: "transparent",
outline: "2px solid var(--color-accent-ink)",
},
"&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": {
backgroundColor: "var(--color-on)",
},
".cm-gutters": {
backgroundColor: "var(--color-surface)",
color: "var(--color-text-muted)",
border: "none",
},
".cm-activeLineGutter": { backgroundColor: "var(--color-hover)", color: "var(--color-text-2)" },
".cm-foldPlaceholder": {
backgroundColor: "transparent",
border: "none",
color: "var(--color-text-2)",
},
".cm-panels": { backgroundColor: "var(--color-raised)", color: "var(--color-text-0)" },
".cm-panels.cm-panels-top": { borderBottom: "1px solid var(--color-border)" },
".cm-panels.cm-panels-bottom": { borderTop: "1px solid var(--color-border)" },
".cm-textfield": {
backgroundColor: "var(--color-input)",
color: "var(--color-text-0)",
border: "1px solid var(--color-border-input)",
},
".cm-button": {
backgroundImage: "none",
backgroundColor: "var(--color-hover)",
color: "var(--color-text-0)",
border: "1px solid var(--color-border)",
},
"&.cm-editor .cm-button:active": {
backgroundImage: "none",
backgroundColor: "var(--color-press)",
},
".cm-specialChar": { color: "var(--color-danger-ink)" },
});
const highlightStyle = HighlightStyle.define([
{ tag: t.keyword, color: "var(--color-code-keyword)" },
{
tag: [t.name, t.deleted, t.character, t.propertyName, t.macroName],
color: "var(--color-code-name)",
},
{ tag: [t.function(t.variableName), t.labelName], color: "var(--color-code-function)" },
{ tag: [t.color, t.constant(t.name), t.standard(t.name)], color: "var(--color-code-constant)" },
{ tag: [t.definition(t.name), t.separator], color: "var(--color-text-0)" },
{
tag: [
t.typeName,
t.className,
t.number,
t.changed,
t.annotation,
t.modifier,
t.self,
t.namespace,
],
color: "var(--color-code-type)",
},
{
tag: [t.operator, t.operatorKeyword, t.url, t.escape, t.regexp, t.special(t.string)],
color: "var(--color-code-operator)",
},
{ tag: [t.meta, t.comment], color: "var(--color-code-comment)" },
{ tag: t.strong, fontWeight: "bold" },
{ tag: t.emphasis, fontStyle: "italic" },
{ tag: t.strikethrough, textDecoration: "line-through" },
{ tag: t.link, color: "var(--color-code-comment)", textDecoration: "underline" },
{ tag: t.heading, fontWeight: "bold", color: "var(--color-code-name)" },
{ tag: [t.atom, t.bool, t.special(t.variableName)], color: "var(--color-code-constant)" },
{ tag: [t.processingInstruction, t.string, t.inserted], color: "var(--color-code-string)" },
{ tag: t.invalid, color: "var(--color-danger-ink)" },
]);
export const studioCodeTheme = [editorTheme, syntaxHighlighting(highlightStyle)];
+10
View File
@@ -77,6 +77,16 @@
--color-media: light-dark(oklch(0.5 0.1 215), #00e3ff);
--color-container: light-dark(oklch(0.55 0.13 70), #f5a623);
/* Code editor syntax: One Light and One Dark hues, held at 4.5:1 on a line, the active line and a selection on it. */
--color-code-keyword: light-dark(oklch(0.509 0.211 329), oklch(0.829 0.164 318));
--color-code-name: light-dark(oklch(0.504 0.179 28), oklch(0.856 0.145 17));
--color-code-function: light-dark(oklch(0.492 0.193 263), oklch(0.805 0.121 245));
--color-code-constant: light-dark(oklch(0.495 0.13 50), oklch(0.815 0.11 50));
--color-code-type: light-dark(oklch(0.48 0.12 92), oklch(0.805 0.1 92));
--color-code-operator: light-dark(oklch(0.471 0.127 237), oklch(0.793 0.092 206));
--color-code-comment: light-dark(oklch(0.48 0.009 279), oklch(0.806 0.03 262));
--color-code-string: light-dark(oklch(0.468 0.142 143), oklch(0.793 0.11 133));
/* The two stops of the HeyGen brand gradient, worn by the header logo. Fixed brand values
* rather than roles, so they are not reachable as a background or text colour by mistake. */
--color-brand-gradient-from: #06e3fa;
+1 -1
View File
@@ -23,11 +23,11 @@ export default defineConfig({
"@codemirror/language",
"@codemirror/search",
"@codemirror/state",
"@codemirror/theme-one-dark",
"@codemirror/view",
"@hyperframes/core",
"@hyperframes/player",
"@hyperframes/sdk",
"@lezer/highlight",
"@phosphor-icons/react",
"bpm-detective",
"mediabunny",
+66 -1
View File
@@ -435,6 +435,70 @@
"role": "text",
"minimum": 4.5,
"sources": ["packages/studio/src/styles/theme.css"]
},
{
"id": "code-keyword",
"foreground": "--color-code-keyword",
"role": "text",
"minimum": 4.5,
"sources": ["packages/studio/src/components/editor/studioCodeTheme.ts"],
"on": ["panel", "panel-hover", "panel-on", "panel-hover-on"]
},
{
"id": "code-name",
"foreground": "--color-code-name",
"role": "text",
"minimum": 4.5,
"sources": ["packages/studio/src/components/editor/studioCodeTheme.ts"],
"on": ["panel", "panel-hover", "panel-on", "panel-hover-on"]
},
{
"id": "code-function",
"foreground": "--color-code-function",
"role": "text",
"minimum": 4.5,
"sources": ["packages/studio/src/components/editor/studioCodeTheme.ts"],
"on": ["panel", "panel-hover", "panel-on", "panel-hover-on"]
},
{
"id": "code-constant",
"foreground": "--color-code-constant",
"role": "text",
"minimum": 4.5,
"sources": ["packages/studio/src/components/editor/studioCodeTheme.ts"],
"on": ["panel", "panel-hover", "panel-on", "panel-hover-on"]
},
{
"id": "code-type",
"foreground": "--color-code-type",
"role": "text",
"minimum": 4.5,
"sources": ["packages/studio/src/components/editor/studioCodeTheme.ts"],
"on": ["panel", "panel-hover", "panel-on", "panel-hover-on"]
},
{
"id": "code-operator",
"foreground": "--color-code-operator",
"role": "text",
"minimum": 4.5,
"sources": ["packages/studio/src/components/editor/studioCodeTheme.ts"],
"on": ["panel", "panel-hover", "panel-on", "panel-hover-on"]
},
{
"id": "code-comment",
"foreground": "--color-code-comment",
"role": "text",
"minimum": 4.5,
"sources": ["packages/studio/src/components/editor/studioCodeTheme.ts"],
"on": ["panel", "panel-hover", "panel-on", "panel-hover-on"]
},
{
"id": "code-string",
"foreground": "--color-code-string",
"role": "text",
"minimum": 4.5,
"sources": ["packages/studio/src/components/editor/studioCodeTheme.ts"],
"on": ["panel", "panel-hover", "panel-on", "panel-hover-on"]
}
],
"surfaces": {
@@ -447,7 +511,8 @@
"panel-pressed": ["--color-surface", "--color-press"],
"panel-on": ["--color-surface", "--color-on"],
"panel-on-hover": ["--color-surface", "--color-on-hover"],
"raised-on-hover": ["--color-raised", "--color-on-hover"]
"raised-on-hover": ["--color-raised", "--color-on-hover"],
"panel-hover-on": ["--color-surface", "--color-hover", "--color-on"]
}
},
{