Docs
/
Core concepts
Export, Extract, Inject
Any part of a LAYR app can read (Extract) and change (Inject) the features of any object in any page or widget. Changes are layers: ordered, reversible, and known to the compiler.
Protected by default
Objects are protected: nothing outside an object's owner changes its params or features. An Extract or Inject is the explicit way in, and using one is what makes a feature changeable. Mark anything that must never change from outside with !mut.
Addressing objects
By id:
SomePage.card // the object with .id(card) in SomePage
Card.frame // .id(frame) inside the Card widget: every instance
card // an id in the same Page or WidgetBy lookup path, one level per ., using the lowercase widget name, a slot name or an id:
DemoPage.scaffold.body.mid.column.text // the only Text in that Column
DemoPage.scaffold.body.mid.column.text(1) // the second of several Texts (zero-based)
SomePage.card.column.text(0) // mix ids and pathsA path continues into a widget instance: past the instance, it names objects inside that widget, so it reaches one instance and leaves the others alone. Naming the widget's root is optional, since the instance is its root:
Room.scaffold.body.column.booking(1).text(0) // the first Text in the second Booking only
Room.scaffold.body.column.booking(1).row.text(0) // the same object, naming Booking's root Row
Booking.row.text(0) // from the widget's definition: that Text in every BookingThe compiler resolves every address. A path that breaks after an edit is an error (L3301), and a bare name with several matches is ambiguous (L3303) with fixes listing the (n) choices. Ids survive edits; paths are handy for objects you did not name.
Extract: read
An Extract reads a feature of another object. Here a page reads its own card three ways: the padding now, the padding before any Inject, and the size it rendered at:
Page(
.name(Reader)
.route('/')
Extract(.from(Reader.card) insets pad = card.padding)
Extract(.from(Reader.card) .exeOrder(-1) insets original = card.padding)
Inject(.into(Reader.card) .exeOrder(0) card.padding = original * 2)
Scaffold(
.config(color: canvas)
.body(
Column(
.config(gap: 8, padding: all(24))
Container(
.id(card)
.config(color: accent, cornerRadius: 12, padding: all(12))
.obj(Text(.config(color: onAccent) .obj('The card')))
)
Text('now: $pad')
Text('before any Inject: $original')
Text(
.config(color: muted)
.obj(
'rendered: ${Math.round(Reader.card.size?.w ?? 0)} × ${Math.round(Reader.card.size?.h ?? 0)}'
)
)
)
)
)
)Extracts are reactive: they update when the feature changes. Features are config keys (padding, w, color…), widget params, what the object shows (obj, below), and the rendered features size, pos and visible, measured from layout. You cannot change a measurement: pos and visible are read-only, and writing size changes the object's own size key instead (a Text's font size, a box's size), while reading it still gives what was measured. .exeOrder(k) reads the value below that point in the cascade: every Inject with a lower order, none at k or above. Here .exeOrder(-1) reads the card before the Inject at 0.
Inline references (Source.card.size in an expression) are Extracts too.
Inject: change
An Inject changes a feature of another object, from anywhere. Here a widget, while it is shown, doubles a card's padding and recolours it:
Page(
.name(Target)
.route('/')
var bool loud = false
Scaffold(
.config(color: canvas)
.body(
Column(
.config(gap: 12, padding: all(24))
Container(
.id(card)
.config(color: accent, cornerRadius: 12, padding: all(10))
.obj(Text(.config(color: onAccent) .obj('card')))
)
Button(
.preset(default)
.config(label: loud ? 'Remove Louder' : 'Show Louder')
.fnc { loud = !loud }
)
If(.cnd(loud) .obj(Louder()))
)
)
)
)
Widget(
.name(Louder)
Inject(.into(Target.card) .exeOrder(0) card.padding = card.padding * 2)
Inject(.into(Target.card) .exeOrder(1) card.color = teal)
.obj(
Text(.config(color: muted) .obj('Louder is shown, so its two layers apply'))
)
)Each Inject is a layer: a change applied on top of the value below it. The rules:
- Layers apply in ascending
.exeOrder, likez-index: any integer works, negative and large ones included.-9999runs under everything and9999over everything; the highest order has the last word. Two Injects on the same feature with the same order are an error (L3102). Injects without an order go after ordered ones, in file order, with a warning if two share a feature (L3103). .exeOrderhas short forms for fast typing:.exeOrd,.eOrdand.eO.layr formatwrites them as.exeOrder.- Inside an Inject, the target's current value is the value from the layer below:
card.padding * 2doubles whatever came before. - A layer lives as long as its owner: a file-level Inject is permanent, a Page's while it is shown, a Widget's while that instance is shown. When the owner goes away, the layer goes and the value reverts.
- Writing a feature from a Function (
card.w = 360) is the imperative form. It sets the base value; layers still apply above it.
Objects: change what an object shows
What an object shows is a feature too: .obj names it. A Text's obj is its text, a Container's is its object, a Column's or Row's are its objects. Read it, write it and inject it like padding:
Page(
.name(Order)
.route('/')
var bool shipped = false
Scaffold(
.config(color: canvas)
.body(
Column(
.config(gap: 12, padding: all(24))
Container(
.id(card)
.config(
.border(color: line, width: 1)
color: panel
cornerRadius: 12
padding: all(16)
)
.obj(
Column(
.config(gap: 4)
Text(
.id(title)
.config(size: 17, weight: semibold)
.obj('Order 1042')
)
Text(.id(status) .config(color: muted) .obj('Packing'))
)
)
)
Button(
.preset(default)
.config(label: shipped ? 'Undo' : 'Ship it')
.fnc { shipped = !shipped }
)
If(.cnd(shipped) .obj(Shipped()))
)
)
)
)
Widget(
.name(Shipped)
Inject(.into(Order.status) status.obj = 'Shipped, arriving Tuesday')
Inject(.into(Order.title) title.obj = title.obj + ' · paid')
.obj(
Text(.config(color: muted) .obj('Shipped is shown, so its layers apply'))
)
)The same works for widgets and lists. A widget instance's params are features by name, and its obj is what the caller put in its .obj. A list's objects are replaced as a list:
Widget(
.name(Tag)
.param(txt label = 'New')
.obj(
Container(
.config(color: accent, cornerRadius: 999, padding: sym(x: 10, y: 4))
.obj(
Text(
.config(size: 13, color: onAccent, weight: medium)
.obj(param.label)
)
)
)
)
)
Page(
.name(Menu)
.route('/')
Scaffold(
.config(color: canvas)
.body(
Column(
.config(gap: 12, padding: all(24), xAlign: start)
Tag(.id(tag))
Column(.id(dishes) .config(gap: 4) Text('Soup'), Text('Bread'))
)
)
)
)
Inject(.into(Menu.tag) tag.label = 'Today only')
Inject(
.into(Menu.dishes)
dishes.objs = [Text('Soup'), Text('Bread'), Text(.config(color: accent) .obj('Pie'))]
)The rules:
- A path that ends on a slot names the slot:
card.obj,list.objs(objandobjsboth name an object's default slot),Menu.scaffold.bar. A path that goes on descends into it:card.obj.column.text(1)is the second Text in the card. - Objects written as values compile like the same objects in the layout:
card.obj = Text('Hi'),list.objs = [Text('a'), Text('b')]. A Text'sobjtakes text. - Inside an Inject,
title.objis the value below, sotitle.obj + ' · paid'extends the text. An Extract of a Text'sobjreads its text. - A Function writes it too:
status.obj = 'Shipped'.
The cascade
Every feature resolves in one order:
schema default → theme → preset → config → .at(frame) → writes → injections by exeOrder → finalAsk the analyzer for any feature's cascade:
layr analyze --explain Target.card.paddingTarget::card.padding
declared (config)
inject exeOrder 0 src/pages/target.layr:12:41
extract at exeOrder -1 src/pages/reader.layr:3:52In VS Code, Find References on an id lists every Extract and Inject of that object.
!mut
Container(.id(logo) .config(w: 40, !mut color: ink))Any Extract/Inject change to a !mut feature is an error (L3101); at runtime it is ignored with a warning. For system code that must override it, Inject(.force ...) is allowed only in files listed under access.force in layr.yaml, and the analyzer lists every forced injection.
Export: name what you share
.export(...) gives features names under an export id, like a small public interface for an object:
Page(
.name(Shared)
bind size doubled = boxFeatures.boxSize * 2
Scaffold(
.body(
Column(
Container(
.config(w: 120, h: 30, color: violet)
.export(id: boxFeatures, size boxSize = container.size)
)
Text('doubled: $doubled')
)
)
)
)Export is optional: every feature is already reachable by id or path. Use it to name and document what other code should use.
From React
React code joins the same cascade: useExtract("Target::card", "padding") reads a feature and inject(address, key, order, fn) adds a layer and returns a function that removes it. The key "obj" is what the object shows, so inject("Order::status", "obj", 0, () => "Shipped") changes a Text. See React interop.