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 changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
## Bug fixes

- Fix the graph widget failing to render in PyCharm and VS Code
- Fix the graph widget restyling the surrounding page in Marimo notebooks

## Improvements

Expand Down
1 change: 1 addition & 0 deletions js-applet/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"jsdom": "^30.1.0",
"oxfmt": "0.68.0",
"oxlint": "1.83.0",
"postcss": "8.5.28",
"typescript": "7.0.2",
"vite": "^8.3.0",
"vite-plugin-singlefile": "^2.3.3",
Expand Down
16 changes: 16 additions & 0 deletions js-applet/src/graph-widget.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,22 @@ describe("graph-widget button testing", () => {
}
});

it("marks the widget wrapper as the NDL scope root", async () => {
const { el, teardown } = await renderWidget();

try {
await waitFor(() => {
// The build scopes the NDL stylesheet under this attribute so it cannot
// reset the host page when applied at the document level (see GDS-440).
expect(el.querySelector('[data-neo4j-viz-ndl="true"]')).toBeTruthy();
});
} finally {
if (typeof teardown === "function") {
await teardown();
}
}
});

it("updates the resolved theme when host theme classes change after mount", async () => {
document.body.className = "light-theme";

Expand Down
1 change: 1 addition & 0 deletions js-applet/src/graph-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,7 @@ function GraphWidget() {
<NeedleThemeProvider theme={resolvedTheme} wrapperProps={{ isWrappingChildren: false }}>
<div
ref={wrapperRef}
data-neo4j-viz-ndl="true"
style={{
position: "relative",
height: height ?? "600px",
Expand Down
112 changes: 112 additions & 0 deletions js-applet/src/ndl-css-scope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { describe, expect, it } from "vitest";

import { NDL_SCOPE_SELECTOR, scopeNdlCss } from "./ndl-css-scope";

const S = NDL_SCOPE_SELECTOR;
const W = `:where(${S})`;

describe("scopeNdlCss", () => {
it("scopes bare element selectors under the widget wrapper", () => {
expect(scopeNdlCss("h1,h2,h3,h4,h5,h6{font-size:inherit}")).toBe(
`${W} h1,${W} h2,${W} h3,${W} h4,${W} h5,${W} h6{font-size:inherit}`,
);
});

it("keeps NDL class rules untouched", () => {
expect(scopeNdlCss(".ndl-btn{color:red}")).toBe(".ndl-btn{color:red}");
expect(scopeNdlCss(".n-display{font-size:30px}")).toBe(".n-display{font-size:30px}");
expect(scopeNdlCss(".cm-editor .cm-button{cursor:pointer}")).toBe(
".cm-editor .cm-button{cursor:pointer}",
);
});

it("scopes only the bare-element part of a mixed selector list", () => {
expect(scopeNdlCss("h1,.n-display{font-size:30px}")).toBe(`${W} h1,.n-display{font-size:30px}`);
});

it("maps document roots onto the wrapper", () => {
expect(scopeNdlCss("body{margin:0}")).toBe(`${W}{margin:0}`);
expect(scopeNdlCss("html,:host{line-height:1.5}")).toBe(`${W},:host{line-height:1.5}`);
});

it("expands the universal selector to cover the wrapper and its descendants", () => {
expect(scopeNdlCss("*,:before,:after{box-sizing:border-box}")).toBe(
`${W}, ${W} *,${W} *:before,${W} *:after{box-sizing:border-box}`,
);
});

it("attaches bare pseudo selectors to a descendant universal", () => {
expect(scopeNdlCss("::backdrop{color:red}")).toBe(`${W} *::backdrop{color:red}`);
expect(scopeNdlCss(":focus-visible{outline:auto}")).toBe(`${W} *:focus-visible{outline:auto}`);
expect(scopeNdlCss("input::-moz-placeholder{opacity:1}")).toBe(
`${W} input::-moz-placeholder{opacity:1}`,
);
});

it("scopes attribute-only selectors", () => {
expect(scopeNdlCss("button,[role=button]{cursor:pointer}")).toBe(
`${W} button,${W} [role=button]{cursor:pointer}`,
);
});

it("leaves design-token definitions untouched", () => {
expect(scopeNdlCss(":where(:root,:host){--theme-color-primary:#fff}")).toBe(
":where(:root,:host){--theme-color-primary:#fff}",
);
});

it("recurses into group at-rules but not into declaration at-rules", () => {
expect(scopeNdlCss("@media (min-width:1px){h1{color:red}}")).toBe(
`@media (min-width:1px){${W} h1{color:red}}`,
);
expect(scopeNdlCss("@font-face{font-family:X;src:url(x.woff2)}")).toBe(
"@font-face{font-family:X;src:url(x.woff2)}",
);
expect(scopeNdlCss("@keyframes spin{from{opacity:0}to{opacity:1}}")).toBe(
"@keyframes spin{from{opacity:0}to{opacity:1}}",
);
expect(scopeNdlCss("@property --x{syntax:'<color>'}")).toBe("@property --x{syntax:'<color>'}");
});

it("preserves specificity by zeroing the scope with :where", () => {
expect(scopeNdlCss("a{color:inherit}")).toBe(`${W} a{color:inherit}`);
expect(scopeNdlCss("a{color:inherit}")).not.toContain(`${S} a`);
});

it("does not split commas inside :where()/:not() or strings", () => {
expect(scopeNdlCss(":where(:root,:host){--x:1}")).toBe(":where(:root,:host){--x:1}");
expect(scopeNdlCss('a[href="x,y"]{color:red}')).toBe(`${W} a[href="x,y"]{color:red}`);
});

it("does not confuse attribute values with class selectors", () => {
expect(scopeNdlCss("input:where([type=button]){border:0}")).toBe(
`${W} input:where([type=button]){border:0}`,
);
});

it("keeps leading comments and does not treat them as class selectors", () => {
expect(
scopeNdlCss(
"/* ! tailwindcss | https://tailwindcss.com */\n*,\n::before{box-sizing:border-box}",
),
).toBe(
`/* ! tailwindcss | https://tailwindcss.com */\n${W}, ${W} *,${W} *::before{box-sizing:border-box}`,
);
});

it("relocates theme-state declarations from the document root to the wrapper", () => {
expect(
scopeNdlCss(
":where(:root,:host){--theme-x:1;color-scheme:light;--lightningcss-light:initial;--lightningcss-dark: }",
),
).toBe(
`:where(:root,:host){--theme-x:1}:where(${S}){color-scheme:light;--lightningcss-light:initial;--lightningcss-dark: }`,
);
});

it("does not relocate theme state from class-scoped theme rules", () => {
expect(scopeNdlCss(".ndl-theme-dark{color-scheme:dark;--lightningcss-dark:initial}")).toBe(
".ndl-theme-dark{color-scheme:dark;--lightningcss-dark:initial}",
);
});
});
104 changes: 104 additions & 0 deletions js-applet/src/ndl-css-scope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Build-time scoping for the Neo4j Design Language (NDL) stylesheet.
*
* NDL ships a Tailwind-style preflight with bare-element selectors (`h1`,
* `a`, `ol,ul,menu`, `*`, `body`, …). The widget applies the whole stylesheet
* at the host document level (as the overlay copy in `document.head` used for
* portaled menus, and as `_css` where the host mounts it document-wide), so
* those bare selectors reset the host page's own typography, links, lists and
* form controls.
*
* This transform rewrites every selector that could match host content so it
* only matches inside the widget, keyed off a scope attribute on the widget
* wrapper. Class/id-scoped rules (`.ndl-*`, `.n-*`, `.cm-*`, …) are left
* untouched: they can only match NDL content, and keeping them unscoped means
* overlays portaled outside the wrapper (menus, tooltips) stay styled. The
* scope is wrapped in `:where(...)` so it adds no specificity and the cascade
* inside the widget is unchanged.
*
* Structure (strings, comments, at-rules, selector lists) is parsed by postcss;
* only the scoping policy below is ours.
*/

import postcss, { type Declaration, type Rule } from "postcss";

export const NDL_SCOPE_ATTRIBUTE = "data-neo4j-viz-ndl";
export const NDL_SCOPE_SELECTOR = `[${NDL_SCOPE_ATTRIBUTE}]`;

// Selectors that describe the document root; inside the widget the wrapper
// plays that role.
const ROOT_SELECTORS = new Set(["html", "body", ":root", ":host"]);

// A class or id selector, e.g. `.ndl-btn` / `#root`. Requires a name after the
// sigil and is not preceded by an attribute/string operator, so `[href*=".com"]`
// is not mistaken for a class.
const CLASS_OR_ID_RE = /(?<![="'*~|^$])[.#][A-Za-z_-]/;

// Theme-state declarations NDL puts on the document root that collide with
// hosts using the same lightningcss/color-scheme toggles (e.g. Marimo).
const THEME_STATE_PROPS = new Set(["color-scheme", "--lightningcss-light", "--lightningcss-dark"]);

export function scopeNdlCss(css: string, scope: string = NDL_SCOPE_SELECTOR): string {
const root = postcss.parse(css);
const where = `:where(${scope})`;

root.walkRules((rule) => {
// `@keyframes` step selectors (`from`, `to`, `50%`) are rules too.
if (isInsideKeyframes(rule)) return;
rule.selector = rule.selectors.map((selector) => scopeSelector(selector, where)).join(",");
});

// Separate pass so the relocated theme-state rule (added below) is not scoped.
root.walkRules((rule) => {
if (isInsideKeyframes(rule)) return;
if (rule.selectors.some(isThemeRootSelector)) relocateThemeState(rule, where);
});

return root.toString();
}

/**
* NDL declares its theme state (`color-scheme` and the lightningcss light/dark
* toggles) on the document root. Hosts use the same lightningcss toggles
* (Marimo), so leaving them on `:root` flips the host's own `light-dark()`
* resolution. Move them to the widget wrapper, where NDL content inherits them
* and the host is untouched.
*/
function relocateThemeState(rule: Rule, where: string): void {
const moved: Declaration[] = [];
rule.walkDecls((decl) => {
if (THEME_STATE_PROPS.has(decl.prop)) moved.push(decl);
});
if (moved.length === 0) return;

const relocated = postcss.rule({ selector: where });
relocated.raws.before = "";
for (const decl of moved) {
decl.remove();
relocated.append(decl);
}
rule.parent?.insertAfter(rule, relocated);
}

function scopeSelector(selector: string, where: string): string {
if (!selector) return selector;
// Token/theme definitions and NDL-scoped rules cannot match host content.
if (isThemeRootSelector(selector)) return selector;
if (CLASS_OR_ID_RE.test(selector)) return selector;

if (ROOT_SELECTORS.has(selector)) return where;
if (selector === "*") return `${where}, ${where} *`;
// Bare pseudo-elements/classes (`::backdrop`, `:before`, `:-moz-focusring`)
// need an explicit universal to attach to.
if (selector.startsWith(":")) return `${where} *${selector}`;
return `${where} ${selector}`;
}

function isThemeRootSelector(selector: string): boolean {
return selector.includes(":root") || selector.includes(":host");
}

function isInsideKeyframes(rule: Rule): boolean {
const parent = rule.parent;
return parent?.type === "atrule" && parent.name.toLowerCase().endsWith("keyframes");
}
19 changes: 19 additions & 0 deletions js-applet/vite-plugin-scope-ndl-css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { Plugin } from "vite";

import { scopeNdlCss } from "./src/ndl-css-scope.ts";

// Scopes the NDL stylesheet to the widget wrapper at build time so it can be
// applied at the host document level (Marimo mounts the widget's `_css`
// globally) without resetting the host page. See src/ndl-css-scope.ts.
const NDL_STYLESHEET = "neo4j-ds-styles.css";

export function scopeNdlCssPlugin(): Plugin {
return {
name: "neo4j-viz:scope-ndl-css",
enforce: "pre",
transform(code, id) {
if (!id.includes(NDL_STYLESHEET)) return;
return { code: scopeNdlCss(code), map: null };
},
};
}
3 changes: 2 additions & 1 deletion js-applet/vite.config.html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";

import { cleanWorkerChunks } from "./vite-plugin-clean-worker-chunks.ts";
import { scopeNdlCssPlugin } from "./vite-plugin-scope-ndl-css.ts";

// HTML build: produces a single self-contained index.html with all JS and
// CSS inlined. Python injects graph data at runtime as an inert
// <script type="application/json" id="neo4j-viz-data"> block before serving.
export default defineConfig({
plugins: [react(), viteSingleFile(), cleanWorkerChunks()],
plugins: [react(), viteSingleFile(), scopeNdlCssPlugin(), cleanWorkerChunks()],
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
// See vite.config.ts: neutralize the UMD/AMD `define()` branch of bundled deps.
Expand Down
3 changes: 2 additions & 1 deletion js-applet/vite.config.streamlit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

import { cleanWorkerChunks } from "./vite-plugin-clean-worker-chunks.ts";
import { scopeNdlCssPlugin } from "./vite-plugin-scope-ndl-css.ts";

// Streamlit build: produces an ES module (graph.js + style.css) shipped as package
// data and passed inline as the `js`/`css` of an st.components.v2.component (see
// python-wrapper/src/neo4j_viz/streamlit.py). The module's default export is the v2
// mount function in src/streamlit-entrypoint.ts.
export default defineConfig({
plugins: [react(), cleanWorkerChunks()],
plugins: [react(), scopeNdlCssPlugin(), cleanWorkerChunks()],
define: {
// React reads process.env.NODE_ENV at runtime.
"process.env.NODE_ENV": JSON.stringify("production"),
Expand Down
3 changes: 2 additions & 1 deletion js-applet/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

import { cleanWorkerChunks } from "./vite-plugin-clean-worker-chunks.ts";
import { scopeNdlCssPlugin } from "./vite-plugin-scope-ndl-css.ts";

// ESM lib build for anywidget (produces widget.js + style.css).
// Dev server: `yarn dev` starts Vite with HMR via @anywidget/vite.
// Python widget points _esm at http://localhost:5173/src/index.tsx?anywidget
export default defineConfig({
plugins: [react(), anywidget(), cleanWorkerChunks()],
plugins: [react(), anywidget(), scopeNdlCssPlugin(), cleanWorkerChunks()],
define: {
// React reference process.env.NODE_ENV at runtime
"process.env.NODE_ENV": JSON.stringify("production"),
Expand Down
2 changes: 1 addition & 1 deletion js-applet/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4427,7 +4427,7 @@ points-on-path@^0.2.1:
path-data-parser "0.1.0"
points-on-curve "0.2.0"

postcss@^8.5.28:
postcss@8.5.28, postcss@^8.5.28:
version "8.5.28"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.28.tgz#da4563a99a06e62d6c1cd1acae363224bcaed6e9"
integrity sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==
Expand Down
4 changes: 2 additions & 2 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,12 @@ streamlit:
marimo:
#!/usr/bin/env bash
set -e
cd {{py_dir}} && uv run --group notebook marimo run {{root_dir}}/examples/marimo-example.py
cd {{py_dir}} && uv run marimo run {{root_dir}}/examples/marimo-example.py

marimo-edit:
#!/usr/bin/env bash
set -e
cd {{py_dir}} && uv run --group notebook marimo edit {{root_dir}}/examples/marimo-example.py
cd {{py_dir}} && uv run marimo edit {{root_dir}}/examples/marimo-example.py

ref-docs:
./scripts/render_antora_docs.sh
Expand Down
2 changes: 1 addition & 1 deletion python-wrapper/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ dev = [
"streamlit==1.64.0",
"matplotlib>=3.9.4",
"jupyterlab>=4.5.7",
"marimo",
"anywidget[dev]",
"python-dotenv",
]
Expand All @@ -86,7 +87,6 @@ notebook = [
"matplotlib>=3.9.4",
"snowflake-snowpark-python==1.55.0",
"requests",
"marimo",
"python-dotenv"
]

Expand Down
Loading
Loading