From 9b570cc80e42e1b37f0003159f3d9ad43f9e4035 Mon Sep 17 00:00:00 2001 From: Justin Kropp Date: Thu, 12 Feb 2026 15:25:58 -0800 Subject: [PATCH 1/2] fix(web): align Glide preview grid theme with app light/dark mode --- .../code-editor/MonacoCodeEditor.tsx | 60 +-------- .../components/DocumentPreviewGrid.tsx | 12 +- .../__tests__/DocumentPreviewGrid.test.tsx | 29 ++++- .../theme/__tests__/glideTheme.test.ts | 64 ++++++++++ frontend/src/providers/theme/cssColor.ts | 116 ++++++++++++++++++ frontend/src/providers/theme/glideTheme.ts | 78 ++++++++++++ 6 files changed, 298 insertions(+), 61 deletions(-) create mode 100644 frontend/src/providers/theme/__tests__/glideTheme.test.ts create mode 100644 frontend/src/providers/theme/cssColor.ts create mode 100644 frontend/src/providers/theme/glideTheme.ts diff --git a/frontend/src/pages/Workspace/sections/ConfigurationEditor/workbench/components/code-editor/MonacoCodeEditor.tsx b/frontend/src/pages/Workspace/sections/ConfigurationEditor/workbench/components/code-editor/MonacoCodeEditor.tsx index 35216f999..9a0637631 100644 --- a/frontend/src/pages/Workspace/sections/ConfigurationEditor/workbench/components/code-editor/MonacoCodeEditor.tsx +++ b/frontend/src/pages/Workspace/sections/ConfigurationEditor/workbench/components/code-editor/MonacoCodeEditor.tsx @@ -16,6 +16,8 @@ import JsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker"; import TsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker"; import clsx from "clsx"; +import { resolveCssColor } from "@/providers/theme/cssColor"; + import type { CodeEditorHandle, CodeEditorProps } from "./CodeEditor.types"; import { disposeAdeScriptHelpers, registerAdeScriptHelpers } from "./registerAdeScriptHelpers"; @@ -291,61 +293,3 @@ function buildAdeDarkTheme(): MonacoEditor.IStandaloneThemeData { colors, }; } - -function resolveCssColor(variable: string, fallback: string): string { - if (typeof window === "undefined" || typeof document === "undefined") { - return fallback; - } - const resolved = resolveCssVarColor(variable, fallback); - const rgb = parseRgbColor(resolved); - if (!rgb) { - return fallback; - } - return rgbToHex(rgb[0], rgb[1], rgb[2]); -} - -function resolveCssVarColor(variable: string, fallback: string): string { - const root = document.documentElement; - if (!root) { - return fallback; - } - const probe = document.createElement("span"); - probe.style.color = `var(${variable}, ${fallback})`; - probe.style.position = "absolute"; - probe.style.opacity = "0"; - probe.style.pointerEvents = "none"; - probe.style.userSelect = "none"; - root.appendChild(probe); - try { - const computed = getComputedStyle(probe).color.trim(); - return computed || fallback; - } finally { - root.removeChild(probe); - } -} - -function parseRgbColor(value: string): [number, number, number] | null { - const matches = value.match(/-?\d*\.?\d+/g); - if (!matches || matches.length < 3) { - return null; - } - const numbers = matches.slice(0, 3).map((part) => Number(part)); - if (numbers.some((num) => Number.isNaN(num))) { - return null; - } - let [red, green, blue] = numbers; - if (Math.max(red, green, blue) <= 1) { - red *= 255; - green *= 255; - blue *= 255; - } - return [red, green, blue]; -} - -function rgbToHex(red: number, green: number, blue: number): string { - const toHex = (value: number) => { - const clamped = Math.max(0, Math.min(255, Math.round(value))); - return clamped.toString(16).padStart(2, "0"); - }; - return `#${toHex(red)}${toHex(green)}${toHex(blue)}`; -} diff --git a/frontend/src/pages/Workspace/sections/Documents/detail/tabs/preview/components/DocumentPreviewGrid.tsx b/frontend/src/pages/Workspace/sections/Documents/detail/tabs/preview/components/DocumentPreviewGrid.tsx index 457609393..833337aff 100644 --- a/frontend/src/pages/Workspace/sections/Documents/detail/tabs/preview/components/DocumentPreviewGrid.tsx +++ b/frontend/src/pages/Workspace/sections/Documents/detail/tabs/preview/components/DocumentPreviewGrid.tsx @@ -8,6 +8,7 @@ import { type Theme, } from "@glideapps/glide-data-grid"; +import { useGlideDataEditorTheme } from "@/providers/theme/glideTheme"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; @@ -15,7 +16,7 @@ const GRID_ROW_HEIGHT = 24; const GRID_HEADER_HEIGHT = 28; const GRID_ROW_MARKER_WIDTH = 44; -const COMPACT_GRID_THEME: Partial = { +const COMPACT_GRID_DENSITY_THEME: Partial = { baseFontStyle: "12px Inter, sans-serif", headerFontStyle: "600 11px Inter, sans-serif", editorFontSize: "12px", @@ -42,6 +43,13 @@ export function DocumentPreviewGrid({ columnLabels: string[]; className?: string; }) { + const paletteTheme = useGlideDataEditorTheme(); + + const dataEditorTheme = useMemo( + () => ({ ...paletteTheme, ...COMPACT_GRID_DENSITY_THEME }), + [paletteTheme], + ); + const gridRows = useMemo(() => { return rows.map((row) => (Array.isArray(row) ? row.map(renderPreviewCell) : [])); }, [rows]); @@ -126,7 +134,7 @@ export function DocumentPreviewGrid({ fixedShadowX fixedShadowY onPaste={false} - theme={COMPACT_GRID_THEME} + theme={dataEditorTheme} /> ); diff --git a/frontend/src/pages/Workspace/sections/Documents/detail/tabs/preview/components/__tests__/DocumentPreviewGrid.test.tsx b/frontend/src/pages/Workspace/sections/Documents/detail/tabs/preview/components/__tests__/DocumentPreviewGrid.test.tsx index 561cb0104..ee42acae9 100644 --- a/frontend/src/pages/Workspace/sections/Documents/detail/tabs/preview/components/__tests__/DocumentPreviewGrid.test.tsx +++ b/frontend/src/pages/Workspace/sections/Documents/detail/tabs/preview/components/__tests__/DocumentPreviewGrid.test.tsx @@ -1,9 +1,18 @@ import type { ComponentProps } from "react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { render, screen } from "@/test/test-utils"; const dataEditorMock = vi.fn(); +const useGlideDataEditorThemeMock = vi.fn(() => ({ + accentColor: "#123456", + bgCell: "#101112", + textDark: "#f8f9fa", +})); + +vi.mock("@/providers/theme/glideTheme", () => ({ + useGlideDataEditorTheme: () => useGlideDataEditorThemeMock(), +})); vi.mock("@glideapps/glide-data-grid", () => ({ GridCellKind: { @@ -36,6 +45,11 @@ function renderGrid(overrides: Partial { + beforeEach(() => { + dataEditorMock.mockClear(); + useGlideDataEditorThemeMock.mockClear(); + }); + it("renders Glide DataEditor with compact, read-only defaults", () => { renderGrid(); @@ -55,6 +69,19 @@ describe("DocumentPreviewGrid", () => { expect(props.smoothScrollY).toBe(true); expect(props.fixedShadowX).toBe(true); expect(props.fixedShadowY).toBe(true); + expect(useGlideDataEditorThemeMock).toHaveBeenCalledTimes(1); + expect(props.theme).toEqual( + expect.objectContaining({ + accentColor: "#123456", + bgCell: "#101112", + textDark: "#f8f9fa", + baseFontStyle: "12px Inter, sans-serif", + headerFontStyle: "600 11px Inter, sans-serif", + editorFontSize: "12px", + cellHorizontalPadding: 6, + cellVerticalPadding: 2, + }), + ); }); it("maps preview rows to read-only text cells", () => { diff --git a/frontend/src/providers/theme/__tests__/glideTheme.test.ts b/frontend/src/providers/theme/__tests__/glideTheme.test.ts new file mode 100644 index 000000000..966543160 --- /dev/null +++ b/frontend/src/providers/theme/__tests__/glideTheme.test.ts @@ -0,0 +1,64 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const resolveCssRgbaColorMock = vi.fn((_: string, fallback: string) => fallback); + +const themeState: { theme: string; resolvedMode: "light" | "dark" } = { + theme: "default", + resolvedMode: "light", +}; + +vi.mock("@/providers/theme", () => ({ + useTheme: () => ({ + theme: themeState.theme, + resolvedMode: themeState.resolvedMode, + }), +})); + +vi.mock("../cssColor", () => ({ + resolveCssRgbaColor: (variable: string, fallback: string) => resolveCssRgbaColorMock(variable, fallback), +})); + +import { useGlideDataEditorTheme } from "../glideTheme"; + +describe("useGlideDataEditorTheme", () => { + beforeEach(() => { + vi.clearAllMocks(); + themeState.theme = "default"; + themeState.resolvedMode = "light"; + }); + + it("falls back to defaults when CSS token resolution is unavailable", () => { + resolveCssRgbaColorMock.mockImplementation((_: string, fallback: string) => fallback); + + const { result } = renderHook(() => useGlideDataEditorTheme()); + + expect(result.current.accentColor).toBe("#4F5DFF"); + expect(result.current.bgCell).toBe("#FFFFFF"); + expect(result.current.borderColor).toBe("rgba(115, 116, 131, 0.16)"); + expect(result.current.linkColor).toBe("#353fb5"); + }); + + it("recomputes palette when theme id or resolved mode changes", () => { + let currentPrimary = "rgb(11, 22, 33)"; + resolveCssRgbaColorMock.mockImplementation((variable: string, fallback: string) => { + if (variable === "--primary") { + return currentPrimary; + } + return fallback; + }); + + const { result, rerender } = renderHook(() => useGlideDataEditorTheme()); + expect(result.current.accentColor).toBe("rgb(11, 22, 33)"); + + currentPrimary = "rgb(44, 55, 66)"; + themeState.theme = "blue"; + rerender(); + expect(result.current.accentColor).toBe("rgb(44, 55, 66)"); + + currentPrimary = "rgb(77, 88, 99)"; + themeState.resolvedMode = "dark"; + rerender(); + expect(result.current.accentColor).toBe("rgb(77, 88, 99)"); + }); +}); diff --git a/frontend/src/providers/theme/cssColor.ts b/frontend/src/providers/theme/cssColor.ts new file mode 100644 index 000000000..b6badef94 --- /dev/null +++ b/frontend/src/providers/theme/cssColor.ts @@ -0,0 +1,116 @@ +export function resolveCssColor(variable: string, fallback: string): string { + if (typeof window === "undefined" || typeof document === "undefined") { + return fallback; + } + const resolved = resolveCssVarColor(variable, fallback); + const rgb = parseRgbColor(resolved); + if (!rgb) { + return fallback; + } + return rgbToHex(rgb[0], rgb[1], rgb[2]); +} + +export function resolveCssRgbaColor(variable: string, fallback: string): string { + if (typeof window === "undefined" || typeof document === "undefined") { + return fallback; + } + const resolved = resolveCssVarColor(variable, fallback); + const rgba = parseRgbaColor(resolved); + if (!rgba) { + return fallback; + } + return toRgbaString(rgba[0], rgba[1], rgba[2], rgba[3]); +} + +export function resolveCssVarColor(variable: string, fallback: string): string { + const root = document.documentElement; + if (!root) { + return fallback; + } + const probe = document.createElement("span"); + probe.style.color = `var(${variable}, ${fallback})`; + probe.style.position = "absolute"; + probe.style.opacity = "0"; + probe.style.pointerEvents = "none"; + probe.style.userSelect = "none"; + root.appendChild(probe); + try { + const computed = getComputedStyle(probe).color.trim(); + return computed || fallback; + } finally { + root.removeChild(probe); + } +} + +export function parseRgbColor(value: string): [number, number, number] | null { + const rgba = parseRgbaColor(value); + if (!rgba) { + return null; + } + return [rgba[0], rgba[1], rgba[2]]; +} + +export function rgbToHex(red: number, green: number, blue: number): string { + const toHex = (value: number) => { + const clamped = Math.max(0, Math.min(255, Math.round(value))); + return clamped.toString(16).padStart(2, "0"); + }; + return `#${toHex(red)}${toHex(green)}${toHex(blue)}`; +} + +let colorContext: CanvasRenderingContext2D | null = null; + +export function parseRgbaColor(value: string): [number, number, number, number] | null { + if (typeof window === "undefined" || typeof document === "undefined") { + return null; + } + const context = getColorContext(); + if (!context) { + return null; + } + + // Detect invalid colors by observing unchanged fillStyle after assignment. + context.fillStyle = "rgba(1, 2, 3, 1)"; + const before = context.fillStyle; + try { + context.fillStyle = value; + } catch { + return null; + } + const after = context.fillStyle; + if (after === before && normalizeColor(value) !== normalizeColor(before)) { + return null; + } + + context.clearRect(0, 0, 1, 1); + context.fillRect(0, 0, 1, 1); + const [red, green, blue, alpha] = context.getImageData(0, 0, 1, 1).data; + return [red, green, blue, alpha / 255]; +} + +function getColorContext(): CanvasRenderingContext2D | null { + if (colorContext) { + return colorContext; + } + const canvas = document.createElement("canvas"); + canvas.width = 1; + canvas.height = 1; + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) { + return null; + } + colorContext = context; + return colorContext; +} + +function normalizeColor(value: string): string { + return value.trim().toLowerCase().replace(/\s+/g, ""); +} + +function toRgbaString(red: number, green: number, blue: number, alpha: number): string { + const formattedAlpha = Number(alpha.toFixed(4)); + if (formattedAlpha >= 1) { + return `rgb(${red}, ${green}, ${blue})`; + } + return `rgba(${red}, ${green}, ${blue}, ${formattedAlpha})`; +} diff --git a/frontend/src/providers/theme/glideTheme.ts b/frontend/src/providers/theme/glideTheme.ts new file mode 100644 index 000000000..aef27692b --- /dev/null +++ b/frontend/src/providers/theme/glideTheme.ts @@ -0,0 +1,78 @@ +import { useEffect, useState } from "react"; +import type { Theme } from "@glideapps/glide-data-grid"; + +import { useTheme } from "@/providers/theme"; + +import { resolveCssRgbaColor } from "./cssColor"; + +type GlideThemeTokenMap = { + field: keyof Theme; + variable: string; + fallback: string; +}; + +const GLIDE_THEME_TOKEN_MAP: readonly GlideThemeTokenMap[] = [ + { field: "accentColor", variable: "--primary", fallback: "#4F5DFF" }, + { field: "accentFg", variable: "--primary-foreground", fallback: "#FFFFFF" }, + { field: "accentLight", variable: "--accent", fallback: "rgba(62, 116, 253, 0.1)" }, + { field: "textDark", variable: "--foreground", fallback: "#313139" }, + { field: "textMedium", variable: "--muted-foreground", fallback: "#737383" }, + { field: "textLight", variable: "--muted-foreground", fallback: "#B2B2C0" }, + { field: "textBubble", variable: "--foreground", fallback: "#313139" }, + { field: "bgIconHeader", variable: "--muted-foreground", fallback: "#737383" }, + { field: "fgIconHeader", variable: "--background", fallback: "#FFFFFF" }, + { field: "textHeader", variable: "--foreground", fallback: "#313139" }, + { field: "textHeaderSelected", variable: "--primary-foreground", fallback: "#FFFFFF" }, + { field: "bgCell", variable: "--background", fallback: "#FFFFFF" }, + { field: "bgCellMedium", variable: "--muted", fallback: "#FAFAFB" }, + { field: "bgHeader", variable: "--card", fallback: "#F7F7F8" }, + { field: "bgHeaderHasFocus", variable: "--accent", fallback: "#E9E9EB" }, + { field: "bgHeaderHovered", variable: "--accent", fallback: "#EFEFF1" }, + { field: "bgBubble", variable: "--secondary", fallback: "#EDEDF3" }, + { field: "bgBubbleSelected", variable: "--background", fallback: "#FFFFFF" }, + { field: "bgSearchResult", variable: "--accent", fallback: "#fff9e3" }, + { field: "borderColor", variable: "--border", fallback: "rgba(115, 116, 131, 0.16)" }, + { field: "horizontalBorderColor", variable: "--border", fallback: "rgba(115, 116, 131, 0.16)" }, + { field: "drilldownBorder", variable: "--ring", fallback: "rgba(0, 0, 0, 0)" }, + { field: "linkColor", variable: "--primary", fallback: "#353fb5" }, +]; + +export function useGlideDataEditorTheme(): Partial { + const { theme, resolvedMode } = useTheme(); + const [paletteTheme, setPaletteTheme] = useState>(() => buildFallbackTheme()); + + useEffect(() => { + setPaletteTheme(buildThemeFromCssTokens()); + }, [resolvedMode, theme]); + + useEffect(() => { + if (typeof MutationObserver === "undefined" || typeof document === "undefined") { + return; + } + const root = document.documentElement; + if (!root) { + return; + } + const observer = new MutationObserver(() => { + setPaletteTheme(buildThemeFromCssTokens()); + }); + observer.observe(root, { attributes: true, attributeFilter: ["class", "data-theme"] }); + return () => observer.disconnect(); + }, []); + + return paletteTheme; +} + +function buildThemeFromCssTokens(): Partial { + return GLIDE_THEME_TOKEN_MAP.reduce>((acc, tokenMap) => { + acc[tokenMap.field] = resolveCssRgbaColor(tokenMap.variable, tokenMap.fallback); + return acc; + }, {}); +} + +function buildFallbackTheme(): Partial { + return GLIDE_THEME_TOKEN_MAP.reduce>((acc, tokenMap) => { + acc[tokenMap.field] = tokenMap.fallback; + return acc; + }, {}); +} From bee4e8cc7ebfee85f47f25a9e24e782a3442b052 Mon Sep 17 00:00:00 2001 From: Justin Kropp Date: Thu, 12 Feb 2026 15:31:12 -0800 Subject: [PATCH 2/2] fix(web): make glide theme adapter pass strict typecheck --- frontend/src/providers/theme/glideTheme.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/frontend/src/providers/theme/glideTheme.ts b/frontend/src/providers/theme/glideTheme.ts index aef27692b..8b5884a25 100644 --- a/frontend/src/providers/theme/glideTheme.ts +++ b/frontend/src/providers/theme/glideTheme.ts @@ -64,15 +64,16 @@ export function useGlideDataEditorTheme(): Partial { } function buildThemeFromCssTokens(): Partial { - return GLIDE_THEME_TOKEN_MAP.reduce>((acc, tokenMap) => { - acc[tokenMap.field] = resolveCssRgbaColor(tokenMap.variable, tokenMap.fallback); - return acc; - }, {}); + return Object.fromEntries( + GLIDE_THEME_TOKEN_MAP.map((tokenMap) => [ + tokenMap.field, + resolveCssRgbaColor(tokenMap.variable, tokenMap.fallback), + ]), + ) as Partial; } function buildFallbackTheme(): Partial { - return GLIDE_THEME_TOKEN_MAP.reduce>((acc, tokenMap) => { - acc[tokenMap.field] = tokenMap.fallback; - return acc; - }, {}); + return Object.fromEntries( + GLIDE_THEME_TOKEN_MAP.map((tokenMap) => [tokenMap.field, tokenMap.fallback]), + ) as Partial; }