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
103 changes: 103 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -608,3 +608,106 @@ export function ensureShadowCss(el: HTMLElement, cssText: string, key: string):
style.textContent = cssText;
container.appendChild(style);
}

// Non-DOM renderer theme resolution. A canvas/WebGL widget never reads CSS,
// so it resolves the --mw-* values it needs into plain JS. Core only provides
// the primitives below; see chart/src/theme.ts for how one widget uses them.

/**
* Resolve a `--mw-*` custom property to its used value.
*
* A direct `getComputedStyle(el).getPropertyValue(varName)` leaves nested
* `var()` references unsubstituted, so tokens like `--mw-color-text` that
* fall back through `--myst-*`/`--jp-*` chains come back unresolved.
* Assigning the same expression to a real CSS property forces the browser to
* resolve it. See
* https://css-tricks.com/making-sense-of-custom-properties-runtime-values/.
*
* One hidden probe per `(el, cssProperty)` is created once and reused,
* instead of inserted and removed on every call.
*/
function getProbe(el: HTMLElement, cssProperty: string): HTMLElement {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@batpad this is the slightly annoying/ugly part, eg Chart.js needs a real hex code and not a CSS var name. Problem is our tokens fall back through a whole chain (--mw-color-text to --myst-* to --jp-* to hex) which is good, but then if you just do getComputedStyle(el).getPropertyValue("--mw-color-text") the browser will give you back the unresolved var() expression, not the actual color. So the workaround is to add a hidden / throwaway <span>, assign the var() to a real CSS property on it (like color) and then read THAT back for the browser to resolve it.

const attr = `data-mw-probe-${cssProperty}`;
const existing = el.querySelector<HTMLElement>(`:scope > [${attr}]`);
if (existing) return existing;
const probe = document.createElement("span");
probe.style.cssText = "display:none;";
probe.setAttribute(attr, "");
el.appendChild(probe);
return probe;
}

function resolveCssVar(el: HTMLElement, cssProperty: string, varExpression: string, fallback: string): string {
const probe = getProbe(el, cssProperty);
probe.style.setProperty(cssProperty, `var(${varExpression}, ${fallback})`);
const value = getComputedStyle(probe).getPropertyValue(cssProperty).trim();
// Environments with no CSS custom-property support in getComputedStyle
// (notably jsdom) hand the var() expression back verbatim.
return value && !value.includes("var(") ? value : fallback;
}

/** Resolve a `--mw-*` token as a used color (e.g. `--mw-color-text`). */
export function resolveThemeColor(el: HTMLElement, varName: string, fallback: string): string {
return resolveCssVar(el, "color", varName, fallback);
}

/** Resolve a `--mw-*` token as a used font family. */
export function resolveThemeFontFamily(el: HTMLElement, varName: string, fallback: string): string {
return resolveCssVar(el, "font-family", varName, fallback);
}

/** Resolve a `--mw-*` token as a used font weight (e.g. `600`, `"bold"`). */
export function resolveThemeFontWeight(el: HTMLElement, varName: string, fallback: string): string {
return resolveCssVar(el, "font-weight", varName, fallback);
}

/** Resolve a `--mw-*` font-size token to its used pixel value. */
export function resolveThemeFontSize(el: HTMLElement, varName: string, fallback: number): number {
const n = Number.parseFloat(resolveCssVar(el, "font-size", varName, `${fallback}px`));
return Number.isFinite(n) ? n : fallback;
}

export interface ResolveThemePaletteOptions {
/** Used for every entry the cascade doesn't override. Required, core has no default palette. */
fallback: string[];
/** Custom property naming the palette length. Defaults to `--mw-palette-size`. */
sizeVar?: string;
/** Maps a 1-based index to its custom property name. Defaults to `--mw-palette-{i}`. */
colorVar?: (index: number) => string;
}

/**
* Resolve an indexed categorical palette (by default `--mw-palette-1`,
* `--mw-palette-2`, and so on, sized by `--mw-palette-size`) into a plain
* array. Reads the custom properties directly, unlike {@link
* resolveThemeColor}, since `Theme.to_vars()` only ever writes literal
* colors here.
*/
export function resolveThemePalette(el: HTMLElement, options: ResolveThemePaletteOptions): string[] {
const { fallback, sizeVar = "--mw-palette-size", colorVar = (i: number) => `--mw-palette-${i}` } = options;
const computed = getComputedStyle(el);
const size = Number.parseInt(computed.getPropertyValue(sizeVar).trim(), 10);
const count = Number.isFinite(size) && size > 0 ? size : fallback.length;
const palette: string[] = [];
for (let i = 1; i <= count; i++) {
const literal = computed.getPropertyValue(colorVar(i)).trim();
palette.push(literal || fallback[(i - 1) % fallback.length]);
}
return palette;
}

type ThemeFieldResolver<T> = (el: HTMLElement) => T;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the only framework-ish bit, it just turns a { field: resolver } object into one (el) => theme function. So if you're adding theming to a new canvas/webgl widget, chart/src/theme.ts below is the file to copy and swap the fields for whatever your lib needs.

We need to document this better. But I think it'd be cool to extend the agent skill, so that if a new dev wants to generate a new widget, it will ask them a bunch of clarifying questions (eg. name of the widget, is it canvas/webgl based, etc) and based on that copy a template that can be based on chart/theme.ts. cc @batpad @wrynearson


/** Turn a `{ field: (el) => value }` map into a single `(el) => theme` function. */
export function defineThemeReader<T extends object>(
resolvers: { [K in keyof T]: ThemeFieldResolver<T[K]> },
): (el: HTMLElement) => T {
const entries = Object.entries(resolvers) as [keyof T, ThemeFieldResolver<T[keyof T]>][];
return (el: HTMLElement): T => {
const theme = {} as T;
for (const [field, resolve] of entries) {
theme[field] = resolve(el);
}
return theme;
};
}
108 changes: 108 additions & 0 deletions packages/core/tests/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
applyThemeVars,
asNumber,
defineThemeReader,
deliverCustomMessage,
detectHostColorMode,
onChanges,
renderChild,
resolveModel,
resolveThemeColor,
resolveThemeFontSize,
resolveThemePalette,
safeSaveChanges,
setByPath,
} from "@manywidgets/core";
Expand Down Expand Up @@ -295,3 +299,107 @@ describe("applyThemeVars", () => {
expect(el.dataset.mwColorMode).toBe("light");
});
});

describe("resolveThemePalette", () => {
it("reads an explicit palette and size from --mw-palette-* custom properties", () => {
const el = mountEl();
const m = fakeModel({
theme_vars: {
"--mw-palette-size": "3",
"--mw-palette-1": "#111111",
"--mw-palette-2": "#222222",
"--mw-palette-3": "#333333",
"--mw-palette-4": "#444444",
},
});
applyThemeVars(el, m as never);

expect(resolveThemePalette(el, { fallback: ["#000000"] })).toEqual([
"#111111", "#222222", "#333333",
]);
});

it("falls back to the caller-supplied palette when no tokens are set", () => {
const el = mountEl();
const m = fakeModel({ theme_vars: {} });
applyThemeVars(el, m as never);

expect(resolveThemePalette(el, { fallback: ["#aaaaaa", "#bbbbbb"] })).toEqual([
"#aaaaaa", "#bbbbbb",
]);
});

it("supports a custom var namespace", () => {
const el = mountEl();
const m = fakeModel({
theme_vars: { "--mw-map-size": "2", "--mw-map-1": "#123123", "--mw-map-2": "#456456" },
});
applyThemeVars(el, m as never);

const palette = resolveThemePalette(el, {
fallback: ["#000000"],
sizeVar: "--mw-map-size",
colorVar: (i) => `--mw-map-${i}`,
});
expect(palette).toEqual(["#123123", "#456456"]);
});
});

describe("resolveThemeColor / resolveThemeFontSize", () => {
it("return the caller-supplied fallback", () => {
const el = mountEl();
const m = fakeModel({ theme_vars: {} });
applyThemeVars(el, m as never);

expect(resolveThemeColor(el, "--mw-color-text", "#123456")).toBe("#123456");
expect(resolveThemeFontSize(el, "--mw-font-size-md", 14)).toBe(14);
});

it("reuses one hidden probe per property instead of inserting/removing on every call", () => {
const el = mountEl();
const m = fakeModel({ theme_vars: {} });
applyThemeVars(el, m as never);

resolveThemeColor(el, "--mw-color-text", "#111111");
resolveThemeColor(el, "--mw-color-text-muted", "#222222");
resolveThemeColor(el, "--mw-color-border", "#333333");
resolveThemeFontSize(el, "--mw-font-size-md", 14);
resolveThemeFontSize(el, "--mw-font-size-sm", 12);

expect(el.querySelectorAll("[data-mw-probe-color]").length).toBe(1);
expect(el.querySelectorAll("[data-mw-probe-font-size]").length).toBe(1);
});
});

describe("defineThemeReader", () => {
it("runs each field's resolver and assembles the result", () => {
const el = mountEl();
const m = fakeModel({ theme_vars: {} });
applyThemeVars(el, m as never);

interface FakeTheme {
textColor: string;
count: number;
}
const readFakeTheme = defineThemeReader<FakeTheme>({
textColor: (target) => resolveThemeColor(target, "--mw-color-text", "#abcdef"),
count: () => 42,
});

expect(readFakeTheme(el)).toEqual({ textColor: "#abcdef", count: 42 });
});

it("re-runs resolvers on every call, not just once", () => {
const el = mountEl();
let calls = 0;
const readFakeTheme = defineThemeReader<{ value: number }>({
value: () => {
calls += 1;
return calls;
},
});

expect(readFakeTheme(el)).toEqual({ value: 1 });
expect(readFakeTheme(el)).toEqual({ value: 2 });
});
});
8 changes: 6 additions & 2 deletions src/manywidgets/button/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { RenderProps } from "@anywidget/types";
import { asNumber, safeSaveChanges } from "@manywidgets/core";
import { applyThemeVars, asNumber, safeSaveChanges } from "@manywidgets/core";

interface ButtonModel {
clicks: number;
label: string;
}

function render({ model, el }: RenderProps<ButtonModel>): void {
function render({ model, el }: RenderProps<ButtonModel>): () => void {
const disposeTheme = applyThemeVars(el, model);

const button = document.createElement("button");
button.className = "manywidgets-button";
button.type = "button";
Expand All @@ -22,6 +24,8 @@ function render({ model, el }: RenderProps<ButtonModel>): void {
model.on("change:label", () => {
button.textContent = model.get("label");
});

return disposeTheme;
}

export default { render };
17 changes: 8 additions & 9 deletions src/manywidgets/button/style.css
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
.manywidgets-button {
padding: 8px 18px;
font-size: 14px;
font-weight: 600;
font-size: var(--mw-font-size-md);
font-weight: var(--mw-font-weight-strong);
border: none;
border-radius: 6px;
cursor: pointer;
background: #0366d6;
color: #fff;
border-radius: var(--mw-input-radius);
cursor: var(--mw-cursor-button);
background: var(--mw-color-accent);
color: var(--mw-color-on-accent);
margin: 10px 0;
transition: background 0.15s ease, transform 0.1s ease;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, sans-serif;
font-family: var(--mw-font-family);
}

.manywidgets-button:hover {
background: #0256c7;
background: var(--mw-color-accent-hover);
}

.manywidgets-button:active {
Expand Down
Loading
Loading