diff --git a/js/packages/ui/src/components/lineage/__tests__/cllPaletteSync.test.ts b/js/packages/ui/src/components/lineage/__tests__/cllPaletteSync.test.ts new file mode 100644 index 000000000..50f699689 --- /dev/null +++ b/js/packages/ui/src/components/lineage/__tests__/cllPaletteSync.test.ts @@ -0,0 +1,168 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + cllAdditiveAccent, + cllAdditiveBadgeBg, + cllAdditiveBadgeFg, + cllChangedAccent, + cllChangedBadgeBg, + cllChangedBadgeFg, + cllChangeStatusBackgroundsDark, + cllChangeStatusBackgroundsLight, + cllChangeStatusColors, + cllImpactedAccent, + cllImpactedBadgeBg, + cllImpactedBadgeFg, +} from "../styles"; + +/** + * Single-source-of-truth guard for the CLL palette. + * + * The CLL colors are declared twice: as TS consts in `lineage/styles.tsx` + * (consumed by the React lineage canvas) and as `--schema-*` CSS custom + * properties in `schema/style.css` (consumed by the ag-grid schema view). + * Both files carry "keep in sync by hand — no build-time check" comments. + * + * This test IS that build-time check: it parses `schema/style.css` and asserts + * every TS const matches its CSS counterpart, so drift on either side fails CI. + * The TS consts are treated as canonical; the CSS values are verified against + * them. (A fuller consolidation — deriving the CSS vars from TS at runtime — is + * deferred; see DRC-3525.) + */ + +const cssPath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../schema/style.css", +); +const css = readFileSync(cssPath, "utf8"); + +/** Extract `--name: value` declarations from the first block matching a header. */ +function block(header: RegExp): Record { + const m = header.exec(css); + if (!m) throw new Error(`CSS block not found for header: ${header}`); + const open = css.indexOf("{", m.index); + const close = css.indexOf("}", open); + const body = css.slice(open + 1, close); + const vars: Record = {}; + for (const decl of body.split(";")) { + const i = decl.indexOf(":"); + if (i === -1) continue; + const name = decl.slice(0, i).trim(); + if (!name.startsWith("--")) continue; + vars[name] = decl.slice(i + 1).trim(); + } + return vars; +} + +const rootVars = block(/:root,\s*\.light\s*\{/); +const darkVars = block(/\n\.dark\s*\{/); +const cllVars = block(/\n\.cll-experience\s*\{/); +const cllDarkVars = block(/\.dark \.cll-experience,/); + +/** + * Resolve a CSS var the way the cascade does inside `.cll-experience`: + * the `.cll-experience` override wins, otherwise the base `:root`/`.dark` value. + */ +const lightCll = (name: string) => cllVars[name] ?? rootVars[name]; +const darkCll = (name: string) => cllDarkVars[name] ?? darkVars[name]; + +describe("CLL palette TS ↔ CSS sync (styles.tsx ↔ schema/style.css)", () => { + it("parses the expected CSS blocks", () => { + expect(Object.keys(rootVars).length).toBeGreaterThan(0); + expect(Object.keys(darkVars).length).toBeGreaterThan(0); + expect(Object.keys(cllVars).length).toBeGreaterThan(0); + expect(Object.keys(cllDarkVars).length).toBeGreaterThan(0); + }); + + it("accent colors match the *-accent vars", () => { + expect(cllChangeStatusColors.modified).toBe( + lightCll("--schema-color-changed-accent"), + ); + expect(cllChangeStatusColors.modified).toBe( + darkCll("--schema-color-changed-accent"), + ); + expect(cllChangeStatusColors.impacted).toBe( + lightCll("--schema-color-impacted-accent"), + ); + expect(cllChangeStatusColors.impacted).toBe( + darkCll("--schema-color-impacted-accent"), + ); + expect(cllChangeStatusColors.added).toBe( + lightCll("--schema-color-added-accent"), + ); + expect(cllChangeStatusColors.removed).toBe( + lightCll("--schema-color-removed-accent"), + ); + }); + + it("row backgrounds match the --schema-color-{changed,impacted,added,removed} vars", () => { + // light + expect(cllChangeStatusBackgroundsLight.modified).toBe( + lightCll("--schema-color-changed"), + ); + expect(cllChangeStatusBackgroundsLight.impacted).toBe( + lightCll("--schema-color-impacted"), + ); + expect(cllChangeStatusBackgroundsLight.added).toBe( + lightCll("--schema-color-added"), + ); + expect(cllChangeStatusBackgroundsLight.removed).toBe( + lightCll("--schema-color-removed"), + ); + // dark + expect(cllChangeStatusBackgroundsDark.modified).toBe( + darkCll("--schema-color-changed"), + ); + expect(cllChangeStatusBackgroundsDark.impacted).toBe( + darkCll("--schema-color-impacted"), + ); + expect(cllChangeStatusBackgroundsDark.added).toBe( + darkCll("--schema-color-added"), + ); + expect(cllChangeStatusBackgroundsDark.removed).toBe( + darkCll("--schema-color-removed"), + ); + }); + + it("impacted badge/accent tokens match the --schema-*impacted* vars", () => { + expect(cllImpactedAccent.light).toBe( + lightCll("--schema-color-impacted-accent"), + ); + expect(cllImpactedAccent.dark).toBe( + darkCll("--schema-color-impacted-accent"), + ); + expect(cllImpactedBadgeBg.light).toBe( + lightCll("--schema-badge-impacted-bg"), + ); + expect(cllImpactedBadgeBg.dark).toBe(darkCll("--schema-badge-impacted-bg")); + expect(cllImpactedBadgeFg.light).toBe( + lightCll("--schema-badge-impacted-fg"), + ); + expect(cllImpactedBadgeFg.dark).toBe(darkCll("--schema-badge-impacted-fg")); + }); + + it("changed badge/accent tokens match the --schema-*changed* vars", () => { + expect(cllChangedAccent).toBe(lightCll("--schema-color-changed-accent")); + expect(cllChangedAccent).toBe(darkCll("--schema-color-changed-accent")); + expect(cllChangedBadgeBg.light).toBe(lightCll("--schema-badge-changed-bg")); + expect(cllChangedBadgeBg.dark).toBe(darkCll("--schema-badge-changed-bg")); + expect(cllChangedBadgeFg.light).toBe(lightCll("--schema-badge-changed-fg")); + expect(cllChangedBadgeFg.dark).toBe(darkCll("--schema-badge-changed-fg")); + }); + + it("additive badge/accent tokens match the --schema-*added* vars", () => { + expect(cllAdditiveAccent.light).toBe( + lightCll("--schema-color-added-accent"), + ); + // Intentional: the additive accent's *dark* value mirrors the added badge + // foreground (a brighter green), not --schema-color-added-accent (which + // stays the mid green in both modes). + expect(cllAdditiveAccent.dark).toBe(darkCll("--schema-badge-added-fg")); + expect(cllAdditiveBadgeBg.light).toBe(lightCll("--schema-badge-added-bg")); + expect(cllAdditiveBadgeBg.dark).toBe(darkCll("--schema-badge-added-bg")); + expect(cllAdditiveBadgeFg.light).toBe(lightCll("--schema-badge-added-fg")); + expect(cllAdditiveBadgeFg.dark).toBe(darkCll("--schema-badge-added-fg")); + }); +}); diff --git a/js/packages/ui/src/components/lineage/__tests__/computeWholeModelImpact.test.ts b/js/packages/ui/src/components/lineage/__tests__/computeWholeModelImpact.test.ts index ae1f60b18..0c1cdb570 100644 --- a/js/packages/ui/src/components/lineage/__tests__/computeWholeModelImpact.test.ts +++ b/js/packages/ui/src/components/lineage/__tests__/computeWholeModelImpact.test.ts @@ -111,4 +111,33 @@ describe("computeWholeModelImpact", () => { expect(result.wholeModelImpactedNodeIds).toEqual(new Set(["a", "b"])); expect(result.wholeModelChangedNodeIds).toEqual(new Set(["a"])); }); + + it("does not bleed impact across disconnected components", () => { + // Component 1: a (changed) -> b + // Component 2: x -> y (no change, no edge to component 1) + // The wave from `a` must stay inside its own component. + const graph = makeGraph([ + ["a", "b"], + ["x", "y"], + ]); + const cll = makeCll(["a"], ["a", "b", "x", "y"]); + const result = computeWholeModelImpact(graph as any, cll as any); + expect(result.wholeModelImpactedNodeIds).toEqual(new Set(["a", "b"])); + expect(result.wholeModelChangedNodeIds).toEqual(new Set(["a"])); + expect(result.wholeModelImpactedNodeIds.has("x")).toBe(false); + expect(result.wholeModelImpactedNodeIds.has("y")).toBe(false); + }); + + it("handles a changed node absent from the lineage graph", () => { + // `ghost` is flagged breaking in the CLL but has no node in the + // lineage graph (CLL/graph can drift). It should still seed both sets, + // and the missing-node guard must skip its BFS expansion without throwing. + const graph = makeGraph([["a", "b"]]); + const cll = makeCll(["ghost"], ["a", "b", "ghost"]); + const result = computeWholeModelImpact(graph as any, cll as any); + expect(result.wholeModelChangedNodeIds).toEqual(new Set(["ghost"])); + expect(result.wholeModelImpactedNodeIds).toEqual(new Set(["ghost"])); + // No graph node for `ghost`, so nothing downstream is reached. + expect(result.wholeModelImpactedNodeIds.has("b")).toBe(false); + }); }); diff --git a/js/packages/ui/src/components/lineage/__tests__/wholeModelTreatment.test.ts b/js/packages/ui/src/components/lineage/__tests__/wholeModelTreatment.test.ts new file mode 100644 index 000000000..ac5e88857 --- /dev/null +++ b/js/packages/ui/src/components/lineage/__tests__/wholeModelTreatment.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import { + cllAdditiveAccent, + cllAdditiveBadgeBg, + cllAdditiveBadgeFg, + cllChangedAccent, + cllChangedBadgeBg, + cllChangedBadgeFg, + cllImpactedAccent, + cllImpactedBadgeBg, + cllImpactedBadgeFg, +} from "../styles"; +import { + pickGraphBadge, + pickTitleChip, + type TreatmentInputs, +} from "../wholeModelTreatment"; + +/** + * Locks the visual tokens produced by `tokensForKind` (exercised through the + * public `pickTitleChip` / `pickGraphBadge` resolvers) against the shared CLL + * palette consts in `styles.tsx`, for every kind × (light / dark). + * + * These two surfaces are the only callers of `tokensForKind`, so asserting + * their `.tokens` output covers all five kinds and both modes. If the palette + * wiring drifts (wrong const, swapped light/dark), this fails loudly. + */ + +const BASE: TreatmentInputs = { + newCllExperience: true, + isWholeModelChanged: false, + isWholeModelImpacted: false, + isImpacted: false, +}; + +describe("wholeModelTreatment tokens", () => { + describe("title chip — changed", () => { + for (const isDark of [false, true] as const) { + const mode = isDark ? "dark" : "light"; + it(`maps the brown/changed palette in ${mode} mode`, () => { + const res = pickTitleChip( + { ...BASE, isWholeModelChanged: true }, + isDark, + ); + expect(res?.kind).toBe("changed"); + expect(res?.tokens).toEqual({ + stripeAccent: "var(--schema-color-changed-accent)", + fg: cllChangedBadgeFg[mode], + badgeBg: cllChangedBadgeBg[mode], + badgeBorder: cllChangedAccent, + }); + }); + } + }); + + describe("title chip — impacted", () => { + for (const isDark of [false, true] as const) { + const mode = isDark ? "dark" : "light"; + it(`maps the amber/impacted palette in ${mode} mode`, () => { + const res = pickTitleChip( + { ...BASE, isWholeModelImpacted: true }, + isDark, + ); + expect(res?.kind).toBe("impacted"); + expect(res?.tokens).toEqual({ + stripeAccent: "var(--schema-color-impacted-accent)", + fg: cllImpactedBadgeFg[mode], + badgeBg: cllImpactedBadgeBg[mode], + badgeBorder: cllImpactedAccent[mode], + }); + }); + } + }); + + describe("graph badge — additive", () => { + for (const isDark of [false, true] as const) { + const mode = isDark ? "dark" : "light"; + it(`maps the green/additive palette in ${mode} mode`, () => { + const res = pickGraphBadge( + { ...BASE, changeCategory: "non_breaking" }, // wire-enum-ok + isDark, + ); + expect(res?.kind).toBe("additive"); + expect(res?.tokens).toEqual({ + stripeAccent: "var(--schema-color-added-accent)", + fg: cllAdditiveBadgeFg[mode], + badgeBg: cllAdditiveBadgeBg[mode], + badgeBorder: cllAdditiveAccent[mode], + }); + }); + } + }); + + describe("graph badge — column-changed", () => { + for (const isDark of [false, true] as const) { + const mode = isDark ? "dark" : "light"; + it(`reuses the brown/changed palette in ${mode} mode`, () => { + const res = pickGraphBadge( + { ...BASE, changeCategory: "partial_breaking" }, // wire-enum-ok + isDark, + ); + expect(res?.kind).toBe("column-changed"); + expect(res?.tokens).toEqual({ + stripeAccent: "var(--schema-color-changed-accent)", + fg: cllChangedBadgeFg[mode], + badgeBg: cllChangedBadgeBg[mode], + badgeBorder: cllChangedAccent, + }); + }); + } + }); + + describe("graph badge — column-impacted", () => { + for (const isDark of [false, true] as const) { + const mode = isDark ? "dark" : "light"; + it(`reuses the amber/impacted palette in ${mode} mode`, () => { + const res = pickGraphBadge({ ...BASE, isImpacted: true }, isDark); + expect(res?.kind).toBe("column-impacted"); + expect(res?.tokens).toEqual({ + stripeAccent: "var(--schema-color-impacted-accent)", + fg: cllImpactedBadgeFg[mode], + badgeBg: cllImpactedBadgeBg[mode], + badgeBorder: cllImpactedAccent[mode], + }); + }); + } + }); +}); diff --git a/js/packages/ui/src/components/lineage/nodes/LineageNode.tsx b/js/packages/ui/src/components/lineage/nodes/LineageNode.tsx index 79bf2ff2b..232b94fdc 100644 --- a/js/packages/ui/src/components/lineage/nodes/LineageNode.tsx +++ b/js/packages/ui/src/components/lineage/nodes/LineageNode.tsx @@ -34,6 +34,7 @@ import { useRef, useState, } from "react"; +import { colors } from "../../../theme/colors"; import { DIM_FILTER } from "../config/zoomConstants"; import { getIconForMaterialization, @@ -367,10 +368,16 @@ function LineageNodeComponent({ const borderWidth = "2px"; const borderColor = colorChangeStatus; + // Themed surface + text colors, sourced from the shared `colors` design + // tokens (the codebase's dark-mode mechanism) and selected by the `isDark` + // prop — rather than inline hex literals. The dark card surface uses + // neutral[800] (#262626), matching the sibling LineageColumnNode card. + const paperBg = isDark ? colors.neutral[800] : colors.white; + const primaryText = isDark ? colors.white : colors.black; + const invertedText = isDark ? colors.black : colors.white; + // Node background color logic const nodeBackgroundColor = (() => { - const paperBg = isDark ? "#1e1e1e" : "#ffffff"; - if (showContent) { if (selectMode === "selecting") { return isSelected ? colorChangeStatus : paperBg; @@ -392,9 +399,6 @@ function LineageNodeComponent({ // Text color logic const titleColor = (() => { - const primaryText = isDark ? "#ffffff" : "#000000"; - const invertedText = isDark ? "#000000" : "#ffffff"; - if (selectMode === "selecting") { return isSelected ? invertedText : primaryText; } @@ -405,9 +409,6 @@ function LineageNodeComponent({ })(); const iconColor = (() => { - const primaryText = isDark ? "#ffffff" : "#000000"; - const invertedText = isDark ? "#000000" : "#ffffff"; - if (selectMode === "selecting") { return isSelected ? invertedText : primaryText; } @@ -418,9 +419,6 @@ function LineageNodeComponent({ })(); const changeStatusIconColor = (() => { - const primaryText = isDark ? "#ffffff" : "#000000"; - const invertedText = isDark ? "#000000" : "#ffffff"; - if (selectMode === "selecting") { return isSelected ? invertedText : colorChangeStatus; } diff --git a/js/packages/ui/src/components/lineage/styles.tsx b/js/packages/ui/src/components/lineage/styles.tsx index e6a28091a..11c11d421 100644 --- a/js/packages/ui/src/components/lineage/styles.tsx +++ b/js/packages/ui/src/components/lineage/styles.tsx @@ -711,7 +711,8 @@ export const changeStatusBackgroundsDark: Record< * CLL palette — muted brown/yellow variant used only inside the new CLL * experience (LineageNode, LineageColumnNode, LineageEdge, LineageCanvas * minimap, LineageLegend). Mirrors the `.cll-experience` overrides in - * ../schema/style.css; keep both sides in sync (no build-time check). + * ../schema/style.css; the two are kept in sync by the guard test + * __tests__/cllPaletteSync.test.ts (fails CI on drift). */ export const cllChangeStatusColors: Record< ChangeStatus | "unchanged" | "impacted", @@ -749,7 +750,7 @@ export const cllChangeStatusBackgroundsDark: Record< /** * Impacted accent + badge palette. Mirrors `--schema-color-impacted-accent` * and `--schema-badge-impacted-{bg,fg}` in ../schema/style.css under - * `.cll-experience` — keep both in sync by hand (no build-time check). + * `.cll-experience` — kept in sync by __tests__/cllPaletteSync.test.ts. */ const IMPACTED_HUE_LIGHT = "252 211 77"; // matches cllChangeStatusColors.impacted @@ -772,7 +773,7 @@ export const cllImpactedBadgeFg = { /** * Changed accent + badge palette. Mirrors `--schema-color-changed-accent` * and `--schema-badge-changed-{bg,fg}` in ../schema/style.css under - * `.cll-experience` — keep both in sync by hand (no build-time check). + * `.cll-experience` — kept in sync by __tests__/cllPaletteSync.test.ts. */ export const cllChangedAccent = cllChangeStatusColors.modified; @@ -788,8 +789,8 @@ export const cllChangedBadgeFg = { /** * Additive accent + badge palette. Mirrors `--schema-color-added-accent` - * and `--schema-badge-added-{bg,fg}` in ../schema/style.css — keep both in - * sync by hand (no build-time check). + * and `--schema-badge-added-{bg,fg}` in ../schema/style.css — kept in sync + * by __tests__/cllPaletteSync.test.ts. */ export const cllAdditiveAccent = { light: cllChangeStatusColors.added, diff --git a/js/packages/ui/src/components/schema/style.css b/js/packages/ui/src/components/schema/style.css index 8a286c13d..a14dde87d 100644 --- a/js/packages/ui/src/components/schema/style.css +++ b/js/packages/ui/src/components/schema/style.css @@ -5,8 +5,9 @@ * Default palette is the long-standing pale yellow/orange "changed" hue. * The new CLL experience overrides "changed" with a deeper brown and adds * "impacted-accent" + impacted badge colors under the `.cll-experience` - * selector below. JS counterparts live in ../lineage/styles.tsx — keep - * them in sync by hand; there is no build-time check: + * selector below. JS counterparts live in ../lineage/styles.tsx and are kept + * in sync by the guard test ../lineage/__tests__/cllPaletteSync.test.ts + * (fails CI on drift): * - cllChangeStatusColors / cllChangeStatusBackgrounds* — row & accent * - cllImpactedAccent — --schema-color-impacted-accent * - cllImpactedBadgeBg / cllImpactedBadgeFg — --schema-badge-impacted-*