One sheet per addon says what the client looks like. A selector names a rule, the rule’s properties are setters, and installing the sheet restyles the running client at once. No restart, no patched files, no permission. Dropped when your addon is.
local sheet = hafen.ui():sheet()
sheet:rule("*"):font(hafen.font():get("serif"):derive():size(11))
sheet:rule("chat"):color{190, 210, 190}
sheet:install()
"*" is the fallback under everything. "chat" refines one surface. sheet:release() puts the stock client back, as does disabling your addon.
| Rule | Detail |
|---|---|
| One sheet per addon | hafen.ui():sheet() hands it back by identity. :install() replaces the applied sheet whole. |
| A rule is the same object each time its selector is named | An edit to an installed sheet lands on the spot. sheet:rule("chat"):release() gives one rule back. |
| Kind | Names | Example |
|---|---|---|
| Site key | A place the client draws, wherever it is: a family of surfaces at once. | chat, window.title, window.frame, panel, button, tooltip, label |
| Tree key | The widgets a selector matches, and everything inside them. A chain reaches one part of one window. | window[title=Inventory], window[title=Inventory] label |
Surfaces, chat and hud say what each surface is under a rule. what each key accepts says which properties a key honours.
| Setter | Says |
|---|---|
:font(h), :color(c) |
The text. |
:emboss(v), :glow(t) |
What a carved caption is filled with, and the halo behind it. |
:bg(t), :border(t), :padding(n) |
The surface it is painted on. |
:picture(t) |
The whole plate a surface is. |
:caption(t), :closeButton(t), :sizer(t) |
A window’s ornaments. |
:position(x, y), :size(w, h), :anchor(t), :margin(n) |
Where the widget is, how big, and the room around it. |
Each setter returns the rule and reads back with no argument. Properties are independent. A misspelt property raises naming the ones that exist.
sheet:rule("window.frame")
:bg{ color = {26, 26, 28, 240} }
:border{ box = "gfx/hud/wnd", mode = "tile" } -- or your own art, cut into a 9-slice
:padding(4, 12, 4, 4)
sheet:rule("window.title"):emboss(false):color{230, 220, 190}
sheet:rule("window[title=Inventory]"):anchor{ to = "screen", at = "bottomright", offset = {-8, -8} }
Every value has a JSON spelling, so a whole look lives in a file and the Lua only reads it. A picture is a path or a resource name. A face is named the same way. A list of pictures is an array. A walked colour is a sequence.
local theme = hafen.json():parse(hafen.asset():get("theme.json"):text())
hafen.ui():sheet():load(theme.rules):install()
{ "rules": {
"*": { "font": { "builtin": "serif", "size": 11 } },
"window.frame": { "border": { "box": "gfx/hud/wnd", "mode": "tile" },
"bg": [ { "res": "gfx/hud/wnd/lg/bg", "mode": "tile" },
{ "res": "gfx/hud/wnd/lg/bgl", "at": "left", "mode": "tile" } ],
"padding": [8, 24, 8, 8] },
"chat": { "color": [190, 210, 190] },
"chat.speaker": { "color": { "palette": [[220, 190, 140], [150, 200, 220]] } }
} }
| Rule | Detail |
|---|---|
| What the file says | A serif face at 11 design pixels under everything. The client’s own frame, named by its folder, edges tiled, over two background layers. Room of 8 px on three sides and 24 at the top. A caption is painted, and the room for a title bar is the padding’s. The chat in one colour. Speakers cycling two colours of yours. |
sheet:load(rules) |
Takes the whole sheet and replaces what it said: the file is the document. Properties are the setter names. An unknown one raises. |
A size on "*" reaches every surface |
Captions and headings included. Omitting the size swaps the family alone and leaves each surface its height. |
sheet:stock() is what the client draws with when no rule says anything, keyed by site, in the shape sheet:load() takes. Open the windows you mean to read first: a site that has not drawn declares nothing — the client’s own look.
local look = hafen.ui():sheet():stock()
hafen.log():write(hafen.json():encode(look)) -- the whole catalogue, ready for theme.json
hafen.ui():sheet():load(look):install() -- and the client looks exactly as it did
To restyle a single widget you hold, ask it for its own rule. That is the top of a cascade resolved most-specific first, composing per property. The levels: the widget’s own, the matching tree rule, the site rule, *, the widget’s own stock, the client’s stock. widget:style() reads what a widget resolves to. Layout resolves through the same cascade with the verb widget:position(x, y) as its top. A saved layout is a table of widget:position() reads kept in hafen.store.
An addon that names the surfaces it builds is themed like anything else, knowing nothing about themes.
{ "rules": {
"[name=otheraddon/bar]": { "border": { "box": "gfx/hud/wnd", "mode": "tile" } },
"[name^=otheraddon/slot]": { "bg": { "asset": "img/slot.png", "mode": "stretch" } }
} }
| Rule | Detail |
|---|---|
[name=…] |
The one refiner an addon owns, written <addon>/<name>. It outranks every other part of a selector. |
| One step is enough | What an addon declares for itself is a stock beneath every rule, so your rule wins without chaining. |
| No site key falls into it | ["*"] reaches the places the client draws. An addon’s surface is reached only by a rule naming it. |
^= reaches a group |
[name^=otheraddon/slot] dresses slot1…slot12. [name=otheraddon/slot7] dresses one of them. The two weigh the same, so an exception is a chain, [name=otheraddon/bar] [name=otheraddon/slot7]. |
| Finding names | Ask the addon, or point the widgetstack addon at it (the dev tools). A name nobody answers to matches nothing. |
The sheet restyles and does not rebuild: a state is a face inside a value, not a key. There is no animation. Re-flowing what a client window puts inside itself is replacing it. Where the skinning system ends states the boundary. Two addons styling one surface: entries tagged by owner, the last applied wins, disabling one falls back to the next.
Next: translating — what a surface draws, rather than what it is drawn with.