Skip to content

LineChart (Chart.js)

A data-bound port of Chart.js. Chart.js paints to a <canvas> the host framework never touches — and, like flatpickr, it has a wrapper in every framework (react-chartjs-2, vue-chartjs, ng2-charts, svelte-chartjs, …), each one a few hundred lines that mostly shuttle a data prop into a new Chart() call. LineChart.rozie collapses all of them into one source.

It is the deepest-reactivity example in the set:

  • One-way reactivity over a deeply-nested prop — Chart.js's data is { labels, datasets: [{ data: [...] }] }. The $watch reconciler mutates the live chart.data arrays in place and calls chart.update(), so Chart.js can tween every point from its old value to its new one. Replacing instance.data wholesale would sever that point identity and kill the animation.
  • $snapshot$snapshot(x) lowers to $state.snapshot(x) on the Svelte target and to plain x on the other five. Chart.js calls Object.defineProperty on the config object it is handed, which collides with Svelte 5's $state proxy; $snapshot unwraps the proxy first. It is a no-op everywhere else. Watch for it in the Svelte output below.
  • $onMount teardown, $refs for the canvas, and a type-change $watch that does re-create the instance — Chart.js has no stable runtime "change chart type" path, so a remount is the honest choice there.

Live demo

LineChartDemo.rozie drives the chart with a simulated live feed: a new data point every 0.8s, reconciled into the running chart with no remount. Toggle the feed off to push points by hand, or switch the chart between Line and Bar.

Source — Chart.rozie (generic Chart.js wrapper)

rozie
<!--
  Chart.rozie — data-bound port of Chart.js (chartjs/Chart.js).

  Chart.js has wrappers in every framework (react-chartjs-2, vue-chartjs,
  ng2-charts/ng-charts, svelte-chartjs, …) — each one a 200-2000 LOC project
  that mostly exists to shuttle props into a `new Chart()` call and forward
  events out. Five maintenance burdens, ONE Rozie source.

  This is the GENERIC chart: the public component is named `Chart` (the engine
  class is aliased `ChartJS` on import so the name is free) and the `type` prop
  switches the chart kind across the WHOLE Chart.js controller set — `line`,
  `bar`, `pie`, `doughnut`, `radar`, `polarArea`, `scatter`, `bubble`, and any
  registerable. `ChartJS.register(...registerables)` registers every controller,
  so `type` genuinely switches kind. Chart.js has no stable runtime type-swap,
  so a type change re-creates the instance (rare enough to be acceptable).

  Usage:

    <Chart :data="$data.chartData" :options="$data.chartOpts" type="bar" />

  Where `data` matches Chart.js's
  `{ labels: string[], datasets: [{ label, data, ... }] }` shape.

  Feature-rich surface (Phase 30):
    - 9 props: data / options / type / height / width / plugins / updateMode /
      redraw / ariaLabel. `:plugins` is the consumer-extensibility passthrough
      (per-instance Chart.js `Plugin[]`) — the Chart.js analog of CodeMirror's
      `:extensions` and FullCalendar's merged `:options.plugins`.
    - 3 events: @click / @hover / @datasetClick — composed onto Chart.js's
      `options.onClick`/`onHover` WITHOUT clobbering a consumer-supplied handler.
    - 8 $expose verbs over the live instance: getChart / updateChart /
      resizeChart / resetChart / renderChart / stopChart / clearChart /
      toBase64Image (the marquee PNG export). NOTE the verb names are SUFFIXED
      (`updateChart`, not `update`) — bare `update`/`render` collide with
      LitElement's reactive-lifecycle methods (`update()`/`render()`) and would
      shadow them on the Lit leaf. This is the Chart.js analog of CodeMirror's
      setValue→replaceValue ($expose-verb-vs-framework-reserved-method) lesson.
    - 1 external-HTML `tooltip` portal slot — canvas owns its own paint, so the
      external-tooltip handler is the single real slot showcase.

  $snapshot discipline: Chart.js's internal `Object.defineProperty` on its data
  config conflicts with Svelte 5's `$state` proxy (`state_descriptors_fixed`);
  `$snapshot(x)` lowers to `$state.snapshot(x)` on Svelte and to identity on the
  other five targets. Every object handed to the engine is snapshotted first.

  $refs discipline: `$refs.canvasEl` is read ONCE inside `$onMount` and captured
  into a plain `canvasNode` let; re-creates use the captured node (never re-read
  $refs outside the mount hook — ROZ123 / project_refs_only_safe_in_onmount).
-->

<rozie name="Chart" inherit-attrs="false" inherit-listeners="false" adopt-document-styles>

<props>
{
  data: {
    type: Object,
    default: () => ({ labels: [], datasets: [] }),
    docs: {
      description:
        "Chart.js data in its own `{ labels, datasets }` shape. Reconciled **in place** on change — the wrapper mutates `chart.data` and calls `chart.update()` so series tween point-to-point instead of remounting.",
      example: '<Chart :data="$data.chartData" type="bar" />',
    },
  },
  options: {
    type: Object,
    default: () => ({}),
    docs: {
      description:
        "Chart.js options (scales, legend, plugins config, …). Merged over the wrapper's responsive defaults and reapplied wholesale on change with `update('none')`. A consumer-supplied `options.onClick`/`onHover` is **composed**, not clobbered.",
    },
  },
  type: {
    type: String,
    default: 'line',
    docs: {
      description:
        'The chart kind — any Chart.js controller (`line`/`bar`/`pie`/`doughnut`/`radar`/`polarArea`/`scatter`/`bubble`/…). Changing it **re-creates** the instance, since Chart.js has no stable runtime type-swap.',
    },
  },
  height: {
    type: Number,
    default: 240,
    docs: {
      description:
        "Chart height in pixels, applied to the wrapper's host box; the canvas fills it responsively.",
    },
  },
  width: {
    type: Number,
    default: undefined,
    docs: {
      description:
        'Optional fixed chart width in pixels. Omit for the default full-width responsive box.',
    },
  },
  plugins: {
    type: Array,
    default: () => [],
    docs: {
      description:
        'Per-instance Chart.js `Plugin[]` — the consumer-extensibility passthrough. Merged into the config; changing the array **re-creates** the instance, since Chart.js has no stable runtime plugin-swap.',
    },
  },
  updateMode: {
    type: String,
    default: undefined,
    docs: {
      description:
        'The Chart.js `update` mode string used by the in-place data reconcile (e.g. `none` to skip the animation on every data tick).',
    },
  },
  redraw: {
    type: Boolean,
    default: false,
    docs: {
      description:
        'When `true`, a `data` change **re-creates** the chart wholesale instead of reconciling in place — mirrors react-chartjs-2 `redraw` for charts whose plugins do not survive an in-place update.',
    },
  },
  ariaLabel: {
    type: String,
    default: undefined,
    docs: {
      description:
        'Accessible label applied to the `<canvas role="img">`, since canvas charts are otherwise opaque to assistive tech. For richer fallback content, fill the `fallback` slot.',
    },
  },
  // Dataset diff key (react-chartjs-2 parity): datasets are matched across
  // updates by ds[datasetIdKey], falling back to array index when the key is
  // absent — guards the "first dataset copied over the others" hazard.
  datasetIdKey: {
    type: String,
    default: 'label',
    docs: {
      description:
        'The dataset-identity key (react-chartjs-2 parity). Across data updates, datasets are matched by `dataset[datasetIdKey]`, falling back to array index when the key is absent, so a stable keyed dataset reconciles onto its prior slot even if its index moved — guarding the "first dataset copied over the others" hazard.',
    },
  },
  // ms grace before destroy() on unmount so exit transitions can finish
  // (vue-chartjs parity). 0 = destroy immediately.
  destroyDelay: {
    type: Number,
    default: 0,
    docs: {
      description:
        'Milliseconds to defer `chart.destroy()` on unmount so an exit transition can finish (vue-chartjs parity). `0` (the default) destroys immediately.',
    },
  },
}
</props>

<script>
import { Chart as ChartJS } from 'chart.js'

// Chart.js v3+ ships with no controllers/elements/scales pre-registered. The
// generic Chart does NOT auto-register — the consumer registers only what they
// use (the tree-shakable Chart.js v3+ idiom every framework wrapper follows), so
// an app that only renders line charts doesn't ship every controller. Two paths:
//   - selective: `import { Chart, LineController, ... } from 'chart.js';
//     Chart.register(LineController, ...)` once at app startup; OR
//   - kitchen sink: import this package's `/auto` entry
//     (`@rozie-ui/chartjs-<fw>/auto`), or `import 'chart.js/auto'`, which
//     registers everything.
// The per-type components (Line/Bar/…) register their own controller set, so
// importing one is tree-shakable by construction.

let instance = null
// $refs.canvasEl is read ONLY inside $onMount (ROZ123); re-creates use this
// captured node so no $refs read ever executes outside the mount hook. Named
// `canvasNode` (NOT `canvasEl`) so it does not collide with the template
// `ref="canvasEl"` binding, which the per-target emitters lower to their own
// `canvasEl` ref declaration (a same-name script local double-declares it).
let canvasNode = null
// Tooltip-portal teardown state at COMPONENT scope (not inside $onMount): the
// $onMount-returned cleanup references these, and the Solid emitter hoists that
// returned cleanup into a sibling onCleanup() OUTSIDE the mount-body IIFE — so a
// cleanup that closed over $onMount-locals would lose scope. Component-scope
// state (like `instance`) is in scope wherever the per-target cleanup lands.
let tooltipEl = null
let tooltipDispose = null
// buildConfig is DEFINED inside $onMount (so its $emit/$portals/$slots
// references are bound in the mount-lifecycle scope the per-target emitters
// provide — mirrors FullCalendar's mount-built opts + CodeMirror's panelExt
// note) and stored here so the top-level re-create $watches can call it.
let buildConfig = null

$onMount(() => {
  canvasNode = $refs.canvasEl

  // ─── @click / @hover / @datasetClick — composed, never clobbering ──────────
  // Chart.js calls onClick/onHover with (event, activeElements, chart). We call
  // any consumer-supplied handler first (read off $props.options), then emit a
  // structured payload resolving the hit element(s) via getElementsAtEventForMode.
  const composedOnClick = (e, activeEls, chart) => {
    const userOnClick = $props.options?.onClick
    if (typeof userOnClick === 'function') userOnClick(e, activeEls, chart)
    const nearest = chart.getElementsAtEventForMode(e, 'nearest', { intersect: true }, false)
    $emit('click', { event: e, elements: nearest, chart })
    const dataset = chart.getElementsAtEventForMode(e, 'dataset', { intersect: true }, false)
    if (dataset.length) {
      $emit('datasetClick', { event: e, elements: dataset, datasetIndex: dataset[0].datasetIndex, chart })
    }
  }
  const composedOnHover = (e, activeEls, chart) => {
    const userOnHover = $props.options?.onHover
    if (typeof userOnHover === 'function') userOnHover(e, activeEls, chart)
    $emit('hover', { event: e, elements: activeEls, chart })
  }

  // ─── external-HTML tooltip portal slot ─────────────────────────────────────
  // Only active when the consumer fills <slot name="tooltip">. The external
  // handler positions a container over the canvas and mounts the consumer's
  // framework-native fragment through $portals.tooltip(dom, scope). The scope
  // carries the live tooltip model (title/body/dataPoints/position). Chart.js
  // throttles external calls to active-element changes, so the dispose+remount
  // on body-change is cheap. enabled:false suppresses the built-in canvas
  // tooltip when we take over.
  let tooltipKey = ''
  const tooltipExternal = (context) => {
    const { chart, tooltip } = context
    if (!tooltipEl) {
      tooltipEl = document.createElement('div')
      tooltipEl.className = 'rozie-chart-tooltip'
      tooltipEl.style.position = 'absolute'
      tooltipEl.style.pointerEvents = 'none'
      tooltipEl.style.transition = 'opacity 0.1s ease'
      chart.canvas.parentNode.appendChild(tooltipEl)
    }
    if (tooltip.opacity === 0) {
      tooltipEl.style.opacity = '0'
      return
    }
    const title = (tooltip.title || []).join(' ')
    const body = (tooltip.body || []).map((b) => b.lines.join(' ')).join(' | ')
    const key = `${title}::${body}`
    if (key !== tooltipKey) {
      tooltipKey = key
      tooltipDispose?.()
      // The scope MUST match the slot's declared param (`model`): the consumer's
      // <slot name="tooltip"> receives a single `model` scoped value.
      const scope = {
        model: {
          title: tooltip.title || [],
          body: (tooltip.body || []).map((b) => b.lines),
          dataPoints: tooltip.dataPoints || [],
          opacity: tooltip.opacity,
        },
      }
      tooltipDispose = $portals.tooltip(tooltipEl, scope)
    }
    const { offsetLeft, offsetTop } = chart.canvas
    tooltipEl.style.opacity = '1'
    tooltipEl.style.left = `${offsetLeft + tooltip.caretX}px`
    tooltipEl.style.top = `${offsetTop + tooltip.caretY}px`
  }

  // ─── config builder ────────────────────────────────────────────────────────
  // $snapshot strips Svelte 5's $state proxy first; Chart.js redefines property
  // descriptors on whatever object it is handed.
  buildConfig = () => {
    const userOpts = $snapshot($props.options) || {}
    const tooltipOpt = $slots.tooltip
      ? { ...(userOpts.plugins?.tooltip || {}), enabled: false, external: tooltipExternal }
      : userOpts.plugins?.tooltip
    return {
      type: $props.type,
      data: $snapshot($props.data),
      // per-instance plugins[] — the consumer-extensibility passthrough.
      plugins: $snapshot($props.plugins),
      options: {
        responsive: true,
        maintainAspectRatio: false,
        animation: { duration: 250 },
        ...userOpts,
        onClick: composedOnClick,
        onHover: composedOnHover,
        plugins: {
          ...(userOpts.plugins || {}),
          tooltip: tooltipOpt,
        },
      },
    }
  }

  instance = new ChartJS(canvasNode, buildConfig())
  return () => {
    tooltipDispose?.()
    tooltipEl?.remove()
    // destroyDelay (vue-chartjs parity): defer destroy() so any exit transition
    // can finish. The captured `dying` instance is destroyed after the grace;
    // 0 (default) destroys synchronously.
    const dying = instance
    if ($props.destroyDelay > 0) {
      setTimeout(() => dying?.destroy(), $props.destroyDelay)
    } else {
      dying?.destroy()
    }
  }
})

// Re-create the live instance. Chart.js exposes no stable runtime type-swap or
// plugin-swap, so `type`/`plugins`/`redraw`-driven changes re-create. Uses the
// captured canvasNode (never re-reads $refs outside $onMount).
const recreate = () => {
  if (!buildConfig || !canvasNode) return
  instance?.destroy()
  instance = new ChartJS(canvasNode, buildConfig())
}

// Reconcile prop changes. Mutating chart.data in place and calling update() is
// the Chart.js-supported runtime path — re-creating on every data tick would
// flicker and leak. (When `redraw` is set, re-create wholesale instead.)
$watch(() => $props.data, (v) => {
  if (!instance) return
  if ($props.redraw) { recreate(); return }

  // Reconcile a new data object into the LIVE chart instead of replacing
  // instance.data. Chart.js matches dataset controllers and point elements by
  // array index across an update(), so mutating the existing labels/datasets
  // arrays lets it tween every point from old value to new. Assigning a fresh
  // instance.data severs that identity.
  const next = $snapshot(v)
  const live = instance.data

  // Aliasing guard. On identity-$snapshot targets (React / Solid / Lit) $snapshot
  // returns its argument unchanged, and Chart.js stores config.data by reference,
  // so a freshly-constructed chart has `instance.data === $props.data === next`.
  // The in-place `live.labels.length = 0` below would then empty the very array we
  // read from on the next line (`next.labels` IS `live.labels`), wiping the labels
  // → cartesian charts lose their category axis and render an empty plot (the
  // doughnut, being radial, survives — it doesn't position by label). When live and
  // next alias there is nothing to reconcile: the chart already holds this data, so
  // just repaint. (Vue/Angular never hit this — their immediate $watch runs before
  // $onMount, when instance is still null.)
  if (live === next) { instance.update($props.updateMode); return }

  live.labels ??= []
  live.labels.length = 0
  live.labels.push(...(next.labels ?? []))

  // Datasets are matched by `ds[datasetIdKey]` (default 'label') so a stable
  // keyed dataset reconciles onto its prior slot even if its array index moved —
  // this guards the "first dataset copied over the others" hazard react-chartjs-2
  // documents. Datasets without the key fall back to positional (index) matching.
  live.datasets ??= []
  const nextSets = next.datasets ?? []
  const key = $props.datasetIdKey
  const prev = live.datasets.slice()
  const byKey = new Map()
  prev.forEach((ds, i) => {
    if (ds && ds[key] != null) byKey.set(ds[key], ds)
  })
  const merged = nextSets.map((ds, i) => {
    const match = (ds && ds[key] != null && byKey.get(ds[key])) || prev[i]
    if (match) { Object.assign(match, ds); return match }
    return ds
  })
  live.datasets.length = 0
  live.datasets.push(...merged)

  instance.update($props.updateMode)
}, { immediate: true })

// Options aren't tweened point-to-point; rebuild the merged options object
// (re-applying the composed onClick/onHover + tooltip external) and assign
// wholesale. Skip the animation rather than rescale-flicker on every tick.
$watch(() => $props.options, () => {
  if (!instance || !buildConfig) return
  instance.options = buildConfig().options
  instance.update('none')
})

// No stable runtime type-swap / plugin-swap → re-create.
$watch(() => $props.type, () => recreate())
$watch(() => $props.plugins, () => recreate())

// Imperative handle (Phase 21 $expose). The lifecycle/redraw verbs are SUFFIXED
// with `Chart` because bare `update`/`render` collide with LitElement's
// reactive-lifecycle methods (`update(changedProperties)` / `render()`) and
// would shadow them on the Lit leaf; `resize`/`reset`/`stop`/`clear` are
// suffixed too for a consistent, unambiguous handle. `getChart` returns the live
// instance for direct API access; `toBase64Image` is the marquee PNG-export.
//
// The visibility + active-element family (added later) is the #1 reason a
// consumer reaches for a chart handle — custom legends, externally-driven
// tooltips/highlights, overlay positioning — none reachable via prop/event:
//   - setDatasetVisibility / isDatasetVisible: drive a custom legend's series
//     show/hide (INSTANT toggle).
//   - hideDataset / showDataset: the ANIMATED hide/show (Chart.js hide()/show()).
//     SUFFIXED with `Dataset` both to dodge inherited HTMLElement-ish ambiguity
//     and to disambiguate the dataset-vs-element overload.
//   - setActiveElements / getActiveElements: programmatically open/read the
//     hovered/active points (sync hover from a table row, a map pin, a sibling
//     chart) — events only REPORT hover, they cannot SET it.
//   - getDatasetMeta: read computed geometry (pixel coords, controller) to
//     position custom overlays/annotations over the canvas.
// Collision-clear: none of the 15 names collide with the 11 props or the 3
// emits (click/datasetClick/hover).
function getChart()                   { return instance }
function updateChart(mode)            { instance?.update(mode) }
function resizeChart(w, h)            { instance?.resize(w, h) }
function resetChart()                 { instance?.reset() }
function renderChart()                { instance?.render() }
function stopChart()                  { return instance?.stop() }
function clearChart()                 { return instance?.clear() }
function toBase64Image(type, quality) { return instance ? instance.toBase64Image(type, quality) : null }
function setDatasetVisibility(datasetIndex, visible) { instance?.setDatasetVisibility(datasetIndex, visible) }
function isDatasetVisible(datasetIndex)              { return instance ? instance.isDatasetVisible(datasetIndex) : false }
function hideDataset(datasetIndex, dataIndex)        { instance?.hide(datasetIndex, dataIndex) }
function showDataset(datasetIndex, dataIndex)        { instance?.show(datasetIndex, dataIndex) }
function setActiveElements(elements)                 { instance?.setActiveElements(elements ?? []) }
function getActiveElements()                         { return instance ? instance.getActiveElements() : [] }
function getDatasetMeta(datasetIndex)                { return instance ? instance.getDatasetMeta(datasetIndex) : null }

$expose({
  getChart, updateChart, resizeChart, resetChart, renderChart, stopChart, clearChart, toBase64Image,
  setDatasetVisibility, isDatasetVisible, hideDataset, showDataset,
  setActiveElements, getActiveElements, getDatasetMeta,
})
</script>

<template>
<div class="rozie-chart" :style="{ height: $props.height + 'px', width: $props.width ? $props.width + 'px' : undefined }">
  <!--
    The `fallback` slot is rendered INSIDE the <canvas> — the canvas-fallback a11y
    idiom (content shown to assistive tech / browsers that can't render the canvas;
    Chart.js paints over it visually). Non-portal named slot; omit it and the canvas
    renders normally. Guarded so an empty slot adds nothing.
  -->
  <canvas ref="canvasEl" role="img" :aria-label="$props.ariaLabel"><slot name="fallback" /></canvas>
</div>
<!--
  External-HTML tooltip portal slot. Declared but NOT rendered in the template —
  per-target template emitters skip it. The wrapper invokes the slot from script
  via $portals.tooltip(dom, scope) inside Chart.js's external tooltip handler;
  the portal helper mounts the consumer's framework-native fragment into the
  wrapper-owned tooltip node and returns a dispose handle.
-->
<slot name="tooltip" portal :params="['model']" />
</template>

<style>
.rozie-chart {
  position: relative;
  width: 100%;
}
.rozie-chart canvas {
  display: block;
  width: 100% !important;
  height: 100% !important;
}
:root {
  .rozie-chart .rozie-chart-tooltip {
    background: rgba(0, 0, 0, 0.8);
    color: #fff;
    border-radius: 4px;
    padding: 6px 8px;
    font-size: 12px;
    transform: translate(-50%, calc(-100% - 8px));
    white-space: nowrap;
  }
}
</style>

</rozie>

Compiled output

vue
<template>

<div class="rozie-chart" :style="{ height: props.height + 'px', width: props.width ? props.width + 'px' : undefined }">
  
  <canvas ref="canvasElRef" role="img" :aria-label="props.ariaLabel"><slot name="fallback"></slot></canvas>
</div>



</template>

<script setup lang="ts">
import { Fragment, h, onBeforeUnmount, onMounted, ref, render, useSlots, watch } from 'vue';

const props = withDefaults(
  defineProps<{
    /**
     * Chart.js data in its own `{ labels, datasets }` shape. Reconciled **in place** on change — the wrapper mutates `chart.data` and calls `chart.update()` so series tween point-to-point instead of remounting.
     * @example
     * <Chart :data="$data.chartData" type="bar" />
     */
    data?: Record<string, any>;
    /**
     * Chart.js options (scales, legend, plugins config, …). Merged over the wrapper's responsive defaults and reapplied wholesale on change with `update('none')`. A consumer-supplied `options.onClick`/`onHover` is **composed**, not clobbered.
     */
    options?: Record<string, any>;
    /**
     * The chart kind — any Chart.js controller (`line`/`bar`/`pie`/`doughnut`/`radar`/`polarArea`/`scatter`/`bubble`/…). Changing it **re-creates** the instance, since Chart.js has no stable runtime type-swap.
     */
    type?: string;
    /**
     * Chart height in pixels, applied to the wrapper's host box; the canvas fills it responsively.
     */
    height?: number;
    /**
     * Optional fixed chart width in pixels. Omit for the default full-width responsive box.
     */
    width?: number;
    /**
     * Per-instance Chart.js `Plugin[]` — the consumer-extensibility passthrough. Merged into the config; changing the array **re-creates** the instance, since Chart.js has no stable runtime plugin-swap.
     */
    plugins?: any[];
    /**
     * The Chart.js `update` mode string used by the in-place data reconcile (e.g. `none` to skip the animation on every data tick).
     */
    updateMode?: string;
    /**
     * When `true`, a `data` change **re-creates** the chart wholesale instead of reconciling in place — mirrors react-chartjs-2 `redraw` for charts whose plugins do not survive an in-place update.
     */
    redraw?: boolean;
    /**
     * Accessible label applied to the `<canvas role="img">`, since canvas charts are otherwise opaque to assistive tech. For richer fallback content, fill the `fallback` slot.
     */
    ariaLabel?: string;
    /**
     * The dataset-identity key (react-chartjs-2 parity). Across data updates, datasets are matched by `dataset[datasetIdKey]`, falling back to array index when the key is absent, so a stable keyed dataset reconciles onto its prior slot even if its index moved — guarding the "first dataset copied over the others" hazard.
     */
    datasetIdKey?: string;
    /**
     * Milliseconds to defer `chart.destroy()` on unmount so an exit transition can finish (vue-chartjs parity). `0` (the default) destroys immediately.
     */
    destroyDelay?: number;
  }>(),
  { data: () => ({
  labels: [],
  datasets: []
}), options: () => ({}), type: 'line', height: 240, width: undefined, plugins: () => [], updateMode: undefined, redraw: false, ariaLabel: undefined, datasetIdKey: 'label', destroyDelay: 0 }
);

const emit = defineEmits<{
  click: [...args: any[]];
  datasetClick: [...args: any[]];
  hover: [...args: any[]];
}>();

defineSlots<{
  fallback(props: {  }): any;
  tooltip(props: { model: any }): any;
}>();

const slots = useSlots();

const canvasElRef = ref<HTMLElement>();

import { Chart as ChartJS } from 'chart.js';

// Chart.js v3+ ships with no controllers/elements/scales pre-registered. The
// generic Chart does NOT auto-register — the consumer registers only what they
// use (the tree-shakable Chart.js v3+ idiom every framework wrapper follows), so
// an app that only renders line charts doesn't ship every controller. Two paths:
//   - selective: `import { Chart, LineController, ... } from 'chart.js';
//     Chart.register(LineController, ...)` once at app startup; OR
//   - kitchen sink: import this package's `/auto` entry
//     (`@rozie-ui/chartjs-<fw>/auto`), or `import 'chart.js/auto'`, which
//     registers everything.
// The per-type components (Line/Bar/…) register their own controller set, so
// importing one is tree-shakable by construction.
// Chart.js v3+ ships with no controllers/elements/scales pre-registered. The
// generic Chart does NOT auto-register — the consumer registers only what they
// use (the tree-shakable Chart.js v3+ idiom every framework wrapper follows), so
// an app that only renders line charts doesn't ship every controller. Two paths:
//   - selective: `import { Chart, LineController, ... } from 'chart.js';
//     Chart.register(LineController, ...)` once at app startup; OR
//   - kitchen sink: import this package's `/auto` entry
//     (`@rozie-ui/chartjs-<fw>/auto`), or `import 'chart.js/auto'`, which
//     registers everything.
// The per-type components (Line/Bar/…) register their own controller set, so
// importing one is tree-shakable by construction.

let instance: any = null;
// $refs.canvasEl is read ONLY inside $onMount (ROZ123); re-creates use this
// captured node so no $refs read ever executes outside the mount hook. Named
// `canvasNode` (NOT `canvasEl`) so it does not collide with the template
// `ref="canvasEl"` binding, which the per-target emitters lower to their own
// `canvasEl` ref declaration (a same-name script local double-declares it).
// $refs.canvasEl is read ONLY inside $onMount (ROZ123); re-creates use this
// captured node so no $refs read ever executes outside the mount hook. Named
// `canvasNode` (NOT `canvasEl`) so it does not collide with the template
// `ref="canvasEl"` binding, which the per-target emitters lower to their own
// `canvasEl` ref declaration (a same-name script local double-declares it).
let canvasNode: any = null;
// Tooltip-portal teardown state at COMPONENT scope (not inside $onMount): the
// $onMount-returned cleanup references these, and the Solid emitter hoists that
// returned cleanup into a sibling onCleanup() OUTSIDE the mount-body IIFE — so a
// cleanup that closed over $onMount-locals would lose scope. Component-scope
// state (like `instance`) is in scope wherever the per-target cleanup lands.
// Tooltip-portal teardown state at COMPONENT scope (not inside $onMount): the
// $onMount-returned cleanup references these, and the Solid emitter hoists that
// returned cleanup into a sibling onCleanup() OUTSIDE the mount-body IIFE — so a
// cleanup that closed over $onMount-locals would lose scope. Component-scope
// state (like `instance`) is in scope wherever the per-target cleanup lands.
let tooltipEl: any = null;
let tooltipDispose: any = null;
// buildConfig is DEFINED inside $onMount (so its $emit/$portals/$slots
// references are bound in the mount-lifecycle scope the per-target emitters
// provide — mirrors FullCalendar's mount-built opts + CodeMirror's panelExt
// note) and stored here so the top-level re-create $watches can call it.
// buildConfig is DEFINED inside $onMount (so its $emit/$portals/$slots
// references are bound in the mount-lifecycle scope the per-target emitters
// provide — mirrors FullCalendar's mount-built opts + CodeMirror's panelExt
// note) and stored here so the top-level re-create $watches can call it.
let buildConfig: any = null;
// Re-create the live instance. Chart.js exposes no stable runtime type-swap or
// plugin-swap, so `type`/`plugins`/`redraw`-driven changes re-create. Uses the
// captured canvasNode (never re-reads $refs outside $onMount).
const recreate = () => {
  if (!buildConfig || !canvasNode) return;
  instance?.destroy();
  instance = new ChartJS(canvasNode, buildConfig());
};

// Reconcile prop changes. Mutating chart.data in place and calling update() is
// the Chart.js-supported runtime path — re-creating on every data tick would
// flicker and leak. (When `redraw` is set, re-create wholesale instead.)
// Imperative handle (Phase 21 $expose). The lifecycle/redraw verbs are SUFFIXED
// with `Chart` because bare `update`/`render` collide with LitElement's
// reactive-lifecycle methods (`update(changedProperties)` / `render()`) and
// would shadow them on the Lit leaf; `resize`/`reset`/`stop`/`clear` are
// suffixed too for a consistent, unambiguous handle. `getChart` returns the live
// instance for direct API access; `toBase64Image` is the marquee PNG-export.
//
// The visibility + active-element family (added later) is the #1 reason a
// consumer reaches for a chart handle — custom legends, externally-driven
// tooltips/highlights, overlay positioning — none reachable via prop/event:
//   - setDatasetVisibility / isDatasetVisible: drive a custom legend's series
//     show/hide (INSTANT toggle).
//   - hideDataset / showDataset: the ANIMATED hide/show (Chart.js hide()/show()).
//     SUFFIXED with `Dataset` both to dodge inherited HTMLElement-ish ambiguity
//     and to disambiguate the dataset-vs-element overload.
//   - setActiveElements / getActiveElements: programmatically open/read the
//     hovered/active points (sync hover from a table row, a map pin, a sibling
//     chart) — events only REPORT hover, they cannot SET it.
//   - getDatasetMeta: read computed geometry (pixel coords, controller) to
//     position custom overlays/annotations over the canvas.
// Collision-clear: none of the 15 names collide with the 11 props or the 3
// emits (click/datasetClick/hover).
function getChart() {
  return instance;
}
function updateChart(mode: any) {
  instance?.update(mode);
}
function resizeChart(w: any, h: any) {
  instance?.resize(w, h);
}
function resetChart() {
  instance?.reset();
}
function renderChart() {
  instance?.render();
}
function stopChart() {
  return instance?.stop();
}
function clearChart() {
  return instance?.clear();
}
function toBase64Image(type: any, quality: any) {
  return instance ? instance.toBase64Image(type, quality) : null;
}
function setDatasetVisibility(datasetIndex: any, visible: any) {
  instance?.setDatasetVisibility(datasetIndex, visible);
}
function isDatasetVisible(datasetIndex: any) {
  return instance ? instance.isDatasetVisible(datasetIndex) : false;
}
function hideDataset(datasetIndex: any, dataIndex: any) {
  instance?.hide(datasetIndex, dataIndex);
}
function showDataset(datasetIndex: any, dataIndex: any) {
  instance?.show(datasetIndex, dataIndex);
}
function setActiveElements(elements: any) {
  instance?.setActiveElements(elements ?? []);
}
function getActiveElements() {
  return instance ? instance.getActiveElements() : [];
}
function getDatasetMeta(datasetIndex: any) {
  return instance ? instance.getDatasetMeta(datasetIndex) : null;
}

const portalContainers = new Set<HTMLElement>();
const portals = {
  tooltip: (container: HTMLElement, scope: { model: unknown }): (() => void) => {
    const slotFn = slots.tooltip;
    if (!slotFn) return () => {};
    // Spike 004: portal-scope attribute injection. Cascades the @portal
    // tooltip { … } selectors from the unscoped <style> block below into
    // the engine-owned subtree.
    container.setAttribute('data-rozie-portal-tooltip', '2228fabc');
    const vnode = h(Fragment, null, slotFn(scope));
    render(vnode, container);
    portalContainers.add(container);
    return () => {
      render(null, container);
      portalContainers.delete(container);
    };
  },
};
onBeforeUnmount(() => {
  for (const container of portalContainers) render(null, container);
  portalContainers.clear();
});

let _cleanup_0: (() => void) | undefined;
onMounted(() => {
  canvasNode = canvasElRef.value;

  // ─── @click / @hover / @datasetClick — composed, never clobbering ──────────
  // Chart.js calls onClick/onHover with (event, activeElements, chart). We call
  // any consumer-supplied handler first (read off $props.options), then emit a
  // structured payload resolving the hit element(s) via getElementsAtEventForMode.
  const composedOnClick = (e: any, activeEls: any, chart: any) => {
    const userOnClick = props.options?.onClick;
    if (typeof userOnClick === 'function') userOnClick(e, activeEls, chart);
    const nearest = chart.getElementsAtEventForMode(e, 'nearest', {
      intersect: true
    }, false);
    emit('click', {
      event: e,
      elements: nearest,
      chart
    });
    const dataset = chart.getElementsAtEventForMode(e, 'dataset', {
      intersect: true
    }, false);
    if (dataset.length) {
      emit('datasetClick', {
        event: e,
        elements: dataset,
        datasetIndex: dataset[0].datasetIndex,
        chart
      });
    }
  };
  const composedOnHover = (e: any, activeEls: any, chart: any) => {
    const userOnHover = props.options?.onHover;
    if (typeof userOnHover === 'function') userOnHover(e, activeEls, chart);
    emit('hover', {
      event: e,
      elements: activeEls,
      chart
    });
  };

  // ─── external-HTML tooltip portal slot ─────────────────────────────────────
  // Only active when the consumer fills <slot name="tooltip">. The external
  // handler positions a container over the canvas and mounts the consumer's
  // framework-native fragment through $portals.tooltip(dom, scope). The scope
  // carries the live tooltip model (title/body/dataPoints/position). Chart.js
  // throttles external calls to active-element changes, so the dispose+remount
  // on body-change is cheap. enabled:false suppresses the built-in canvas
  // tooltip when we take over.
  let tooltipKey = '';
  const tooltipExternal = (context: any) => {
    const {
      chart,
      tooltip
    } = context;
    if (!tooltipEl) {
      tooltipEl = document.createElement('div');
      tooltipEl.className = 'rozie-chart-tooltip';
      tooltipEl.style.position = 'absolute';
      tooltipEl.style.pointerEvents = 'none';
      tooltipEl.style.transition = 'opacity 0.1s ease';
      chart.canvas.parentNode.appendChild(tooltipEl);
    }
    if (tooltip.opacity === 0) {
      tooltipEl.style.opacity = '0';
      return;
    }
    const title = (tooltip.title || []).join(' ');
    const body = (tooltip.body || []).map((b: any) => b.lines.join(' ')).join(' | ');
    const key = `${title}::${body}`;
    if (key !== tooltipKey) {
      tooltipKey = key;
      tooltipDispose?.();
      // The scope MUST match the slot's declared param (`model`): the consumer's
      // <slot name="tooltip"> receives a single `model` scoped value.
      const scope = {
        model: {
          title: tooltip.title || [],
          body: (tooltip.body || []).map((b: any) => b.lines),
          dataPoints: tooltip.dataPoints || [],
          opacity: tooltip.opacity
        }
      };
      tooltipDispose = portals.tooltip(tooltipEl, scope);
    }
    const {
      offsetLeft,
      offsetTop
    } = chart.canvas;
    tooltipEl.style.opacity = '1';
    tooltipEl.style.left = `${offsetLeft + tooltip.caretX}px`;
    tooltipEl.style.top = `${offsetTop + tooltip.caretY}px`;
  };

  // ─── config builder ────────────────────────────────────────────────────────
  // $snapshot strips Svelte 5's $state proxy first; Chart.js redefines property
  // descriptors on whatever object it is handed.
  buildConfig = () => {
    const userOpts = props.options || {};
    const tooltipOpt = slots.tooltip ? {
      ...(userOpts.plugins?.tooltip || {}),
      enabled: false,
      external: tooltipExternal
    } : userOpts.plugins?.tooltip;
    return {
      type: props.type,
      data: props.data,
      // per-instance plugins[] — the consumer-extensibility passthrough.
      plugins: props.plugins,
      options: {
        responsive: true,
        maintainAspectRatio: false,
        animation: {
          duration: 250
        },
        ...userOpts,
        onClick: composedOnClick,
        onHover: composedOnHover,
        plugins: {
          ...(userOpts.plugins || {}),
          tooltip: tooltipOpt
        }
      }
    };
  };
  instance = new ChartJS(canvasNode, buildConfig());
  _cleanup_0 = () => {
    tooltipDispose?.();
    tooltipEl?.remove();
    // destroyDelay (vue-chartjs parity): defer destroy() so any exit transition
    // can finish. The captured `dying` instance is destroyed after the grace;
    // 0 (default) destroys synchronously.
    const dying = instance;
    if (props.destroyDelay > 0) {
      setTimeout(() => dying?.destroy(), props.destroyDelay);
    } else {
      dying?.destroy();
    }
  };
});
onBeforeUnmount(() => { _cleanup_0?.(); });

watch(() => props.data, (v: any) => {
  if (!instance) return;
  if (props.redraw) {
    recreate();
    return;
  }

  // Reconcile a new data object into the LIVE chart instead of replacing
  // instance.data. Chart.js matches dataset controllers and point elements by
  // array index across an update(), so mutating the existing labels/datasets
  // arrays lets it tween every point from old value to new. Assigning a fresh
  // instance.data severs that identity.
  const next = v;
  const live = instance.data;

  // Aliasing guard. On identity-$snapshot targets (React / Solid / Lit) $snapshot
  // returns its argument unchanged, and Chart.js stores config.data by reference,
  // so a freshly-constructed chart has `instance.data === $props.data === next`.
  // The in-place `live.labels.length = 0` below would then empty the very array we
  // read from on the next line (`next.labels` IS `live.labels`), wiping the labels
  // → cartesian charts lose their category axis and render an empty plot (the
  // doughnut, being radial, survives — it doesn't position by label). When live and
  // next alias there is nothing to reconcile: the chart already holds this data, so
  // just repaint. (Vue/Angular never hit this — their immediate $watch runs before
  // $onMount, when instance is still null.)
  if (live === next) {
    instance.update(props.updateMode);
    return;
  }
  live.labels ??= [];
  live.labels.length = 0;
  live.labels.push(...(next.labels ?? []));

  // Datasets are matched by `ds[datasetIdKey]` (default 'label') so a stable
  // keyed dataset reconciles onto its prior slot even if its array index moved —
  // this guards the "first dataset copied over the others" hazard react-chartjs-2
  // documents. Datasets without the key fall back to positional (index) matching.
  live.datasets ??= [];
  const nextSets = next.datasets ?? [];
  const key = props.datasetIdKey;
  const prev = live.datasets.slice();
  const byKey = new Map();
  prev.forEach((ds: any, i: any) => {
    if (ds && ds[key] != null) byKey.set(ds[key], ds);
  });
  const merged = nextSets.map((ds: any, i: any) => {
    const match = ds && ds[key] != null && byKey.get(ds[key]) || prev[i];
    if (match) {
      Object.assign(match, ds);
      return match;
    }
    return ds;
  });
  live.datasets.length = 0;
  live.datasets.push(...merged);
  instance.update(props.updateMode);
}, { immediate: true });
watch(() => props.options, () => {
  if (!instance || !buildConfig) return;
  instance.options = buildConfig().options;
  instance.update('none');
});
watch(() => props.type, () => recreate());
watch(() => props.plugins, () => recreate());

defineExpose({ getChart, updateChart, resizeChart, resetChart, renderChart, stopChart, clearChart, toBase64Image, setDatasetVisibility, isDatasetVisible, hideDataset, showDataset, setActiveElements, getActiveElements, getDatasetMeta });
</script>

<style scoped>
.rozie-chart {
  position: relative;
  width: 100%;
}
.rozie-chart canvas {
  display: block;
  width: 100% !important;
  height: 100% !important;
}
</style>

<style>
.rozie-chart .rozie-chart-tooltip {
    background: rgba(0, 0, 0, 0.8);
    color: #fff;
    border-radius: 4px;
    padding: 6px 8px;
    font-size: 12px;
    transform: translate(-50%, calc(-100% - 8px));
    white-space: nowrap;
  }
</style>
tsx
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { flushSync } from 'react-dom';
import './Chart.css';
import './Chart.global.css';
import { Chart as ChartJS } from 'chart.js';

// Chart.js v3+ ships with no controllers/elements/scales pre-registered. The
// generic Chart does NOT auto-register — the consumer registers only what they
// use (the tree-shakable Chart.js v3+ idiom every framework wrapper follows), so
// an app that only renders line charts doesn't ship every controller. Two paths:
//   - selective: `import { Chart, LineController, ... } from 'chart.js';
//     Chart.register(LineController, ...)` once at app startup; OR
//   - kitchen sink: import this package's `/auto` entry
//     (`@rozie-ui/chartjs-<fw>/auto`), or `import 'chart.js/auto'`, which
//     registers everything.
// The per-type components (Line/Bar/…) register their own controller set, so
// importing one is tree-shakable by construction.

interface TooltipCtx { model: any; }

interface ChartProps {
  /**
   * Chart.js data in its own `{ labels, datasets }` shape. Reconciled **in place** on change — the wrapper mutates `chart.data` and calls `chart.update()` so series tween point-to-point instead of remounting.
   * @example
   * <Chart :data="$data.chartData" type="bar" />
   */
  data?: Record<string, any>;
  /**
   * Chart.js options (scales, legend, plugins config, …). Merged over the wrapper's responsive defaults and reapplied wholesale on change with `update('none')`. A consumer-supplied `options.onClick`/`onHover` is **composed**, not clobbered.
   */
  options?: Record<string, any>;
  /**
   * The chart kind — any Chart.js controller (`line`/`bar`/`pie`/`doughnut`/`radar`/`polarArea`/`scatter`/`bubble`/…). Changing it **re-creates** the instance, since Chart.js has no stable runtime type-swap.
   */
  type?: string;
  /**
   * Chart height in pixels, applied to the wrapper's host box; the canvas fills it responsively.
   */
  height?: number;
  /**
   * Optional fixed chart width in pixels. Omit for the default full-width responsive box.
   */
  width?: number;
  /**
   * Per-instance Chart.js `Plugin[]` — the consumer-extensibility passthrough. Merged into the config; changing the array **re-creates** the instance, since Chart.js has no stable runtime plugin-swap.
   */
  plugins?: any[];
  /**
   * The Chart.js `update` mode string used by the in-place data reconcile (e.g. `none` to skip the animation on every data tick).
   */
  updateMode?: string;
  /**
   * When `true`, a `data` change **re-creates** the chart wholesale instead of reconciling in place — mirrors react-chartjs-2 `redraw` for charts whose plugins do not survive an in-place update.
   */
  redraw?: boolean;
  /**
   * Accessible label applied to the `<canvas role="img">`, since canvas charts are otherwise opaque to assistive tech. For richer fallback content, fill the `fallback` slot.
   */
  ariaLabel?: string;
  /**
   * The dataset-identity key (react-chartjs-2 parity). Across data updates, datasets are matched by `dataset[datasetIdKey]`, falling back to array index when the key is absent, so a stable keyed dataset reconciles onto its prior slot even if its index moved — guarding the "first dataset copied over the others" hazard.
   */
  datasetIdKey?: string;
  /**
   * Milliseconds to defer `chart.destroy()` on unmount so an exit transition can finish (vue-chartjs parity). `0` (the default) destroys immediately.
   */
  destroyDelay?: number;
  onClick?: (...args: any[]) => void;
  onDatasetClick?: (...args: any[]) => void;
  onHover?: (...args: any[]) => void;
  renderFallback?: () => ReactNode;
  renderTooltip?: (ctx: TooltipCtx) => ReactNode;
  slots?: Record<string, () => import('react').ReactNode>;
}

export interface ChartHandle {
  getChart: (...args: any[]) => any;
  updateChart: (...args: any[]) => any;
  resizeChart: (...args: any[]) => any;
  resetChart: (...args: any[]) => any;
  renderChart: (...args: any[]) => any;
  stopChart: (...args: any[]) => any;
  clearChart: (...args: any[]) => any;
  toBase64Image: (...args: any[]) => any;
  setDatasetVisibility: (...args: any[]) => any;
  isDatasetVisible: (...args: any[]) => any;
  hideDataset: (...args: any[]) => any;
  showDataset: (...args: any[]) => any;
  setActiveElements: (...args: any[]) => any;
  getActiveElements: (...args: any[]) => any;
  getDatasetMeta: (...args: any[]) => any;
}

const Chart = forwardRef<ChartHandle, ChartProps>(function Chart(_props: ChartProps, ref): JSX.Element {
  const portalRoots = useRef<Set<Root>>(new Set());
  const __defaultData = useState(() => (() => ({
    labels: [],
    datasets: []
  }))())[0];
  const __defaultOptions = useState(() => (() => ({}))())[0];
  const __defaultPlugins = useState(() => (() => [])())[0];
  const props: Omit<ChartProps, 'data' | 'options' | 'type' | 'height' | 'width' | 'plugins' | 'updateMode' | 'redraw' | 'ariaLabel' | 'datasetIdKey' | 'destroyDelay'> & { data: Record<string, any>; options: Record<string, any>; type: string; height: number; width: number; plugins: any[]; updateMode: string; redraw: boolean; ariaLabel: string; datasetIdKey: string; destroyDelay: number } = {
    ..._props,
    data: _props.data ?? __defaultData,
    options: _props.options ?? __defaultOptions,
    type: _props.type ?? 'line',
    height: _props.height ?? 240,
    width: _props.width ?? undefined,
    plugins: _props.plugins ?? __defaultPlugins,
    updateMode: _props.updateMode ?? undefined,
    redraw: _props.redraw ?? false,
    ariaLabel: _props.ariaLabel ?? undefined,
    datasetIdKey: _props.datasetIdKey ?? 'label',
    destroyDelay: _props.destroyDelay ?? 0,
  };
  const _renderTooltipRef = useRef(props.renderTooltip);
  _renderTooltipRef.current = props.renderTooltip;
  const canvasNode = useRef<any>(null);
  const tooltipEl = useRef<any>(null);
  const tooltipDispose = useRef<any>(null);
  const buildConfig = useRef<any>(null);
  const instance = useRef<any>(null);
  const _dataRef = useRef(props.data);
  _dataRef.current = props.data;
  const _optionsRef = useRef(props.options);
  _optionsRef.current = props.options;
  const _pluginsRef = useRef(props.plugins);
  _pluginsRef.current = props.plugins;
  const _typeRef = useRef(props.type);
  _typeRef.current = props.type;
  const canvasEl = useRef<HTMLCanvasElement | null>(null);
  const _watch1First = useRef(true);
  const _watch2First = useRef(true);
  const _watch3First = useRef(true);

  function recreate() {
    if (!buildConfig.current || !canvasNode.current) return;
    instance.current?.destroy();
    instance.current = new ChartJS(canvasNode.current, buildConfig.current());
  }
  // Imperative handle (Phase 21 $expose). The lifecycle/redraw verbs are SUFFIXED
  // with `Chart` because bare `update`/`render` collide with LitElement's
  // reactive-lifecycle methods (`update(changedProperties)` / `render()`) and
  // would shadow them on the Lit leaf; `resize`/`reset`/`stop`/`clear` are
  // suffixed too for a consistent, unambiguous handle. `getChart` returns the live
  // instance for direct API access; `toBase64Image` is the marquee PNG-export.
  //
  // The visibility + active-element family (added later) is the #1 reason a
  // consumer reaches for a chart handle — custom legends, externally-driven
  // tooltips/highlights, overlay positioning — none reachable via prop/event:
  //   - setDatasetVisibility / isDatasetVisible: drive a custom legend's series
  //     show/hide (INSTANT toggle).
  //   - hideDataset / showDataset: the ANIMATED hide/show (Chart.js hide()/show()).
  //     SUFFIXED with `Dataset` both to dodge inherited HTMLElement-ish ambiguity
  //     and to disambiguate the dataset-vs-element overload.
  //   - setActiveElements / getActiveElements: programmatically open/read the
  //     hovered/active points (sync hover from a table row, a map pin, a sibling
  //     chart) — events only REPORT hover, they cannot SET it.
  //   - getDatasetMeta: read computed geometry (pixel coords, controller) to
  //     position custom overlays/annotations over the canvas.
  // Collision-clear: none of the 15 names collide with the 11 props or the 3
  // emits (click/datasetClick/hover).
  function getChart() {
    return instance.current;
  }
  function updateChart(mode: any) {
    instance.current?.update(mode);
  }
  function resizeChart(w: any, h: any) {
    instance.current?.resize(w, h);
  }
  function resetChart() {
    instance.current?.reset();
  }
  function renderChart() {
    instance.current?.render();
  }
  function stopChart() {
    return instance.current?.stop();
  }
  function clearChart() {
    return instance.current?.clear();
  }
  function toBase64Image(type: any, quality: any) {
    return instance.current ? instance.current.toBase64Image(type, quality) : null;
  }
  function setDatasetVisibility(datasetIndex: any, visible: any) {
    instance.current?.setDatasetVisibility(datasetIndex, visible);
  }
  function isDatasetVisible(datasetIndex: any) {
    return instance.current ? instance.current.isDatasetVisible(datasetIndex) : false;
  }
  function hideDataset(datasetIndex: any, dataIndex: any) {
    instance.current?.hide(datasetIndex, dataIndex);
  }
  function showDataset(datasetIndex: any, dataIndex: any) {
    instance.current?.show(datasetIndex, dataIndex);
  }
  function setActiveElements(elements: any) {
    instance.current?.setActiveElements(elements ?? []);
  }
  function getActiveElements() {
    return instance.current ? instance.current.getActiveElements() : [];
  }
  function getDatasetMeta(datasetIndex: any) {
    return instance.current ? instance.current.getDatasetMeta(datasetIndex) : null;
  }

  useEffect(() => {
    const portals = {
    tooltip: (container: HTMLElement, scope: { model: unknown }): (() => void) => {
      const slot = _renderTooltipRef.current ?? props.slots?.['tooltip'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      // Cascades the @portal tooltip { … } selectors from the
      // component's .module.css into the engine-owned subtree.
      container.setAttribute('data-rozie-portal-tooltip', '2228fabc');
      const root = createRoot(container);
      flushSync(() => root.render(slot(scope)));
      portalRoots.current.add(root);
      return () => {
        root.unmount();
        portalRoots.current.delete(root);
      };
    },
  };
    canvasNode.current = canvasEl.current;

    // ─── @click / @hover / @datasetClick — composed, never clobbering ──────────
    // Chart.js calls onClick/onHover with (event, activeElements, chart). We call
    // any consumer-supplied handler first (read off $props.options), then emit a
    // structured payload resolving the hit element(s) via getElementsAtEventForMode.
    const composedOnClick = (e: any, activeEls: any, chart: any) => {
      const userOnClick = _optionsRef.current?.onClick;
      if (typeof userOnClick === 'function') userOnClick(e, activeEls, chart);
      const nearest = chart.getElementsAtEventForMode(e, 'nearest', {
        intersect: true
      }, false);
      props.onClick && props.onClick({
        event: e,
        elements: nearest,
        chart
      });
      const dataset = chart.getElementsAtEventForMode(e, 'dataset', {
        intersect: true
      }, false);
      if (dataset.length) {
        props.onDatasetClick && props.onDatasetClick({
          event: e,
          elements: dataset,
          datasetIndex: dataset[0].datasetIndex,
          chart
        });
      }
    };
    const composedOnHover = (e: any, activeEls: any, chart: any) => {
      const userOnHover = _optionsRef.current?.onHover;
      if (typeof userOnHover === 'function') userOnHover(e, activeEls, chart);
      props.onHover && props.onHover({
        event: e,
        elements: activeEls,
        chart
      });
    };

    // ─── external-HTML tooltip portal slot ─────────────────────────────────────
    // Only active when the consumer fills <slot name="tooltip">. The external
    // handler positions a container over the canvas and mounts the consumer's
    // framework-native fragment through $portals.tooltip(dom, scope). The scope
    // carries the live tooltip model (title/body/dataPoints/position). Chart.js
    // throttles external calls to active-element changes, so the dispose+remount
    // on body-change is cheap. enabled:false suppresses the built-in canvas
    // tooltip when we take over.
    let tooltipKey = '';
    const tooltipExternal = (context: any) => {
      const {
        chart,
        tooltip
      } = context;
      if (!tooltipEl.current) {
        tooltipEl.current = document.createElement('div');
        tooltipEl.current.className = 'rozie-chart-tooltip';
        tooltipEl.current.style.position = 'absolute';
        tooltipEl.current.style.pointerEvents = 'none';
        tooltipEl.current.style.transition = 'opacity 0.1s ease';
        chart.canvas.parentNode.appendChild(tooltipEl.current);
      }
      if (tooltip.opacity === 0) {
        tooltipEl.current.style.opacity = '0';
        return;
      }
      const title = (tooltip.title || []).join(' ');
      const body = (tooltip.body || []).map((b: any) => b.lines.join(' ')).join(' | ');
      const key = `${title}::${body}`;
      if (key !== tooltipKey) {
        tooltipKey = key;
        tooltipDispose.current?.();
        // The scope MUST match the slot's declared param (`model`): the consumer's
        // <slot name="tooltip"> receives a single `model` scoped value.
        const scope = {
          model: {
            title: tooltip.title || [],
            body: (tooltip.body || []).map((b: any) => b.lines),
            dataPoints: tooltip.dataPoints || [],
            opacity: tooltip.opacity
          }
        };
        tooltipDispose.current = portals.tooltip(tooltipEl.current, scope);
      }
      const {
        offsetLeft,
        offsetTop
      } = chart.canvas;
      tooltipEl.current.style.opacity = '1';
      tooltipEl.current.style.left = `${offsetLeft + tooltip.caretX}px`;
      tooltipEl.current.style.top = `${offsetTop + tooltip.caretY}px`;
    };

    // ─── config builder ────────────────────────────────────────────────────────
    // $snapshot strips Svelte 5's $state proxy first; Chart.js redefines property
    // descriptors on whatever object it is handed.
    buildConfig.current = () => {
      const userOpts = _optionsRef.current || {};
      const tooltipOpt = (props.renderTooltip ?? props.slots?.["tooltip"]) ? {
        ...(userOpts.plugins?.tooltip || {}),
        enabled: false,
        external: tooltipExternal
      } : userOpts.plugins?.tooltip;
      return {
        type: _typeRef.current,
        data: _dataRef.current,
        // per-instance plugins[] — the consumer-extensibility passthrough.
        plugins: _pluginsRef.current,
        options: {
          responsive: true,
          maintainAspectRatio: false,
          animation: {
            duration: 250
          },
          ...userOpts,
          onClick: composedOnClick,
          onHover: composedOnHover,
          plugins: {
            ...(userOpts.plugins || {}),
            tooltip: tooltipOpt
          }
        }
      };
    };
    instance.current = new ChartJS(canvasNode.current, buildConfig.current());
    return () => {
      for (const root of portalRoots.current) root.unmount();
  portalRoots.current.clear();
      tooltipDispose.current?.();
      tooltipEl.current?.remove();
      // destroyDelay (vue-chartjs parity): defer destroy() so any exit transition
      // can finish. The captured `dying` instance is destroyed after the grace;
      // 0 (default) destroys synchronously.
      const dying = instance.current;
      if (props.destroyDelay > 0) {
        setTimeout(() => dying?.destroy(), props.destroyDelay);
      } else {
        dying?.destroy();
      }
    };
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
  useEffect(() => {
    const v = props.data;
    if (!instance.current) return;
    if (props.redraw) {
      recreate();
      return;
    }

    // Reconcile a new data object into the LIVE chart instead of replacing
    // instance.data. Chart.js matches dataset controllers and point elements by
    // array index across an update(), so mutating the existing labels/datasets
    // arrays lets it tween every point from old value to new. Assigning a fresh
    // instance.data severs that identity.
    const next = v;
    const live = instance.current.data;

    // Aliasing guard. On identity-$snapshot targets (React / Solid / Lit) $snapshot
    // returns its argument unchanged, and Chart.js stores config.data by reference,
    // so a freshly-constructed chart has `instance.data === $props.data === next`.
    // The in-place `live.labels.length = 0` below would then empty the very array we
    // read from on the next line (`next.labels` IS `live.labels`), wiping the labels
    // → cartesian charts lose their category axis and render an empty plot (the
    // doughnut, being radial, survives — it doesn't position by label). When live and
    // next alias there is nothing to reconcile: the chart already holds this data, so
    // just repaint. (Vue/Angular never hit this — their immediate $watch runs before
    // $onMount, when instance is still null.)
    if (live === next) {
      instance.current.update(props.updateMode);
      return;
    }
    live.labels ??= [];
    live.labels.length = 0;
    live.labels.push(...(next.labels ?? []));

    // Datasets are matched by `ds[datasetIdKey]` (default 'label') so a stable
    // keyed dataset reconciles onto its prior slot even if its array index moved —
    // this guards the "first dataset copied over the others" hazard react-chartjs-2
    // documents. Datasets without the key fall back to positional (index) matching.
    live.datasets ??= [];
    const nextSets = next.datasets ?? [];
    const key = props.datasetIdKey;
    const prev = live.datasets.slice();
    const byKey = new Map();
    prev.forEach((ds: any, i: any) => {
      if (ds && ds[key] != null) byKey.set(ds[key], ds);
    });
    const merged = nextSets.map((ds: any, i: any) => {
      const match = ds && ds[key] != null && byKey.get(ds[key]) || prev[i];
      if (match) {
        Object.assign(match, ds);
        return match;
      }
      return ds;
    });
    live.datasets.length = 0;
    live.datasets.push(...merged);
    instance.current.update(props.updateMode);
  }, [props.data]); // eslint-disable-line react-hooks/exhaustive-deps
  useEffect(() => {
    if (_watch1First.current) { _watch1First.current = false; return; }
    if (!instance.current || !buildConfig.current) return;
    instance.current.options = buildConfig.current().options;
    instance.current.update('none');
  }, [props.options]);
  useEffect(() => {
    if (_watch2First.current) { _watch2First.current = false; return; }
    recreate();
  }, [props.type]); // eslint-disable-line react-hooks/exhaustive-deps
  useEffect(() => {
    if (_watch3First.current) { _watch3First.current = false; return; }
    recreate();
  }, [props.plugins]); // eslint-disable-line react-hooks/exhaustive-deps

  const _rozieExposeRef = useRef({ getChart, updateChart, resizeChart, resetChart, renderChart, stopChart, clearChart, toBase64Image, setDatasetVisibility, isDatasetVisible, hideDataset, showDataset, setActiveElements, getActiveElements, getDatasetMeta });
  _rozieExposeRef.current = { getChart, updateChart, resizeChart, resetChart, renderChart, stopChart, clearChart, toBase64Image, setDatasetVisibility, isDatasetVisible, hideDataset, showDataset, setActiveElements, getActiveElements, getDatasetMeta };
  useImperativeHandle(ref, () => ({ getChart: (...args: Parameters<typeof getChart>): ReturnType<typeof getChart> => _rozieExposeRef.current.getChart(...args), updateChart: (...args: Parameters<typeof updateChart>): ReturnType<typeof updateChart> => _rozieExposeRef.current.updateChart(...args), resizeChart: (...args: Parameters<typeof resizeChart>): ReturnType<typeof resizeChart> => _rozieExposeRef.current.resizeChart(...args), resetChart: (...args: Parameters<typeof resetChart>): ReturnType<typeof resetChart> => _rozieExposeRef.current.resetChart(...args), renderChart: (...args: Parameters<typeof renderChart>): ReturnType<typeof renderChart> => _rozieExposeRef.current.renderChart(...args), stopChart: (...args: Parameters<typeof stopChart>): ReturnType<typeof stopChart> => _rozieExposeRef.current.stopChart(...args), clearChart: (...args: Parameters<typeof clearChart>): ReturnType<typeof clearChart> => _rozieExposeRef.current.clearChart(...args), toBase64Image: (...args: Parameters<typeof toBase64Image>): ReturnType<typeof toBase64Image> => _rozieExposeRef.current.toBase64Image(...args), setDatasetVisibility: (...args: Parameters<typeof setDatasetVisibility>): ReturnType<typeof setDatasetVisibility> => _rozieExposeRef.current.setDatasetVisibility(...args), isDatasetVisible: (...args: Parameters<typeof isDatasetVisible>): ReturnType<typeof isDatasetVisible> => _rozieExposeRef.current.isDatasetVisible(...args), hideDataset: (...args: Parameters<typeof hideDataset>): ReturnType<typeof hideDataset> => _rozieExposeRef.current.hideDataset(...args), showDataset: (...args: Parameters<typeof showDataset>): ReturnType<typeof showDataset> => _rozieExposeRef.current.showDataset(...args), setActiveElements: (...args: Parameters<typeof setActiveElements>): ReturnType<typeof setActiveElements> => _rozieExposeRef.current.setActiveElements(...args), getActiveElements: (...args: Parameters<typeof getActiveElements>): ReturnType<typeof getActiveElements> => _rozieExposeRef.current.getActiveElements(...args), getDatasetMeta: (...args: Parameters<typeof getDatasetMeta>): ReturnType<typeof getDatasetMeta> => _rozieExposeRef.current.getDatasetMeta(...args) }), []);

  return (
    <>
    <div className={"rozie-chart"} style={{ height: props.height + 'px', width: props.width ? props.width + 'px' : undefined }} data-rozie-s-2228fabc="">
      
      <canvas ref={canvasEl} role="img" aria-label={props.ariaLabel} data-rozie-s-2228fabc="">{(props.renderFallback ?? props.slots?.['fallback'])?.()}</canvas>
    </div>


    </>
  );
});
export default Chart;
svelte
<script lang="ts">
import type { Snippet } from 'svelte';
import { mount, unmount } from 'svelte';
import PortalHost from '@rozie/runtime-svelte/PortalHost.svelte';
import { onMount, untrack } from 'svelte';

interface Props {
  /**
   * Chart.js data in its own `{ labels, datasets }` shape. Reconciled **in place** on change — the wrapper mutates `chart.data` and calls `chart.update()` so series tween point-to-point instead of remounting.
   * @example
   * <Chart :data="$data.chartData" type="bar" />
   */
  data?: any;
  /**
   * Chart.js options (scales, legend, plugins config, …). Merged over the wrapper's responsive defaults and reapplied wholesale on change with `update('none')`. A consumer-supplied `options.onClick`/`onHover` is **composed**, not clobbered.
   */
  options?: any;
  /**
   * The chart kind — any Chart.js controller (`line`/`bar`/`pie`/`doughnut`/`radar`/`polarArea`/`scatter`/`bubble`/…). Changing it **re-creates** the instance, since Chart.js has no stable runtime type-swap.
   */
  type?: string;
  /**
   * Chart height in pixels, applied to the wrapper's host box; the canvas fills it responsively.
   */
  height?: number;
  /**
   * Optional fixed chart width in pixels. Omit for the default full-width responsive box.
   */
  width?: number;
  /**
   * Per-instance Chart.js `Plugin[]` — the consumer-extensibility passthrough. Merged into the config; changing the array **re-creates** the instance, since Chart.js has no stable runtime plugin-swap.
   */
  plugins?: any[];
  /**
   * The Chart.js `update` mode string used by the in-place data reconcile (e.g. `none` to skip the animation on every data tick).
   */
  updateMode?: string;
  /**
   * When `true`, a `data` change **re-creates** the chart wholesale instead of reconciling in place — mirrors react-chartjs-2 `redraw` for charts whose plugins do not survive an in-place update.
   */
  redraw?: boolean;
  /**
   * Accessible label applied to the `<canvas role="img">`, since canvas charts are otherwise opaque to assistive tech. For richer fallback content, fill the `fallback` slot.
   */
  ariaLabel?: string;
  /**
   * The dataset-identity key (react-chartjs-2 parity). Across data updates, datasets are matched by `dataset[datasetIdKey]`, falling back to array index when the key is absent, so a stable keyed dataset reconciles onto its prior slot even if its index moved — guarding the "first dataset copied over the others" hazard.
   */
  datasetIdKey?: string;
  /**
   * Milliseconds to defer `chart.destroy()` on unmount so an exit transition can finish (vue-chartjs parity). `0` (the default) destroys immediately.
   */
  destroyDelay?: number;
  fallback?: Snippet;
  tooltip?: Snippet<[{ model: any }]>;
  snippets?: Record<string, any>;
  onclick?: (...args: unknown[]) => void;
  ondatasetclick?: (...args: unknown[]) => void;
  onhover?: (...args: unknown[]) => void;
}

let __defaultData = (() => ({
  labels: [],
  datasets: []
}))();
let __defaultOptions = (() => ({}))();
let __defaultPlugins = (() => [])();

let {
  data = __defaultData,
  options = __defaultOptions,
  type = 'line',
  height = 240,
  width = undefined,
  plugins = __defaultPlugins,
  updateMode = undefined,
  redraw = false,
  ariaLabel = undefined,
  datasetIdKey = 'label',
  destroyDelay = 0,
  fallback: __fallbackProp,
  tooltip: __tooltipProp,
  snippets,
  onclick,
  ondatasetclick,
  onhover
}: Props = $props();

const fallback = $derived(__fallbackProp ?? snippets?.fallback);
const tooltip = $derived(__tooltipProp ?? snippets?.tooltip);

let canvasEl = $state<HTMLElement | undefined>(undefined);

import { Chart as ChartJS } from 'chart.js';

// Chart.js v3+ ships with no controllers/elements/scales pre-registered. The
// generic Chart does NOT auto-register — the consumer registers only what they
// use (the tree-shakable Chart.js v3+ idiom every framework wrapper follows), so
// an app that only renders line charts doesn't ship every controller. Two paths:
//   - selective: `import { Chart, LineController, ... } from 'chart.js';
//     Chart.register(LineController, ...)` once at app startup; OR
//   - kitchen sink: import this package's `/auto` entry
//     (`@rozie-ui/chartjs-<fw>/auto`), or `import 'chart.js/auto'`, which
//     registers everything.
// The per-type components (Line/Bar/…) register their own controller set, so
// importing one is tree-shakable by construction.
// Chart.js v3+ ships with no controllers/elements/scales pre-registered. The
// generic Chart does NOT auto-register — the consumer registers only what they
// use (the tree-shakable Chart.js v3+ idiom every framework wrapper follows), so
// an app that only renders line charts doesn't ship every controller. Two paths:
//   - selective: `import { Chart, LineController, ... } from 'chart.js';
//     Chart.register(LineController, ...)` once at app startup; OR
//   - kitchen sink: import this package's `/auto` entry
//     (`@rozie-ui/chartjs-<fw>/auto`), or `import 'chart.js/auto'`, which
//     registers everything.
// The per-type components (Line/Bar/…) register their own controller set, so
// importing one is tree-shakable by construction.

let instance: any = null;
// $refs.canvasEl is read ONLY inside $onMount (ROZ123); re-creates use this
// captured node so no $refs read ever executes outside the mount hook. Named
// `canvasNode` (NOT `canvasEl`) so it does not collide with the template
// `ref="canvasEl"` binding, which the per-target emitters lower to their own
// `canvasEl` ref declaration (a same-name script local double-declares it).
// $refs.canvasEl is read ONLY inside $onMount (ROZ123); re-creates use this
// captured node so no $refs read ever executes outside the mount hook. Named
// `canvasNode` (NOT `canvasEl`) so it does not collide with the template
// `ref="canvasEl"` binding, which the per-target emitters lower to their own
// `canvasEl` ref declaration (a same-name script local double-declares it).
let canvasNode: any = null;
// Tooltip-portal teardown state at COMPONENT scope (not inside $onMount): the
// $onMount-returned cleanup references these, and the Solid emitter hoists that
// returned cleanup into a sibling onCleanup() OUTSIDE the mount-body IIFE — so a
// cleanup that closed over $onMount-locals would lose scope. Component-scope
// state (like `instance`) is in scope wherever the per-target cleanup lands.
// Tooltip-portal teardown state at COMPONENT scope (not inside $onMount): the
// $onMount-returned cleanup references these, and the Solid emitter hoists that
// returned cleanup into a sibling onCleanup() OUTSIDE the mount-body IIFE — so a
// cleanup that closed over $onMount-locals would lose scope. Component-scope
// state (like `instance`) is in scope wherever the per-target cleanup lands.
let tooltipEl: any = null;
let tooltipDispose: any = null;
// buildConfig is DEFINED inside $onMount (so its $emit/$portals/$slots
// references are bound in the mount-lifecycle scope the per-target emitters
// provide — mirrors FullCalendar's mount-built opts + CodeMirror's panelExt
// note) and stored here so the top-level re-create $watches can call it.
// buildConfig is DEFINED inside $onMount (so its $emit/$portals/$slots
// references are bound in the mount-lifecycle scope the per-target emitters
// provide — mirrors FullCalendar's mount-built opts + CodeMirror's panelExt
// note) and stored here so the top-level re-create $watches can call it.
let buildConfig: any = null;
// Re-create the live instance. Chart.js exposes no stable runtime type-swap or
// plugin-swap, so `type`/`plugins`/`redraw`-driven changes re-create. Uses the
// captured canvasNode (never re-reads $refs outside $onMount).
const recreate = () => {
  if (!buildConfig || !canvasNode) return;
  instance?.destroy();
  instance = new ChartJS(canvasNode, buildConfig());
};

// Reconcile prop changes. Mutating chart.data in place and calling update() is
// the Chart.js-supported runtime path — re-creating on every data tick would
// flicker and leak. (When `redraw` is set, re-create wholesale instead.)
// Imperative handle (Phase 21 $expose). The lifecycle/redraw verbs are SUFFIXED
// with `Chart` because bare `update`/`render` collide with LitElement's
// reactive-lifecycle methods (`update(changedProperties)` / `render()`) and
// would shadow them on the Lit leaf; `resize`/`reset`/`stop`/`clear` are
// suffixed too for a consistent, unambiguous handle. `getChart` returns the live
// instance for direct API access; `toBase64Image` is the marquee PNG-export.
//
// The visibility + active-element family (added later) is the #1 reason a
// consumer reaches for a chart handle — custom legends, externally-driven
// tooltips/highlights, overlay positioning — none reachable via prop/event:
//   - setDatasetVisibility / isDatasetVisible: drive a custom legend's series
//     show/hide (INSTANT toggle).
//   - hideDataset / showDataset: the ANIMATED hide/show (Chart.js hide()/show()).
//     SUFFIXED with `Dataset` both to dodge inherited HTMLElement-ish ambiguity
//     and to disambiguate the dataset-vs-element overload.
//   - setActiveElements / getActiveElements: programmatically open/read the
//     hovered/active points (sync hover from a table row, a map pin, a sibling
//     chart) — events only REPORT hover, they cannot SET it.
//   - getDatasetMeta: read computed geometry (pixel coords, controller) to
//     position custom overlays/annotations over the canvas.
// Collision-clear: none of the 15 names collide with the 11 props or the 3
// emits (click/datasetClick/hover).
export function getChart() {
  return instance;
}
export function updateChart(mode: any) {
  instance?.update(mode);
}
export function resizeChart(w: any, h: any) {
  instance?.resize(w, h);
}
export function resetChart() {
  instance?.reset();
}
export function renderChart() {
  instance?.render();
}
export function stopChart() {
  return instance?.stop();
}
export function clearChart() {
  return instance?.clear();
}
export function toBase64Image(type: any, quality: any) {
  return instance ? instance.toBase64Image(type, quality) : null;
}
export function setDatasetVisibility(datasetIndex: any, visible: any) {
  instance?.setDatasetVisibility(datasetIndex, visible);
}
export function isDatasetVisible(datasetIndex: any) {
  return instance ? instance.isDatasetVisible(datasetIndex) : false;
}
export function hideDataset(datasetIndex: any, dataIndex: any) {
  instance?.hide(datasetIndex, dataIndex);
}
export function showDataset(datasetIndex: any, dataIndex: any) {
  instance?.show(datasetIndex, dataIndex);
}
export function setActiveElements(elements: any) {
  instance?.setActiveElements(elements ?? []);
}
export function getActiveElements() {
  return instance ? instance.getActiveElements() : [];
}
export function getDatasetMeta(datasetIndex: any) {
  return instance ? instance.getDatasetMeta(datasetIndex) : null;
}

const portalInstances = new Set<Record<string, unknown>>();
const portals = {
  tooltip: (container: HTMLElement, scope: { model: unknown }): (() => void) => {
    if (!tooltip) return () => {};
    // Spike 004: portal-scope attribute injection.
    container.setAttribute('data-rozie-portal-tooltip', '2228fabc');
    const inst = mount(PortalHost, {
      target: container,
      props: { snippet: tooltip, scope },
    });
    portalInstances.add(inst as Record<string, unknown>);
    return () => {
      unmount(inst);
      portalInstances.delete(inst as Record<string, unknown>);
    };
  },
};
$effect(() => () => {
  for (const inst of portalInstances) unmount(inst as Parameters<typeof unmount>[0]);
  portalInstances.clear();
});

onMount(() => {
  canvasNode = canvasEl;

  // ─── @click / @hover / @datasetClick — composed, never clobbering ──────────
  // Chart.js calls onClick/onHover with (event, activeElements, chart). We call
  // any consumer-supplied handler first (read off $props.options), then emit a
  // structured payload resolving the hit element(s) via getElementsAtEventForMode.
  const composedOnClick = (e: any, activeEls: any, chart: any) => {
    const userOnClick = options?.onClick;
    if (typeof userOnClick === 'function') userOnClick(e, activeEls, chart);
    const nearest = chart.getElementsAtEventForMode(e, 'nearest', {
      intersect: true
    }, false);
    onclick?.({
      event: e,
      elements: nearest,
      chart
    });
    const dataset = chart.getElementsAtEventForMode(e, 'dataset', {
      intersect: true
    }, false);
    if (dataset.length) {
      ondatasetclick?.({
        event: e,
        elements: dataset,
        datasetIndex: dataset[0].datasetIndex,
        chart
      });
    }
  };
  const composedOnHover = (e: any, activeEls: any, chart: any) => {
    const userOnHover = options?.onHover;
    if (typeof userOnHover === 'function') userOnHover(e, activeEls, chart);
    onhover?.({
      event: e,
      elements: activeEls,
      chart
    });
  };

  // ─── external-HTML tooltip portal slot ─────────────────────────────────────
  // Only active when the consumer fills <slot name="tooltip">. The external
  // handler positions a container over the canvas and mounts the consumer's
  // framework-native fragment through $portals.tooltip(dom, scope). The scope
  // carries the live tooltip model (title/body/dataPoints/position). Chart.js
  // throttles external calls to active-element changes, so the dispose+remount
  // on body-change is cheap. enabled:false suppresses the built-in canvas
  // tooltip when we take over.
  let tooltipKey = '';
  const tooltipExternal = (context: any) => {
    const {
      chart,
      tooltip
    } = context;
    if (!tooltipEl) {
      tooltipEl = document.createElement('div');
      tooltipEl.className = 'rozie-chart-tooltip';
      tooltipEl.style.position = 'absolute';
      tooltipEl.style.pointerEvents = 'none';
      tooltipEl.style.transition = 'opacity 0.1s ease';
      chart.canvas.parentNode.appendChild(tooltipEl);
    }
    if (tooltip.opacity === 0) {
      tooltipEl.style.opacity = '0';
      return;
    }
    const title = (tooltip.title || []).join(' ');
    const body = (tooltip.body || []).map((b: any) => b.lines.join(' ')).join(' | ');
    const key = `${title}::${body}`;
    if (key !== tooltipKey) {
      tooltipKey = key;
      tooltipDispose?.();
      // The scope MUST match the slot's declared param (`model`): the consumer's
      // <slot name="tooltip"> receives a single `model` scoped value.
      const scope = {
        model: {
          title: tooltip.title || [],
          body: (tooltip.body || []).map((b: any) => b.lines),
          dataPoints: tooltip.dataPoints || [],
          opacity: tooltip.opacity
        }
      };
      tooltipDispose = portals.tooltip(tooltipEl, scope);
    }
    const {
      offsetLeft,
      offsetTop
    } = chart.canvas;
    tooltipEl.style.opacity = '1';
    tooltipEl.style.left = `${offsetLeft + tooltip.caretX}px`;
    tooltipEl.style.top = `${offsetTop + tooltip.caretY}px`;
  };

  // ─── config builder ────────────────────────────────────────────────────────
  // $snapshot strips Svelte 5's $state proxy first; Chart.js redefines property
  // descriptors on whatever object it is handed.
  buildConfig = () => {
    const userOpts = $state.snapshot(options) || {};
    const tooltipOpt = tooltip ? {
      ...(userOpts.plugins?.tooltip || {}),
      enabled: false,
      external: tooltipExternal
    } : userOpts.plugins?.tooltip;
    return {
      type: type,
      data: $state.snapshot(data),
      // per-instance plugins[] — the consumer-extensibility passthrough.
      plugins: $state.snapshot(plugins),
      options: {
        responsive: true,
        maintainAspectRatio: false,
        animation: {
          duration: 250
        },
        ...userOpts,
        onClick: composedOnClick,
        onHover: composedOnHover,
        plugins: {
          ...(userOpts.plugins || {}),
          tooltip: tooltipOpt
        }
      }
    };
  };
  instance = new ChartJS(canvasNode, buildConfig());
  return () => {
    tooltipDispose?.();
    tooltipEl?.remove();
    // destroyDelay (vue-chartjs parity): defer destroy() so any exit transition
    // can finish. The captured `dying` instance is destroyed after the grace;
    // 0 (default) destroys synchronously.
    const dying = instance;
    if (destroyDelay > 0) {
      setTimeout(() => dying?.destroy(), destroyDelay);
    } else {
      dying?.destroy();
    }
  };
});

$effect(() => { const __watchVal = (() => data)(); untrack(() => ((v: any) => {
  if (!instance) return;
  if (redraw) {
    recreate();
    return;
  }

  // Reconcile a new data object into the LIVE chart instead of replacing
  // instance.data. Chart.js matches dataset controllers and point elements by
  // array index across an update(), so mutating the existing labels/datasets
  // arrays lets it tween every point from old value to new. Assigning a fresh
  // instance.data severs that identity.
  const next = $state.snapshot(v);
  const live = instance.data;

  // Aliasing guard. On identity-$snapshot targets (React / Solid / Lit) $snapshot
  // returns its argument unchanged, and Chart.js stores config.data by reference,
  // so a freshly-constructed chart has `instance.data === $props.data === next`.
  // The in-place `live.labels.length = 0` below would then empty the very array we
  // read from on the next line (`next.labels` IS `live.labels`), wiping the labels
  // → cartesian charts lose their category axis and render an empty plot (the
  // doughnut, being radial, survives — it doesn't position by label). When live and
  // next alias there is nothing to reconcile: the chart already holds this data, so
  // just repaint. (Vue/Angular never hit this — their immediate $watch runs before
  // $onMount, when instance is still null.)
  if (live === next) {
    instance.update(updateMode);
    return;
  }
  live.labels ??= [];
  live.labels.length = 0;
  live.labels.push(...(next.labels ?? []));

  // Datasets are matched by `ds[datasetIdKey]` (default 'label') so a stable
  // keyed dataset reconciles onto its prior slot even if its array index moved —
  // this guards the "first dataset copied over the others" hazard react-chartjs-2
  // documents. Datasets without the key fall back to positional (index) matching.
  live.datasets ??= [];
  const nextSets = next.datasets ?? [];
  const key = datasetIdKey;
  const prev = live.datasets.slice();
  const byKey = new Map();
  prev.forEach((ds: any, i: any) => {
    if (ds && ds[key] != null) byKey.set(ds[key], ds);
  });
  const merged = nextSets.map((ds: any, i: any) => {
    const match = ds && ds[key] != null && byKey.get(ds[key]) || prev[i];
    if (match) {
      Object.assign(match, ds);
      return match;
    }
    return ds;
  });
  live.datasets.length = 0;
  live.datasets.push(...merged);
  instance.update(updateMode);
})(__watchVal)); });
let __rozieWatchInitial_1 = true;
$effect(() => { (() => options)(); untrack(() => { if (__rozieWatchInitial_1) { __rozieWatchInitial_1 = false; return; } (() => {
  if (!instance || !buildConfig) return;
  instance.options = buildConfig().options;
  instance.update('none');
})(); }); });
let __rozieWatchInitial_2 = true;
$effect(() => { (() => type)(); untrack(() => { if (__rozieWatchInitial_2) { __rozieWatchInitial_2 = false; return; } (() => recreate())(); }); });
let __rozieWatchInitial_3 = true;
$effect(() => { (() => plugins)(); untrack(() => { if (__rozieWatchInitial_3) { __rozieWatchInitial_3 = false; return; } (() => recreate())(); }); });
</script>

<div class="rozie-chart" style:height={height + 'px'} style:width={width ? width + 'px' : undefined} data-rozie-s-2228fabc><canvas bind:this={canvasEl} role="img" aria-label={ariaLabel} data-rozie-s-2228fabc>{@render fallback?.()}</canvas></div>

<style>
:global {
  .rozie-chart[data-rozie-s-2228fabc] {
    position: relative;
    width: 100%;
  }
  .rozie-chart[data-rozie-s-2228fabc] canvas[data-rozie-s-2228fabc] {
    display: block;
    width: 100% !important;
    height: 100% !important;
  }
}

:global {
  .rozie-chart .rozie-chart-tooltip {
      background: rgba(0, 0, 0, 0.8);
      color: #fff;
      border-radius: 4px;
      padding: 6px 8px;
      font-size: 12px;
      transform: translate(-50%, calc(-100% - 8px));
      white-space: nowrap;
    }
}
</style>
ts
import { Component, ContentChild, DestroyRef, ElementRef, EmbeddedViewRef, TemplateRef, ViewContainerRef, ViewEncapsulation, contentChild, effect, inject, input, output, untracked, viewChild } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';

import { Chart as ChartJS } from 'chart.js';

// Chart.js v3+ ships with no controllers/elements/scales pre-registered. The
// generic Chart does NOT auto-register — the consumer registers only what they
// use (the tree-shakable Chart.js v3+ idiom every framework wrapper follows), so
// an app that only renders line charts doesn't ship every controller. Two paths:
//   - selective: `import { Chart, LineController, ... } from 'chart.js';
//     Chart.register(LineController, ...)` once at app startup; OR
//   - kitchen sink: import this package's `/auto` entry
//     (`@rozie-ui/chartjs-<fw>/auto`), or `import 'chart.js/auto'`, which
//     registers everything.
// The per-type components (Line/Bar/…) register their own controller set, so
// importing one is tree-shakable by construction.

interface FallbackCtx {}

interface TooltipCtx {
  $implicit: { model: any };
  model: any;
}

@Component({
  selector: 'rozie-chart',
  standalone: true,
  imports: [NgTemplateOutlet],
  template: `

    <div class="rozie-chart" [style]="__style">
      
      <canvas #canvasEl role="img" [attr.aria-label]="ariaLabel()"><ng-container *ngTemplateOutlet="(fallbackTpl ?? templates()?.['fallback'])" /></canvas>
    </div>


    <ng-container #rozie_portalAnchor></ng-container>
  `,
  styles: [`
    .rozie-chart {
      position: relative;
      width: 100%;
    }
    .rozie-chart canvas {
      display: block;
      width: 100% !important;
      height: 100% !important;
    }

    ::ng-deep .rozie-chart .rozie-chart-tooltip {
        background: rgba(0, 0, 0, 0.8);
        color: #fff;
        border-radius: 4px;
        padding: 6px 8px;
        font-size: 12px;
        transform: translate(-50%, calc(-100% - 8px));
        white-space: nowrap;
      }
  `],
})
export class Chart {
  /**
   * Chart.js data in its own `{ labels, datasets }` shape. Reconciled **in place** on change — the wrapper mutates `chart.data` and calls `chart.update()` so series tween point-to-point instead of remounting.
   * @example
   * <Chart :data="$data.chartData" type="bar" />
   */
  data = input<Record<string, any>>((() => ({
    labels: [],
    datasets: []
  }))());
  /**
   * Chart.js options (scales, legend, plugins config, …). Merged over the wrapper's responsive defaults and reapplied wholesale on change with `update('none')`. A consumer-supplied `options.onClick`/`onHover` is **composed**, not clobbered.
   */
  options = input<Record<string, any>>((() => ({}))());
  /**
   * The chart kind — any Chart.js controller (`line`/`bar`/`pie`/`doughnut`/`radar`/`polarArea`/`scatter`/`bubble`/…). Changing it **re-creates** the instance, since Chart.js has no stable runtime type-swap.
   */
  type = input<string>('line');
  /**
   * Chart height in pixels, applied to the wrapper's host box; the canvas fills it responsively.
   */
  height = input<number>(240);
  /**
   * Optional fixed chart width in pixels. Omit for the default full-width responsive box.
   */
  width = input<number>(undefined);
  /**
   * Per-instance Chart.js `Plugin[]` — the consumer-extensibility passthrough. Merged into the config; changing the array **re-creates** the instance, since Chart.js has no stable runtime plugin-swap.
   */
  plugins = input<any[]>((() => [])());
  /**
   * The Chart.js `update` mode string used by the in-place data reconcile (e.g. `none` to skip the animation on every data tick).
   */
  updateMode = input<string>(undefined);
  /**
   * When `true`, a `data` change **re-creates** the chart wholesale instead of reconciling in place — mirrors react-chartjs-2 `redraw` for charts whose plugins do not survive an in-place update.
   */
  redraw = input<boolean>(false);
  /**
   * Accessible label applied to the `<canvas role="img">`, since canvas charts are otherwise opaque to assistive tech. For richer fallback content, fill the `fallback` slot.
   */
  ariaLabel = input<string>(undefined);
  /**
   * The dataset-identity key (react-chartjs-2 parity). Across data updates, datasets are matched by `dataset[datasetIdKey]`, falling back to array index when the key is absent, so a stable keyed dataset reconciles onto its prior slot even if its index moved — guarding the "first dataset copied over the others" hazard.
   */
  datasetIdKey = input<string>('label');
  /**
   * Milliseconds to defer `chart.destroy()` on unmount so an exit transition can finish (vue-chartjs parity). `0` (the default) destroys immediately.
   */
  destroyDelay = input<number>(0);
  canvasEl = viewChild<ElementRef<HTMLElement>>('canvasEl');
  click = output<unknown>();
  datasetClick = output<unknown>();
  hover = output<unknown>();
  @ContentChild('fallback', { read: TemplateRef }) fallbackTpl?: TemplateRef<FallbackCtx>;
  @ContentChild('tooltip', { read: TemplateRef }) tooltipTpl?: TemplateRef<TooltipCtx>;
  templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
  private _portalViews = new Set<EmbeddedViewRef<unknown>>();
  private _portalAnchor = viewChild('rozie_portalAnchor', { read: ViewContainerRef });
  private _tooltipTpl = contentChild('tooltip', { read: TemplateRef });
  private __rozieDestroyRef = inject(DestroyRef);
  private __rozieWatchInitial_1 = true;
  private __rozieWatchInitial_2 = true;
  private __rozieWatchInitial_3 = true;

  constructor() {
    effect(() => { const __watchVal = (() => this.data())(); untracked(() => ((v: any) => {
      const __updateMode = this.updateMode();
      if (!this.instance) return;
      if (this.redraw()) {
        this.recreate();
        return;
      }

      // Reconcile a new data object into the LIVE chart instead of replacing
      // instance.data. Chart.js matches dataset controllers and point elements by
      // array index across an update(), so mutating the existing labels/datasets
      // arrays lets it tween every point from old value to new. Assigning a fresh
      // instance.data severs that identity.
      const next = v;
      const live = this.instance.data;

      // Aliasing guard. On identity-$snapshot targets (React / Solid / Lit) $snapshot
      // returns its argument unchanged, and Chart.js stores config.data by reference,
      // so a freshly-constructed chart has `instance.data === $props.data === next`.
      // The in-place `live.labels.length = 0` below would then empty the very array we
      // read from on the next line (`next.labels` IS `live.labels`), wiping the labels
      // → cartesian charts lose their category axis and render an empty plot (the
      // doughnut, being radial, survives — it doesn't position by label). When live and
      // next alias there is nothing to reconcile: the chart already holds this data, so
      // just repaint. (Vue/Angular never hit this — their immediate $watch runs before
      // $onMount, when instance is still null.)
      if (live === next) {
        this.instance.update(__updateMode);
        return;
      }
      live.labels ??= [];
      live.labels.length = 0;
      live.labels.push(...(next.labels ?? []));

      // Datasets are matched by `ds[datasetIdKey]` (default 'label') so a stable
      // keyed dataset reconciles onto its prior slot even if its array index moved —
      // this guards the "first dataset copied over the others" hazard react-chartjs-2
      // documents. Datasets without the key fall back to positional (index) matching.
      live.datasets ??= [];
      const nextSets = next.datasets ?? [];
      const key = this.datasetIdKey();
      const prev = live.datasets.slice();
      const byKey = new Map();
      prev.forEach((ds: any, i: any) => {
        if (ds && ds[key] != null) byKey.set(ds[key], ds);
      });
      const merged = nextSets.map((ds: any, i: any) => {
        const match = ds && ds[key] != null && byKey.get(ds[key]) || prev[i];
        if (match) {
          Object.assign(match, ds);
          return match;
        }
        return ds;
      });
      live.datasets.length = 0;
      live.datasets.push(...merged);
      this.instance.update(__updateMode);
    })(__watchVal)); });
    effect(() => { const __watchVal = (() => this.options())(); untracked(() => { if (this.__rozieWatchInitial_1) { this.__rozieWatchInitial_1 = false; return; } (() => {
      if (!this.instance || !this.buildConfig) return;
      this.instance.options = this.buildConfig().options;
      this.instance.update('none');
    })(); }); });
    effect(() => { const __watchVal = (() => this.type())(); untracked(() => { if (this.__rozieWatchInitial_2) { this.__rozieWatchInitial_2 = false; return; } (() => this.recreate())(); }); });
    effect(() => { const __watchVal = (() => this.plugins())(); untracked(() => { if (this.__rozieWatchInitial_3) { this.__rozieWatchInitial_3 = false; return; } (() => this.recreate())(); }); });
  }

  ngAfterViewInit() {
    const portals = {
      tooltip: (container: HTMLElement, scope: { model: unknown }): (() => void) => {
        const tpl = this._tooltipTpl();
        const vcr = this._portalAnchor();
        if (!tpl || !vcr) return () => {};
        // Spike 004: portal-scope attribute injection.
        container.setAttribute('data-rozie-portal-tooltip', '2228fabc');
        const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
        view.detectChanges();
        for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
        this._portalViews.add(view as EmbeddedViewRef<unknown>);
        return () => {
          view.destroy();
          this._portalViews.delete(view as EmbeddedViewRef<unknown>);
        };
      },
    };
    this.canvasNode = this.canvasEl()?.nativeElement;

    // ─── @click / @hover / @datasetClick — composed, never clobbering ──────────
    // Chart.js calls onClick/onHover with (event, activeElements, chart). We call
    // any consumer-supplied handler first (read off $props.options), then emit a
    // structured payload resolving the hit element(s) via getElementsAtEventForMode.
    // ─── @click / @hover / @datasetClick — composed, never clobbering ──────────
    // Chart.js calls onClick/onHover with (event, activeElements, chart). We call
    // any consumer-supplied handler first (read off $props.options), then emit a
    // structured payload resolving the hit element(s) via getElementsAtEventForMode.
    const composedOnClick = (e: any, activeEls: any, chart: any) => {
      const userOnClick = this.options()?.onClick;
      if (typeof userOnClick === 'function') userOnClick(e, activeEls, chart);
      const nearest = chart.getElementsAtEventForMode(e, 'nearest', {
        intersect: true
      }, false);
      this.click.emit({
        event: e,
        elements: nearest,
        chart
      });
      const dataset = chart.getElementsAtEventForMode(e, 'dataset', {
        intersect: true
      }, false);
      if (dataset.length) {
        this.datasetClick.emit({
          event: e,
          elements: dataset,
          datasetIndex: dataset[0].datasetIndex,
          chart
        });
      }
    };
    const composedOnHover = (e: any, activeEls: any, chart: any) => {
      const userOnHover = this.options()?.onHover;
      if (typeof userOnHover === 'function') userOnHover(e, activeEls, chart);
      this.hover.emit({
        event: e,
        elements: activeEls,
        chart
      });
    };

    // ─── external-HTML tooltip portal slot ─────────────────────────────────────
    // Only active when the consumer fills <slot name="tooltip">. The external
    // handler positions a container over the canvas and mounts the consumer's
    // framework-native fragment through $portals.tooltip(dom, scope). The scope
    // carries the live tooltip model (title/body/dataPoints/position). Chart.js
    // throttles external calls to active-element changes, so the dispose+remount
    // on body-change is cheap. enabled:false suppresses the built-in canvas
    // tooltip when we take over.
    // ─── external-HTML tooltip portal slot ─────────────────────────────────────
    // Only active when the consumer fills <slot name="tooltip">. The external
    // handler positions a container over the canvas and mounts the consumer's
    // framework-native fragment through $portals.tooltip(dom, scope). The scope
    // carries the live tooltip model (title/body/dataPoints/position). Chart.js
    // throttles external calls to active-element changes, so the dispose+remount
    // on body-change is cheap. enabled:false suppresses the built-in canvas
    // tooltip when we take over.
    let tooltipKey = '';
    const tooltipExternal = (context: any) => {
      const {
        chart,
        tooltip
      } = context;
      if (!this.tooltipEl) {
        this.tooltipEl = document.createElement('div');
        this.tooltipEl.className = 'rozie-chart-tooltip';
        this.tooltipEl.style.position = 'absolute';
        this.tooltipEl.style.pointerEvents = 'none';
        this.tooltipEl.style.transition = 'opacity 0.1s ease';
        chart.canvas.parentNode.appendChild(this.tooltipEl);
      }
      if (tooltip.opacity === 0) {
        this.tooltipEl.style.opacity = '0';
        return;
      }
      const title = (tooltip.title || []).join(' ');
      const body = (tooltip.body || []).map((b: any) => b.lines.join(' ')).join(' | ');
      const key = `${title}::${body}`;
      if (key !== tooltipKey) {
        tooltipKey = key;
        this.tooltipDispose?.();
        // The scope MUST match the slot's declared param (`model`): the consumer's
        // <slot name="tooltip"> receives a single `model` scoped value.
        const scope = {
          model: {
            title: tooltip.title || [],
            body: (tooltip.body || []).map((b: any) => b.lines),
            dataPoints: tooltip.dataPoints || [],
            opacity: tooltip.opacity
          }
        };
        this.tooltipDispose = portals.tooltip(this.tooltipEl, scope);
      }
      const {
        offsetLeft,
        offsetTop
      } = chart.canvas;
      this.tooltipEl.style.opacity = '1';
      this.tooltipEl.style.left = `${offsetLeft + tooltip.caretX}px`;
      this.tooltipEl.style.top = `${offsetTop + tooltip.caretY}px`;
    };

    // ─── config builder ────────────────────────────────────────────────────────
    // $snapshot strips Svelte 5's $state proxy first; Chart.js redefines property
    // descriptors on whatever object it is handed.
    // ─── config builder ────────────────────────────────────────────────────────
    // $snapshot strips Svelte 5's $state proxy first; Chart.js redefines property
    // descriptors on whatever object it is handed.
    this.buildConfig = () => {
      const userOpts = this.options() || {};
      const tooltipOpt = (this.tooltipTpl ?? this.templates()?.['tooltip']) ? {
        ...(userOpts.plugins?.tooltip || {}),
        enabled: false,
        external: tooltipExternal
      } : userOpts.plugins?.tooltip;
      return {
        type: this.type(),
        data: this.data(),
        // per-instance plugins[] — the consumer-extensibility passthrough.
        plugins: this.plugins(),
        options: {
          responsive: true,
          maintainAspectRatio: false,
          animation: {
            duration: 250
          },
          ...userOpts,
          onClick: composedOnClick,
          onHover: composedOnHover,
          plugins: {
            ...(userOpts.plugins || {}),
            tooltip: tooltipOpt
          }
        }
      };
    };
    this.instance = new ChartJS(this.canvasNode, this.buildConfig());
    this.__rozieDestroyRef.onDestroy(() => {
      const __destroyDelay = this.destroyDelay();
      this.tooltipDispose?.();
      this.tooltipEl?.remove();
      // destroyDelay (vue-chartjs parity): defer destroy() so any exit transition
      // can finish. The captured `dying` instance is destroyed after the grace;
      // 0 (default) destroys synchronously.
      const dying = this.instance;
      if (__destroyDelay > 0) {
        setTimeout(() => dying?.destroy(), __destroyDelay);
      } else {
        dying?.destroy();
      }
    });
    this.__rozieDestroyRef.onDestroy(() => {
      for (const view of this._portalViews) view.destroy();
      this._portalViews.clear();
    });
  }

  instance: any = null;
  canvasNode: any = null;
  tooltipEl: any = null;
  tooltipDispose: any = null;
  buildConfig: any = null;
  recreate = () => {
    if (!this.buildConfig || !this.canvasNode) return;
    this.instance?.destroy();
    this.instance = new ChartJS(this.canvasNode, this.buildConfig());
  };
  getChart = () => {
    return this.instance;
  };
  updateChart = (mode: any) => {
    this.instance?.update(mode);
  };
  resizeChart = (w: any, h: any) => {
    this.instance?.resize(w, h);
  };
  resetChart = () => {
    this.instance?.reset();
  };
  renderChart = () => {
    this.instance?.render();
  };
  stopChart = () => {
    return this.instance?.stop();
  };
  clearChart = () => {
    return this.instance?.clear();
  };
  toBase64Image = (type: any, quality: any) => {
    return this.instance ? this.instance.toBase64Image(type, quality) : null;
  };
  setDatasetVisibility = (datasetIndex: any, visible: any) => {
    this.instance?.setDatasetVisibility(datasetIndex, visible);
  };
  isDatasetVisible = (datasetIndex: any) => {
    return this.instance ? this.instance.isDatasetVisible(datasetIndex) : false;
  };
  hideDataset = (datasetIndex: any, dataIndex: any) => {
    this.instance?.hide(datasetIndex, dataIndex);
  };
  showDataset = (datasetIndex: any, dataIndex: any) => {
    this.instance?.show(datasetIndex, dataIndex);
  };
  setActiveElements = (elements: any) => {
    this.instance?.setActiveElements(elements ?? []);
  };
  getActiveElements = () => {
    return this.instance ? this.instance.getActiveElements() : [];
  };
  getDatasetMeta = (datasetIndex: any) => {
    return this.instance ? this.instance.getDatasetMeta(datasetIndex) : null;
  };

  static ngTemplateContextGuard(
    _dir: Chart,
    _ctx: unknown,
  ): _ctx is FallbackCtx | TooltipCtx {
    return true;
  }

  protected get __style() {
      const __width = this.width();
      return { height: this.height() + 'px', width: __width ? __width + 'px' : undefined };
    }
}

export default Chart;
tsx
import type { JSX } from 'solid-js';
import { createEffect, mergeProps, on, onCleanup, onMount, splitProps, untrack } from 'solid-js';
import { render } from 'solid-js/web';
import { __rozieInjectStyle } from '@rozie/runtime-solid';
import { Chart as ChartJS } from 'chart.js';

// Chart.js v3+ ships with no controllers/elements/scales pre-registered. The
// generic Chart does NOT auto-register — the consumer registers only what they
// use (the tree-shakable Chart.js v3+ idiom every framework wrapper follows), so
// an app that only renders line charts doesn't ship every controller. Two paths:
//   - selective: `import { Chart, LineController, ... } from 'chart.js';
//     Chart.register(LineController, ...)` once at app startup; OR
//   - kitchen sink: import this package's `/auto` entry
//     (`@rozie-ui/chartjs-<fw>/auto`), or `import 'chart.js/auto'`, which
//     registers everything.
// The per-type components (Line/Bar/…) register their own controller set, so
// importing one is tree-shakable by construction.

__rozieInjectStyle('Chart-2228fabc', `.rozie-chart[data-rozie-s-2228fabc] {
  position: relative;
  width: 100%;
}
.rozie-chart[data-rozie-s-2228fabc] canvas[data-rozie-s-2228fabc] {
  display: block;
  width: 100% !important;
  height: 100% !important;
}
.rozie-chart .rozie-chart-tooltip {
    background: rgba(0, 0, 0, 0.8);
    color: #fff;
    border-radius: 4px;
    padding: 6px 8px;
    font-size: 12px;
    transform: translate(-50%, calc(-100% - 8px));
    white-space: nowrap;
  }`);

interface TooltipSlotCtx { model: any; }

interface ChartProps {
  /**
   * Chart.js data in its own `{ labels, datasets }` shape. Reconciled **in place** on change — the wrapper mutates `chart.data` and calls `chart.update()` so series tween point-to-point instead of remounting.
   * @example
   * <Chart :data="$data.chartData" type="bar" />
   */
  data?: Record<string, any>;
  /**
   * Chart.js options (scales, legend, plugins config, …). Merged over the wrapper's responsive defaults and reapplied wholesale on change with `update('none')`. A consumer-supplied `options.onClick`/`onHover` is **composed**, not clobbered.
   */
  options?: Record<string, any>;
  /**
   * The chart kind — any Chart.js controller (`line`/`bar`/`pie`/`doughnut`/`radar`/`polarArea`/`scatter`/`bubble`/…). Changing it **re-creates** the instance, since Chart.js has no stable runtime type-swap.
   */
  type?: string;
  /**
   * Chart height in pixels, applied to the wrapper's host box; the canvas fills it responsively.
   */
  height?: number;
  /**
   * Optional fixed chart width in pixels. Omit for the default full-width responsive box.
   */
  width?: number;
  /**
   * Per-instance Chart.js `Plugin[]` — the consumer-extensibility passthrough. Merged into the config; changing the array **re-creates** the instance, since Chart.js has no stable runtime plugin-swap.
   */
  plugins?: any[];
  /**
   * The Chart.js `update` mode string used by the in-place data reconcile (e.g. `none` to skip the animation on every data tick).
   */
  updateMode?: string;
  /**
   * When `true`, a `data` change **re-creates** the chart wholesale instead of reconciling in place — mirrors react-chartjs-2 `redraw` for charts whose plugins do not survive an in-place update.
   */
  redraw?: boolean;
  /**
   * Accessible label applied to the `<canvas role="img">`, since canvas charts are otherwise opaque to assistive tech. For richer fallback content, fill the `fallback` slot.
   */
  ariaLabel?: string;
  /**
   * The dataset-identity key (react-chartjs-2 parity). Across data updates, datasets are matched by `dataset[datasetIdKey]`, falling back to array index when the key is absent, so a stable keyed dataset reconciles onto its prior slot even if its index moved — guarding the "first dataset copied over the others" hazard.
   */
  datasetIdKey?: string;
  /**
   * Milliseconds to defer `chart.destroy()` on unmount so an exit transition can finish (vue-chartjs parity). `0` (the default) destroys immediately.
   */
  destroyDelay?: number;
  onClick?: (...args: unknown[]) => void;
  onDatasetClick?: (...args: unknown[]) => void;
  onHover?: (...args: unknown[]) => void;
  fallbackSlot?: JSX.Element;
  tooltipSlot?: (ctx: TooltipSlotCtx) => JSX.Element;
  slots?: Record<string, (ctx: any) => JSX.Element>;
  ref?: (h: ChartHandle) => void;
}

export interface ChartHandle {
  getChart: (...args: any[]) => any;
  updateChart: (...args: any[]) => any;
  resizeChart: (...args: any[]) => any;
  resetChart: (...args: any[]) => any;
  renderChart: (...args: any[]) => any;
  stopChart: (...args: any[]) => any;
  clearChart: (...args: any[]) => any;
  toBase64Image: (...args: any[]) => any;
  setDatasetVisibility: (...args: any[]) => any;
  isDatasetVisible: (...args: any[]) => any;
  hideDataset: (...args: any[]) => any;
  showDataset: (...args: any[]) => any;
  setActiveElements: (...args: any[]) => any;
  getActiveElements: (...args: any[]) => any;
  getDatasetMeta: (...args: any[]) => any;
}

export default function Chart(_props: ChartProps): JSX.Element {
  const _merged = mergeProps({ data: (() => ({
  labels: [],
  datasets: []
}))(), options: (() => ({}))(), type: 'line', height: 240, width: undefined, plugins: (() => [])(), updateMode: undefined, redraw: false, ariaLabel: undefined, datasetIdKey: 'label', destroyDelay: 0 }, _props);
  const [local, attrs] = splitProps(_merged, ['data', 'options', 'type', 'height', 'width', 'plugins', 'updateMode', 'redraw', 'ariaLabel', 'datasetIdKey', 'destroyDelay', 'ref']);
  onMount(() => { local.ref?.({ getChart, updateChart, resizeChart, resetChart, renderChart, stopChart, clearChart, toBase64Image, setDatasetVisibility, isDatasetVisible, hideDataset, showDataset, setActiveElements, getActiveElements, getDatasetMeta }); });

  const portalDisposers = new Set<() => void>();
  const portals = {
    tooltip: (container: HTMLElement, scope: { model: unknown }): (() => void) => {
      const slot = _props.tooltipSlot ?? _props.slots?.['tooltip'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      container.setAttribute('data-rozie-portal-tooltip', '2228fabc');
      const dispose = render(() => slot(scope), container);
      portalDisposers.add(dispose);
      return () => {
        dispose();
        portalDisposers.delete(dispose);
      };
    },
  };
  onCleanup(() => {
    for (const dispose of portalDisposers) dispose();
    portalDisposers.clear();
  });
  onMount(() => {
    const _cleanup = (() => {
    canvasNode = canvasElRef;

    // ─── @click / @hover / @datasetClick — composed, never clobbering ──────────
    // Chart.js calls onClick/onHover with (event, activeElements, chart). We call
    // any consumer-supplied handler first (read off $props.options), then emit a
    // structured payload resolving the hit element(s) via getElementsAtEventForMode.
    const composedOnClick = (e: any, activeEls: any, chart: any) => {
      const userOnClick = local.options?.onClick;
      if (typeof userOnClick === 'function') userOnClick(e, activeEls, chart);
      const nearest = chart.getElementsAtEventForMode(e, 'nearest', {
        intersect: true
      }, false);
      _props.onClick?.({
        event: e,
        elements: nearest,
        chart
      });
      const dataset = chart.getElementsAtEventForMode(e, 'dataset', {
        intersect: true
      }, false);
      if (dataset.length) {
        _props.onDatasetClick?.({
          event: e,
          elements: dataset,
          datasetIndex: dataset[0].datasetIndex,
          chart
        });
      }
    };
    const composedOnHover = (e: any, activeEls: any, chart: any) => {
      const userOnHover = local.options?.onHover;
      if (typeof userOnHover === 'function') userOnHover(e, activeEls, chart);
      _props.onHover?.({
        event: e,
        elements: activeEls,
        chart
      });
    };

    // ─── external-HTML tooltip portal slot ─────────────────────────────────────
    // Only active when the consumer fills <slot name="tooltip">. The external
    // handler positions a container over the canvas and mounts the consumer's
    // framework-native fragment through $portals.tooltip(dom, scope). The scope
    // carries the live tooltip model (title/body/dataPoints/position). Chart.js
    // throttles external calls to active-element changes, so the dispose+remount
    // on body-change is cheap. enabled:false suppresses the built-in canvas
    // tooltip when we take over.
    let tooltipKey = '';
    const tooltipExternal = (context: any) => {
      const {
        chart,
        tooltip
      } = context;
      if (!tooltipEl) {
        tooltipEl = document.createElement('div');
        tooltipEl.className = 'rozie-chart-tooltip';
        tooltipEl.style.position = 'absolute';
        tooltipEl.style.pointerEvents = 'none';
        tooltipEl.style.transition = 'opacity 0.1s ease';
        chart.canvas.parentNode.appendChild(tooltipEl);
      }
      if (tooltip.opacity === 0) {
        tooltipEl.style.opacity = '0';
        return;
      }
      const title = (tooltip.title || []).join(' ');
      const body = (tooltip.body || []).map((b: any) => b.lines.join(' ')).join(' | ');
      const key = `${title}::${body}`;
      if (key !== tooltipKey) {
        tooltipKey = key;
        tooltipDispose?.();
        // The scope MUST match the slot's declared param (`model`): the consumer's
        // <slot name="tooltip"> receives a single `model` scoped value.
        const scope = {
          model: {
            title: tooltip.title || [],
            body: (tooltip.body || []).map((b: any) => b.lines),
            dataPoints: tooltip.dataPoints || [],
            opacity: tooltip.opacity
          }
        };
        tooltipDispose = portals.tooltip(tooltipEl, scope);
      }
      const {
        offsetLeft,
        offsetTop
      } = chart.canvas;
      tooltipEl.style.opacity = '1';
      tooltipEl.style.left = `${offsetLeft + tooltip.caretX}px`;
      tooltipEl.style.top = `${offsetTop + tooltip.caretY}px`;
    };

    // ─── config builder ────────────────────────────────────────────────────────
    // $snapshot strips Svelte 5's $state proxy first; Chart.js redefines property
    // descriptors on whatever object it is handed.
    buildConfig = () => {
      const userOpts = local.options || {};
      const tooltipOpt = (_props.tooltipSlot ?? _props.slots?.["tooltip"]) ? {
        ...(userOpts.plugins?.tooltip || {}),
        enabled: false,
        external: tooltipExternal
      } : userOpts.plugins?.tooltip;
      return {
        type: local.type,
        data: local.data,
        // per-instance plugins[] — the consumer-extensibility passthrough.
        plugins: local.plugins,
        options: {
          responsive: true,
          maintainAspectRatio: false,
          animation: {
            duration: 250
          },
          ...userOpts,
          onClick: composedOnClick,
          onHover: composedOnHover,
          plugins: {
            ...(userOpts.plugins || {}),
            tooltip: tooltipOpt
          }
        }
      };
    };
    instance = new ChartJS(canvasNode, buildConfig());
  })() as unknown;
    if (_cleanup) onCleanup(_cleanup as () => void);
    onCleanup(() => {
    tooltipDispose?.();
    tooltipEl?.remove();
    // destroyDelay (vue-chartjs parity): defer destroy() so any exit transition
    // can finish. The captured `dying` instance is destroyed after the grace;
    // 0 (default) destroys synchronously.
    const dying = instance;
    if (local.destroyDelay > 0) {
      setTimeout(() => dying?.destroy(), local.destroyDelay);
    } else {
      dying?.destroy();
    }
  });
  });
  createEffect(() => { const __watchVal = (() => local.data)(); untrack(() => ((v: any) => {
    if (!instance) return;
    if (local.redraw) {
      recreate();
      return;
    }

    // Reconcile a new data object into the LIVE chart instead of replacing
    // instance.data. Chart.js matches dataset controllers and point elements by
    // array index across an update(), so mutating the existing labels/datasets
    // arrays lets it tween every point from old value to new. Assigning a fresh
    // instance.data severs that identity.
    const next = v;
    const live = instance.data;

    // Aliasing guard. On identity-$snapshot targets (React / Solid / Lit) $snapshot
    // returns its argument unchanged, and Chart.js stores config.data by reference,
    // so a freshly-constructed chart has `instance.data === $props.data === next`.
    // The in-place `live.labels.length = 0` below would then empty the very array we
    // read from on the next line (`next.labels` IS `live.labels`), wiping the labels
    // → cartesian charts lose their category axis and render an empty plot (the
    // doughnut, being radial, survives — it doesn't position by label). When live and
    // next alias there is nothing to reconcile: the chart already holds this data, so
    // just repaint. (Vue/Angular never hit this — their immediate $watch runs before
    // $onMount, when instance is still null.)
    if (live === next) {
      instance.update(local.updateMode);
      return;
    }
    live.labels ??= [];
    live.labels.length = 0;
    live.labels.push(...(next.labels ?? []));

    // Datasets are matched by `ds[datasetIdKey]` (default 'label') so a stable
    // keyed dataset reconciles onto its prior slot even if its array index moved —
    // this guards the "first dataset copied over the others" hazard react-chartjs-2
    // documents. Datasets without the key fall back to positional (index) matching.
    live.datasets ??= [];
    const nextSets = next.datasets ?? [];
    const key = local.datasetIdKey;
    const prev = live.datasets.slice();
    const byKey = new Map();
    prev.forEach((ds: any, i: any) => {
      if (ds && ds[key] != null) byKey.set(ds[key], ds);
    });
    const merged = nextSets.map((ds: any, i: any) => {
      const match = ds && ds[key] != null && byKey.get(ds[key]) || prev[i];
      if (match) {
        Object.assign(match, ds);
        return match;
      }
      return ds;
    });
    live.datasets.length = 0;
    live.datasets.push(...merged);
    instance.update(local.updateMode);
  })(__watchVal)); });
  createEffect(on(() => (() => local.options)(), (v) => untrack(() => (() => {
    if (!instance || !buildConfig) return;
    instance.options = buildConfig().options;
    instance.update('none');
  })()), { defer: true }));
  createEffect(on(() => (() => local.type)(), (v) => untrack(() => (() => recreate())()), { defer: true }));
  createEffect(on(() => (() => local.plugins)(), (v) => untrack(() => (() => recreate())()), { defer: true }));
  let canvasElRef: HTMLElement | null = null;

  // Chart.js v3+ ships with no controllers/elements/scales pre-registered. The
  // generic Chart does NOT auto-register — the consumer registers only what they
  // use (the tree-shakable Chart.js v3+ idiom every framework wrapper follows), so
  // an app that only renders line charts doesn't ship every controller. Two paths:
  //   - selective: `import { Chart, LineController, ... } from 'chart.js';
  //     Chart.register(LineController, ...)` once at app startup; OR
  //   - kitchen sink: import this package's `/auto` entry
  //     (`@rozie-ui/chartjs-<fw>/auto`), or `import 'chart.js/auto'`, which
  //     registers everything.
  // The per-type components (Line/Bar/…) register their own controller set, so
  // importing one is tree-shakable by construction.

  let instance: any = null;
  // $refs.canvasEl is read ONLY inside $onMount (ROZ123); re-creates use this
  // captured node so no $refs read ever executes outside the mount hook. Named
  // `canvasNode` (NOT `canvasEl`) so it does not collide with the template
  // `ref="canvasEl"` binding, which the per-target emitters lower to their own
  // `canvasEl` ref declaration (a same-name script local double-declares it).
  let canvasNode: any = null;
  // Tooltip-portal teardown state at COMPONENT scope (not inside $onMount): the
  // $onMount-returned cleanup references these, and the Solid emitter hoists that
  // returned cleanup into a sibling onCleanup() OUTSIDE the mount-body IIFE — so a
  // cleanup that closed over $onMount-locals would lose scope. Component-scope
  // state (like `instance`) is in scope wherever the per-target cleanup lands.
  let tooltipEl: any = null;
  let tooltipDispose: any = null;
  // buildConfig is DEFINED inside $onMount (so its $emit/$portals/$slots
  // references are bound in the mount-lifecycle scope the per-target emitters
  // provide — mirrors FullCalendar's mount-built opts + CodeMirror's panelExt
  // note) and stored here so the top-level re-create $watches can call it.
  let buildConfig: any = null;
  // Re-create the live instance. Chart.js exposes no stable runtime type-swap or
  // plugin-swap, so `type`/`plugins`/`redraw`-driven changes re-create. Uses the
  // captured canvasNode (never re-reads $refs outside $onMount).
  function recreate() {
    if (!buildConfig || !canvasNode) return;
    instance?.destroy();
    instance = new ChartJS(canvasNode, buildConfig());
  }

  // Reconcile prop changes. Mutating chart.data in place and calling update() is
  // the Chart.js-supported runtime path — re-creating on every data tick would
  // flicker and leak. (When `redraw` is set, re-create wholesale instead.)

  // Imperative handle (Phase 21 $expose). The lifecycle/redraw verbs are SUFFIXED
  // with `Chart` because bare `update`/`render` collide with LitElement's
  // reactive-lifecycle methods (`update(changedProperties)` / `render()`) and
  // would shadow them on the Lit leaf; `resize`/`reset`/`stop`/`clear` are
  // suffixed too for a consistent, unambiguous handle. `getChart` returns the live
  // instance for direct API access; `toBase64Image` is the marquee PNG-export.
  //
  // The visibility + active-element family (added later) is the #1 reason a
  // consumer reaches for a chart handle — custom legends, externally-driven
  // tooltips/highlights, overlay positioning — none reachable via prop/event:
  //   - setDatasetVisibility / isDatasetVisible: drive a custom legend's series
  //     show/hide (INSTANT toggle).
  //   - hideDataset / showDataset: the ANIMATED hide/show (Chart.js hide()/show()).
  //     SUFFIXED with `Dataset` both to dodge inherited HTMLElement-ish ambiguity
  //     and to disambiguate the dataset-vs-element overload.
  //   - setActiveElements / getActiveElements: programmatically open/read the
  //     hovered/active points (sync hover from a table row, a map pin, a sibling
  //     chart) — events only REPORT hover, they cannot SET it.
  //   - getDatasetMeta: read computed geometry (pixel coords, controller) to
  //     position custom overlays/annotations over the canvas.
  // Collision-clear: none of the 15 names collide with the 11 props or the 3
  // emits (click/datasetClick/hover).
  function getChart() {
    return instance;
  }
  function updateChart(mode: any) {
    instance?.update(mode);
  }
  function resizeChart(w: any, h: any) {
    instance?.resize(w, h);
  }
  function resetChart() {
    instance?.reset();
  }
  function renderChart() {
    instance?.render();
  }
  function stopChart() {
    return instance?.stop();
  }
  function clearChart() {
    return instance?.clear();
  }
  function toBase64Image(type: any, quality: any) {
    return instance ? instance.toBase64Image(type, quality) : null;
  }
  function setDatasetVisibility(datasetIndex: any, visible: any) {
    instance?.setDatasetVisibility(datasetIndex, visible);
  }
  function isDatasetVisible(datasetIndex: any) {
    return instance ? instance.isDatasetVisible(datasetIndex) : false;
  }
  function hideDataset(datasetIndex: any, dataIndex: any) {
    instance?.hide(datasetIndex, dataIndex);
  }
  function showDataset(datasetIndex: any, dataIndex: any) {
    instance?.show(datasetIndex, dataIndex);
  }
  function setActiveElements(elements: any) {
    instance?.setActiveElements(elements ?? []);
  }
  function getActiveElements() {
    return instance ? instance.getActiveElements() : [];
  }
  function getDatasetMeta(datasetIndex: any) {
    return instance ? instance.getDatasetMeta(datasetIndex) : null;
  }

  return (
    <>
    <div class={"rozie-chart"} style={{ height: local.height + 'px', width: local.width ? local.width + 'px' : undefined }} data-rozie-s-2228fabc="">
      
      <canvas ref={(el) => { canvasElRef = el as HTMLElement; }} role="img" aria-label={local.ariaLabel} data-rozie-s-2228fabc="">{(_props.fallbackSlot ?? _props.slots?.['fallback']?.({}))}</canvas>
    </div>


    </>
  );
}
ts
import { LitElement, css, html, nothing, render } from 'lit';
import { customElement, property, query, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher } from '@lit-labs/preact-signals';
import { adoptDocumentStyles, injectGlobalStyles } from '@rozie/runtime-lit';
import { styleMap } from 'lit/directives/style-map.js';
import { Chart as ChartJS } from 'chart.js';

// Chart.js v3+ ships with no controllers/elements/scales pre-registered. The
// generic Chart does NOT auto-register — the consumer registers only what they
// use (the tree-shakable Chart.js v3+ idiom every framework wrapper follows), so
// an app that only renders line charts doesn't ship every controller. Two paths:
//   - selective: `import { Chart, LineController, ... } from 'chart.js';
//     Chart.register(LineController, ...)` once at app startup; OR
//   - kitchen sink: import this package's `/auto` entry
//     (`@rozie-ui/chartjs-<fw>/auto`), or `import 'chart.js/auto'`, which
//     registers everything.
// The per-type components (Line/Bar/…) register their own controller set, so
// importing one is tree-shakable by construction.

interface RozieTooltipSlotCtx {
  model: unknown;
}

@customElement('rozie-chart')
export default class Chart extends SignalWatcher(LitElement) {
  static styles = css`
.rozie-chart[data-rozie-s-2228fabc] {
  position: relative;
  width: 100%;
}
.rozie-chart[data-rozie-s-2228fabc] canvas[data-rozie-s-2228fabc] {
  display: block;
  width: 100% !important;
  height: 100% !important;
}
.rozie-chart .rozie-chart-tooltip {
    background: rgba(0, 0, 0, 0.8);
    color: #fff;
    border-radius: 4px;
    padding: 6px 8px;
    font-size: 12px;
    transform: translate(-50%, calc(-100% - 8px));
    white-space: nowrap;
  }
`;

  /**
   * Chart.js data in its own `{ labels, datasets }` shape. Reconciled **in place** on change — the wrapper mutates `chart.data` and calls `chart.update()` so series tween point-to-point instead of remounting.
   * @example
   * <Chart :data="$data.chartData" type="bar" />
   */
  @property({ type: Object }) data: any = {
  labels: [],
  datasets: []
};
  /**
   * Chart.js options (scales, legend, plugins config, …). Merged over the wrapper's responsive defaults and reapplied wholesale on change with `update('none')`. A consumer-supplied `options.onClick`/`onHover` is **composed**, not clobbered.
   */
  @property({ type: Object }) options: any = {};
  /**
   * The chart kind — any Chart.js controller (`line`/`bar`/`pie`/`doughnut`/`radar`/`polarArea`/`scatter`/`bubble`/…). Changing it **re-creates** the instance, since Chart.js has no stable runtime type-swap.
   */
  @property({ type: String, reflect: true }) type: string = 'line';
  /**
   * Chart height in pixels, applied to the wrapper's host box; the canvas fills it responsively.
   */
  @property({ type: Number, reflect: true }) height: number = 240;
  /**
   * Optional fixed chart width in pixels. Omit for the default full-width responsive box.
   */
  @property({ type: Number, reflect: true }) width: number = undefined;
  /**
   * Per-instance Chart.js `Plugin[]` — the consumer-extensibility passthrough. Merged into the config; changing the array **re-creates** the instance, since Chart.js has no stable runtime plugin-swap.
   */
  @property({ type: Array }) plugins: any[] = [];
  /**
   * The Chart.js `update` mode string used by the in-place data reconcile (e.g. `none` to skip the animation on every data tick).
   */
  @property({ type: String, reflect: true }) updateMode: string = undefined;
  /**
   * When `true`, a `data` change **re-creates** the chart wholesale instead of reconciling in place — mirrors react-chartjs-2 `redraw` for charts whose plugins do not survive an in-place update.
   */
  @property({ type: Boolean, reflect: true }) redraw: boolean = false;
  /**
   * Accessible label applied to the `<canvas role="img">`, since canvas charts are otherwise opaque to assistive tech. For richer fallback content, fill the `fallback` slot.
   */
  @property({ type: String, reflect: true }) ariaLabel: string = undefined;
  /**
   * The dataset-identity key (react-chartjs-2 parity). Across data updates, datasets are matched by `dataset[datasetIdKey]`, falling back to array index when the key is absent, so a stable keyed dataset reconciles onto its prior slot even if its index moved — guarding the "first dataset copied over the others" hazard.
   */
  @property({ type: String, reflect: true }) datasetIdKey: string = 'label';
  /**
   * Milliseconds to defer `chart.destroy()` on unmount so an exit transition can finish (vue-chartjs parity). `0` (the default) destroys immediately.
   */
  @property({ type: Number, reflect: true }) destroyDelay: number = 0;
  @query('[data-rozie-ref="canvasEl"]') private _refCanvasEl!: HTMLElement;
private __rozieFirstUpdateDone = false;
private _portalContainers = new Set<HTMLElement>();

  @state() private _hasSlotFallback = false;
  @queryAssignedElements({ slot: 'fallback', flatten: true }) private _slotFallbackElements!: Element[];
  @state() private _hasSlotTooltip = false;
  @queryAssignedElements({ slot: 'tooltip', flatten: true }) private _slotTooltipElements!: Element[];
  @property({ attribute: false }) tooltip?: (scope: { model: unknown }) => unknown;

  private _disconnectCleanups: Array<() => void> = [];
  // Re-parenting guard: set true once the deferred teardown has actually
  // run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
  private _rozieTornDown = false;

  private _armListeners(): void {
    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="fallback"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotFallback = this._slotFallbackElements.length > 0; };
        slotEl.addEventListener('slotchange', update);
        // CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
        this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
        update();
      }
    }

    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="tooltip"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotTooltip = this._slotTooltipElements.length > 0; };
        slotEl.addEventListener('slotchange', update);
        // CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
        this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
        update();
      }
    }
  }

  connectedCallback(): void {
    // Phase 07.3.1 D-LIT-15 — pre-seed _hasSlot<X> from light DOM so first render isn't deadlocked.
    this._hasSlotFallback = Array.from(this.children).some((el) => el.getAttribute('slot') === 'fallback');
    this._hasSlotTooltip = Array.from(this.children).some((el) => el.getAttribute('slot') === 'tooltip');
    super.connectedCallback();
    if (this.hasUpdated && this._rozieTornDown) { this._rozieTornDown = false; this._armListeners(); }
  }

  firstUpdated(): void {
    adoptDocumentStyles(this);

    this._armListeners();

    const portals = {
      tooltip: (container: HTMLElement, scope: { model: unknown }): (() => void) => {
        const tpl = this.tooltip;
        if (typeof tpl !== 'function') return () => {};
        // Spike 004: portal-scope attribute injection.
        container.setAttribute('data-rozie-portal-tooltip', '2228fabc');
        render(tpl(scope), container);
        this._portalContainers.add(container);
        return () => {
          render(nothing, container);
          this._portalContainers.delete(container);
        };
      },
    };

    this._disconnectCleanups.push((() => {
      this.tooltipDispose?.();
      this.tooltipEl?.remove();
      // destroyDelay (vue-chartjs parity): defer destroy() so any exit transition
      // can finish. The captured `dying` instance is destroyed after the grace;
      // 0 (default) destroys synchronously.
      const dying = this.instance;
      if (this.destroyDelay > 0) {
        setTimeout(() => dying?.destroy(), this.destroyDelay);
      } else {
        dying?.destroy();
      }
    }));

    this.canvasNode = this._refCanvasEl;

    // ─── @click / @hover / @datasetClick — composed, never clobbering ──────────
    // Chart.js calls onClick/onHover with (event, activeElements, chart). We call
    // any consumer-supplied handler first (read off $props.options), then emit a
    // structured payload resolving the hit element(s) via getElementsAtEventForMode.
    // ─── @click / @hover / @datasetClick — composed, never clobbering ──────────
    // Chart.js calls onClick/onHover with (event, activeElements, chart). We call
    // any consumer-supplied handler first (read off $props.options), then emit a
    // structured payload resolving the hit element(s) via getElementsAtEventForMode.
    const composedOnClick = (e: any, activeEls: any, chart: any) => {
      const userOnClick = this.options?.onClick;
      if (typeof userOnClick === 'function') userOnClick(e, activeEls, chart);
      const nearest = chart.getElementsAtEventForMode(e, 'nearest', {
        intersect: true
      }, false);
      this.dispatchEvent(new CustomEvent("click", {
        detail: {
          event: e,
          elements: nearest,
          chart
        },
        bubbles: true,
        composed: true
      }));
      const dataset = chart.getElementsAtEventForMode(e, 'dataset', {
        intersect: true
      }, false);
      if (dataset.length) {
        this.dispatchEvent(new CustomEvent("datasetClick", {
          detail: {
            event: e,
            elements: dataset,
            datasetIndex: dataset[0].datasetIndex,
            chart
          },
          bubbles: true,
          composed: true
        }));
      }
    };
    const composedOnHover = (e: any, activeEls: any, chart: any) => {
      const userOnHover = this.options?.onHover;
      if (typeof userOnHover === 'function') userOnHover(e, activeEls, chart);
      this.dispatchEvent(new CustomEvent("hover", {
        detail: {
          event: e,
          elements: activeEls,
          chart
        },
        bubbles: true,
        composed: true
      }));
    };

    // ─── external-HTML tooltip portal slot ─────────────────────────────────────
    // Only active when the consumer fills <slot name="tooltip">. The external
    // handler positions a container over the canvas and mounts the consumer's
    // framework-native fragment through $portals.tooltip(dom, scope). The scope
    // carries the live tooltip model (title/body/dataPoints/position). Chart.js
    // throttles external calls to active-element changes, so the dispose+remount
    // on body-change is cheap. enabled:false suppresses the built-in canvas
    // tooltip when we take over.
    // ─── external-HTML tooltip portal slot ─────────────────────────────────────
    // Only active when the consumer fills <slot name="tooltip">. The external
    // handler positions a container over the canvas and mounts the consumer's
    // framework-native fragment through $portals.tooltip(dom, scope). The scope
    // carries the live tooltip model (title/body/dataPoints/position). Chart.js
    // throttles external calls to active-element changes, so the dispose+remount
    // on body-change is cheap. enabled:false suppresses the built-in canvas
    // tooltip when we take over.
    let tooltipKey = '';
    const tooltipExternal = (context: any) => {
      const {
        chart,
        tooltip
      } = context;
      if (!this.tooltipEl) {
        this.tooltipEl = document.createElement('div');
        this.tooltipEl.className = 'rozie-chart-tooltip';
        this.tooltipEl.style.position = 'absolute';
        this.tooltipEl.style.pointerEvents = 'none';
        this.tooltipEl.style.transition = 'opacity 0.1s ease';
        chart.canvas.parentNode.appendChild(this.tooltipEl);
      }
      if (tooltip.opacity === 0) {
        this.tooltipEl.style.opacity = '0';
        return;
      }
      const title = (tooltip.title || []).join(' ');
      const body = (tooltip.body || []).map((b: any) => b.lines.join(' ')).join(' | ');
      const key = `${title}::${body}`;
      if (key !== tooltipKey) {
        tooltipKey = key;
        this.tooltipDispose?.();
        // The scope MUST match the slot's declared param (`model`): the consumer's
        // <slot name="tooltip"> receives a single `model` scoped value.
        const scope = {
          model: {
            title: tooltip.title || [],
            body: (tooltip.body || []).map((b: any) => b.lines),
            dataPoints: tooltip.dataPoints || [],
            opacity: tooltip.opacity
          }
        };
        this.tooltipDispose = portals.tooltip(this.tooltipEl, scope);
      }
      const {
        offsetLeft,
        offsetTop
      } = chart.canvas;
      this.tooltipEl.style.opacity = '1';
      this.tooltipEl.style.left = `${offsetLeft + tooltip.caretX}px`;
      this.tooltipEl.style.top = `${offsetTop + tooltip.caretY}px`;
    };

    // ─── config builder ────────────────────────────────────────────────────────
    // $snapshot strips Svelte 5's $state proxy first; Chart.js redefines property
    // descriptors on whatever object it is handed.
    // ─── config builder ────────────────────────────────────────────────────────
    // $snapshot strips Svelte 5's $state proxy first; Chart.js redefines property
    // descriptors on whatever object it is handed.
    this.buildConfig = () => {
      const userOpts = this.options || {};
      const tooltipOpt = this.tooltip !== undefined ? {
        ...(userOpts.plugins?.tooltip || {}),
        enabled: false,
        external: tooltipExternal
      } : userOpts.plugins?.tooltip;
      return {
        type: this.type,
        data: this.data,
        // per-instance plugins[] — the consumer-extensibility passthrough.
        plugins: this.plugins,
        options: {
          responsive: true,
          maintainAspectRatio: false,
          animation: {
            duration: 250
          },
          ...userOpts,
          onClick: composedOnClick,
          onHover: composedOnHover,
          plugins: {
            ...(userOpts.plugins || {}),
            tooltip: tooltipOpt
          }
        }
      };
    };
    this.instance = new ChartJS(this.canvasNode, this.buildConfig());
  }

  updated(changedProperties: Map<string, unknown>): void {
    if (changedProperties.has('data')) { const __watchVal = (() => this.data)(); ((v: any) => {
      if (!this.instance) return;
      if (this.redraw) {
        this.recreate();
        return;
      }

      // Reconcile a new data object into the LIVE chart instead of replacing
      // instance.data. Chart.js matches dataset controllers and point elements by
      // array index across an update(), so mutating the existing labels/datasets
      // arrays lets it tween every point from old value to new. Assigning a fresh
      // instance.data severs that identity.
      const next = v;
      const live = this.instance.data;

      // Aliasing guard. On identity-$snapshot targets (React / Solid / Lit) $snapshot
      // returns its argument unchanged, and Chart.js stores config.data by reference,
      // so a freshly-constructed chart has `instance.data === $props.data === next`.
      // The in-place `live.labels.length = 0` below would then empty the very array we
      // read from on the next line (`next.labels` IS `live.labels`), wiping the labels
      // → cartesian charts lose their category axis and render an empty plot (the
      // doughnut, being radial, survives — it doesn't position by label). When live and
      // next alias there is nothing to reconcile: the chart already holds this data, so
      // just repaint. (Vue/Angular never hit this — their immediate $watch runs before
      // $onMount, when instance is still null.)
      if (live === next) {
        this.instance.update(this.updateMode);
        return;
      }
      live.labels ??= [];
      live.labels.length = 0;
      live.labels.push(...(next.labels ?? []));

      // Datasets are matched by `ds[datasetIdKey]` (default 'label') so a stable
      // keyed dataset reconciles onto its prior slot even if its array index moved —
      // this guards the "first dataset copied over the others" hazard react-chartjs-2
      // documents. Datasets without the key fall back to positional (index) matching.
      live.datasets ??= [];
      const nextSets = next.datasets ?? [];
      const key = this.datasetIdKey;
      const prev = live.datasets.slice();
      const byKey = new Map();
      prev.forEach((ds: any, i: any) => {
        if (ds && ds[key] != null) byKey.set(ds[key], ds);
      });
      const merged = nextSets.map((ds: any, i: any) => {
        const match = ds && ds[key] != null && byKey.get(ds[key]) || prev[i];
        if (match) {
          Object.assign(match, ds);
          return match;
        }
        return ds;
      });
      live.datasets.length = 0;
      live.datasets.push(...merged);
      this.instance.update(this.updateMode);
    })(__watchVal); }
    if (this.__rozieFirstUpdateDone && (changedProperties.has('options'))) { const __watchVal = (() => this.options)(); (() => {
      if (!this.instance || !this.buildConfig) return;
      this.instance.options = this.buildConfig().options;
      this.instance.update('none');
    })(); }
    if (this.__rozieFirstUpdateDone && (changedProperties.has('type'))) { const __watchVal = (() => this.type)(); (() => this.recreate())(); }
    if (this.__rozieFirstUpdateDone && (changedProperties.has('plugins'))) { const __watchVal = (() => this.plugins)(); (() => this.recreate())(); }
    this.__rozieFirstUpdateDone = true;
  }

  disconnectedCallback(): void {
    super.disconnectedCallback();
    queueMicrotask(() => {
      if (this.isConnected || this._rozieTornDown) return;
      this._rozieTornDown = true;
      for (const container of this._portalContainers) render(nothing, container);
      this._portalContainers.clear();
      for (const fn of this._disconnectCleanups) fn();
      this._disconnectCleanups = [];
    });
  }

  render() {
    return html`
<div class="rozie-chart" style=${styleMap({ height: this.height + 'px', width: this.width ? this.width + 'px' : undefined })} data-rozie-s-2228fabc>
  
  <canvas role="img" aria-label=${this.ariaLabel} data-rozie-ref="canvasEl" data-rozie-s-2228fabc><slot name="fallback"></slot></canvas>
</div>

<slot name="tooltip"></slot>
`;
  }

  instance: any = null;

  canvasNode: any = null;

  tooltipEl: any = null;

  tooltipDispose: any = null;

  buildConfig: any = null;

  recreate = () => {
  if (!this.buildConfig || !this.canvasNode) return;
  this.instance?.destroy();
  this.instance = new ChartJS(this.canvasNode, this.buildConfig());
};

  getChart() {
    return this.instance;
  }

  updateChart(mode: any) {
    this.instance?.update(mode);
  }

  resizeChart(w: any, h: any) {
    this.instance?.resize(w, h);
  }

  resetChart() {
    this.instance?.reset();
  }

  renderChart() {
    this.instance?.render();
  }

  stopChart() {
    return this.instance?.stop();
  }

  clearChart() {
    return this.instance?.clear();
  }

  toBase64Image(type: any, quality: any) {
    return this.instance ? this.instance.toBase64Image(type, quality) : null;
  }

  setDatasetVisibility(datasetIndex: any, visible: any) {
    this.instance?.setDatasetVisibility(datasetIndex, visible);
  }

  isDatasetVisible(datasetIndex: any) {
    return this.instance ? this.instance.isDatasetVisible(datasetIndex) : false;
  }

  hideDataset(datasetIndex: any, dataIndex: any) {
    this.instance?.hide(datasetIndex, dataIndex);
  }

  showDataset(datasetIndex: any, dataIndex: any) {
    this.instance?.show(datasetIndex, dataIndex);
  }

  setActiveElements(elements: any) {
    this.instance?.setActiveElements(elements ?? []);
  }

  getActiveElements() {
    return this.instance ? this.instance.getActiveElements() : [];
  }

  getDatasetMeta(datasetIndex: any) {
    return this.instance ? this.instance.getDatasetMeta(datasetIndex) : null;
  }
}

injectGlobalStyles('rozie-chart-global', `
.rozie-chart .rozie-chart-tooltip {
    background: rgba(0, 0, 0, 0.8);
    color: #fff;
    border-radius: 4px;
    padding: 6px 8px;
    font-size: 12px;
    transform: translate(-50%, calc(-100% - 8px));
    white-space: nowrap;
  }
`);

Demo source — LineChartDemo.rozie

rozie
<!--
  LineChartDemo.rozie — companion consumer for LineChart.

  Six-way fanout of a "live-updating dashboard chart" demo from one .rozie.

  Exercises:
    - One-way prop reactivity over a deeply-nested object (Chart.js's
      `{ labels, datasets: [{...}] }` shape) — the wrapper's $watch
      reconciles into the live chart instead of re-mounting
    - $computed deriving chart data from raw $data inputs
    - r-for outside any foreign slot — index() auto-invocation safe on Solid
    - Interval-driven $data mutation via $onMount + cleanup (the live-feed
      simulator pushes a new point every 0.8s)
    - Component composition via <components> block
-->

<rozie name="LineChartDemo">

<components>
{
  Chart: '../../packages/ui/chartjs/src/Chart.rozie',
}
</components>

<data>
{
  points: [12, 19, 8, 15, 22, 18, 24],
  liveFeed: true,
  chartType: 'line',
}
</data>

<script>
// The generic Chart no longer auto-registers — consumers register what they use.
// This demo renders line + bar, so register the kitchen sink (== chart.js/auto).
import { Chart as ChartJS, registerables } from 'chart.js'
ChartJS.register(...registerables)

let timerId = null

const chartData = $computed(() => {
  const labels = $data.points.map((_, i) => `T-${$data.points.length - i - 1}`)
  return {
    labels,
    datasets: [{
      label: 'Demand',
      data: $data.points,
      borderColor: 'rgb(59, 130, 246)',
      backgroundColor: 'rgba(59, 130, 246, 0.15)',
      tension: 0.3,
      fill: true,
      pointRadius: 3,
      pointHoverRadius: 5,
    }],
  }
})

const chartOpts = $computed(() => ({
  plugins: {
    legend: { display: true, position: 'top' },
  },
  scales: {
    y: { beginAtZero: true, grid: { color: 'rgba(0, 0, 0, 0.04)' } },
    x: { grid: { display: false } },
  },
}))

const stats = $computed(() => {
  const xs = $data.points
  if (xs.length === 0) return { min: 0, max: 0, avg: 0 }
  const min = Math.min(...xs)
  const max = Math.max(...xs)
  const avg = xs.reduce((a, b) => a + b, 0) / xs.length
  return { min, max, avg: Math.round(avg * 10) / 10 }
})

const pushPoint = () => {
  const next = Math.round(5 + Math.random() * 25)
  $data.points = [...$data.points.slice(-19), next]
}

const reset = () => {
  $data.points = [12, 19, 8, 15, 22, 18, 24]
}

const toggleFeed = () => {
  $data.liveFeed = !$data.liveFeed
}

$onMount(() => {
  // The interval lifecycle is driven by the boolean prop, not by $onMount
  // alone — we start/stop in a $watch so toggling the checkbox flips the
  // feed without remounting the component.
  return () => {
    if (timerId !== null) {
      clearInterval(timerId)
      timerId = null
    }
  }
})

$watch(() => $data.liveFeed, (on) => {
  if (on) {
    if (timerId === null) timerId = setInterval(pushPoint, 800)
  } else {
    if (timerId !== null) {
      clearInterval(timerId)
      timerId = null
    }
  }
}, { immediate: true })
</script>

<template>
<div class="chart-demo">
  <header>
    <h3>Live data feed</h3>
    <p class="hint">Push points manually or toggle the simulated feed. The chart reconciles via <code>$watch</code> — no remount per tick.</p>
  </header>

  <div class="chart-wrap">
    <Chart :data="chartData" :options="chartOpts" :type="$data.chartType" :height="280" update-mode="none" />
  </div>

  <div class="grid">
    <section class="controls">
      <div class="control-row">
        <button type="button" @click="pushPoint">Push point</button>
        <button type="button" @click="reset">Reset</button>
      </div>
      <label class="check">
        <input type="checkbox" r-model="$data.liveFeed" />
        Live feed (0.8s)
      </label>
      <div class="control-row">
        <span class="row-label">Type</span>
        <button type="button" :class="{ active: $data.chartType === 'line' }" @click="$data.chartType = 'line'">Line</button>
        <button type="button" :class="{ active: $data.chartType === 'bar' }" @click="$data.chartType = 'bar'">Bar</button>
      </div>
    </section>

    <aside class="state-pane">
      <h4>Stats</h4>
      <dl class="stats">
        <dt>Count</dt>
        <dd>{{ $data.points.length }}</dd>
        <dt>Min</dt>
        <dd>{{ stats.min }}</dd>
        <dt>Max</dt>
        <dd>{{ stats.max }}</dd>
        <dt>Avg</dt>
        <dd>{{ stats.avg }}</dd>
      </dl>
    </aside>
  </div>
</div>
</template>

<style>
.chart-demo {
  font-family: system-ui, -apple-system, sans-serif;
  color: #1a1a1a;
  padding: 1rem;
  max-width: 720px;
}

header h3 { margin: 0 0 0.25rem; font-size: 1.125rem; }
header .hint { margin: 0 0 1rem; color: rgba(0, 0, 0, 0.55); font-size: 0.875rem; }
header code { background: rgba(0, 0, 0, 0.04); padding: 0.0625rem 0.25rem; border-radius: 2px; font-size: 0.85em; }

.chart-wrap {
  margin-bottom: 1rem;
  padding: 0.5rem;
  border: 1px solid rgba(0, 0, 0, 0.08);
  border-radius: 6px;
  background: white;
}

.grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1.5rem;
  align-items: start;
}

.controls, .state-pane { min-width: 0; }

.control-row {
  display: flex;
  align-items: center;
  gap: 0.375rem;
  margin-bottom: 0.5rem;
}
.row-label {
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: rgba(0, 0, 0, 0.55);
  margin-right: 0.25rem;
}

.controls button {
  padding: 0.375rem 0.75rem;
  border: 1px solid rgba(0, 0, 0, 0.15);
  background: white;
  border-radius: 4px;
  cursor: pointer;
  font: inherit;
  font-size: 0.875rem;
}
.controls button.active {
  background: #1a1a1a;
  color: white;
  border-color: #1a1a1a;
}

.check {
  display: inline-flex;
  align-items: center;
  gap: 0.375rem;
  font-size: 0.875rem;
  margin-bottom: 0.5rem;
  cursor: pointer;
}

.state-pane h4 {
  margin: 0 0 0.5rem;
  font-size: 0.75rem;
  color: rgba(0, 0, 0, 0.55);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

.stats {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 0.375rem 0.75rem;
  margin: 0;
  padding: 0.625rem 0.75rem;
  border: 1px solid rgba(0, 0, 0, 0.08);
  border-radius: 6px;
  font-size: 0.875rem;
}
.stats dt {
  color: rgba(0, 0, 0, 0.55);
  font-weight: 500;
}
.stats dd {
  margin: 0;
  font-variant-numeric: tabular-nums;
}
</style>

</rozie>

Pre-v1.0 — internal monorepo.