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
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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)}`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ 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";

const GRID_ROW_HEIGHT = 24;
const GRID_HEADER_HEIGHT = 28;
const GRID_ROW_MARKER_WIDTH = 44;

const COMPACT_GRID_THEME: Partial<Theme> = {
const COMPACT_GRID_DENSITY_THEME: Partial<Theme> = {
baseFontStyle: "12px Inter, sans-serif",
headerFontStyle: "600 11px Inter, sans-serif",
editorFontSize: "12px",
Expand All @@ -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]);
Expand Down Expand Up @@ -126,7 +134,7 @@ export function DocumentPreviewGrid({
fixedShadowX
fixedShadowY
onPaste={false}
theme={COMPACT_GRID_THEME}
theme={dataEditorTheme}
/>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -36,6 +45,11 @@ function renderGrid(overrides: Partial<ComponentProps<typeof DocumentPreviewGrid
}

describe("DocumentPreviewGrid", () => {
beforeEach(() => {
dataEditorMock.mockClear();
useGlideDataEditorThemeMock.mockClear();
});

it("renders Glide DataEditor with compact, read-only defaults", () => {
renderGrid();

Expand All @@ -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", () => {
Expand Down
64 changes: 64 additions & 0 deletions frontend/src/providers/theme/__tests__/glideTheme.test.ts
Original file line number Diff line number Diff line change
@@ -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)");
});
});
116 changes: 116 additions & 0 deletions frontend/src/providers/theme/cssColor.ts
Original file line number Diff line number Diff line change
@@ -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})`;
}
Loading