On this page

Upgrade guide

One section per released 0.x transition, newest first. Each heading is a stable anchor that changesets and release notes link to. Pre-1.0, breaking changes ride minor releases; every deprecation or removal ships with a migration note here.

Five-minute path

  • Check that linked @ggts-sh/* packages (spec, core, compose, react, svelte, cli, skill) resolve to one compatible release.
  • Read only the adjacent transition sections needed for the installed version.
  • Apply the before/after source change backed by the migration fixtures.
  • Run strict type, build, render, and visual checks before deploying.
  • Follow a stable diagnostic anchor if blocked; roll package versions back together if needed.

The accepted lifecycle and deprecation policy remains in Lifecycle and editions; this page applies it rather than creating a second policy.

0.42 to 0.43

ggsvelte is now ggts

The seven packages move from @ggsvelte/* to @ggts-sh/*. The repository is ggts-sh/ggts and the documentation is at ggts.sh. The old npm versions remain available. PortableSpec JSON, appearance editions, builder names, and grammar semantics keep their existing contracts.

Install the packages your application imports, keeping all seven on the same release. For example, a Svelte application with the local agent feedback loop:

npm uninstall @ggsvelte/svelte @ggsvelte/core @ggsvelte/cli @ggsvelte/skill
npm install @ggts-sh/svelte @ggts-sh/core
npm install --save-dev @ggts-sh/cli @ggts-sh/skill

Use @ggts-sh/react for a React application. Core and spec can run without installing either framework. Update imports from @ggsvelte/spec, @ggsvelte/core, @ggsvelte/compose, @ggsvelte/react, and @ggsvelte/svelte to the matching @ggts-sh/* package.

Teaching datasets now live at @ggts-sh/core/data; install core directly when importing them. @ggts-sh/svelte/data remains a compatibility re-export.

Replace ggsvelte-render chart.json with ggts render chart.json:

npm exec -- ggts check chart.json
npm exec -- ggts render chart.json > chart.svg

Both commands retain JSON Lines diagnostics on stderr and the existing exit classes. check runs the rendering checks without writing SVG. The Svelte migration executable is now ggts-codemod.

Point agent instructions at node_modules/@ggts-sh/skill/SKILL.md. The skill name is now ggts. Replace any copied skill directory with the new package contents; dependency upgrades do not refresh a copy. One skill covers the shared grammar and routes to React or Svelte references.

Keep the updated lockfile, then typecheck, build, render, and verify chart interactions in a browser. The complete quickstarts and agent workflow exercise the new package names.

Earlier sections describe the pre-rename releases; old names in those notes refer to the packages published at that time.

0.38 to 0.39

Explicit Temporal registration for spec-driven charts

GGPlot no longer installs @js-temporal/polyfill when its module loads. The numeric scatter fixture drops 59.5 KB gzip as a result.

Temporal scale children such as <ScaleXDate>, <ScaleYDatetime>, and <ScaleColorDate> install full Temporal parsing and guide planning, so component-composed temporal charts need no source change. ISO strings with no explicit temporal options keep the lean UTC inference path.

Charts driven through spec or layers have no temporal child to install the runtime. Call installTemporal() once before mounting when those charts use an explicit temporal parser, timezone, date interval, or full Temporal guide planning. registerAll() includes the same install.

<script lang="ts">
  import { GGPlot, installTemporal } from "@ggsvelte/svelte";

  installTemporal();

  const temporalSpec = {
    data: {
      values: [
        { when: "2026-01-01", value: 10 },
        { when: "2026-02-01", value: 20 },
        { when: "2026-03-01", value: 15 },
      ],
    },
    layers: [{ geom: "point" as const, aes: { x: "when", y: "value" } }],
    scales: {
      x: {
        type: "time" as const,
        temporalKind: "date" as const,
      },
    },
  };
</script>

<GGPlot spec={temporalSpec} width={480} height={320} />

0.28 to 0.29

Removed Tableau 10, Summer, Winter, and stone schemes

Six categorical scheme names (and matching public *_PALETTE constants) are gone:

  • tableau10
  • tableau_summer, tableau_winter
  • tableau_miller_stone, tableau_superfishel_stone, tableau_nuriel_stone

Prefer observable10, colorblind, Dark2, pander, or another remaining Tableau scheme (tableau20, tableau_colorblind, tableau_jewel_bright, …), or pass an explicit range.

// Before
{
  "scales": { "color": { "type": "ordinal", "scheme": "tableau10" } }
}
// After
{
  "scales": { "color": { "type": "ordinal", "scheme": "observable10" } }
}

0.27 to 0.28

Removed spreadsheet, Highcharts, and extra Stata schemes and themes

Nine categorical scheme names (and the matching public *_PALETTE constants from @ggsvelte/core) are gone:

  • stata_s1color, stata_s1rcolor, stata_mono
  • hc, hc_dark
  • calc, excel, excel_fill, excel_new

A PortableSpec that still names one of those schemes fails validation. Switch to a remaining scheme — stata, observable10, Dark2, and pander are the usual replacements — or pass an explicit range of hex color stops.

Four chart themes are also gone: stata_mono, calc, excel, and excel_new (and their Svelte shells ThemeStatamono, ThemeCalc, ThemeExcel, ThemeExcelnew). Prefer stata, stata_s1color, bw, classic, or minimal.

// Before: scheme / theme names that no longer validate
{
  "theme": "excel_new",
  "scales": { "color": { "type": "ordinal", "scheme": "excel_new" } }
}
// After: pick remaining theme + scheme (or an explicit color range)
{
  "theme": "minimal",
  "scales": { "color": { "type": "ordinal", "scheme": "observable10" } }
}

Removed Accent, Paired, Grey, Google Docs, and Tableau multi-hue schemes

Eight more categorical scheme names (and matching public *_PALETTE constants where they existed) are gone:

  • Accent, Paired
  • grey, gray
  • gdocs
  • tableau_green_orange_teal, tableau_red_blue_brown, tableau_purple_pink_gray

Theme gdocs (and Svelte shell ThemeGdocs) is also gone. Prefer minimal, classic, or bw.

scaleColorGrey() / <ScaleColorGrey /> still work: they bake an explicit greyscale range (optional start/end). They no longer emit scheme: "grey".

// Before
{
  "theme": "gdocs",
  "scales": { "color": { "type": "ordinal", "scheme": "Accent" } }
}
// After
{
  "theme": "minimal",
  "scales": { "color": { "type": "ordinal", "scheme": "Dark2" } }
}

0.26 to 0.27

Explicit registration for spec-driven charts

Apps that declare layers with components (<GeomPoint>, <GeomSmooth>, …) need no change: each component now registers its own geom batch and default stat on import, and GGPlot bundles only the geoms and stats a chart declares. Identity charts (point, line, path, col, bar, area, rule, hline, vline, text, label, rect, ribbon, segment, count, blank, jitter, step) work out of the box as before.

Apps that drive GGPlot with a layers prop or a spec, or call runPipeline / renderToSVGString directly, must register specialty geoms/stats (smooth, boxplot, violin, hex, contour, density_2d, sf, qq, …) explicitly. A missing registration fails loudly: Geom "smooth" is not registered in this build. Call registerAll() ….

<script lang="ts">
  import { GGPlot } from "@ggsvelte/svelte";

  const rows = [
    { x: 1, y: 10 },
    { x: 2, y: 20 },
    { x: 3, y: 15 },
    { x: 4, y: 25 },
  ];
</script>

<!-- Before 0.27: any geom worked from the layers prop — the core barrel
     bundled and registered the full grammar for every app. -->
<GGPlot
  data={rows}
  aes={{ x: "x", y: "y" }}
  layers={[{ geom: "smooth" }]}
  width={480}
  height={320}
/>
<script lang="ts">
  import { GGPlot, registerAll } from "@ggsvelte/svelte";

  // After 0.27: spec-driven charts (layers prop / spec, no <Geom*> components
  // to self-register) opt into the full grammar once at app startup. Component
  // children need no call — they self-register on import.
  registerAll();

  const rows = [
    { x: 1, y: 10 },
    { x: 2, y: 20 },
    { x: 3, y: 15 },
    { x: 4, y: 25 },
  ];
</script>

<GGPlot
  data={rows}
  aes={{ x: "x", y: "y" }}
  layers={[{ geom: "smooth" }]}
  width={480}
  height={320}
/>
  • Prefer one registerAll() at app startup for the pre-0.27 "full grammar" behavior — grammar + Temporal + interaction candidates (also re-exported from @ggsvelte/svelte).
  • Prefer a per-family call (registerSmooth(), registerBoxplot(), …, from @ggsvelte/core) for granular opt-in without the full grammar.
  • Overriding stat to a specialty stat (e.g. <GeomPoint stat="density_2d" />) needs that stat's register call too — the component registers only its default stat.
  • Direct @ggsvelte/core barrel importers: the barrel is side-effect-free now — registerAll() restores pre-0.27 import-time registration. The lean @ggsvelte/core/render entry is unchanged (basic registration on import).

Skill moved to @ggsvelte/skill

The agent skill (SKILL.md + references/) no longer ships inside @ggsvelte/svelte. It is its own package, @ggsvelte/skill, versioned in lock-step with the rest of ggsvelte — the package root IS the skill directory:

npm install --save-dev @ggsvelte/skill
# then copy or symlink it into your agent's skills directory:
cp -R node_modules/@ggsvelte/skill .claude/skills/ggsvelte

Or point agents at the stable path node_modules/@ggsvelte/skill/SKILL.md directly. Re-copy on every version bump; dependabot (or npm-check-updates) surfaces those bumps now. Any checkout that previously read node_modules/@ggsvelte/svelte/skills/ggsvelte/ must switch — that directory is gone.

0.22 to 0.23

CLI moved to @ggsvelte/cli

The ggsvelte-render bin no longer ships with @ggsvelte/svelte. It is its own package, @ggsvelte/cli, with no Svelte dependency — install it wherever the command runs (agent sandboxes above all):

npm install -g @ggsvelte/cli
# or add @ggsvelte/cli as a dependency of the project that invokes it

The command name, flags, exit codes, and JSONL diagnostics are unchanged. ggsvelte-codemod still ships with @ggsvelte/svelte. If a sandbox image or CI step ran ggsvelte-render via the Svelte package's bin, add @ggsvelte/cli there before upgrading @ggsvelte/svelte.

0.20 to 0.21

Row identity on interaction

Durable row identity no longer belongs on the grammar root. Ordinary charts omit identity entirely: the engine uses an id column when present, otherwise the row index (order-stable only — not reorder-safe across data refresh).

Custom durable identity (non-id natural keys, composite accessors, pin rebind across reorder) lives on interaction surfaces:

<script lang="ts">
  import { GeomPoint, GGPlot, Inspect } from "@ggsvelte/svelte";

  const rows = [
    { country: "Japan", year: 812, temp: 6.1 },
    { country: "Korea", year: 900, temp: 5.8 },
  ];
</script>

<!-- Before 0.21: plot-level key. -->
<GGPlot data={rows} aes={{ x: "year", y: "temp" }} key="country">
  <GeomPoint />
  <Inspect />
</GGPlot>
<script lang="ts">
  import { GeomPoint, GGPlot, Inspect } from "@ggsvelte/svelte";

  const rows = [
    { country: "Japan", year: 812, temp: 6.1 },
    { country: "Korea", year: 900, temp: 5.8 },
  ];
</script>

<!-- After 0.21: identity on Inspect (or select / createPlotInteraction). -->
<GGPlot data={rows} aes={{ x: "year", y: "temp" }}>
  <GeomPoint />
  <Inspect identity="country" />
</GGPlot>
  • Prefer <Inspect identity="…" /> or inspect={{ identity: "…" }} when inspect is enabled.
  • Prefer select={{ type: "point", identity: "…" }} (or interval) when selection owns the key without inspect.
  • Prefer createPlotInteraction({ identity: "…" }) when linked plots share one controller and the same identity field.
  • Resolution order: Inspect → Select → controller → deprecated key → auto id → row index.
  • <GGPlot key> still dual-reads through 0.21.x and emits DEPRECATED_PLOT_PROP; it is removed in 0.22.0.

0.18 to 0.19

Legend focus on GuideLegend

Discrete legend focus is no longer a plot-host capability prop. Opt in on the guide child that owns the aesthetic:

<script lang="ts">
  import { GeomPoint, GGPlot } from "@ggsvelte/svelte";

  const rows = [
    { id: "a", x: 1, y: 2, series: "North" },
    { id: "b", x: 2, y: 3, series: "South" },
  ];
</script>

<!-- Before 0.19: legendFocus was a plot-host prop. -->
<GGPlot
  data={rows}
  aes={{ x: "x", y: "y", color: "series" }}
  key="id"
  legendFocus
>
  <GeomPoint />
</GGPlot>
<script lang="ts">
  import { GeomPoint, GGPlot, GuideLegend } from "@ggsvelte/svelte";

  const rows = [
    { id: "a", x: 1, y: 2, series: "North" },
    { id: "b", x: 2, y: 3, series: "South" },
  ];
</script>

<!-- After 0.19: focus lives on GuideLegend for that aesthetic. -->
<GGPlot data={rows} aes={{ x: "x", y: "y", color: "series" }} key="id">
  <GeomPoint />
  <GuideLegend channel="color" focus />
</GGPlot>
  • focus accepts true or { preview?: boolean } (same shape as the old plot prop). It is host-only — not a PortableSpec / guideLegend() field.
  • Only channels with an active <GuideLegend focus> get interactive legend targets. Enable multiple aesthetics with multiple GuideLegend children.
  • A focus-only GuideLegend (no presentation options) does not force type: "legend", so continuous colour scales keep their colorbar.
  • <GGPlot legendFocus> still works plot-wide through 0.19.x and emits DEPRECATED_PLOT_PROP; it is removed in 0.20.0.
  • Handlers stay plot-level: onlegendfocus, oninteraction, and key.

Legend filter on GuideLegend

Discrete legend filter is no longer a plot-host capability prop. Opt in on the guide child that owns the aesthetic:

<script lang="ts">
  import { GeomPoint, GGPlot } from "@ggsvelte/svelte";

  const rows = [
    { id: "a", x: 1, y: 2, series: "North" },
    { id: "b", x: 2, y: 3, series: "South" },
  ];
</script>

<!-- Before 0.19: legendFilter was a plot-host prop. -->
<GGPlot
  data={rows}
  aes={{ x: "x", y: "y", color: "series" }}
  key="id"
  legendFilter
>
  <GeomPoint />
</GGPlot>
<script lang="ts">
  import { GeomPoint, GGPlot, GuideLegend } from "@ggsvelte/svelte";

  const rows = [
    { id: "a", x: 1, y: 2, series: "North" },
    { id: "b", x: 2, y: 3, series: "South" },
  ];
</script>

<!-- After 0.19: filter lives on GuideLegend for that aesthetic. -->
<GGPlot data={rows} aes={{ x: "x", y: "y", color: "series" }} key="id">
  <GeomPoint />
  <GuideLegend channel="color" filter />
</GGPlot>
  • filter accepts true or { mode?, multiple? } (same shape as the old plot prop). It is host-only — not a PortableSpec / guideLegend() field.
  • Only channels with an active <GuideLegend filter> get filter checkboxes. Enable multiple aesthetics with multiple GuideLegend children.
  • A filter-only GuideLegend (no presentation options) does not force type: "legend", so continuous colour scales keep their colorbar.
  • <GGPlot legendFilter> still works plot-wide through 0.19.x and emits DEPRECATED_PLOT_PROP; it is removed in 0.20.0.
  • Handlers stay plot-level: onlegendfilter, oninteraction, and key.
  • Focus and filter coexist on one GuideLegend: <GuideLegend channel="color" focus filter />.

0.11 to 0.12

Manual color domain/range diagnostic code

Validation used to emit the code scale-manual-domain-range when a manual color/fill scale had mismatched domain and range lengths. It now emits color-manual-domain-range — the same string the pipeline already used — so agents and the error-reference page have one name for that fault.

If your tooling matches SpecError.code or docs anchors by string, update:

  • code: scale-manual-domain-range → color-manual-domain-range
  • docs anchor: #scale-manual-domain-range → #color-manual-domain-range (pipeline entry is now #color-manual-domain-range-pipeline when both sources appear on the page)

Pipeline-only and validation-only catalogs remain separate objects, but dual codes share one prose source in @ggsvelte/spec. PIPELINE_ERROR_CATALOG is also exported from @ggsvelte/spec (and still re-exported from @ggsvelte/core).

0.12 to 0.13

Grammar props removed from <GGPlot>

The seven grammar props deprecated in 0.11.0 — theme, scales, coord, facet, labs, guides, and legend — are removed from <GGPlot> in 0.13.0. Compose them only as declaration-only children. The ggsvelte-codemod still rewrites old source that uses the prop form.

LayerDescriptor is removed; use MarkLayerDescriptor.

normalize() returns the post-normalize geom union

normalize() rewrites five convenience geoms to a canonical name — histogram to bar, freqpoly to line, jitter to point, hline and vline to rule. Its declared return type used to name all 49 geoms anyway, so nothing could tell which 44 actually reach the pipeline.

It now returns NormalizedSpec, whose layers are NormalizedLayerSpec — the same shape, minus the five names normalize has already removed. Alongside it, @ggsvelte/spec exports ALIAS_GEOMS, GEOM_ALIASES, AliasGeomName and NormalizedGeomName.

Authored specs are unaffected: PortableSpec and the published JSON Schema still accept every one of the 49 names, and geom: "histogram" works exactly as before.

One kind of caller changes. Code that reads geoms back off a normalized spec and expects all 49 now sees 44:

// Before — the "histogram" arm was reachable in the type, never at runtime.
const spec: PortableSpec = normalize(input);
for (const layer of spec.layers) {
  if (layer.geom === "histogram") { /* dead branch */ }
}

// After — narrow before normalize, or drop the branch.
const spec = normalize(input); // NormalizedSpec
for (const layer of spec.layers) {
  if (layer.geom === "bar") { /* what histogram became */ }
}

Annotating the result as PortableSpec still compiles, so passing a normalized spec on to anything that takes one needs no change.

0.10 to 0.11

Compose the theme as a child layer

The theme prop on <GGPlot> was deprecated in 0.11.0 and removed in 0.13.0. Compose the theme as a declaration-only child — named shells for every built-in theme, or the generic <Theme> escape hatch for dynamic names and role overrides.

Before:

<script lang="ts">
  import { GeomPoint, GGPlot } from "@ggsvelte/svelte";

  // Historical pre-0.13 GGPlot grammar prop (removed). Cast for typecheck.
  /* oxlint-disable-next-line typescript/no-explicit-any -- intentional pre-removal fixture */
  const Plot = GGPlot as any;

  const rows = [
    { x: 1, y: 2 },
    { x: 2, y: 4 },
  ];
</script>

<!-- Before 0.11: theme was a top-level GGPlot prop. -->
<Plot data={rows} aes={{ x: "x", y: "y" }} theme="dark">
  <GeomPoint />
</Plot>

After:

<script lang="ts">
  import { GeomPoint, GGPlot, ThemeDark } from "@ggsvelte/svelte";

  const rows = [
    { x: 1, y: 2 },
    { x: 2, y: 4 },
  ];
</script>

<!-- After 0.11: compose the theme as a declaration-only child layer. -->
<GGPlot data={rows} aes={{ x: "x", y: "y" }}>
  <GeomPoint />
  <ThemeDark />
</GGPlot>

LayerDescriptor was renamed to MarkLayerDescriptor in 0.11.0 and the alias was removed in 0.13.0.

Compose scales as child layers

The scales prop on <GGPlot> was deprecated in 0.11.0 and removed in 0.13.0. Compose scales as declaration-only children — named shells for every color/fill helper (<ScaleColorDiscrete/>, <ScaleFillManual/>, British Colour aliases, …), or the generic <Scale value={…}> escape hatch for raw fragments and computed scales. Two children on one channel emit a DUPLICATE_SCALE_CHANNEL advisory (last child still wins).

Named shells route through the matching helpers, so migrating a raw fragment like scales={{color:{scheme:"colorblind"}}} to <ScaleColorDiscrete scheme="colorblind"/> adds type:"ordinal" to the PortableSpec (rendering is unchanged). Use <Scale value={…}> when you need byte-identical PortableSpec.

Before:

<script lang="ts">
  import {
    GeomPoint,
    GGPlot,
    scaleColorDiscrete,
  } from "@ggsvelte/svelte";

  // Historical pre-0.13 GGPlot grammar prop (removed). Cast for typecheck.
  /* oxlint-disable-next-line typescript/no-explicit-any -- intentional pre-removal fixture */
  const Plot = GGPlot as any;

  const rows = [
    { x: 1, y: 2, c: "a" },
    { x: 2, y: 4, c: "b" },
  ];
</script>

<!-- Before 0.11: scales was a top-level GGPlot prop. -->
<Plot
  data={rows}
  aes={{ x: "x", y: "y", color: "c" }}
  scales={scaleColorDiscrete({ scheme: "colorblind" })}
>
  <GeomPoint />
</Plot>

After:

<script lang="ts">
  import {
    GeomPoint,
    GGPlot,
    ScaleColorDiscrete,
  } from "@ggsvelte/svelte";

  const rows = [
    { x: 1, y: 2, c: "a" },
    { x: 2, y: 4, c: "b" },
  ];
</script>

<!-- After 0.11: compose scales as declaration-only child layers. -->
<GGPlot data={rows} aes={{ x: "x", y: "y", color: "c" }}>
  <GeomPoint />
  <ScaleColorDiscrete scheme="colorblind" />
</GGPlot>

PlotDiagnostic also widens to include CompositionDiagnostic (DUPLICATE_SCALE_CHANNEL, DUPLICATE_PLOT_LAYER). Exhaustive switch on .code needs new arms; handlers annotated PlotDiagnostic keep compiling.

Compose coord as a child layer

The coord prop on <GGPlot> was deprecated in 0.11.0 and removed in 0.13.0. Compose the coordinate system as a declaration-only child — <CoordFlip/>, <CoordFixed/> / <CoordEqual/>, <CoordTransform/>, <CoordCartesian/>, or the generic <Coord value={…}> escape hatch. Two coord children emit a DUPLICATE_PLOT_LAYER advisory (last child still wins).

Compose facet as a child layer

The facet prop on <GGPlot> was deprecated in 0.11.0 and removed in 0.13.0. Compose facets as declaration-only children — <FacetWrap field="g"/>, <FacetGrid rows="a" cols="b"/>, or the complete <Facet wrap={…} /> surface. Keep strip nested (strip={{position,show}}). Two facet children emit a DUPLICATE_PLOT_LAYER advisory (last child still wins). Bare <Facet/> with no wrap/rows/cols fails validation (facet-form-missing).

Compose labs as a child layer

The labs prop on <GGPlot> was deprecated in 0.11.0 and removed in 0.13.0. Compose labels as a declaration-only child — <Labs title="Sales" subtitle="FY25" x="Quarter" color="Region"/>. There is no <Labs value={…}> escape hatch because Labs is a flat bag of strings: <Labs {...computed} /> already covers the computed case.

labs is a MERGE family: two <Labs/> children setting different keys both survive. Two children setting the SAME key emit a DUPLICATE_MERGE_KEY advisory and the later one wins.

Compose guides as child layers

The guides prop on <GGPlot> was deprecated in 0.11.0 and removed in 0.13.0. Guides are keyed by aesthetic, so the child form is one shell per guide TYPE taking a channel prop — the aesthetic is a key, never part of the component name: <GuideAxis channel="x" showTicks={false}/>, <GuideLegend channel="color" position="bottom"/>, <GuideColorbar channel="fill"/>, <GuideColorsteps channel="color"/>, <GuideNone channel="size"/>, plus <Guides value={…}> for raw or computed guide bags.

guides is a MERGE family keyed by channel, but the value AT a channel is replaced whole. Two guide children on one channel emit a DUPLICATE_MERGE_KEY advisory (last child still wins). A top-level guide child still wins over a scale-local guide on the same channel.

The shells carry no scale knowledge and do not guess: <GuideColorbar/> over a discrete color scale fails loudly rather than silently degrading to a legend.

Compose legend as a child layer

The legend prop on <GGPlot> was deprecated in 0.11.0 and removed in 0.13.0. Compose it as <Legend order="sorted"/>.

<Legend order> is the plot-wide entry-SORT enum ("stable-domain" | "present-first-seen" | "sorted"); ordering never changes color assignments. It is NOT <GuideLegend order={2}/>, which is a per-aesthetic INTEGER placement rank. Same word, unrelated concepts — the two compose independently on one plot.

Migrate the grammar props with the codemod

All seven grammar props above move mechanically, so @ggsvelte/svelte ships a codemod. It is opt-in and prints a diff by default — it writes nothing until you pass --write:

# see what would change
npx ggsvelte-codemod src

# apply it
npx ggsvelte-codemod --write src

It rewrites facet, coord, scales, guides, legend, theme and labs into their child layers and adds the components to the @ggsvelte/svelte import that already provided GGPlot. Migrated children are inserted BEFORE any child the file already had, because props apply before children — so a hand-written <ScaleColorDiscrete/> keeps winning over a migrated scales prop exactly as it did before.

The rewrite is meaning-preserving, never a style rewrite. It targets the generic escape hatches (<Coord value={…}/>, <Scale value={…}/>, <Guides value={…}/>) rather than the named shells this guide recommends by hand: for scales the named form is not byte-identical (normalize() does not infer a scale type), so choosing it is a judgment call the tool does not make for you. Flat prop bags become named props — labs={{ title: "Sales" }} → <Labs title="Sales"/> — falling back to <Labs {...expr}/> for anything it cannot expand losslessly.

One shape is deliberately left alone: theme={expr} where expr is not a string literal. theme accepts ThemeName | ThemeSpec and <Theme> has no value hatch, so routing a dynamic value needs a human. Those sites are printed as manual change required with a link back to this guide — the tool reports them rather than half-migrating them.

Two more things worth knowing: the codemod only touches files that import GGPlot from @ggsvelte/svelte (a GGPlot of your own is never rewritten), and it edits only the ranges it changes, so run your formatter afterwards if you keep multi-line open tags.

Diagnostic handlers receive PlotDiagnostic

ondiagnostic now receives the PlotDiagnostic union (InteractionDiagnostic | DeprecationDiagnostic). Explicitly annotated handlers that named InteractionDiagnostic alone need a one-line type widening; inline arrow props continue to infer.

Before:

<script lang="ts">
  import { GeomPoint, GGPlot } from "@ggsvelte/svelte";
  import type {
    InteractionDiagnostic,
    PlotDiagnostic,
  } from "@ggsvelte/svelte";

  const rows = [
    { x: 1, y: 2 },
    { x: 2, y: 4 },
  ];

  function legacy(diagnostic: InteractionDiagnostic): void {
    console.warn(diagnostic.code, diagnostic.message);
  }

  // @ts-expect-error Pre-0.11 InteractionDiagnostic-only handlers are not assignable to PlotDiagnostic.
  const ondiagnostic: (diagnostic: PlotDiagnostic) => void = legacy;
</script>

<!-- Before 0.11: ondiagnostic was typed as InteractionDiagnostic only. -->
<GGPlot data={rows} aes={{ x: "x", y: "y" }} {ondiagnostic}>
  <GeomPoint />
</GGPlot>

After:

<script lang="ts">
  import { GeomPoint, GGPlot } from "@ggsvelte/svelte";
  import type { PlotDiagnostic } from "@ggsvelte/svelte";

  const rows = [
    { x: 1, y: 2 },
    { x: 2, y: 4 },
  ];

  function ondiagnostic(diagnostic: PlotDiagnostic): void {
    console.warn(diagnostic.code, diagnostic.message);
  }
</script>

<!-- After 0.11: ondiagnostic receives PlotDiagnostic (interaction ∪ deprecation). -->
<GGPlot data={rows} aes={{ x: "x", y: "y" }} {ondiagnostic}>
  <GeomPoint />
</GGPlot>

0.7 to 0.8

Map style semantics instead of precomputing outputs

Mapped size, linewidth, and alpha now train and render complete scales. shape and linetype now use closed finite symbol sets. Remove application-side radius, opacity, stroke-width, and dash lookup columns when they only existed to compensate for ignored style mappings. Map the semantic source field and select a scale family instead.

Before 0.8, applications commonly precomputed a point radius and passed it through identity:

<script lang="ts">
  import { GeomPoint, GGPlot } from "@ggsvelte/svelte";

  // Historical pre-0.13 GGPlot grammar prop (removed). Cast for typecheck.
  /* oxlint-disable-next-line typescript/no-explicit-any -- intentional pre-removal fixture */
  const Plot = GGPlot as any;

  // Before 0.8, applications precomputed symbol radii.
  const rows = [
    { x: 1, y: 2, radius: 2 },
    { x: 2, y: 3, radius: 5 },
    { x: 3, y: 4, radius: 9 },
  ];
</script>

<Plot
  data={rows}
  aes={{ x: "x", y: "y", size: "radius" }}
  scales={{ size: { type: "identity" } }}
>
  <GeomPoint />
</Plot>

In 0.8, keep the source measure and let the scale interpolate in symbol area:

<script lang="ts">
  import {
    GeomPoint,
    GGPlot,
    ScaleSizeContinuous,
  } from "@ggsvelte/svelte";

  // In 0.8, map the semantic measure and let size interpolate in symbol area.
  const rows = [
    { x: 1, y: 2, magnitude: 4 },
    { x: 2, y: 3, magnitude: 25 },
    { x: 3, y: 4, magnitude: 81 },
  ];
</script>

<GGPlot data={rows} aes={{ x: "x", y: "y", size: "magnitude" }}>
  <GeomPoint />
  <ScaleSizeContinuous range={[2, 9]} />
</GGPlot>

Review implicit grouping on line, area, smooth, errorbar, and boxplot layers. Discrete and binned style mappings now split groups, as color/fill mappings do; continuous numeric styles do not. If a discrete style is descriptive rather than structural, author an explicit group mapping. Shape/linetype do not silently interpolate quantitative values: use type: "binned" or move the measure to a numeric style channel.

A mapped alpha is now the complete authored opacity aesthetic; it is not multiplied by a competing scalar geom alpha parameter. Remove that scalar parameter and set the mapped scale's range when you need a lower opacity ceiling.

Move guide layout into the guide API

Automatic non-position guides now move below the chart when the viewport is at most 480px or a right guide would leave less than 320px of readable panel. Bottom colorbars/colorsteps are horizontal and discrete keys wrap without shrinking text. If an application positioned or hid the old fixed right legend with surrounding CSS, remove that workaround and author portable guide intent:

<script lang="ts">
  import { GGPlot, GeomPoint } from "@ggsvelte/svelte";

  // Before 0.8, automatic legends always occupied the fixed right column.
  const rows = [
    { x: 1, y: 2, region: "North" },
    { x: 2, y: 3, region: "South" },
  ];
</script>

<GGPlot data={rows} aes={{ x: "x", y: "y", color: "region" }}>
  <GeomPoint />
</GGPlot>

In 0.8, declare the alternate presentation directly:

<script lang="ts">
  import { GGPlot, GeomPoint, GuideLegend } from "@ggsvelte/svelte";

  // Since 0.8, guide presentation is portable and responsive without changing scale math.
  const rows = [
    { x: 1, y: 2, region: "North" },
    { x: 2, y: 3, region: "South" },
  ];
</script>

<GGPlot data={rows} aes={{ x: "x", y: "y", color: "region" }}>
  <GeomPoint />
  <GuideLegend channel="color" position="bottom" direction="horizontal" />
</GGPlot>

Top-level guides override scale-local guide settings. Use guideNone() for suppression and force: true only when an identity or single-value manual guide is intentional. Guide appearance does not alter scale domains or assignments. Exact discrete entries remain focus/filter targets; numeric guide ticks and bins remain representative and non-interactive.

0.8 to 0.9

Constrain the data rectangle instead of the outer box

Before 0.9, CSS aspect-ratio on a chart wrapper constrained the complete SVG. Axes, titles, and guides still changed the inner panel ratio, so equal data units could render at unequal physical lengths:

<script lang="ts">
  import { GGPlot, GeomLine } from "@ggsvelte/svelte";

  // Before 0.9, an outer CSS aspect ratio could not preserve data-unit lengths
  // after axes, titles, and guides consumed chart space.
  const circle = [
    { x: 1, y: 0 },
    { x: 0, y: 1 },
    { x: -1, y: 0 },
    { x: 0, y: -1 },
    { x: 1, y: 0 },
  ];
</script>

<div style="aspect-ratio: 1">
  <GGPlot data={circle} aes={{ x: "x", y: "y" }}>
    <GeomLine />
  </GGPlot>
</div>

Since 0.9, remove that wrapper workaround and author the coordinate directly:

<script lang="ts">
  import { CoordFixed, GGPlot, GeomLine } from "@ggsvelte/svelte";

  // Since 0.9, constrain the measured data rectangle instead of the outer box.
  const circle = [
    { x: 1, y: 0 },
    { x: 0, y: 1 },
    { x: -1, y: 0 },
    { x: 0, y: -1 },
    { x: 1, y: 0 },
  ];
</script>

<GGPlot data={circle} aes={{ x: "x", y: "y" }}>
  <GeomLine />
  <CoordFixed />
</GGPlot>

coordFixed({ ratio: 1 }) measures the trained data rectangle after chart chrome is allocated and letterboxes it without distortion. Fixed aspect now rejects facet.scales values "free", "free_x", and "free_y"; switch those facets to "fixed" or remove the fixed coordinate rather than presenting a false shared physical scale.

0.6 to 0.7

Choose explicit color/fill families

Color/fill now exposes complete continuous, discrete, binned, transformed, temporal, manual, and identity helpers. Existing ordinal and sequential JSON remains canonical. Review charts that relied on implicit continuous clamping: with an explicit domain, the default oob: "censor" now uses unknownValue; opt into oob: "squish" to clamp deliberately.

Use type: "binned" plus semantic breaks for colorsteps. Manual scales require one range color per explicit domain value and never recycle extras. Identity scales accept validated hex source values and suppress their guide by default. Replace ad-hoc preprocessing with scaleColorDate/ scaleColorDatetime and an explicit parser when date order is ambiguous.

Before 0.7, an explicit continuous color domain clamped implicitly:

<script lang="ts">
  import { GeomPoint, GGPlot } from "@ggsvelte/svelte";

  // Historical pre-0.13 GGPlot grammar prop (removed). Cast for typecheck.
  /* oxlint-disable-next-line typescript/no-explicit-any -- intentional pre-removal fixture */
  const Plot = GGPlot as any;

  const rows = [
    { x: 1, y: 2, score: -10 },
    { x: 2, y: 3, score: 50 },
    { x: 3, y: 4, score: 110 },
  ];
</script>

<Plot
  data={rows}
  aes={{ x: "x", y: "y", color: "score" }}
  scales={{ color: { type: "sequential", domain: [0, 100] } }}
>
  <GeomPoint />
</Plot>

In 0.7, opt into clamping when it is the intended encoding:

<script lang="ts">
  import {
    GeomPoint,
    GGPlot,
    ScaleColorContinuous,
  } from "@ggsvelte/svelte";

  const rows = [
    { x: 1, y: 2, score: -10 },
    { x: 2, y: 3, score: 50 },
    { x: 3, y: 4, score: 110 },
  ];
</script>

<GGPlot data={rows} aes={{ x: "x", y: "y", color: "score" }}>
  <GeomPoint />
  <ScaleColorContinuous domain={[0, 100]} oob="squish" />
</GGPlot>

RenderModel.guidePlans now includes serializable discrete, colorbar, and colorsteps plans beside axes. Code that assumed every plan was an axis must narrow on plan.type === "axis" before reading axis-only fields.

0.5 to 0.6

Move position transforms before statistics

Position transforms now follow ggplot2 staging: parsing, source-limit OOB, and the scale transform happen before statistics and positions. The old late projection produced incorrect smooths, bins, densities, summaries, and boxplots. This is the pre-1.0 semantic-correctness exception in decision 0015; there is no legacy staging switch.

Authored type: "log" still validates, but canonical specs now store type: "linear", transform: "log10". Prefer the explicit transform or scaleXLog10/scaleYLog10 helpers. A codemod would only rewrite spelling and cannot decide whether changed statistics are intended, so migration remains a manual chart review.

Before 0.6, this fit used the old late log projection:

<script lang="ts">
  import { GeomPoint, GeomSmooth, GGPlot } from "@ggsvelte/svelte";

  // Historical pre-0.13 GGPlot grammar prop (removed). Cast for typecheck.
  /* oxlint-disable-next-line typescript/no-explicit-any -- intentional pre-removal fixture */
  const Plot = GGPlot as any;

  const rows = [
    { latency: 1, throughput: 8 },
    { latency: 10, throughput: 18 },
    { latency: 100, throughput: 31 },
    { latency: 1000, throughput: 47 },
  ];
</script>

<Plot
  data={rows}
  aes={{ x: "latency", y: "throughput" }}
  scales={{ x: { type: "log", domain: [1, 1000] } }}
>
  <GeomPoint />
  <GeomSmooth method="lm" />
</Plot>

In 0.6, make the pre-stat transform and limit policy explicit, then compare the fit with the intended analysis. The zero expansion below restores flush bounds; the new default for non-temporal continuous and binned scales is 5% multiplicative display expansion, including pinned domains.

<script lang="ts">
  import {
    GeomPoint,
    GeomSmooth,
    GGPlot,
    ScaleXLog10,
  } from "@ggsvelte/svelte";

  const rows = [
    { latency: 1, throughput: 8 },
    { latency: 10, throughput: 18 },
    { latency: 100, throughput: 31 },
    { latency: 1000, throughput: 47 },
  ];
</script>

<GGPlot data={rows} aes={{ x: "latency", y: "throughput" }}>
  <GeomPoint />
  <GeomSmooth method="lm" />
  <ScaleXLog10
    domain={[1, 1000]}
    oob="censor"
    expand={{ mult: 0, add: 0 }}
    nice={false}
  />
</GGPlot>

Review limits, zoom, and transformed units

A pinned domain is now an unexpanded source limit. The default oob: "censor" removes out-of-limit values before stats; oob: "squish" clamps them before transform/stats. Brush zoom writes a semantic domain with nice: false and zero expansion, so it intentionally re-runs stats on the zoomed subset rather than acting like a post-stat coordinate crop. Use a wider scale domain or squish only when that is the intended analysis; a future coordinate-transform API owns visual-only zoom.

Position offsets and stack totals are transformed-space units. Under log10 or sqrt, numeric stat_bin binwidth, boundary, and center are also transformed-space units: for example log10 boundary: 0 means semantic 1, not log10(0).

Update scale and interaction inspection

Continuous log10/sqrt scales no longer report trained type: "log". RenderModel.scales, AxisGuidePlan, interval selections, and precise bounds use family-plus-transform contracts:

scale type / guide scaleType / interval kind: "linear"
transform: "identity" | "log10" | "sqrt"

Reject or migrate transient snapshots containing kind: "log"; pre-1.0 interaction snapshots do not have a compatibility branch. Keep semantic source-space domains and apply the named transform exactly once.

0.2 to 0.3

Replace custom hit indexes with CandidateStore

The experimental buildHitIndex export and its SceneHitIndex types have been removed. Every render model already owns a lazy CandidateStore with the same exact geometry hit behavior, so custom browser hosts no longer build and retain a second spatial index.

Before 0.3:

import { buildHitIndex } from "@ggsvelte/core/dom";

const hitIndex = buildHitIndex(model.scene);
const hit = hitIndex.hitTest(plotX, plotY);

In 0.3, use the model-owned candidate identity directly. hitTest() follows paint order, honors panel clipping, and returns CandidateFacts. Rectangle queries remain available as model.candidates.queryRect(...) candidate ids.

<script lang="ts">
  import {
    GeomPoint,
    GGPlot,
    Inspect,
    type RenderModel,
  } from "@ggsvelte/svelte";

  const rows = [
    { id: "a", x: 1, y: 3 },
    { id: "b", x: 2, y: 4 },
  ];
  let model = $state<RenderModel | null>(null);
  let hitRow = $state<number | null>(null);

  function inspectPlotPixel(x: number, y: number): void {
    hitRow = model?.candidates.hitTest(x, y)?.rowIndex ?? null;
  }
</script>

<GGPlot
  data={rows}
  aes={{ x: "x", y: "y" }}
  key="id"
  onrender={(next) => (model = next)}
>
  <GeomPoint />
  <Inspect />
</GGPlot>

<button type="button" onclick={() => inspectPlotPixel(100, 100)}>
  Resolve plot pixel
</button>
<p>{hitRow === null ? "No hit" : `Row ${hitRow}`}</p>

For a separately constructed scene, call buildCandidateStore(scene, { hitTolerance }) from @ggsvelte/core. The old tolerance default remains 3 plot pixels.

0.1 to 0.2

No source changes are required: every 0.1 prop, callback, and export keeps working in 0.2. One environment requirement changed: the svelte peer dependency floor rose from ^5.29.0 to ^5.33.1, so upgrade Svelte first. The additions below are optional to adopt.

Optional: shared interaction state with a controller

0.2 adds createPlotInteraction for linked views: selection, emphasis, and zoom state shared across plots, controls, and tables. Chart-local props and callbacks remain fully supported — reach for a controller only when more than one surface consumes the same interaction state.

Chart-local (unchanged from 0.1):

<script lang="ts">
  import {
    GeomPoint,
    GGPlot,
    type PlotSelection,
  } from "@ggsvelte/svelte";

  const rows = [
    { id: "a", flipper: 181, mass: 3750, species: "Adelie" },
    { id: "b", flipper: 195, mass: 3800, species: "Chinstrap" },
    { id: "c", flipper: 217, mass: 4500, species: "Gentoo" },
  ];
  let selection = $state<PlotSelection<string> | null>(null);
</script>

<GGPlot
  data={rows}
  aes={{ x: "flipper", y: "mass", color: "species" }}
  key="id"
  select={{ type: "point", multiple: true }}
  onselect={(event) => (selection = event)}
>
  <GeomPoint />
</GGPlot>

<p>{selection === null ? 0 : selection.keys.length} selected</p>

Shared controller (new in 0.2, optional):

<script lang="ts">
  import {
    createPlotInteraction,
    GeomPoint,
    GGPlot,
  } from "@ggsvelte/svelte";

  const rows = [
    { id: "a", flipper: 181, mass: 3750, species: "Adelie" },
    { id: "b", flipper: 195, mass: 3800, species: "Chinstrap" },
    { id: "c", flipper: 217, mass: 4500, species: "Gentoo" },
  ];
  const scope = { keys: "row-id", x: "flipper-mm", y: "mass-g" } as const;
  const interaction = createPlotInteraction<string>();
  const selected = $derived(interaction.selected(scope));
</script>

<GGPlot
  data={rows}
  aes={{ x: "flipper", y: "mass", color: "species" }}
  key="id"
  select={{ type: "point", multiple: true }}
  {interaction}
  interactionScope={scope}
>
  <GeomPoint />
</GGPlot>

<p>{selected.length} selected</p>

See the linked views example and Interactions for the full controller contract.

Deprecated type aliases

Unchanged in 0.2: these pre-0.1 names have been deprecated since 0.1.0 and still compile. Replace them when convenient:

  • BrushSelection → IntervalSelection
  • TooltipContext → PlotInspectionChange
  • ZoomDomains → ReadonlyZoomDomains

See Interactions for current options, event shapes, and identity requirements.