# SVG authoring, coordinate, color, and hit-testing quirks

SVG content behaves differently from HTML in ways that don't show up until a
primitive is placed inside a themed, resizable, or bled container. This file
collects the SVG-specific rules, everything else about authoring a
primitive (yaml, tokens, lifecycle) is the rest of this skill's charter, not
repeated here. Load this file when modifying `chart-ui`, `qr-code-ui`,
`icon-ui`, or authoring any NEW primitive whose `class.js` builds `<svg>`
markup (via `document.createElementNS`/`innerHTML`) rather than plain HTML.

## 0. Which primitives actually render SVG (scope check first)

Not every chart-family or chart-adjacent primitive renders SVG, check
before assuming this file applies:

- **Genuinely SVG-rendered**: `chart-ui` (`packages/web-components/components/chart/chart.class.js`, builds a `<svg>` string per chart type, §§#renderBar/#renderLine/etc.), `qr-code-ui` (`packages/web-components/components/qr-code/qr-code.class.js:107-118` + `qr-encoder.js:609-631`'s `matrixToSVG`), `icon-ui` (`packages/web-components/components/icon/icon.class.js:97-98`, stamps a Phosphor `<svg>` string via `getIcon()`).
- **NOT SVG** despite living in the chart family: `chart-legend-ui` (`packages/web-components/components/chart-legend/chart-legend.class.js`, composes `<badge-ui>` + `<swatch-ui>`, no `<svg>` anywhere) and `swatch-ui` (`packages/web-components/components/swatch/swatch.class.js`, plain `<span data-tile>` divs styled via CSS `background`/`border`, confirmed by `grep -rn svg` returning nothing in either file). gh#1344's own body assumed `chart-legend-ui` was SVG-adjacent; it isn't, its swatch shapes (dot/square/line/dashed) are CSS box-model tricks, not paths.

A future primitive whose `class.js` calls `createElementNS('http://www.w3.org/2000/svg', ...)` or sets `innerHTML` to a string containing `<svg>` is in scope for every rule below; one that only composes other `*-ui` elements (however chart-shaped visually) is not.

## 1. viewBox is a coordinate system, not a size, two sizing strategies coexist

`viewBox="minX minY width height"` defines the SVG's INTERNAL coordinate
system; the element's rendered box size is separate (CSS `width`/`height` or
SVG `width`/`height` attributes). Every number emitted into the SVG markup
(`x`, `y`, `r`, `stroke-width`, `font-size`) is in viewBox units, not CSS
pixels, the browser scales the whole coordinate system to fit the rendered
box (`preserveAspectRatio`, default `xMidYMid meet`).

Two different sizing strategies are in use, deliberately:

- **`chart-ui`, responsive viewBox, CSS owns the box.** `chart.css:143-149` sets `svg { width: 100%; height: auto; max-height: 100%; overflow: visible }`; `#dims()` (`chart.class.js:405-457`) computes `width`/`height` FROM `this.clientWidth`/`clientHeight` every render, and `#renderChart()` sets `viewBox="0 0 ${width} ${height}"` (e.g. `chart.class.js:523`) to match. Because the viewBox is recomputed from the actual container size on every render, viewBox units and CSS px are numerically equal in the steady state, a `stroke-width: 2` in `chart.css:199` reads as 2 real px. A `ResizeObserver` (`chart.class.js:363-380`, debounced via `requestAnimationFrame`) keeps this in sync across container resizes; there's a brief window between a resize and the debounced re-render where the OLD viewBox is still active against the NEW box size, during which strokes/dots/fonts visually scale up or down with the mismatch: this is inherent to the responsive-viewBox strategy, not a bug to fix per-primitive.
- **`qr-code-ui`, fixed pixel viewBox, explicit width/height attributes.** `matrixToSVG` (`qr-encoder.js:609-631`) sets `viewBox="0 0 ${total} ${total}"` AND `width="${total}" height="${total}"` (equal, so no scaling happens at generation time); `qr-code.class.js:123-127` then overwrites the `width`/`height` ATTRIBUTES (not CSS) to the `[size]` prop after `innerHTML` is set. `qr-code.css:23-28` documents why it does NOT use `width: 100%`: the host is `display: block` sized-to-content (the SVG itself), so a CSS-percentage width on the SVG would create a circular sizing dependency, "trust the SVG attributes" is the comment's own words.

**When authoring a new SVG primitive**, pick one of these two strategies deliberately and document which: responsive-viewBox (chart-ui's approach, needed when the primitive must fill an arbitrary, resizable container) or fixed-attribute (qr-code-ui's approach, needed when the primitive has a scannable/pixel-exact payload where uncontrolled scaling would break fidelity, and the `[size]` prop is the only sizing lever a consumer needs).

## 2. `stroke-width` and other bare numbers scale with the coordinate system

Because `stroke-width`, circle `r`, and `font-size` values written into the
SVG markup are viewBox-unit numbers (see §1), they are NOT the same kind of
value as a CSS `border-width` or `font-size` on an HTML element, an HTML
border stays a fixed px regardless of ancestor `transform: scale()` (the
border itself doesn't get bigger, only the box does); an SVG stroke drawn in
viewBox units scales proportionally with ANY transform that changes the
effective viewBox-to-rendered-size ratio, including a CSS `transform: scale()`
on the `<svg>` or an ancestor, and including the responsive-viewBox mismatch
window described in §1. `chart.css:198-203`'s `[data-line] { stroke-width:
var(--chart-line-width) }` (unitless, SVG interprets an unadorned number as
user units) is a concrete example: at steady state this renders at the CSS
`--chart-line-width` value in real px, but during a `transform: scale(1.5)`
hover-zoom on a chart card it renders at 1.5× that, same as every other
number in the shape's geometry, there is no way to pin stroke-width to a
fixed screen px independent of the coordinate system short of
`vector-effect: non-scaling-stroke` (not used anywhere in this codebase
today, flag it if a future primitive needs scale-independent strokes).

## 3. `text-anchor`/`dominant-baseline` position an anchor POINT, not a box corner

SVG `<text>` has no intrinsic box model, `x`/`y` mark a single anchor
point, and `text-anchor`/`dominant-baseline` say which part of the glyph run
sits at that point. Getting this wrong is the single most common SVG label
bug (text drifts off its intended mark as content length changes). Every
label renderer in `chart.class.js` picks the anchor deliberately:

- **Y-axis labels**, `text-anchor="end"` (`chart.class.js:1053`): the anchor point sits at the RIGHT edge of the label so labels of different digit-widths ("5", "5,000") stay right-aligned against the axis rather than growing rightward from a fixed left point.
- **X-axis / value labels**, `text-anchor="middle"` (`chart.class.js:1068`, `1109`): centers over each bar/point regardless of label width.
- **Donut center total/label, gauge value, funnel stage/value/drop, radar labels, sankey node labels**, `dominant-baseline="central"` (e.g. `chart.class.js:1270-1271`, `1585,1587`, `1642-1648`, `1357`, `1846,1852`): vertically centers the glyph on its `y` coordinate, needed anywhere a label sits beside or inside a shape whose center, not its top, is the meaningful reference point (a donut's numeric center, a radial label at a computed angle).
- **Treemap tile labels, `dominant-baseline` switches per available space** (`chart.class.js:1766-1771`): `hanging` (top-aligned, the default vertical-metrics baseline) for a "tall" tile where label + value stack top-down, `central` for a "short" tile where only the label fits and it should sit mid-height rather than clipped against the top edge. Pick the baseline that matches the layout decision, not a single default for the whole primitive.

Rule of thumb: `text-anchor` picks the horizontal anchor (`start`/`middle`/`end`), `dominant-baseline` picks the vertical one (`hanging`/`central`/`middle`/the default alphabetic baseline), set both explicitly whenever a label's position depends on computed geometry rather than a fixed corner.

## 4. Card-boundary clipping, `overflow: visible` is the default; a bleed section changes the contract

`chart.css:143-149`'s `svg { overflow: visible }` is intentional: chart
labels routinely extend slightly past the nominal plot rectangle (Y-axis
labels sit at `pad.left - 4`, per §3), and `overflow: visible` lets that
render instead of clipping at the SVG's own box edge. That default is safe
inside a normally-inset `card-ui` section. It stops being safe the moment
the SAME chart sits inside a `<section bleed>`, `card-ui`'s `:scope` itself
clips at `overflow: hidden` with a rounded `border-radius` (`card.css`, top
of file), and `[bleed]` zeroes the section's own margin/padding
(`card.css:342` onward), so a chart's axis-label overhang, or gridlines
extending to the plot edge, lands flush against that rounded corner and
clips silently. This was gh#1095's original incident (PR #1105): a
bar/line chart's Y-axis labels clipped under a bled card's corner.

**gh#1095's own mechanical fix, a `:has()`-based auto-restore of the
card's inset, was itself unratified and removed (gh#1801, operator ruling
2026-08-20)**: it silently defeated an author's own `[bleed]` the moment a
chart drew any guide/value text or paired a `<chart-legend-ui>`, which
collided with the card-chart design language's overlay-chip labels
(rendered INSET within the plot box on purpose, but still enough to trip
the old guard's `:not([no-grid])`/`:not([no-values])` test). `[bleed]` is
now unconditionally author-controlled, card.css never re-inserts an inset
the author explicitly zeroed.

**The clipping hazard itself is real and unchanged**, only the mitigation
moved from mechanical CSS to documented author responsibility. card.yaml's
`bleed` prop docs and `chart-in-card.examples.html` both carry the
resulting rule: a full-bleed chart-ui with visible guide/value text, or one
paired with a `<chart-legend-ui>`, must keep that text clear of the card's
rounded-corner clip, e.g. by rendering it as an overlay chip INSET within
the plot area (never hanging outside it, and never relying on card margin
for clearance), or by putting the legend in its own non-bled section.
`#renderSparkline()` is still the only renderer that never emits axis
ticks, gridlines, value text, or a legend (`chart.class.js`'s sparkline
branch), so it's still the one type where a bare `[bleed]` needs no such
care; every other type is the author's call now, not a stylesheet fail-safe.

**The generalized rule for any new SVG primitive placed inside a bleed
section**: an SVG whose content can extend past its own nominal box
(`overflow: visible`, or geometry computed with negative padding) needs
either a "bare marks" mode (no overhanging content) that's safe to bleed,
or a documented author-responsibility note at the point of use (the
`bleed`-prop docs, the pattern's own examples) naming the clipping hazard
and its mitigation, never a mechanical CSS guard that silently overrides
an author's own explicit attribute (gh#1801's own lesson). Don't assume
`overflow: hidden` on the ancestor container will clip cleanly, SVG content
drawn PAST an ancestor's padding box (not its own) clips at whatever
ancestor in the chain actually sets `overflow: hidden`, which for
`card-ui` is the rounded-corner boundary itself, producing the specific
silently-clipped-under-a-curve look #1095 originally reported.

## 5. CSS custom properties don't resolve inside raw SVG attribute strings, only inside actual CSS declarations

A CSS custom property (`var(--foo)`) only resolves where the CSS cascade
parses it: inside a stylesheet rule, or inside an inline `style="..."`
attribute. It does NOT resolve inside an arbitrary SVG presentation
attribute value written as a plain string (`fill="var(--foo)"` is invalid: the literal text `var(--foo)` is not a recognized SVG color, and the shape
renders with the initial/inherited fill instead, silently). `currentColor`
is different: it's a CSS-wide keyword the SVG spec itself recognizes inside
presentation attributes, and it resolves against the computed `color`
property the normal way, so `fill="currentColor"` written directly into
markup DOES cascade correctly. `icon-ui` relies on exactly this: the
installed Phosphor SVGs ship `fill="currentColor"` on their root `<svg>`
(confirmed: `node_modules/@phosphor-icons/core/assets/regular/caret-right.svg`, `<svg ... fill="currentColor">`), and `icon.css`'s `:scope { color:
var(--icon-color) }` (`icon.css:11`) drives it through the ordinary
`color` inheritance chain, no `var()` inside the SVG markup is needed
because `currentColor` isn't a custom property.

`chart-ui` hit this distinction directly and got it wrong once (gh#561,
documented in `chart.class.js:548-560`): an earlier version wrote
`--color-{key}: var(--chart-N)` as an inline STYLE on the chart HOST, then
tried to reference `--color-{key}` from series-colored shapes, but because
inline styles win the cascade over everything except `!important`, a
consumer's own `--color-MAU` set on an ancestor lost to the chart's own
inline default, making the documented "override `--color-{key}` to recolor
a series" hook unusable. **The fix, and the pattern to follow**: never set
the color custom property on the host; instead emit it as an inline `style`
attribute ON THE SHAPE ITSELF, with the fallback chain built into the same
declaration, `#seriesFill()`/`#seriesStroke()` (`chart.class.js:568-575`)
emit ` style="fill: var(--color-${seriesKey}, var(--chart-${slotIdx}))"` per
`<path>`/`<circle>`. Because this IS a real CSS declaration (inside
`style=""`), `var()` resolves normally, an ancestor-set `--color-{key}`
flows through the cascade and wins, and an unset one falls through to the
palette slot, exactly the semantics a bare attribute string can't provide.

**`qr-code-ui` shows the failure mode `chart-ui` avoided**: `qr-code.css:7-8`
declares `--qr-code-fg: currentColor` / `--qr-code-bg: transparent` and sets
them as `color`/`background` on the HOST (`qr-code.css:17-18`), but the
actual QR modules are painted via `matrixToSVG` (`qr-encoder.js:609-631`),
which bakes `fill="${fg}"`/`fill="${bg}"` as literal hex strings
(`options.color || '#000'`, `qr-code.class.js:115-116` passes
`this.color || '#000000'`) directly into the generated markup at render
time. The `--qr-code-fg`/`--qr-code-bg` tokens are real and declared, but
nothing in the render path ever reads them, setting `color` on an ancestor
of a default `<qr-code-ui>` does nothing to its rendered fill; only the
explicit `[color]`/`[background]` HTML attributes do (and deliberately so, the code comment at `qr-code.class.js:110-115` explains theme-aware
`currentColor` would produce light-on-dark QR codes that most phone cameras
refuse to scan). **When authoring a new SVG primitive with a
"theming token" in its CSS, verify the render path actually consumes it as
a live CSS value (inline `style=` per shape, or a bare `currentColor`
keyword) rather than baking a computed color into the generated markup as a
one-time string, a declared-but-dead token is a real trap for the next
author who tries to theme the primitive from outside.**

## 6. Hit-testing: `fill: transparent` is clickable, `fill: none` is not

SVG's default `pointer-events: visiblePainted` treats a shape as
hit-testable only if it's "painted", `fill: transparent` counts as painted
(alpha-zero, but still a fill), `fill: none` does not. `chart.css:381-386`
states this explicitly as the reason its hit-target circles are always
`fill: transparent !important` rather than `fill: none`:

```css
/* Hit-target overlays must never be filled by the slice palette, they're meant to be invisible pointer-event surfaces. */
circle[data-hit] {
  fill: transparent !important;
  stroke: none;
}
```

This matters most for THIN shapes, a `<path data-line>` stroke has almost
zero hit area along its own geometry, so `chart.class.js` renders a SEPARATE,
generously-radiused invisible circle per point (`data-hit`, `hitR =
Math.max(dotR, 10)` at `chart.class.js:1157/1165`, similarly `1450` for
scatter) purely to catch pointer/click events, decoupled from the visible
dot's actual radius. The average-line overlay is the same pattern applied to
a LINE instead of a point: the visible dashed average line is `stroke-width:
1.5` (`chart.css:259-263`), effectively unclickable, so a second invisible
line with `stroke: transparent stroke-width="12"` rides on top purely for
hit area (`chart.class.js:1119,1177`, labeled "Wider invisible hit target so
the thin dashed line is hoverable" in the source comment). By contrast,
FILLED shapes with real area, bars (`<path data-bar>`), pie/donut slices,
radial-bar arcs, carry their `tip()` data attributes directly on the
visible shape (`chart.class.js:1106`, `1209`, `1514`) with no separate hit
overlay needed, because the visible fill already satisfies
`visiblePainted`.

**Rule for a new SVG primitive**: any interactive target whose visible
stroke/fill area is too thin or too small to reliably hit with a pointer
needs an invisible, generously-sized `fill: transparent` (never `fill:
none`) overlay shape carrying the actual event data, don't rely on the
visible geometry's own hit area once its rendered stroke-width or radius
drops below a comfortable pointer target size (chart-ui's overlays use
`r ≥ 10`, `stroke-width ≥ 12` as the floor).

## 7. `shape-rendering`, pixel-grid content vs. smooth curves

`qr-code.css:29`/`qr-encoder.js:629` set `shape-rendering: crispEdges` on
the generated QR `<svg>`, this disables anti-aliasing so each QR module
renders as a hard-edged square rather than a slightly blurred one, which
matters for scanner reliability (soft edges reduce contrast at module
boundaries a camera decoder relies on). `chart-ui` sets no `shape-rendering`
override anywhere, its curves (`smoothPath`'s Catmull-Rom bezier
conversion, `chart.class.js:158-180`) are meant to anti-alias normally.
**When authoring a new SVG primitive rendering a hard pixel/module grid
(a matrix code, a pixel-art preview, anything where edge crispness affects
correctness rather than just aesthetics), set `shape-rendering: crispEdges`
explicitly**, the browser default (`auto`, effectively anti-aliased) is
correct for everything else and should stay the default.

## 8. Attribute-shadowing on SVG-adjacent primitives (ADR-0053/0054/0070)

Two of the global-attribute-grammar collisions gh#1335 surfaced are
specifically SVG-rendered primitives; both exemptions are **GRANTED**
(ADR-0070, ratified 2026-08-17), closing the last 2 of gh#1335's 17 and
emptying `check-attribute-shadowing.mjs`'s `KNOWN_FINDINGS`, the gate now
enforces via `attribute-api-system.md`'s ratified §11 table alone:

- **`qr-code-ui[color]`**, a free-form CSS color string (drives the raw
  `fill` baked into the generated matrix SVG, §5 above), structurally
  identical to the ratified `swatch-ui`/`noodles-ui[color]` §11 exemptions
  ("the component's entire subject is a color"). The exemption covers the
  free-form value space AND the hardcoded `#000000` scanability fallback
  (deliberately never theme-derived, light-on-dark won't scan), plus its
  contract pairing with `[background]`.
- **`icon-ui[weight]`**, Phosphor's own glyph-variant vocabulary
  (`thin/light/regular/bold/fill/duotone`, selecting which pre-rendered SVG
  set `getIcon()` loads) is a DIFFERENT CONCEPT from CSS `font-weight`
  despite the shared name; the global utility's `font-weight` is inert on
  the inline SVG, so the collision has no cascade effect.

Known residual (documented in the §11 justification, accepted not fixed):
global-only `weight` values (`normal|medium|semibold`) select no Phosphor
set and fall back to `regular`; a semantic-enum value on `qr-code-ui[color]`
(`color="danger"`) passes to the SVG fill verbatim, unresolved through
tokens. Cite this section, ADR-0070, not a fresh investigation, if either
attribute surfaces again in a yaml audit; the next colliding
`color`/`weight` on any OTHER component still needs its own ADR.

## When to load this file

Any authoring task touching `chart-ui`, `qr-code-ui`, `icon-ui`, or a new
primitive whose `class.js` emits `<svg>` markup, a new chart type, a
label-positioning fix, a card-bleed interaction, a hit-target bug, or a
color/theming prop on an SVG-rendered primitive. NOT for `chart-legend-ui`
or `swatch-ui` (§0), those are HTML/CSS primitives despite the chart-family
name; their authoring questions route through the general
[css-patterns.md](css-patterns.md) / [api-contract.md](api-contract.md)
same as any other component. The `--chart-*` and `--qr-code-*` TOKEN
declarations themselves (naming, `:where(:scope)` placement) still follow
[token-contract.md](token-contract.md), this file covers only what's
SVG-specific once those tokens reach the render path. The ≤2px raw
`stroke-width` carve-out in [css-patterns.md](css-patterns.md)'s "Raw
values" section is the general rule this file's §2 explains the SVG-specific
mechanism behind, cite both together when a stroke-width literal comes up
in review.
