From 5135f4c9f88eec8697a3ef161f06aa610a1501f0 Mon Sep 17 00:00:00 2001 From: Owoh Chidubem Alexander Date: Fri, 31 Jul 2026 08:21:26 +0100 Subject: [PATCH] design: tokens admin diff and export --- docs/uiux/ux497-token-diff-export.md | 62 ++ .../DesignTokens/ChartPaletteGuidelines.tsx | 6 +- src/pages/DesignTokens/DesignTokensPage.css | 528 +++++++++++++++++- .../DesignTokens/DesignTokensPage.test.tsx | 37 +- src/pages/DesignTokens/DesignTokensPage.tsx | 21 +- src/pages/DesignTokens/DevicePreview.tsx | 10 +- src/pages/DesignTokens/TokenDiff.test.tsx | 388 +++++++++++++ src/pages/DesignTokens/TokenDiff.tsx | 410 ++++++++++++++ src/pages/DesignTokens/tokenDiff.ts | 291 ++++++++++ src/pages/DesignTokens/tokens.ts | 2 + vite.config.ts | 9 + 11 files changed, 1738 insertions(+), 26 deletions(-) create mode 100644 docs/uiux/ux497-token-diff-export.md create mode 100644 src/pages/DesignTokens/TokenDiff.test.tsx create mode 100644 src/pages/DesignTokens/TokenDiff.tsx create mode 100644 src/pages/DesignTokens/tokenDiff.ts diff --git a/docs/uiux/ux497-token-diff-export.md b/docs/uiux/ux497-token-diff-export.md new file mode 100644 index 0000000..7038df5 --- /dev/null +++ b/docs/uiux/ux497-token-diff-export.md @@ -0,0 +1,62 @@ +# Design Tokens Admin — Diff & Export (Issue #497) + +## Purpose + +Designers iterating on tokens need to see what changed between saves and export the diff. The Design Tokens admin page now includes a diff view that highlights added, changed, and removed tokens, plus per-format export (JSON, CSS variables, Sass) with copy-to-clipboard and download affordances. + +## Anatomy + +- **Summary chips**: read-only counts for added / changed / removed / unchanged tokens. +- **Status filter**: segmented control (All / Added / Changed / Removed) that narrows the rows. +- **Show unchanged toggle**: defaults to *off* so large diffs stay readable; enabling it reveals identical tokens. +- **Group accordions**: one per token category, each collapsible and showing how many rows are visible. +- **Three-column diff layout**: `Token | Before | After`. Each row carries a status badge; added rows show a dash in *Before*, removed rows a dash in *After*. +- **Color affordance**: hex color values render with a swatch in both value columns so hue shifts are visible at a glance. +- **Binary tokens**: icon/asset tokens (e.g. `--icon-logo`) are labeled "Binary asset" instead of dumping raw payloads. +- **Export panel**: format tabs (JSON / CSS variables / Sass), a live preview, a copy button, and a download button per format. + +## Behavior + +- The diff compares a *before* snapshot against the *after* (current draft) snapshot by CSS variable name across matching groups. +- Statuses are derived automatically: + - `added` — token present only in the current draft + - `removed` — token present only in the previous snapshot + - `changed` — present in both, value differs + - `unchanged` — present in both, value identical +- Exports represent the diff: + - **JSON**: `{ added: {...}, changed: { var: { before, after } }, removed: {...} }` + - **CSS variables**: `:root { /* added */ ... /* changed */ ... }` with removed tokens commented out. + - **Sass**: `$var: value;` grouped under `// added` / `// changed` comments, with removed variables commented out. +- Binary tokens export as `[binary asset]`. +- Empty (no changes) state shows when the snapshots match; a smaller empty state appears when a status filter matches no rows. + +## Large-diff readability + +- Unchanged rows are hidden by default. +- Groups collapse independently so reviewers can focus on one category. +- Status filters slice the diff to a single change type. +- Values wrap within their column rather than truncating, and long export previews scroll (`max-height: 320px`). + +## Accessibility (WCAG 2.1 AA) + +- Status is never conveyed by color alone: badges include text labels and rows expose `aria-label=": "`. +- The status filter uses `aria-pressed` toggles and the export format tabs use `role="tablist"` / `role="tab"` with `aria-selected`. +- The diff table uses proper `role="table"` / `role="row"` / `role="columnheader"` / `role="cell"` relationships. +- The empty state uses `role="status"` with `aria-live="polite"`. +- All controls have visible focus indicators and the copy action announces success via the button label. +- Verified with `jest-axe` (`axe` + `toHaveNoViolations`) on both the component and the full page. Pre-existing ARIA misuses (grid/row roles, nested `main` landmarks, heading-order jumps) in the token sections, device preview, and chart guidelines were corrected as part of this work. + +## Responsive and RTL notes + +- Below 640px each row stacks: the token cell spans the full width, with Before/After side by side beneath it. +- The layout uses logical properties (`padding-inline`, `border-inline-end`, `text-align: start`) so the three columns mirror correctly under `dir="rtl"`. +- In print, the interactive controls (toggles, tabs, export actions) are hidden; rows remain readable. +- The code preview forces `direction: ltr` so token values render consistently regardless of page direction. + +## Implementation notes + +- Diff logic and export formatters live in [src/pages/DesignTokens/tokenDiff.ts](src/pages/DesignTokens/tokenDiff.ts). +- The component lives in [src/pages/DesignTokens/TokenDiff.tsx](src/pages/DesignTokens/TokenDiff.tsx). +- The page integrates it from [src/pages/DesignTokens/DesignTokensPage.tsx](src/pages/DesignTokens/DesignTokensPage.tsx) with snapshot data in `tokenDiff.ts`. +- Tests: `TokenDiff.test.tsx` (unit + interaction + axe) and `DesignTokensPage.test.tsx` (integration + axe). +- Coverage thresholds: 95% for `TokenDiff.tsx` and `tokenDiff.ts` enforced in `vite.config.ts`. diff --git a/src/pages/DesignTokens/ChartPaletteGuidelines.tsx b/src/pages/DesignTokens/ChartPaletteGuidelines.tsx index 713aa41..5c55040 100644 --- a/src/pages/DesignTokens/ChartPaletteGuidelines.tsx +++ b/src/pages/DesignTokens/ChartPaletteGuidelines.tsx @@ -115,7 +115,7 @@ export function ChartPaletteGuidelines({ surface = "dark" }: ChartPaletteGuideli {/* Tab 1: Categorical Swatches & Contrast Table */} {activeTab === "swatches" && (
-
+
{DARK_CHART_TOKENS.map((darkToken, idx) => { const lightToken = LIGHT_CHART_TOKENS[idx]; const activeToken = surface === "dark" ? darkToken : lightToken; @@ -123,7 +123,7 @@ export function ChartPaletteGuidelines({ surface = "dark" }: ChartPaletteGuideli const grade = wcagGrade(ratio); return ( -
+
-

Accessibility (WCAG 2.1 AA) & Responsive Assumptions

+

Accessibility (WCAG 2.1 AA) & Responsive Assumptions

  • WCAG 2.1 AA Non-Text Contrast (1.4.11): Every dark-mode categorical hue maintains a contrast ratio of ≥ 6.8:1 against #020617, well exceeding the 3:1 graphical requirement.
  • Color Vision Deficiencies (WCAG 1.4.1): Designed across blue/yellow and red/green channels. Combined with shape markers or legend text, all categories remain identifiable.
  • diff --git a/src/pages/DesignTokens/DesignTokensPage.css b/src/pages/DesignTokens/DesignTokensPage.css index ddd4339..6c1c187 100644 --- a/src/pages/DesignTokens/DesignTokensPage.css +++ b/src/pages/DesignTokens/DesignTokensPage.css @@ -719,7 +719,7 @@ gap: var(--spacing-xs); } -.dt-notes-callout h4 { +.dt-notes-callout h3 { font-size: var(--font-size-base); font-weight: var(--font-weight-semibold); color: var(--text-main); @@ -738,3 +738,529 @@ color: var(--text-muted); } +/* ─── Token Diff & Export (Issue #497) ─────────────────────────────────────── */ +.dt-diff-section { + display: flex; + flex-direction: column; + gap: var(--spacing-lg); +} + +.dt-diff-header { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: flex-start; + gap: var(--spacing-md); +} + +.dt-diff-header-text { + min-width: 0; +} + +.dt-diff-subtitle { + font-size: var(--font-size-sm); + color: var(--text-muted); +} + +.dt-diff-summary { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-xs); +} + +.dt-diff-chip { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + padding: 4px 10px; + border-radius: var(--radius-full); + border: 1px solid var(--glass-border); + color: var(--text-muted); +} + +.dt-diff-chip-count { + font-weight: var(--font-weight-bold); +} + +.dt-diff-chip--added { + color: var(--success); + background: rgba(16, 185, 129, 0.1); + border-color: rgba(16, 185, 129, 0.25); +} + +.dt-diff-chip--changed { + color: #fbbf24; + background: rgba(251, 191, 36, 0.1); + border-color: rgba(251, 191, 36, 0.25); +} + +.dt-diff-chip--removed { + color: var(--error); + background: rgba(239, 68, 68, 0.1); + border-color: rgba(239, 68, 68, 0.25); +} + +/* Toolbar */ +.dt-diff-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--spacing-md); +} + +.dt-diff-toggle { + display: flex; + border: 1px solid var(--glass-border); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.dt-diff-toggle-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: var(--spacing-xs) var(--spacing-md); + background: transparent; + border: none; + color: var(--text-muted); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: all 0.2s ease; +} + +.dt-diff-toggle-btn:hover { + color: var(--text-main); +} + +.dt-diff-toggle-btn--active { + background: var(--primary); + color: #fff; +} + +.dt-diff-toggle-count { + font-size: var(--font-size-xs); + opacity: 0.8; +} + +.dt-diff-showall-btn { + padding: var(--spacing-xs) var(--spacing-md); + background: var(--glass-bg-accent); + border: 1px solid var(--glass-border); + border-radius: var(--radius-lg); + color: var(--text-main); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: all 0.2s ease; +} + +.dt-diff-showall-btn:hover { + border-color: var(--primary); + color: var(--primary); +} + +.dt-diff-showall-btn[aria-pressed="true"] { + border-color: var(--primary); + color: var(--primary); + background: rgba(59, 130, 246, 0.1); +} + +/* Diff groups */ +.dt-diff-groups { + display: flex; + flex-direction: column; + gap: var(--spacing-lg); +} + +.dt-diff-group { + border: 1px solid var(--glass-border); + border-radius: var(--radius-xl); + overflow: hidden; + background: rgba(15, 23, 42, 0.4); +} + +.dt-diff-group-header { + display: flex; + align-items: center; + gap: var(--spacing-sm); + width: 100%; + padding: var(--spacing-sm) var(--spacing-md); + background: transparent; + border: none; + border-bottom: 1px solid var(--glass-border); + color: var(--text-main); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + cursor: pointer; + text-align: start; + transition: background 0.2s ease; +} + +.dt-diff-group-header:hover { + background: rgba(148, 163, 184, 0.08); +} + +.dt-diff-group-header:focus-visible { + outline: 2px solid var(--primary); + outline-offset: -2px; +} + +.dt-diff-group-caret { + display: inline-flex; + color: var(--text-muted); + flex-shrink: 0; +} + +.dt-diff-group-label { + flex: 1; + min-width: 0; +} + +/* Diff table */ +.dt-diff-table { + display: flex; + flex-direction: column; + padding-inline: var(--spacing-md); + padding-bottom: var(--spacing-sm); +} + +.dt-diff-row { + display: grid; + grid-template-columns: minmax(180px, 1.2fr) minmax(0, 1fr) minmax(0, 1fr); + gap: var(--spacing-md); + align-items: center; + padding-block: var(--spacing-sm); + border-bottom: 1px solid var(--glass-border); +} + +.dt-diff-row:last-child { + border-bottom: none; +} + +.dt-diff-row--head { + border-bottom: 1px solid var(--glass-border-bright); +} + +.dt-diff-row--head .dt-diff-cell { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.dt-diff-cell { + min-width: 0; +} + +.dt-diff-token { + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +.dt-diff-row--added { + background: rgba(16, 185, 129, 0.04); +} + +.dt-diff-row--removed { + background: rgba(239, 68, 68, 0.04); +} + +.dt-diff-row--changed { + background: rgba(251, 191, 36, 0.04); +} + +.dt-diff-row--unchanged { + opacity: 0.65; +} + +.dt-diff-badge { + flex-shrink: 0; + font-size: 10px; + font-weight: var(--font-weight-semibold); + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 2px 8px; + border-radius: var(--radius-full); + border: 1px solid var(--glass-border); + color: var(--text-muted); +} + +.dt-diff-badge--added { + color: var(--success); + border-color: rgba(16, 185, 129, 0.4); + background: rgba(16, 185, 129, 0.1); +} + +.dt-diff-badge--changed { + color: #fbbf24; + border-color: rgba(251, 191, 36, 0.4); + background: rgba(251, 191, 36, 0.1); +} + +.dt-diff-badge--removed { + color: var(--error); + border-color: rgba(239, 68, 68, 0.4); + background: rgba(239, 68, 68, 0.1); +} + +.dt-diff-token-name { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--text-main); +} + +.dt-diff-var { + font-size: var(--font-size-xs); + color: var(--text-muted); + font-family: "SF Mono", "Fira Code", monospace; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dt-diff-value { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + min-width: 0; + max-width: 100%; +} + +.dt-diff-value code { + font-family: "SF Mono", "Fira Code", monospace; + font-size: var(--font-size-xs); + color: var(--text-accent); + background: rgba(56, 189, 248, 0.07); + padding: 2px 6px; + border-radius: var(--radius-xs); + word-break: break-word; + white-space: normal; +} + +.dt-diff-swatch { + width: 22px; + height: 22px; + border-radius: var(--radius-xs); + border: 1px solid var(--glass-border-bright); + flex-shrink: 0; +} + +.dt-diff-binary { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: var(--font-size-xs); + color: var(--text-muted); +} + +.dt-diff-na { + color: var(--text-muted); +} + +/* Diff empty state */ +.dt-diff-empty { + text-align: center; + padding: var(--spacing-3xl) var(--spacing-xl); + color: var(--text-muted); + font-size: var(--font-size-sm); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-xs); + border: 1px dashed var(--glass-border-bright); + border-radius: var(--radius-xl); +} + +.dt-diff-empty-title { + font-weight: var(--font-weight-semibold); + color: var(--text-main); +} + +.dt-diff-empty-text { + max-width: 42ch; +} + +.dt-diff-link-btn { + background: none; + border: none; + padding: 0; + color: var(--primary); + font-size: inherit; + cursor: pointer; + text-decoration: underline; +} + +.dt-diff-link-btn:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; +} + +/* Export panel */ +.dt-export-panel { + border: 1px solid var(--glass-border); + border-radius: var(--radius-xl); + padding: var(--spacing-lg); + background: rgba(15, 23, 42, 0.5); + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.dt-export-header { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: var(--spacing-sm); +} + +.dt-export-title { + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + color: var(--text-main); +} + +.dt-export-tabs { + display: flex; + gap: var(--spacing-2xs); + background: rgba(15, 23, 42, 0.6); + padding: 4px; + border-radius: var(--radius-lg); + border: 1px solid var(--glass-border); +} + +.dt-export-tab { + padding: var(--spacing-3xs) var(--spacing-md); + background: transparent; + border: none; + color: var(--text-muted); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.2s ease; +} + +.dt-export-tab:hover { + color: var(--text-main); +} + +.dt-export-tab--active { + background: var(--primary); + color: #ffffff; + font-weight: var(--font-weight-semibold); +} + +.dt-diff-toggle-btn:focus-visible, +.dt-diff-showall-btn:focus-visible, +.dt-diff-group-header:focus-visible, +.dt-export-tab:focus-visible, +.dt-export-action-btn:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; +} + +.dt-export-preview { + max-height: 320px; + overflow: auto; + background: rgba(2, 6, 23, 0.6); + border: 1px solid var(--glass-border); + border-radius: var(--radius-md); + padding: var(--spacing-md); + font-family: "SF Mono", "Fira Code", monospace; + font-size: var(--font-size-xs); + color: var(--text-accent); + line-height: var(--line-height-normal); + white-space: pre; + direction: ltr; + text-align: start; +} + +.dt-export-actions { + display: flex; + gap: var(--spacing-sm); +} + +.dt-export-action-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: var(--spacing-xs) var(--spacing-md); + background: var(--primary); + color: #fff; + border: none; + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + cursor: pointer; + transition: all 0.2s ease; +} + +.dt-export-action-btn:hover { + background: var(--primary-hover); +} + +.dt-export-action-btn--secondary { + background: var(--glass-bg-accent); + border: 1px solid var(--glass-border); + color: var(--text-main); +} + +.dt-export-action-btn--secondary:hover { + background: rgba(148, 163, 184, 0.15); +} + +/* Diff responsive */ +@media (max-width: 640px) { + .dt-diff-row { + grid-template-columns: 1fr 1fr; + } + + .dt-diff-row .dt-diff-token { + grid-column: 1 / -1; + border-bottom: 1px solid var(--glass-border); + padding-bottom: var(--spacing-xs); + } + + .dt-diff-before { + padding-inline-end: var(--spacing-sm); + border-inline-end: 1px solid var(--glass-border); + } + + .dt-diff-group-header { + font-size: var(--font-size-xs); + padding: var(--spacing-xs) var(--spacing-sm); + } + + .dt-diff-toggle-btn, + .dt-diff-showall-btn, + .dt-export-tab { + font-size: var(--font-size-xs); + padding: var(--spacing-2xs) var(--spacing-sm); + } + + .dt-export-preview { + max-height: 240px; + } +} + +@media print { + .dt-diff-toggle, + .dt-diff-showall-btn, + .dt-export-tabs, + .dt-export-actions { + display: none; + } +} + + diff --git a/src/pages/DesignTokens/DesignTokensPage.test.tsx b/src/pages/DesignTokens/DesignTokensPage.test.tsx index d5054d1..bcc65d2 100644 --- a/src/pages/DesignTokens/DesignTokensPage.test.tsx +++ b/src/pages/DesignTokens/DesignTokensPage.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { axe } from "jest-axe"; import { DesignTokensPage } from "./DesignTokensPage"; import { contrastRatio, wcagGrade } from "./contrast"; import { TOKEN_GROUPS } from "./tokens"; @@ -74,13 +75,13 @@ describe("DesignTokensPage", () => { it("renders all section headings", () => { render(); - expect(screen.getByText("Colors")).toBeInTheDocument(); - expect(screen.getByText("Chart Categorical Palette (Dark Mode)")).toBeInTheDocument(); - expect(screen.getByText("Chart Categorical Palette (Light Mode)")).toBeInTheDocument(); - expect(screen.getByText("Spacing")).toBeInTheDocument(); - expect(screen.getByText("Border Radius")).toBeInTheDocument(); - expect(screen.getByText("Typography")).toBeInTheDocument(); - expect(screen.getByText("Shadows / Elevation")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: /^colors/i, level: 2 })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: /chart categorical palette \(dark mode\)/i, level: 2 })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: /chart categorical palette \(light mode\)/i, level: 2 })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: /^spacing/i, level: 2 })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: /^border radius/i, level: 2 })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: /^typography/i, level: 2 })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: /shadows \/ elevation/i, level: 2 })).toBeInTheDocument(); }); it("renders Export JSON button", () => { @@ -109,8 +110,8 @@ describe("DesignTokensPage", () => { render(); const input = screen.getByRole("searchbox"); await userEvent.type(input, "primary"); - expect(screen.getByText("Primary")).toBeInTheDocument(); - expect(screen.queryByText("Spacing")).not.toBeInTheDocument(); + expect(screen.getAllByText("Primary").length).toBeGreaterThan(0); + expect(screen.queryByRole("heading", { name: /^spacing/i, level: 2 })).not.toBeInTheDocument(); }); it("shows empty state when no tokens match", async () => { @@ -173,6 +174,22 @@ describe("DesignTokensPage", () => { const input = screen.getByRole("searchbox"); await userEvent.type(input, "primary"); await userEvent.clear(input); - expect(screen.getByText("Spacing")).toBeInTheDocument(); + expect(screen.getAllByText("Spacing").length).toBeGreaterThan(0); + }); + + it("renders the token diff & export section", () => { + render(); + expect( + screen.getByRole("heading", { name: /token diff & export/i }) + ).toBeInTheDocument(); + expect(screen.getByRole("tablist", { name: /export format/i })).toBeInTheDocument(); + expect(screen.getByLabelText("1 removed")).toBeInTheDocument(); + expect(screen.getByLabelText("3 added")).toBeInTheDocument(); + }); + + it("has no accessibility violations", async () => { + const { container } = render(); + const results = await axe(container); + expect(results).toHaveNoViolations(); }); }); diff --git a/src/pages/DesignTokens/DesignTokensPage.tsx b/src/pages/DesignTokens/DesignTokensPage.tsx index ffc263b..bca6e29 100644 --- a/src/pages/DesignTokens/DesignTokensPage.tsx +++ b/src/pages/DesignTokens/DesignTokensPage.tsx @@ -3,6 +3,8 @@ import { TOKEN_GROUPS, type TokenGroup, type Token } from "./tokens"; import { contrastRatio, wcagGrade, LIGHT_SURFACE, DARK_SURFACE } from "./contrast"; import { DevicePreview } from "./DevicePreview"; import { ChartPaletteGuidelines } from "./ChartPaletteGuidelines"; +import { TokenDiff } from "./TokenDiff"; +import { TOKEN_DIFF_BEFORE, TOKEN_DIFF_AFTER } from "./tokenDiff"; import "./DesignTokensPage.css"; // ─── Copy hook ──────────────────────────────────────────────────────────────── @@ -50,7 +52,7 @@ function ColorSwatch({ token, surface }: { token: Token; surface: string }) { grade === "AA Large" ? "dt-grade--large" : "dt-grade--fail"; return ( -
    +
    +
    @@ -112,7 +114,7 @@ function RadiusRow({ token }: { token: Token }) { const { copy, copied } = useCopy(); return ( -
    +
    +
    {isSize && ( Aa @@ -170,7 +172,7 @@ function ShadowRow({ token }: { token: Token }) { const { copy, copied } = useCopy(); return ( -
    +
    +
    {token.name} {token.value} {token.description} @@ -242,7 +244,7 @@ function TokenSection({ {filtered.length} -
    +
    {group.type === "color" && filtered.map((t) => ( @@ -355,6 +357,11 @@ export function DesignTokensPage() { ))}
    + {/* Token diff & export */} +
    + +
    + {/* Empty state */} {normalized && TOKEN_GROUPS.every( diff --git a/src/pages/DesignTokens/DevicePreview.tsx b/src/pages/DesignTokens/DevicePreview.tsx index 190b18e..756bf4b 100644 --- a/src/pages/DesignTokens/DevicePreview.tsx +++ b/src/pages/DesignTokens/DevicePreview.tsx @@ -45,12 +45,12 @@ export function DevicePreview({ surface }: DevicePreviewProps) {

    Sample App

    - +
    -
    +

    Welcome Back @@ -67,14 +67,14 @@ export function DevicePreview({ surface }: DevicePreviewProps) { {[1, 2, 3].map((i) => (
    -

    Feature {i}

    +

    Feature {i}

    Responsive design tokens in action.

    ))}
    -

    +
    ); diff --git a/src/pages/DesignTokens/TokenDiff.test.tsx b/src/pages/DesignTokens/TokenDiff.test.tsx new file mode 100644 index 0000000..2574193 --- /dev/null +++ b/src/pages/DesignTokens/TokenDiff.test.tsx @@ -0,0 +1,388 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { axe } from "jest-axe"; +import type { TokenGroup } from "./tokens"; +import { TokenDiff } from "./TokenDiff"; +import { + computeTokenDiff, + diffFilename, + formatDiff, + formatDiffCSS, + formatDiffJSON, + formatDiffSass, + isHexColor, + TOKEN_DIFF_AFTER, + TOKEN_DIFF_BEFORE, + type TokenDiffGroup, +} from "./tokenDiff"; + +const mockWriteText = vi.fn().mockResolvedValue(undefined); +Object.assign(navigator, { + clipboard: { writeText: mockWriteText }, +}); + +global.URL.createObjectURL = vi.fn(() => "blob:mock"); +global.URL.revokeObjectURL = vi.fn(); + +const colors = (tokens: TokenGroup["tokens"]): TokenGroup => ({ + id: "colors", + label: "Colors", + type: "color", + tokens, +}); + +const makeToken = ( + variable: string, + name: string, + value: string +): TokenGroup["tokens"][number] => ({ variable, name, value }); + +const minimalBefore: TokenGroup[] = [ + colors([ + makeToken("--primary", "Primary", "#2563eb"), + makeToken("--overlay", "Overlay", "rgba(0,0,0,0.5)"), + makeToken("--text-main", "Text Main", "#e5e7eb"), + ]), +]; + +const minimalAfter: TokenGroup[] = [ + colors([ + makeToken("--primary", "Primary", "#3b82f6"), + makeToken("--overlay", "Overlay", "rgba(0,0,0,0.6)"), + makeToken("--text-main", "Text Main", "#e5e7eb"), + makeToken("--accent", "Accent", "#38bdf8"), + ]), + { + id: "icons", + label: "Icons", + type: "motion", + tokens: [ + { name: "Logo", variable: "--icon-logo", value: "binary:logo.svg", isBinary: true }, + ], + }, +]; + +describe("tokenDiff helpers", () => { + it("classifies added, changed, removed, and unchanged tokens", () => { + const before: TokenGroup[] = [ + colors([ + makeToken("--primary", "Primary", "#2563eb"), + makeToken("--legacy", "Legacy", "#f59e0b"), + makeToken("--text-main", "Text Main", "#e5e7eb"), + ]), + ]; + const after: TokenGroup[] = [ + colors([ + makeToken("--primary", "Primary", "#3b82f6"), + makeToken("--text-main", "Text Main", "#e5e7eb"), + makeToken("--accent", "Accent", "#38bdf8"), + ]), + ]; + + const groups = computeTokenDiff(before, after); + expect(groups).toHaveLength(1); + const byVariable = new Map(groups[0].entries.map((e) => [e.variable, e])); + + expect(byVariable.get("--primary")?.status).toBe("changed"); + expect(byVariable.get("--accent")?.status).toBe("added"); + expect(byVariable.get("--legacy")?.status).toBe("removed"); + expect(byVariable.get("--text-main")?.status).toBe("unchanged"); + }); + + it("includes groups present in only one side and merges labels", () => { + const groups = computeTokenDiff( + [colors([makeToken("--a", "A", "1")])], + [ + { + id: "icons", + label: "Icons (Binary)", + type: "motion", + tokens: [ + { name: "Logo", variable: "--icon-logo", value: "x", isBinary: true }, + ], + }, + ] + ); + const ids = groups.map((g) => g.id); + expect(ids).toContain("colors"); + expect(ids).toContain("icons"); + const icons = groups.find((g) => g.id === "icons"); + expect(icons?.entries[0].status).toBe("added"); + expect(icons?.entries[0].isBinary).toBe(true); + }); + + it("skips empty groups and falls back to before-side details", () => { + const before: TokenGroup[] = [ + { + id: "motion", + label: "Motion Before", + type: "motion", + tokens: [ + { name: "Legacy Accent", variable: "--legacy-accent", value: "#f59e0b", description: "old" }, + ], + }, + { id: "empty-group", label: "Empty", type: "motion", tokens: [] }, + ]; + const groups = computeTokenDiff(before, []); + expect(groups).toHaveLength(1); + expect(groups[0].label).toBe("Motion Before"); + expect(groups[0].entries[0]).toMatchObject({ + status: "removed", + before: "#f59e0b", + description: "old", + type: "motion", + isBinary: false, + }); + }); + + it("produces a meaningful diff from the bundled snapshots", () => { + const groups = computeTokenDiff(TOKEN_DIFF_BEFORE, TOKEN_DIFF_AFTER); + const all = groups.flatMap((g) => g.entries); + const statusCounts = all.reduce>((acc, e) => { + acc[e.status] = (acc[e.status] ?? 0) + 1; + return acc; + }, {}); + expect(statusCounts.added).toBe(3); + expect(statusCounts.changed).toBe(11); + expect(statusCounts.removed).toBe(1); + expect(statusCounts.unchanged).toBe(73); + }); + + it("isHexColor accepts shorthand and long hex only", () => { + expect(isHexColor("#fff")).toBe(true); + expect(isHexColor("#3b82f6")).toBe(true); + expect(isHexColor("rgba(0,0,0,0.5)")).toBe(false); + expect(isHexColor("binary:logo.svg")).toBe(false); + expect(isHexColor("#12345")).toBe(false); + expect(isHexColor("red")).toBe(false); + }); +}); + +describe("export formatters", () => { + it("formats JSON with added/changed/removed sections", () => { + const groups = computeTokenDiff(minimalBefore, minimalAfter); + const json = JSON.parse(formatDiffJSON(groups)); + expect(json.added).toMatchObject({ "--accent": "#38bdf8", "--icon-logo": "[binary asset]" }); + expect(json.changed["--primary"]).toEqual({ before: "#2563eb", after: "#3b82f6" }); + expect(json.removed).toEqual({}); + }); + + it("formats JSON removed values from the before snapshot", () => { + const before = [colors([makeToken("--legacy", "Legacy", "#f59e0b")])]; + const after = [colors([])]; + const groups = computeTokenDiff(before, after); + const json = JSON.parse(formatDiffJSON(groups)); + expect(json.removed).toEqual({ "--legacy": "#f59e0b" }); + }); + + it("formats CSS variables with added/changed and commented removed lines", () => { + const groups = computeTokenDiff(minimalBefore, minimalAfter); + const css = formatDiffCSS(groups); + expect(css).toContain(":root {"); + expect(css).toContain("/* added */"); + expect(css).toContain(" --primary: #3b82f6;"); + expect(css).toContain(" --icon-logo: [binary asset];"); + }); + + it("formats CSS with a commented removed block when tokens were removed", () => { + const before = [colors([makeToken("--legacy", "Legacy", "#f59e0b")])]; + const after = [colors([])]; + const groups = computeTokenDiff(before, after); + const css = formatDiffCSS(groups); + expect(css).toContain("/* removed */"); + expect(css).toContain("/* --legacy: #f59e0b; */"); + }); + + it("formats Sass variables with dollar-prefixed names", () => { + const groups = computeTokenDiff(minimalBefore, minimalAfter); + const sass = formatDiffSass(groups); + expect(sass).toContain("// added"); + expect(sass).toContain("$accent: #38bdf8;"); + expect(sass).toContain("// changed"); + expect(sass).toContain("$primary: #3b82f6;"); + }); + + it("formats Sass removed variables as comments", () => { + const before = [colors([makeToken("--legacy", "Legacy", "#f59e0b")])]; + const after = [colors([])]; + const groups = computeTokenDiff(before, after); + const sass = formatDiffSass(groups); + expect(sass).toContain("// removed"); + expect(sass).toContain("// $legacy: #f59e0b;"); + }); + + it("dispatches by format", () => { + const groups = computeTokenDiff(minimalBefore, minimalAfter); + expect(formatDiff(groups, "json")).toBe(formatDiffJSON(groups)); + expect(formatDiff(groups, "css")).toBe(formatDiffCSS(groups)); + expect(formatDiff(groups, "sass")).toBe(formatDiffSass(groups)); + }); + + it("maps formats to filenames", () => { + expect(diffFilename("json")).toBe("revora-token-diff.json"); + expect(diffFilename("css")).toBe("revora-token-diff.css"); + expect(diffFilename("sass")).toBe("revora-token-diff.scss"); + }); + + it("handles entries with missing before/after values defensively", () => { + const groups: TokenDiffGroup[] = [ + { + id: "colors", + label: "Colors", + entries: [ + { + variable: "--primary", + name: "Primary", + status: "changed", + before: undefined, + after: "#3b82f6", + type: "color", + isBinary: false, + }, + { + variable: "--legacy", + name: "Legacy", + status: "removed", + before: undefined, + after: undefined, + type: "color", + isBinary: false, + }, + ], + }, + ]; + const json = JSON.parse(formatDiffJSON(groups)); + expect(json.changed["--primary"].before).toBe(""); + expect(json.removed["--legacy"]).toBe(""); + expect(formatDiffCSS(groups)).toContain(" --legacy: ;"); + expect(formatDiffSass(groups)).toContain("// $legacy: ;"); + }); +}); + +describe("TokenDiff component", () => { + beforeEach(() => { + mockWriteText.mockClear(); + }); + + it("renders heading, summary chips, and export tabs", () => { + render(); + expect( + screen.getByRole("heading", { name: /token diff & export/i }) + ).toBeInTheDocument(); + expect(screen.getByLabelText("3 added")).toBeInTheDocument(); + expect(screen.getByLabelText("11 changed")).toBeInTheDocument(); + expect(screen.getByLabelText("1 removed")).toBeInTheDocument(); + expect(screen.getByRole("tablist", { name: /export format/i })).toBeInTheDocument(); + }); + + it("shows added, changed, and removed rows and hides unchanged by default", () => { + render(); + expect(screen.getByLabelText("Legacy Accent: removed")).toBeInTheDocument(); + expect(screen.getAllByLabelText(/changed/i).length).toBeGreaterThan(0); + expect(screen.queryByLabelText("Text Main: unchanged")).not.toBeInTheDocument(); + }); + + it("reveals unchanged rows when Show unchanged is toggled", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: /show unchanged/i })); + expect(screen.getByLabelText("Text Main: unchanged")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: /hide unchanged/i })); + expect(screen.queryByLabelText("Text Main: unchanged")).not.toBeInTheDocument(); + }); + + it("filters rows by status", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: /^removed/i })); + expect(screen.getByLabelText("Legacy Accent: removed")).toBeInTheDocument(); + expect(screen.queryByLabelText("Primary: changed")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^removed/i })).toHaveAttribute("aria-pressed", "true"); + }); + + it("renders binary tokens without raw values", () => { + render(); + expect(screen.getAllByText("Binary asset").length).toBeGreaterThan(0); + expect(screen.queryByText("binary:logo.svg")).not.toBeInTheDocument(); + }); + + it("renders color swatches only for hex color values", () => { + const { container } = render( + + ); + const swatches = container.querySelectorAll(".dt-diff-swatch"); + expect(swatches.length).toBeGreaterThan(0); + }); + + it("renders a dash for added-before and removed-after cells", () => { + render(); + expect(screen.getAllByText("—").length).toBeGreaterThan(0); + }); + + it("collapses and expands a group", async () => { + render(); + const header = screen.getByRole("button", { name: /^colors/i }); + expect(header).toHaveAttribute("aria-expanded", "true"); + await userEvent.click(header); + expect(header).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByLabelText("Primary: changed")).not.toBeInTheDocument(); + await userEvent.click(header); + expect(screen.getByLabelText("Primary: changed")).toBeInTheDocument(); + }); + + it("copies the export preview and shows feedback", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: /copy diff as json/i })); + expect(mockWriteText).toHaveBeenCalledWith(expect.stringContaining('"added"')); + await waitFor(() => + expect(screen.getByRole("button", { name: /copy diff as json/i })).toHaveTextContent("Copied") + ); + }); + + it("switches export formats and updates the preview", async () => { + render(); + const preview = screen.getByLabelText(/diff export preview/i); + expect(preview).toHaveTextContent('"added"'); + + await userEvent.click(screen.getByRole("tab", { name: /css variables/i })); + expect(screen.getByLabelText(/diff export preview \(css\)/i)).toHaveTextContent(":root {"); + await userEvent.click(screen.getByRole("tab", { name: /sass/i })); + expect(screen.getByLabelText(/diff export preview \(sass\)/i)).toHaveTextContent("// added"); + }); + + it("downloads the diff in the selected format", () => { + const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + render(); + fireEvent.click(screen.getByRole("button", { name: /download diff as json/i })); + expect(URL.createObjectURL).toHaveBeenCalled(); + clickSpy.mockRestore(); + }); + + it("shows the no-changes empty state when snapshots match", () => { + render(); + expect(screen.getByRole("status")).toHaveTextContent(/no token changes/i); + expect(screen.queryByRole("tablist")).not.toBeInTheDocument(); + }); + + it("shows a filter empty state when no rows match and lets users clear it", async () => { + const onlyBefore = [colors([makeToken("--primary", "Primary", "#2563eb")])]; + const onlyAfter = [ + colors([ + makeToken("--primary", "Primary", "#2563eb"), + makeToken("--accent", "Accent", "#38bdf8"), + ]), + ]; + render(); + await userEvent.click(screen.getByRole("button", { name: /^removed/i })); + expect(screen.getByRole("status")).toHaveTextContent(/no tokens match the current filter/i); + await userEvent.click(screen.getByRole("button", { name: /clearing the status filter/i })); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + + it("has no accessibility violations", async () => { + const { container } = render( + + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/src/pages/DesignTokens/TokenDiff.tsx b/src/pages/DesignTokens/TokenDiff.tsx new file mode 100644 index 0000000..febbc7f --- /dev/null +++ b/src/pages/DesignTokens/TokenDiff.tsx @@ -0,0 +1,410 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import { + Check, + ChevronDown, + ChevronRight, + Copy, + Download, + Image as ImageIcon, +} from "lucide-react"; +import type { TokenGroup } from "./tokens"; +import { + computeTokenDiff, + diffFilename, + formatDiff, + isHexColor, + type ChangeStatus, + type ExportFormat, + type TokenDiffEntry, + type TokenDiffGroup, +} from "./tokenDiff"; + +function useCopy() { + const [copied, setCopied] = useState(null); + const timer = useRef | null>(null); + + const copy = useCallback((text: string, key: string) => { + navigator.clipboard.writeText(text).then(() => { + setCopied(key); + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(() => setCopied(null), 1800); + }); + }, []); + + return { copied, copy }; +} + +function downloadDiff(text: string, format: ExportFormat) { + const blob = new Blob([text], { + type: "text/plain;charset=utf-8", + }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = diffFilename(format); + link.click(); + URL.revokeObjectURL(url); +} + +const STATUS_LABELS: Record = { + added: "Added", + changed: "Changed", + removed: "Removed", + unchanged: "Unchanged", +}; + +function ValueCell({ + value, + type, + isBinary, +}: { + value?: string; + type: TokenGroup["type"]; + isBinary: boolean; +}) { + if (!value) return ; + if (isBinary) + return ( + + + ); + + const isColor = type === "color" && isHexColor(value); + return ( + + {isColor && ( + + ); +} + +function DiffRow({ entry }: { entry: TokenDiffEntry }) { + const badgeText = STATUS_LABELS[entry.status]; + return ( +
    +
    + + {badgeText} + + + {entry.name} + {entry.variable} + +
    +
    + {entry.status === "added" ? ( + + ) : ( + + )} +
    +
    + {entry.status === "removed" ? ( + + ) : ( + + )} +
    +
    + ); +} + +interface TokenDiffProps { + before: TokenGroup[]; + after: TokenGroup[]; +} + +export function TokenDiff({ before, after }: TokenDiffProps) { + const [showAll, setShowAll] = useState(false); + const [statusFilter, setStatusFilter] = useState("all"); + const [format, setFormat] = useState("json"); + const [collapsed, setCollapsed] = useState>(new Set()); + const { copy, copied } = useCopy(); + + const groups = useMemo( + () => computeTokenDiff(before, after), + [before, after] + ); + + const counts = useMemo(() => { + const c = { added: 0, changed: 0, removed: 0, unchanged: 0 }; + for (const group of groups) { + for (const entry of group.entries) c[entry.status] += 1; + } + return c; + }, [groups]); + + const hasChanges = counts.added + counts.changed + counts.removed > 0; + + const visibleGroups = useMemo( + () => + groups + .map((group) => ({ + ...group, + entries: group.entries.filter((entry) => { + const matchesStatus = + statusFilter === "all" || entry.status === statusFilter; + const matchesVisibility = + showAll || entry.status !== "unchanged"; + return matchesStatus && matchesVisibility; + }), + })) + .filter((group) => group.entries.length > 0), + [groups, statusFilter, showAll] + ); + + const toggleGroup = useCallback((id: string) => { + setCollapsed((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + const exportText = useMemo(() => formatDiff(groups, format), [groups, format]); + + const statusFilters: Array = [ + "all", + "added", + "changed", + "removed", + ]; + + return ( +
    +
    +
    +

    + Token Diff & Export + + {counts.added + counts.changed + counts.removed} changes + +

    +

    + Changes between the last saved snapshot and the current draft. +

    +
    + +
    + {(Object.keys(STATUS_LABELS) as ChangeStatus[]).map((status) => ( + + {counts[status]} + {STATUS_LABELS[status]} + + ))} +
    +
    + +
    +
    + {statusFilters.map((status) => { + const label = + status === "all" ? "All" : STATUS_LABELS[status]; + const count = + status === "all" + ? counts.added + counts.changed + counts.removed + : counts[status]; + return ( + + ); + })} +
    + + +
    + + {hasChanges ? ( +
    + {visibleGroups.length === 0 ? ( +
    +

    + No tokens match the current filter. Try{" "} + + . +

    +
    + ) : ( + visibleGroups.map((group) => ( + + )) + )} +
    + ) : ( +
    +
    + )} + + {hasChanges && ( +
    +
    +

    Export diff

    +
    + {(["json", "css", "sass"] as ExportFormat[]).map((f) => ( + + ))} +
    +
    + +
    +            {exportText}
    +          
    + +
    + + +
    +
    + )} +
    + ); +} + +function DiffGroup({ + group, + isCollapsed, + onToggle, +}: { + group: TokenDiffGroup; + isCollapsed: boolean; + onToggle: (id: string) => void; +}) { + return ( +
    + + + {!isCollapsed && ( +
    +
    +
    + Token +
    +
    + Before +
    +
    + After +
    +
    + {group.entries.map((entry) => ( + + ))} +
    + )} +
    + ); +} diff --git a/src/pages/DesignTokens/tokenDiff.ts b/src/pages/DesignTokens/tokenDiff.ts new file mode 100644 index 0000000..2c3657a --- /dev/null +++ b/src/pages/DesignTokens/tokenDiff.ts @@ -0,0 +1,291 @@ +import { TOKEN_GROUPS, type Token, type TokenGroup } from "./tokens"; + +export type ChangeStatus = "added" | "changed" | "removed" | "unchanged"; +export type ExportFormat = "json" | "css" | "sass"; + +export interface TokenDiffEntry { + variable: string; + name: string; + status: ChangeStatus; + before?: string; + after?: string; + description?: string; + type: TokenGroup["type"]; + isBinary: boolean; +} + +export interface TokenDiffGroup { + id: string; + label: string; + entries: TokenDiffEntry[]; +} + +export const BINARY_ASSET_LABEL = "[binary asset]"; + +function indexByGroup(groups: TokenGroup[]): Map> { + const index = new Map>(); + for (const group of groups) { + const tokens = new Map(); + for (const token of group.tokens) tokens.set(token.variable, token); + index.set(group.id, tokens); + } + return index; +} + +export function computeTokenDiff( + before: TokenGroup[], + after: TokenGroup[] +): TokenDiffGroup[] { + const beforeIndex = indexByGroup(before); + const afterIndex = indexByGroup(after); + const groupIds = Array.from( + new Set([...beforeIndex.keys(), ...afterIndex.keys()]) + ); + + return groupIds + .map((id) => { + const beforeGroup = before.find((g) => g.id === id); + const afterGroup = after.find((g) => g.id === id); + // Every group id comes from at least one snapshot, so the + // representative group is always present. + const group = (afterGroup ?? beforeGroup)!; + const beforeTokens = beforeIndex.get(id) ?? new Map(); + const afterTokens = afterIndex.get(id) ?? new Map(); + const variables = Array.from( + new Set([...beforeTokens.keys(), ...afterTokens.keys()]) + ); + + const entries: TokenDiffEntry[] = variables.map((variable) => { + const beforeToken = beforeTokens.get(variable); + const afterToken = afterTokens.get(variable); + // Every variable comes from at least one snapshot, so the + // representative token is always present. + const current = (afterToken ?? beforeToken)!; + + let status: ChangeStatus = "unchanged"; + if (beforeToken && !afterToken) status = "removed"; + else if (!beforeToken && afterToken) status = "added"; + else if (beforeToken && afterToken && beforeToken.value !== afterToken.value) + status = "changed"; + + return { + variable, + name: current.name, + status, + before: beforeToken?.value, + after: afterToken?.value, + description: afterToken?.description ?? beforeToken?.description, + type: group.type, + isBinary: Boolean(afterToken?.isBinary ?? beforeToken?.isBinary), + }; + }); + + return { + id, + label: group.label, + entries, + }; + }) + .filter((group) => group.entries.length > 0); +} + +export function isHexColor(value: string): boolean { + return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value); +} + +function displayValue(entry: TokenDiffEntry): string { + if (entry.isBinary) return BINARY_ASSET_LABEL; + return entry.after ?? entry.before ?? ""; +} + +export function formatDiffJSON(groups: TokenDiffGroup[]): string { + const added: Record = {}; + const changed: Record = {}; + const removed: Record = {}; + + for (const group of groups) { + for (const entry of group.entries) { + if (entry.status === "added") { + added[entry.variable] = displayValue(entry); + } else if (entry.status === "changed") { + changed[entry.variable] = { + before: entry.before ?? "", + after: displayValue(entry), + }; + } else if (entry.status === "removed") { + removed[entry.variable] = entry.before ?? ""; + } + } + } + + return JSON.stringify({ added, changed, removed }, null, 2); +} + +export function formatDiffCSS(groups: TokenDiffGroup[]): string { + const added: string[] = []; + const changed: string[] = []; + const removed: string[] = []; + + for (const group of groups) { + for (const entry of group.entries) { + if (entry.status === "added") { + added.push(` ${entry.variable}: ${displayValue(entry)};`); + } else if (entry.status === "changed") { + changed.push(` ${entry.variable}: ${displayValue(entry)};`); + } else if (entry.status === "removed") { + removed.push(` ${entry.variable}: ${entry.before ?? ""};`); + } + } + } + + const sections: string[] = []; + const root: string[] = [":root {"]; + if (added.length > 0) root.push(" /* added */", ...added); + if (changed.length > 0) root.push(" /* changed */", ...changed); + root.push("}"); + sections.push(root.join("\n")); + + if (removed.length > 0) { + sections.push( + ["/* removed */", ...removed.map((line) => `/* ${line} */`)].join("\n") + ); + } + + return sections.join("\n\n"); +} + +export function formatDiffSass(groups: TokenDiffGroup[]): string { + const added: string[] = []; + const changed: string[] = []; + const removed: string[] = []; + const sassName = (variable: string) => variable.replace(/^--/, "$"); + + for (const group of groups) { + for (const entry of group.entries) { + if (entry.status === "added") { + added.push(`${sassName(entry.variable)}: ${displayValue(entry)};`); + } else if (entry.status === "changed") { + changed.push(`${sassName(entry.variable)}: ${displayValue(entry)};`); + } else if (entry.status === "removed") { + removed.push(`${sassName(entry.variable)}: ${entry.before ?? ""};`); + } + } + } + + const sections: string[] = []; + if (added.length > 0) sections.push(`// added\n${added.join("\n")}`); + if (changed.length > 0) sections.push(`// changed\n${changed.join("\n")}`); + if (removed.length > 0) + sections.push(`// removed\n${removed.map((line) => `// ${line}`).join("\n")}`); + + return sections.join("\n\n"); +} + +export function formatDiff( + groups: TokenDiffGroup[], + format: ExportFormat +): string { + switch (format) { + case "css": + return formatDiffCSS(groups); + case "sass": + return formatDiffSass(groups); + case "json": + default: + return formatDiffJSON(groups); + } +} + +export function diffFilename(format: ExportFormat): string { + const extension = format === "sass" ? "scss" : format; + return `revora-token-diff.${extension}`; +} + +function overrideValues( + groups: TokenGroup[], + overrides: Record +): TokenGroup[] { + return groups.map((group) => ({ + ...group, + tokens: group.tokens.map((token) => + overrides[token.variable] !== undefined + ? { ...token, value: overrides[token.variable] } + : token + ), + })); +} + +function removeTokens(groups: TokenGroup[], variables: string[]): TokenGroup[] { + return groups.map((group) => ({ + ...group, + tokens: group.tokens.filter((t) => !variables.includes(t.variable)), + })); +} + +function addTokens( + groups: TokenGroup[], + id: string, + tokens: Token[] +): TokenGroup[] { + return groups.map((group) => + group.id === id ? { ...group, tokens: [...group.tokens, ...tokens] } : group + ); +} + +/** + * The previous saved snapshot. A small number of values differ from the + * current draft so the diff view has added, changed, and removed examples. + */ +export const TOKEN_DIFF_BEFORE: TokenGroup[] = addTokens( + removeTokens( + overrideValues(TOKEN_GROUPS, { + "--primary": "#2563eb", + "--primary-hover": "#1d4ed8", + "--error": "#dc2626", + "--chart-cat-6-light": "#0e7490", + "--spacing-2xl": "1.75rem", + "--spacing-4xl": "4.5rem", + "--radius-md": "0.375rem", + "--font-size-5xl": "3.5rem", + "--font-weight-bold": "800", + "--shadow-xl": "0 24px 60px rgba(0,0,0,0.45)", + "--duration-kpi": "2s", + }), + ["--ds-error-icon-bg"] + ), + "colors", + [ + { + name: "Legacy Accent", + variable: "--legacy-accent", + value: "#f59e0b", + description: "Deprecated accent — replaced by the primary scale", + }, + ] +); + +/** The current draft state, including newly added and binary tokens. */ +export const TOKEN_DIFF_AFTER: TokenGroup[] = [ + ...TOKEN_GROUPS, + { + id: "icons", + label: "Icons (Binary)", + type: "motion", + tokens: [ + { + name: "Logo Mark", + variable: "--icon-logo", + value: "binary:logo.svg", + description: "Brand logo asset", + isBinary: true, + }, + { + name: "Lock Icon", + variable: "--icon-lock", + value: "binary:lock.svg", + description: "Secure action glyph", + isBinary: true, + }, + ], + }, +]; diff --git a/src/pages/DesignTokens/tokens.ts b/src/pages/DesignTokens/tokens.ts index 8dad6d0..7966151 100644 --- a/src/pages/DesignTokens/tokens.ts +++ b/src/pages/DesignTokens/tokens.ts @@ -3,6 +3,8 @@ export interface Token { variable: string; value: string; description?: string; + /** True when the token payload is binary (e.g. icon/image assets) and cannot be shown as text. */ + isBinary?: boolean; } export interface TokenGroup { diff --git a/vite.config.ts b/vite.config.ts index 18cf275..cbe5e41 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -49,6 +49,9 @@ export default defineConfig({ 'src/hooks/useCommandPalette.ts', // Issue #493 – Notification bell reduced-motion 'src/components/Notifications/NotificationBell.tsx', + // Issue #497 – Token diff and export UI + 'src/pages/DesignTokens/TokenDiff.tsx', + 'src/pages/DesignTokens/tokenDiff.ts', ], thresholds: { 'src/utils/financialTermsValidation.ts': { @@ -158,6 +161,12 @@ export default defineConfig({ 'src/components/Notifications/NotificationBell.tsx': { branches: 95, functions: 95, lines: 95, statements: 95, }, + 'src/pages/DesignTokens/TokenDiff.tsx': { + branches: 95, functions: 95, lines: 95, statements: 95, + }, + 'src/pages/DesignTokens/tokenDiff.ts': { + branches: 95, functions: 95, lines: 95, statements: 95, + }, } } }