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
335 changes: 335 additions & 0 deletions .agents/skills/codeatlas-ui/API.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions .agents/skills/codeatlas-ui/DEMOS.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,3 +384,6 @@ Every demo has a stable number. Fetch one with `get_demo` (MCP) or https://ui.co
| 987 | A whole page | layout/preview-stage | PreviewStage |
| 988 | Hear it | feedback/sound | Button, SegmentedControl, SoundToggle |
| 989 | Components | feedback/sound | Button, Dialog, Slider, Switch, Tabs, TabsList, TabsTrigger, SoundToggle |
| 990 | Brand color | inputs/color-field | ColorField |
| 991 | Text color | inputs/color-field | ColorField |
| 992 | Label and number | inputs/slider | SliderField |
5 changes: 5 additions & 0 deletions .changeset/charts-follow-theme-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@codeatlasdev/ui": patch
---

Charts follow a theme scope. They read their colors with `getComputedStyle` on `<html>`, so a chart inside a `ThemeIsland` or any `data-preset` / `data-theme-scope` element painted the page's colors, and its series palette ignored the island's preset. `ThemeIsland` now provides its element as the scope, and the new `ChartScope` (from `@codeatlasdev/ui/charts`) does the same for your own scoped containers. Without a scope nothing changes.
5 changes: 5 additions & 0 deletions .changeset/theme-studio-color-field.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@codeatlasdev/ui": minor
---

New `ColorField` (swatch, native picker, `#hex`/`rgb()`/`oklch()` entry and a live WCAG contrast readout) and `SliderField` (label, slider and an editable number with a unit). `Slider` takes a `label` so a thumb is announced as "Radius" instead of "Value 40". The color helpers behind them are public: `parseColorInput`, `contrast`, `wcagLevel`, `toHex`, `toOklch`, `formatOklch`, `extractPalette` and `pickBrand` (derive a brand from the pixels of a logo), plus `presetContrastIssues` and `presetTokens` for theme editors.
10 changes: 10 additions & 0 deletions docs/css-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ State attributes on `.ca-chip-group__chip`: `data-state="checked|unchecked"`, `d

`.ca-code-snippet` `.ca-code-snippet__copy` `.ca-code-snippet__lang` `.ca-code-snippet__pre` `.ca-code-snippet__pre--raw` `.ca-code-snippet__toolbar`

## ColorField

`.ca-color-field` `.ca-color-field__contrast` `.ca-color-field__level` `.ca-color-field__native` `.ca-color-field__pick` `.ca-color-field__ratio` `.ca-color-field__swatch`

Contrast state is exposed as `[data-pass]` and `[data-level]` on `.ca-color-field__contrast`.

## Collapsible

`.ca-collapsible` `.ca-collapsible__body` `.ca-collapsible__chevron` `.ca-collapsible__content` `.ca-collapsible__title` `.ca-collapsible__trigger` `.ca-slide-down` `.ca-slide-up`
Expand Down Expand Up @@ -395,6 +401,10 @@ The `skeleton` prop of `Avatar`, `KpiCard`, `TextField`, `Table`, `Card` and `Gl

`.ca-slider` `.ca-slider__range` `.ca-slider__thumb` `.ca-slider__track`

## SliderField

`.ca-slider-field` `.ca-slider-field__head` `.ca-slider-field__hint` `.ca-slider-field__label` `.ca-slider-field__value`

## SoundToggle

`.ca-rail-btn` `.ca-sound-toggle` `.ca-sound-toggle__btn` `.ca-sound-toggle__volume`
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@
"types": "./dist/utils/palette-generator.d.ts",
"default": "./dist/utils/palette-generator.js"
},
"./utils/color": {
"types": "./dist/utils/color.d.ts",
"default": "./dist/utils/color.js"
},
"./vite": {
"types": "./dist/vite-plugin.d.ts",
"default": "./dist/vite-plugin.js"
Expand Down
1 change: 1 addition & 0 deletions packages/ui/scripts/build-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const API_ENTRIES: Record<string, string> = {
[`${PKG}/seo`]: "seo/index.ts",
[`${PKG}/tokens`]: "tokens/index.ts",
[`${PKG}/utils/palette-generator`]: "utils/palette-generator.ts",
[`${PKG}/utils/color`]: "utils/color.ts",
};

export type ExportKind = "function" | "store" | "const" | "class" | "type";
Expand Down
12 changes: 5 additions & 7 deletions packages/ui/src/lib/actions/ScrollFocusableHarness.test.svelte
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
<script lang="ts">
import { scrollFocusable } from "./scroll-focusable.js";

let { later = false }: { later?: boolean } = $props();
let show = $state(false);
// Well after the action's first measurement and the first frame.
$effect(() => {
if (!later) return;
const t = setTimeout(() => (show = true), 300);
return () => clearTimeout(t);
});
// The test decides when the overflowing content mounts (after its first assertion), not a timer
// a slow machine can outrun. A test that never calls this renders a scroller with nothing in it.
export function reveal() {
show = true;
}
</script>

<!-- The scroller holds a fixed-height wrapper (like the stage's island); the content that
Expand Down
5 changes: 3 additions & 2 deletions packages/ui/src/lib/actions/scroll-focusable.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,16 @@ import Harness from "./ScrollFocusableHarness.test.svelte";

describe("scrollFocusable", () => {
it("content that overflows after mount, below a direct child, makes the scroller focusable", async () => {
const screen = await render(Harness, { later: true });
const screen = await render(Harness);
const el = screen.getByTestId("scroller").element() as HTMLElement;
expect(el.hasAttribute("tabindex")).toBe(false);
screen.component.reveal();
await expect.poll(() => el.scrollHeight > el.clientHeight + 1).toBe(true);
await expect.poll(() => el.tabIndex, { timeout: 2000 }).toBe(0);
});

it("adds no tab stop while nothing overflows", async () => {
const screen = await render(Harness, { later: false });
const screen = await render(Harness);
const el = screen.getByTestId("scroller").element() as HTMLElement;
expect(el.hasAttribute("tabindex")).toBe(false);
});
Expand Down
5 changes: 4 additions & 1 deletion packages/ui/src/lib/charts/BarChart.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import ChartCanvas from "./ChartCanvas.svelte";
import { barSeries } from "./series/bar.js";
import { withChartTheme } from "./theme.js";
import { useChartScope } from "./tokens.js";
import type { ChartSeries } from "./types.js";

interface Props {
Expand All @@ -27,6 +28,8 @@
height = "320px",
class: className = "",
}: Props = $props();
// Tokens come from the theme island this chart sits in (the page when there is none).
const scope = useChartScope();

const option = $derived<EChartsOption>(
withChartTheme({
Expand All @@ -45,7 +48,7 @@
...(s.color ? { itemStyle: { color: s.color, borderRadius: [6, 6, 0, 0] } } : {}),
}),
),
}),
}, scope.el),
);
</script>

Expand Down
4 changes: 3 additions & 1 deletion packages/ui/src/lib/charts/ChartCanvas.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import type { ECharts, EChartsOption } from "echarts";
import { chartGradient } from "./gradient.js";
import { chartPalette } from "./palette.js";
import { useChartScope } from "./tokens.js";

interface Props {
option: EChartsOption;
Expand All @@ -25,6 +26,7 @@

let { option, height = "320px", class: className = "", gradientSeries = [], overlay }: Props = $props();

const scope = useChartScope();
let container = $state<HTMLDivElement | null>(null);
let chart = $state<ECharts | null>(null);
// Module-level so onDestroy can disconnect even if onMount's async chain
Expand All @@ -36,7 +38,7 @@
if (!ec || !gradientSeries.length) return opt;
const series = Array.isArray(opt.series) ? [...opt.series] : [];
if (!series.length) return opt;
const palette = chartPalette();
const palette = chartPalette(scope.el);
const patched = series.map((s, i) => {
if (!gradientSeries.includes(i) || !s || typeof s !== "object") return s;
const row = s as Record<string, unknown>;
Expand Down
37 changes: 37 additions & 0 deletions packages/ui/src/lib/charts/ChartScope.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { setChartScope, type ChartScopeRef } from './tokens.js';

/**
* ChartScope: tells every chart below it which element holds its theme tokens.
*
* Charts are canvas, so they read tokens with `getComputedStyle` instead of the cascade. Without a
* scope they read `<html>`, which is wrong inside a `data-theme-scope` / `data-preset` island: the
* chart would paint the page's colors. Wrap the island's content in this and its charts follow it.
* `ThemeIsland` already does.
*/
interface Props {
/** The element to read tokens from. Omit to use the wrapper div this component renders. */
scope?: HTMLElement | null;
class?: string;
children?: Snippet;
}
let { scope = null, class: className = '', children }: Props = $props();

let host = $state<HTMLElement | null>(null);
const ref: ChartScopeRef = $state({ el: null });
// `display: contents` has no box but still inherits: its computed style is the island's.
$effect(() => {
ref.el = scope ?? host;
});
setChartScope(ref);
</script>

<div bind:this={host} class="ca-chart-scope {className}">{@render children?.()}</div>

<style>
@layer ca.reset, ca.tokens, ca.themes, ca.base, ca.materials, ca.utilities, ca.components;
@layer ca.components {
:global(.ca-chart-scope) { display: contents; }
}
</style>
8 changes: 8 additions & 0 deletions packages/ui/src/lib/charts/ChartScopeHarness.test.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<script lang="ts">
import ChartScope from './ChartScope.svelte';
import Probe from './ChartScopeProbe.test.svelte';
</script>

<!-- The island carries the scope attributes; ChartScope inside it resolves tokens from the island. -->
<div data-theme-scope="light" data-preset="ocean"><ChartScope><Probe id="light" /></ChartScope></div>
<div data-theme-scope="dark" data-preset="violet"><ChartScope><Probe id="dark" /></ChartScope></div>
19 changes: 19 additions & 0 deletions packages/ui/src/lib/charts/ChartScopeProbe.test.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<script lang="ts">
// A chart-shaped consumer: it resolves its scope during init and tokens when it renders, exactly
// like every public chart.
import { chartPalette } from './palette.js';
import { chartTokens, useChartScope } from './tokens.js';

let { id }: { id: string } = $props();
const scope = useChartScope();
let host = $state<HTMLElement>();
let brand = $state('');
let palette = $state('');
$effect(() => {
if (!host || !scope.el) return;
brand = chartTokens(scope.el).brand;
palette = chartPalette(scope.el)[0] ?? '';
});
</script>

<output bind:this={host} data-testid={id} data-brand={brand} data-palette={palette}></output>
9 changes: 6 additions & 3 deletions packages/ui/src/lib/charts/GaugeChart.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import type { EChartsOption } from "echarts";
import ChartCanvas from "./ChartCanvas.svelte";
import { withChartTheme } from "./theme.js";
import { useChartScope } from "./tokens.js";
import { chartTokens, resolveZoneColor } from "./tokens.js";
import type { GaugeZone } from "./types.js";

Expand Down Expand Up @@ -37,9 +38,11 @@
height = "280px",
class: className = "",
}: Props = $props();
// Tokens come from the theme island this chart sits in (the page when there is none).
const scope = useChartScope();

const option = $derived.by<EChartsOption>(() => {
const t = chartTokens();
const t = chartTokens(scope.el);
const fmt = formatter ?? ((v: number) => `${v}%`);

const seriesBase = {
Expand Down Expand Up @@ -83,7 +86,7 @@
detail: { ...detail, fontSize: 36, fontWeight: "bold", offsetCenter: [0, 0] },
},
],
});
}, scope.el);
}

return withChartTheme({
Expand Down Expand Up @@ -113,7 +116,7 @@
: {}),
},
],
});
}, scope.el);
});
</script>

Expand Down
11 changes: 7 additions & 4 deletions packages/ui/src/lib/charts/HeatmapChart.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import type { EChartsOption } from "echarts";
import ChartCanvas from "./ChartCanvas.svelte";
import { withChartTheme } from "./theme.js";
import { useChartScope } from "./tokens.js";
import { chartPalette, withAlpha } from "./palette.js";
import { chartTokens } from "./tokens.js";
import type { CalendarHeatmapPoint, MatrixHeatmapPoint } from "./types.js";
Expand Down Expand Up @@ -40,6 +41,8 @@
height = "240px",
class: className = "",
}: Props = $props();
// Tokens come from the theme island this chart sits in (the page when there is none).
const scope = useChartScope();

function buildCalendarOption(
t: ReturnType<typeof chartTokens>,
Expand Down Expand Up @@ -82,7 +85,7 @@
data,
},
],
});
}, scope.el);
}

function buildMatrixOption(
Expand Down Expand Up @@ -145,12 +148,12 @@
animationDelay: (idx: number) => idx * 2,
},
],
});
}, scope.el);
}

const option = $derived.by<EChartsOption>(() => {
const t = chartTokens();
const palette = chartPalette();
const t = chartTokens(scope.el);
const palette = chartPalette(scope.el);
return variant === "calendar"
? buildCalendarOption(t, palette)
: buildMatrixOption(t, palette);
Expand Down
5 changes: 4 additions & 1 deletion packages/ui/src/lib/charts/LineChart.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import ChartCanvas from "./ChartCanvas.svelte";
import { lineSeries } from "./series/line.js";
import { withChartTheme } from "./theme.js";
import { useChartScope } from "./tokens.js";
import type { ChartSeries } from "./types.js";

interface Props {
Expand All @@ -23,6 +24,8 @@
}

let { series, categories, valueFormatter, area = false, height = "320px", class: className = "" }: Props = $props();
// Tokens come from the theme island this chart sits in (the page when there is none).
const scope = useChartScope();

const option = $derived<EChartsOption>(
withChartTheme({
Expand All @@ -42,7 +45,7 @@
...(area ? { areaStyle: {} } : {}),
}),
),
}),
}, scope.el),
);

const gradientSeries = $derived(area ? series.map((_, i) => i) : []);
Expand Down
7 changes: 5 additions & 2 deletions packages/ui/src/lib/charts/PieChart.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import ChartCanvas from "./ChartCanvas.svelte";
import { pieSeries } from "./series/pie.js";
import { withChartTheme } from "./theme.js";
import { chartTokens, useChartScope } from "./tokens.js";
import type { ChartSlice } from "./types.js";

interface Props {
Expand All @@ -19,6 +20,8 @@
}

let { data, donut = false, rose = false, centerLabel, height = "300px", class: className = "" }: Props = $props();
// Tokens come from the theme island this chart sits in (the page when there is none).
const scope = useChartScope();

const option = $derived<EChartsOption>(
withChartTheme({
Expand All @@ -31,9 +34,9 @@
roseType: rose ? "radius" : undefined,
label: rose ? { show: true, fontSize: 11 } : { show: false },
data,
}),
}, chartTokens(scope.el)),
],
}),
}, scope.el),
);
</script>

Expand Down
7 changes: 5 additions & 2 deletions packages/ui/src/lib/charts/RadarChart.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import type { EChartsOption } from "echarts";
import ChartCanvas from "./ChartCanvas.svelte";
import { withChartTheme } from "./theme.js";
import { useChartScope } from "./tokens.js";
import { chartTokens } from "./tokens.js";
import type { RadarIndicator, RadarSeries } from "./types.js";

Expand All @@ -14,9 +15,11 @@
}

let { indicators, series, height = "340px", class: className = "" }: Props = $props();
// Tokens come from the theme island this chart sits in (the page when there is none).
const scope = useChartScope();

const option = $derived.by<EChartsOption>(() => {
const t = chartTokens();
const t = chartTokens(scope.el);
return withChartTheme({
tooltip: { trigger: "item" },
legend: series.length > 1 ? { data: series.map((s) => s.name) } : undefined,
Expand All @@ -41,7 +44,7 @@
})),
},
],
});
}, scope.el);
});
</script>

Expand Down
Loading