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
- 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. - Validity is judged on the value. A type's
validate/1receives the value (atom keys where the type has keys, string names, its own structs), never the map form. Judge a value built in Elixir withDocument.check(value, type).Document.read/3andForm.from_json/3are gone: read untrusted JSON withForm.read(input, type, opts), whose gates (Form.gates/3) are the JSON adapter's. - Name maps. A type whose maps hold names (a style named
"fill") implementsname_map?/1; a fragment type does it in one line withFragment.name_map?/2. Names in a name map are strings; an atom there is{:type, :name}when written. - Colors are terms. A literal color is
%Color{}with float fields; a string is never a color. Write literals with~RGB"#3b6fa8"in code andColor.parse!/1at an edge, and write CSS withColor.to_css/1. Helpers raise for text and say so. - A theme's names are strings, the theme's own
nameand itsdark/lightincluded (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,:infoor 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. - 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, andTheme.Document.from_json/2is givenextensions:to read them back. - 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. - The palette stacks through
Theme.Style. Nothing changes for a theme without hooks; an adopter with its own style rules gives a fragment type throughTheme.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 aDateTime'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/1now 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/3is removed.- The gates move to the JSON adapter:
Form.gates(input, type, opts)(bytes, scan, version, migration, recount, withlimits:as before) gives the map form at the current version, andForm.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/3is removed (useForm.read/3);Form.to_json/2stamps"$version"for a type with a version line. Bundle.validate/1judges a%Bundle{};Bundle.check(bundle, types)adds each entry's kind.Bundle.from_map/3andfrom_json/3report the map form's own faults, then the entries' reads, then the term's check.Store.relocate/3checks 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/2judges the value with no form written or read and takes nolimits:(they arefrom_json/2's);Theme.Document.validate/1judges the envelope value, so a key besidethemeis 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
Formcallback,name_map?(path): whether the map atpathholds 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:unionor:deepkey holds a name map whose entries are nodes of itselementkind, for a fragment type to implement the callback in one line.- Breaking:
Theme.Document'sramps,colors,stylesanddefaultsare name maps, and its value spells every name in them as a string:node/1,fragment/1andto_map/1write"info"where they wrote:info, so a theme entry of a store goes through a bundle's forms and import.Form.to_map/2of 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
localis a fragment of the type:Form.encode_term/3takes the term's place below its tag (at), the reader asksname_map?/1at{:tag, tag}and the path within the payload, andFragment.name_map?/2places a$uselocal 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,labandhclstill round their channels, now held as floats;interpolate_rgb/3clamps its result. A struct written with integer fields still compares==but is notColor.literal?/1and has another digest.to_rgb/1andto_hex/1are unchanged. - The adapters:
Color.parse!/1(raisesArgumentErrornaming the text) and the compile-time sigil~RGB"#3b6fa8"(unreadable text is aCompileError) in;Color.to_css/1out, hex where the color is 8-bit exact (Color.exact?/1), elsergb(r g b / a), whichparse/1now reads (CSS Color 4's space-separatedrgb()). Macaw.Layers.Color.Formis public, the colors' JSON adapter (tags/0,encode_value/1,decode_value/2), with the tag$rgbfor 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?/1no longer accepts a string: a literal is%Color{}whereColor.literal?/1holds, sovalue?("#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, namingColor.parse!/1and~RGB.saturate/2of a literal keeps its HSL meaning; the transform of a literal is the tuple{:saturate, c, t}.Color.Formwrites and reads operands and pinned steps in their own forms ($rgb), never bare strings.%Theme{}holds terms:seriesand the furniture are literals, and so are the ramps' anchors,colorsentries and pinned steps, transforms' operands and style paint colors where they are literal. The built-ins are written with~RGBand resolve, compile and check exactly as before.Theme.new/1andmerge/2raise for text in a color field (Theme.new(axis: "#555")raises namingColor.parse!/1and~RGB); text in acolorsentry, a pinned step, an anchor or a style'sfill/strokecolor 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/2andTheme.data_color/2's{:ok, c}answer%Color{};:csswrites each literal byColor.to_css/1, so everyvar()reference and the stylesheet are byte for byte as before. A transform's literal stays quantized to 8 bits.Theme.ink/2takes a%Color{}orniland raises for text.Theme.check_color/2raises for a term that is no color value, text included;Theme.data_color/2answers{:error, :unreadable}for text.Theme.Documentis at version 2: a literal is tagged$rgb. The step from version 1 tags each string at a color position thatColor.parse/1reads (the series, the furniture, ramp anchors,colorsentries,$stepsvalues, transform,$contrastand$blendoperands, stylefill/strokecolors), 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/1andreadable?/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 oframps,colors,stylesanddefaults, 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, andcheck/1/violations/1subjects ({:ramp, "status"}),floors/1faults ({"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:infosilently 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,colorsentries, 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 raisesArgumentError; in a document value it is{:duplicate, :name}at[:theme, map, name]. Color.color/1andColor.ramp/2build the name's string (color(:red)is{:color, "red"}), andColor.Formreads$colorand$rampnames 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/1no 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.Wordsholds only words now, no names. - The vocabulary is the JSON reader's:
Form.read/3,from_map/3,to_map/3andto_json/3takevocabulary: %{keys: [...], atoms: [...]}, merged over the type's tables for that call; no atom is made from a document beyond them.Theme.Extensiongains an optionalwords/0;Theme.Document.to_json/1writes with the theme's extensions' words andfrom_json/2reads with theextensions:given, sofrom_jsonneedsextensions:for an adopter's words (without them an adopter's atom value is{:enum, allowed}at its path). An extension'sstyles/0andcolors/0may 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, andColor.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{}'sname,darkandlightare strings, the built-ins' included:Theme.default().nameis"default"(was:default),default().dark"dark",dark().light"default",color_safe/0andprint/0named"color_safe"and"print". Atom input is normalized once, at construction (new/1,merge/2, a document read), sonew(name: :house)andnew(name: "house")are one theme andnew(dark: :dark)isnew(dark: "dark").Theme.builtin/1takes either spelling (builtin("dark") == builtin(:dark));Theme.builtins/0(internal) answers strings. Fault messages name the theme as its string (theme "default": …).Theme.Documentwrites 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; anamethat 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.Styleis a style node as a fragment type: root:style, every part%{merge: :override, node: :part}, no hooks. A list entry of the palette isFragment.stack/2of its layers, a left fold, instyle/2,check/1andviolations/1alike; for a theme with no extension's style type every entry is as before.Theme.Extensiongains an optionalstyle_type/0: an adopter's fragment type of a style node with its ownnormalize/4andunder/3, which the theme stacks its palette with in place ofTheme.Style(visualize D-172: a gradient over a solid paint drops its color). At most one extension per theme gives one; two raiseArgumentErrornaming 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 throughread/3'slimits:; 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/3each site's contribution by the composite node's own keys (visualize'sdefaults), andplace_whole/2a 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/4a key both fragments hold (a shorthand against a part merges as the part) and trim withunder/3what of a lower node a higher one keeps (a node whose type changes); both hold incompose/3,stack/2andexplain/2. A trim is a replacement, so a stack where one happens is its left fold.explain/2reports 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 byForm, the cascade and every other site, replacesFragment.callback?/3; CI refuses a barefunction_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