diff --git a/eslint.config.js b/eslint.config.js index ccf23ac..9bccbe3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -44,10 +44,10 @@ export default tseslint.config( languageOptions: { globals: { ...globals.node } }, }, - // Plain JS config files (this file, etc.) are not part of the TS project — - // run them through the untyped ruleset only. + // Plain JS config files and Node scripts (this file, scripts/*.mjs, etc.) are + // not part of the TS project — run them through the untyped ruleset only. { - files: ['**/*.js'], + files: ['**/*.{js,mjs}'], extends: [tseslint.configs.disableTypeChecked], languageOptions: { globals: { ...globals.node } }, }, diff --git a/package-lock.json b/package-lock.json index dd1a39c..ea44ac3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "@types/react": "^19.2.16", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.2.0", + "ajv": "^8.20.0", "eslint": "^10.4.1", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", diff --git a/package.json b/package.json index c5729f1..9578c15 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "description": "A browser-based snippet manager for Vega-Lite visualizations.", "scripts": { "dev": "vite", - "build": "tsc --noEmit && vite build", + "build": "tsc --noEmit && vite build && node scripts/check-light-entries.mjs", "preview": "vite preview", "typecheck": "tsc --noEmit", "test": "vitest run", @@ -56,6 +56,7 @@ "@types/react": "^19.2.16", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.2.0", + "ajv": "^8.20.0", "eslint": "^10.4.1", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", diff --git a/public/_headers b/public/_headers new file mode 100644 index 0000000..e31a272 --- /dev/null +++ b/public/_headers @@ -0,0 +1,10 @@ +# Cloudflare Pages header rules (copied into dist/ by Vite's public/ passthrough). +# +# Everything under /assets/ is content-hashed by the build (JS, CSS, and the +# font files), so it is safe to cache forever — a changed file gets a new URL. +# Without this rule CF Pages serves its default `max-age=14400, must-revalidate`, +# making every returning visitor revalidate multi-MB vendor chunks every 4 hours. +# HTML keeps the platform default (max-age=0, must-revalidate) so deploys +# propagate instantly; icons live at the root un-hashed and keep the default too. +/assets/* + Cache-Control: public, max-age=31536000, immutable diff --git a/scripts/check-light-entries.mjs b/scripts/check-light-entries.mjs new file mode 100644 index 0000000..61918b4 --- /dev/null +++ b/scripts/check-light-entries.mjs @@ -0,0 +1,47 @@ +// Post-build gate: the marketing entries stay light. The landing (/) and the +// learning section (/learn/) must never gain a static edge into the heavy +// vendor chunks — this shipped once (Vite's preload helper emitted inside the +// monaco chunk chained every lazy import() to it; a vega-scale import in +// theme-controls was reachable from Landing). Vite lists an entry's full +// static graph as modulepreload links / module scripts in its HTML, so +// grepping the emitted HTML catches any regression regardless of cause. +// +// Runs as part of `npm run build` (after `vite build`). Exit 1 on violation. +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +const pages = ['dist/index.html', 'dist/learn/index.html']; +if (existsSync('dist/learn')) { + for (const entry of readdirSync('dist/learn', { withFileTypes: true })) { + if (entry.isDirectory()) pages.push(join('dist/learn', entry.name, 'index.html')); + } +} + +const HEAVY = /vendor-(?:monaco|vega)-[^"']*\.js/; +const missing = pages.filter((p) => !existsSync(p)); +if (missing.length > 0) { + console.error( + `check-light-entries: expected pages missing from dist/:\n ${missing.join('\n ')}`, + ); + process.exit(1); +} +// The gate bans chunks by name, so it must not fail open: if the names minted in +// vite.config.ts (manualChunks) ever change, this makes the rename update the gate +// instead of silently disarming it. +const assets = readdirSync('dist/assets'); +for (const chunk of ['vendor-monaco', 'vendor-vega']) { + if (!assets.some((f) => f.startsWith(`${chunk}-`) && f.endsWith('.js'))) { + console.error( + `check-light-entries: no ${chunk}-*.js in dist/assets — chunk naming changed; update vite.config.ts manualChunks and this gate together.`, + ); + process.exit(1); + } +} +const offenders = pages.filter((p) => HEAVY.test(readFileSync(p, 'utf8'))); +if (offenders.length > 0) { + console.error( + `check-light-entries: light entries reference heavy vendor chunks:\n ${offenders.join('\n ')}`, + ); + process.exit(1); +} +console.log(`check-light-entries: OK (${pages.length} pages clean of vendor-monaco/vendor-vega)`); diff --git a/src/app/components/ColorControls.tsx b/src/app/components/ColorControls.tsx index 9ae887b..e9ab2a5 100644 --- a/src/app/components/ColorControls.tsx +++ b/src/app/components/ColorControls.tsx @@ -20,13 +20,8 @@ import type { ReactNode } from 'react'; import type { JsonObject } from '@core/spec-config'; -import { - type ConfigPath, - countSet, - getConfigValue, - schemeColors, - schemesByKind, -} from '@core/theme-controls'; +import { type ConfigPath, countSet, getConfigValue, schemesByKind } from '@core/theme-controls'; +import { schemeColors } from '@core/scheme-colors'; import { Button } from './Button'; import { ColorField } from './ColorField'; import { Icon } from './Icon'; diff --git a/src/core/scheme-colors.test.ts b/src/core/scheme-colors.test.ts new file mode 100644 index 0000000..f2e02bc --- /dev/null +++ b/src/core/scheme-colors.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { schemeColors } from './scheme-colors'; + +describe('schemeColors', () => { + it('returns the full fixed palette for a categorical scheme (count ignored)', () => { + const colors = schemeColors('tableau10', 3); + expect(colors).toHaveLength(10); + expect(colors.every((c) => /^#[0-9a-f]{6}$/i.test(c))).toBe(true); + }); + + it('samples a continuous scheme at count stops, normalized to hex', () => { + const colors = schemeColors('viridis', 5); + expect(colors).toHaveLength(5); + expect(colors.every((c) => /^#[0-9a-f]{6}$/.test(c))).toBe(true); + }); + + it('samples the midpoint for a single continuous stop', () => { + expect(schemeColors('viridis', 1)).toHaveLength(1); + }); + + it('returns [] for an unknown scheme', () => { + expect(schemeColors('not-a-scheme')).toEqual([]); + }); +}); diff --git a/src/core/scheme-colors.ts b/src/core/scheme-colors.ts new file mode 100644 index 0000000..2a62576 --- /dev/null +++ b/src/core/scheme-colors.ts @@ -0,0 +1,40 @@ +/** + * Named-scheme resolution to hex swatches (docs/exploration/chart-theming-scope.md §5). + * + * Split from theme-controls.ts because of the `vega-scale` import below: + * theme-controls is reachable from the landing/learn entry graphs (via + * `vega-themes`), and a static vega-scale edge there drags the vega vendor + * chunk into the marketing pages' eager JS (`scripts/check-light-entries.mjs` + * guards this after every build). Only the app's color controls resolve + * schemes to swatches, so the edge lives here, app-side of the split. + */ + +import { scheme } from 'vega-scale'; + +/** + * Resolve a named Vega scheme to hex swatches. A categorical scheme returns its + * fixed palette in full; a continuous scheme (sequential/diverging) is sampled + * at `count` evenly-spaced stops (`count` applies to continuous schemes only). + * Continuous interpolators yield `rgb(...)`, normalized to hex here. An unknown + * name returns `[]` — the picker shows that scheme without a preview rather than + * throwing. Used for the swatch/gradient preview and "materialize to swatches". + */ +export function schemeColors(name: string, count = 9): string[] { + const resolved: unknown = scheme(name); + if (Array.isArray(resolved)) return resolved.map((c) => toHex(String(c))); + if (typeof resolved === 'function' && count > 0) { + const interp = resolved as (t: number) => string; + if (count === 1) return [toHex(interp(0.5))]; + return Array.from({ length: count }, (_, i) => toHex(interp(i / (count - 1)))); + } + return []; +} + +/** Normalize a CSS color to `#rrggbb`; passes through existing hex and unknowns. */ +function toHex(color: string): string { + if (color.startsWith('#')) return color; + const m = /rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i.exec(color); + if (!m) return color; + const h = (n: string) => Math.round(Number(n)).toString(16).padStart(2, '0'); + return `#${h(m[1])}${h(m[2])}${h(m[3])}`; +} diff --git a/src/core/theme-controls.test.ts b/src/core/theme-controls.test.ts index 7d0c4ae..104c2cf 100644 --- a/src/core/theme-controls.test.ts +++ b/src/core/theme-controls.test.ts @@ -8,10 +8,10 @@ import { enumValue, getConfigValue, normalizeRangeSchemes, - schemeColors, schemesByKind, setConfigValue, } from './theme-controls'; +import { schemeColors } from './scheme-colors'; describe('getConfigValue', () => { const config = { range: { category: ['#111', '#222'] }, mark: { color: '#333' } }; @@ -163,28 +163,6 @@ describe('leaf coercion', () => { }); }); -describe('schemeColors', () => { - it('returns the full fixed palette for a categorical scheme (count ignored)', () => { - const colors = schemeColors('tableau10', 3); - expect(colors).toHaveLength(10); - expect(colors.every((c) => /^#[0-9a-f]{6}$/i.test(c))).toBe(true); - }); - - it('samples a continuous scheme at count stops, normalized to hex', () => { - const colors = schemeColors('viridis', 5); - expect(colors).toHaveLength(5); - expect(colors.every((c) => /^#[0-9a-f]{6}$/.test(c))).toBe(true); - }); - - it('samples the midpoint for a single continuous stop', () => { - expect(schemeColors('viridis', 1)).toHaveLength(1); - }); - - it('returns [] for an unknown scheme', () => { - expect(schemeColors('not-a-scheme')).toEqual([]); - }); -}); - describe('enumValue', () => { const options = [{ value: '' }, { value: 'start' }, { value: 'end' }] as const; diff --git a/src/core/theme-controls.ts b/src/core/theme-controls.ts index 93ff420..6ff0543 100644 --- a/src/core/theme-controls.ts +++ b/src/core/theme-controls.ts @@ -1,9 +1,8 @@ /** - * Theme Builder structured-control primitives (docs/chart-theming-scope.md §5). + * Theme Builder structured-control primitives (docs/exploration/chart-theming-scope.md §5). * * Pure core: the read/write transforms the builder's structured controls run on - * a draft config, plus the named-color-scheme catalog and its resolution. Three - * concerns: + * a draft config, plus the named-color-scheme catalog. Two concerns: * * - **Path get/set** — read a value at a nested config path, and set one back * immutably while preserving every sibling key at each level. That guarantee @@ -15,16 +14,15 @@ * writes a minimal diff, never a default dump. * - **Scheme catalog** — the named Vega color schemes the color controls offer, * grouped by kind (categorical / sequential / diverging). - * - **Scheme resolution** (`schemeColors`) — a scheme name to hex swatches, for - * the picker preview and the "materialize to an editable array" action. * - * `vega-scale` (a focused vega sub-package, like core's `vega-expression`) owns - * the scheme registry; importing it here keeps the umbrella `vega` out of core. - * The one control transform that predates this module, `applyFontToConfig`, - * stays in custom-theme.ts with the record entity. + * Scheme *resolution* (name → hex swatches) lives in `scheme-colors.ts`: it + * imports the `vega-scale` registry, and this module is reachable from the + * landing/learn entry graphs (via `vega-themes`), which must stay free of + * static edges into the vega vendor chunk. The one control transform that + * predates this module, `applyFontToConfig`, stays in custom-theme.ts with the + * record entity. */ -import { scheme } from 'vega-scale'; import { isJsonObject, type JsonObject } from './spec-config'; // ── Path get/set ────────────────────────────────────────────────────────── @@ -213,33 +211,3 @@ export const THEME_SCHEMES: ReadonlyArray = [ export function schemesByKind(kind: SchemeKind): ThemeScheme[] { return THEME_SCHEMES.filter((s) => s.kind === kind); } - -// ── Scheme resolution ─────────────────────────────────────────────────────── - -/** - * Resolve a named Vega scheme to hex swatches. A categorical scheme returns its - * fixed palette in full; a continuous scheme (sequential/diverging) is sampled - * at `count` evenly-spaced stops (`count` applies to continuous schemes only). - * Continuous interpolators yield `rgb(...)`, normalized to hex here. An unknown - * name returns `[]` — the picker shows that scheme without a preview rather than - * throwing. Used for the swatch/gradient preview and "materialize to swatches". - */ -export function schemeColors(name: string, count = 9): string[] { - const resolved: unknown = scheme(name); - if (Array.isArray(resolved)) return resolved.map((c) => toHex(String(c))); - if (typeof resolved === 'function' && count > 0) { - const interp = resolved as (t: number) => string; - if (count === 1) return [toHex(interp(0.5))]; - return Array.from({ length: count }, (_, i) => toHex(interp(i / (count - 1)))); - } - return []; -} - -/** Normalize a CSS color to `#rrggbb`; passes through existing hex and unknowns. */ -function toHex(color: string): string { - if (color.startsWith('#')) return color; - const m = /rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i.exec(color); - if (!m) return color; - const h = (n: string) => Math.round(Number(n)).toString(16).padStart(2, '0'); - return `#${h(m[1])}${h(m[2])}${h(m[3])}`; -} diff --git a/vite.config.ts b/vite.config.ts index f422260..fe7b4db 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -44,9 +44,38 @@ export default defineConfig({ // Split the heavy vendors so the chunk graph stays legible and the PWA // can precache/update them independently of app code. Monaco and the // Vega stack are each multi-MB; isolating them keeps app rebuilds small. - manualChunks: { - monaco: ['monaco-editor'], - vega: ['vega', 'vega-lite', 'vega-embed'], + // + // Function form with explicit homes for every module the light and + // heavy graphs *share* — Rollup merges small shared modules into their + // biggest importer, which is how the landing's eager JS twice gained a + // static edge into a multi-MB vendor chunk: + // - Vite's dynamic-import preload helper (a virtual module used by + // every chunk that calls `import()`, monaco included) was absorbed + // into the monaco chunk, chaining the landing's lazy chart embed to + // all of Monaco. It gets its own micro-chunk. + // - vega-themes (preset config data), vega-expression (+ its dep + // vega-util), and json-stringify-pretty-compact (used by core's + // json-format) are imported both by core modules the landing/learn + // entries reach and by vega/vega-embed internals; unassigned, they + // were absorbed into the vega chunk. They get a small shared + // 'vendor-light' chunk — the heavy chunk importing the light one is + // fine, the reverse is not. vega-scale stays unassigned: after the + // scheme-colors.ts split, only app code and vega internals reach it. + // `scripts/check-light-entries.mjs` asserts the invariant (no + // vendor-monaco/vendor-vega reference in the landing/learn HTML) after + // every build. + manualChunks: (id: string) => { + if (id.includes('vite/preload-helper')) return 'preload-helper'; + if (!id.includes('node_modules')) return undefined; + if (id.includes('node_modules/monaco-editor/')) return 'vendor-monaco'; + if ( + /node_modules\/(vega-themes|vega-expression|vega-util|json-stringify-pretty-compact)\//.test( + id, + ) + ) + return 'vendor-light'; + if (/node_modules\/(vega|vega-lite|vega-embed)\//.test(id)) return 'vendor-vega'; + return undefined; }, }, },