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
1 change: 1 addition & 0 deletions .gitattributes

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.

Added this to mark the generated token file as generated code so GitHub can exclude it from language stats and de-emphasize it in review. I was hoping it would also reduce the visible lines-changed count in the PR bar but it looks like GitHub still includes it there

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
src/manywidgets/themes/_radix_tokens.py linguist-generated=true
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
"typecheck": "tsc --noEmit -p tsconfig.json",
"test": "vitest run",
"test:watch": "vitest",
"tokens:gen": "node scripts/generate_radix_tokens.mjs",
"tokens:check": "node scripts/generate_radix_tokens.mjs --check",
"docs:gen": "python scripts/build_widget_docs.py",
"skill:gen": "python scripts/build_skill_reference.py",
"serve": "python -m http.server -d docs/_build/html 3030",
Expand Down
224 changes: 221 additions & 3 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ export function deliverCustomMessage(
}
}

// ── Static-export host registry ──────────────────────────────────────────────
// Static-export host registry

type Registry = {
get?: (key: string) => AnyModel | undefined;
Expand Down Expand Up @@ -313,7 +313,7 @@ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
});
}

// ── Child rendering (layout widgets) ─────────────────────────────────────────
// Child rendering for layout widgets

const noop = (): void => {};

Expand Down Expand Up @@ -367,7 +367,225 @@ export async function renderChild(
};
}

// ── Shadow-DOM-safe CSS injection ────────────────────────────────────────────
// Theme variables

export type HostColorMode = "light" | "dark";

export const HOST_DARK_MODE_SELECTORS = [
".dark",
".dark-theme",
"[data-theme='dark']",
"[data-color-mode='dark']",
] as const;

export const HOST_LIGHT_MODE_SELECTORS = [
".light",
".light-theme",
"[data-theme='light']",
"[data-color-mode='light']",
] as const;

const THEME_DEFAULT_CSS = `
.manywidgets-theme-defaults {
--mw-font-family: var(--jp-ui-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif);
--mw-font-family-mono: var(--jp-code-font-family, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
--mw-font-size-sm: 12px;
--mw-font-size-md: 14px;
--mw-font-weight-strong: 600;
--mw-color-text: var(--myst-color-text, var(--jp-content-font-color1, #24292e));
--mw-color-text-muted: var(--myst-color-text-secondary, var(--jp-content-font-color2, #586069));
--mw-color-surface: var(--myst-color-bg, var(--jp-layout-color1, #ffffff));
--mw-color-surface-elevated: var(--myst-color-surface, var(--jp-layout-color2, #f6f8fa));
--mw-color-border: var(--myst-color-border, var(--jp-border-color2, #e1e4e8));
--mw-color-border-strong: var(--myst-color-border-strong, var(--jp-border-color1, #d0d7de));
--mw-color-accent: var(--myst-color-primary, var(--jp-brand-color1, #0366d6));
--mw-color-accent-hover: var(--myst-color-primary-hover, var(--jp-brand-color0, #0256c7));
--mw-color-accent-soft: var(--myst-color-active-bg, #ddf4ff);
--mw-color-on-accent: #ffffff;
--mw-color-code-bg: var(--myst-color-bg-secondary, var(--jp-layout-color2, #f6f8fa));
--mw-color-positive: var(--myst-color-success, #1a7f37);
--mw-color-negative: var(--myst-color-danger, #cf222e);
--mw-color-warning: var(--myst-color-warning, #9a6700);
--mw-color-info: var(--myst-color-info, var(--mw-color-accent));
--mw-color-focus: var(--myst-color-focus-ring, var(--mw-color-accent));
--mw-radius-2: 4px;
--mw-radius-3: 6px;
--mw-radius-4: 8px;
--mw-radius-5: 10px;
--mw-radius-thumb: 9999px;
--mw-shadow-1: 0 1px 2px rgba(0, 0, 0, 0.25);
--mw-shadow-2: 0 0 0 1px rgba(0, 0, 0, 0.1);
--mw-control-padding-x: 14px;
--mw-control-padding-y: 10px;
--mw-control-gap: 6px;
--mw-control-max-width: 320px;
--mw-control-radius: var(--mw-radius-4);
--mw-input-padding-x: 8px;
--mw-input-padding-y: 6px;
--mw-input-radius: var(--mw-radius-3);
--mw-panel-padding-x: 18px;
--mw-panel-padding-y: 14px;
--mw-panel-radius: var(--mw-radius-5);
--mw-stat-min-width: 140px;
--mw-stat-value-size: 30px;
--mw-number-display-value-size: 40px;
--mw-toggle-track-width: 40px;
--mw-toggle-track-height: 22px;
--mw-toggle-thumb-size: 18px;
--mw-cursor-button: pointer;
--mw-cursor-slider-thumb: default;
}

.manywidgets-theme-defaults[data-mw-color-mode="dark"] {
--mw-color-text: var(--myst-color-text, var(--jp-content-font-color1, #f0f6fc));
--mw-color-text-muted: var(--myst-color-text-secondary, var(--jp-content-font-color2, #8b949e));
--mw-color-surface: var(--myst-color-bg, var(--jp-layout-color1, #111113));
--mw-color-surface-elevated: var(--myst-color-surface, var(--jp-layout-color2, #18191b));
--mw-color-border: var(--myst-color-border, var(--jp-border-color2, #3a3f44));
--mw-color-border-strong: var(--myst-color-border-strong, var(--jp-border-color1, #4d5358));
--mw-color-accent: var(--myst-color-primary, var(--jp-brand-color1, #6b9cff));
--mw-color-accent-hover: var(--myst-color-primary-hover, var(--jp-brand-color0, #8bb3ff));
--mw-color-accent-soft: var(--myst-color-active-bg, #1d2d50);
--mw-color-code-bg: var(--myst-color-bg-secondary, var(--jp-layout-color2, #1f2328));
--mw-color-positive: var(--myst-color-success, #7ee787);
--mw-color-negative: var(--myst-color-danger, #ff7b72);
--mw-color-warning: var(--myst-color-warning, #f2cc60);
--mw-shadow-1: 0 1px 2px rgba(0, 0, 0, 0.5);
--mw-shadow-2: 0 0 0 1px rgba(255, 255, 255, 0.12);
}
`;

function readThemeVars(model: AnyModel): Record<string, unknown> {
const vars = model.get("theme_vars");
return vars && typeof vars === "object" ? vars as Record<string, unknown> : {};
}

function colorModeFromThemeVars(vars: Record<string, unknown>): HostColorMode | null {
const mode = vars["--mw-color-mode"];
if (typeof mode !== "string") return null;
const normalized = mode.trim().toLowerCase();
if (normalized === "dark" || normalized === "light") return normalized;
return null;
}

function elementHasSelector(el: Element, selectors: readonly string[]): boolean {
return selectors.some((selector) => el.matches(selector) || !!el.closest(selector));
}

function hasThemeAncestor(el: HTMLElement): boolean {
if (el.parentElement?.closest(".manywidgets-theme")) return true;
const root = el.getRootNode();
const host = typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot ? root.host : null;
return !!host?.closest(".manywidgets-theme");
}

/**
* Detect the host page's current color mode.
*
* MyST's default themes expose dark mode through Tailwind's `.dark` class.
* The extra selectors cover common class/data-attribute host conventions so
* widgets can share one detection path in notebooks and static exports.
*/
export function detectHostColorMode(el: HTMLElement): HostColorMode | null {
const doc = el.ownerDocument;
const root = el.getRootNode();
const host = typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot ? root.host : null;
const candidates = [el, host, doc.body, doc.documentElement].filter(
(candidate): candidate is Element => candidate instanceof Element,
);

if (candidates.some((candidate) => elementHasSelector(candidate, HOST_DARK_MODE_SELECTORS))) {
return "dark";
}
if (candidates.some((candidate) => elementHasSelector(candidate, HOST_LIGHT_MODE_SELECTORS))) {
return "light";
}

const colorScheme = [el, doc.documentElement]
.map((candidate) => getComputedStyle(candidate).colorScheme)
.find((value) => value && value !== "normal") || "";
const schemes = colorScheme.split(/\s+/);
if (schemes.includes("dark") && !schemes.includes("light")) return "dark";
if (schemes.includes("light") && !schemes.includes("dark")) return "light";
return null;
}

export function observeHostColorMode(el: HTMLElement, fn: () => void): () => void {
const doc = el.ownerDocument;
const root = el.getRootNode();
const host = typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot ? root.host : null;
const targets = [host, doc.body, doc.documentElement].filter(
(target): target is Element => target instanceof Element,
);
const observer = typeof MutationObserver !== "undefined"
? new MutationObserver(fn)
: null;
for (const target of targets) {
observer?.observe(target, {
attributes: true,
attributeFilter: ["class", "data-theme", "data-color-mode", "style"],
});
}

const media = typeof globalThis.matchMedia === "function"
? globalThis.matchMedia("(prefers-color-scheme: dark)")
: null;
media?.addEventListener?.("change", fn);

return () => {
observer?.disconnect();
media?.removeEventListener?.("change", fn);
};
}

/**
* Apply a widget's `theme_vars` trait as inline manywidgets CSS custom
* properties on `el`.
*
* The Python side resolves `theme=` and `style=` to a flat `{ "--mw-*": value }`
* dict. This helper intentionally stays dumb: set each manywidgets-owned var,
* clear vars that disappeared and ignore any non-`--mw-*` key. Vars set here
* cascade to descendants, so a themed layout can theme nested widgets once
* individual widgets opt into this helper.
*/
export function applyThemeVars(el: HTMLElement, model: AnyModel): () => void {
let applied: string[] = [];
ensureShadowCss(el, THEME_DEFAULT_CSS, "manywidgets-theme-defaults");
el.classList.add("manywidgets-theme");
if (!hasThemeAncestor(el)) el.classList.add("manywidgets-theme-defaults");

const apply = () => {
const vars = readThemeVars(model);
const next = Object.keys(vars).filter((k) => k.startsWith("--mw-"));
for (const key of applied) {
if (!next.includes(key)) el.style.removeProperty(key);
}
for (const key of next) {
el.style.setProperty(key, String(vars[key]));
}
applied = next;
applyColorMode();
};

const applyColorMode = () => {
const mode = colorModeFromThemeVars(readThemeVars(model)) ?? detectHostColorMode(el);
if (mode) {
el.dataset.mwColorMode = mode;
} else {
delete el.dataset.mwColorMode;
}
};

apply();
const offTheme = onChange(model, "theme_vars", apply);
const offHost = observeHostColorMode(el, applyColorMode);
return () => {
offTheme();
offHost();
};
}

// Shadow-DOM-safe CSS injection

/**
* Inject a `<style>` block into the nearest shadow root (or document head),
Expand Down
103 changes: 103 additions & 0 deletions packages/core/tests/core.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
applyThemeVars,
asNumber,
deliverCustomMessage,
detectHostColorMode,
onChanges,
renderChild,
resolveModel,
Expand All @@ -16,6 +18,15 @@ import {
mountEl,
} from "@manywidgets/test-utils";

afterEach(() => {
document.documentElement.className = "";
document.documentElement.removeAttribute("data-theme");
document.documentElement.removeAttribute("data-color-mode");
document.body.className = "";
document.body.removeAttribute("data-theme");
document.body.removeAttribute("data-color-mode");
});

describe("asNumber", () => {
it("coerces and falls back", () => {
expect(asNumber(3)).toBe(3);
Expand Down Expand Up @@ -192,3 +203,95 @@ describe("renderChild", () => {
).rejects.toThrow();
});
});

describe("applyThemeVars", () => {
it("sets mw vars and ignores non-mw keys", () => {
const el = mountEl();
const m = fakeModel({
theme_vars: {
"--mw-color-mode": "dark",
"--mw-color-accent": "#3e63dd",
"--radix-accent-9": "#3e63dd",
},
});

applyThemeVars(el, m as never);

expect(el.classList.contains("manywidgets-theme")).toBe(true);
expect(el.dataset.mwColorMode).toBe("dark");
expect(el.style.getPropertyValue("--mw-color-accent")).toBe("#3e63dd");
expect(el.style.getPropertyValue("--radix-accent-9")).toBe("");
});

it("updates and removes stale vars on trait changes", () => {
const el = mountEl();
const m = fakeModel({
theme_vars: {
"--mw-color-accent": "#3e63dd",
"--mw-radius-4": "8px",
},
});

applyThemeVars(el, m as never);
m.set("theme_vars", { "--mw-color-accent": "#3358d4" });

expect(el.style.getPropertyValue("--mw-color-accent")).toBe("#3358d4");
expect(el.style.getPropertyValue("--mw-radius-4")).toBe("");
});

it("only attaches default tokens to the outer theme root", () => {
const parent = mountEl();
const child = document.createElement("div");
parent.appendChild(child);
const m = fakeModel({ theme_vars: {} });

applyThemeVars(parent, m as never);
applyThemeVars(child, m as never);

expect(parent.classList.contains("manywidgets-theme-defaults")).toBe(true);
expect(child.classList.contains("manywidgets-theme")).toBe(true);
expect(child.classList.contains("manywidgets-theme-defaults")).toBe(false);
});

it("defaults to verified MyST tokens before Jupyter and hardcoded fallbacks", () => {
const el = mountEl();
const m = fakeModel({ theme_vars: {} });

applyThemeVars(el, m as never);
const css = document.querySelector(
'style[data-manywidgets-css="manywidgets-theme-defaults"]',
)?.textContent ?? "";

expect(css).toContain(
"--mw-color-text: var(--myst-color-text, var(--jp-content-font-color1, #24292e));",
);
expect(css).toContain(
"--mw-color-surface: var(--myst-color-bg, var(--jp-layout-color1, #ffffff));",
);
expect(css).toContain("--mw-color-accent: var(--myst-color-primary");
expect(css).toContain("--mw-color-positive: var(--myst-color-success");
expect(css).toContain("--mw-color-negative: var(--myst-color-danger");
expect(css).toContain("--mw-color-focus: var(--myst-color-focus-ring");
});

it("detects MyST-style dark mode from the host page", () => {
document.documentElement.classList.add("dark");
const el = mountEl();
const m = fakeModel({ theme_vars: {} });

expect(detectHostColorMode(el)).toBe("dark");

applyThemeVars(el, m as never);
expect(el.dataset.mwColorMode).toBe("dark");
});

it("prefers explicit theme color mode over host color mode", () => {
document.documentElement.classList.add("dark");
const el = mountEl();
const m = fakeModel({ theme_vars: { "--mw-color-mode": "light" } });

applyThemeVars(el, m as never);

expect(el.dataset.mwColorMode).toBe("light");
});
});
7 changes: 7 additions & 0 deletions scripts/build_widget_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@

# Traits inherited from anywidget/ipywidgets that are not part of a widget's API.
_BASE_TRAITS = set(anywidget.AnyWidget.class_traits())
_INTERNAL_TRAITS = {
# User-facing theming happens through `theme=` and `style=` kwargs; this is
# only the resolved frontend sync payload.
"theme_vars",
}

# ```{code-cell} python ... ``` fenced blocks.
_CODE_CELL_RE = re.compile(r"```\{code-cell\}[^\n]*\n(.*?)\n```", re.DOTALL)
Expand Down Expand Up @@ -70,6 +75,8 @@ def public_traits(cls):
continue
if name in _BASE_TRAITS and name != "widget_id":
continue
if name in _INTERNAL_TRAITS:
continue
seen.add(name)
out.append((name, tr))
out.sort(key=lambda nt: nt[0] == "widget_id") # stable: widget_id to the end
Expand Down
Loading
Loading