# Client-side charting for Libertas protocol data

This is the V1 chart contract for schema authors and compatible clients.
Schema support and renderer availability are distinct: a client that cannot
display a chart follows the [chart failure policy](#10-validation-and-fallback).
This reference does not certify feature parity for every distributed build.

<!-- rule: chart.data.value-schema class=hard enforced=no tasks=client-runtime -->
Presentation uses the schema, exact dependency versions, and locale document
associated with the returned value; unrelated installed versions do not
redefine that value. See the [schema reference](../schema.md).

## 1. Purpose

A Libertas protocol endpoint can return data that Android and iOS render as a
chart instead of only as a generic data tree. <!-- rule: chart.data.app-computes class=hard enforced=no tasks=charts --> The App task remains responsible
for all domain calculations—including arbitrary time transforms—and can return
an arbitrarily transformed but strictly typed payload. The client is responsible
only for scales, layout,
primitive marks, guides, accessibility, and standard chart interaction.

This division removes the largest and riskiest parts of a Vega-Lite grammar from
the client. There are no client expressions, transforms, joins, data loaders, or
arbitrary field paths. The server returns a complete chart-ready snapshot, and
the schema describes how direct fields in that snapshot map to visual channels.

This document's two-dimensional schema protocol uses four language-neutral
schema attribute families:

| Schema attribute | Placement | Purpose |
| --- | --- | --- |
| `Chart` | A chart-data `List`, primitive carrier `Table`, or composition `Table` | Select a primitive mark or composition. A protocol Union variant may instead wrap one such chart field plus direct `Hidden` fields. |
| `ChartChannel` | A scalar row field | Map the field to one or more visual roles. |
| `ChartScale` | A field with a scale-bearing channel | Override automatic scale behavior or share a scale. |
| `ChartGuide` | A direct scalar chart-row field | Configure, suppress, or source a generic guide. |

V1 also defines the strictly parallel `Chart3D`, `Chart3DChannel`,
`Chart3DScale`, `Chart3DGuide`, and `Chart3DView` namespace in the
[3D chart contract](3d.md). Those five attributes do not
extend or change any `Chart*` token or behavior. A presented result cannot mix
the two namespaces, so an older client never mistakes a 3D result for a 2D
chart and instead follows the chart failure policy in §10.

<!-- rule: chart.data.scope-limits class=hard enforced=no tasks=charts -->
These attributes target **broad chart-family coverage for server-prepared
data**. They do not provide Vega-Lite syntax compatibility, pixel compatibility,
or complete feature parity. In particular, they do not reproduce Vega's runtime
language, signals, arbitrary event expressions, client-side transforms,
geographic coordinate/projection/topology semantics, raster/image marks, or a
general widget system. A server-projected geographic region is ordinary
polygon geometry and carries no client-side geographic meaning.

This document defines the complete two-dimensional `Chart*` contract. The
TypeScript and Rust parsers extract the four source annotations into
language-neutral schema attributes. Libertas Studio's shared validator
canonicalizes those attributes and owns every chart semantic rule decidable
from the extracted schema and its materialized dependencies. The Hub carries
them as ordinary schema attributes
without a renderer, chart-specific message, or chart-specific persistence
path. Rendering clients implement this V1 contract, including the chart failure
policy in §10. The concise attribute
inventory is in the [Libertas schema reference](../schema.md),
with source syntax in the [TypeScript application manual](../languages/typescript.md)
and [Rust application manual](../languages/rust.md).



## 2. Three distinct attribute layers

<!-- rule: chart.layers.separate-responsibilities class=hard enforced=no tasks=charts -->
The word "attribute" refers to three related but different things in Libertas.
Chart support must keep them separate.

### 2.1 Source-language annotations

App authors use the native annotation mechanism of their language. The exact
spellings are:

```typescript
/**
 * Temperature history
 * Recent temperature measurements over time.
 * @LibertasChart(line)
 */
export type TemperatureChart = TemperaturePoint[];

/**
 * Temperature sample
 * One timestamped temperature measurement.
 */
export interface TemperaturePoint {
  /**
   * Sample time
   * The time at which this measurement was taken.
   * @LibertasChartChannel(x, tooltip)
   * @LibertasChartScale(id=time, kind=utc)
   */
  time: LibertasDateTime;

  /**
   * Temperature
   * The measured temperature.
   * @LibertasChartChannel(y, tooltip)
   * @LibertasChartScale(id=temperature, kind=linear, zero=false)
   */
  temperature: number;
}
```

```rust
/// Temperature history
/// Recent temperature measurements over time.
#[libertas_chart(line)]
pub type TemperatureChart = Vec<TemperaturePoint>;

/// Temperature sample
/// One timestamped temperature measurement.
#[derive(LibertasExport, LibertasAvroDecode)]
pub struct TemperaturePoint {
    /// Sample time
    /// The time at which this measurement was taken.
    #[libertas_chart_channel(x, tooltip)]
    #[libertas_chart_scale(id = time, kind = utc)]
    pub time: LibertasDateTime,

    /// Temperature
    /// The measured temperature.
    #[libertas_chart_channel(y, tooltip)]
    #[libertas_chart_scale(
        id = temperature,
        kind = linear,
        zero = false
    )]
    pub temperature: f64,
}
```

The TypeScript and Rust spellings do not need to be byte-for-byte identical. <!-- rule: chart.data.kind-not-type class=hard enforced=yes tasks=charts title="Source annotations use `kind`; the generated scale property is `type`" -->
For example, both use the source parameter `kind` even though the generated
scale property is named `type`. Each parser owns its native annotation syntax,
resolution, traversal, and language-neutral extraction. Studio's shared
validator owns semantic validation and produces the same canonical final
schema result from equivalent declarations.

### 2.2 Generated language-neutral schema attributes

After Studio validation, the source examples above produce ordinary Libertas
`{Name, Value}` schema attributes. Rendering clients and the Hub never need to
parse TypeScript JSDoc or Rust macro syntax.

```json
[
  {
    "NativeName": "TemperatureChart",
    "Type": "List",
    "Children": [
      {
        "NativeName": "",
        "Type": "TemperaturePoint",
        "BuiltinType": false,
        "Children": [],
        "Attributes": []
      }
    ],
    "Attributes": [
      { "Name": "Chart", "Value": "line" }
    ]
  },
  {
    "NativeName": "TemperaturePoint",
    "Type": "Table",
    "Children": [
      {
        "NativeName": "time",
        "Type": "DateTime",
        "Attributes": [
          { "Name": "ChartChannel", "Value": "x,tooltip" },
          { "Name": "ChartScale", "Value": "id=time,type=utc" }
        ]
      },
      {
        "NativeName": "temperature",
        "Type": "Number",
        "Attributes": [
          { "Name": "ChartChannel", "Value": "y,tooltip" },
          {
            "Name": "ChartScale",
            "Value": "id=temperature,type=linear,zero=false"
          }
        ]
      }
    ],
    "Attributes": []
  }
]
```

The selected type nodes are abbreviated; all normal schema fields remain
present. See [Display Template rules](../presentation.md#display-template).  The example is the validated parser result. Scale `type` and guide
`position` are chart appearance: Studio then compiles them into the package's
[Display Template](../presentation.md#display-template) as `scale` and `guide` hints and omits them from
the final schema, and clients read them from the template. Every rule in this
specification about those properties applies to their effective value, whether
it comes from source or from a template rule. The canonical schema values use stable, case-sensitive tokens, stable
key ordering, no optional whitespace, and no language-specific spelling. Mark,
composition, and scale-enum tokens are lowercase; multiword channel tokens use
the exact lower-camel-case spelling defined in this document.

### 2.3 Generated localized document

The same short and long comments generate a separate default-locale document.
The selected entries below are abbreviated; numeric field `Type` values are
schema-node IDs as in the existing document format.

```json
{
  "DocumentVersion": "0.0.2",
  "Locale": "en",
  "StringResources": {},
  "Types": [
    {
      "Name": "TemperatureChart",
      "DisplayName": "Temperature history",
      "Description": "Recent temperature measurements over time.",
      "List": []
    },
    {
      "Name": "TemperaturePoint",
      "DisplayName": "Temperature sample",
      "Description": "One timestamped temperature measurement.",
      "List": [
        {
          "Type": 1,
          "Name": "Sample time",
          "Description": "The time at which this measurement was taken."
        },
        {
          "Type": 2,
          "Name": "Temperature",
          "Description": "The measured temperature."
        }
      ]
    }
  ]
}
```

This document contains presentation text, not chart semantics. Its translations
may change independently of the canonical `Chart*` attributes. Section 8.1
defines the deliberately soft dependency between this existing document model
and chart presentation.

### 2.4 Client interpretation

<!-- rule: chart.data.spec-from-schema class=hard enforced=no tasks=client-runtime -->
Android and iOS build the semantic `ChartSpec` from only the generated schema
and decoded `ScriptValue` tree. The presentation layer may then decorate that
model with the already-resolved localized document. A client must not infer
chart behavior from document prose, source-language text, or Task Editor
presentation rows.

This separation has four important consequences:

1. TypeScript and Rust packages produce equivalent charts when their generated
   schemas are equivalent.
2. No chart-specific protocol message or payload byte is required.
3. <!-- rule: chart.data.atomic class=hard enforced=no tasks=charts --> Chart presentation is atomic: a consumer either validates the complete
   current contract or presents the existing ordinary data view.
4. Parser, schema, and client conformance can be tested independently.

## 3. Data contract and placement

### 3.1 Chart metadata belongs to returned data

<!-- rule: chart.data.response-payload-only class=hard enforced=yes tasks=charts title="Chart attributes go on a response or subscription-data payload type" -->
Chart attributes belong to a protocol response or subscription-data payload
type, not to the Endpoint task parameter. The Endpoint identifies and routes the
server. The response schema describes the data returned by a particular
protocol contract.

<!-- rule: chart.data.payload-lookup class=hard enforced=no tasks=client-runtime -->
The presentation path checks only the effective type of the `ScriptValue`
selected as the response or subscription payload. If that effective value does
not carry `Chart`, the ordinary data view is shown. A client must not recursively
hunt for an arbitrary annotated descendant. For a Union response, the client
first resolves the selected concrete variant and then checks that effective
variant value.

A unit chart is a complete snapshot with either this effective shape:

```text
List<Table Row>
```

<!-- rule: chart.data.unit-shape class=hard enforced=yes tasks=charts title="A unit chart is a List of row Tables; the List carries the mark" -->
The `List` carries `Chart=<mark>`. Direct scalar fields of `Row` carry
`ChartChannel`, and optionally `ChartScale` and `ChartGuide`. The server may
duplicate values in the row when one logical value needs multiple independent
encodings.

<!-- rule: chart.data.hidden-row-allowed class=hard enforced=no tasks=charts -->
A row field may carry `Hidden=true`. It is chart data, not presentation: the
client keeps it in the retained row value, so a custom chart request can
CopyFrom it. <!-- rule: chart.data.hidden-row-field class=hard enforced=yes tasks=charts title="A Hidden row field carries no ChartChannel, ChartScale or ChartGuide" --> Chart presentation data is never Hidden: a row field
carrying `Hidden=true` cannot carry `ChartChannel`, `ChartScale`, or
`ChartGuide`.

Or the mark may be placed on a carrier table:

```text
Table UnitCarrier carrying Chart=<mark> {
    data: List<Table Row>
    transactionContext: any value carrying Hidden=true
    ...
}
```

<!-- rule: chart.data.carrier-table class=hard enforced=yes tasks=charts title="A carrier Table has exactly one non-hidden List field; every other field is Hidden" -->
`data` is the carrier's exactly one non-hidden direct field and is the real
chart data. It does not repeat `Chart`; the enclosing table supplies the mark.
Every other direct field must carry `Hidden=true`. <!-- /rule --> <!-- rule: chart.data.carrier-context class=hard enforced=no tasks=charts --> Hidden carrier fields may
use any otherwise valid Libertas type and remain ordinary encoded response
data. They do not become rows, channels, guides, titles, tooltips,
accessibility content, interaction identities, or chart resource usage.
Their primary purpose is request/response transaction context analogous to an
HTTP cookie: the client retains the complete original response tree so a later
typed `NextRequest` or `CopyFrom` operation can copy the context back without
displaying it or requiring the App to duplicate it in every chart row.

A composition has this effective shape:

```text
Table Composition {
    childA: List<Table RowA> carrying Chart=<mark>
    childB: List<Table RowB> carrying Chart=<mark>
    transactionContext: any value carrying Hidden=true
    ...
}
```

<!-- rule: chart.compose.shape class=hard enforced=yes tasks=charts title="A composition Table holds at least one chart child; every other child is Hidden" -->
The composition `Table` carries `Chart=layer`, `hconcat`, or `vconcat`.
It must contain at least one chart-bearing direct child. Chart-bearing children
are traversed in schema order; every direct child that does not independently
carry `Chart` must carry `Hidden=true` and is retained only as ordinary carrier
data. The same rule applies recursively to nested compositions.

<!-- rule: chart.data.variant-wrapper class=hard enforced=yes tasks=charts title="A chart-wrapping Union variant has exactly one chart field; every other field is Hidden" -->
A `Response` or `SubscriptionData` Union variant may also be a carrier table
without carrying `Chart` itself. In that form it has exactly one direct field
whose effective type independently carries `Chart`, and every other direct
field carries `Hidden=true`. The client projects that one chart field while
retaining the complete selected variant as the authoritative transaction
source. A variant that carries `Chart` itself follows the unit-carrier or
composition rules above. Clients never search deeper or choose among multiple
chart fields; Apps use an explicit composition when one result contains
multiple charts.

<!-- rule: chart.data.title-from-variant class=hard enforced=no tasks=charts -->
For a protocol chart, the selected `Response` or `SubscriptionData` variant's
localized name and description supply the overall chart title and description,
including when its chart payload is a child field. Unwrapping the payload does
not replace that wording with the child field's documentation. Charts nested
inside a composition retain their own localized names and descriptions.

### 3.2 Complete snapshots

<!-- rule: chart.snapshot.replace class=hard enforced=no tasks=charts -->
Every response, and every complete subscription snapshot, replaces the previous
chart snapshot. A subscription report may instead update the working value
through the shared SubscriptionData mechanisms: an accepted
[append report](../protocols.md#live-chart-use) extends the chart-bearing
working array, and a
[reduced SubscriptionData](../protocols.md#reduced-subscriptiondata-design-pattern)
report updates the stable value it copies to. The chart presents the complete
resulting value. The client does not otherwise join, patch, or merge chart
rows. An empty list is a valid empty chart when the schema is valid.

<!-- rule: chart.snapshot.no-new-message class=hard enforced=no tasks=charts -->
Existing protocol response and subscription routing is unchanged: the normal
response reaches the originating client, and chart metadata arrives with the
App schema rather than in a new Hub/client message or in every payload.

<!-- rule: chart.snapshot.request-new-snapshot class=hard enforced=no tasks=charts -->
If a chart must react to a user choice, the client sends a typed Libertas
request and the endpoint returns another complete snapshot. Filtering,
aggregation, binning, sorting, stacking, window calculation, regression,
projection, and sampling therefore remain ordinary App code.

### 3.3 Direct row fields only

<!-- rule: chart.data.direct-row-fields class=hard enforced=yes tasks=charts title="Chart channels go only on direct scalar fields of the chart row" -->
A `ChartChannel` applies to a direct scalar field of the chart row. Arbitrary
paths into nested tables are not supported. The endpoint flattens nested domain
data into chart rows before returning it.

This rule keeps channel resolution deterministic, makes validation bounded, and
prevents a second expression/path language from entering the schema. It also
allows a client to retain the exact row `ScriptValue` for tooltips and the
complete response or selected Union variant—including hidden carrier
fields—for typed follow-up requests.

### 3.4 The source tree is authoritative

<!-- rule: chart.data.source-tree class=hard enforced=no tasks=charts -->
Clients traverse the decoded `ScriptValue` tree, including the selected concrete
Union variant. They must not build chart data from Task Editor visible rows.
For carrier tables they project only the contract-selected chart child while
retaining the unmodified root tree as the request context. `Hidden` removes a
carrier field from chart projection even when its referenced effective type
also carries `Chart`; it does not remove, redact, re-encode, or detach that
value from the response. Features such as the Display Template's `promote` and `flatten` can promote,
flatten, or hide presentation rows but do not change the underlying chart shape.

## 4. `Chart`

<!-- rule: chart.mark.one-token class=hard enforced=yes tasks=charts title="Chart has exactly one lowercase token as its generated Value" -->
`Chart` has exactly one lowercase token as its generated `Value`.

### 4.1 Primitive marks

| Value | Required channels | Additional geometry/grouping | Notes |
| --- | --- | --- | --- |
| `point` | `x,y`, or `theta,radius` | Cartesian offsets when valid | Cartesian or polar scatterplots and symbol layers. Labels use a separate `text` layer. |
| `line` | `x,y`, or `theta,radius` | Path grouping and order | Cartesian or polar paths. Null positional values split a path. |
| `area` | `x,y` | Exactly one of `y2` or `x2`; path grouping and order | `y2` creates a vertical band; `x2` creates a horizontal band. |
| `bar` | `x,y`, or a ranged form | `x2`, `y2`, `xOffset`, `yOffset` where valid | A semantic rectangle with bar defaults. |
| `rect` | Ranged coordinates or banded `x,y` | `x2`, `y2`, `xOffset`, `yOffset` where valid | Heatmaps, ranges, candle bodies, tiles. |
| `rule` | Cartesian or polar endpoint form | Endpoint roles only | Ranges, ticks, and links. Section 5.3 defines both endpoint forms. |
| `arc` | `theta,theta2` | Optional pair `radius,radius2` | Pie, donut, radial bar, and polar ranges. |
| `text` | `x,y,text`, or `theta,radius,text` | Cartesian offsets when valid | Cartesian or polar labels and custom annotations. |
| `polygon` | `x,y`, or `theta,radius` | Path grouping and order | One or more closed filled subpaths, including radar regions, funnels, projected regions, and server-sampled contours. |

<!-- rule: chart.mark.shared-channels class=hard enforced=no tasks=charts -->
`row`, `column`, `tooltip`, `detail`, `order`, and `key` are available to every
mark under their ordinary facet, grouping, ordering, and interaction rules.
Cartesian offsets remain subject to the discrete parent-band rules in §5.3.
Appearance applicability is closed:

| Mark | Allowed appearance channels |
| --- | --- |
| `point` | `color,fill,stroke,opacity,fillOpacity,strokeOpacity,size,shape,angle,strokeWidth,strokeDash` |
| `line` | `color,stroke,opacity,strokeOpacity,strokeWidth,strokeDash` |
| `area`, `polygon`, `bar`, `rect`, `arc` | `color,fill,stroke,opacity,fillOpacity,strokeOpacity,strokeWidth,strokeDash` |
| `rule` | `color,stroke,opacity,strokeOpacity,strokeWidth,strokeDash` |
| `text` | `color,fill,opacity,fillOpacity,size,angle` |

<!-- rule: chart.mark.appearance-closed class=hard enforced=yes tasks=charts title="Each mark accepts only the appearance channels in its row of the table" -->
An appearance channel outside its mark row is invalid rather than inert. Every
implicit appearance guide therefore describes an encoding that the mark
actually paints.

<!-- rule: chart.mark.bar-lowering class=hard enforced=no tasks=client-runtime -->
`bar` is a convenience. A conforming implementation may lower it to `rect`
before rendering. This keeps the primitive renderer small without making common
source schemas verbose. <!-- rule: chart.mark.tick-as-rule class=judgment enforced=no tasks=charts --> Tick-like glyphs use an explicit short `rule`, so their
length is portable data-space geometry rather than a platform-dependent size.
Point, line, rule, and text also have the polar coordinate forms defined in
Section 5.3. `polygon` supplies the closed filled-path primitive; `line` remains
open and `arc` remains the only primitive whose edges are circular rather than
straight screen-space segments.

The following are deliberately not special marks:

- <!-- rule: chart.mark.family-histogram class=judgment enforced=no tasks=charts --> histogram: server-computed bins rendered as `bar` with `x,x2,y`;
- <!-- rule: chart.mark.family-stacked class=judgment enforced=no tasks=charts --> stacked bar or area: server-computed bounds rendered with `y,y2` or `x,x2`;
- <!-- rule: chart.mark.family-box-plot class=judgment enforced=no tasks=charts --> box plot: layered `rect` and `rule`;
- <!-- rule: chart.mark.family-candlestick class=judgment enforced=no tasks=charts --> candlestick: layered `rect` and `rule`;
- <!-- rule: chart.mark.family-ohlc class=judgment enforced=no tasks=charts --> OHLC: layered `rule` marks;
- <!-- rule: chart.mark.family-error-band class=judgment enforced=no tasks=charts --> error bar or confidence band: layered `rule` or `area` and `line`;
- <!-- rule: chart.mark.family-map-network class=judgment enforced=no tasks=charts --> map, network, or Sankey: server-computed coordinates rendered with primitive
  polygons, paths, rules, areas, points, and text.

### 4.2 Compositions

| Value | Meaning |
| --- | --- |
| <!-- rule: chart.compose.layer-order class=hard enforced=no tasks=charts --> `layer` | Draw every child in schema order in one plot area. Later children are visually above earlier children. |
| `hconcat` | Place child charts left to right. |
| `vconcat` | Place child charts top to bottom. |

<!-- rule: chart.compose.facets-and-grids class=judgment enforced=no tasks=charts -->
Faceting is expressed by `row` and `column` channels on a unit chart. A repeat
operation is unnecessary: the endpoint emits the explicit chart children or
facet values it wants the client to display. A grid is represented by nested
`hconcat` and `vconcat` compositions, avoiding a separate column-count grammar.

<!-- rule: chart.compose.facet-grid-lowering class=hard enforced=no tasks=client-runtime -->
Facet-grid equality in [resource limits](#12-security-resource-and-accessibility-requirements)
defines only a portable count; it does not merge independent scales or choose
one layered child's labels. A renderer that lowers layered children into one
physical facet grid may do so only when every facet orientation uses the same
nonempty compatible shared scale ID and every resolved raw identity has the
same display label. Otherwise it renders the grids and headings independently
or follows the chart failure policy in §10. A faceted and an unfaceted child therefore do
not silently become one grid.

See [composition shape rules](#42-compositions).
A composition cannot carry row channels directly. Every non-hidden effective
child must independently form a valid chart. Direct children without `Chart`
are permitted only when they carry `Hidden=true`; renderers ignore them while
preserving their values in the source snapshot. An unsupported visible child
or a non-chart child without `Hidden` invalidates the complete composition.
Fallback behavior is described in
[Validation and fallback](#10-validation-and-fallback).

<!-- rule: chart.compose.layer-children class=hard enforced=yes tasks=charts title="A layer child is a unit chart or a layer, never a concat" -->
A `layer` child must be a unit chart or another `layer`; a direct `hconcat` or
`vconcat` child is invalid because a multi-plot layout cannot share the outer
layer's one plot area. Nested layers are flattened depth-first in schema order
at their position in the parent. That flattened order owns painting, guide
ownership, hit-test ties, and accessibility traversal. Concat compositions may
contain units, layers, or further concats, so grids remain freely nestable.

<!-- rule: chart.compose.layer-one-family class=hard enforced=yes tasks=charts title="A layer is entirely Cartesian or entirely polar" -->
Every effective unit sharing one physical `layer` plot must use the same
coordinate family. A layer may be entirely Cartesian or entirely polar, but it
must not mix the two. Polar `arc`, `point`, `line`, `rule`, `text`, and
`polygon` children share one center and available plot radius. `hconcat` and
`vconcat` children have independent plot areas and may use different coordinate
families. Faceting is valid for either family; every polar facet pane receives
its own equal-aspect circular plot.

## 5. `ChartChannel`

The generated value is a comma-separated set of canonical roles. A single field
may serve several compatible roles:

```json
{ "Name": "ChartChannel", "Value": "x,tooltip" }
```

<!-- rule: chart.channel.canonical-order class=hard enforced=no tasks=charts -->
Studio's shared validator deduplicates roles and writes them in the canonical
order shown below. <!-- rule: chart.channel.nearest-complete class=hard enforced=no tasks=charts -->
The nearest `ChartChannel` declaration is complete; channel values are not
merged through a reference chain.

<!-- rule: chart.channel.one-per-node class=hard enforced=yes tasks=charts title="At most one Chart, ChartChannel, ChartScale and ChartGuide per schema node" -->
Each schema node may contain at most one `Chart`, one `ChartChannel`, one
`ChartScale`, and one `ChartGuide` attribute. Repeating an attribute is invalid;
the client never depends on duplicate-attribute lookup order.

### 5.1 Channel vocabulary

| Group | Roles | Meaning |
| --- | --- | --- |
| Cartesian position | `x`, `x2`, `y`, `y2`, `xOffset`, `yOffset` | Primary positions, paired range endpoints, and within-band offsets. |
| Polar position | `theta`, `theta2`, `radius`, `radius2` | Angular and radial positions or ranges. |
| Paint | `color`, `fill`, `stroke` | Mark-default paint or explicit fill/stroke. |
| Paint strength | `opacity`, `fillOpacity`, `strokeOpacity` | Overall or component alpha. |
| Symbol and stroke | `size`, `shape`, `angle`, `strokeWidth`, `strokeDash` | Mark geometry and stroke appearance. |
| Annotation | `text`, `tooltip` | Visible text and inspector values. |
| Structure | `detail`, `order`, `key` | Series grouping, path/z order, and stable row identity. |
| Facet | `row`, `column` | Small-multiple partitioning. |

Canonical role order is:

```text
x,x2,y,y2,xOffset,yOffset,
theta,theta2,radius,radius2,
color,fill,stroke,opacity,fillOpacity,strokeOpacity,
size,shape,angle,strokeWidth,strokeDash,
text,tooltip,detail,order,key,row,column
```

<!-- rule: chart.channel.positional-roles class=hard enforced=yes tasks=charts title="ChartChannel roles are positional vocabulary tokens, never named properties" -->
Whitespace is not present in a generated value. Source-language parsers may
accept their native whitespace and quoting, but `ChartChannel` roles remain
positional rather than named properties.

### 5.2 Multiplicity

<!-- rule: chart.channel.singletons class=hard enforced=yes tasks=charts title="Every role except tooltip and detail is a singleton; detail fields form one series key" -->
The positional, paint, symbol, `text`, `order`, `key`, `row`, and `column` roles
are singletons within one unit chart. `tooltip` and `detail` may occur on
multiple fields. Multiple `detail` fields form a composite series key in schema
field order.

<!-- rule: chart.channel.scale-bearing class=hard enforced=yes tasks=charts title="A field carries at most one scale-bearing role; duplicate the value for a second scale" -->
Positions, facets, paint, opacity, symbol, and stroke roles are scale-bearing.
A field may carry at most one scale-bearing role. It may combine that role with
non-scale roles such as `text`, `tooltip`, `detail`, `order`, or `key`. Thus
`x,tooltip` and `color,detail,tooltip` are valid, while `x,color` is ambiguous
and invalid. The endpoint duplicates a value into two row fields when the same
logical value needs two independently scaled roles.

<!-- rule: chart.channel.scale-needs-role class=hard enforced=yes tasks=charts title="No two fields share a singleton role; ChartScale needs a scale-bearing role" -->
It is not valid for two fields to claim the same singleton role. A field with
only non-scale roles cannot carry `ChartScale`.

<!-- rule: chart.channel.secondary-endpoints class=hard enforced=yes tasks=charts title="A secondary endpoint needs its primary, shares its scale and type, and carries no ChartScale" -->
`x2` always uses the scale of `x`; `y2` uses `y`; `theta2` uses `theta`; and
`radius2` uses `radius`. An endpoint role without its primary role is invalid.
A secondary endpoint field cannot carry `ChartScale`; its type and values are
validated against the primary binding.

### 5.3 Cartesian and polar geometry

<!-- rule: chart.geometry.endpoint-order class=hard enforced=no tasks=charts -->
`x` and `y` locate the primary point. `x2` and `y2` are explicit data-space
endpoints. Endpoint order is not semantic: clients render the interval between
the two mapped values, so `open > close` and reversed ranges are valid. A
zero-length rectangle dimension remains meaningful and is drawn one device
pixel thick, so a doji or median remains visible.

<!-- rule: chart.geometry.lane-thickness class=hard enforced=no tasks=client-runtime -->
For `bar` or `rect` with a continuous `x,x2` interval in a discrete `y`
band, the categorical dimension uses a compact density-independent screen-space
thickness capped at 72 percent of the mapped band. It is not synthesized by
adding and subtracting category-index values. A positive interval shorter than
that lane thickness may receive a paint-only minimum of the same length, while
a zero interval remains one device pixel. A positive floor stays centered on
the mapped source interval and clips at a plot edge; it must not shift inward,
because doing so can collapse distinct adjacent buckets onto one identical
footprint. A true zero at an edge may shift inward only enough to retain its
single device pixel. Clipping, hit testing, and overlap use the same painted
footprint. This responsive presentation never changes source endpoints,
domains, tooltip values, semantic order, or stacking.

<!-- rule: chart.geometry.synthetic-overlap class=hard enforced=no tasks=client-runtime -->
The paint-only minimum is not authored geometry. Within one categorical lane,
raw intervals with a positive intersection retain source/`order` precedence;
a zero interval likewise retains that precedence when it is identical to or
contained by another raw interval. Two positive intervals that merely abut do
not overlap. If otherwise-disjoint raw intervals overlap only after clipping or
minimum-width expansion, the renderer may adjust only their screen z-order so
the containing or wider synthetic footprint paints first and the contained or
narrower footprint remains visible. Equal synthetic footprints retain canonical
order. Any conflict is resolved in favor of authored raw-overlap precedence.
The resulting order and exact clipped footprint are shared by painting and
hit-test ties, while appearance lookup continues to use the row's canonical
source/`order` identity. Grid and axis strokes paint behind these marks so an
edge-clipped one-device-pixel interval cannot be erased. No part of this
screen-space rule changes scales, raw endpoints, lane assignment, tooltip
values, keys, or stacking.

<!-- rule: chart.geometry.offsets class=hard enforced=yes tasks=charts title="xOffset requires a discrete x band and yOffset a discrete y band; each creates a nested categorical band" -->
`xOffset` requires a discrete `x` band, and `yOffset` requires a discrete `y`
band. The offset field creates a nested band. Enumeration order is used when
available; otherwise the canonical categorical ordering from
[Appearance values](#56-appearance-values) defines nested-band order.
Offsets are useful for grouped bars. They do not add numeric pixels or bypass
responsive layout.

The primitive geometry rules are:

- <!-- rule: chart.geometry.point class=hard enforced=yes tasks=charts title="A point requires exactly one primary pair: Cartesian x,y or polar theta,radius" --> `point` requires exactly one primary coordinate pair: Cartesian `x,y` or
  polar `theta,radius`;
- <!-- rule: chart.geometry.line class=hard enforced=yes tasks=charts title="A line requires exactly one primary pair: Cartesian x,y or polar theta,radius" --> `line` requires exactly one primary coordinate pair: Cartesian `x,y` or
  polar `theta,radius`;
- <!-- rule: chart.geometry.area-vertical class=hard enforced=yes tasks=charts title="A vertical area uses x,y and optional y2" --> a vertical `area` uses `x,y` and optional `y2`; <!-- /rule --> <!-- rule: chart.geometry.area-baseline class=hard enforced=no tasks=charts --> without `y2`, a vertical `area` has a quantitative
  y baseline of zero;
- <!-- rule: chart.geometry.area-horizontal class=hard enforced=yes tasks=charts title="A horizontal area uses y,x,x2; an area never uses both x2 and y2" --> a horizontal `area` uses `y,x,x2`; it is horizontal only when `x2` is
  present;
- <!-- rule: chart.geometry.bar-one-sided class=hard enforced=yes tasks=charts title="A one-sided bar has one discrete axis and one quantitative Number axis from zero" --> a one-sided `bar` has one discrete position and one quantitative `Number`
  position whose other endpoint is zero;
- <!-- rule: chart.geometry.bar-rect-axes class=hard enforced=yes tasks=charts title="Each bar or rect axis is one discrete band field or an explicit interval" --> for a `bar` or `rect`, each Cartesian axis independently uses either one
  discrete band field or an explicit primary/secondary interval;
- <!-- rule: chart.geometry.bar-x-interval class=hard enforced=yes tasks=charts title="bar(x,x2,y) needs continuous x and either a discrete y band or a Number histogram y" --> `bar(x,x2,y)` uses continuous `x,x2` and is either a horizontal interval in
  a discrete `y` band or, when `y` is continuous `Number`, a histogram whose
  y endpoint is zero unless `zero=false` excludes it from the visible domain;
- <!-- rule: chart.geometry.bar-y-interval class=hard enforced=yes tasks=charts title="bar(x,y,y2) needs a discrete x band; with x2 and y2 both primaries are continuous" --> `bar(x,y,y2)` requires a discrete `x` band and continuous `y,y2`; it invents
  no baseline. With both `x2` and `y2`, both primaries are continuous and every
  endpoint is explicit. A secondary endpoint never pairs with a discrete
  primary;
- <!-- rule: chart.geometry.rect-no-baseline class=hard enforced=no tasks=charts title="rect never invents a baseline: each axis is ranged or banded" --> `rect` never invents a baseline;
- <!-- rule: chart.geometry.no-log-baseline class=hard enforced=yes tasks=charts title="No log scale on a one-sided bar, a histogram y, or a baseline area y" --> a `log` scale is invalid on the quantitative axis of a one-sided `bar`, and
  on histogram `y`, or on `y` of a vertical area without `y2`: those forms
  necessarily invent a zero baseline, which a log scale cannot represent.
  Authors use explicit positive endpoints instead;
- consequently, `bar(x,x2,y)` is a histogram bin, `bar(x,y,y2)` is a stacked
  categorical segment, `rect(x,x2,y-band)` is an irregular-time heatmap cell,
  and `rect(x-band,y,y2)` is a box body;
- <!-- rule: chart.geometry.rule-cartesian class=hard enforced=yes tasks=charts title="A Cartesian rule needs x,y plus x2 and/or y2; no plot-spanning endpoint is implied" --> a vertical `rule` uses `x,y,y2`, a horizontal rule uses `y,x,x2`, and an
  arbitrary segment uses `x,y,x2,y2`; plot-spanning endpoints are never
  implied;
- <!-- rule: chart.geometry.rule-polar class=hard enforced=yes tasks=charts title="A polar rule needs theta,radius plus theta2 and/or radius2" --> a polar `rule` requires `theta,radius` plus at least one of
  `theta2,radius2`. An omitted secondary coordinate repeats its primary:
  `theta,radius,radius2` is a radial segment, `theta,theta2,radius` is a
  straight equal-radius chord, and all four position an arbitrary chord. These
  are point endpoints, not an angular or radial interval, so either endpoint
  order is valid and no plot-spanning endpoint is implied;
- <!-- rule: chart.geometry.text class=hard enforced=yes tasks=charts title="A text mark requires exactly one of x,y,text or theta,radius,text" --> `text` requires exactly one of `x,y,text` or `theta,radius,text`;
- <!-- rule: chart.geometry.polygon class=hard enforced=yes tasks=charts title="A polygon uses x,y or theta,radius with no endpoints, offsets, text, size, shape or angle" --> `polygon` requires exactly one of `x,y` or `theta,radius`. It cannot use
  `x2`, `y2`, `xOffset`, `yOffset`, `theta2`, or `radius2`. Its applicable
  non-positional channels are `color`, `fill`, `stroke`, `opacity`,
  `fillOpacity`, `strokeOpacity`, `strokeWidth`, `strokeDash`, `tooltip`,
  `detail`, `order`, `key`, `row`, and `column`; `text`, `size`, `shape`, and
  `angle` are invalid on this mark.

<!-- rule: chart.geometry.one-family class=hard enforced=yes tasks=charts title="One mark never mixes Cartesian and polar positions; area, bar and rect are Cartesian, arc polar" -->
Except for facet and non-positional channels, one mark never mixes Cartesian
and polar positions. `area`, `bar`, and `rect` are Cartesian; `arc` is polar;
`point`, `line`, `rule`, `text`, and `polygon` select their coordinate family
from their required pair. The `angle` appearance channel is separate from
theta. It rotates a point shape or text mark clockwise in screen-space degrees
and never changes its polar position.

Polar geometry is normalized identically on Android and iOS:

- See [channel compatibility rules](#55-type-inference).  `theta`, `theta2`, `radius`, and `radius2` are ordinary numeric `Number`
  bindings, not `DateOnly` numbers or categorical values. Clients do not derive
  angular categories, indicator spacing, or radial normalization from labels;
- <!-- rule: chart.geometry.polar-zero class=hard enforced=no tasks=charts --> angular zero is at twelve o'clock and positive angles advance clockwise;
- <!-- rule: chart.geometry.theta-turns class=hard enforced=no tasks=charts --> a linear theta domain maps its minimum to zero turns and maximum to one full
  turn; an identity theta value is already measured in turns;
- <!-- rule: chart.geometry.radius-normalized class=hard enforced=no tasks=charts --> an identity radius is normalized from `0` at the plot center to `1` at the
  available plot radius; a quantitative radius scale maps its domain into the
  same normalized interval;
- <!-- rule: chart.geometry.polar-plot-circle class=hard enforced=no tasks=client-runtime --> after titles, guides, and facet headings reserve their space, the polar plot
  is the largest centered circle in the remaining pane. Its physical radius
  `R` is half the smaller plot dimension, so a polar plot is never stretched
  into an ellipse;
- <!-- rule: chart.geometry.polar-projection class=hard enforced=no tasks=client-runtime --> for normalized mapped values `t,r`, the screen coordinate is
  `x = centerX + R * r * sin(2*pi*t)` and
  `y = centerY - R * r * cos(2*pi*t)`. Scale reversal is applied before this
  conversion. A polar layer uses this same center and `R` for every child;
- <!-- rule: chart.geometry.polar-range class=hard enforced=no tasks=charts --> mapped theta and radius values must be finite and lie in the inclusive
  interval `0...1`. An out-of-range mapped coordinate is invalid; it is not
  wrapped or clamped to the circular boundary. An invalid point, rule, or text
  row is suppressed; an invalid line vertex breaks its path; and an invalid
  polygon vertex becomes a subpath separator after scale domains are resolved.
  Its finite raw positions still contribute to automatic domain resolution,
  but it contributes no geometry, appearance domain, group-style comparison,
  hit target, or accessibility focus. Tooltip and accessibility output for
  valid geometry retain the raw source value, not its normalized coordinate;
- <!-- rule: chart.geometry.polar-clip class=hard enforced=no tasks=client-runtime --> after valid geometry is constructed, marks and hit regions are clipped to
  the circular plot boundary. Automatic guide labels and titles occupy their
  reserved guide space and are not part of that mark clip. Painting and hit
  testing use the same final clipped footprint;
- <!-- rule: chart.geometry.straight-segments class=hard enforced=no tasks=charts --> `point`, `line`, `rule`, `text`, and `polygon` use straight screen-space
  segments after the conversion above. A provider samples any spiral, circular
  edge, geodesic, or other curve it needs. Only `arc` has intrinsically circular
  edges;
- <!-- rule: chart.geometry.arc-theta class=hard enforced=yes tasks=charts title="An arc requires theta and theta2; the server supplies explicit angular bounds" --> `arc` requires `theta,theta2`; the server supplies explicit angular bounds;
- <!-- rule: chart.geometry.arc-angular-order class=hard enforced=no tasks=charts --> before output-range reversal, `theta2` must not precede `theta`, and one arc
  cannot exceed one full turn;
- <!-- rule: chart.geometry.arc-default-radius class=hard enforced=no tasks=charts --> omitting both radial channels means inner radius `0` and outer radius `1`;
- <!-- rule: chart.geometry.arc-radius-pair class=hard enforced=yes tasks=charts title="An arc uses both radius and radius2 or neither" --> when radial channels are present, both `radius,radius2` are required;
- <!-- rule: chart.geometry.arc-radial-order class=hard enforced=no tasks=charts --> before output-range reversal, `radius` is the inner source endpoint and
  `radius2` is the outer source endpoint.

<!-- rule: chart.geometry.arc-mapped-interval class=hard enforced=no tasks=charts -->
After both endpoints are mapped through their shared scale, painted arc
geometry and hit testing use the interval between the two mapped endpoints.
`reverse=true` may therefore place the mapped primary endpoint above its
secondary endpoint without invalidating the row: the physical angular or
radial interval is mirrored, while source roles and tooltip values remain
unchanged. Both mapped endpoint intervals must remain within `0...1`; polar
geometry is not wrapped or clipped across an angular boundary. A source
ordering or mapped-range violation suppresses that arc row as invalid geometry;
it is not repaired by reordering the source values.

<!-- rule: chart.geometry.polar-closure class=judgment enforced=no tasks=charts -->
For connected polar vertices, mapped theta `0` and `1` are physically
coincident. Polygon closure is implicit, so authors normally use an explicit
theta domain such as `0...1`, emit vertices in the half-open interval `0...1`,
and do not repeat the first vertex. Repeating it is nevertheless well-defined:
it remains a counted source vertex and creates a zero-length edge. An automatic
theta domain still maps its finite minimum to `0` and maximum to `1`; the client
does not invent a cyclic category or an unobserved angular endpoint.

### 5.4 Default order and grouping

<!-- rule: chart.order.grouping class=hard enforced=no tasks=charts -->
List order is the default path and semantic paint order. An `order` channel
overrides it. The sole presentation-only exception is the synthetic-overlap
z-order rule in [Cartesian and polar geometry](#53-cartesian-and-polar-geometry),
which never reverses two rows whose authored raw intervals overlap.
For line, area, and polygon marks, rows are first partitioned by facet values
and then by the composite `detail` key. A `color`, `stroke`, or `fill` category
does not implicitly become a grouping key; the same field should also declare
`detail` when separate paths or polygons are required:

```typescript
enum TemperatureSeries {
  Indoor,
  Outdoor,
}

/** @LibertasChartChannel(color, detail, tooltip) */
series: TemperatureSeries;
```

<!-- rule: chart.order.polygon-subpaths class=hard enforced=no tasks=charts -->
Within one polygon facet/detail group, source order first determines subpaths.
A row with a null or non-finite required position is a source separator. After
scale domains are resolved, a polar row whose finite required positions map to
non-finite values or outside `0...1` is a mapped separator. Either kind ends the
current subpath and begins a new one at the next validly mapped vertex. Leading,
trailing, and consecutive separators do not create empty geometry. If `order`
is present, the client then sorts the validly mapped vertices ascending
**within each resulting subpath**, retaining source order for equal values;
sorting never moves a vertex across a separator. For polygon, `order` affects
only vertices within a subpath. Polygon groups paint in order of their first
validly mapped source row and are not reordered by one member's `order` value.

<!-- rule: chart.order.polygon-evenodd class=hard enforced=no tasks=charts -->
Each contiguous run of at least three finite mapped vertices is closed by an
implicit last-to-first edge. A shorter run produces no polygon geometry and a
bounded diagnostic. All valid subpaths in one facet/detail group form one
compound polygon. The fill rule is always `evenOdd`: a test point is filled
when a ray from it crosses the compound boundary an odd number of times. Ring
winding has no meaning, nested rings create holes by parity, and disjoint rings
create islands. Self-intersections, coincident vertices, touching rings, and
crossing subpaths are legal and use the same rule; clients neither repair them
nor impose a platform-native simple-polygon restriction. This permits, among
other valid data, a radar polygon whose zero values collapse several vertices
at the center.

<!-- rule: chart.order.polygon-edges class=hard enforced=no tasks=charts -->
Polygon edges are straight segments in mapped screen coordinates. Their
implicit closing edges participate in stroke dashing, clipping, hit testing,
and resource accounting. A repeated first vertex is permitted but unnecessary;
it remains a source vertex and creates one zero-length edge rather than changing
closure or fill semantics. A provider samples a curved boundary explicitly.

<!-- rule: chart.row.key-identity class=hard enforced=no tasks=charts -->
The optional `key` channel identifies a logical row across complete snapshots.
It supports stable accessibility focus and future transitions, but does not
change snapshot replacement semantics.

<!-- rule: chart.row.no-implicit-key class=hard enforced=no tasks=charts -->
Position, tooltip, and detail values are not implicit row keys. Any number of
source rows may carry the same `x`, `y`, temporal bucket, or other scalar value;
clients preserve those rows independently through projection, paint, tooltip,
and accessibility lowering. This rule is type-independent: a repeated bucket
may be `DateOnly`, `DateTime`, numeric, or categorical. Only an authored `key`
asserts logical row identity, and its documented facet/detail scope still
applies.

<!-- rule: chart.type.raw-identity class=hard enforced=no tasks=charts -->
Categorical identity is always the typed raw value, never its rendered label.
Enumeration members use their member value. `Device`, `VirtualDevice`, `User`,
`Endpoint`, and `LanDevice` use their Libertas reference ID. FormattedText Bytes
use their canonical tuple blob.
Resolved names or localized text may be duplicated or changed without merging
categories, changing focus identity, or reordering a domain.

<!-- rule: chart.row.numeric-identity class=hard enforced=no tasks=client-runtime -->
Numeric raw identity is also closed. An integral Number retains its exact
signed or unsigned typed integer. An `f32` is first rounded to its declared
binary32 value and then widened exactly to binary64; an `f64` retains its
binary64 value. Floating negative zero is canonicalized to positive zero before
grouping, ordering, key comparison, style constancy, or candidate
deduplication. A nonfinite value is invalid before it can become an identity.
For a shared numeric scale, every contribution is converted to binary64 with
round-to-nearest, ties-to-even; equal converted mathematical values occupy one
domain coordinate even when the original integer widths differ. That geometry
deduplication never merges source rows or rewrites their exact raw tooltip,
key, or request values.

<!-- rule: chart.order.enum-appearance-domain class=hard enforced=no tasks=charts -->
An Enumeration is a closed categorical declaration. For an automatic
categorical `color`, `fill`, `stroke`, `shape`, or `strokeDash` scale, the
domain contains every declared member in declaration order, including members
that have no eligible row in the current snapshot. This keeps appearance slots
and legends stable as sparse data appears or disappears; declared-but-absent
members create no marks. The complete declared appearance domain is subject to
the ordinary paint, shape, and dash category limits. Position, offset, and
facet domains remain derived from eligible rows, so a declaration alone never
creates an empty axis band, nested lane, or facet pane. Open categorical types
likewise remain row-derived.

### 5.5 Type inference

Clients infer the semantic data type from the effective Libertas scalar type:

| Libertas type | Default chart interpretation |
| --- | --- |
| `Number` | Quantitative continuous data. |
| `Number` with `DateOnly=true` | Timezone-free Gregorian calendar date encoded as integer `yyyyMMdd`; temporal on positions. |
| <!-- rule: chart.type.datetime-local class=hard enforced=no tasks=charts --> `DateTime` | Temporal data shown in local time unless `type=utc` is explicit. |
| `TimeOnly` | Local time-of-day data. |
| <!-- rule: chart.type.string-row class=hard enforced=no tasks=charts --> `String` | Genuine literal runtime/domain text, a nominal category whose value is itself literal data, or a literal appearance token. It is never a fixed program vocabulary or a copied object display name. |
| `Boolean` | Nominal two-value category. |
| `Enumeration` | Ordered or nominal discrete category with localized labels. |
| `Device` | Nominal Libertas device reference whose display name is resolved by the client. |
| `VirtualDevice` | Nominal Libertas virtual-device reference resolved by the client. |
| `User` | Nominal Libertas user reference whose display name is resolved by the client. |
| `Endpoint` | Nominal Libertas endpoint reference resolved by the client. |
| `LanDevice` | Nominal Libertas LAN-device reference resolved by the client. |
| `Bytes` with `FormattedText=true` | Nominal canonical resource tuple rendered from the App package catalog with LMF1. |

<!-- rule: chart.type.string-source-choice class=hard enforced=no tasks=charts -->
The `String` row is a semantic assertion about provenance, not permission to
put untranslated program wording in a payload. Whenever a channel can expose a
value as human-readable text—including position/facet labels, ordinal legends,
`text`, `tooltip`, explicit value/span guides, and accessibility—the App must
choose the source type as follows:

- <!-- rule: chart.type.source-enumeration class=hard enforced=no tasks=charts --> a closed App-defined vocabulary uses an `Enumeration`;
- <!-- rule: chart.type.source-object class=hard enforced=no tasks=charts --> an identity that is a Libertas system object remains `Device`,
  `VirtualDevice`, `User`, `Endpoint`, or `LanDevice`, so the ordinary client
  resolver supplies its display name;
- <!-- rule: chart.type.source-formatted class=hard enforced=no tasks=charts --> App-authored dynamic or parameterized wording uses a formatted Bytes
  value and LMF1; and
- <!-- rule: chart.type.source-literal class=hard enforced=no tasks=charts --> only genuine literal domain/user text, such as an externally supplied proper
  name or free-form annotation, remains a plain `String` and is displayed
  verbatim.

<!-- rule: chart.type.string-runtime-token class=judgment enforced=no tasks=charts -->
A small or repeated set of proper names does not become a program vocabulary,
and a token does not become literal data merely because task code writes it at
runtime. A plain String used only by `key`, or by an identity-scale appearance
channel whose guide is necessarily absent, is not rendered as text and is
outside this presentation-source rule. If that same field also carries
`tooltip` or another text-emitting role, the rule applies.

<!-- rule: chart.type.string-provenance-review class=hard enforced=no tasks=charts -->
The schema validator can enforce the effective scalar, channel, scale, and
guide combinations, but schema JSON cannot prove whether task code obtained a
String from a user/domain source or selected it from a fixed English token set.
Declaring a visible plain String is therefore an author/provider conformance
assertion that package review and response fixtures must audit.

<!-- rule: chart.type.date-only-civil class=hard enforced=no tasks=charts -->
`DateTime` is an instant. An ordinary `Number` retains its declared
quantitative semantics, including unit or duration metadata. `DateOnly=true`
is the explicit exception: its integer is the decimal calendar tuple
`yyyyMMdd`, not epoch time. Clients validate the Gregorian date and construct
chart/calendar components directly, without local- or UTC-offset conversion.

<!-- rule: chart.type.discrete-opt-in class=hard enforced=yes tasks=charts title="Discrete Number scales need an integral non-duration Number; DateTime may use band or point" -->
An integral, non-duration Number can opt into `ordinal`, `band`, or `point`
only in the role-specific combinations closed by §6.1. A `DateTime` can opt
into `band` or `point` when sparse epoch
instants are categories rather than a continuous timeline. A `Number` carrying
the existing `TimeInterval` attribute is a quantitative duration in seconds. A
string appearance field can opt into `identity`.

<!-- rule: chart.type.timeonly-range class=hard enforced=no tasks=charts -->
A `TimeOnly` payload is finite seconds from midnight in `0 <= value < 86400`.
An out-of-range value is invalid typed input and never wraps modulo a day for
geometry, grouping, labels, or tooltips.

#### 5.5.1 Physical units

`PhysicalUnit` is the general Libertas numeric-unit attribute used by charts and
ordinary presentation. It does not change the Number wire type. The source
spellings are exactly one quoted positional canonical identifier:

```typescript
/** @LibertasPhysicalUnit("millimeter") */
amount: number;
```

```rust
#[libertas_physical_unit("millimeter")]
pub amount: f64,
```

See [PhysicalUnit rules](../schema.md#43-value-constraint-and-presentation-attributes).
Both emit `PhysicalUnit=millimeter`. Empty, unquoted, named, multiple, aliased,
or invalid identifiers are schema errors. The value may be any canonical CLDR
core unit identifier accepted by the
[Unicode UTS #35 unit-identifier grammar](https://unicode.org/reports/tr35/tr35-general.html#Unit_Identifiers).
Libertas does not define a smaller unit catalog based on what one client or ICU
release happens to render.

<!-- rule: chart.unit.identifier-meaning class=hard enforced=no tasks=charts -->
CLDR core identifiers include simple units such as `millimeter`, generated
compound units such as `millimeter-per-day`, products such as
`kilowatt-hour`, and powered units such as `square-meter`. These identifiers
are not source field names, localized strings, symbols, legacy UCUM aliases, or
App resource keys. Thus an author declares `millimeter`, while a supporting
CLDR formatter may visibly produce `mm` or a localized long name. `percent`
means that raw `50` is fifty percent and displays as `50%`; it never rescales
`0.5` into fifty percent. A plain Number with `PhysicalUnit=second` is a
measured quantity in seconds, while `TimeInterval` retains its separate
duration semantics; the two attributes cannot coexist.

<!-- rule: chart.units.unsupported-preserve-value class=hard enforced=no tasks=client-runtime -->
Schema admission and client rendering support are intentionally separate. The
schema always preserves a valid CLDR identifier because that semantic metadata
is useful even when a particular client ICU version cannot format it. A client
that supports the identifier localizes the complete unit, including a compound
numerator and denominator. A client that does not support it may display the
numeric value without unit decoration; it must not reject or alter the value.

See [PhysicalUnit rules](../schema.md#43-value-constraint-and-presentation-attributes).
`PhysicalUnit` is valid only on an effective `Number` and cannot coexist with
`DateOnly`, `TimeInterval`, or `EnumSource`. Effective attribute inheritance
uses ordinary nearest whole-value replacement. The raw value, constraints,
domain, ordering, grouping, tick candidates, geometry, tooltip/request value,
and persistence remain expressed in the declared unit. V1 never performs
automatic unit conversion: a Celsius field remains Celsius in every locale and
a millimeter field remains millimeters. An App that wants converted data emits
the converted value and declares its resulting unit.

<!-- rule: chart.unit.client-formatting class=hard enforced=no tasks=client-runtime -->
A client that can format a unit-bearing visual number first applies that
surface's existing deterministic numeric precision rule, then asks its CLDR
unit formatter to join the resulting number and the declared *provided* unit.
Visible chart and ordinary-data text uses CLDR short width; accessibility uses
CLDR long width with the locale's plural and grammatical rules. A client must
not concatenate an English name or ASCII symbol, infer a unit from a native
field name or description, convert to a locale-preferred unit, or let the unit
formatter independently change the already selected numeric precision.
Negative zero remains zero.

<!-- rule: chart.unit.chart-surfaces class=hard enforced=no tasks=charts -->
Within a chart, the rule applies wherever that exact numeric field is rendered:
ordinary and explicit tooltip values, Cartesian and polar scale-guide ticks,
numeric `source=value` or `source=span` guide labels, continuous appearance
legend values, numeric text marks, selection/guide overlays, and accessibility.
A `source=scale` guide uses its target binding's unit; a value/span guide uses
the declaring label field's unit and never borrows the target's unit. Field and
guide titles remain their independently localized document text and do not
silently gain a unit suffix. Null or suppressed values produce no standalone
unit text.

<!-- rule: chart.unit.continuous-compatible class=hard enforced=yes tasks=charts title="Unit-bearing chart fields use continuous scales; paired and shared bindings need the same unit" -->
A unit-bearing chart binding is a continuous physical quantity. If it owns a
scale, its effective type must be `linear`, `log`, `symlog`, or `sqrt`; it
cannot use `identity`, `ordinal`, `band`, `point`, `time`, or `utc`. A primary
and secondary endpoint pair must have the same effective `PhysicalUnit`,
including both absent. Bindings sharing one nonempty scale ID must also have the
same effective unit; dimensionally convertible but different declarations such
as `meter` and `millimeter` are incompatible because V1 does not convert. An
incompatible pair/shared scale invalidates chart presentation and invokes the
chart failure policy in §10. An unknown received unit, a unit unsupported by
the client's ICU release, or a unit-formatter failure instead omits only unit
decoration everywhere that value is rendered; the numeric scalar and the chart
remain available. Raw unit identifiers are diagnostic-only and never visible.

#### 5.5.2 Scalar display formats

`Format` uses the same language-neutral Unicode LDML contract as ordinary
Libertas UI presentation: ordinary Number fields use
[UTS #35 number format patterns](https://unicode.org/reports/tr35/tr35-numbers.html#Number_Format_Patterns),
while DateOnly, DateTime, and TimeOnly use
[UTS #35 date/time format patterns](https://unicode.org/reports/tr35/tr35-dates.html#Date_Format_Patterns).
The portable field/token subset and placement rules are defined in the
[schema specification](../schema.md#43-value-constraint-and-presentation-attributes).

<!-- rule: chart.format.text-only class=hard enforced=no tasks=charts -->
A chart applies the effective `Format` whenever that field produces visible
text: scale ticks, tooltips, text marks, value/span guide labels, selection
overlays, and accessibility descriptions. It never parses the formatted text
back into data. Domains, tick candidates, sorting, grouping, equality, focus
keys, interpolation, scale transforms, and geometry always use the raw Number,
`yyyyMMdd`, epoch-second, or seconds-from-midnight value. DateTime formatting
uses the viewer's local timezone even when an explicitly `utc` scale uses UTC
for geometry; the authored pattern controls presentation, not scale semantics.

See [shared-scale compatibility rules](#63-shared-and-independent-scales).
Two fields sharing one nonempty `ChartScale.id` must have identical effective
`Format` values, including both absent, because the shared guide has one tick
label policy. A primary/secondary range pair may use different formats for its
own tooltip values. When `PhysicalUnit` and `Format` coexist, `Format` produces
the numeric portion first and the provided unit is then joined by the existing
CLDR unit rule; `%` and `‰` in a number pattern are scaling presentation tokens,
whereas `PhysicalUnit=percent` and `permille` never rescale the raw value.

<!-- rule: chart.format.datetime-range class=hard enforced=no tasks=charts -->
A chart `DateTime` payload is an integral Unix second in the inclusive range
`0..253402214399`, whose upper endpoint is
9999-12-30T23:59:59Z. This chart-specific bound deliberately reserves a full
day before the end of civil year 9999 so conversion through any configured
time-zone offset whose absolute value is less than 86400 seconds stays within
the proleptic-Gregorian years 0001 through 9999. A nonintegral or out-of-range
row value, explicit bound, unavailable time-zone offset, or conversion outside
that civil range invalidates chart presentation with a bounded diagnostic; it
is never clamped or delegated to a platform formatter's private range.
Client-generated temporal ticks and transient DateTime viewport edges may be
fractional epoch seconds; they are inert derived guide instants, never a
DateTime payload, tooltip, key, or typed request value.

<!-- rule: chart.type.no-other-scalars class=hard enforced=yes tasks=charts title="Other semantic types, tables, lists, unions, nil and plain Bytes are not chart scalars" -->
Other Libertas semantic types, tables, lists, unions, functions, nil values,
and plain Bytes are not direct chart scalars in version one. <!-- /rule --> <!-- rule: chart.type.retain-domain-identity class=hard enforced=no tasks=charts --> An App
must not flatten an `Enumeration` or Libertas object reference into a duplicate
display `String` merely for a chart. Keeping the typed value lets the frontend
apply the same localization and permission-aware identity resolution as every
other Libertas view, without adding configuration burden to the end user.

In the compatibility table below, **categorical** means a provenance-valid
literal `String`, `Boolean`,
`Enumeration`, `Device`, `VirtualDevice`, `User`, `Endpoint`, `LanDevice`, or a
FormattedText Bytes value. A discrete `Number` is separately allowed only
with an explicit `ordinal`, `band`, or `point` scale.

<!-- rule: chart.type.compatibility-table class=hard enforced=yes tasks=charts title="Each channel role accepts only the scalar types in the compatibility table" -->
Scalar compatibility is closed rather than inferred differently by native
libraries:

| Channel roles | Permitted effective scalar types |
| --- | --- |
| `x`, `y` | `Number` (continuous by default or explicitly discrete), `DateOnly` Number (temporal or band/point), `DateTime`, `TimeOnly`, or categorical |
| `x2`, `y2` | Continuous ordinary `Number`, `DateOnly` Number, `DateTime`, or `TimeOnly`, compatible with the primary |
| `xOffset`, `yOffset`, `row`, `column` | Categorical, or integral non-duration `Number`/`DateTime`/`DateOnly` with an explicit `band` scale |
| `theta`, `theta2`, `radius`, `radius2` | `Number` |
| `color`, `fill`, `stroke` | Continuous `Number`; categorical; or validated identity plain `String` |
| `opacity`, `fillOpacity`, `strokeOpacity`, `size`, `angle`, `strokeWidth` | `Number` |
| `shape`, `strokeDash` | Categorical; discrete `Number`; or validated identity plain `String` |
| `text`, `tooltip`, `detail`, `order`, `key` | Any version-one chart scalar listed in the inference table |

See [channel compatibility rules](#55-type-inference).
The categorical types above are intrinsically discrete. A `Number` is discrete
only with `ordinal`, `band`, or `point` as required by the role; `DateTime` and
`DateOnly` are discrete only with `band` or `point`. A continuous field cannot facet or offset;
a categorical field cannot supply a numeric
opacity, radius, width, angle, or size. Formatted text can use only categorical
or textual roles and an automatic, `ordinal`, `band`, or `point` scale; it
cannot use a quantitative role, continuous domain, or identity scale. Any
combination outside this table is invalid and falls back instead of relying on
a platform library's coercion.

<!-- rule: chart.row.nullable-positions class=hard enforced=no tasks=charts -->
Nullable positional values create missing points. For line and area marks, a
missing position breaks the path; it is not interpolated. For polygon, it
separates subpaths as defined in Section 5.4. A missing optional appearance
value uses the native mark default. Non-finite numbers receive the same
mark-specific suppression or separator behavior and are reported through
bounded diagnostics.

### 5.6 Appearance values

<!-- rule: chart.appearance.defaults class=hard enforced=no tasks=charts -->
Without an appearance channel, the client uses its accessible theme-native
default. Categorical paint channels use a deterministic Libertas palette.
Literal values use `ChartScale(type=identity)`; their implicit guide is none.

<!-- rule: chart.appearance.color-default-paint class=hard enforced=no tasks=charts -->
`color` is the mark's default paint: it supplies stroke for `line` and `rule`,
fill for `area`, `bar`, `rect`, `arc`, `text`, and `polygon`, and both fill and
stroke for `point`. An explicit `fill` or `stroke` overrides `color` for that
component.
Overall `opacity` multiplies the color alpha and the applicable `fillOpacity` or
`strokeOpacity`; missing opacity components default to `1`.

<!-- rule: chart.appearance.explicit-stroke class=hard enforced=no tasks=charts -->
An explicit `stroke` paints the boundary of an area, bar, rect, arc, polygon,
or point and the path of a line or rule. For area, bar, rect, arc, and polygon,
the boundary is absent when no explicit stroke resolves; `strokeWidth`,
`strokeDash`, and `strokeOpacity` alone never synthesize it. Text uses its fill
paint for glyphs and has no outline in version one. Point shape, rotation,
radius, fill, and boundary use the same final geometry in marks, legends, hit
testing, and accessibility. <!-- rule: chart.appearance.zero-stroke class=hard enforced=no tasks=client-runtime -->
A resolved stroke width less than or equal to zero paints no path or boundary,
creates no stroke hit region, and must skip the native stroke call. Clients
must not pass semantic zero to Core Graphics, Skia, or another backend that may
interpret zero as a device hairline.

<!-- rule: chart.appearance.polygon-fill class=hard enforced=no tasks=charts -->
A polygon always has a fill: an explicit `fill`, otherwise `color`, otherwise
the accessible theme-native polygon fill. Its boundary stroke is absent unless
an explicit `stroke` binding resolves to visible paint. `strokeWidth`,
`strokeDash`, and `strokeOpacity` do not synthesize a stroke or a stroke hit
region by themselves.

<!-- rule: chart.appearance.polygon-group-style class=hard enforced=no tasks=charts -->
A polygon facet/detail group has one paint and stroke style, not a per-vertex
gradient. For every group-level appearance binding—`color`, `fill`, `stroke`,
`opacity`, `fillOpacity`, `strokeOpacity`, `strokeWidth`, and `strokeDash`—all
finite, validly mapped vertices in all of that group's subpaths must carry the
same typed canonical raw value, or all carry null and use the same mark default.
Comparison uses raw identity, never a localized label, clamped scale output, or
theme-adjusted paint. A mismatch invalidates the complete chart presentation
and invokes the chart failure policy in §10. <!-- rule: chart.appearance.polygon-paint-order class=hard enforced=no tasks=client-runtime --> The renderer fills each compound path with
`evenOdd`. Within one unit it paints every polygon detail-group fill in group
paint order before painting any of the groups' explicit visible boundary
strokes in that same order. A later translucent fill therefore cannot bury an
earlier series outline. Each stroke remains above every fill along every
subpath boundary, including its implicit closing edge.

<!-- rule: chart.appearance.discrete-order class=hard enforced=no tasks=charts -->
For any discrete scale, Enumeration values use declaration order;
Boolean uses `false,true`; Number and Libertas object references use raw numeric
order; a discrete DateTime uses raw epoch-second order and a DateOnly value uses
its validated chronological `yyyyMMdd` numeric order; String uses Unicode
scalar-value order of its raw value; and formatted Bytes use canonical blob
order. Resolved display names and formatted output never affect ordering. The
same ordering applies to categorical axes, legends, facets, and nested bands.
An **automatic
categorical paint** domain is limited to ten values and maps in order to these
stable sRGB base hues:

```text
#4E79A7,#F28E2B,#E15759,#76B7B2,#59A14F,
#EDC948,#B07AA1,#FF9DA7,#9C755F,#BAB0AC
```

<!-- rule: chart.palette.theme-contrast class=hard enforced=no tasks=client-runtime -->
The base hue and category index are semantic and remain stable. At rendering
time, each client minimally adjusts an automatic categorical color toward the
platform theme's semantic foreground when the base color does not provide at
least `3:1` non-text graphical contrast against every active opaque chart
surface. This adaptation may differ between native platform themes, but it must
not reorder categories, rotate hues between categories, use localized labels as
identity, or change while a theme remains unchanged. Legends and marks use the
same adjusted color. Color is never the only category indicator: resolved
legend text and accessibility semantics remain authoritative.

<!-- rule: chart.palette.identity-contrast class=judgment enforced=no tasks=charts -->
An identity color is literal endpoint data and is never theme-adjusted. An
author who chooses `ChartScale(type=identity)` owns its contrast on
every supported theme.

<!-- rule: chart.palette.ten-categories class=hard enforced=no tasks=charts -->
An endpoint needing more than ten automatic paint categories supplies a bounded
identity-color field or uses facets; clients do not repeat colors and imply
false equivalence. Positional bands, facet values, and textual legends are not
subject to the paint limit; they remain subject to row, facet, and layout caps.

<!-- rule: chart.palette.viridis class=hard enforced=no tasks=client-runtime -->
A continuous numeric paint scale without `mid` uses these Viridis stops from
domain minimum to maximum:

```text
#440154,#482878,#3E4989,#31688E,#26828E,
#1F9E89,#35B779,#6DCD59,#B4DE2C,#FDE725
```

<!-- rule: chart.palette.diverging class=hard enforced=no tasks=charts -->
With `mid`, it uses these blue-neutral-red stops with the neutral stop fixed at
the midpoint:

```text
#053061,#2166AC,#4393C3,#92C5DE,#D1E5F0,#F7F7F7,
#FDDBC7,#F4A582,#D6604D,#B2182B,#67001F
```

<!-- rule: chart.palette.interpolation class=hard enforced=no tasks=client-runtime -->
Clients interpolate unpremultiplied sRGB components linearly between adjacent
stops. These are semantic rendering constants, not extra schema attributes.
They make heatmaps, divergent maps, and legends deterministic on both
platforms. <!-- rule: chart.palette.custom-colors class=judgment enforced=no tasks=charts --> Authors needing different colors send validated identity values.

<!-- rule: chart.appearance.ranges-and-limits class=hard enforced=no tasks=charts -->
Other automatic appearance ranges are also portable: point radius maps to
`3..18` density-independent points, text size to `10..32`, stroke width to
`1..8`, opacity to `0.15..1`, and angle to `0..360` clockwise degrees. Ordinal
shape uses `circle,square,triangle,diamond,cross` in order, while ordinal stroke
dash uses `solid,dash,dot,dash-dot`. Automatic shape and dash domains are
therefore limited to five and four categories respectively. Identity values are
used directly. Size must be finite and at least `0.5`, with no schema upper
bound; stroke width is bounded to `0..32`, opacity to `0..1`, and angle to
finite degrees. <!-- rule: chart.appearance.size-saturation class=hard enforced=no tasks=client-runtime --> A client may saturate an accepted size only while converting
it to the finite native scalar range of its drawing API. Such representational
saturation is not schema validation and must never reject the chart.

<!-- rule: chart.appearance.ordinal-output class=hard enforced=no tasks=client-runtime -->
Every ordinal appearance output is also deterministic. Let `i` be a category's
zero-based index in a domain of `N` values. Ordinal paint uses palette entry
`i`; shape and stroke dash use entry `i` in their fixed lists. For opacity,
size, and stroke width, let `u=0` when `N=1`, otherwise `u=i/(N-1)`, and map
that value through the same bounded physical range as the corresponding
continuous scale. Ordinal angle instead uses the half-open slot
`360*i/N` degrees, with a singleton at zero, so the last category never
duplicates the first at 360 degrees. `reverse=true` replaces `i` with
`N-1-i` for output selection without changing raw domain order. Paint and all
other ordinal appearance domains are limited to ten categories unless the
smaller shape or dash limit applies. A schema-integral, non-`TimeInterval`
Number may opt into this ordinal behavior; a floating or duration Number may
not use coincidental whole-valued rows as categories.

Recommended literal encodings are:

| Channel | Identity value |
| --- | --- |
| <!-- rule: chart.appearance.identity-color class=hard enforced=no tasks=charts --> `color`, `fill`, `stroke` | sRGB `#RRGGBB` or `#RRGGBBAA`. |
| Opacity channels | A finite number from `0` through `1`. |
| <!-- rule: chart.appearance.identity-shape class=hard enforced=no tasks=charts --> `shape` | A supported token such as `circle`, `square`, `triangle`, `diamond`, or `cross`. |
| `angle` | Clockwise degrees. |
| `size` | Point radius or text font size in density-independent points. |
| `strokeWidth` | Density-independent points. |
| <!-- rule: chart.appearance.identity-dash class=hard enforced=no tasks=charts --> `strokeDash` | `solid`, `dash`, `dot`, `dash-dot`, or a bounded space-separated positive-number pattern. |

<!-- rule: chart.appearance.unsupported-literal class=hard enforced=no tasks=charts -->
Unsupported literal values invalidate the chart presentation, not the protocol
payload. Arbitrary CSS, URLs, scripts, and platform-specific color objects are
not accepted.

<a id="display-template-palette"></a>

#### 5.6.1 Display Template palette

<!-- rule: chart.palette.hint class=hard enforced=no tasks=charts -->
A [Display Template](../presentation.md#behavior-hints) `palette` hint on a
`Chart` or `Chart3D` root selects the discrete color list for that chart's
automatic categorical and ordinal paint (`color`, `fill`, and `stroke`) and for
the default colors of series that bind no paint channel:

| Value | Discrete color list |
| --- | --- |
| `auto` | The specification default: the ten categorical base hues above. |
| `categorical` | The same ten categorical base hues. |
| `sequential` | The ten Viridis stops above, as entries `0` through `9`. |
| `diverging` | The eleven blue-neutral-red stops above, as entries `0` through `10`. |

<!-- rule: chart.palette.hint-scope class=hard enforced=no tasks=charts -->
Category `i` uses entry `i` of the selected list, the theme contrast adjustment
applies to every list, and the ten-category paint limit applies for every
value. The hint never changes continuous numeric paint, whose stops `mid` alone
chooses, or identity colors. <!-- rule: chart.palette.hint-validation class=hard enforced=yes tasks=charts title="A palette rule targets a Chart or Chart3D root with one of the four tokens" --> Studio reports a `palette` rule whose node has no
`Chart` or `Chart3D`, or whose value is not one of the four tokens; every token
is valid with every paint scale.

### 5.7 Runtime row semantics

<!-- rule: chart.row.order-sort class=hard enforced=no tasks=charts -->
List order is stable source order for semantic paint order and hit-test ties. An
`order` binding sorts ascending by the effective scalar's canonical ordering;
equal values retain list order. The synthetic-overlap exception in
[Cartesian and polar geometry](#53-cartesian-and-polar-geometry) changes only
screen z-order and the matching hit-test tie for otherwise-disjoint intervals;
canonical order remains the owner of style fallback and every raw-overlap tie. <!-- rule: chart.row.unordered-list-order class=hard enforced=no tasks=charts -->
`Unordered` on a chart list does not change chart geometry. A line, area, or
polygon without an `order` binding follows the received list order, as on any
other list, and polygon separator rows split subpaths in that order. An App
whose path must not depend on the emitted sequence declares `order`.

<!-- rule: chart.row.null-required class=hard enforced=no tasks=charts -->
A null required position suppresses a point, bar, rect, rule, arc, or text row.
For line and area, a null required position breaks the path. For polygon it is
a subpath separator under Section 5.4. A non-finite required position has the
same mark-specific behavior and produces a bounded diagnostic; it never reaches
a native drawing API. Null optional style values use the mark default. Tooltip
fields are shown in structural field order and null tooltip values are omitted.

<!-- rule: chart.row.key-unique class=hard enforced=no tasks=charts -->
A non-null `key` must be unique within the tuple of chart unit, facet values,
and `detail` values. A duplicate key makes the runtime chart invalid and invokes
the chart failure policy in §10 rather than ambiguous interaction. Without `key`, row
index is identity only for the current complete snapshot.

<!-- rule: chart.row.hit-priority class=hard enforced=no tasks=client-runtime -->
Layer traversal and row order define hit priority: the last painted eligible
mark wins when distances are equal. A line segment resolves to the nearest of
its originating endpoint rows. Tooltip and request actions use that row's
original `ScriptValue`; the renderer never synthesizes or joins a source row.

## 6. `ChartScale`

<!-- rule: chart.scale.optional class=hard enforced=no tasks=charts -->
`ChartScale` is optional. Native automatic scales are used when it is absent.
Its `type` reaches clients through the [Display Template](../presentation.md#display-template)
[as described above](#22-generated-language-neutral-schema-attributes).
Its generated `Value` is a comma-separated sequence of `key=value` properties
in this canonical order:

```text
id,type,min,max,mid,zero,reverse
```

<!-- rule: chart.scale.grammar-closed class=hard enforced=yes tasks=charts title="The ChartScale grammar is closed: id, type, min, max, mid, zero and reverse only" -->
The grammar is intentionally closed and does not provide an arbitrary option
object:

```text
scale       = property *( "," property )
property    = id | type | min | max | mid | zero | reverse
id          = "id=" identifier
type        = "type=" scale-type
min         = "min=" scalar
max         = "max=" scalar
mid         = "mid=" scalar
zero        = "zero=" boolean
reverse     = "reverse=" boolean
identifier  = ( ALPHA | "_" ) *( ALPHA | DIGIT | "_" | "." | "-" )
boolean     = "true" | "false"
scalar      = [ "-" ] integer [ fraction ] [ exponent ]
integer     = "0" | nonzero-digit *DIGIT
fraction    = "." 1*DIGIT
exponent    = ( "e" | "E" ) [ "+" | "-" ] 1*DIGIT
```

<!-- rule: chart.scale.scalar-syntax class=hard enforced=yes tasks=charts title="ChartScale properties appear once; scalars are finite grammar numbers without leading zeros" -->
Properties appear at most once and in canonical order. The generated form has
no whitespace. A source `scalar` must match the grammar above, is parsed to
binary64 with round-to-nearest, ties-to-even, and must not overflow to a
nonfinite value. Generated scalar spelling is semantic, not a lexical cleanup:
every numeric zero, including negative zero and underflow, is `0`; every other
value uses the shortest decimal that round-trips to the same binary64. It uses
fixed notation when the normalized base-ten exponent is from `-6` through `20`
inclusive and lowercase scientific notation otherwise. Scientific notation has
one nonzero digit before the decimal point, no `+` sign or leading exponent
zeros, and no redundant fractional zeros or decimal point. Thus `100e-2` and
`1.0e0` become `1`, `0e2`, `-0e-3`, and `1e-999` become `0`, and a leading-zero
integer such as `001` is invalid. TypeScript and Rust use this same canonical
algorithm before same-ID property comparison.

<!-- rule: chart.scale.scalar-units class=hard enforced=yes tasks=charts title="Scale scalars use runtime units: epoch seconds, seconds of day, duration seconds, yyyyMMdd" -->
For `DateTime` the scalar is an integral Unix
second in the chart range `0..253402214399`, for `TimeOnly` it is seconds from
midnight, and for a `Number` carrying
`TimeInterval` it is a duration in seconds, matching their existing runtime
scalar representations. For `DateOnly` it is the integer `yyyyMMdd`. No quoting or escaping is needed because every
string-valued property uses a closed token or restricted identifier. A future
property that needs free text must use localized schema documents rather than
extending this grammar with arbitrary strings.

For example:

```json
{
  "Name": "ChartScale",
  "Value": "id=price,type=linear,min=0,max=100"
}
```

### 6.1 Properties

| Property | Values | Meaning |
| --- | --- | --- |
| <!-- rule: chart.scale.id-identifier class=hard enforced=yes tasks=charts title="A scale id is a schema identifier" --> `id` | A schema identifier | Share or resolve a scale within the containing composition. |
| <!-- rule: chart.scale.type-tokens class=hard enforced=yes tasks=charts title="Scale type is one of linear, log, symlog, sqrt, time, utc, ordinal, band, point, identity" --> `type` | `linear`, `log`, `symlog`, `sqrt`, `time`, `utc`, `ordinal`, `band`, `point`, `identity` | Override the scale inferred from the scalar type and channel. |
| `min` | A canonical scalar literal | Fix the lower domain bound. |
| `max` | A canonical scalar literal | Fix the upper domain bound. |
| `mid` | A canonical scalar literal | Supply a center for a divergent continuous scale. |
| `zero` | `true` or `false` | Include or exclude zero from an automatic quantitative domain. |
| `reverse` | `true` or `false` | Reverse the output range. |

<!-- rule: chart.scale.invalid-is-error class=hard enforced=yes tasks=charts title="Repeated or unknown ChartScale keys and invalid combinations are errors" -->
Property keys cannot repeat. Unknown keys or invalid combinations are shared
Studio-validation errors before final schema generation and presentation
errors when received from an untrusted or newer schema. <!-- rule: chart.scale.nearest-replaces class=hard enforced=no tasks=charts --> Because lookup uses
nearest-value precedence, a
local `ChartScale` replaces the complete inherited or referenced scale value;
individual scale properties are never merged across declarations.

<!-- rule: chart.scale.time-vs-utc class=hard enforced=no tasks=charts -->
For `DateTime`, `time` formats and groups an epoch value in the device's
configured local time zone and `utc` uses UTC. For `DateOnly`, both scale tokens
operate on the same timezone-free calendar components. `identity` is intended for literal
appearance values and normalized polar geometry; it must not bypass bounds or
type validation.

<!-- rule: chart.scale.type-matrix class=hard enforced=yes tasks=charts title="The scale-type matrix is closed; only integral non-duration Numbers become categories" -->
The effective scale-type matrix is closed. “Integral Number” below means a
field whose schema `NumberType` is integral; a floating field does not become a
category merely because its current values happen to be whole numbers.

| Target | Effective scalar | Permitted effective scale types |
| --- | --- | --- |
| <!-- rule: chart.scale.matrix.xy-number class=hard enforced=yes tasks=charts title="Numeric x and y use linear, log, symlog or sqrt; integral Numbers may use band or point" --> `x`, `y` | Number, including `TimeInterval` | `linear`, `log`, `symlog`, `sqrt`; an integral non-duration Number may explicitly use `band` or `point` |
| <!-- rule: chart.scale.matrix.xy-temporal class=hard enforced=yes tasks=charts title="DateTime and DateOnly x and y use time, utc, band or point" --> `x`, `y` | `DateTime` or DateOnly Number | `time`, `utc`, `band`, `point` |
| <!-- rule: chart.scale.matrix.xy-time-only class=hard enforced=yes tasks=charts title="TimeOnly x and y use linear" --> `x`, `y` | `TimeOnly` | `linear` |
| <!-- rule: chart.scale.matrix.xy-categorical class=hard enforced=yes tasks=charts title="Categorical and FormattedText x and y use band or point" --> `x`, `y` | categorical or FormattedText | `band`, `point` |
| <!-- rule: chart.scale.matrix.offset-facet class=hard enforced=yes tasks=charts title="Offsets and facets take discrete values on a band scale" --> `xOffset`, `yOffset`, `row`, `column` | categorical, FormattedText, integral non-duration Number, `DateTime`, or DateOnly Number | `band` |
| <!-- rule: chart.scale.matrix.theta-radius class=hard enforced=yes tasks=charts title="`theta` and `radius` take linear, log, symlog, sqrt or identity scales" --> `theta`, `radius` | non-DateOnly Number, including `TimeInterval` | `linear`, `log`, `symlog`, `sqrt`; a non-duration Number may instead use `identity` |
| See [channel compatibility rules](#55-type-inference).  `color`, `fill`, `stroke` | non-DateOnly Number | `linear`, `log`, `symlog`, `sqrt`; an integral non-duration Number may explicitly use `ordinal` |
| <!-- rule: chart.scale.matrix.paint-categorical class=hard enforced=yes tasks=charts title="Categorical paint uses ordinal; a plain String may use identity" --> `color`, `fill`, `stroke` | categorical or FormattedText | `ordinal`; a plain String may instead use `identity` |
| See [channel compatibility rules](#55-type-inference).  `opacity`, `fillOpacity`, `strokeOpacity`, `size`, `angle`, `strokeWidth` | non-DateOnly Number | `linear`, `log`, `symlog`, `sqrt`; a non-duration Number may use `identity`, and an integral non-duration Number may explicitly use `ordinal` |
| See [channel compatibility rules](#55-type-inference).  `shape`, `strokeDash` | categorical, FormattedText, or integral non-duration Number | `ordinal`; a plain String may instead use `identity` |

<!-- rule: chart.scale.band-extent class=hard enforced=yes tasks=charts title="Only scale-bearing primaries own scales; bar and rect discrete dimensions use band" -->
Secondary endpoints inherit their primary scale. `text`, `tooltip`, `detail`,
`order`, and `key` do not own scales. A discrete dimension of `bar` or `rect`
must resolve to `band`; `point` has no band extent from which to construct the
rectangle. Offset and facet roles likewise require `band`, never `point` or
`ordinal`. A combination absent from this matrix is invalid even if a native
chart library would coerce it.

<!-- rule: chart.scale.time-interval class=hard enforced=yes tasks=charts title="A TimeInterval scale is linear, log, symlog or sqrt, never discrete or identity" -->
`TimeInterval` is always a quantitative duration. Wherever its target permits a
quantitative Number it may use `linear`, `log`, `symlog`, or `sqrt`, but never
`identity`, `ordinal`, `band`, or `point`.

Scale validation includes these rules:

- <!-- rule: chart.scale.min-max-order class=hard enforced=yes tasks=charts title="Scale min must not exceed max" --> `min` must not exceed `max`;
- <!-- rule: chart.scale.mid-in-domain class=hard enforced=yes tasks=charts title="mid belongs to a continuous divergent scale and lies within explicit bounds" --> `mid` is valid only on a compatible continuous divergent scale and must lie
  within an explicit domain when both bounds are present;
- <!-- rule: chart.scale.log-positive class=hard enforced=yes tasks=charts title="A log scale and every value the App supplies to it are strictly positive" --> a `log` scale and every value supplied to it must be strictly positive;
- <!-- rule: chart.scale.sqrt-non-negative class=hard enforced=yes tasks=charts title="A sqrt scale and every value the App supplies to it are non-negative" --> a `sqrt` scale and every value supplied to it must be non-negative;
- <!-- rule: chart.scale.zero-quantitative class=hard enforced=yes tasks=charts title="zero only on quantitative continuous non-log scales" --> `zero` is valid only for quantitative continuous scales other than `log`;
- <!-- rule: chart.scale.identity-literal class=hard enforced=yes tasks=charts title="An identity scale declares no domain properties; its values are already portable literals" --> `identity` cannot declare `min`, `max`, `mid`, `zero`, or `reverse`, and its
  values must already use the channel's portable physical
  or style representation;
- <!-- rule: chart.scale.zero-not-temporal class=hard enforced=yes tasks=charts title="Temporal, categorical and identity scales cannot use zero" --> temporal, categorical, and identity scales cannot use a numeric zero
  baseline;
- <!-- rule: chart.scale.band-point-positional class=hard enforced=yes tasks=charts title="band and point only on discrete positional or facet channels; on DateTime they make sparse categories" --> `band` and `point` are valid only for discrete positional or facet channels.
  Applying either to `DateTime` explicitly changes epoch values into sparse
  temporal categories: identity and order still use raw epoch seconds, while
  the axis contains only values emitted by the endpoint. Labels use the
  device's configured local date and time;
- See [scale type compatibility rules](#61-properties).  primary `theta` and `radius` positions are quantitative Number channels and
  cannot use `ordinal`, `band`, `point`, `time`, or `utc`. Their explicit scale
  type is `linear`, `log`, `symlog`, `sqrt`, or `identity`; identity values must
  already lie in `0...1`;
- See [scale type compatibility rules](#61-properties).  `TimeOnly` is always continuous local time-of-day and cannot use `ordinal`,
  `band`, or `point`; explicit bounds must lie in `0..86400` inclusive;
- See [scale scalar rules](#6-chartscale).  `DateTime` values and explicit bounds must be integral Unix seconds in
  `0..253402214399` inclusive;
- See [scale type compatibility rules](#61-properties).  style and paint channels cannot use `time`, `utc`, `band`, or `point`;
- <!-- rule: chart.scale.temporal-types class=hard enforced=yes tasks=charts title="time and utc need DateTime or DateOnly; log, symlog and sqrt need an ordinary Number" --> `time` and `utc` are valid only for `DateTime` or a `DateOnly` Number;
  `DateOnly` otherwise permits only `band` or `point`; `log`, `symlog`, and
  `sqrt` are valid only for ordinary quantitative `Number`;
- <!-- rule: chart.scale.bounds-continuous class=hard enforced=yes tasks=charts title="min and max need a continuous scale; mid needs a continuous quantitative paint scale" --> `min` and `max` are valid only for continuous quantitative or temporal
  scales; `mid` is valid only for a continuous quantitative paint scale;
- <!-- rule: chart.scale.secondary-no-scale class=hard enforced=yes tasks=charts title="A secondary endpoint channel cannot declare a scale" --> a secondary endpoint channel cannot declare a scale.

<!-- rule: chart.scale.transform-geometry class=hard enforced=no tasks=client-runtime -->
Continuous scale geometry is defined in transformed coordinates so every
client produces the same mapping instead of delegating nonlinear semantics to
a platform library. For a finite raw value `v`, the canonical transform is:

```text
linear, time, utc:  f(v) = v
log:                f(v) = ln(v)                         (v > 0)
symlog:             f(v) = sign(v) * ln(1 + abs(v))
sqrt:               f(v) = sqrt(v)                       (v >= 0)
```

<!-- rule: chart.scale.date-only-ordinal class=hard enforced=no tasks=client-runtime -->
`DateOnly` is the explicit temporal exception to the first line. Its raw
identity remains the integer `yyyyMMdd`, but `time` and `utc` decode it to the
timezone-free proleptic-Gregorian day ordinal relative to 1970-01-01 before
mapping: `f(v)=days(date(v))-days(1970-01-01)`. The mathematical inverse is a
real day ordinal; only an exact integral ordinal is a typed DateOnly candidate
and encodes back to `yyyyMMdd`. Clients never round a fractional ordinal or
interpolate through invalid decimal dates when producing a data value, tick, or
interaction value; §9 alone rounds a transient requested viewport boundary to
a typed date. <!-- rule: chart.scale.date-only-bounds class=hard enforced=yes tasks=charts title="DateOnly min and max canonicalize to exact valid yyyyMMdd dates" --> After semantic scalar canonicalization, a generated DateOnly
`min` or `max` must be an exact eight-digit valid Gregorian date. An equivalent
source literal such as `2.0240229e7` is accepted and emitted as `20240229`;
source spelling never survives into the wire value. Because that
encoding is fixed-width year-month-day order, raw numeric comparison remains
chronological after validation.

<!-- rule: chart.scale.normalization class=hard enforced=no tasks=client-runtime -->
The `symlog` transform therefore uses the fixed portable constant `1` inside
`log1p`; version one does not expose a configurable constant. Every canonical
transform and inverse result is rounded to binary64 with round-to-nearest,
ties-to-even; clients use a shared correctly-rounded implementation or match
the conformance vectors rather than accepting a platform-libm ULP difference.
For a resolved
raw domain `lo..hi`, let `A=f(lo)`, `B=f(hi)`, `T=f(v)`, and
`q=max(1,abs(A),abs(B))`. Clients use the overflow-safe normalization
`u = ((T/q) - (A/q)) / ((B/q) - (A/q))`. The contract value is the
round-to-nearest, ties-to-even binary64 rounding of that mathematical real
ratio; a conforming implementation uses sufficient intermediate precision if
binary64 division would collapse distinct finite `A` and `B`. `reverse=true`
maps the result to `1-u`.
An implementation compares `T` with the ordered transformed endpoints before
normalization; an out-of-domain value is classified beyond the corresponding
output edge without evaluating a potentially overflowing outside ratio. <!-- rule: chart.scale.explicit-domain-clips class=hard enforced=no tasks=charts -->
Explicit bounds and automatic-domain inference remain raw data values and are
validated before transformation. Values outside an explicit domain are
clipped by the plot; a client does not clamp or rewrite their source values.
When `lo == hi`, that one transformed value maps to the range midpoint and the
scale-sourced guide exposes one raw-value tick; clients do not invent different
padding domains around it.

<!-- rule: chart.scale.inverse-transform class=hard enforced=no tasks=client-runtime -->
Guide candidates and responsive density follow the finite algorithm in §6.4.5;
a nonlinear tick position is always computed through this transform and its
label represents the corresponding raw value. Interaction performs the matching inverse transform: `exp(t)` for
`log`, `sign(t) * expm1(abs(t))` for `symlog`, and `t * t` for `sqrt`.
Implementations use the numerically stable `log1p` and `expm1` operations so
values close to zero do not collapse during a forward/inverse round trip. <!-- rule: chart.scale.raw-values-exposed class=hard enforced=no tasks=charts -->
Tooltips, accessibility, explicit bounds, and request values always expose raw
values, never transformed coordinates. The logarithm base does not change a
normalized position; natural logarithms above define the portable inverse.

<!-- rule: chart.scale.appearance-normalization class=hard enforced=no tasks=client-runtime -->
The same transformed normalization governs continuous numeric appearance
channels. Without `mid`, `u` selects the sequential range. With `mid`, the
resolved raw domain expands to contain the midpoint, `lo..mid` maps through
`f` to `0..0.5`, and `mid..hi` maps through `f` to `0.5..1`; the neutral
divergent color is therefore exactly at `mid`. `reverse=true` is applied after
that piecewise normalization. Appearance output clamps an out-of-domain value
to the nearest range endpoint; its raw value remains unchanged in tooltips and
accessibility. Appearance guides label raw values.

<!-- rule: chart.scale.discrete-reverse class=hard enforced=no tasks=charts -->
For ordinal, band, and point scales, `reverse=true` reverses the output range,
not the canonical raw-identity domain. Axis/facet placement and automatic
appearance ranges therefore run in the opposite direction while grouping,
ordering, keys, labels, and tooltip identity remain unchanged.

### 6.2 Automatic domains

Default scale selection is deterministic:

| Field and channel | Default scale |
| --- | --- |
| <!-- rule: chart.domain.default-number class=hard enforced=no tasks=charts --> `Number` on a quantitative channel | `linear` |
| <!-- rule: chart.domain.default-datetime class=hard enforced=no tasks=charts --> `DateTime` on a position | `time` |
| <!-- rule: chart.domain.default-date-only class=hard enforced=no tasks=charts --> `Number` with `DateOnly` on a position | `time`, formatted as a timezone-free calendar date |
| <!-- rule: chart.domain.default-time-only class=hard enforced=no tasks=charts --> `TimeOnly` on a position | `linear`, formatted as local clock time |
| <!-- rule: chart.domain.default-time-interval class=hard enforced=no tasks=charts --> `Number` with `TimeInterval` | `linear`, formatted as a duration |
| <!-- rule: chart.domain.default-discrete-band class=hard enforced=no tasks=charts --> Discrete value on `bar`, `rect`, `row`, `column`, or an offset | `band` |
| <!-- rule: chart.domain.default-discrete-point class=hard enforced=no tasks=charts --> Discrete value on another position | `point` |
| <!-- rule: chart.domain.default-discrete-ordinal class=hard enforced=no tasks=charts --> Discrete value on paint, shape, or stroke dash | `ordinal` |
| <!-- rule: chart.domain.default-numeric-paint class=hard enforced=no tasks=charts --> Numeric paint | `linear` sequential, or divergent when `mid` is present |

<!-- rule: chart.domain.time-only class=hard enforced=yes tasks=charts title="TimeOnly row values lie in half-open 0..86400; bounds in inclusive 0..86400" -->
`TimeOnly` row values use seconds from midnight in the half-open interval
`0..86400` and default to the full `0..86400`-second scale domain. The upper
domain endpoint is an end-of-day bound sentinel, not a second midnight data
value. Explicit `min` and `max` may use the inclusive interval `0..86400`. <!-- rule: chart.domain.datetime-utc-choice class=judgment enforced=no tasks=charts -->
A `DateTime` author
uses `type=utc` when UTC formatting is semantically required; otherwise device
local time is the default. An explicit `band` or `point` instead requests a
discrete, sparse DateTime axis and therefore cannot simultaneously request the
`utc` scale. Endpoints should choose bucket instants whose local labels are
unambiguous for their audience.

### 6.2.1 Sparse temporal lanes

A server-precomputed water-usage report is a representative sparse temporal
chart. One unit `rect` chart puts a zone reference on a categorical `y` band,
so every zone occupies exactly one lane. Each nonempty day or week bucket begins
one server-computed horizontal stack. The App emits `Number` `x,x2` fields named
`display_start,display_end` on a shared linear scale; they are
synthetic display seconds, not timestamps. The separate `Number`
`bucket_starts_on` field carries `DateOnly=true`; it is the exact timezone-free
UTC calendar bucket and supplies both the tooltip and explicit span guide. Clients present it as a localized
human date with no time or timezone conversion.

Every response uses a 600-second full stack and one response-wide maximum bucket
amount, producing one seconds-per-millimeter scale for every zone and occupied
bucket. The App clips source contributions to the exact requested range before
aggregation. It groups all zones by their real occupied bucket start, assigns
every stack in a group the same display anchor, and uses the greatest rounded
stack extent in that group when placing the next anchor. After a prior extent,
a real gap of 0–59 seconds is retained; a gap of 60 seconds or more is replaced
by a fixed 30-second display gap. The gap is 5% of the full stack and still
leaves capacity for four one-second positive-contributor floors. Within each
stack, rain, irrigation, forecast rain, and scheduled water remain contiguous
in that order and are amount-proportional except for bounded whole-second
allocation needed to keep every positive contributor nondegenerate. The exact
segment amount and real bucket in the tooltip are authoritative.
`color,detail` distinguishes rain, irrigation, forecast rain, and scheduled
water.

#### Normative broken-time interval-lane policy

<!-- rule: chart.lane.when-to-use class=judgment enforced=no tasks=charts -->
This pattern is for sparse, interval-like quantities whose visual comparison is
more useful than a calendar-proportional gap. It makes occupied buckets legible
without pretending that empty calendar time is measured. It is not a time
series, event timeline, duration chart, or a client-side stacking feature.

<!-- rule: chart.lane.provider-owns-geometry class=hard enforced=no tasks=charts -->
The provider owns every geometric decision. Its row schema uses the generic
guide attribute explicitly:

```rust
#[libertas_chart_channel(x)]
#[libertas_chart_scale(id = usage_display, kind = linear)]
display_start: f64,
#[libertas_chart_channel(x2)]
display_end: f64,
#[libertas_date_only]
#[libertas_chart_channel(tooltip)]
#[libertas_chart_guide(
    target = x,
    source = span,
    position = bottom,
    title = none,
    grid = none
)]
bucket_starts_on: u32,
#[libertas_chart_channel(tooltip)]
amount: f32,
```

<!-- rule: chart.lane.display-coordinates class=hard enforced=no tasks=charts -->
`display_start,display_end` must be finite numeric coordinates and every valid
positive contribution must have `display_end > display_start`. They are
explicitly display coordinates: neither field is an event time, and an
automatic time guide must be absent. The provider supplies the real bucket date
and exact quantity in tooltip fields. Those tooltip values, not the synthetic
coordinate, are the factual date and amount presented to a person, copied to
accessibility output, and used by details or drill-in actions. A bucket value is
never implicit row identity: every same-bucket source contribution remains a
distinct row unless the author provides an explicit `key` channel.

<!-- rule: chart.lane.span-guide-grouping class=hard enforced=no tasks=client-runtime -->
The explicit `ChartGuide` is a generic span-label guide; `DateOnly` has no
special guide status. Within each facet pane it groups eligible rows by the
exact raw `bucket_starts_on` identity, maps and clips their authored `x,x2`
intervals, merges overlapping intervals, and places one localized date at each
connected component's midpoint. Its explicit guide replaces the implicit x
axis. Adding, removing, or reordering tooltip fields therefore cannot change
the guide. This is not a time scale: the client does not derive calendar
distance from the labels, move an interval, fill a gap, or expose the synthetic
coordinates.

<!-- rule: chart.lane.anchor-grouping class=hard enforced=no tasks=charts -->
For a response, group all valid rows by the exact real bucket anchor across all
lanes. Sort those anchors ascending, start the first display anchor at zero,
and give every lane in a group that same anchor. Use one response-wide quantity
denominator. For each lane, allocate the ordered positive contributors from
cumulative quantity boundaries, preserving contiguity and reserving a whole
synthetic unit for every remaining contributor. The group extent is the largest
resulting rounded lane extent. Thus rounding cannot make another lane overlap
or cause its next group to relocate.

<!-- rule: chart.lane.fixed-gap-parameters class=hard enforced=no tasks=charts -->
For the sprinkler V1 contract, the full-scale extent is exactly 600 synthetic
seconds. Let `previous_extent` be the preceding real bucket's group extent and
let `delta` be the difference between consecutive real bucket anchors. The next
display anchor is `previous_anchor + previous_extent + delta` when
`0 <= delta < 60`, and `previous_anchor + previous_extent + 30` when
`delta >= 60`. Equal anchors are one group, not a zero-gap second group. The
30-unit collapsed gap is five percent of 600 and leaves at least four integer
display units for one-unit positive-contributor floors. A later contract version
may choose different response-wide fixed parameters, but it must document them
as a provider contract; clients must never infer or normalize a gap locally.

<!-- rule: chart.lane.provider-rounding class=hard enforced=no tasks=charts -->
The provider uses stable contributor ordering (for sprinkler: rain, irrigation,
forecast rain, scheduled water), a deterministic sort by real bucket anchor,
and cumulative-boundary rounding rather than independently rounded segment
widths. It omits nonfinite or nonpositive inputs. If no valid positive interval
can be produced for a lane, it returns the ordinary empty-lane annotation rather
than a zero-width mark or fabricated value. Saturating coordinate arithmetic
must preserve order where representable; a provider that cannot emit a finite,
strictly positive interval omits that invalid row rather than wrapping or
relocating unrelated rows.

For example, suppose a first real bucket has a 10-mm zone A stack that rounds
to 600 units and a 5-mm zone B stack that rounds to 300; both start at display
zero. A second real bucket 59 seconds later starts at `600 + 59 = 659` for both
zones. A third bucket 60 seconds after that starts at `659 + 600 + 30 = 1289`.
A much later fourth bucket also adds only the preceding maximum extent and 30,
not its calendar-sized gap. Within each stack the ordered segments remain
adjacent, so, under the same denominator, a 6-mm segment is three times the
width of a 2-mm segment except where the documented one-unit floor is required.

<!-- rule: chart.lane.client-literal-mapping class=hard enforced=no tasks=charts -->
The client maps valid endpoints literally into the continuous x range and the
categorical lane into its band. Apart from the explicit span guide above,
it does not aggregate, sort, stack, renormalize, replace gaps, relabel the x
axis as time, or repair provider geometry. It applies
the paint-only minimum and matched clipping/hit-testing footprint defined in
Section 5.3; that presentation rule never changes this provider geometry. The
tooltip and accessibility label must state the real bucket date and exact
amount, never synthetic seconds.

<!-- rule: chart.lane.provider-mapping class=hard enforced=no tasks=charts -->
The provider owns this precomputed visual-coordinate contract. It must use one
consistent chart-wide mapping and emit a finite, nondegenerate `x,x2` interval
for every positive segment; a clipped edge, rounding step, or empty contributor
must not corrupt unrelated geometry. The client maps the supplied endpoints
literally. It does not aggregate, normalize, stack, facet, or repair these rows.
Responsive renderers may guarantee a minimum visible pixel width for a valid
positive interval; that presentation aid is not a substitute for valid provider
geometry and does not change the data-space mapping. The explicit span guide
replaces the misleading numeric axis for synthetic coordinates. This uses the
generic `ChartGuide` attribute and adds no special bucket, histogram,
stack, or timeline grammar.

### 6.2.2 Domain contributors and baselines

<!-- rule: chart.domain.contributors class=hard enforced=no tasks=client-runtime -->
Every finite point, line, text, or polygon vertex contributes its primary
position values to automatic domains before path lowering. A polygon source
separator whose required position is null or non-finite contributes neither a
position nor an appearance value. A polar polygon vertex whose finite raw
position maps outside `0...1` becomes a separator only after domain resolution:
its raw position still contributes to that resolution, but its appearance
values do not contribute to appearance domains or group-style constancy. A
valid secondary endpoint on a Cartesian or polar rule contributes through its
primary scale; when a polar rule omits one secondary coordinate, repeating the
primary coordinate adds no distinct domain value. A short or fully transparent
polygon subpath therefore does not silently change how its finite authored
values participate in an otherwise visible shared guide.

<!-- rule: chart.domain.automatic-zero class=hard enforced=no tasks=charts -->
Automatic continuous domains cover the finite visible data. One-sided
quantitative `bar` and area baselines include zero by default. Line, point,
explicit area bands, rule, rect, polygon, and ranged bar marks fit their
explicit data by default. `zero` overrides that choice when the scale type
permits it.
If exactly one explicit bound lies beyond every automatic domain contributor,
or there are no contributors, the unresolved side collapses to that explicit
bound. The result is the degenerate one-tick domain defined above; clients do
not ignore the bound or invent platform-specific padding merely because all
supplied marks are clipped.

<!-- rule: chart.domain.guide-visibility class=hard enforced=no tasks=charts -->
A continuous scale-sourced guide is visible when its resolved shared scale has at
least one eligible domain contributor anywhere under the selected chart root,
or when the author supplied a domain anchor (`min`, `max`, paint `mid`, or
`zero=true`). Thus an empty first binding can still own the guide for data
contributed by a later binding with the same scale ID, and an explicitly
bounded empty snapshot retains its truthful authored guide. A fully automatic
scale with no contributors owns only an internal transform placeholder and
shows no axis or legend. A categorical scale-sourced guide likewise requires at
least one resolved category. A member of a closed Enumeration appearance
domain is resolved even when the current snapshot has no row for that member;
positional and facet guides remain eligible-row-driven.

<!-- rule: chart.domain.shared-zero class=hard enforced=no tasks=charts -->
For a shared scale whose bindings all omit `zero`, the automatic domain is the
union of every contributing binding's domain contribution, so any one-sided
bar or baseline area contributes zero. An explicit shared `zero=false` disables
that automatic contribution for the complete shared scale; `zero=true` forces
it. This rule does not depend on composition traversal order.

<!-- rule: chart.domain.number-bounds-not-domain class=hard enforced=no tasks=charts -->
`NumberMin` and `NumberMax` remain input/data validation constraints. They do
not silently become chart domain bounds. Authors use `ChartScale(min=...,max=...)`
when a stable visual domain is required.

### 6.3 Shared and independent scales

<!-- rule: chart.shared-scale.scope class=hard enforced=no tasks=charts -->
A scale `id` is scoped to the outermost rendered chart root. Compatible bindings
with the same ID share their **domain and scale configuration**, never their
physical pixel range. Each unit chart maps that shared domain into its own plot
rectangle. Thus vertically concatenated price and volume charts can align in
time without sharing screen coordinates.

<!-- rule: chart.shared-scale.unnamed-local class=hard enforced=no tasks=charts -->
An unnamed scale is always local to one unit chart. There is no implicit
cross-layer sharing based on coincidental field names. Different IDs create
independent scales, which supports multi-scale layers without a separate Vega
`resolve` grammar; §6.4 defines their deterministic guide layout.

For one shared ID:

- <!-- rule: chart.shared-scale.compatible-bindings class=hard enforced=yes tasks=charts title="Bindings sharing a scale ID agree on channel family, scalar type, presentation class, Enumeration, Money, PhysicalUnit and Format" --> all bindings must use the same compatible channel family (`x/x2`, `y/y2`,
  `theta/theta2`, `radius/radius2`, or one appearance role), effective scalar
  type, formatted-versus-plain interpretation, ordinary-number-versus-
  `TimeInterval` presentation class, effective `Money`, `PhysicalUnit`,
  and `Format` values, and domain, midpoint, zero, and reverse
  rules; a duration scale therefore cannot share an ID or guide with an
  ordinary numeric scale even though both use numeric geometry. Shared
  Enumeration bindings must also name the same effective Enumeration type.
  Shared FormattedText bindings already use the same package catalog and remain
  compatible when their other scalar and scale properties match;
- <!-- rule: chart.shared-scale.range-class class=hard enforced=yes tasks=charts title="Point-radius size and text-font size cannot share a scale ID" --> all bindings must use the same automatic physical output-range class. In
  particular, point-radius `size` (`3..18` density-independent points) and
  text-font `size` (`10..32`) cannot share an ID or one size guide;
- See [shared-scale compatibility rules](#63-shared-and-independent-scales).  every binding resolves its scale `type` independently, from its explicit
  `type` property or its local inference. Resolved types must match; an omitted
  `type` never inherits another binding's explicit type;
- <!-- rule: chart.shared-scale.endpoint-domain class=hard enforced=no tasks=charts --> domain inference includes the primary and secondary endpoint values from
  every binding carrying that ID;
- <!-- rule: chart.shared-scale.property-fill class=hard enforced=no tasks=charts --> declarations may repeat identical `min`, `max`, `mid`, `zero`, and `reverse`
  properties, while one of those omitted properties is filled from another
  binding or inferred automatically;
- <!-- rule: chart.shared-scale.continuous-union class=hard enforced=no tasks=charts --> continuous domain contributions union across all bindings, including the
  automatic baseline rule above;
- <!-- rule: chart.shared-scale.conflict-invalid class=hard enforced=yes tasks=charts title="Conflicting properties on a shared scale ID invalidate the chart" --> a conflicting shared property invalidates the containing chart.

<!-- rule: chart.shared-scale.paired-inherit class=hard enforced=no tasks=charts title="Paired endpoint fields inherit their primary's scale and do not repeat the ID" -->
Paired endpoint fields inherit their primary field's scale and do not repeat
the ID. This is how a candlestick's wick and body share time and price domains.
The body still has its own pixel geometry, and an optional volume subplot uses
an independent y-scale while sharing only the time ID.

See [shared-scale compatibility rules](#63-shared-and-independent-scales).
`PhysicalUnit` is part of shared-scale compatibility. Reusing an ID remains the
author's assertion that the values represent one comparable quantity, and every
contributor must carry the same effective unit or no unit. Unit text in a
localized field document is presentation only and cannot satisfy or override
that semantic check.

### 6.4 `ChartGuide`: generic guides

<!-- rule: chart.guide.attribute class=hard enforced=no tasks=charts -->
`ChartGuide` is the fourth chart attribute. It separates guide presentation
from `ChartScale` domain and transform semantics. Its `position` reaches
clients through the [Display Template](../presentation.md#display-template). Its generated value contains
properties in this canonical order:

```text
target,source,position,title,domain,ticks,labels,grid
```

<!-- rule: chart.guide.grammar-closed class=hard enforced=yes tasks=charts title="The ChartGuide grammar is closed: target, source, position, title, domain, ticks, labels, grid" -->
The grammar is closed:

```text
guide       = target "," source *( "," property )
property    = position | title | domain | ticks | labels | grid
target      = "target=" primary-role
source      = "source=" ( "scale" | "value" | "span" | "none" )
position    = "position=" ( "auto" | "top" | "right" | "bottom" | "left" )
title       = "title=" component
domain      = "domain=" component
ticks       = "ticks=" component
labels      = "labels=" component
grid        = "grid=" component
component   = "auto" | "none"
primary-role = "x" | "y" | "xOffset" | "yOffset" |
               "theta" | "radius" | "color" | "fill" | "stroke" |
               "opacity" | "fillOpacity" | "strokeOpacity" | "size" |
               "shape" | "angle" | "strokeWidth" | "strokeDash" |
               "row" | "column"
```

<!-- rule: chart.guide.required-keys class=hard enforced=yes tasks=charts title="ChartGuide keys appear once; source tokens equal the generated keys" -->
`target` and `source` are required, keys cannot repeat, generated values contain
no whitespace, and unspecified optional properties use the defaults below.
Source annotations use
`@LibertasChartGuide(target=x, source=scale, ...)` and
`#[libertas_chart_guide(target = x, source = scale, ...)]`; unlike
`ChartScale.kind`, their tokens already match the generated keys.

#### 6.4.1 Placement and validation

<!-- rule: chart.guide.scale-source-placement class=hard enforced=yes tasks=charts title="source=scale or none goes on the field whose primary scale-bearing role is the target" -->
`source=scale` and `source=none` are declared on the direct field that owns the
named scale-bearing `target`; the two tokens respectively request its inferred
generic guide or suppress it. The target must equal that field's one primary
scale-bearing role. Secondary endpoints cannot carry a guide. A field may carry
`ChartChannel`, `ChartScale`, and `ChartGuide` together.

<!-- rule: chart.guide.label-field-placement class=hard enforced=yes tasks=charts title="value and span guides sit on a scale-free label field; span needs continuous x or y with x2 or y2" -->
`source=value` and `source=span` are declared on a direct scalar guide-label
field. That field is guide-only for scaling: it has no scale-bearing
`ChartChannel`, carries no `ChartScale`, and may additionally carry only
non-scale roles such as `tooltip`, `detail`, or `key`. Its typed value supplies
the label; `target` identifies the position binding in the same unit chart.
`value` is valid for `x`, `y`, `theta`, or `radius` and creates one candidate at
each guide-eligible row's mapped primary coordinate. `span` is valid only for a
continuous Cartesian `x` or `y` target with the corresponding `x2` or `y2`
binding. It is not valid for bands, points, polar ranges, or appearance scales.

The valid positions and components are:

| Effective guide | Targets | Positions | Optional properties |
| --- | --- | --- | --- |
| <!-- rule: chart.guide.matrix.cartesian-axis class=hard enforced=yes tasks=charts title="Cartesian axes: x top or bottom, y left or right" --> Cartesian axis | `x`, `y` | `x`: `auto/top/bottom`; `y`: `auto/left/right` | `title,domain,ticks,labels,grid` |
| <!-- rule: chart.guide.matrix.polar-axis class=hard enforced=yes tasks=charts title="Polar axes take only position auto" --> Polar axis | `theta`, `radius` | `auto` | `title,domain,ticks,labels,grid` |
| <!-- rule: chart.guide.matrix.offset-tier class=hard enforced=yes tasks=charts title="Offset tiers: xOffset top or bottom, yOffset left or right" --> Nested offset tier | `xOffset`, `yOffset` | `xOffset`: `auto/top/bottom`; `yOffset`: `auto/left/right` | `title,domain,ticks,labels,grid` |
| <!-- rule: chart.guide.matrix.facet-header class=hard enforced=yes tasks=charts title="Facet headers take only title and labels; row left or right, column top or bottom" --> Facet header | `row`, `column` | `row`: `auto/left/right`; `column`: `auto/top/bottom` | `title,labels` |
| See [guide placement rules](#641-placement-and-validation).  Appearance legend | appearance targets | `auto/top/right/bottom/left` | `title,ticks,labels`; `domain` and `grid` must be omitted |
| <!-- rule: chart.guide.matrix.value-rail class=hard enforced=yes tasks=charts title="Value label rails use their Cartesian axis side matrix" --> Value label rail | `x`, `y` | same side matrix as its Cartesian axis | `title,domain,ticks,labels,grid` |
| <!-- rule: chart.guide.matrix.polar-value class=hard enforced=yes tasks=charts title="Polar value labels take only position auto" --> Polar value labels | `theta`, `radius` | `auto` | `title,domain,ticks,labels,grid` |
| <!-- rule: chart.guide.matrix.span-rail class=hard enforced=yes tasks=charts title="Span label rails use their Cartesian axis side matrix" --> Span label rail | continuous `x`, `y` | same side matrix as its Cartesian axis | `title,domain,ticks,labels,grid` |
| <!-- rule: chart.guide.matrix.suppressed class=hard enforced=yes tasks=charts title="A suppressed guide declares no position or components" --> Suppressed | every primary target | no `position` property | no component properties |

<!-- rule: chart.guide.outside-row-error class=hard enforced=yes tasks=charts title="A guide property outside its target row is an error" -->
Any property outside its row is an error; it is never silently ignored. <!-- rule: chart.guide.auto-defaults class=hard enforced=no tasks=charts -->
`position=auto` resolves to bottom for x-oriented guides, left for y-oriented
guides, top for column headers, bottom for legends, and the intrinsic
perimeter/ray layout for polar guides. Automatic row headers appear above each
row in a single-column facet stack, preserving the plot's horizontal space;
in a multi-column facet grid they appear on the left. Explicit `left` or `right`
row-header positions remain on the requested side. `domain=auto` means the
Cartesian axis baseline or polar boundary/ray. An appearance legend's sample
or ramp is intrinsic and is not an axis domain component. For an offset tier,
the domain and ticks repeat within each parent band, and its grid strokes are
clipped to that parent band. Headers have no domain line. `ticks=auto` means
axis, data-anchored, offset-tier, or continuous-legend tick strokes;
categorical legends and headers have no ticks. `grid=auto` means Cartesian grid
lines, theta spokes, radius circles, or repeated offset separators. Defaults are
`position=auto` and every component supported by the inferred guide is `auto`;
unsupported components default to `none`. A value or span guide defaults
`title=none` and every other supported component to `auto`; `source=none`
permits no optional property.

<!-- rule: chart.guide.implicit-explicit class=hard enforced=yes tasks=charts title="One explicit ChartGuide per target per unit replaces the implicit guide; identity and offsets default to none" -->
Without an explicit `ChartGuide`, a non-identity primary binding has the same
effect as `source=scale` with all defaults. Identity scales and `xOffset` or
`yOffset` have an implicit `source=none`; an author may explicitly request an
offset label tier with `source=scale`. An offset is always a nested band: its
effective scale type is `band`, whether inferred or explicit; `ordinal` and
`point` are invalid because neither defines a child-band extent. Any explicit
declaration targeting a
role replaces that role's implicit guide. Thus a `source=value` or
`source=span` label field replaces, rather than supplements, the implicit axis.
There may be at most one explicit declaration for a target in a unit chart.

<!-- rule: chart.guide.kinds class=hard enforced=no tasks=charts -->
The inferred scale guide is an axis for Cartesian or polar positions, a nested
categorical label tier for an offset, a facet header for `row` or `column`, and
a legend for an appearance role. There are no bucket-date, radar, map, or other
chart-family guide types.

#### 6.4.2 Ownership, layers, facets, and composition

<!-- rule: chart.guide.shared-ownership class=hard enforced=yes tasks=charts title="A shared scale ID in one layered plot owns one guide with at most one explicit declaration" -->
An unnamed scale and its guide are local to one unit. Within one physical
layered plot, compatible bindings with the same nonempty scale ID and channel
family own one guide. At most one of those bindings may carry an explicit
declaration; it controls the merged guide, including `source=none`. With no
explicit declaration, their implicit scale guides deduplicate and the first
binding in flattened composition traversal order owns presentation text and
side order. Bindings with different IDs remain independent guides; they never
union domains or borrow one another's labels.

<!-- rule: chart.guide.label-candidates-local class=hard enforced=no tasks=charts -->
An explicit `source=value` or `source=span` guide takes candidates only from
the declaring unit's rows; sharing its target scale with another layer does not
join row data across units. Its output positions nevertheless use the complete
resolved shared domain.

<!-- rule: chart.guide.owner-key class=hard enforced=no tasks=client-runtime -->
Guide sharing, suppression, and `position=auto` resolve before layout. Every
surviving guide has an owner key `(panel,target,unit)`: `panel` is its concat
pane, `target` uses this fixed guide-layout rank:
`xOffset,x,yOffset,y,theta,radius,color,fill,stroke,opacity,fillOpacity,`
`strokeOpacity,size,shape,angle,strokeWidth,strokeDash,row,column`. Offset tiers
precede their parent axes so subgroup labels remain nearest the marks, and
facet headers follow positional and appearance guides so their grid-wide title
stays outermost. `unit` is depth-first flattened chart traversal order inside
the pane and breaks ties between guides with the same target rank. A
shared-scale guide uses the unit and target carrying its sole explicit
declaration, or the first binding when all declarations are
implicit. A value or span guide uses its declaring unit and target rank, never
the guide-label field's source-field position.

<!-- rule: chart.guide.title-owner class=hard enforced=no tasks=charts -->
A resolved guide takes its title policy and localized title source only from
that owner. For `source=scale` the source is the owner's target-binding field;
for `source=value` or `source=span` it is the guide-label declaration field.
Documents on other shared-scale contributors are ignored. Matching or differing
rendered title text never changes scale sharing or chart validity.

<!-- rule: chart.guide.side-order class=hard enforced=no tasks=charts -->
Within one pane, guides on each resolved physical side sort by `(target,unit)`;
the first is nearest the plot and later guides stack outward. Facet copies
preserve that order. Explicit sides do not move responsively. Automatic x
guides stack at bottom, automatic y guides at left, and automatic legends at
bottom; an author uses `position` to distribute them. No localized text, runtime
value, measured label width, or native traversal order changes this semantic
ordering. The old compound multi-axis fallback does not exist.

<!-- rule: chart.guide.concat-instances class=hard enforced=no tasks=client-runtime -->
Each concat child owns its guide instances and layout even when its scale domain
shares an ID with a sibling; concat sharing never deduplicates guides across
panels. A faceted positional axis repeats in each pane because each pane owns a
pixel range. A facet header appears once per resolved row or column lane, and an
appearance legend appears once for the unit's complete facet grid. Layered
facet grids share axes, headers, or legends only when the grid-equality and
shared-scale conditions in Section 4.2 hold; otherwise each grid renders its
guides independently.

<!-- rule: chart.guide.polar-one-per-family class=hard enforced=yes tasks=charts title="A polar layer shows at most one theta and one radius guide" -->
A polar layered pane may expose at most one non-suppressed theta guide and one
non-suppressed radius guide. Multiple bindings in one family therefore require
one compatible shared nonempty scale ID or explicit suppression of every extra
independent guide. Every polar layer shares the same center and radius after
the one resolved guide layout reserves space.

<!-- rule: chart.guide.a11y-order class=hard enforced=no tasks=client-runtime -->
Accessibility visits outside sides in the fixed order top, right, bottom,
left, using inward-to-outward guide order on each side, then intrinsic polar
theta and radius guides in that order, then marks. Concat panes and facet panes
retain schema traversal order within those stages.

#### 6.4.3 Cartesian, offset, and facet guide geometry

<!-- rule: chart.guide.cartesian-geometry class=hard enforced=no tasks=client-runtime -->
An x guide's domain is the plot's top or bottom edge selected by `position`;
its ticks point outward and labels are unrotated and horizontally centered on
their mapped coordinates. The nearest painted edge of an outside x label begins
beyond the tick endpoint with a four-logical-point gap; multiline labels grow
away from the plot edge and never back across the domain or grid. A y guide uses
the selected left or right edge; its
ticks point outward and labels are unrotated and vertically centered.
For either family, a grid component crosses the complete plot perpendicular to
the axis at each selected candidate. Scale reversal changes mapped candidate
positions, never these physical anchors. Cartesian `source=value` and
`source=span` guides use the same domain, tick, label, grid, and title geometry.

<!-- rule: chart.guide.axis-title-placement class=hard enforced=no tasks=client-runtime -->
A Cartesian axis title is complete localized schema context, not changing
chart data, and therefore has zero horizontal plot authority. It may use
otherwise-unused space already reserved by its guide, including a rotated side
or corner placement, provided it adds no left/right inset and overlaps neither
guide values nor marks. Otherwise the renderer places it in scrollable safe
content above or below the complete plot-and-guide frame: a top or left guide
uses the upper rail and a bottom or right guide uses the lower rail. A safe-rail
title is unabridged. Titles in the same rail share a centered line in guide
order, separated by a middle dot (` · `), and wrap using ordinary locale-aware
text layout only when the combined text exceeds the available width. Their
height extends the outer chart scroll content and never reduces the assigned
charting-region height. Rotation, alignment, and opportunistic placement within
already-reserved guide space are renderer-flexible and have no effect on scale,
mark, hit-testing, or accessibility semantics. An axis title alone can never
cause chart presentation failure.

<!-- rule: chart.guide.offset-geometry class=hard enforced=no tasks=client-runtime -->
An xOffset guide repeats its complete resolved child-domain tier inside every
parent x band; yOffset does the same inside every parent y band. Each child tick
and label is centered on its nested band. The repeated domain segment and grid
separators are clipped to that one parent band. The offset title appears once
and follows the same zero-horizontal-authority title policy above. Offset
tiers are inside their parent axes by the layout rank in §6.4.2; they never
create an independent full-plot scale line.

<!-- rule: chart.guide.facet-geometry class=hard enforced=no tasks=client-runtime -->
A column facet label is centered over or under each nonempty column lane. An
automatic row facet label is centered above its row in a single-column stack;
other row facet labels are vertically centered beside their row. Facet labels
are changing data and retain their allocated header space. The facet field title is stable
schema context and has the same zero-horizontal-authority policy as an axis
title: it may use already-reserved guide or corner space, including a rotated
placement, only when that adds no left/right inset and overlaps neither labels
nor marks. Otherwise it appears once in the upper safe rail for a top or left
facet guide, or the lower safe rail for a bottom or right facet guide. It may
wrap there and extend only the outer vertical scroll content. A facet title can
never reduce a facet pane, create trailing horizontal blank space, or cause
chart presentation failure. Facet labels remain unrotated. Explicit `labels=none`
or `title=none` suppresses that component; collision handling never silently
removes an enabled nonempty lane label.

#### 6.4.4 Polar guide geometry

<!-- rule: chart.guide.theta-geometry class=hard enforced=no tasks=client-runtime -->
Let `P(t,r)` denote the normalized screen mapping in §5.3. A theta guide's
`domain=auto` is the circle through `P(t,1)`. For each retained theta candidate,
`grid=auto` draws the center-to-`P(t,1)` spoke, `ticks=auto` draws a short
outward radial tick beginning at `P(t,1)`, and `labels=auto` places an unrotated
label beyond that tick on the same ray. The label box lies wholly outward: its
horizontal alignment follows the sign of the ray's x component and its vertical
alignment follows the sign of its y component, with centered alignment on a
zero component. The theta title is horizontally centered below the complete
circle-and-label envelope.

<!-- rule: chart.guide.radius-geometry class=hard enforced=no tasks=client-runtime -->
A radius guide's `domain=auto` is the `t=0` ray from `P(0,0)` through `P(0,1)`.
For each retained radius candidate, `grid=auto` draws its concentric circle
except at radius zero, `ticks=auto` draws a screen-horizontal tick crossing the
zero-angle ray, and `labels=auto` places an unrotated label on the ray's
clockwise, screen-right side. The radius title is horizontally centered above
the complete circle-and-label envelope. These same anchors govern theta and
radius `source=value` guides. Theme-native font metrics, tick length, and gaps
may differ, but the semantic anchors, orientations, and component ownership may
not.

<!-- rule: chart.guide.polar-coincident class=hard enforced=no tasks=client-runtime -->
Mapped theta zero and one are visually coincident. When both survive candidate
generation, they remain distinct raw accessibility entries, but the renderer
paints one spoke and tick and displays the mapped-zero label; if no mapped-zero
candidate exists, it displays the first coincident candidate in semantic order.
A radius-zero candidate likewise retains its tick, label, and accessibility
entry but has no visible zero-radius grid circle. Polar guide geometry is inert,
outside mark hit testing, and not clipped by the circular mark clip. Collision
rules may thin labels only at these anchors; they may not rotate or relocate
them.

#### 6.4.5 Scale ticks, labels, and typed formatting

<!-- rule: chart.ticks.quantitative-ladder class=hard enforced=no tasks=client-runtime -->
Tick generation creates a deterministic ladder of raw candidate sets before
measuring text. For this subsection, let the guide-cadence transform `g` equal
`log10` with inverse `10^t` for a `log` scale and otherwise equal the canonical
scale transform `f` and its inverse from §6.1. This guide-only decimal basis
does not change normalized log positions, but it makes aligned log ticks
decimal decades instead of raw `exp(n)` values. For a nondegenerate continuous
quantitative domain, let `lo` and `hi` be the raw bounds of the fixed
cadence-planning window defined below, let `a=min(g(lo),g(hi))`,
`b=max(g(lo),g(hi))`, and let `P` contain zero when
it is in-domain and inside that window, plus an authored `mid` when
it is inside that window. Let `E` contain each resolved raw domain endpoint
that is inside the planning window. `E` bounds candidate generation but is not
added to an ordinary candidate tier: a non-aligned endpoint may carry a binary
arithmetic tail and must not displace a denser aligned interval merely because
it bounds the data. An endpoint appears in an ordinary tier only when the
lattice itself generates that exact binary64 value. A transient viewport edge
is not a data or scale-domain endpoint and is never synthesized as a tick. Candidate
spacings are the strictly increasing set `S={1,2,5}*10^k` for every integral
`k`; each mathematical spacing is rounded once to binary64, and duplicate,
zero, or nonfinite results are omitted. For one spacing `s`, the candidate set is

```text
C(s) = P union { g_inverse(n*s) | n=ceil(a/s)...floor(b/s), inverse is valid }
```

<!-- rule: chart.ticks.candidate-count-bound class=hard enforced=no tasks=client-runtime -->
The client canonicalizes negative zero to zero, deduplicates by binary64 raw
equality, and sorts by mapped output position and then raw value. Before
converting `ceil(a/s)` or `floor(b/s)` to an integer or iterating, it computes
the inclusive count with saturating arithmetic capped at `4097`; a nonfinite
quotient, an index outside the exactly representable integer interval
`-(2^53-1)..(2^53-1)`, or a count over `4096` skips that spacing without
allocation. If the
binary64 width `b-a` is nonfinite, or no positive representable member of `S`
has a bounded count, the client goes directly to the terminal `P union E` tier.
Otherwise it starts with the smallest bounded `s`, tests successive
representable spacings, and stops after the last spacing not greater than the
finite width or before the next spacing would be nonfinite. It then tests one
terminal tier containing `P union E`. A nondegenerate ordinary tier must contain
at least two retained candidates; a one-tick tier cannot win over the terminal
endpoint pair merely because it fits. Cadence probing is a pure arithmetic and
geometry operation. For a horizontal guide, the first bounded tier whose
adjacent mapped anchors are at least `64` density-independent points apart
wins. For a vertical or radial guide, the required pitch is the greater of
`40` density-independent points and `1.4` times the effective caption-font line
height. When `labels=none`, every orientation instead uses a `40`-point pitch.
No candidate label is needed to make this decision. A degenerate domain has
only its sole raw value. If even the terminal tier cannot fit, guide layout
falls back as defined below. Candidate tiers are transient; only the selected
tier counts toward guide resource limits. Normalized positions always use the
canonical forward transform, so this one finite algorithm covers linear, log,
symlog, and sqrt scales rather than delegating ticks to a platform library.
For example, a compact linear domain `0...10.199999999999...` selects the
aligned tier `0,5,10`; it does not force the non-aligned maximum into that tier
and then collapse to two endpoint labels. The raw domain and mark geometry
remain unchanged.

<!-- rule: chart.ticks.temporal-ladder class=hard enforced=no tasks=client-runtime -->
DateTime and DateOnly guides use the same `4096`-candidate bound, fixed-pitch
capacity, resolved-domain-endpoint priority, and endpoint-only terminal tier.
Their
ordered cadence ladder is: `1,2,5,10,20,50,100,200,500` milliseconds;
`1,2,5,10,15,30` seconds; `1,2,5,10,15,30` minutes; `1,2,3,6,12` hours;
`1,2,7` days; `1,2` weeks; `1,3,6` months; then
`1,2,5 * 10^k` years for integral `k >= 0`. A cadence producing more than
`4096` candidates is detected with a saturating count capped at `4097` and
skipped without allocating them. The cadence probe compares raw width `w` with
the closed ladder using binary64 arithmetic. For nominal step width `d`, its
probe count is `ceil(w/d)+1`; the tier fits capacity when this finite count is
from `2` through the fixed-pitch capacity inclusive. Fixed cadences use their
exact seconds. DateTime calendar cadences use `86400` seconds per day, seven
days per week, `28` days per month, and `365` days per year. DateOnly uses the
same day counts in its raw day-ordinal space. These nominal widths are probe
geometry only; they do not redefine Gregorian boundaries. The probe does not
create a calendar, enumerate civil boundaries, resolve a
time zone, or call ICU. Exact UTC or local calendar candidates are enumerated
only once after the cadence has been selected, while constructing the bounded
visible guide run. DST and calendar variation may make that exact run denser;
the selected-run stride rule handles it without changing cadence. Calendar
conversion there is confined to the chart DateTime
range and civil years defined in §5.5. Year cadences stop after testing the first
step greater than the maximum of one, the absolute endpoint years, and their
year span, or before the next step would overflow; no later step can add a new
interior aligned year. The endpoint-only tier follows immediately, so the
search is finite for every supported civil domain.

<!-- rule: chart.ticks.temporal-alignment class=hard enforced=no tasks=client-runtime -->
For `utc`, sub-day boundaries align in UTC; Gregorian day ordinals are anchored
at 1970-01-01, week ordinals at Monday 1970-01-05, month indices use
`12*year+(month-1)`, and an aligned year is divisible by its cadence. For
`time`, the same divisibility rules apply to local civil fields. Every aligned
local civil boundary maps to all valid instants: a daylight-saving gap is
skipped and both instants in an overlap are retained. `DateOnly` tests only the
day, week, month, and year cadences against its timezone-free Gregorian day
ordinal; `time` and `utc` are identical for it and never synthesize sub-day
candidates. `TimeOnly` tests only the millisecond through hour cadences aligned
from raw second zero at midnight, plus each resolved-domain endpoint inside the
planning window. A Number carrying
`TimeInterval` is not a calendar: it uses the quantitative lattice in raw
seconds and the existing localized duration formatter.

<!-- rule: chart.ticks.time-only-end-of-day class=hard enforced=no tasks=client-runtime -->
The TimeOnly bound sentinel `86400` never formats as the same midnight label as
raw zero. It uses the client's localized end-of-day label with a locale-shaped
`24:00` representation; if a locale does not conventionally display hour 24,
the localized end-of-day phrase remains visible and distinct. The sentinel is
not a row identity and cannot appear in a tooltip or data-anchored value guide.

<!-- rule: chart.ticks.temporal-skeletons class=hard enforced=no tasks=client-runtime -->
Temporal label skeletons follow the selected cadence: sub-second and second
tiers include seconds and needed fractional digits; minute/hour tiers use
localized time; day/week tiers use localized date; month tiers use month and
year; and year tiers use year. A sub-day DateTime guide adds a localized date
tier at the first visible candidate and every local or UTC day transition.
Repeated local clocks add the localized numeric offset or zone abbreviation.
If two retained temporal values still render identically, clients add omitted
calendar fields, then fractional seconds, then a locale-shaped shortest
round-tripping raw-seconds suffix until the labels differ.

<!-- rule: chart.ticks.collision-thinning class=hard enforced=no tasks=client-runtime -->
Discrete positional axes, nested offset tiers, and value/span guide rails begin
with every resolved candidate in canonical domain or mapped-position order.
Collision thinning tests strides `1,2,4,8,...` anchored at index zero and always
retains the last candidate; the first non-overlapping stride wins. A categorical
label may wrap to two lines before thinning. To choose that wrapping, enumerate
every legal Unicode line-break position in the unabridged label, trim break
whitespace from the two painted lines, and measure each resulting pair. Choose
the pair that minimizes the greater of the two measured line widths; an exact
tie chooses the earliest source break. If there is no legal interior break, the
label remains one unabridged line. This same intrinsic two-line rule applies to
categorical axes, value/span rails, facet labels, and categorical legend-entry
labels; it never changes raw identity or accessibility text. On Android, automatic
row labels above the panes of a single-column facet stack instead use the full
pane width: retain the original label and wrap only when that width requires it.
These headings add their measured height to the vertical stack without reserving
side width or reducing plot height. Side and multi-column facet headers retain
the intrinsic two-line rule. Facet headers and categorical
legends never thin: every nonempty facet lane keeps its header and every legend
keeps every resolved domain entry. They obey the fixed geometry in §6.4.3 and
§6.4.7, and the chart falls back if all entries cannot remain visibly
represented.

<!-- rule: chart.ticks.visible-run-measurement class=hard enforced=no tasks=client-runtime -->
After cadence selection, the client constructs and localizes exactly the one
bounded visible run. It measures that run's final localized, unabridged text
with at least four density-independent points of separation and applies the
anchored stride-thinning rule when necessary. Thinning may remove ticks and
their grid lines from that visible run; it must not change cadence, inspect a
second cadence tier, or cause another cadence probe. Native font metrics may
therefore thin a selected run differently, but may not select a different
cadence. Ellipsis is a last layout fallback and never changes accessibility
text.

<!-- rule: chart.guide-label.text-sources class=hard enforced=no tasks=charts -->
Guide text never comes from a `ChartGuide` property: its grammar contains no
literal or format string. On the exact owner field defined in §6.4.2, a title
resolves `ChartGuideTitle`, then the localized document `Name`; `title=none`
omits it. Boolean values use the client's localized true/false resources while
raw `false,true` remain identity and order. <!-- rule: chart.guide-label.numeric-precision class=hard enforced=no tasks=client-runtime --> An ordinary numeric guide candidate
is presentation text, not a lossless wire serialization. <!-- rule: chart.guide-label.authored-format class=hard enforced=no tasks=charts --> A nonempty authored
`Format` on the exact guide owner is authoritative. <!-- rule: chart.guide-label.numeric-cadence class=hard enforced=no tasks=client-runtime --> Otherwise the complete
retained numeric run owns one display cadence: an authored positive `tickStep`
when present, or the quantitative cadence selected by the automatic ladder.
For every run, the client also considers the smallest positive adjacent raw gap
so candidates closer together than the selected cadence contribute the needed
fractional scale. It first normalizes that gap to two significant decimal
digits, removing any trailing zeros, so the gap's base-ten order does not
inherit a long binary subtraction tail. Starting precision is the greater of
the cadence-derived and normalized-gap-derived fractional scales. A value/span run with no positive gap
uses its associated scale cadence, while a true singleton with neither uses the
locale-shaped shortest round-tripping decimal.

<!-- rule: chart.guide-label.precision-rounding class=hard enforced=no tasks=client-runtime -->
For a positive decimal cadence `q`, remove trailing fractional decimal zeros
and let `p` be the remaining number of digits to the right of the decimal
point, clamped to zero; an integer cadence such as `100` therefore has `p=0`
and never rounds away integer digits. The normalized gap's contribution to `p`
is likewise its nonnegative decimal scale after the two-significant-digit
rounding and trailing-zero removal above: `0.1` contributes one fractional
place, while `0.25` contributes two. Round every finite binary64 candidate in the
complete run to `p` fractional decimal places using round-to-nearest,
ties-to-even, remove insignificant trailing fractional zeros, and then apply
the locale's digit shaping, sign, decimal/grouping, and exponent symbols plus
any existing semantic unit decoration. After rounding, fixed notation is
required when the adjusted base-ten exponent is from `-6` through `20`
inclusive; scientific notation is required outside that interval. This
cadence-derived guard removes binary arithmetic tails such as
`0.6000000000000001` from an otherwise ordinary `0.6` guide tick without
inventing unrelated precision. If distinct retained identities collide, raise
`p` by one for the whole run and retry, for at most 17 increases; the bounded
terminal fallback is the locale-shaped shortest round-tripping decimal. A
client never chooses precision independently per tick. Negative zero is the one
canonical zero identity and displays as zero. DateOnly, DateTime,
TimeOnly, and duration values use the semantic rules above; a duration collision
is disambiguated with the locale-shaped shortest round-tripping raw seconds.
For a `PhysicalUnit` Number, selected-visible-run collision testing and label
measurement use the complete CLDR-formatted number-plus-unit strings at the selected precision; the
unit formatter cannot introduce a second rounding pass. Visual text uses the
short unit width and accessibility uses the long width defined in §5.5.1. <!-- rule: chart.guide-label.typed-resolution class=hard enforced=no tasks=charts -->
Enumeration labels use their member documents. FormattedText Bytes use their declared resource
catalog and arguments; an unresolved or malformed essential formatted value
falls back the chart rather than exposing its resource key or argument
diagnostics. `Device`, `VirtualDevice`, `User`, `Endpoint`, and `LanDevice` use
the ordinary permission-aware system object-name resolver.
Resolution never changes raw identity, grouping, ordering, or scale mapping.
An unavailable object uses the resolver's existing localized unavailable-name
result; if that contract supplies no safe display value for an essential
label, the chart falls back instead of exposing a raw ID. Fixed program tokens
must be Enumeration or FormattedText. A value that identifies a Libertas
system object must retain its typed object reference and use the ordinary
resolver; it must not copy the resolved or source-language name into a String.
A plain String is visible only when it is genuine runtime/domain/user text and
is displayed literally. Generic words, overflow
indicators, and accessibility phrases come exclusively from client resources.
Native names, scale IDs, channel tokens, and raw object IDs are never synthetic
user-visible fallbacks.

##### Stable cadence during pan and zoom

<!-- rule: chart.cadence.inputs class=hard enforced=no tasks=client-runtime -->
The selected guide spacing depends on the chart snapshot, resolved guide and
scale/domain/transform, committed viewport span, settled plot length, guide
presentation, component switches, and font-line-height/density metrics. It is
independent of pan offset/history, locale, calendar/time zone, selection,
tooltips, pointer position, animation, and mark paint state.

<!-- rule: chart.cadence.pan-zoom class=hard enforced=no tasks=client-runtime -->
Panning retains that cadence and alignment. Only aligned candidates and visible
priority candidates appear; moving viewport endpoints are not invented ticks.
Wider localized labels use the existing anchored thinning rule rather than
changing cadence. Marks and guides move together. Live pinch transforms the
current presentation; committing a new zoom span selects the new cadence.
Gesture completion always commits the exact terminal viewport. Shared physical
facet/layer guides retain the same cadence. No interaction changes raw data,
source domains, or localization meaning.

#### 6.4.6 Value and span guide sources

<!-- rule: chart.guide-label.value-span-eligibility class=hard enforced=no tasks=charts -->
A value/span row is guide-eligible independently of mark eligibility through a
two-phase rule. Before domains resolve, both sources require a non-null typed
label and every authored `row`/`column` facet binding needed to place the row;
`value` additionally requires one finite, type-valid, transform-valid raw target
primary, while `span` requires both target endpoints. Those raw target values
contribute only to the target scale domain, and the facet identities contribute
only to their facet domains. Unrelated position and appearance fields do not
contribute on behalf of the guide. After all mark and guide contributions have
resolved shared scales and panes, the client maps and clips these provisional
candidates; a mapped-invalid or fully clipped result emits no guide entry.

<!-- rule: chart.guide-label.guide-only-rows class=hard enforced=no tasks=charts -->
Another required mark position may be null, so an App task can emit a
guide-only row that produces no painted data mark. The row nevertheless keeps
its ordinary path meaning: it breaks a line or area run or separates a polygon
subpath when the missing role ordinarily does so. This is the explicit generic
path for task-computed ticks or intervals after an arbitrary transformation;
clients never have to infer the transformation from the label.

<!-- rule: chart.guide-label.value-candidates class=hard enforced=no tasks=charts -->
A value candidate retains its source row's typed raw label identity and mapped
target coordinate. A null label omits that candidate. Failure to localize or
resolve a non-null label follows the essential-text fallback in §8.1; it never
silently removes a semantic candidate. Within one guide instance and facet pane, rows with the same
typed raw label identity and the same canonical raw target identity collapse
to one candidate whose ordering index is the first row; this does not give the
guide an interactive source-row owner. Equal labels at different target values
remain distinct. Candidates are ordered by mapped output position, then
canonical row order, and use the same collision thinning as a discrete scale.
A value guide with no valid semantic candidate resolves no guide instance: it
draws no title, domain, tick, label, or grid. Authors who need an empty bounded
axis use `source=scale`.

<!-- rule: chart.guide-label.span-grouping class=hard enforced=no tasks=charts -->
A span guide groups eligible rows within one unit and facet pane by exact typed
raw label identity, never by localized text. For each group it maps and clips
every valid authored primary/secondary interval to the current plot viewport,
normalizes endpoint order, discards empty intersections, and merges overlapping
or touching intervals. It emits one guide entry for each connected component
at that component's mapped midpoint. It does not bridge a visible gap, average
source coordinates, derive a time distance from the label, or invent any domain
coordinate beyond the raw endpoints contributed by the pre-domain rule above.
Components order by mapped midpoint and then the first contributing
row. Zoom recomputes clipping, components, and collision from the unchanged raw
snapshot. The first and last visible candidates remain priority labels.

<!-- rule: chart.guide-label.span-accessibility class=hard enforced=no tasks=client-runtime -->
Each emitted span entry exposes its localized label plus the inverse-mapped raw
bounds of that visible clipped connected component, ordered in the target
scale's input direction. It does not announce the unclipped authored bounds or
an arbitrary contributing row. Original row intervals remain available only
through their ordinary data and tooltip presentation. A span guide with no
nonempty visible connected component resolves no guide instance.

<!-- rule: chart.guide-label.tooltip-isolation class=hard enforced=no tasks=charts -->
This is the complete generic rule for sparse or task-transformed interval
labels. A tooltip field can affect a guide only when it explicitly carries
`ChartGuide`; adding an unrelated tooltip can never change guide semantics.

#### 6.4.7 Legend lowering and layout

<!-- rule: chart.legend.categorical-samples class=hard enforced=no tasks=client-runtime -->
Categorical legends contain one entry per resolved domain value in mapped
output order, including `reverse`. Sample geometry is closed and independent of
runtime text: `color` and `fill` use a filled swatch; `stroke`, `strokeDash`,
and `strokeWidth` use a line; `shape` and `size` use a point symbol; `angle` uses
an asymmetric directional needle; `opacity` and `fillOpacity` use a filled
swatch over the chart surface; and `strokeOpacity` uses a line over that
surface. A text mark's size legend instead uses the localized client resource
`ChartTextSizeSample` rendered at the mapped font size; the resource selects a
representative glyph appropriate to the active writing system. Each sample uses
the same resolved appearance output as its marks for the guide's target
channel. A legend sample isolates that target: every non-target appearance
component uses the accessible theme-native default for the owning mark and
never borrows a value from an arbitrary row or another data-driven binding.
Thus a point-size guide uses the default point shape and paint, a shape guide
uses the default point radius and paint, and a stroke-width guide uses the
default solid, fully opaque semantic foreground stroke. This isolation changes
only sample presentation; it never changes the mark's combined appearance.

<!-- rule: chart.legend.continuous-ramps class=hard enforced=no tasks=client-runtime -->
Continuous `color`, `fill`, and `stroke` guides use the contract's portable
color ramp. `opacity` and `fillOpacity` use a filled alpha ramp;
`strokeOpacity` uses a line alpha ramp. A point-size guide places the owning
point shape at every selected candidate, a text-size guide places the localized
sample above at every candidate, an angle guide places an asymmetric needle at
every candidate, and a stroke-width guide places a line at every candidate.
Those samples use the mapped physical radius, font size, clockwise angle, or
line width. The continuous rail's scale-output axis is exactly `160` logical
points long in both orientations; Android uses density-independent points and
iOS uses points. Cross-axis swatch thickness, tick length, and component gaps
remain theme-native and measured, but cannot alter that 160-point mapping.
Their selected raw candidates provide ticks and labels. The sample
or rail is intrinsic and remains present independently of `title` and `labels`
component switches; accessibility still exposes its target role and unabridged
raw domain.

<!-- rule: chart.legend.packing class=hard enforced=no tasks=client-runtime -->
Every left/right categorical legend is one vertical entry column ordered top
to bottom. An above/below categorical legend uses stable greedy row packing:
measure every complete entry with the final localized text and accessibility
font, retain domain order, place the longest prefix whose entry widths plus
fixed `8`-point inter-entry gaps fit the available width, then repeat on the
next row. At least one entry is attempted per row; an individual entry wider
than the available width is an essential-label layout failure. Entries never
split, clip, reorder, or use balancing/look-ahead. After packing, the widest
produced row defines the intrinsic width of the complete above/below guide
group. Center that group within the available chart width, then retain one
shared leading edge for every row inside it; shorter rows are not centered
independently. Multiple blocks on the left or right form outward-stacked
columns. Multiple blocks above or below share a
second stable greedy horizontal flow instead of each claiming a complete row.
First measure each block at its ideal unwrapped width; if that exceeds the
viewport width, remeasure that block at the viewport width using its internal
entry flow above. In resolved guide order, place the longest prefix of complete
blocks whose widths plus fixed `16`-point inter-block gaps fit, then continue on
the next row. A block title remains attached above its entries, so two compact
blocks such as `Line` and `Result` may sit head-to-head on one row without
mixing their entry identities. There is no balancing or look-ahead at either
packing level. Extra rows increase the chart's outer scroll height instead of
consuming plot width or creating a private legend scroller. A vertical
continuous rail maps normalized
scale output zero at the bottom and one at the top; a horizontal rail maps zero
at the left and one at the right. `reverse` therefore moves raw values without
changing the legend geometry. A visible categorical or vertical-rail title sits
above its legend; a horizontal-rail title sits at its screen-left leading edge.
It precedes entries in accessibility order. Entries never reorder, wrap into
another row or column, or disappear.
Labels within one entry may use the two-line categorical wrapping rule. A
legend must fit its non-scrolling cross-axis: top/bottom legends must fit the
chart viewport width, while a left/right legend that prevents the minimum plot
width joins the deterministic temporary overlay under §6.4.8. A legend may
exceed the visible viewport vertically under §6.4.8; that is ordinary
outer-scroll overflow, not layout failure. There is no legend-only scroller,
entry clipping, semantic thinning, or maximum legend height.

<!-- rule: chart.legend.entry-density class=hard enforced=no tasks=client-runtime -->
Categorical entry density is portable rather than platform-native. Entry
labels use the client's 11-point small/caption style and the intrinsic two-line
rule above. The sample-to-label gap is `5` logical points and adjacent vertical
entries have a `4`-point gap. Filled swatches are `10x10`; stroke samples are
`24x10`; point-shape, point-size, and angle samples use a `24x24` canvas. The
mapped point-size radius remains `3..18`, the shape radius is `5`, and the angle
needle radius is `9`. A text-size sample remains intrinsic to its mapped
`10..32` font size rather than being padded to a fixed sample box. Android uses
density-independent points and its `labelSmall` style; iOS uses points and its
`caption2` style. Native glyph metrics may differ, but clients may not add a
different entry box, row spacing, or sample padding that changes guide fit.

<!-- rule: chart.legend.shape-label-adapter class=hard enforced=no tasks=client-runtime -->
Any explicitly admitted automatic shape-label adapter uses one common
inside-first pipeline. This includes a chart-family rule that lowers an
automatic categorical guide and may include a future declaration that
explicitly associates authored text with one painted shape. Semantic
eligibility and stable owner/label identity resolve before screen layout.
Localization, intrinsic two-line wrapping, and native glyph measurement
then occur exactly once at the explicit text boundary; the pure placement tier
must not call ICU, relocalize, reshape, shrink, ellipsize, rotate, or otherwise
change that complete label block. Each admitted shape family supplies only its
final painted geometry, preferred in-shape anchor, padded containment test,
boundary anchor, and permitted outside-label adapter.

<!-- rule: chart.legend.shape-label-placement class=hard enforced=no tasks=client-runtime -->
Placement is resolved independently in the adapter's specified stable order;
a guide-lowering adapter uses resolved category order, including its declared
reverse. The complete
measured label plus the family rule's padding first attempts the preferred
in-shape anchor. A fit comparison permits at most one-half physical pixel of
raster error. A fitting label remains inside its own final painted shape; one
candidate's failure must never externalize a fitting sibling. Only failed
candidates enter the outside collision plan and reserve outside space. That
plan starts from the painted shape boundary and moves each label only as much
as the family placement and collision rules require. Residual container or screen
width is outer slack: it must not stretch the plot-to-label gap, lengthen a
leader, resize the already solved mark, or cause a second classification pass.
Any native-pixel placement is canonicalized at most once before obstacle and
route preflight; paint consumes those identical stored positions rather than
independently rounding the plot, labels, or leaders after spending the
half-pixel tolerance.

<!-- rule: chart.legend.shape-label-paint class=hard enforced=no tasks=client-runtime -->
Every automatic label is painted exactly once, whether inside or linked outside,
before any preview shell applies its documented outer clip. Those label
views are decorative and accessibility-hidden: the existing canonical mark
accessibility row, which already includes appearance-channel values, remains
the label's single semantic owner. Source presentation changes only after a
complete mixed placement succeeds. A guide-lowering adapter then suppresses its
ordinary guide and restores that complete guide on failure; a future
owner-associated authored-text adapter must define its own literal fallback. If
ownership, complete text, containment, collision, clipping, or safe-area
requirements cannot produce a plan, opportunistic automatic labels never
reject an otherwise renderable chart. An adapter must be explicitly specified.
Clients must not infer label ownership from layer
order, proximity, matching display text or keys, or a shape that merely looks
geographic.
Lowering changes guide geometry, not semantic resource ownership. A suppressed
guide reports the same entry, title, sample, label, and total primitive usage as
its ordinary presentation, so automatic placement cannot bypass a guide or
chart resource limit.

<!-- rule: chart.legend.polar-radius class=hard enforced=no tasks=client-runtime -->
**HARD INVARIANT:** every fullscreen polar family has one orientation-owned
maximum mark radius before mark, label, or legend layout. In portrait, let `W`
be the cutout-safe physical-axis width expressed in logical coordinates and
let `s` be the display scale. That width owns the natural outer coordinate
radius after the universal eight-logical-point paint inset on each physical
side: `S0=(W-16)/2`, equivalently
`S0Pixels=(WPixels-16*s)/2`. This inset is shared by Radar, pie, donut, ARC,
and every other
portrait polar mark; it is neither a label reservation nor a Cartesian height
cap. A Cartesian nominal-height cap or floor never reduces `S0`. The
portrait logical composition is `W x Hp`, where
`Hp=max(W,resolvedPolarMinimumHeight,completePolarFootprintHeight)`. Thus real
vertical host space and a complete automatic-label or title footprint may move
or enlarge the composition around the width-owned mark, but may not substitute
an inscribed label square or unrelated height for the natural-radius owner. An
eligible automatic-label adapter may still select a slightly smaller largest
feasible painted radius through its documented exact bounded planner. In
landscape, the physical height `H` below the one top bar and above the stable
bottom navigation/safe-area inset owns the natural radius `S0=H/2` and the
base mark square is `H x H`. An outer host that already excludes an inset must
not subtract it again. Radar, pie, donut, annulus, rose/radial ARC, and polar
line/point/rule/text all use this same coordinate-family guard. Preview retains
its established intrinsic polar height and documented outer-shell clip; it
does not enter a fullscreen sizing branch merely because the application is
portrait or landscape. Planner, paint/clip, grid, hit testing, tooltip and
accessibility geometry consume the same radius, composition and origin.

<!-- rule: chart.legend.polar-end-distribution class=hard enforced=no tasks=client-runtime -->
An opportunistic fullscreen-landscape polar end legend replaces that fixed
vertical gap with a measured distribution. For each categorical block, measure
the title and every complete entry intrinsically. The title remains attached to
the first entry by the `4`-point minimum gap, and adjacent entries retain that
same minimum. Let `N` be the entry count and `B` be the resulting intrinsic
block height. When `B <= H`, divide the surplus `H-B` equally among exactly
`N+1` flexible spaces: one before the block, one after it, and one between each
pair of entries. Round each absolute placement boundary to the nearest physical
pixel; do not round and accumulate independent gap sizes. All categorical
blocks therefore occupy the same measured charting height while preserving
their own entry counts. A continuous block sharing that rail is vertically
centered. When `B > H`, rows retain their intrinsic geometry and the automatic
end placement fails the height-fit test below; clients never compress, clip, or
overlap rows to manufacture a fit. This distribution applies only to the
opportunistic end rail. Preview, portrait/bottom flow, and explicitly authored
side guides retain their ordinary intrinsic geometry.

<!-- rule: chart.legend.lane-order class=hard enforced=no tasks=client-runtime -->
Facet headers and outside guide value lanes reserve space before the plot is
mapped. Cartesian axis titles use only already-reserved guide space or the
vertical safe rails of §6.4.3; they never enlarge a left/right lane. On each
physical side, the first guide in the owner-key order of §6.4.2
is nearest the plot and later guides stack outward. Grid geometry is clipped to
the plot and paints first; axis domains and ticks paint next; data marks paint
in schema order; guide labels and titles paint last outside the mark clip.
In fullscreen, an automatic appearance legend owned by a polar plot uses an
orientation-responsive preferred side: `bottom` in the physical portrait
viewport and physical screen `right` in the physical landscape viewport. The
assigned polar square's equal dimensions are never an orientation signal. A
resolved bottom appearance legend also
participates in this fullscreen adaptation; explicitly authored `top`, `left`,
and `right` remain exact. The landscape end choice is stable: a responsive
retry never rewrites it or converts it into the temporary side overlay. The
measured fit exception below is the only automatic end-to-bottom fallback. The
plot uses the complete height below the single top bar. Before any chart-family
layout, `W` and `H` exclude platform safe-drawing and physical display-cutout
insets.

<!-- rule: chart.legend.landscape-polar-guard class=hard enforced=no tasks=client-runtime -->
This adaptation is guarded by coordinate family as well as presentation and
orientation: it is active only for an interactive, fullscreen-filling,
landscape polar pane with a positive finite charting height. A client must use
one effective-position classification, including resolved-bottom-to-end
pinning, for both end-legend preflight and legend rendering. It may not infer
end occupancy independently in those two phases. Radar, donut, radial bar,
polar point/line/rule/text, and future polar marks all use this same
coordinate-family guard; Cartesian, concatenated, faceted, preview, portrait,
and transition passes do not enter it.

<!-- rule: chart.legend.landscape-polar-size class=hard enforced=no tasks=client-runtime -->
For an unfaceted landscape polar plot without an automatic outside-label
footprint, the known
charting height `H` owns polar size before the end legend is measured. The
client selects one guide cadence, measures its actual painted labels, and uses
only the required vertical label/title reservation `V` to solve the circle as
`R=H/2-V`. Horizontal label widths never become matching top and bottom insets
and therefore never shrink this height-derived circle. Because the centered
polar renderer applies `V` on both axes, the complete chart canvas is at least
`H` wide. If either side's actual painted text extends beyond that base, the
canvas grows symmetrically by the larger excess so width cannot silently
recompute a smaller `R` through `min(width,height)`.

<!-- rule: chart.legend.landscape-polar-placement class=hard enforced=no tasks=client-runtime -->
The client then places this fixed chart footprint immediately to the physical
left of the measured right legend with a `16`-point middle gap and centers
their combined group in `W`, balancing the unused outer space. The right legend
is therefore moved inward toward the chart rather than consuming the whole
residual width. Legend measurement may translate that already-solved group,
but must never determine, widen, narrow, or validate the polar circle.

<!-- rule: chart.legend.end-fit-fallback class=hard enforced=no tasks=client-runtime -->
Physical-end placement is opportunistic. It is used only when the complete
`chart footprint + 16-point gap + intrinsic right legend` fits `W` and the
intrinsic, pre-distribution right-legend height fits `H`, using the universal
half-physical-pixel raster tolerance. A fitting categorical guide is then
distributed through `H` by the rule above. If either comparison fails, the
automatic appearance legend moves to `bottom` and uses the ordinary horizontal
greedy flow. The fixed
height-derived chart footprint is not reduced, the guide is not truncated, and
the fit failure is not reported unless the resulting bottom layout also cannot
represent essential content. A viewport, font, locale, or projection change
retries end placement from a fresh measurement. Explicitly authored `right`
guides do not use this automatic fallback.

<!-- rule: chart.legend.diameter-preference class=hard enforced=no tasks=client-runtime -->
Once the plot already consumes that maximum physical height, the 200-point
interactive diameter is a preference rather than a reason by itself to move
the legend; the two-dimensional end-guide fit above may still select bottom.
Positive geometry and a valid polar-guide plan remain mandatory. Preview
retains the ordinary automatic `bottom` default so the fixed summary does not
depend on application-window orientation.

<!-- rule: chart.legend.arc-label-priority class=hard enforced=no tasks=client-runtime -->
An eligible ARC partition's automatic outside-label subset from §11.4.1 is nearer the
plot than every automatic appearance legend. It is reserved and measured
first; fitting in-shape labels consume no outside space. The ARC adapter's
labels, leaders, and complete polar frame form one tight chart footprint. That
footprint is the union of final painted bounds, not a proposal or flexible
container, so an `outer_radius < 1` cannot turn unused polar-frame space into
leader length. Center the tight footprint in the available safe width without
changing its internal geometry. It then
participates in the two-dimensional opportunistic-end test above. A
lower-priority automatic right legend is placed outside the footprint with the
ordinary `16`-point middle gap when the composition fits; otherwise that legend
moves to bottom. It may not hide, overlay, reorder, truncate, or replace inside
or linked labels to make itself fit. Explicit non-bottom legends retain their
authored side and ordinary owner-key order.
Polar grids follow the existing spoke/circle rules, including suppression of a
duplicate theta-one spoke and a zero-radius circle. A renderer may thin, wrap,
or ellipsize only by the rules above. If the resolved lanes cannot leave the
minimum plot width required by §6.4.8 or an essential label cannot be represented,
the complete chart follows the failure policy in §10 rather than overlapping or
relocating an explicit guide.

<!-- rule: chart.legend.guides-inert class=hard enforced=no tasks=charts -->
Guides are semantically inert. They do not compete in mark hit testing, own a
tooltip or source row, trigger a typed request, or become a selection. A value
or span label remains guide-inert even though rows supplied it; authors use a
`text` mark when the label must be interactive. <!-- rule: chart.legend.guide-accessibility class=hard enforced=no tasks=client-runtime --> Accessibility traverses chart
title, guides by physical side and inward-to-outward order, then marks. It
exposes every unabridged guide title and raw candidate through the typed
localized rendering above, including candidates thinned visually; a span entry
also exposes its target interval. Legends expose the appearance role and
sample meaning, and facet headers label the pane they govern.

<!-- rule: chart.legend.guide-resource-limits class=hard enforced=no tasks=charts -->
Guide projection is bounded before native allocation: at most `256` resolved
guide instances under one chart root, `4096` semantic candidates for one guide,
`16384` guide entries in total, and `32768` guide primitives in total. A title,
label, tick, grid line/spoke/circle, domain stroke, or legend sample is one
guide primitive. These counts are separate from the data-mark primitive budget;
responsive thinning may reduce native objects but cannot make an over-limit
semantic guide valid. Text retains the existing 4096-byte per-value limit.

#### 6.4.8 Deterministic responsive guide overlay

<!-- rule: chart.overlay.usable-viewport class=hard enforced=no tasks=client-runtime -->
The containing chart shell first establishes one usable visible viewport after
safe areas, system bars, and persistent application chrome. Chart controls use
the upper title safe rail and do not reduce or overlay the charting region.
Android measures in density-
independent points and iOS in points.

<a id="preview-chart-height"></a>

<!-- rule: chart.preview.height class=hard enforced=no tasks=charts -->
**Preview height.** A preview's charting-region height `R` follows its pane.
Let `Wp` be the width assigned to the chart's plot-and-guide frame before any
guide lane, which is the preview shell width `Wv` for a unit, layer, facet, or
`vconcat` and the pane width `P` below for an `hconcat` child; let `Hp` be the
visible height of the nearest enclosing pane under the Display Template's
[height tokens](../presentation.md#height-tokens). The family aspect `a`
(width over height) is `1` for a polar chart, `4/3` for a Cartesian chart while
the pane's `width` condition is `compact`, and `16/9` for every other width
class. The cap share `c` is `0.5`. A Display Template
[`chart`](../presentation.md#behavior-hints) rule on the chart root may replace
`a` with `aspect` (`square` is `1`, `4:3`, `16:9`) and `c` with `max_height`
(`small` `0.25`, `medium` `0.40`, `large` `0.60`):

```text
R = max(180, min(Wp / a, c * Hp))
R = 180                                when max_height is "thumbnail"
```

<!-- rule: chart.preview.floor-and-viewport class=hard enforced=no tasks=client-runtime -->
The `180`-point floor wins over the cap: a short pane, such as a phone in
landscape, still gets the V1 region and the excess is reached by the
enclosing scroll. The preview viewport keeps the V1 proportion, assigning
three quarters of its height to that region: its height is exactly
`4R / 3`, so the floor reproduces the V1 `240`-point viewport. `R` is
re-evaluated when the pane's width, height, or width class changes; that
re-layout is a new presentation of the same snapshot and never changes the
data. A non-finite or zero `Hp` leaves only the floor and the width term.
Neither this rule nor `chart` changes fullscreen sizing, and a `Chart3D`
preview keeps its own fixed `240`-point viewport.

<!-- rule: chart.preview.fullscreen-height class=hard enforced=no tasks=client-runtime -->
In fullscreen, let `W,H` be the current usable
viewport width and height. The assigned charting-region height `R` is:

```text
landscape or square (H <= W): R = 0.75 * H
portrait (H > W):            R = min(0.75 * H, W)
```

<!-- rule: chart.preview.concat-and-facet-panes class=hard enforced=no tasks=client-runtime -->
The portrait width is the deterministic stand-in for the height visible after
rotation to landscape. This supersedes a fixed 300-point portrait cap while
still preventing a tall phone from stretching one plot arbitrarily. An
unfaceted unit/layer and each concat child use exactly `R`. For an `hconcat`,
let `Wv` be the current usable visible viewport width after shell insets. Every
child receives cross-axis pane width `P = max(Wv, 280 points)`; the client must
never divide `Wv` by the number of children. Preview keeps the root title and
Chart Explorer control in a shell rail of width `Wv`, outside composition
clipping, then clips later panes. Fullscreen uses the same per-pane rule and one
horizontal composition scroller, so every complete pane remains reachable in
portrait, landscape, and after rotation. A facet chart assigns `R` to its
complete pane grid, subtracts authored row gaps, and divides the remainder
equally among facet rows. An established per-pane geometry minimum may increase
the complete grid beyond `R`; panes never collapse to satisfy the reservation.
A `vconcat` likewise gives each child its own `R`, so later children remain
reachable through the same outer vertical viewport.

<!-- rule: chart.preview.outer-viewport class=hard enforced=no tasks=client-runtime -->
Chart title and appearance legends are outside that assigned charting region.
They never shrink or stretch it. In particular, a left/right legend taller
than `R` increases the complete scroll content height while the adjacent plot
remains `R`; top/bottom legends precede or follow it normally. Preview and
fullscreen have different shell semantics. A preview is a bounded, gesture-neutral
summary: excess legend or composition content is clipped by its `4R / 3`
preview viewport and is reached only by opening Chart Explorer. It has no internal
vertical or horizontal scroller. Fullscreen Chart Explorer owns exactly one
vertical chart viewport over the complete title, charting regions, and legends;
its excess content remains reachable through that outer scroll. A renderer must
not add a nested legend scroller, cap legend height, discard entries, or fall
back merely because complete vertical content is taller than either viewport.

<!-- rule: chart.preview.initial-scroll class=hard enforced=no tasks=client-runtime -->
Initial presentation is data-focused. Preview places its fixed control rail and
first charting region at the leading edge and does not establish mutable scroll
state. In fullscreen, starting from semantic scroll position zero, the client
makes the minimum nonnegative vertical displacement that fully exposes the first
charting region. This leaves up to the permitted first quarter available for
preceding legend/title content. If that charting region itself is taller than
the viewport, its top aligns with the viewport top. Subsequent measurement,
rotation, Dynamic Type changes, and recomposition never reset a fullscreen
user's scroll position; a new immutable chart snapshot establishes a new
initial presentation.

<!-- rule: chart.overlay.minimum-plot-width class=hard enforced=no tasks=client-runtime -->
The preferred minimum usable plot width is `200` logical points: Android uses
density-independent points and iOS uses points. For each plot or facet pane,
let `C` be the horizontal width assigned to its plot-and-guide frame after
shell padding and concat/facet allocation but before any left/right guide lane
reserves space. Its required usable plot width is `M = min(200, C)`. `M` is a
post-guide plot measurement, not the outer chart width. A container narrower
than `200` points is therefore valid and the chart must not fall back merely
because its plot cannot reach `200`; it must still reclaim guide-reserved width
until it reaches `C` or exhausts the policy below. Integer-pixel rasterization
may satisfy `M` at the nearest physical pixel, with at most one-half physical
pixel of error. The renderer measures final localized text with the user's
active accessibility font metrics before choosing presentation. Native font
metrics may therefore change whether `M` is crossed, but may not change the
policy below.

<!-- rule: chart.overlay.polar-preview-diameter class=hard enforced=no tasks=client-runtime -->
An interactive polar pane applies that same preference to its equal-aspect
diameter. A bounded, gesture-neutral preview instead requires a strictly positive
post-guide diameter and validates every final polar label against measured
bounds and collisions. It does not reuse the interactive 200-point diameter:
at the `180`-point preview floor, any outside polar label would make that
condition structurally impossible before candidate thinning could run, and a
taller responsive `R` does not reinstate it. This preview rule
never removes a semantic guide candidate; thinned candidates remain in
accessibility, and opening Chart Explorer restores the interactive diameter
preference.

<!-- rule: chart.overlay.facet-columns class=hard enforced=no tasks=client-runtime -->
Facet columns are responsive allocations, never intrinsic `200`-point widgets.
After subtracting the one resolved row-header lane from the facet grid's
pre-guide width, the renderer divides every remaining point
equally among the nonempty facet columns in source-resolved order. A one-column
facet therefore consumes all remaining width and leaves no trailing blank
column. That equal share is the pane's `C`; a share below `200` is valid under
`M = min(200, C)` and cannot itself cause fallback. Column headers are centered
within the same equal shares. Their existing complete-label and two-line
categorical wrapping rules still apply; the renderer does not restore an
intrinsic pane minimum to make a header fit.

<!-- rule: chart.overlay.two-stage-policy class=hard enforced=no tasks=client-runtime -->
Every guide first participates in the ordinary reserving layout in §6.4.7. If
that complete plan leaves a plot narrower than `M`, every Cartesian
axis, offset tier, value rail, or span rail resolved to the physical left or
right side changes to temporary overlay presentation together, and the client
remeasures. If that stage-one plot is still narrower than `M`, every
visible appearance legend on the left or right side joins the same overlay,
and the client remeasures a second and final time. A legend never moves when
the axis-only stage already restores the minimum. Top and bottom guides remain
outside because converting them cannot reclaim horizontal width. Facet headers
and polar guides never move or disappear. This is a mandatory runtime policy,
not author metadata: no `ChartGuide` property enables, disables, or reorders
it. If the final Cartesian plot or interactive polar diameter is still narrower
than `M`, or any final plot has nonpositive height, the complete chart follows
the failure policy in §10. Apart from the closed gesture-neutral polar-preview
rule above, there is no device-size exception beyond `M = min(200, C)`, partial
conversion within either stage, orientation-specific guess, or additional
platform heuristic.

<!-- rule: chart.overlay.control-set class=hard enforced=no tasks=client-runtime -->
Exactly one chart-control set belongs to one rendered chart root. A facet pane,
layer unit, or concat child never creates another set; its viewport actions are
coordinated through that root set. In fullscreen, one guide control is present
whenever the runtime policy places at least one resolved guide in overlay mode.
Preview exposes only its Chart Explorer presentation action and never guide or
zoom controls. Every control is a visible `44` by `44` point minimum touch target.
The upper safe rail packs
complete localized guide titles into its leading remainder and the one control
set at its physical trailing edge. When both fit they share a row. When they do
not, the title or controls wrap to additional complete safe-rail rows in reading
order; that vertical growth belongs to the outer chart scroll and can never
reduce, cover, or reject the plot. Controls appear in this fixed physical
left-to-right order when present: Guides, Zoom out, Reset, Zoom in, then
Fullscreen or Close. Hidden/inapplicable controls leave no empty slot. The
guide control's closed and open accessibility names use the localized client
resources `ChartShowGuides` and `ChartHideGuides`; no schema token or native
name is shown. Activating it opens or closes every overlay guide atomically.
Guide controls are operable only in fullscreen Chart Explorer. Pointer,
keyboard, switch-control, and screen-reader activation are equivalent.

<!-- rule: chart.overlay.open-geometry class=hard enforced=no tasks=client-runtime -->
While open, an overlay guide uses the same resolved candidates, component
switches, side, owner order, localization, and accessibility entries as its
outside form. Its domain remains on the selected plot edge, but ticks point
inward and its label and title lanes mirror inward over the plot instead of
reserving exterior space. On one side the first axis, offset, value, or span
owner is nearest that edge and later such owners stack farther inward. Side
appearance legends retain their ordinary vertical block geometry and authored
side, and stack inward after those mirrored lanes in their resolved owner order.
Their complete title, samples, labels, and accessibility entries remain
unchanged; legend labels do not gain the axis-label ellipsis permission below.
Overlay components paint after marks on a theme surface that preserves text
contrast and may temporarily obscure data; they never remap, resize, pan,
select, or hit-test marks. Existing deterministic collision rules still apply
inside the fixed plot bounds. Ellipsis is permitted only for candidate labels
in this temporary overlay presentation; outside-guide labels, legend labels,
and every guide title remain unabridged. For a horizontal overlay, a
candidate's label cell extends horizontally from the midpoint to the preceding
retained anchor to the midpoint to the following retained anchor, clipped to
the plot; the first and last cells use the corresponding plot edge. Its height
is the ordinary one- or two-line label lane. A vertical overlay uses the
analogous y interval for cell height and the mirrored outside-guide lane for
cell width. Mirrored lanes retain their measured outside widths and owner
stacking, then intersect the plot; no equal-width or fixed-width lane is
invented. Each cell reserves the ordinary four-point inter-label gap, split
equally across its two anchor-axis sides.

<!-- rule: chart.overlay.label-ellipsis class=hard enforced=no tasks=client-runtime -->
A categorical overlay label first wraps to at most two lines. Using extended
grapheme clusters, the renderer puts the longest prefix that fits on the first
line, preferring the last Unicode line-break opportunity within that prefix;
leading and trailing break whitespace is not painted. The remaining text forms
the second line. A noncategorical label remains one line. If a resulting line
does not fit the cell width, the renderer removes trailing extended grapheme clusters
until appending the single Unicode ellipsis character `…` fits. It never emits
three dots, splits a grapheme cluster, changes the semantic candidate, or uses
the truncated text for collision, grouping, or identity. The complete
unabridged localized value remains the accessibility label. If the required
one- or two-line font height does not fit the cell height, the ellipsis alone
does not fit the cell width, a measured mirrored lane has no nonempty
intersection with the plot, or an unabridged title cannot fit its overlay lane,
the complete chart follows the failure policy in §10.

<!-- rule: chart.overlay.side-legend class=hard enforced=no tasks=client-runtime -->
A side appearance legend keeps its intrinsic vertical extent while overlaid.
If it is taller than the assigned charting region, the legend extends the one
outer vertical chart viewport exactly as it did outside the overlay; the plot
height remains `R`, and there is still no legend-only scroller or height cap.
The left and right overlay stacks, including their preceding mirrored lanes,
must fit without crossing. Otherwise the complete chart follows the failure
policy in §10. Closing the overlay removes their visible paint but not their guide
resource counts; it also removes their direct accessibility entries until the
localized guide control opens the overlay again.

<!-- rule: chart.overlay.dismissal class=hard enforced=no tasks=client-runtime -->
The overlay remains open until the user activates the guide control again,
uses the platform accessibility dismiss action, or taps outside the chart. It
has no timeout. A layout change reruns the complete policy and closes the
overlay; if the guides now fit outside they return outside, otherwise the
closed overlay affordance remains. Overlay mode does not reduce guide
candidate or primitive resource counts and cannot rescue a semantically
over-limit chart.

## 7. Why four attribute families are sufficient

Vega-Lite includes data loading, transforms, mark configuration, encoding,
scales, guides, composition, selection, and expressions. Libertas already has a
typed protocol and an executable App server, so most of those grammar families
would duplicate stronger existing mechanisms.

| Vega-Lite concern | Libertas design |
| --- | --- |
| Data source and loading | The typed protocol response payload. |
| Calculate, filter, lookup, join | App task code. |
| Aggregate, bin, time unit, window | App task code emits final rows. |
| Sort | Server list order or the `order` channel. |
| Stack | Explicit server-computed `x,x2` or `y,y2` bounds. |
| Regression and density | Server-computed points. |
| Geographic projection | Server-computed Cartesian geometry. |
| Mark | `Chart` primitive token. |
| Encoding | `ChartChannel` on direct scalar fields. |
| Scale and resolution | Automatic defaults plus optional `ChartScale`. |
| Constant style | A repeated literal data field with an identity scale. |
| Layer, concat, facet | `Chart` compositions and `row`/`column` channels. |
| Axis, legend, facet header, and data-anchored label | `ChartGuide` with scale, value, or span sources. |
| Parameters and data-changing selection | A typed next protocol request and a new snapshot. |

Consequently, separate `ChartTransform`, `ChartSort`, `ChartStack`,
`ChartStyle`, `ChartAxis`, and `ChartLegend` attributes are intentionally not
part of the minimum design.

<!-- rule: chart.attr.time-projection class=hard enforced=no tasks=charts -->
Time is not a privileged client-side transform. An App task may emit a native
DateTime/DateOnly position for the ordinary automatic temporal scale, or it may
project time into any bounded numeric coordinate its domain requires. In the
latter case, a typed DateTime, DateOnly, Enumeration, FormattedText, or Libertas
object-reference field can independently supply `source=value` or
`source=span` guide labels, including on guide-only rows whose unrelated mark
geometry is null. The client therefore localizes truthful typed labels
without reverse-engineering calendar meaning from synthetic geometry. This
ability to execute arbitrary application code before returning one typed,
chart-ready snapshot is a defining Libertas boundary, not a missing client
transform language.

## 8. Attribute resolution and references

<!-- rule: chart.attr.nearest-complete class=hard enforced=no tasks=charts -->
Libertas attribute lookup returns the nearest declaration for one attribute
name. Conceptually, lookup considers:

1. the use-site or local field;
2. the same-named inherited table field;
3. the referenced named type.

<!-- rule: chart.channels.override-complete-set class=hard enforced=no tasks=charts -->
It does not union values from several declarations. Therefore a local override
must contain the complete channel set:

```text
referenced type: ChartChannel=tooltip
use site:        ChartChannel=x,tooltip   correct
use site:        ChartChannel=x           tooltip is intentionally replaced
```

<!-- rule: chart.attr.names-compose class=hard enforced=no tasks=charts -->
Different attribute names compose normally. A local `ChartChannel` can use a
referenced type's `ChartScale` or `ChartGuide` when no nearer same-named
attribute replaces it. A local `ChartGuide` is always a complete declaration;
its properties never merge with an inherited guide.

<!-- rule: chart.attr.foreign-preserved class=hard enforced=no tasks=charts -->
Foreign compatible types preserve official chart attributes unless the local
schema supplies a valid explicit override. Both language parsers must extract
the same local declarations and references; Studio's shared validator applies
the portable placement, inheritance, canonicalization, and override rules.

### 8.1 Localization and schema-document dependency

<!-- rule: chart.l10n.no-document-dependency class=hard enforced=no tasks=client-runtime -->
Chart semantics have **no schema-document dependency**. A client must be able to
parse and validate geometry, domains, grouping, and interaction identity from
only the language-neutral schema and payload:

```text
schema + ScriptValue snapshot -> semantic ChartSpec
semantic ChartSpec + resolved document? + client resources -> presentation
```

<!-- rule: chart.l10n.document-never-semantic class=hard enforced=no tasks=charts -->
The second step reuses the same already-resolved Libertas document used by the
ordinary data view, using the existing
[localized package document](../schema.md#10-localized-package-documents) model.
Chart support adds no document fetch, no chart-specific document format, and no
document reference inside `Chart`, `ChartChannel`, `ChartScale`, or
`ChartGuide`. Document text can never change data meaning or make an otherwise
invalid chart valid.

The presentation dependency is deliberately small:

| Existing localized source | Chart use | Required? |
| --- | --- | --- |
| <!-- rule: chart.l10n.title-optional class=hard enforced=no tasks=client-runtime --> Type `DisplayName` | Chart or composition title | No; omit an empty title. |
| <!-- rule: chart.l10n.description-optional class=hard enforced=no tasks=client-runtime --> Type `Description` | Long help and accessibility context | No; omit when empty. |
| <!-- rule: chart.l10n.field-name-optional class=hard enforced=no tasks=client-runtime --> Field/member localized `Name` | Guide, legend, and tooltip caption | No for geometry; omit an optional caption when empty. |
| <!-- rule: chart.l10n.field-description-optional class=hard enforced=no tasks=client-runtime --> Field/member `Description` | Tooltip help and accessibility detail | No; omit when empty. |
| <!-- rule: chart.l10n.enum-documents-required class=hard enforced=no tasks=charts --> Enumeration member `Name` and `Description` | Fixed category label and help | Required only when that Enumeration is visibly encoded. |
| <!-- rule: chart.l10n.object-resolver-required class=hard enforced=no tasks=client-runtime --> Existing Libertas object resolver | Dynamic display name for a `Device`, `VirtualDevice`, `User`, `Endpoint`, or `LanDevice` reference | Required only when that reference is visibly encoded. |
| <!-- rule: chart.l10n.string-resources-runtime-keys class=hard enforced=no tasks=charts --> Package document `StringResources` | Render a `FormattedText` Bytes tuple with Libertas Message Format 1 (LMF1) | Used when supplied; schema validation checks the declared package keys but does not prove every runtime tuple key. |
| <!-- rule: chart.l10n.custom-property-not-token-map class=hard enforced=no tasks=charts --> Flattened custom document property | Static App-specific chart wording | Never required by the minimum grammar and never a runtime-token map. |
| <!-- rule: chart.l10n.client-catalog class=hard enforced=no tasks=client-runtime --> Client localization catalog | Generic actions, fallback/errors, mark names, and accessibility grammar | Required of the client, not of the App schema. |

<!-- rule: chart.l10n.resolution-and-no-synthesis class=hard enforced=no tasks=client-runtime -->
Resolution uses the platform's existing locale priority and default-locale
fallback. Enumeration values use their localized member documents. Every
Libertas object type uses its existing dynamic, permission-aware client
resolver and ordinary unavailable-reference behavior; chart code must not
invent a second resolver or require an App-supplied label. Names may change at
runtime without changing the raw reference identity. If an optional localized
value remains empty, the client omits it. It must not synthesize a caption from
`NativeName`, a scale ID, channel token, `Chart` value, source-code identifier,
or hard-coded English. If an essential visible category cannot be localized or
resolved after normal fallback, the client follows the chart failure policy in
§10 instead of presenting an inaccessible or misleading chart.

<!-- rule: chart.l10n.optional-custom-properties class=hard enforced=no tasks=charts -->
The minimum chart introduces no mandatory custom document keys. Existing
`DisplayName`, long description, field names/descriptions, and Enumeration
documents are sufficient. An App may optionally provide localized flattened
properties such as `ChartGuideTitle`, `ChartTooltipName`, `ChartEmpty`, or
`ChartAccessibilitySummary` when the established text is not appropriate. The
corresponding ordinary name/description or a built-in localized client resource
remains the fallback; these properties never affect chart validation. They are
static wording attached to the documented schema node. A client must never use
them as a dictionary for arbitrary plain String payload values.

<!-- rule: chart.l10n.formatted-runtime-text class=hard enforced=no tasks=charts -->
Formatted runtime text has a separate, closed path. The App schema declares one
package-owned `StringResources` catalog, and its package document contains the
localized LMF1 templates. The emitting task already identifies that package;
the tuple therefore carries no type owner or resource FQN. Schema validation
checks declared keys and default-document membership but does not prove every
runtime tuple key. At chart
presentation time, an unresolved or malformed optional formatted value is
omitted and an essential one invokes the chart failure policy in §10 for the
complete chart. Resource keys, tuple bytes, and typed argument diagnostics may appear in
bounded developer diagnostics or the ordinary data view's established fallback,
but never as chart presentation text.

A FormattedText Bytes payload is the canonical encoding of one key plus ordered
typed Libertas Message Arguments, including an empty argument list. The client
renders the localized template with its custom LMF1
renderer, including zero-based positions, number/date styles,
plural/select/selectordinal/choice, and Libertas types such as
`LibertasDevice` and `LibertasEnum`. The encoded argument type takes precedence
over a conflicting template type, and `LibertasEnum` uses its style as the
actual Enumeration type name. See
[Libertas Message Format](../message-format.md).

<!-- rule: chart.text.formatted-bytes class=hard enforced=no tasks=charts -->
Formatted chart values never use a String containing Base64. Encoding only to
decode again at render time provides no benefit, while the Bytes host creates a
sharp distinction from literal text and makes authoring mistakes less likely.

<!-- rule: chart.text.lmf-format-complete class=hard enforced=no tasks=charts -->
The LMF1 number or date style is the complete formatting instruction for that
message argument; field-level `Format` and chart scale formatting do not
override it. A locale, translated template, number style, or resolved object or
Enumeration label may change text measurement and therefore label collision,
guide reservation, legend flow, or chart presentation failure. It cannot change
the data-derived plot geometry, domain membership, category ordering, shared
scale compatibility, or interaction identity.

<!-- rule: chart.text.no-runtime-template class=hard enforced=no tasks=charts -->
The payload never contains a template. The raw key/blob remains the category,
grouping, ordering, and interaction identity. Two values that render to the
same text therefore remain distinct.

Every user-visible string obeys one of these rules:

- <!-- rule: chart.l10n.static-wording-source class=hard enforced=no tasks=charts --> static App-authored chart wording comes from the resolved document or an
  optional custom document property;
- <!-- rule: chart.l10n.generic-client-wording class=hard enforced=no tasks=client-runtime --> generic client wording comes from the client's Libertas string resources;
- <!-- rule: chart.l10n.typed-labels class=hard enforced=no tasks=charts --> localized Enumeration text comes from Enumeration documents;
- `Device`, `VirtualDevice`, `User`, `Endpoint`, and `LanDevice` labels come from
  their normal client resolvers, while raw IDs remain category and interaction
  identity;
- <!-- rule: chart.l10n.dynamic-formatted class=hard enforced=no tasks=charts --> App-authored dynamic presentation uses `FormattedText`; the App package's
  `StringResources` catalog supplies localization;
- genuine runtime content, such as an externally supplied product proper name
  or free-form annotation, may be displayed verbatim as data;
- <!-- rule: chart.l10n.string-never-resource-key class=hard enforced=no tasks=charts --> a plain String payload is always literal data, never an implicit resource key;
  a fixed localizable program token must instead be an Enumeration or formatted
  Bytes output, and a Libertas object name must instead come from its
  typed object reference and normal resolver.

<!-- rule: chart.l10n.uniform-scope class=hard enforced=no tasks=charts -->
These rules apply uniformly to every primitive and server-projected family,
including direct labels, heatmaps, bubbles, networks, and Sankey-style flows.
Changing geometry does not create an exception for presentation provenance.
Only `key`-only Strings and guide-less identity appearance Strings are
non-textual. A String that also participates in a tooltip, label, guide,
legend, facet, or accessibility distinction is user-visible and must satisfy
the literal-data assertion above.

Representative response fields therefore resolve as follows:

| Chart family and field | Required semantic source |
| --- | --- |
| <!-- rule: chart.l10n.bubble-product class=hard enforced=no tasks=charts --> Bubble portfolio `product` | Plain String only when it is the product's proper name; use an Enumeration for a fixed product class. |
| <!-- rule: chart.l10n.heatmap-time-block class=hard enforced=no tasks=charts --> Activity heatmap `time_block` | Enumeration for fixed blocks, or formatted Bytes for parameterized wording. |
| <!-- rule: chart.l10n.direct-label class=hard enforced=no tasks=charts --> Direct-label `label` | Enumeration, formatted Bytes, typed Libertas object reference, or genuine literal domain/user text according to provenance. |
| <!-- rule: chart.l10n.network-node-connection class=hard enforced=no tasks=charts --> Network `node` / `connection` | Typed Libertas object references when they identify system objects; otherwise Enumeration for a closed relationship vocabulary or formatted Bytes for dynamic wording. |
| <!-- rule: chart.l10n.sankey-stage class=hard enforced=no tasks=charts --> Sankey `stage` | Enumeration for fixed stages, or formatted Bytes for parameterized wording. |
| <!-- rule: chart.l10n.identity-and-key-strings class=hard enforced=no tasks=charts --> Identity-scale colors and key-only identifiers | Plain String is allowed only while the field has no text-emitting role or guide. |

<!-- rule: chart.l10n.enum-label-not-flattened class=hard enforced=no tasks=charts -->
An enum-backed traffic-source arc label already satisfies this rule because the
client resolves its member document; it must not be flattened into a String.

<!-- rule: chart.l10n.essential-text class=hard enforced=no tasks=charts -->
Essential chart text means a `text` mark value, a visible positional, facet, or
appearance category, a retained guide or legend label, or an accessibility name
needed to distinguish rendered items. Chart and guide titles, descriptions,
tooltip captions, and nonessential tooltip values are optional and are omitted
when their localized presentation is unavailable.

<!-- rule: chart.l10n.diagnostics-and-a11y-sentences class=hard enforced=no tasks=client-runtime -->
Diagnostics may include stable native names in developer logs, but those names
are never user-interface fallback text. Accessibility sentences are assembled
from localized field/category values with localized client templates; they are
not constructed by concatenating English fragments in renderer code. Chart
metadata never embeds a format string.

## 9. Interaction model

The minimum client behavior is:

- <!-- rule: chart.interaction.hit-and-slice class=hard enforced=no tasks=client-runtime --> a tap or pointer focus must first hit at least one datum's final painted
  geometry within the platform's normal touch/pointer radius; plot background
  dismisses. In a Cartesian plot, a directly hit polygon competes with other
  directly hit geometry by full geometry distance and then reverse paint order,
  but never joins an X slice. If the winning direct hit is not a polygon, the
  client resolves exactly one horizontal slice among the remaining Cartesian
  marks by rendered X distance, then full geometry distance, then reverse paint
  order. Continuous X distance is measured after the resolved forward
  transform and current normalized viewport, so zoom increases raw X resolution
  without changing source data;
- <!-- rule: chart.interaction.slice-identity class=hard enforced=no tasks=client-runtime --> a slice has one X-scale owner and exact source identity. A nonempty compatible
  scale ID is a shared owner; an unnamed X scale is local to its unit. Point
  slices use the effective scalar type and exact raw X identity. X/X2 marks use
  the unordered endpoint pair and never merge with a point slice. Categorical
  identity is the exact raw key. Pixel epsilon, localized labels, and coincident
  normalized positions never merge different slices or independent scales;
- <!-- rule: chart.interaction.grouped-callout class=hard enforced=no tasks=client-runtime --> after the slice is chosen, the grouped callout contains only distinct source
  rows in that slice whose final painted vertical geometry is within the
  platform radius. The winning row appears first; peers are ordered by vertical
  distance and stable composition/row order. Selection is confined to the
  current concat child and facet pane, while distinct detail series and layers
  at that X remain eligible;
- <!-- rule: chart.interaction.no-interpolated-row class=hard enforced=no tasks=client-runtime --> a line or rule may use interpolation only to measure its painted hit geometry.
  It resolves to the nearer originating source row in output X, choosing the
  later row at an exact midpoint; tooltip values, typed requests, and selection
  identity never synthesize an interpolated row. Cartesian intervals use their
  final clipped/minimum-width footprint for hit testing;
- <!-- rule: chart.interaction.polar-selection class=hard enforced=no tasks=client-runtime --> polar marks retain geometry selection because they have no Cartesian X
  slice. Point, text, and arc resolve to their source row. A polar line or rule
  resolves a hit segment to its nearer originating endpoint row in screen
  distance, choosing the later canonical row at an exact midpoint. Competing
  polar hits use full geometry distance and then reverse paint order;
- <!-- rule: chart.interaction.polygon-hit class=hard enforced=no tasks=client-runtime --> a polygon hit uses the exact final clipped path: containment uses the
  `evenOdd` fill, so a point in a hole is not a fill hit, and stroke proximity
  applies only when a visible stroke was painted. A boundary hit resolves like
  a line edge. A fill-interior hit resolves to the nearest finite, validly
  mapped source vertex in screen distance across every subpath, choosing the
  later canonical vertex at an exact tie. Overlapping polygons use reverse
  paint order to establish the winning hit. For a polar polygon, that winning
  vertex establishes one mapped theta spoke; the callout then includes one
  finite, valid original source vertex from every polygon detail group in the
  same pane on that spoke. Theta zero and one are the same spoke. Groups retain
  stable paint order, and each selected vertex's original `ScriptValue`, not a
  synthesized group or intersection, owns its tooltip and typed request;
- <!-- rule: chart.interaction.cartesian-callout class=hard enforced=no tasks=client-runtime --> the Cartesian callout presents at most one common horizontal header, followed
  by the selected rows' remaining tooltip fields. It promotes and removes only
  X/X2 source fields present in the authored or default tooltip projection; an
  explicit omission remains omitted and a distinct tooltip-only field remains.
  When both endpoints are present, one range header preserves the interval. The
  callout is anchored at the interaction point, prefers the space above, flips
  below near the top edge, remains inside the complete physical chart, and
  stays bounded and internally scrollable. A facet pane confines selection
  identity but is not the callout's horizontal viewport; sibling facet space
  remains available to the callout. The desired outer width is the widest
  complete logical row plus both content insets and exactly one dedicated,
  stationary close-control slot. The complete physical chart width after edge
  insets is the only horizontal cap: no smaller fixed or style maximum may
  force overflow while unused chart width could expose that row. The close
  control never overlays or scrolls with content. Each localized caption and
  typed value is an unabridged, non-wrapping token. Only when the complete row
  plus its reserved chrome exceeds that physical bound does the content scroll
  horizontally rather than wrap or truncate. It follows layout changes through a normalized
  interaction anchor and provides both a visible close control and the platform
  accessibility dismiss action. The ordered selected-source identity owns both
  scroll axes: replacing one nonempty selection with a different identity
  resets the callout to top-leading before paint, while anchor movement or a
  repeated tap on the same ordered identity may retain its scroll offsets. A
  newly selected datum can therefore never inherit a prior tooltip's scrolled
  state and mask its first row or leading token;
- <!-- rule: chart.interaction.polar-callout class=hard enforced=no tasks=client-runtime --> a polar or polygon callout presents each selected source row's authored or
  default tooltip projection without synthesizing a Cartesian horizontal
  header. It uses the same bounded placement, scrolling, close, and
  accessibility behavior;
- <!-- rule: chart.a11y.focus-values-and-units class=hard enforced=no tasks=client-runtime --> accessibility focus exposes the mark, localized series/category, and the
  original typed channel values rendered through the localization and
  permission-aware name-resolution rules in section 8.1, plus display units
  from effective `PhysicalUnit` metadata using the long localized form. Unit
  prose present only in field documents or descriptions is not semantic unit
  metadata and is not parsed or synthesized into a value.
  Polygon separators and invalid vertices are not focusable; polygon vertices
  traverse in group paint order, subpath order, and canonical vertex order.
  Polar accessibility exposes the raw theta and radius values and never
  substitutes normalized turns or screen radii;
- <!-- rule: chart.a11y.virtualized-sequence class=hard enforced=no tasks=client-runtime --> native accessibility may virtualize this mark sequence instead of allocating
  one native accessibility object per admitted source row. A virtualized
  sequence initially exposes its first eligible mark, resets to that mark only
  when the complete chart snapshot is replaced, and provides localized previous
  and next mark actions in the same canonical traversal order. Each action
  exposes the complete localized semantics of the newly current mark. It may
  not skip a row because the row is offscreen or visually resampled. This is an
  accessibility lowering mechanism, not chart grammar, visible chart chrome,
  selection, or a typed App action;
- <!-- rule: chart.interaction.preview-and-zoom-range class=hard enforced=no tasks=client-runtime --> preview charts do not select marks, invoke requests, pan, zoom, or otherwise
  mutate a viewport. They retain the deterministic initial discrete-position
  window and overflow indicator specified below; Chart Explorer makes every
  authored category reachable. An interactive chart with a continuous `x` scale
  owns a transient output-space viewport over the resolved snapshot domain.
  Its minimum zoom is `1x` (the complete domain) and its maximum zoom is
  `8192x`; zooming and panning clamp to that domain and never rewrite source
  rows, scale bounds, tooltip values, or task-computed interval endpoints;
- <!-- rule: chart.interaction.polar-no-viewport class=hard enforced=no tasks=client-runtime --> version-one polar panes do not pan or zoom. Their complete resolved theta and
  radius domains remain visible in preview and fullscreen. A Cartesian polygon
  continues to clip and hit-test through the ordinary continuous-X viewport;
- <!-- rule: chart.interaction.initial-continuous-viewport class=hard enforced=no tasks=client-runtime --> for a continuous-only X dimension, fullscreen entry, Reset, and a replacement
  snapshot use the complete `1x` semantic X domain. The discrete-position rule
  below is the only initial-viewport exception: it starts at its category-density
  minimum rather than compressing authored category lanes;
- <!-- rule: chart.interaction.gestures class=hard enforced=no tasks=client-runtime --> pinch zooms around the gesture focus where the platform exposes it. A
  horizontal drag pans once zoomed, while vertical drag remains owned by the
  surrounding fullscreen scroller. Tap remains selection. Visible localized
  zoom-in, zoom-out, and reset controls provide pointer and accessibility
  equivalents; Reset and full zoom-out restore the continuous-only `1x` view or
  the discrete-position category-density minimum defined below. Directional
  ownership is decided before a child chart pan recognizes:
  ignoring a vertical drag only after recognition is not sufficient because it
  can still prevent the ancestor scroller from moving. High-frequency pan and
  pinch updates may be coalesced to the display cadence, but gesture completion
  commits the exact final viewport rather than dropping the last delta. To
  avoid invalidating every linked native chart on every pointer sample, the
  actively manipulated pane may use a gesture-local viewport; panes sharing the
  same scale ID converge atomically when the gesture commits and must not retain
  mismatched viewports afterward;
- <!-- rule: chart.interaction.normalized-viewport class=hard enforced=no tasks=client-runtime --> viewport coordinates are normalized scale-output fractions, so linear,
  `log`, `symlog`, `sqrt`, temporal, and `reverse=true` scales all use the same
  already-resolved forward/inverse transform. Automatic guide ticks and labels
  use the stable cadence plan and bounded visible guide run in §6.4.5; viewport
  movement never reruns cadence selection. A replacement snapshot creates a
  new initial viewport;
- <!-- rule: chart.interaction.categories-preserved class=hard enforced=no tasks=charts --> a Cartesian physical plot preserves every authored category on positional
  `band` and `point` scales. It never samples, aggregates, drops, or replaces a
  category. Only the App task may intentionally return already-aggregated
  category rows, which are then ordinary authored chart data;
- <!-- rule: chart.render.category-pitch class=hard enforced=no tasks=client-runtime --> the minimum leaf-category pitch is exactly `20` logical points on both
  platforms. For one unit and one dimension, the leaf-lane count is the
  primary category count multiplied by the matching `xOffset` or `yOffset`
  count when that band has an offset, otherwise by one. A point scale has no
  child band. The physical plot's leaf-lane count for that dimension is the
  maximum required by any contributing unit. This `20`-point rule is derived
  from the established `14`-point interactive lane and the 72-percent band cap
  (`14 / 0.72`, rounded upward); it is not a platform theme default;
- <!-- rule: chart.render.category-extent class=hard enforced=no tasks=client-runtime --> let `P` be the post-guide physical plot extent and `L` the leaf-lane count in
  one dimension. Its virtual category-content extent is `E=max(P,20*L)`.
  Every Cartesian scale in that physical plot maps its complete normalized
  output through the same `E`-point content extent, so layered independent
  scales remain spatially coherent. The visible window is exactly `P` points
  and pans continuously within `0...(E-P)` without snapping. When `E=P`, that
  dimension has no category viewport. Concat children resolve independently;
  facets and flattened layers that share one physical scale share its viewport;
- <!-- rule: chart.render.category-initial-window class=hard enforced=no tasks=client-runtime --> a new snapshot initially exposes the visual top-left of category content:
  the leftmost X output and topmost Y output after `reverse` has been applied.
  Thus `reverse=true` reverses the visible category order rather than being
  silently cancelled by initialization. A later size, font, overlay-guide, or
  rotation change recomputes `E` and `P`, preserves the normalized leading
  content position, and clamps it; it never resets a user's category position;
- <!-- rule: chart.render.category-preview-and-fullscreen class=hard enforced=no tasks=client-runtime --> preview retains the same virtual category extent and deterministic visual
  top-left window but accepts no pan, zoom, selection-scrub, or accessibility
  viewport-adjustment action. Its noninteractive position indicator truthfully
  discloses clipped category content, and the containing endpoint workspace
  exposes the localized action that opens Chart Explorer. In fullscreen, an
  overflowing X dimension accepts horizontal movement and an overflowing Y
  dimension accepts vertical movement. When both overflow, including a
  two-dimensional heatmap, one drag applies both pointer components
  simultaneously. When only one overflows, only that component is consumed. At
  a Y boundary, the exact unconsumed vertical delta chains to the one outer
  Chart Explorer scroller; horizontal movement never blocks that chaining;
- <!-- rule: chart.render.category-density-zoom class=hard enforced=no tasks=client-runtime --> one physical dimension owns one normalized viewport even when independent
  categorical and continuous scales are layered on it. Its category-density
  minimum zoom is `Zc=max(1,E/P)`. A continuous X interaction may zoom further
  but never below `Zc`; its maximum is `max(8192,Zc)`. Reset and a replacement
  snapshot restore the visual top-left category window at `Zc`, not a
  category-compressing full-domain view. A category-only plot adds scrolling
  and indicators but no zoom buttons. A mixed continuous/category X plot keeps
  the ordinary zoom controls and uses this same viewport for every X scale. Y
  category overflow likewise applies one viewport to every Y scale because V1
  has no independent continuous-Y zoom;
- <!-- rule: chart.render.scroll-indicator class=hard enforced=no tasks=client-runtime --> each overflowing category dimension paints a noninteractive scroll-position
  indicator inside the plot: a `2`-point track inset `3` points from the bottom
  for X or right edge for Y, and a proportional thumb with a minimum length of
  `16` points. The thumb position is `(offset/(E-P))` over the remaining track.
  The indicator uses the theme's secondary foreground at 40-percent opacity,
  paints above marks, is excluded from mark hit testing, and is not the
  resampling indicator because no data was discarded. The plot accessibility
  node always exposes the localized visible category range. In fullscreen it
  additionally exposes forward/back scroll actions that move by one visible
  window minus one leaf lane, with a minimum movement of one leaf lane. Preview
  exposes no viewport-adjustment action; its containing endpoint workspace
  exposes the localized action that opens Chart Explorer;
- <!-- rule: chart.render.culling class=hard enforced=no tasks=client-runtime --> clients cull offscreen discrete marks and guide entries before allocating
  native scene objects. Path lowering must retain any boundary-crossing segment
  needed for the exact clipped result, and culling never changes domains,
  canonical order, grouping, tooltip/source-row identity, accessibility, or
  resource validation. Category labels may use the existing deterministic
  guide thinning and responsive overlay policies, but neither policy removes
  its category geometry;
- <!-- rule: chart.render.resampling-scope class=hard enforced=no tasks=client-runtime --> automatic visual resampling applies only to Cartesian `line` and vertical
  `area` runs whose X coordinates are monotone after facet/detail partitioning,
  canonical ordering, invalid-row breaks, and visible-viewport culling. Equal X
  positions are monotone. A non-monotone line, a horizontal `area(x,x2)`, every
  polar path, and every other mark remain exact; category scrolling and
  offscreen culling are not resampling;
- <!-- rule: chart.render.resampling-algorithm class=hard enforced=no tasks=client-runtime --> let `W=max(1,ceil(P))`, where `P` is the final plot width in logical points.
  Visible culling first retains every segment needed to cross the viewport
  boundary. If one resulting eligible run contains at most `4*W` vertices, the
  client paints it unchanged. Otherwise map each vertex X through the resolved
  scale and viewport, clamp its logical-point bucket to `0...(W-1)`, and retain
  from every nonempty bucket the canonical first row, lower-envelope row,
  upper-envelope row, and canonical last row, deduplicated and returned in
  canonical source order. For `line`, both envelopes are Y. For a vertical
  area, the lower envelope is `min(y,y2)` and the upper envelope is
  `max(y,y2)`; an omitted `y2` uses the resolved zero baseline. Extrema ties
  choose the earlier canonical row. This yields no more than four painted
  vertices per logical plot point while preserving endpoints and visible
  extrema. Resize, zoom, pan, guide-overlay, and facet-layout changes recompute
  the result from the immutable full run; a client never resamples an already
  resampled path;
- <!-- rule: chart.render.resampling-not-transform class=hard enforced=no tasks=client-runtime --> resampling is a renderer-owned level of detail, not a data transform. Full
  source rows still determine domains, guides, grouping, structural/guide
  resource accounting,
  tooltip values, requests, and the ordinary data view. Hit testing may search
  the full source path so a retained visual segment resolves to an original
  endpoint row; it never synthesizes a bucket row. Accessibility announces
  that automatic resampling is active and retains original typed values;
- <!-- rule: chart.render.resampling-indicator class=hard enforced=no tasks=client-runtime --> whenever at least one painted run actually loses vertices, the plot paints
  one noninteractive resampling indicator above marks at its top-left: a
  `16`-point square inset `4` points, using the chart surface at 85-percent
  opacity, a `3`-point corner radius, and a centered `≈` glyph in the secondary
  foreground. The glyph is not localized; its accessibility label is the
  localized equivalent of “Automatically resampled.” It is absent when the
  current viewport paints every eligible vertex and is separate from category
  scroll indicators;
- <!-- rule: chart.interaction.date-only-viewport class=hard enforced=no tasks=client-runtime --> a continuous DateOnly X viewport is the typed exception to arbitrary
  fractional edges. After each requested zoom or pan is converted to Gregorian
  day ordinals, both ordered edges round to the nearest integral ordinal with
  binary64 ties-to-even and clamp to the resolved domain. Equal snapped edges
  are a valid one-date degenerate viewport; further zoom in is a no-op. That
  date uses the degenerate-domain rule and maps to the plot midpoint, every
  other X value clips outside the viewport, and a zero-width clipped span
  intersection remains empty. The
  client derives the stored normalized fractions back from those snapped
  ordinals. Consequently every visible DateOnly endpoint, regenerated priority
  tick, and inverse-mapped clipped span bound remains an exact typed
  `yyyyMMdd` date rather than a platform-specific partial-day value;
- <!-- rule: chart.interaction.selected-row-source class=hard enforced=no tasks=charts --> a selected mark retains its source `ScriptValue`, allowing existing typed
  `NextRequest` and `CopyFrom` handling to be reused where applicable;
- <!-- rule: chart.interaction.carrier-context-retained class=hard enforced=no tasks=charts --> chart projection also retains the complete response or selected Union
  variant. Hidden fields in a unit carrier, composition, or Union wrapper are
  therefore available to the same typed transaction without becoming visible
  chart data. A renderer must not clone only the projected List and discard its
  carrier ancestors;
- <!-- rule: chart.interaction.data-change-new-snapshot class=hard enforced=no tasks=charts --> a data-changing action sends a protocol request and replaces the chart with
  the returned snapshot.

Linked brushing, arbitrary cross-filter expressions, and persistent client-side
selections are not in the minimum design. If a future local linked-selection use
case cannot be represented as a typed request, it should be introduced as a
separate, narrowly defined `ChartSelection` proposal rather than folded into
`ChartScale`, `ChartChannel`, or `ChartGuide`.

## 10. Validation and fallback

Validation occurs at three boundaries:

1. TypeScript and Rust parsers reject invalid native annotation syntax and
   source resolution/traversal failures while preserving machine-decodable
   source locations.
2. Studio's shared validator rejects invalid chart placement, tokens,
   arguments, exported shapes, and every other semantic rule decidable from the
   extracted schema and materialized dependencies.
3. <!-- rule: chart.fallback.clients-revalidate class=hard enforced=no tasks=client-runtime --> Android and iOS validate the effective schema and runtime values because a
   remote or newer schema is still untrusted input.

At minimum, clients validate:

- <!-- rule: chart.fallback.validate-placement class=hard enforced=no tasks=client-runtime --> chart placement and complete `List<Table>` unit shape;
- <!-- rule: chart.fallback.validate-composition-children class=hard enforced=no tasks=client-runtime --> composition child types;
- <!-- rule: chart.fallback.validate-nesting class=hard enforced=no tasks=client-runtime --> layer/concat nesting and nested-layer flattening rules;
- <!-- rule: chart.fallback.validate-tokens class=hard enforced=no tasks=client-runtime --> known mark, composition, channel, scale, guide, and property tokens;
- <!-- rule: chart.fallback.validate-multiplicity class=hard enforced=no tasks=client-runtime --> channel multiplicity and mark-required channel combinations;
- <!-- rule: chart.fallback.validate-coordinates class=hard enforced=no tasks=client-runtime --> exclusive Cartesian/polar coordinate forms and polar-layer compatibility;
- <!-- rule: chart.fallback.validate-scalar-types class=hard enforced=no tasks=client-runtime --> scalar type/channel compatibility;
- <!-- rule: chart.fallback.validate-endpoints class=hard enforced=no tasks=client-runtime --> paired endpoint and scale compatibility;
- <!-- rule: chart.fallback.validate-finite-values class=hard enforced=no tasks=client-runtime --> finite numeric values and valid literal styles;
- <!-- rule: chart.fallback.validate-polygons class=hard enforced=no tasks=client-runtime --> polygon subpath structure and canonical raw group-style constancy;
- <!-- rule: chart.fallback.validate-shared-scales class=hard enforced=no tasks=client-runtime --> shared-scale compatibility;
- <!-- rule: chart.fallback.validate-guides class=hard enforced=no tasks=client-runtime --> guide placement, source/target matrices, shared ownership, localization, and
  the single-visible-guide rule for each polar position family;
- <!-- rule: chart.fallback.validate-resource-limits class=hard enforced=no tasks=client-runtime --> resource limits.

<!-- rule: chart.text.review-literal-provenance class=hard enforced=no tasks=charts -->
Package conformance review and response-fixture tests additionally validate the
provenance assertion that cannot be derived from schema JSON: every visible
plain String is genuine literal domain/user text. Fixed program vocabularies
use Enumeration or FormattedText, and Libertas object labels use their typed
system-object reference. This audit covers every role that can emit text and
explicitly excludes only key-only and guide-less identity appearance Strings.

<!-- rule: chart.fallback.policy class=hard enforced=no tasks=charts title="Chart failure policy: ordinary-data fallback or a bounded localized error" -->
**Chart failure policy:** when a chart cannot be rendered, the implementation
may provide a complete ordinary-data fallback or display a bounded localized
error message. This choice applies to 2D and 3D charts, including default or
automatic previews and Chart Explorer. It covers invalid chart presentation,
unsupported features or future tokens, layout failure, and recoverable resource
failure.

<!-- rule: chart.fallback.atomic-replacement class=hard enforced=no tasks=client-runtime -->
Replace the failed chart atomically; never silently omit an unsupported layer
or display altered data meaning. A presentation failure must not reject an
otherwise accepted protocol response, alter its data, crash the screen, or
change pending-operation ownership. Diagnostics remain bounded. The
[endpoint presentation contract](../endpoint-presentation.md#endpoint-workspace)
owns workspace and navigation continuity. Platforms may choose different
failure presentations while preserving these rules.

## 11. Detailed examples

<!-- rule: chart.example.document-chart-fields class=judgment enforced=no tasks=charts -->
The examples use the implemented TypeScript or Rust source syntax. The first
cross-language example shows ordinary short and long documentation. Later
examples omit most comments for geometric clarity, but their native field names
never stand in for user-visible labels. Production packages supply localized
names and descriptions through the existing document pipeline; optional labels
are omitted and essential category failures follow §8.1 and the chart failure
policy in §10.

Plain String values in examples are limited to genuine literal runtime/domain
data, key-only identity, or guide-less identity appearance values. Neither
package `StringResources` nor custom document properties implicitly map them. A closed
localizable category such as `rising` or `heating` uses an Enumeration;
App-authored dynamic wording uses formatted Bytes output; and a device,
user, endpoint, or other supported Libertas object remains its typed reference.
Only actual user/domain content such as a proper name or free-form annotation
may remain a visible plain String.

### 11.1 Multi-series line chart

The server returns long-form rows. `series` is both the color and path grouping
key. Each measurement is also available in the tooltip.

```typescript
/** @LibertasChart(line) */
export type SensorHistory = SensorPoint[];

export enum SensorSeries {
  Indoor,
  Outdoor,
}

export interface SensorPoint {
  /**
   * @LibertasChartChannel(x, tooltip)
   * @LibertasChartScale(id=time, kind=utc)
   */
  time: LibertasDateTime;

  /**
   * @LibertasChartChannel(y, tooltip)
   * @LibertasChartScale(id=value, kind=linear, zero=false)
   */
  value: number;

  /** @LibertasChartChannel(color, detail, tooltip) */
  series: SensorSeries;

  /** @LibertasChartChannel(key) */
  sampleId: string;
}
```

Example payload:

```json
[
  { "time": 1786536000, "value": 22.4,
    "series": 0, "sampleId": "indoor-1200" },
  { "time": 1786536000, "value": 29.1,
    "series": 1, "sampleId": "outdoor-1200" },
  { "time": 1786536300, "value": 22.6,
    "series": 0, "sampleId": "indoor-1205" },
  { "time": 1786536300, "value": 28.8,
    "series": 1, "sampleId": "outdoor-1205" }
]
```

`SensorSeries` is a closed program vocabulary, so its encoded Enumeration
member—not the English token—is the category identity. `sampleId` remains a
plain String because it is key-only and never rendered.

### 11.2 Histogram

The endpoint computes bins and counts. The client draws explicit bin bounds and
does no statistical transformation.

```rust
#[libertas_chart(bar)]
pub type LatencyHistogram = Vec<LatencyBin>;

#[derive(LibertasExport, LibertasAvroDecode)]
pub struct LatencyBin {
    #[libertas_chart_channel(x, tooltip)]
    pub start_ms: f64,

    #[libertas_chart_channel(x2, tooltip)]
    pub end_ms: f64,

    #[libertas_chart_channel(y, tooltip)]
    #[libertas_chart_scale(kind = linear, min = 0, zero = true)]
    pub count: u64,
}
```

```json
[
  { "start_ms": 0, "end_ms": 10, "count": 41 },
  { "start_ms": 10, "end_ms": 20, "count": 96 },
  { "start_ms": 20, "end_ms": 50, "count": 33 }
]
```

### 11.3 Explicit stacked bar

The endpoint computes cumulative lower and upper bounds. No stack mode or
implicit category ordering exists on the client.

```typescript
/** @LibertasChart(bar) */
export type EnergyBreakdown = EnergySegment[];

export enum EnergySource {
  Solar,
  Battery,
  Grid,
}

export interface EnergySegment {
  /**
   * @LibertasChartChannel(x, tooltip)
   * @LibertasChartScale(kind=band)
   * @LibertasDateOnly
   * @LibertasNumber(type=u32)
   */
  day: number;

  /** @LibertasChartChannel(y) */
  startKwh: number;

  /** @LibertasChartChannel(y2, tooltip) */
  endKwh: number;

  /** @LibertasChartChannel(color, detail, tooltip) */
  source: EnergySource;
}
```

For one day, the server might emit bounds `0..4.2` for solar, `4.2..6.8` for
battery, and `6.8..9.1` for grid. Reordering or recomputing the stack is an App
server decision.

### 11.4 Pie, annulus, and categorical ARC partitions

The endpoint converts values to cumulative turn fractions. `radius2` supplies
the outer radius and `radius` supplies the hole.

```rust
#[libertas_chart(arc)]
pub type UsageDonut = Vec<UsageSlice>;

#[derive(LibertasExport, LibertasAvroDecode)]
pub enum UsageCategory {
    Heating,
    Lighting,
    Other,
}

#[derive(LibertasExport, LibertasAvroDecode)]
pub struct UsageSlice {
    #[libertas_chart_channel(theta)]
    #[libertas_chart_scale(kind = linear, min = 0, max = 1)]
    #[libertas_chart_guide(target = theta, source = none)]
    pub start_turn: f64,

    #[libertas_chart_channel(theta2)]
    pub end_turn: f64,

    #[libertas_chart_channel(radius)]
    #[libertas_chart_scale(kind = identity)]
    pub inner_radius: f64,

    #[libertas_chart_channel(radius2)]
    pub outer_radius: f64,

    #[libertas_chart_channel(color, detail, tooltip)]
    pub category: UsageCategory,

    #[libertas_chart_channel(tooltip)]
    pub value: f64,
}
```

```json
[
  { "start_turn": 0.00, "end_turn": 0.55,
    "inner_radius": 0.55, "outer_radius": 1.0,
    "category": 0, "value": 55 },
  { "start_turn": 0.55, "end_turn": 0.80,
    "inner_radius": 0.55, "outer_radius": 1.0,
    "category": 1, "value": 25 },
  { "start_turn": 0.80, "end_turn": 1.00,
    "inner_radius": 0.55, "outer_radius": 1.0,
    "category": 2, "value": 20 }
]
```

A pie uses `inner_radius=0`. A radial bar uses server-computed numeric
`theta,theta2` turn bounds plus `radius=0` and a quantitative `radius2`; it does
not ask the client to derive an angular band or cumulative boundary.

#### 11.4.1 Automatic in-shape and linked ARC labels

<!-- rule: chart.render.arc-labels.eligibility class=hard enforced=no tasks=client-runtime -->
A client may lower one automatic categorical appearance guide owned by an
eligible `arc` unit into automatic shape labels. This is an ARC-family rule,
not a donut-only rule: a pie with `inner_radius=0`, an annulus with a positive
inner radius, and a rose/radial categorical partition with row-varying radii
use the same planner. The first qualifying guide in resolved guide order must
meet every condition below:

1. <!-- rule: chart.render.arc-labels.condition-theta-radius class=hard enforced=no tasks=client-runtime --> its owner has explicit `theta` and `theta2`; radial bindings are either both
   absent, resolving every row to pie radii `0...1`, or both `radius` and
   `radius2` are present;
2. <!-- rule: chart.render.arc-labels.condition-position class=hard enforced=no tasks=client-runtime --> its position is absent or `auto` and its labels component is enabled;
3. <!-- rule: chart.render.arc-labels.condition-target class=hard enforced=no tasks=client-runtime --> its target is categorical `fill`, or categorical `color` when the owner has
   no explicit `fill` binding; other targets and a color shadowed by explicit
   fill retain their ordinary guide;
4. <!-- rule: chart.render.arc-labels.condition-sole-unit class=hard enforced=no tasks=client-runtime --> the owner is the pane's only unit with painted rows, and no other resolved
   guide occupies physical left or right;
5. <!-- rule: chart.render.arc-labels.condition-row-geometry class=hard enforced=no tasks=client-runtime --> every painted row has finite `0<=inner_radius<outer_radius<=1`, positive
   angular span, fully opaque fill, and no visible nonzero-width arc stroke;
6. <!-- rule: chart.render.arc-labels.condition-complete-turn class=hard enforced=no tasks=client-runtime --> the rows' angular open interiors do not materially overlap and their positive
   authored spans form one complete turn under the portable arrangement tolerance below,
   so they are a categorical pie/annulus/rose partition rather than occluding
   radial layers; shared boundaries are allowed, inner and outer radii may vary
   by row, but a material gap is not eligible for automatic rearrangement;
7. <!-- rule: chart.render.arc-labels.condition-one-row-per-category class=hard enforced=no tasks=client-runtime --> every painted row has exactly one target-category identity, every resolved
   target category owns exactly one painted row, and no two rows own the same
   identity; and
8. <!-- rule: chart.render.arc-labels.condition-no-polar-guide class=hard enforced=no tasks=client-runtime --> the plot has no visible `theta` or `radius` guide.

<!-- rule: chart.render.arc-labels.ineligible-keeps-guide class=hard enforced=no tasks=client-runtime -->
Failure of any condition keeps the complete ordinary appearance guide and is
not a render rejection. In particular, a varying-radius rose is not rejected
or forced into common-radius geometry: its row-specific painted outer boundary
is the leader anchor and Apache ECharts' common maximum outer radius owns the
elbow envelope. A future adapter is required for overlapping ARC layers,
translucent compositing, or visible-stroke paint bounds.

<!-- rule: chart.render.arc-labels.exactly-once class=hard enforced=no tasks=client-runtime -->
For a successful plan, every category appears exactly once as either an
in-shape label or a linked outside label. Text is the complete localized
category label; clients do not synthesize a value or percentage from tooltip
fields. Localization, the intrinsic two-line categorical wrapping rule of
§6.4.5, native shaping, and native measurement happen once before geometric
search. Automatic labels are never rewrapped, truncated, ellipsized, scaled,
rotated, hidden, or semantically thinned.

<!-- rule: chart.render.arc-labels.leaders class=hard enforced=no tasks=client-runtime -->
In-shape labels are centered. Outside labels align toward their leader; labels
above or below the plot remain upright and centered. Leaders attach to the
owning row's painted boundary, including row-varying radii. They must not cross
one another, another label's ink, or a filled sector. Complete labels, leaders,
and the guide title fit the cutout-safe composition with the contract's
half-physical-pixel raster allowance.

<!-- rule: chart.render.arc-labels.title class=hard enforced=no tasks=client-runtime -->
The guide title appears once, inside the common unpainted hole when it fits at
the minimum radius, otherwise in the ordinary guide's title-only lane. A pie
and annulus with the same outer geometry retain the same natural mark scale;
a hole changes title placement, not scale. The minimum painted radius is 100
logical points. The title uses the 11-point guide-title style and four-point
clearance. A bottom title lane has an eight-point gap; a physical-end lane has
a sixteen-point gap. The title is never omitted to make labels fit.

<!-- rule: chart.render.arc-labels.turn-tolerance class=hard enforced=no tasks=client-runtime -->
A complete categorical partition is eligible only when its total span and
adjacent seams differ from one complete turn by no more than
`nextUp(1e-9)` binary64 turns. The accepted arrangement preserves category order,
identity, and proportions. A material gap or overlap retains authored geometry
and the ordinary guide.

<!-- rule: chart.render.arc-labels.fullscreen-layout class=hard enforced=no tasks=client-runtime -->
Full-screen portrait uses the complete safe width `W` and natural radius
`(W-16)/2`; the composition height includes the full label/title footprint and
is at least `W` and the polar minimum. Full-screen landscape uses the complete
height `H` below the title bar and above the stable bottom inset, with an `H x H`
plot centered in safe width `W`. Insets are applied once. If `W < H`, automatic
labels fall back to the ordinary guide. Additional safe width does not lengthen
leaders. The combined plot/title/label footprint is centered without changing
its accepted internal arrangement. An auxiliary guide that does not fit is
available through **Show Guides**.

<!-- rule: chart.render.arc-labels.preview-and-fallback class=hard enforced=no tasks=client-runtime -->
Preview retains its intrinsic polar host, sized from the preview's square `R`.
It applies the `4R / 3` preview viewport clip only after the complete logical
layout succeeds; it has no label scroller.
Chart Explorer reveals the complete footprint. If complete automatic placement
fails, restore the complete ordinary guide atomically. Do not reject an
otherwise valid chart, clip label text, omit categories, or weaken collision
requirements to retain automatic labels.

### 11.5 Heatmap and facets

Each row is a server-computed cell. Explicit ranges support irregular buckets;
band scales support regular categorical cells.

```typescript
/** @LibertasChart(rect) */
export type OccupancyHeatmap = OccupancyCell[];

export interface OccupancyCell {
  /** @LibertasChartChannel(x, tooltip) */
  hourStart: LibertasDateTime;

  /** @LibertasChartChannel(x2, tooltip) */
  hourEnd: LibertasDateTime;

  /** @LibertasChartChannel(y, tooltip) */
  zone: LibertasDevice;

  /** @LibertasChartChannel(color, tooltip) */
  occupancy: number;

  /** @LibertasChartChannel(row) */
  building: LibertasDevice;
}
```

The client creates one row facet per building. The response remains one flat
list; no client lookup or pivot is involved. Both categorical identities remain
typed Libertas system-object references, and the client resolves their current,
permission-aware display names without the App copying names into Strings.

### 11.6 Confidence band with a line

An area layer carries lower and upper bounds. A line layer carries the estimate.
Both use the same scale IDs.

```typescript
/** @LibertasChart(area) */
export type ConfidenceBand = ConfidencePoint[];

export interface ConfidencePoint {
  /**
   * @LibertasChartChannel(x)
   * @LibertasChartScale(id=time, kind=utc)
   */
  time: LibertasDateTime;

  /**
   * @LibertasChartChannel(y)
   * @LibertasChartScale(id=value, kind=linear, zero=false)
   */
  low: number;

  /** @LibertasChartChannel(y2) */
  high: number;
}

/** @LibertasChart(line) */
export type EstimateLine = EstimatePoint[];

export interface EstimatePoint {
  /**
   * @LibertasChartChannel(x, tooltip)
   * @LibertasChartScale(id=time, kind=utc)
   * @LibertasChartGuide(target=x, source=scale)
   */
  time: LibertasDateTime;

  /**
   * @LibertasChartChannel(y, tooltip)
   * @LibertasChartScale(id=value, kind=linear, zero=false)
   * @LibertasChartGuide(target=y, source=scale)
   */
  estimate: number;
}

/** @LibertasChart(layer) */
export interface ForecastChart {
  band: ConfidenceBand;
  estimate: EstimateLine;
}
```

### 11.7 Candlestick chart

Candlestick is a layer of a high-low rule and an open-close rectangle. The App
server computes the candle's horizontal time bounds and its rising/falling
color. There is no special `candlestick` mark and no special open/high/low/close
channel vocabulary.

```typescript
export enum MarketDirection {
  Rising,
  Falling,
}

/** @LibertasChart(rule) */
export type CandleWicks = CandleWick[];

export interface CandleWick {
  /**
   * @LibertasChartChannel(x, tooltip)
   * @LibertasChartScale(id=time, kind=utc)
   * @LibertasChartGuide(target=x, source=scale)
   */
  time: LibertasDateTime;

  /**
   * @LibertasChartChannel(y, tooltip)
   * @LibertasChartScale(id=price, kind=linear, zero=false)
   * @LibertasChartGuide(target=y, source=scale)
   */
  low: number;

  /** @LibertasChartChannel(y2, tooltip) */
  high: number;

  /** @LibertasChartChannel(tooltip) */
  open: number;

  /** @LibertasChartChannel(tooltip) */
  close: number;

  /** @LibertasChartChannel(tooltip) */
  volume: number;

  /** @LibertasChartChannel(tooltip) */
  direction: MarketDirection;

  /** @LibertasChartChannel(key) */
  candleId: string;

  /**
   * @LibertasChartChannel(stroke)
   * @LibertasChartScale(kind=identity)
   */
  wickColor: string;
}

/** @LibertasChart(rect) */
export type CandleBodies = CandleBody[];

export interface CandleBody {
  /**
   * @LibertasChartChannel(x)
   * @LibertasChartScale(id=time, kind=utc)
   */
  start: LibertasDateTime;

  /** @LibertasChartChannel(x2) */
  end: LibertasDateTime;

  /**
   * @LibertasChartChannel(y, tooltip)
   * @LibertasChartScale(id=price, kind=linear, zero=false)
   */
  open: number;

  /** @LibertasChartChannel(y2, tooltip) */
  close: number;

  /** @LibertasChartChannel(tooltip) */
  time: LibertasDateTime;

  /** @LibertasChartChannel(tooltip) */
  low: number;

  /** @LibertasChartChannel(tooltip) */
  high: number;

  /**
   * @LibertasChartChannel(fill)
   * @LibertasChartScale(kind=identity)
   */
  directionColor: string;

  /** @LibertasChartChannel(tooltip) */
  volume: number;

  /** @LibertasChartChannel(tooltip) */
  direction: MarketDirection;

  /** @LibertasChartChannel(key) */
  candleId: string;
}

/** @LibertasChart(layer) */
export interface CandlestickChart {
  wicks: CandleWicks;
  bodies: CandleBodies;
}
```

Example rows for one candle:

```json
{
  "wicks": [
    {
      "time": 1786545000,
      "low": 101.20,
      "high": 108.40,
      "open": 102.10,
      "close": 106.80,
      "volume": 18342,
      "direction": 0,
      "candleId": "2026-08-12T14:30Z",
      "wickColor": "#657184"
    }
  ],
  "bodies": [
    {
      "start": 1786544975,
      "end": 1786545025,
      "time": 1786545000,
      "open": 102.10,
      "close": 106.80,
      "low": 101.20,
      "high": 108.40,
      "directionColor": "#178A55",
      "volume": 18342,
      "direction": 0,
      "candleId": "2026-08-12T14:30Z"
    }
  ]
}
```

<!-- rule: chart.example.layers-not-joined class=hard enforced=no tasks=charts -->
Both layers repeat all desired OHLCV tooltip fields because the client does not
join rows across layers, even when their `key` values match. The key preserves
conceptual identity and focus stability only. Both literal paint fields declare
an identity scale and are not rendered as text. The fixed `direction` wording is
an Enumeration whose localized member label is used by the tooltip. The
single-pixel zero-height rule from the geometry contract keeps an
`open == close` doji visible.

An optional volume subplot is a `bar` chart placed below the candlestick with
`Chart=vconcat`; both children use `id=time` so panning and the time guide remain
aligned.

### 11.8 OHLC chart

An OHLC glyph is three rules. The endpoint computes `left`, `middle`, and
`right` time positions from the sampling interval:

1. a vertical low-high rule at `middle`;
2. a horizontal open rule from `left` to `middle`;
3. a horizontal close rule from `middle` to `right`.

```rust
#[derive(LibertasExport, LibertasAvroDecode)]
pub enum MarketDirection {
    Rising,
    Falling,
}

#[libertas_chart(rule)]
pub type OhlcRanges = Vec<OhlcRange>;

#[derive(LibertasExport, LibertasAvroDecode)]
pub struct OhlcRange {
    #[libertas_chart_channel(x, tooltip)]
    #[libertas_chart_scale(id = time, kind = utc)]
    #[libertas_chart_guide(target = x, source = scale)]
    pub middle: LibertasDateTime,

    #[libertas_chart_channel(y, tooltip)]
    #[libertas_chart_scale(
        id = price,
        kind = linear,
        zero = false
    )]
    #[libertas_chart_guide(target = y, source = scale)]
    pub low: f64,

    #[libertas_chart_channel(y2, tooltip)]
    pub high: f64,

    #[libertas_chart_channel(tooltip)]
    pub open: f64,

    #[libertas_chart_channel(tooltip)]
    pub close: f64,

    #[libertas_chart_channel(tooltip)]
    pub volume: u64,

    #[libertas_chart_channel(tooltip)]
    pub direction: MarketDirection,

    #[libertas_chart_channel(key)]
    pub candle_id: String,

    #[libertas_chart_channel(stroke)]
    #[libertas_chart_scale(kind = identity)]
    pub direction_color: String,
}

#[libertas_chart(rule)]
pub type OhlcOpenTicks = Vec<OhlcOpenTick>;

#[derive(LibertasExport, LibertasAvroDecode)]
pub struct OhlcOpenTick {
    #[libertas_chart_channel(x)]
    #[libertas_chart_scale(id = time, kind = utc)]
    pub left: LibertasDateTime,

    #[libertas_chart_channel(x2, tooltip)]
    pub middle: LibertasDateTime,

    #[libertas_chart_channel(y, tooltip)]
    #[libertas_chart_scale(
        id = price,
        kind = linear,
        zero = false
    )]
    pub open: f64,

    #[libertas_chart_channel(tooltip)]
    pub low: f64,

    #[libertas_chart_channel(tooltip)]
    pub high: f64,

    #[libertas_chart_channel(tooltip)]
    pub close: f64,

    #[libertas_chart_channel(tooltip)]
    pub volume: u64,

    #[libertas_chart_channel(tooltip)]
    pub direction: MarketDirection,

    #[libertas_chart_channel(key)]
    pub candle_id: String,

    #[libertas_chart_channel(stroke)]
    #[libertas_chart_scale(kind = identity)]
    pub direction_color: String,
}

#[libertas_chart(rule)]
pub type OhlcCloseTicks = Vec<OhlcCloseTick>;

#[derive(LibertasExport, LibertasAvroDecode)]
pub struct OhlcCloseTick {
    #[libertas_chart_channel(x, tooltip)]
    #[libertas_chart_scale(id = time, kind = utc)]
    pub middle: LibertasDateTime,

    #[libertas_chart_channel(x2)]
    pub right: LibertasDateTime,

    #[libertas_chart_channel(y, tooltip)]
    #[libertas_chart_scale(
        id = price,
        kind = linear,
        zero = false
    )]
    pub close: f64,

    #[libertas_chart_channel(tooltip)]
    pub open: f64,

    #[libertas_chart_channel(tooltip)]
    pub low: f64,

    #[libertas_chart_channel(tooltip)]
    pub high: f64,

    #[libertas_chart_channel(tooltip)]
    pub volume: u64,

    #[libertas_chart_channel(tooltip)]
    pub direction: MarketDirection,

    #[libertas_chart_channel(key)]
    pub candle_id: String,

    #[libertas_chart_channel(stroke)]
    #[libertas_chart_scale(kind = identity)]
    pub direction_color: String,
}

#[derive(LibertasExport, LibertasAvroDecode)]
#[libertas_chart(layer)]
pub struct OhlcChart {
    pub ranges: OhlcRanges,
    pub opens: OhlcOpenTicks,
    pub closes: OhlcCloseTicks,
}
```

This representation also supports irregular trading intervals because the
server supplies the exact horizontal bounds. A convenience source annotation
could expand an OHLC domain type into these primitives in the future, but it
would generate instances of the same four canonical attribute families and
would not add client semantics. Every layer row repeats the complete OHLCV
tooltip set and the same conceptual key because, as in candlestick, the client
does not join layer data.

### 11.9 Box plot and error bars

A box plot uses:

- `rect`: `x` category, `y` first quartile, `y2` third quartile;
- `rule`: category position, `y` low whisker, `y2` high whisker;
- zero-height `rect`: the same x band with `y=y2` at the median;
- optional zero-height `rect` rows at the low/high whiskers for caps;
- optional `point`: outliers.

The server computes quartiles and outliers. An error bar is the same pattern
without the box. An error band uses `area` with `y,y2`, usually layered under a
line. The one-device-pixel zero-interval rule makes medians and caps visible
without asking the server to know responsive band-edge coordinates.

### 11.10 Server-projected maps, networks, and flows

<!-- rule: chart.example.server-projected-geometry class=judgment enforced=no tasks=charts -->
The minimum grammar deliberately has no geographic projection, topology, force
layout, contour generation, or Sankey transform. The endpoint returns projected
and sampled geometry that the primitives can express:

- map points and boundaries as points or ordered line rows grouped by `detail`;
- <!-- rule: chart.example.projected-region-polygon class=hard enforced=no tasks=charts --> a filled projected region or choropleth region as a polygon detail group,
  using null-position separators for islands and holes and repeating one
  canonical raw fill value on all of its vertices;
- a network edge as a sampled line or `rule` and nodes as points;
- an x-monotonic Sankey link as sampled `x,y,y2` area rows and nodes as
  rectangles;
- a sampled ribbon, isoband, or other closed region as a polygon;
- labels as a text layer.

<!-- rule: chart.example.projected-text-source class=hard enforced=no tasks=charts -->
Projected geometry does not relax the presentation-source rule. The
field-by-field audit in §8.1 is normative for bubble `product`, activity
`time_block`, direct `label`, network `node` and `connection`, Sankey `stage`,
and traffic-source values. Apps must not copy an object's current display name
or emit fixed English tokens in a visible plain String. Reusing one of these row
schemas in another response, such as an operations dashboard reusing the
activity heatmap, does not create an exception.

<!-- rule: chart.example.app-owns-projection class=hard enforced=no tasks=charts -->
This provides useful projected-map and flow/network charts without embedding a
geospatial, contour, or graph runtime in every client. The App remains
responsible for projection, antimeridian decisions, topology/ring extraction,
force simulation, contouring, and path sampling. A client does not interpret
geographic coordinates or topology merely because a polygon happens to depict
a map.

<!-- rule: chart.render.map-labels-literal class=hard enforced=no tasks=client-runtime -->
The automatic shape-label policy of §6.4.7 is universal, but an authored map
`text` row is not automatically eligible: its `x,y` remain literal. Polygon and
text units are independent snapshots, and matching `key`, `detail`, localized
text, proximity, or layer order does not establish ownership. A future
projected-region adapter must define an explicit one-label/one-compound-polygon
association, retain the authored text coordinate as its preferred inside
anchor, and specify even-odd containment, hole/island ownership, eligible outer
boundary, outside-side order, segment ties, collision, clipping, and safe-area
fallback before either client may move that label or create a leader. Once
admitted, it must use the same inside-first, half-physical-pixel, exactly-once,
pure-planner, complete-label, and atomic-fallback rules. A shape adapter may
explicitly reuse the §11.4.1 natural-boundary, physical-side partition,
side-local collision, and post-displacement leader-recomputation pipeline when
its ownership and boundary geometry make those stages meaningful; the eligible
ARC-family adapter is the current adapter that does so. Until a projected-region adapter defines
otherwise, failure retains the authored text at its literal `x,y`; there is no
ordinary guide to restore. Clients may not implement a map-only approximation
based on a bounding box or centroid.

### 11.11 Scatter and bubble chart

A point chart becomes a bubble chart when it binds `size`. The endpoint returns
the already calculated x, y, and size measure; the client performs no regression
or domain transform beyond the declared scale. The renderer maps that measure
to its bounded responsive point-radius range.

```rust
#[libertas_chart(point)]
pub type ProductPortfolio = Vec<ProductPoint>;

#[derive(LibertasExport, LibertasAvroDecode)]
pub struct ProductPoint {
    #[libertas_chart_channel(x, tooltip)]
    #[libertas_chart_scale(kind = log)]
    pub market_size: f64,

    #[libertas_chart_channel(y, tooltip)]
    #[libertas_chart_scale(kind = linear, zero = false)]
    pub growth_percent: f64,

    #[libertas_chart_channel(size, tooltip)]
    #[libertas_chart_scale(kind = sqrt, min = 1, max = 100000)]
    pub revenue: f64,

    #[libertas_chart_channel(color, detail, tooltip)]
    pub product: String,

    #[libertas_chart_channel(key)]
    pub product_key: String,
}
```

Here `min` and `max` constrain the revenue domain; the client chooses a bounded
responsive point-radius range. `product` is a genuine proper name supplied by
the business domain and may therefore remain literal text even if one snapshot
happens to contain only a small set of names. A fixed product class would use
an Enumeration instead. <!-- rule: chart.example.literal-point-radius class=hard enforced=no tasks=charts --> If the endpoint supplies a literal point radius in
density-independent points, it uses `type=identity` and must stay within the
client resource limit.

### 11.12 Grouped bar chart

Grouped bars use a nested discrete band. The server duplicates the series value
because one field cannot own both the independently scaled `xOffset` and `color`
roles.

```typescript
/** @LibertasChart(bar) */
export type GroupedUsage = GroupedUsageRow[];

export enum UsageSeries {
  Actual,
  Target,
}

export interface GroupedUsageRow {
  /** @LibertasChartChannel(x, tooltip) */
  room: LibertasDevice;

  /** @LibertasChartChannel(xOffset) */
  seriesOffset: UsageSeries;

  /** @LibertasChartChannel(y, tooltip) */
  usageKwh: number;

  /** @LibertasChartChannel(color, detail, tooltip) */
  series: UsageSeries;
}
```

For each row, `seriesOffset` and `series` contain the same raw category. The
nested-band and color-domain rules give that category stable placement and
paint on both platforms. The room keeps its Libertas device ID as raw identity;
the client resolves the localized, permission-aware display name.

### 11.13 Filled radar chart

<!-- rule: chart.example.radar-app-normalizes class=judgment enforced=no tasks=charts -->
A radar chart is not a special mark. The App assigns every indicator an
explicit turn, normalizes that indicator's value against its own documented
bound, and emits one polar polygon detail group per series. The original value
and indicator remain tooltip data. The client does not infer indicator maxima
or evenly space categories.

```rust
#[libertas_chart(polygon)]
pub type RadarRegions = Vec<RadarVertex>;

#[derive(LibertasExport, LibertasAvroDecode)]
pub enum RadarIndicator {
    SoilMoisture,
    Coverage,
    Pressure,
    Flow,
}

#[derive(LibertasExport, LibertasAvroDecode)]
pub struct RadarVertex {
    #[libertas_chart_channel(theta)]
    #[libertas_chart_scale(id = radar_theta, kind = identity)]
    pub indicator_turn: f64,

    #[libertas_chart_channel(radius)]
    #[libertas_chart_scale(id = radar_radius, kind = identity)]
    pub normalized_value: f64,

    #[libertas_chart_channel(color, detail, tooltip)]
    pub series: LibertasDevice,

    #[libertas_chart_channel(fillOpacity)]
    #[libertas_chart_scale(kind = identity)]
    pub region_opacity: f64,

    #[libertas_chart_channel(order)]
    pub indicator_order: u8,

    #[libertas_chart_channel(tooltip)]
    #[libertas_chart_guide(
        target = theta,
        source = value,
        title = none,
        domain = none,
        ticks = none,
        grid = none
    )]
    pub indicator: RadarIndicator,

    #[libertas_chart_channel(tooltip)]
    pub original_value: f64,

    #[libertas_chart_channel(key)]
    pub vertex_key: String,
}
```

For four indicators, one series might use turns `0,0.25,0.5,0.75`, radii
`0.8,0.6,0.9,0.4`, orders `0,1,2,3`, and the same series and opacity values on
all four rows. It does not repeat the turn-zero row: the polygon closes the
last-to-first edge. A second series is another `detail` group and may overlap
the first with a different stable palette color. Each series is a typed device
reference, so its current resolved name is presentation and its device ID stays
the grouping and interaction identity.

An optional polar `point` layer can repeat the vertices for explicit markers
and vertex-sized hit targets. Here the guide on `indicator` emits one localized
caption from the indicator member's localized document at each numeric theta
position; repeated series rows with the same raw indicator and theta collapse
to one candidate. The server may instead supply
spokes as polar `rule(theta,radius,radius2)` rows and captions as
`text(theta,radius,text)` rows when the labels must be in-plot or interactive.
Polygon rings at chosen radii can provide an exact polygonal grid. Such authored
marks remain data geometry, not guides, and their identity scales have no
implicit guide. Per-indicator normalization, label choice, and custom grid
sampling remain server-owned transformations.

## 12. Security, resource, and accessibility requirements

Chart data and metadata are untrusted even when a parser would normally produce
them. Implementations must:

- <!-- rule: chart.limits.structural class=hard enforced=no tasks=charts --> impose the same version-one structural and metadata limits on both platforms:
  composition depth `8`, unit charts `64`, facets `64`, resolved guides `256`,
  candidates per guide `4096`, total guide entries `16384`, total guide
  primitives `32768`, tooltip fields per row `32`, and UTF-8 bytes in one text,
  guide label, or tooltip value `4096`;
- <!-- rule: chart.limits.bounded-logging class=hard enforced=no tasks=client-runtime --> use bounded diagnostic logging without dumping complete payloads;
- <!-- rule: chart.limits.non-finite-geometry class=hard enforced=no tasks=client-runtime --> prevent non-finite geometry from reaching drawing APIs by applying the
  documented row-suppression or path-separator rule, and reject invalid literal
  style tokens;
- <!-- rule: chart.limits.no-executable-content class=hard enforced=no tasks=client-runtime --> avoid executable expressions, arbitrary field paths, URLs, embedded HTML,
  remote images, and platform-specific style objects;
- <!-- rule: chart.limits.no-row-ceiling class=hard enforced=no tasks=charts --> impose no chart-specific numeric ceiling on source rows, eligible source
  primitives, or vertices in a source path. The enclosing ScriptValue transport
  and ordinary-data model already own payload admission; chart presentation
  must not reject the same admitted snapshot because it crosses an unrelated
  conservative geometry count;
- <!-- rule: chart.limits.checked-scan class=hard enforced=no tasks=client-runtime --> scan source geometry with overflow-checked indices and totals, cancellation
  checks, and bounded diagnostics. Projection must not reserve storage from an
  unchecked row/product count. A superseded scan stops promptly. If a platform
  allocation or scene-construction API reports a recoverable failure, the
  client follows the chart failure policy in §10 rather than predicting it
  with a portable hard-coded row number. Process-level out-of-memory termination
  is not recoverable on every V1 platform and is therefore not a semantic chart
  fallback guarantee;
- <!-- rule: chart.limits.culling-preserves-data class=hard enforced=no tasks=client-runtime --> apply the specified viewport culling and deterministic resampling without
  changing source domains, raw row identity, tooltip values, or accessible data;
- <!-- rule: chart.limits.stable-gestures class=hard enforced=no tasks=client-runtime --> preserve stable layout and exact terminal viewport state during gestures;

- <!-- rule: chart.limits.cancel-superseded class=hard enforced=no tasks=client-runtime --> cancel chart construction when its screen or source snapshot is superseded;
- <!-- rule: chart.limits.authorization class=hard enforced=no tasks=client-runtime --> preserve normal protocol authorization and request ownership;
- <!-- rule: chart.a11y.expose-localized class=hard enforced=no tasks=client-runtime --> expose localized chart, series, category, channel, and tooltip information to
  accessibility services;
- <!-- rule: chart.a11y.color-not-only-carrier class=judgment enforced=no tasks=charts --> require any meaning encoded through paint to also be exposed through a
  localized `tooltip`, `detail`, text, shape, or equivalent accessibility
  value, so color is not the only carrier of state;
- <!-- rule: chart.limits.preserve-response class=hard enforced=no tasks=client-runtime --> preserve the underlying response and follow the chart failure policy in §10
  when its chart cannot be rendered.

The structural limits use semantic, renderer-independent counts:

- <!-- rule: chart.limits.depth-and-unit-counting class=hard enforced=no tasks=charts --> the outer selected chart root has composition depth `0`; each composition
  edge adds one, so a unit reached through at most eight composition edges is
  permitted. Every mark-bearing List leaf is one unit chart; composition
  containers do not consume the `64` unit-chart budget;
- <!-- rule: chart.limits.facet-counting class=hard enforced=no tasks=charts --> a facet grid contains the Cartesian product of its resolved `row` and
  `column` domains, with a missing dimension contributing a factor of one.
  The `64` limit is the sum of panes produced by faceted plot nodes under the
  outer chart root. Sibling units in one `layer` count one grid only when their
  ordered raw row and column domains are identical; otherwise each distinct
  grid contributes its own product.

<!-- rule: chart.limits.reject-before-lowering class=hard enforced=no tasks=client-runtime -->
Projection rejects a structurally over-limit chart before native lowering.
Source geometry size alone is not structural invalidity. Renderers keep the
source snapshot available for exact domains, interaction, and ordinary data,
while allocating only the culled/resampled visible scene described above.

Remote image marks are intentionally excluded from the initial primitive set.
If later required, they need a separate asset identity, size, cache, content
type, authorization, and lifetime design rather than a raw URL channel.

## 13. Cross-platform rendering contract

<!-- rule: chart.runtime.cross-platform-semantics class=hard enforced=no tasks=client-runtime -->
Android and iOS may use different native drawing libraries, but they must share
the semantic behavior defined here:

- <!-- rule: chart.render.shared-validation class=hard enforced=no tasks=client-runtime --> schema parsing and validation;
- <!-- rule: chart.render.shared-mark-lowering class=hard enforced=no tasks=client-runtime --> mark lowering (`bar` to `rect` where desired);
- <!-- rule: chart.render.shared-domains class=hard enforced=no tasks=client-runtime --> domain, scale, grouping, ordering, and missing-value rules;
- <!-- rule: chart.render.shared-polar-mapping class=hard enforced=no tasks=client-runtime --> equal-aspect polar mapping, circular clipping, and straight-chord geometry;
- <!-- rule: chart.render.shared-polygons class=hard enforced=no tasks=client-runtime --> polygon subpaths, implicit closure, `evenOdd` fill, and fill-before-stroke
  order;
- <!-- rule: chart.render.shared-polar-guides class=hard enforced=no tasks=client-runtime --> polar guide ownership and geometry, polar mark hit testing, and the absence
  of a polar viewport in version one;
- <!-- rule: chart.render.shared-palette class=hard enforced=no tasks=client-runtime --> deterministic category palette assignment;
- <!-- rule: chart.render.shared-composition class=hard enforced=no tasks=client-runtime --> composition traversal and layer order;
- <!-- rule: chart.render.shared-guides class=hard enforced=no tasks=client-runtime --> generic guide inference and explicit replacement, candidate identity,
  tick/label selection, typed localization, span components, layout order, and
  inert interaction semantics;
- <!-- rule: chart.render.shared-tooltips class=hard enforced=no tasks=client-runtime --> tooltip contents and source-row identity;
- <!-- rule: chart.render.shared-limits class=hard enforced=no tasks=client-runtime --> structural/metadata limits, visible-work policy, and fallback behavior.

<!-- rule: chart.render.allowed-platform-differences class=hard enforced=no tasks=client-runtime -->
Theme-native fonts, gesture feel, the fitting density tier selected under the
normative collision algorithm, and exact pixel layout may differ. The complete
candidate ladder, identities and ordering within each tier, priority rules,
data meaning, series grouping, scale domains, and literal colors must not
differ; the selected responsive tier may.



## 14. Deliberate exclusions and future evolution

The minimum design excludes:

- <!-- rule: chart.exclusion.client-transforms class=hard enforced=no tasks=charts --> client-side transform and expression languages;
- <!-- rule: chart.exclusion.option-blobs class=hard enforced=no tasks=charts --> arbitrary JSON option blobs;
- <!-- rule: chart.exclusion.client-joins class=hard enforced=no tasks=charts --> client joins, lookups, aggregation, binning, or stack inference;
- <!-- rule: chart.exclusion.field-paths class=hard enforced=no tasks=charts --> arbitrary field paths;
- <!-- rule: chart.exclusion.url-html-marks class=hard enforced=no tasks=charts --> raw URL or HTML marks;
- <!-- rule: chart.exclusion.client-geo class=hard enforced=no tasks=charts --> client-side geographic projection, topology/ring extraction, force layout,
  contouring, curve interpolation, or path sampling;
- <!-- rule: chart.exclusion.signals class=hard enforced=no tasks=charts --> Vega signals and general event streams;
- <!-- rule: chart.exclusion.brush-state class=hard enforced=no tasks=charts --> persistent crossfilter or brush state;
- <!-- rule: chart.exclusion.patches class=hard enforced=no tasks=charts --> chart-specific incremental patch formats;
- <!-- rule: chart.exclusion.special-marks class=hard enforced=no tasks=charts --> special marks whose geometry can be composed from primitives.

<!-- rule: chart.exclusion.emit-rows-first class=judgment enforced=no tasks=charts -->
New capability should first be tested against the core question: can the App
server emit explicit rows and primitive layers instead? If yes, no schema
extension is needed. A future attribute is justified only when it represents
portable client interaction or presentation that cannot be supplied safely as
typed chart-ready data.

## 15. Design references

The vocabulary and coverage analysis were compared with the official
Vega-Lite documentation for [encoding channels](https://vega.github.io/vega-lite/docs/encoding.html),
[layering](https://vega.github.io/vega-lite/docs/layer.html),
[concatenation](https://vega.github.io/vega-lite/docs/concat.html), and the
[layered candlestick example](https://vega.github.io/vega-lite/examples/layer_candlestick.html).
Those references motivate familiar terminology only. The Libertas contract in
this document is intentionally smaller and has its own normative semantics.
