mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Add shared Icon primitive and controlled glyph vocabulary (Carbon, filled)
This commit is contained in:
@@ -214,14 +214,20 @@
|
||||
}
|
||||
|
||||
.warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.warning::before {
|
||||
content: '⚠ ';
|
||||
color: var(--support-warning, var(--text-secondary));
|
||||
/* Filled status-warning glyph (arch 09 §5.2), in the contrast-safe amber. Replaces
|
||||
the old ⚠ text character so the warning matches the toast status family. */
|
||||
.warningIcon {
|
||||
flex: none;
|
||||
margin-top: 1px;
|
||||
color: var(--support-warning-fg);
|
||||
}
|
||||
|
||||
/* Explains the disabled Create action (contract 10: a disabled control must say
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
useChartBuilderStore,
|
||||
} from '../stores/ChartBuilderStore';
|
||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||
import { Icon } from './Icon';
|
||||
import styles from './ChartBuilderModal.module.css';
|
||||
|
||||
const RENDER_DEBOUNCE_MS = 300;
|
||||
@@ -460,7 +461,8 @@ export function ChartBuilderModal() {
|
||||
<ul className={styles.warnings}>
|
||||
{warnings.map((w) => (
|
||||
<li key={w.message} className={styles.warning}>
|
||||
{w.message}
|
||||
<Icon name="status-warning" className={styles.warningIcon} />
|
||||
<span>{w.message}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
|
||||
.newButton {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
margin: var(--space-4);
|
||||
height: 40px;
|
||||
padding: 0 var(--space-5);
|
||||
|
||||
@@ -22,6 +22,7 @@ import { notify } from '../stores/NotificationStore';
|
||||
import { selectSelectedDataset, useDatasetStore, byModifiedDesc } from '../stores/DatasetStore';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||
import { Icon } from './Icon';
|
||||
import styles from './DatasetsModal.module.css';
|
||||
|
||||
/** Display label for a format (spec §05 → List item: JSON / CSV / TSV / TopoJSON). */
|
||||
@@ -76,7 +77,7 @@ export function DatasetsModal() {
|
||||
<div className={styles.manager}>
|
||||
<div className={styles.listPane}>
|
||||
<button type="button" className={styles.newButton} onClick={handleNew}>
|
||||
+ New Dataset
|
||||
<Icon name="add" /> New Dataset
|
||||
</button>
|
||||
<ul className={styles.list}>
|
||||
{ordered.length === 0 && (
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/* Shared icon primitive (arch 09 §5). Icons are fill: currentColor SVG, so they
|
||||
inherit the surrounding text colour and theme automatically. The size classes
|
||||
map to the Carbon scale tokens; the glyph keeps its own rounded geometry and is
|
||||
exempt from --radius (square chrome applies to chrome, not icons). */
|
||||
.icon {
|
||||
display: inline-block;
|
||||
flex: 0 0 auto;
|
||||
fill: currentColor;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.sm {
|
||||
width: var(--icon-sm);
|
||||
height: var(--icon-sm);
|
||||
}
|
||||
|
||||
.md {
|
||||
width: var(--icon-md);
|
||||
height: var(--icon-md);
|
||||
}
|
||||
|
||||
.lg {
|
||||
width: var(--icon-lg);
|
||||
height: var(--icon-lg);
|
||||
}
|
||||
|
||||
.xl {
|
||||
width: var(--icon-xl);
|
||||
height: var(--icon-xl);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
act(() => root.render(node));
|
||||
const svg = container.querySelector('svg');
|
||||
if (!svg) throw new Error('no <svg> rendered');
|
||||
return svg;
|
||||
}
|
||||
|
||||
describe('Icon', () => {
|
||||
test('renders an SVG on the Carbon 32-grid, decorative and unfocusable', () => {
|
||||
const svg = render(<Icon name="close" />);
|
||||
expect(svg.getAttribute('viewBox')).toBe('0 0 32 32');
|
||||
// Decorative by default: the enclosing control carries the accessible name.
|
||||
expect(svg.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(svg.getAttribute('focusable')).toBe('false');
|
||||
});
|
||||
|
||||
test('each name draws a distinct glyph from the registry', () => {
|
||||
// close is a single polygon; delete (TrashCan) is rects + a path; dataset
|
||||
// (DataTable) is six cells + a path. Distinct geometry ⇒ distinct meaning.
|
||||
expect(render(<Icon name="close" />).querySelector('polygon')).not.toBeNull();
|
||||
const del = render(<Icon name="delete" />);
|
||||
expect(del.querySelectorAll('rect').length).toBe(3);
|
||||
expect(del.querySelector('path')).not.toBeNull();
|
||||
expect(render(<Icon name="dataset" />).querySelectorAll('rect').length).toBe(6);
|
||||
});
|
||||
|
||||
test('size maps to a single, changeable class token', () => {
|
||||
const sm = render(<Icon name="add" />); // default
|
||||
const smClass = sm.getAttribute('class') ?? '';
|
||||
const md = render(<Icon name="add" size="md" />);
|
||||
const mdClass = md.getAttribute('class') ?? '';
|
||||
// Whatever the CSS-module hashing, the two sizes must resolve to different
|
||||
// class strings so the 16/20/24/32 scale is actually applied, not ignored.
|
||||
expect(smClass).not.toBe(mdClass);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Icon — the shared icon primitive and controlled vocabulary (arch 09 §5).
|
||||
*
|
||||
* Astrolabe is label-first: an icon is added only when it does real work, usually
|
||||
* alongside text (arch 09 §5.1). This module is the single source of truth for the
|
||||
* icon set — one glyph per meaning, app-wide (§5.2). Adding an icon means adding a
|
||||
* `IconName` + a registry entry here, never inlining an SVG in a component.
|
||||
*
|
||||
* The glyphs are traced from IBM Carbon's icon set (32×32 grid, fill-based — they
|
||||
* read as outlines but are filled shapes). Carbon is inspiration, not a dependency.
|
||||
* Every glyph draws with `fill: currentColor`, so it inherits its text colour and
|
||||
* themes for free.
|
||||
*
|
||||
* Icons are decorative by default (`aria-hidden`): the control around them carries
|
||||
* the accessible name (text label, or an `aria-label` on an icon-only button — APG
|
||||
* button pattern). Never rely on an icon alone to name a control.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import styles from './Icon.module.css';
|
||||
|
||||
/** The controlled icon vocabulary (arch 09 §5.2). One entry = one meaning. */
|
||||
export type IconName =
|
||||
| 'close' // close / dismiss (universal, icon-only)
|
||||
| 'moon' // theme: switch to dark (universal, icon-only)
|
||||
| 'sun' // theme: switch to light (universal, icon-only)
|
||||
| 'dataset' // "references a dataset" — Carbon DataTable
|
||||
| 'delete' // delete — Carbon TrashCan
|
||||
| 'add' // add / create-new — Carbon Add
|
||||
// Status sub-family (arch 09 §5.2) — Carbon's FILLED notification glyphs, coloured
|
||||
// by status (not text). A redundant non-colour severity channel (WCAG 1.4.1): the
|
||||
// triangle shape-codes warning apart from the round error/success/info.
|
||||
| 'status-error' // Carbon ErrorFilled
|
||||
| 'status-warning' // Carbon WarningAltFilled (triangle)
|
||||
| 'status-success' // Carbon CheckmarkFilled
|
||||
| 'status-info'; // Carbon InformationFilled
|
||||
|
||||
/** Carbon icon scale (arch 09 §5.3). 16px (sm) is the default, paired to 14px body. */
|
||||
export type IconSize = 'sm' | 'md' | 'lg' | 'xl';
|
||||
|
||||
/** Inner SVG geometry per glyph, on Carbon's 0 0 32 32 grid. fill comes from CSS. */
|
||||
const GLYPHS: Record<IconName, ReactNode> = {
|
||||
close: (
|
||||
<polygon points="17.4141 16 24 9.4141 22.5859 8 16 14.5859 9.4143 8 8 9.4141 14.5859 16 8 22.5859 9.4143 24 16 17.4141 22.5859 24 24 22.5859 17.4141 16" />
|
||||
),
|
||||
add: <polygon points="17,15 17,8 15,8 15,15 8,15 8,17 15,17 15,24 17,24 17,17 24,17 24,15" />,
|
||||
delete: (
|
||||
<>
|
||||
<rect x="12" y="12" width="2" height="12" />
|
||||
<rect x="18" y="12" width="2" height="12" />
|
||||
<path d="M4,6V8H6V28a2,2,0,0,0,2,2H24a2,2,0,0,0,2-2V8h2V6ZM8,28V8H24V28Z" />
|
||||
<rect x="12" y="2" width="8" height="2" />
|
||||
</>
|
||||
),
|
||||
dataset: (
|
||||
<>
|
||||
<rect x="8" y="18" width="4" height="2" />
|
||||
<rect x="14" y="18" width="4" height="2" />
|
||||
<rect x="8" y="14" width="4" height="2" />
|
||||
<rect x="14" y="22" width="4" height="2" />
|
||||
<rect x="20" y="14" width="4" height="2" />
|
||||
<rect x="20" y="22" width="4" height="2" />
|
||||
<path d="M27,3H5A2.0025,2.0025,0,0,0,3,5V27a2.0025,2.0025,0,0,0,2,2H27a2.0025,2.0025,0,0,0,2-2V5A2.0025,2.0025,0,0,0,27,3Zm0,2,0,4H5V5ZM5,27V11H27l0,16Z" />
|
||||
</>
|
||||
),
|
||||
moon: (
|
||||
<path d="M13.5025,5.4136A15.0755,15.0755,0,0,0,25.096,23.6082a11.1134,11.1134,0,0,1-7.9749,3.3893c-.1385,0-.2782.0051-.4178,0A11.0944,11.0944,0,0,1,13.5025,5.4136M14.98,3a1.0024,1.0024,0,0,0-.1746.0156A13.0959,13.0959,0,0,0,16.63,28.9973c.1641.006.3282,0,.4909,0a13.0724,13.0724,0,0,0,10.702-5.5556,1.0094,1.0094,0,0,0-.7833-1.5644A13.08,13.08,0,0,1,15.8892,4.38,1.0149,1.0149,0,0,0,14.98,3Z" />
|
||||
),
|
||||
sun: (
|
||||
<>
|
||||
<rect x="15" y="2" width="2" height="5" />
|
||||
<rect
|
||||
x="21.6675"
|
||||
y="6.8536"
|
||||
width="4.958"
|
||||
height="1.9998"
|
||||
transform="translate(1.5191 19.3744) rotate(-45)"
|
||||
/>
|
||||
<rect x="25" y="15" width="5" height="2" />
|
||||
<rect
|
||||
x="23.1466"
|
||||
y="21.6675"
|
||||
width="1.9998"
|
||||
height="4.958"
|
||||
transform="translate(-10.0018 24.1465) rotate(-45)"
|
||||
/>
|
||||
<rect x="15" y="25" width="2" height="5" />
|
||||
<rect
|
||||
x="5.3745"
|
||||
y="23.1466"
|
||||
width="4.958"
|
||||
height="1.9998"
|
||||
transform="translate(-14.7739 12.6256) rotate(-45)"
|
||||
/>
|
||||
<rect x="2" y="15" width="5" height="2" />
|
||||
<rect
|
||||
x="6.8536"
|
||||
y="5.3745"
|
||||
width="1.9998"
|
||||
height="4.958"
|
||||
transform="translate(-3.253 7.8535) rotate(-45)"
|
||||
/>
|
||||
<path d="M16,12a4,4,0,1,1-4,4,4.0045,4.0045,0,0,1,4-4m0-2a6,6,0,1,0,6,6,6,6,0,0,0-6-6Z" />
|
||||
</>
|
||||
),
|
||||
// Filled status glyphs: the symbol is a winding-rule knockout in the solid shape,
|
||||
// so it shows the surface colour through (Carbon's filled notification family).
|
||||
'status-error': (
|
||||
<path d="M16,2A13.914,13.914,0,0,0,2,16,13.914,13.914,0,0,0,16,30,13.914,13.914,0,0,0,30,16,13.914,13.914,0,0,0,16,2Zm5.4449,21L9,10.5557,10.5557,9,23,21.4448Z" />
|
||||
),
|
||||
'status-warning': (
|
||||
<path d="M16.002,6.1714h-.004L4.6487,27.9966,4.6506,28H27.3494l.0019-.0034ZM14.875,12h2.25v9h-2.25ZM16,26a1.5,1.5,0,1,1,1.5-1.5A1.5,1.5,0,0,1,16,26Z" />
|
||||
),
|
||||
'status-success': (
|
||||
<path d="M16,2A14,14,0,1,0,30,16,14,14,0,0,0,16,2ZM14,21.5908l-5-5L10.5906,15,14,18.4092,21.41,11l1.5957,1.5859Z" />
|
||||
),
|
||||
'status-info': (
|
||||
<path d="M16,2A14,14,0,1,0,30,16,14,14,0,0,0,16,2Zm0,6a1.5,1.5,0,1,1-1.5,1.5A1.5,1.5,0,0,1,16,8Zm4,16.125H12v-2.25h2.875v-5.75H13v-2.25h4.125v8H20Z" />
|
||||
),
|
||||
};
|
||||
|
||||
export interface IconProps {
|
||||
name: IconName;
|
||||
/** Carbon scale; defaults to `sm` (16px) — the body-text default. */
|
||||
size?: IconSize;
|
||||
/** Extra class for layout-level overrides (margins, colour). */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Icon({ name, size = 'sm', className }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={`${styles.icon} ${styles[size]}${className ? ` ${className}` : ''}`}
|
||||
viewBox="0 0 32 32"
|
||||
// Decorative: the enclosing control names itself (arch 09 §5.1).
|
||||
aria-hidden="true"
|
||||
focusable={false}
|
||||
>
|
||||
{GLYPHS[name]}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { useAppStore } from '../stores/AppStore';
|
||||
import { getModalConfig, getModalTitle } from '../modals/modal-registry';
|
||||
import { closeModal } from '../modals/ModalCoordinator';
|
||||
import { useFocusTrap } from '../hooks/useFocusTrap';
|
||||
import { Icon } from './Icon';
|
||||
import styles from './ModalShell.module.css';
|
||||
|
||||
export function ModalShell() {
|
||||
@@ -69,7 +70,7 @@ export function ModalShell() {
|
||||
aria-label="Close"
|
||||
onClick={() => void closeModal()}
|
||||
>
|
||||
×
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</header>
|
||||
<div className={styles.body}>
|
||||
|
||||
@@ -13,45 +13,9 @@
|
||||
*/
|
||||
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
import { Icon } from './Icon';
|
||||
import styles from './ThemeToggle.module.css';
|
||||
|
||||
function MoonIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SunIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="4.5" />
|
||||
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
const uiTheme = useAppStore((s) => s.uiTheme);
|
||||
const toggleTheme = useAppStore((s) => s.toggleTheme);
|
||||
@@ -69,7 +33,7 @@ export function ThemeToggle() {
|
||||
aria-label="Dark theme"
|
||||
title={`Switch to ${target} theme`}
|
||||
>
|
||||
{uiTheme === 'dark' ? <SunIcon /> : <MoonIcon />}
|
||||
{uiTheme === 'dark' ? <Icon name="sun" size="md" /> : <Icon name="moon" size="md" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,18 @@
|
||||
--toast-accent: var(--support-info);
|
||||
}
|
||||
|
||||
/* Leading status glyph (arch 09 §5.2): coloured by kind, top-aligned with the
|
||||
title. The accent IS the icon colour, except warning, whose raw yellow fails
|
||||
contrast on the light surface — it uses the darkened fg token. */
|
||||
.statusIcon {
|
||||
flex: none;
|
||||
margin-top: 1px;
|
||||
color: var(--toast-accent);
|
||||
}
|
||||
.warning .statusIcon {
|
||||
color: var(--support-warning-fg);
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { useNotificationStore, type NotificationKind } from '../stores/NotificationStore';
|
||||
import { Toaster } from './Toaster';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
useNotificationStore.getState().clear();
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
useNotificationStore.getState().clear();
|
||||
});
|
||||
|
||||
const KINDS: NotificationKind[] = ['error', 'warning', 'success', 'info'];
|
||||
|
||||
describe('Toaster status glyphs', () => {
|
||||
// The point of the status icon is a non-colour severity channel (WCAG 1.4.1) — so
|
||||
// every kind must render a leading glyph, not lean on the border colour alone.
|
||||
test.each(KINDS)('a %s toast renders a leading status glyph', (kind) => {
|
||||
act(() => {
|
||||
useNotificationStore.getState().notify({ kind, title: `${kind} title`, message: 'body' });
|
||||
root.render(<Toaster />);
|
||||
});
|
||||
const toast = container.querySelector('[role="alert"], [role="status"]');
|
||||
expect(toast).not.toBeNull();
|
||||
// The status glyph is a direct-child <svg> of the toast (the close button's svg
|
||||
// is nested inside the button, so :scope > svg isolates the status icon).
|
||||
expect(toast!.querySelector(':scope > svg')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('error and warning are assertive; success and info are polite', () => {
|
||||
act(() => {
|
||||
useNotificationStore.getState().notify({ kind: 'error', title: 'e', message: 'b' });
|
||||
useNotificationStore.getState().notify({ kind: 'success', title: 's', message: 'b' });
|
||||
root.render(<Toaster />);
|
||||
});
|
||||
expect(container.querySelector('[role="alert"]')).not.toBeNull();
|
||||
expect(container.querySelector('[role="status"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type Notification,
|
||||
type NotificationKind,
|
||||
} from '../stores/NotificationStore';
|
||||
import { Icon, type IconName } from './Icon';
|
||||
import styles from './Toaster.module.css';
|
||||
|
||||
/** Auto-dismiss delay for the non-critical kinds (ms). Errors/warnings persist. */
|
||||
@@ -36,6 +37,15 @@ const KIND_CLASS: Record<NotificationKind, string> = {
|
||||
info: styles.info,
|
||||
};
|
||||
|
||||
/** Filled status glyph per kind (arch 09 §5.2) — a non-colour severity channel,
|
||||
* decorative (the title names the toast); coloured by kind in the CSS. */
|
||||
const KIND_ICON: Record<NotificationKind, IconName> = {
|
||||
error: 'status-error',
|
||||
warning: 'status-warning',
|
||||
success: 'status-success',
|
||||
info: 'status-info',
|
||||
};
|
||||
|
||||
function Toast({ notification }: { notification: Notification }) {
|
||||
const dismiss = useNotificationStore((s) => s.dismiss);
|
||||
const { id, kind, title, message, detail } = notification;
|
||||
@@ -54,6 +64,7 @@ function Toast({ notification }: { notification: Notification }) {
|
||||
// Errors/warnings interrupt assistive tech (assertive); the rest are polite.
|
||||
role={kind === 'error' || kind === 'warning' ? 'alert' : 'status'}
|
||||
>
|
||||
<Icon name={KIND_ICON[kind]} size="md" className={styles.statusIcon} />
|
||||
<div className={styles.body}>
|
||||
<p className={styles.title}>{title}</p>
|
||||
<p className={styles.message}>{message}</p>
|
||||
@@ -71,7 +82,7 @@ function Toast({ notification }: { notification: Notification }) {
|
||||
aria-label="Dismiss notification"
|
||||
onClick={() => dismiss(id)}
|
||||
>
|
||||
✕
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Vega-Lite chart config per UI theme (docs/architecture/05 §3, 09 §5).
|
||||
* Vega-Lite chart config per UI theme (docs/architecture/05 §3, 09 §6).
|
||||
*
|
||||
* Portable core: a Vega-Lite `Config` styles every chart globally so charts
|
||||
* visually belong to the app rather than looking like stock Vega-Lite. This is
|
||||
|
||||
Reference in New Issue
Block a user