# Changelog

## v0.3.0 (2026-10-29)

**Terms are the form** (dco-tek/macaw/macaw#188). A value's identity and validity are the BEAM term's own; JSON, and every other encoding, is an adapter at the edge. This release is breaking throughout; the guide below is what an adopter of 0.2.4 changes, and the entries after it are the detail.

### Upgrading from 0.2.4

1. **Digests change.** Every digest is now over the value itself (`Document.digest/1`), so a stored digest, cache key or golden written by 0.2.4 differs. Regenerate them; nothing reads an old digest.
2. **Validity is judged on the value.** A type's `validate/1` receives the value (atom keys where the type has keys, string names, its own structs), never the map form. Judge a value built in Elixir with `Document.check(value, type)`. `Document.read/3` and `Form.from_json/3` are gone: read untrusted JSON with `Form.read(input, type, opts)`, whose gates (`Form.gates/3`) are the JSON adapter's.
3. **Name maps.** A type whose maps hold names (a style named `"fill"`) implements `name_map?/1`; a fragment type does it in one line with `Fragment.name_map?/2`. Names in a name map are strings; an atom there is `{:type, :name}` when written.
4. **Colors are terms.** A literal color is `%Color{}` with float fields; a string is never a color. Write literals with `~RGB"#3b6fa8"` in code and `Color.parse!/1` at an edge, and write CSS with `Color.to_css/1`. Helpers raise for text and say so.
5. **A theme's names are strings,** the theme's own `name` and its `dark`/`light` included (`Theme.default().name == "default"`). Atom input is still accepted at construction and normalized there, so most construction code is unchanged; code that matches on `:default`, `:info` or a slot atom in a theme's fields or in the lists it answers (`slots/1`, `color_names/1`, `ramp_names/0`, faults) matches strings now. A host's `"info"` now replaces the library's.
6. **Words are kept as given,** and **the JSON reader's vocabulary is the reader's:** an extension that writes atoms of its own into styles or defaults implements `Theme.Extension.words/0`, and `Theme.Document.from_json/2` is given `extensions:` to read them back.
7. **Theme JSON is version 2.** A literal is tagged `{"$rgb": "#hex"}` and theme names are JSON strings. Version 1 documents written by 0.2.4 still read, through the version step, to the same theme; documents are written at version 2.
8. **The palette stacks through `Theme.Style`.** Nothing changes for a theme without hooks; an adopter with its own style rules gives a fragment type through `Theme.Extension.style_type/0`.

### Changes

**Breaking:** every digest changes.

- The kernel's digest is over the value itself, any data term, not its map form (spec/01 §7, #3, macaw#188): a type-tagged canonical encoding of the term, so identity no longer needs a representation. `1`/`1.0`, `:a`/`"a"`, `{:a}`/`[:a]` and a `DateTime`'s zone all count. A function, PID, reference, port or improper list raises.

**Breaking:** validity is judged on the value, and `Form` is the JSON adapter (spec/01 §5–§6, spec/03 §3.2–§3.3, spec/04 §5, #9, macaw#188).

- `Macaw.Layers.Document.validate/1` now receives the type's value (atom keys where the type has keys, string names, atoms, times, its own structs), never the map form. `Document.check(value, type)` judges a value with no encoding and no limits: how a value built in Elixir is judged. `Document.read/3` is removed.
- The gates move to the JSON adapter: `Form.gates(input, type, opts)` (bytes, scan, version, migration, recount, with `limits:` as before) gives the map form at the current version, and `Form.read(input, type, opts)` reads the value from it and checks it. The recount is now a gate of its own: its faults stop the read before the type's. `Form.from_json/3` is removed (use `Form.read/3`); `Form.to_json/2` stamps `"$version"` for a type with a version line.
- `Bundle.validate/1` judges a `%Bundle{}`; `Bundle.check(bundle, types)` adds each entry's kind. `Bundle.from_map/3` and `from_json/3` report the map form's own faults, then the entries' reads, then the term's check. `Store.relocate/3` checks the bundle term instead of writing it to its map form and reading it back, so a bundle read under a raised limit relocates.
- `Theme.Document.from_map/2` judges the value with no form written or read and takes no `limits:` (they are `from_json/2`'s); `Theme.Document.validate/1` judges the envelope value, so a key beside `theme` is named by its atom.

The JSON adapter tells a name map from a node by the type's positions (spec/01 §6, spec/02 §9, spec/04 §5, #10, dco-tek/macaw/macaw#184).

- An optional `Form` callback, `name_map?(path)`: whether the map at `path` holds names, never the type's keys. At a name map a string key is written and read as the name, whatever its spelling (a style named `"fill"`, a scale `"x"`), escaped as before when it starts with `$`; an atom key there is `{:type, :name}`, since names are strings. At a node, and for a type without the callback, keys are as before.
- `Fragment.name_map?(type, path)`, from the type's specs: a `:union` or `:deep` key holds a name map whose entries are nodes of its `element` kind, for a fragment type to implement the callback in one line.
- **Breaking:** `Theme.Document`'s `ramps`, `colors`, `styles` and `defaults` are name maps, and its value spells every name in them as a string: `node/1`, `fragment/1` and `to_map/1` write `"info"` where they wrote `:info`, so a theme entry of a store goes through a bundle's forms and import. `Form.to_map/2` of a node with atom names is `{:type, :name}` at each. (#13, below, makes the theme itself hold strings, which retired the reading of a library spelling back as its atom.)
- A use site's `local` is a fragment of the type: `Form.encode_term/3` takes the term's place below its tag (`at`), the reader asks `name_map?/1` at `{:tag, tag}` and the path within the payload, and `Fragment.name_map?/2` places a `$use` local at the root's positions, so a local holding a style named `"fill"` round-trips.

**Breaking:** `%Macaw.Layers.Color{}` fields are floats (spec/04 §1.1, §1.6, #11, macaw#188).

- A literal color is a term: every constructor (`rgb`, `hsl`, `lab`, `hcl`, `oklab`, `oklch`, `parse`, the manipulations and interpolations) builds `%Color{}` with every field a float, clamped, never a negative zero, so equal colors are one term and one digest whatever built them. `hsl`, `lab` and `hcl` still round their channels, now held as floats; `interpolate_rgb/3` clamps its result. A struct written with integer fields still compares `==` but is not `Color.literal?/1` and has another digest. `to_rgb/1` and `to_hex/1` are unchanged.
- The adapters: `Color.parse!/1` (raises `ArgumentError` naming the text) and the compile-time sigil `~RGB"#3b6fa8"` (unreadable text is a `CompileError`) in; `Color.to_css/1` out, hex where the color is 8-bit exact (`Color.exact?/1`), else `rgb(r g b / a)`, which `parse/1` now reads (CSS Color 4's space-separated `rgb()`).
- `Macaw.Layers.Color.Form` is public, the colors' JSON adapter (`tags/0`, `encode_value/1`, `decode_value/2`), with the tag `$rgb` for a literal: the hex string when 8-bit exact, else `[r, g, b, a]`. A theme document's tag table lists it. (Text as a color goes in #12, below.)
- `Color.name?/1`: the grammar of a theme's names (lower-case letters, digits and `_`, starting with a letter), the one its stylesheet names were already held to.

**Breaking:** a string is never a color; a theme holds color terms; theme JSON version 2 (spec/04 §1.3–§1.4, §2.1–§2.6, §3.1–§3.4, §4–§6, #12, macaw#188).

- `Color.value?/1` no longer accepts a string: a literal is `%Color{}` where `Color.literal?/1` holds, so `value?("#fff")` is false, and a transform's operand is a color value, never text. The helpers (`intensity/2`, `alpha/2`, `blend/1`, `contrast/1`, `shade/2`, …) raise for text, naming `Color.parse!/1` and `~RGB`. `saturate/2` of a literal keeps its HSL meaning; the transform of a literal is the tuple `{:saturate, c, t}`. `Color.Form` writes and reads operands and pinned steps in their own forms (`$rgb`), never bare strings.
- `%Theme{}` holds terms: `series` and the furniture are literals, and so are the ramps' anchors, `colors` entries and pinned steps, transforms' operands and style paint colors where they are literal. The built-ins are written with `~RGB` and resolve, compile and check exactly as before. `Theme.new/1` and `merge/2` raise for text in a color field (`Theme.new(axis: "#555")` raises naming `Color.parse!/1` and `~RGB`); text in a `colors` entry, a pinned step, an anchor or a style's `fill`/`stroke` color is a contract fault naming them; an extension's color default that is text raises naming the extension.
- `Theme.resolve(theme, slot, :literal)`, `Color.resolve(v, theme, :literal)`, `Theme.ramp/3`, `Theme.ink/2` and `Theme.data_color/2`'s `{:ok, c}` answer `%Color{}`; `:css` writes each literal by `Color.to_css/1`, so every `var()` reference and the stylesheet are byte for byte as before. A transform's literal stays quantized to 8 bits. `Theme.ink/2` takes a `%Color{}` or `nil` and raises for text. `Theme.check_color/2` raises for a term that is no color value, text included; `Theme.data_color/2` answers `{:error, :unreadable}` for text.
- `Theme.Document` is at version 2: a literal is tagged `$rgb`. The step from version 1 tags each string at a color position that `Color.parse/1` reads (the series, the furniture, ramp anchors, `colors` entries, `$steps` values, transform, `$contrast` and `$blend` operands, style `fill`/`stroke` colors), so a v0.2.4 document, and a bundle's entry of one, reads to the same theme; other text is refused at its path, a series entry or furniture as `{:type, :color}` (was `{:type, :string}`).
- Removed: `Color.Typed.unreadable/1` and `readable?/1` (internal), whose only use was text as a color.

**Breaking:** a theme's names are strings, one key per spelling; words as given; the vocabulary is the JSON reader's (spec/04 §1.3, §1.6, §2.1–§2.6, §3.4, §5, spec/01 §5–§6, #13, macaw#188, macaw#181).

- Every name inside `%Theme{}` is a string, the library's, its extensions' and a host's alike: the keys of `ramps`, `colors`, `styles` and `defaults`, the built-in palettes and colors, the slots and the name in every typed color (`{:color, "good"}`, `{:ramp, "status", 0.5}`). The lists answer strings: `slots/1`, `color_slots/1`, `color_names/1` (now plainly sorted), `style_names/1`, `ramp_names/0`, `role_names/0`, `series_slot/2`, and `check/1`/`violations/1` subjects (`{:ramp, "status"}`), `floors/1` faults (`{"text", …}`, `{"surface", {:equal, "grid"}}`) and fault messages name them so. Every function that takes a name still takes either spelling.
- Atom input is normalized once, at construction (`Theme.new/1`, `merge/2`, `Theme.Document`'s read), by one function: `colors: %{"info" => c}` is the library's `"info"`, **the host's value winning** — before, the library's `:info` silently shadowed a host's `"info"` (macaw#181) — and likewise a ramp `"status"` or a style `"muted"`. A typed color's name normalizes wherever it stands (ramp anchors, `colors` entries, pinned steps, style nodes, `defaults`), so a ramp anchor `{:color, :brand}` is `{:color, "brand"}` and round-trips through JSON. One name given both as its atom and its string in one map raises `ArgumentError`; in a document value it is `{:duplicate, :name}` at `[:theme, map, name]`.
- `Color.color/1` and `Color.ramp/2` build the name's string (`color(:red)` is `{:color, "red"}`), and `Color.Form` reads `$color` and `$ramp` names as strings, never atoms: a typed color built in Elixir is the value its JSON form reads back.
- Words are kept as given: `Theme.new/1` no longer turns a style's or a default's keys and atom values outside the library's vocabulary into strings (`curve: %{type: :monotone_x}`, `font: %{line_height: 1.2}` stay atoms). `Theme.Words` holds only words now, no names.
- The vocabulary is the JSON reader's: `Form.read/3`, `from_map/3`, `to_map/3` and `to_json/3` take `vocabulary: %{keys: [...], atoms: [...]}`, merged over the type's tables for that call; no atom is made from a document beyond them. `Theme.Extension` gains an optional `words/0`; `Theme.Document.to_json/1` writes with the theme's extensions' words and `from_json/2` reads with the `extensions:` given, so **`from_json` needs `extensions:` for an adopter's words** (without them an adopter's atom value is `{:enum, allowed}` at its path). An extension's `styles/0` and `colors/0` may name by atom or string; the theme holds them as strings.
- `Theme.Document`'s read path is the theme's normalization (its library-spelling-to-atom reading and the style-series name table are gone); `Table.spelled/2`'s either-way search is gone, and `Color.Names.atoms/0` (internal) is removed.

**Breaking:** a theme's own name and its `dark` and `light` are strings (spec/04 §2.1, §2.5, §5, #16).

- `%Theme{}`'s `name`, `dark` and `light` are strings, the built-ins' included: `Theme.default().name` is `"default"` (was `:default`), `default().dark` `"dark"`, `dark().light` `"default"`, `color_safe/0` and `print/0` named `"color_safe"` and `"print"`. Atom input is normalized once, at construction (`new/1`, `merge/2`, a document read), so `new(name: :house)` and `new(name: "house")` are one theme and `new(dark: :dark)` is `new(dark: "dark")`. `Theme.builtin/1` takes either spelling (`builtin("dark") == builtin(:dark)`); `Theme.builtins/0` (internal) answers strings. Fault messages name the theme as its string (`theme "default": …`).
- `Theme.Document` writes the three as JSON strings (`"dark": "dark"`, where it wrote `{"$atom": "dark"}`) and reads them as strings; a version 1 (v0.2.4) document's `{"$atom": "dark"}` migrates to `"dark"` in the step to version 2, and is refused at version 2; a `name` that is not a string is `{:type, :string}`. The stylesheet's theme classes are unchanged (`.mw-theme-color-safe`).

The palette stacks as a fragment type, and an adopter gives its style type (spec/04 §2.4, §3.4, spec/02 §3.3, #14, macaw#188, macaw#181).

- `Macaw.Layers.Theme.Style` is a style node as a fragment type: root `:style`, every part `%{merge: :override, node: :part}`, no hooks. A list entry of the palette is `Fragment.stack/2` of its layers, a left fold, in `style/2`, `check/1` and `violations/1` alike; for a theme with no extension's style type every entry is as before.
- `Theme.Extension` gains an optional `style_type/0`: an adopter's fragment type of a style node with its own `normalize/4` and `under/3`, which the theme stacks its palette with in place of `Theme.Style` (visualize D-172: a gradient over a solid paint drops its color). At most one extension per theme gives one; two raise `ArgumentError` naming both, and a module that is no fragment type raises naming the extension.

## v0.2.4 (2026-10-29)

Additive: the hooks visualize needs to adopt the cascade and composites (macaw#179, macaw#183, visualize#963), and a load-order fix.

- A host may raise a named limit, explicitly: `Limits.new(items: {:raise, 500_000})`, also through `read/3`'s `limits:`; a plain value above the default still raises (spec/01 §5, #7, macaw#183).
- The composite's hooks (spec/02 §8, #6, macaw#179): a type may `scope/3` each site's contribution by the composite node's own keys (visualize's `defaults`), and `place_whole/2` a keyed site's contribution of no kind under the site's key. spec/02 §8 says which keys a composite node holds.
- The cascade's hooks (spec/02 §3.3, #5, macaw#179): a fragment type may `normalize/4` a key both fragments hold (a shorthand against a part merges as the part) and trim with `under/3` what of a lower node a higher one keeps (a node whose type changes); both hold in `compose/3`, `stack/2` and `explain/2`. A trim is a replacement, so a stack where one happens is its left fold. `explain/2` reports a normalized part's leaf with the layer that set it.
- An optional callback is found whether or not its module is loaded: `Macaw.Layers.Callback.exported?/3`, used by `Form`, the cascade and every other site, replaces `Fragment.callback?/3`; CI refuses a bare `function_exported?/3` (spec/01 §9, #4, macaw#136).

## v0.2.3 (2026-10-28)

- The macaw logo in the docs and the README (macaw#175).
- Tests check "makes no atom" on the input's own names, never the node's atom count, and a test forbids the count (#1).

## v0.2.2 (2026-10-28)

- The README opens with the early-release notice (macaw#174).

## v0.2.1 (2026-10-28)

- Spec and README links to the public home, `github.com/dcoai/macaw_layers`, and the install snippet from Hex (macaw#171, #173).
- Package metadata for Hex: the public home, the changelog link, the files shipped, and docs with `ex_doc` (macaw_compose#155).

## v0.2.0 (2026-10-27)

- macaw_layers v0.2.0: fragments and stacking, stores and bundles, colors and themes (macaw_compose#94)

## v0.1.0 (2026-10-27)

- macaw_layers v0.1.0: the document kernel (macaw_compose#56): names, vocabularies, faults, version lines, the gates, the forms and tags, the digest
