Crea — Theme & Customisation Guide
v0.8.0Crea · local-first

Crea · reference

Everything that decides how a realm looks

One page for the Theme table, the pages that dress each piece of furniture (the dock, the navrail, the sidebars) and the two that hold the project's own faces and marks, the per-kind windows, the menus, the exported looks, and the stylesheet themes you write by hand — plus the files all of it is stored in.

·What this page is

A realm is Crea's word for a project: an ordinary folder the writer chose. Its folder is the database — every preference on this page is a file inside it, so a look survives Crea being uninstalled, travels on a USB stick, and can be handed to another writer by copying a folder.

Nothing here is stored in app storage. That is deliberate: a look is a property of this project, not of the machine it happens to be open on.

Tokens

Colours are named, never hardcoded in components. Every utility reads var(--project-*, var(--token)) — so one value repaints the whole app.

Two halves

Dark and light are one set of names with two palettes. A customisation stores both, so a theme exported from a project looks right in either.

Three layers

Base tokens, then an author's stylesheet, then the writer's colour pickers. Later wins — see §14.

1Where a look lives

<realm>/
├── .genesis/
│   ├── ui-prefs.json          ← every preference on this page, as one block
│   ├── folder-colors.json     ← the file/folder palette (NOT a theme, §18)
│   ├── icons/.packs/          ← installed icon packs (zip + index)
│   ├── themes/
│   │   ├── <name>.json        ← an exported look: a customisation block (§8)
│   │   └── <id>/theme.css     ← a CSS theme: a stylesheet you author (§13)
│   └── fonts/                 ← font files the Font picker offers (§5)
├── .characters/               ← the Cast
├── .timeline/                 ← Timeline events
├── .media/                    ← images, and subfolders as albums
└── <anything else>            ← the writer's own notes and pictures

🔴 .genesis/themes/ holds two species of theme, told apart by shape on disk: a .json file is a saved look (a whole customisation Crea writes), and a <id>/ folder containing theme.css is a CSS theme (a stylesheet a person authors). The picker groups them separately, because they behave differently — see §3.

Crea's own folders are dot-prefixed. They were characters/, timeline/ and media/ once, which put our folder names beside the writer's own work in their file tree. All of Crea's state now lives under a leading dot, which is also why .genesis is refused by the generic filesystem bridge and has dedicated channels instead (§19).

2The theme table — 35 tokens, four groups

Customisation › Theme is one grid of colour rows under four headings. It edits the half the table's own toggle names (dark or light); it is not the app's theme switch, and switching the app switches the half shown. Stored as customisation.theme.dark[token] and …theme.light[token].

A token missing from a half means the app's own colour for it, which is also what a reset restores: absence is the default, never a copied constant. Each row's Default writes the value of the project's theme — not the built-in look — see §3.

App palette — the nine that matter most

tokenlabel in Settingswhat it paints
bgBackgroundthe desk behind every window
surfacePanelwindows, rows, dialogs, the rail, the sidebars
surface-elevatedWindow headerwindow headers, block headers, popovers, hover
borderBorderthe one 1px grid the interface meets at
text-primaryTextvalues, names, prose
text-secondaryMuted textlabels, extensions, quiet chrome
accentAccentselection, focus, active state — amber by default
electricButtonprimary buttons, and nothing else
dangerDangerdelete, and its confirmations

These nine are also the authoring surface for a CSS theme (§15): every other token below has a default that already points at one of them.

Chrome — the strips, the fields, the scroll bar

tokenlabel in Settingsfalls back to
rail-bgNavrailsurface
rail-textNavrail texttext-secondary
header-bgHeaderbg
header-mutedHeader muted texttext-secondary
footer-bgFootersurface
sidebar-bgSidebarssurface
sidebar-textSidebar texttext-primary
sidebar-iconSidebar iconstext-secondary
sidebar-header-iconSidebar header iconstext-secondary
scrollbar-thumbScroll barborder
input-bgInput fieldssurface
input-textInput texttext-primary
input-borderInput borderborder
input-labelInput labeltext-secondary
close-bg / close-textWindows Close hover / glypha pair — see below
Close is a pair on purpose. A fill the writer chose next to a glyph they did not is a Close button that can vanish into its own tint. It defaults to #c42b1c / #ffffff — the Windows caption — and both the application's caption set and an in-app window's own ✕ read it, so the two can never disagree about what Close looks like.

Two header tokens were removed rather than left empty: header-logo (2026-10-25) coloured the ◆ that the app's artwork replaced — the drawing carries its own amber — and header-text (2026-10-26) coloured the CREA word when the owner took that word out of the header. A token whose only consumer is gone is a settings row that writes a value and paints nothing.

Buttons — four groups, one per kind of press

tokenlabel in Settingsmaps to
btn-save-bgSave buttonsthe primary variant
btn-save-textSave button text
btn-delete-bgDelete buttonsthe filled dangerSolid variant
btn-delete-textDelete button text
btn-normal-bgNormal buttonsthe quiet variant; at rest transparent, and this same value is its hover fill
btn-normal-textNormal button text
btn-normal-borderNormal button border
btn-special-textSpecial buttonsone text hex; its 15% soft fill is mixed from it, so a tint and its words cannot drift

Borders — the window frame and the app's seams

tokenlabel in Settings
win-borderWindow border — the all-kinds default
app-borderApp border — the seams between the app's strips
The app-border switch removes a width, not only a colour. A transparent 1px still occupies a pixel, so the five seam elements lose their border width entirely; two panels that met 1px apart would read as a gap between them rather than as a removed line. Window frames are untouched — a window's edge is never a "border: off".

How a row resets

Default beside a swatch writes the project theme's value for that one token; the toolbar's reset is a menu of three answers — Reset dark, Reset light, Reset all — each disabled when that half carries nothing. A project on Genesis resets to the app's built-in look; a project wearing a saved look resets to that file; a project on a CSS theme writes no override at all, and the theme underneath shows through. See §16.

3Theme choice — one dropdown, three species

Project › Theme names the look this project is wearing, and it is stored as customisation.themeName. Choosing is applying — there is no separate Apply button. The list is grouped because the species behave differently:

groupstored aschoosing it
The appGenesis (the default)the app's own look; nothing to read, so it always works
CSS themescss:<id>injects the stylesheet and keeps the writer's colour pickers
Saved looksthe file's namereplaces the whole customisation with the file's block
Missinga name with no filelisted so the writer can see what the project expects; picking it falls back to the app's look

🔴 The stored name is also the reset baseline. Every "reset to default" in Customisation writes this theme's value — the way a document's local formatting resets to its style — which is the whole reason the choice is persisted rather than just applied.

Open CSS themes folder is the only door into .genesis/themes/: nothing else in the interface can print an absolute path, and the folder is dot-prefixed inside a dot-prefixed folder. Rescan re-lists it and re-reads the theme currently on screen, which is the authoring loop (§16).

4The furniture pages — Navrail, Dock, Sidebar

Customisation's children are Look & Feel, Font, Icons, Navrail, Dock, Sidebar, Menus, Theme, Windows and Characters. Each one is a piece of furniture and carries only the controls that piece actually draws: a writer who opens Navrail is looking at the navrail, not at the app.

pageoffered inholds
Look & FeelbothLayout · Window header icons · Icon button boxes · Window controls (the frame's pin / expand / close marks)
FontbothApp font · Markdown font — the project's two faces
IconsbothApp icons — the one size the app's own chrome draws
Navraildefault onlyfloat · width · radius · icons position · Navrail icons · Navrail font
Dockos onlyfloat · position · radius · width · icons position · Dock icons
Sidebarbothfloat · length · radius · Sidebar icons · Files icons · Sidebar font
Menusbothrow text · row marks
Themeboththe colour table and the theme picker
Windows · Charactersbothone block per window kind; the card's type and indent
Why the numbers live on a furniture page. A size is a property of a piece of furniture, not of the project, so the rail's mark is on Navrail and the tree's on Sidebar. Navrail and Dock are the same piece of furniture under the two names the two layouts give it, and both are saved per layout: setting 24px marks on an os dock leaves the default rail's own number alone (§11).
What is not on a furniture page, and why. Three families are the project's, shared by both layouts, so they keep project-level pages: the two faces that voice the app and the manuscript (Font), the app's own mark (Icons), and the theme table. A typeface is not furniture — switch layouts and the same face is still there while the size beside it changes. That asymmetry is the whole rule: how big the marks are belongs to an arrangement, what the words are set in belongs to the project.
🔴 A dock draws no text, so it has no font. Dock font used to sit on the Dock page and painted nothing: an os bar is a row of marks with no codes to voice. The preference still exists — it is the navrail's lettering, and it is on Navrail, the one layout that draws three-letter codes.
🔴 A section is gated by layout; a field is not. The Dock page is hidden in default and Navrail in os (sectionsFor), because every control on them is about a bar the other layout does not draw. But no individual icon size is hidden anywhere: both slots of a rail entry are in the DOM in both layouts and a theme may flip which one paints (§11), so Dock icons sizes something a stylesheet can put on screen in default too. Hiding a field that works would be a filter that removes a live control.

Look & Feel — three toggles, the layout and the frame's marks

fieldstored asvalues
Layout customisation.layout → data-layout on <html> default (the attribute is absent) · os (the navrail becomes a dock of marks on one edge — see Dock below — HELP moves into the header, there is no footer, and the sidebars float by default). A saved ios normalises to os.
Window header icons customisation.headerIcons off by default. On, a window's leading three-letter code is drawn as a mark. The code survives in data-window-code and in the frame's accessible name, so a window is still FLS whether or not the letters are drawn. A note window has no fixed mark: it resolves the icon the writer gave that file in the sidebar.
Icon button boxes customisation.iconBoxes → data-icon-boxes="off" off by default. Off removes the border, background and hover fill from the sidebar's create buttons and from the app header's own cells — the mark is the control, which is why the icon defaults were raised in the same unit. The slot, the glyph and the accent-on-pressed state are untouched.
Window controls customisation.layouts.<layout>.icons.header → --icon-header the size of an in-app window's pin, expand and close — and its header mark, because a header cannot become two toolkits at two sizes. 8–64px, 10px at rest. It is on this page rather than on Icons because it dresses the window frame, not the app's chrome.
A layout is an attribute, not a style. An untouched project carries no data-layout attribute at all, and a saved ide falls back to default on both sides of the bridge — so an old project cannot open layoutless.
All three toggles and the window-control size are saved per layout (2026-10-26). They are furniture rather than look, so the value belongs to the arrangement it was set in — see §11. Switch layouts and the three switches show that layout's own state; the colours and the fonts do not move, because they are the project's.

The default layout's rail is the dock's counterpart, so it answers the same four questions with the opposite defaults — float, length, radius and alignment; a dock has a fifth, its edge, because it can move to any of the four — and the difference is the whole point: a dock floats and hugs its entries, a navrail is welded to the wall and runs the height of the desk. It had no shape of its own until 2026-10-27 — its width was an inline style in the markup, which no stylesheet and no setting could reach — so a theme could restyle its fill but not its body.

fieldstored asreaches
Float the navrail customisation.layouts.<layout>.navrail.float → data-navrail-float="on" | "off" Off by default (welded to the wall). On, the rail is inset by --navrail-gap with a full 1px frame and a corner. 🔴 It floats in flow, not absolutely: the rail is the shell's first flex child, so a floating one pushes the desk across by its own margin rather than being drawn over it.
Navrail width …navrail.width → --navrail-span, plus data-navrail-fill Full height (the default: the whole left edge) · Fit (a strip around its codes) · a px value, 40–4000. Its span is the vertical one, because the rail is anchored to the left edge — which is why the alignment words below read Top / Center / Bottom.
Navrail radius …navrail.radius → --navrail-radius 0–64px. null = not chosen: 10px floating, 0 attached. An explicit 0 is square either way; only Reset to default writes null.
Navrail icons position …navrail.align → data-navrail-align start (default) · center · end, drawn only when the width is Full height — a fitted strip has no spare length to place. The Dock's rule, one layout over.
🔴 Floating and length are two questions, and the split is the point. Until 2026-10-27 data-dock-fill="edge" meant full width and zero gap and a square corner and a scoped alignment — so choosing Full width silently deleted a radius the writer had just set. That is exactly what "dock corners doesnt work" was describing. The three pieces of furniture now each answer does it float and how much of its edge does it cover separately, and either answer can go with either of the other's: a full-width bar can float (a shelf with a gap) and a fitted one can be welded to the wall (a taskbar stub).

Dock — where the bar sits, and how big it is

Five fields decide the dock's shape, and one sizes its marks. Everything else — its fill, its border, its lettering, its shadow, its translucent blur — is CSS, and a theme may rewrite all of it (§11).

fieldstored asreaches
Float the dock customisation.layouts.<layout>.dock.float → data-dock-float="on" | "off" On by default. On, the bar holds --dock-gap off its edge with a corner; off, it sits flush and squares its corners by default. It is independent of Dock width: a full-width bar can float and a fitted one can be flush. This one attribute — not data-dock-fill — is what a stylesheet should key a gap or a radius default on.
Dock position customisation.layouts.<layout>.dock.position → data-dock-position bottom (default) · top · left · right. The bar and its entries turn with the edge: a left dock is a column. The live dot follows the screen edge the bar is pinned to — under the mark on a bottom dock, above it on a top one, on the left of a left dock and on the right of a right dock.
Dock radius …dock.radius → --dock-radius 0–64px. null = not chosen, and the layout answers: 10px for a floating bar, 0 when it is flush. An explicit 0 is square either way — a chosen zero is stored as 0, never as null.
Dock width …dock.width → --dock-span, plus data-dock-fill="edge" Fit (the default: a pill around its entries) · Full width (a taskbar: the whole edge) · a px value, 40–4000. data-dock-fill="edge" is present only when the width is full, and it now means nothing more than "runs the whole edge" — the gap and the corner default are keyed on data-dock-float.
Dock icons position …dock.align → data-dock-align start · center (default) · end, along the bar's own axis. On a bottom or top dock that reads Left / Center / Right; on a left or right dock it reads Top / Center / Bottom. Only a Full-width bar has spare length to place — a fitted pill is sized to its entries — so the control is drawn only on a full-width bar, with a sentence saying what unlocks it. A control that writes a value and paints nothing is the class of bug this page keeps warning about.
There is no Dock length any more (removed 2026-10-27 on the owner's instruction: "the dock should adapt to icon size"). The depth is derived in the app's own stylesheet — one entry's cell (--dock-cell, which grows with Dock icons) plus the bar's padding and its 1px border — so a bigger mark makes a deeper bar and no stored number can clip a glyph. What went with it: --dock-entry-cap, the cap that used to squeeze a cell into a too-shallow bar. A theme that wants a shallower taskbar re-points --dock-depth at nav.crea-os-dock, never at :root.
The Dock page is offered only in os. The default layout draws a navrail pinned to the left, with no edge to choose — a position row there would be a control that paints nothing. It lives as its own page under Customisation, beside Navrail and Sidebar, and its five controls are furniture: saved per layout, like the windows and the icon sizes. It carries no fonts — a bar of marks has no words to set.
These five are inputs, not the design. Placement is CSS keyed by data-dock-position, not a Tailwind class in the markup, precisely so a stylesheet can overrule it: a theme that wants an XP taskbar, a Ubuntu launcher or a macOS shelf writes CSS rather than asking the app for a new setting. The hooks are in §11.

The Files, Characters and Help panels can be attached to the edge they slide from or floating over the desk, with a gap and a corner — the same distinction the dock makes, which is why it is the same shape of setting. Three fields decide the panel's shape, and two more plus a face dress what it draws; everything else about it is CSS (§11).

fieldstored asreaches
Float the sidebars customisation.layouts.<layout>.sidebar.float → data-sidebar-float="on" | "off" On, the panel is inset by --sidebar-gap with a corner and a full 1px frame. Off, it meets its edge. Seeded per layout: os floats its sidebars, default attaches them — and an explicit choice always wins, so a writer who attached them in os keeps them attached.
Sidebar length …sidebar.span → data-sidebar-span="fit" | "full" Fit (a card as tall as its own list) · Full width (the whole height, which is what a drawer has always done). Seeded per layout — os fits, default runs the height — and independent of Float: a fitted panel can be attached and a full-height one can float. It is named span and not width because width is already the number the divider drags.
Sidebar radius …sidebar.radius → --sidebar-radius 0–64px. null = not chosen: 10px when floating, 0 when attached. An explicit 0 is square either way — a chosen zero is stored as 0, never as null, so the control can be made square as well as rounder. The stepper shows the radius actually drawn; only Reset to default writes null.
Sidebar icons …icons.sidebar → --icon-sidebar both panels' create and reorder buttons, 8–64px (20px at rest)
Files icons …icons.files → --icon-files the tree's folder, note, image and pin marks, 8–64px (18px at rest)
Sidebar font sidebarFont — a top-level field, outside customisation the file tree and the Cast only. It is the project's, not the layout's, for the reason in §5
🔴 A left or right dock pushes the panel clear of it. With the dock on the left edge, an inset panel would otherwise be drawn under the bar — two objects in one place, and the one the writer just pressed is the one hidden. So the left drawer's inset adds --dock-edge to its own gap, and the right drawer's adds --dock-edge-right. Both tokens are 0 for every other edge and scoped to html[data-layout="os"], so default cannot inherit a gap the width of a dock it never drew.
🔴 The offset applies to an attached panel too. Only the floating rule used to carry it, so unticking Float the sidebars in an os project put the drawer flush at left: 0 and buried a left dock underneath it — the one state in which the box that says "attached" also covers the bar. The two states are told apart by the float value rather than by which rule is written later.
Floating is the os layout's own answer, not a hidden second default. normaliseSidebar(value, layout) answers true for os and false for default only when the stored value is not a boolean, and the checkbox shows the effective state — so the writer sees what is on screen and one press changes it, in either direction.

5Fonts — four faces, three pages

All four write customisation.fonts (or the sidebar's own top-level field) and they are the project's: shared by both arrangements, so switching layout never changes a face. What differs is where the control sits — each face lives on the page of the thing it dresses, and the one that dresses a layout-specific piece of furniture sits on that furniture's page. Every field opens the same picker with two sources: SYSTEM lists installed families (through the Local Font Access API, with a curated fallback when the permission is refused) and PROJECT lists font files in the realm's .genesis/fonts/, filtered to .ttf .otf .woff .woff2. A project font has never been installed, so choosing one loads it from its bytes and registers an @font-face under a name of Crea's own.

fieldpagestored asreaches
App fontFontcustomisation.fonts.appeverything that does not name a font of its own — including buttons and chrome, because the interface's mono voice is re-pointed to the app font
Markdown fontFontcustomisation.fonts.markdownthe editor and the preview's prose only. Inter by name, not the app font: reading type must not re-voice because the chrome did
Navrail fontNavrail (default only)customisation.fonts.railthe rail's three-letter codes only. There is no Dock font: an os dock draws marks, not words, so the field that used to sit on the Dock page wrote a value and painted nothing
Sidebar fontSidebarsidebarFont — a top-level field, outside customisationthe file tree and the Cast

Stored shape is { source: "system" | "project", value: "…" }, or null for Default. Both sides normalise it, so a hand-edited ui-prefs.json cannot put an unreadable value in front of the interface. Each of the four has its own Reset to default, and the picker always shows the face it is wearing — Inter, JetBrains Mono, or Default (…).

🔴 Project fonts are not served over the media scheme. That would mean a font-src entry in the content policy plus CORS headers on an image-only scheme — three changes to a security boundary to move a few kilobytes. A blob: URL is same-origin, so the policy gains one word instead.

6Per-kind windows

Each window kind owns a frame block — the header strip, its 1px rule, the name on it, and a corner radius. radius: null means not chosen: square in the default layout, 8px in os, because a window floating on a dock desk with a hard square corner reads as a panel. An explicit 0 stays square in both — a layout must not overrule a number it was given. The stepper shows the effective radius, so the control and the drawn window cannot disagree.

key (stored)writer-facing name
noteNote editor — dashboard resolves here
characterCharacter card
timelineTimeline
mapMap
galleryGallery
statsStats
settingsSettings
viewerPhoto viewer — gallery-viewer resolves here
The key is the customisation key, not the window's content id. dashboard is stored as note and gallery-viewer as viewer, resolved once where the window is drawn — so a window cannot be coloured by one key and read by another.
Every frame block is saved per layout (2026-10-26). A rounded window is a decision about this arrangement's windows, and default is a square desk while os floats — so the eight blocks live in customisation.layouts.<layout>.windows and the two arrangements cannot overrule each other. The desk's coordinates are not in here at all: a window's size and position belong to a screen, in screens.json (see §11).

7Menus, icons, density

Crea's density is a constant: the same row heights in every panel, from one file. Three sets of numbers are the named exceptions, and each is a project preference rather than a second density scale. All three are saved per layout (2026-10-26) — they are the furniture of an arrangement, so the dock's menu text and the rail's icon sizes are two separate answers. Menus keeps its own page, because a menu belongs to no side; the five icon sizes are split across the pages of the things they size (§4), and the two rail labels follow the layout: Dock icons in os, Navrail icons in default.

Menus — text and marks

fieldstored asrangedefault
Menu textcustomisation.layouts.<layout>.menus.text → --menu-text9–24px13px
Menu iconscustomisation.layouts.<layout>.menus.icon → --icon-menu8–32px16px

One pair serves both menu spellings — the dialog menus and the file tree's hand-built one — and the row height is derived, never stored: max(--h-row, --icon-menu + 8px, --menu-text × 1.6), which is 24px at the defaults. Ask for a 24px word or a 32px mark and the row grows instead of clipping. A menu is an overlay read one-handed, so it is the one list in the app not bounded by the standard row height.

Five icon sizes, four pages

tokenfieldpagereachesdefault
--icon-railNavrail icons / Dock iconsNavrail · Dockthe navrail's three-letter codes' replacement marks, and the dock's marks — offered in both layouts, because a theme may show marks in default too (§11)20px
--icon-appApp iconsIconsevery glyph the app's own chrome draws — header cells and the footer's help17px
--icon-headerWindow controlsLook & Feelan in-app window's pin, expand and close, and its header mark10px
--icon-sidebarSidebar iconsSidebarboth sidebars' create and reorder buttons20px
--icon-filesFiles iconsSidebarthe rows' folder, note, image and pin marks18px

🔴 Each token sizes its own container, not only its glyph. The cell around a mark is max(…, --icon-… + n px), so asking for a 24px mark grows the box rather than drawing outside it — a glyph that outgrows a fixed box is either clipped or spilling, and both read as a rendering fault rather than as a large icon.

🔴 The control hugs its value. The stepper and the segmented group are width: fit-content, because a flex child of a column stretches on the cross axis — which painted [+] 5 [-] with an empty box to its right, and a three-option group with a long blank rail after the last cell. A width, not an alignment: the same controls sit in centred rows, and pushing them to the start would move them out from under their labels.

The two sidebars also keep their own long-standing type tokens — a 28px row and 13px names, larger than the app's 22px row — because the writer reads those two lists all day.

8Export a look

Export theme, beside the reset menu, writes the whole current customisation — both theme halves included — to .genesis/themes/<name>.json:

{
  "version": 1,
  "name": "Lighthouse",
  "savedAt": 1761000000000,
  "customisation": { /* the entire block, normalised */ }
}

Applying one is a replace, not a merge: the file holds every key it knows about, so it hands the project a complete look, and a token the file says nothing about falls back to the app's own value rather than standing on the applying project's old override.

The dialog is the one panel that is deliberately not un-themed: it is a look being named, so it wears the look. A theme is shared by copying one file; Delete asks first. The name is sanitised to a–z A–Z 0–9 _ - and spaces, which is also what makes it impossible for a saved look and a CSS theme folder to be called the same thing.

9The full block, as written

{
  "sidebarFont": null,               // ← §5, its own top-level field
  "customisation": {
    "theme": {
      "dark":  { "bg": "#131313", "accent": "#e8a838", "header-muted": "#8a8a8a" },
      "light": { /* … */ }        // ← both halves; the active one is editable
    },
    "themeName": "Genesis",          // ← "Genesis" | a saved look | "css:<id>"
    "layout": "os",                  // ← "os" | absent = default
    "layouts": {                      // ← the furniture, per arrangement (§11)
      "default": {
        "iconBoxes": false,          // true → boxes; false sets data-icon-boxes="off"
        "headerIcons": false,        // true → the frame's code is drawn as a mark
        "icons": { "navrail": 20, "app": 17, "header": 10, "sidebar": 20, "files": 18 },
        "menus": { "text": 13, "icon": 16 },
        "dock": { "float": true, "position": "bottom", "radius": null, "width": "fit", "align": "center" },
        "navrail": { "float": false, "radius": null, "width": "full", "align": "start" },
        "sidebar": { "float": false, "span": "full", "radius": null },
        "windows": { "note": { "border": null, "top": null, "text": null, "radius": null }, /* …one per kind */ }
      },
      "os": {
        "iconBoxes": true,           // ← an independent set of the same numbers
        "headerIcons": true,
        "icons": { /* … */ }, "menus": { /* … */ },
        "dock": { "float": false, "position": "left", "width": "full", "align": "start", /* … */ },
        "navrail": { /* … this desk's rail, if it ever stops being attached */ },
        "sidebar": { "float": true, "span": "fit", /* ← the layout's own seed */ },
        "windows": { /* … */ }
      }
    },
    /* the flat eight — windows · icons · menus · dock · navrail · sidebar · headerIcons
       · iconBoxes — are a MIRROR of layouts[layout]. They are written by the normaliser,
       never by a control, so a reader that asks customisation().icons gets the active
       layout's sizes and no call site had to learn that a split happened. */
    "fonts": { "app": null, "rail": null, "markdown": null },
    "characters": { "indent": 2, /* type sizes and weights */ },
    "windows": { /* the active layout's frames — see layouts above */ },
    "dock": { /* the active layout's dock — see layouts above */ },
    "navrail": { /* the active layout's rail — see layouts above */ },
    "sidebar": { /* the active layout's sidebars — see layouts above */ }
  }
}

🔴 The furniture travels with its layout; the look does not. layouts holds windows, icons, menus, dock, navrail, sidebar, headerIcons and iconBoxes — the eight things that describe how an arrangement is built. Everything else in the block (theme, fonts, characters, editor) is the project's and is shared by both. A project written before the split has no layouts at all, and both maps are seeded from its flat block — so a writer's numbers survive whichever layout they open next.

🔴 A change is written to the layout it was made in. setCustomisation computes furnitureTouched from the patch's own keys (FURNITURE_SECTIONS plus the two scalars) and copies the target layout's block before merging, so a 12px radius set in os cannot round the windows in default. A { layout } patch touches none of them, so it is a read that re-derives the mirror and invents nothing.

Two normalisers own this shape — one in src/lib/prefs.js and one in electron/vault.cjs — and they are deliberately twins. Both sides read the file, so a hand-edited ui-prefs.json cannot reach the renderer unvetted, and a renderer a version ahead cannot write a shape the main process would then misread.

10Porting a pre-theme project

Projects written before 2026-10-22 may still carry the old subordinate blocks. They still paint: the app normalises them and applies them under the theme half, so a token the active half claims wins, and a legacy value remains the painter only where the table has none. A port is a rename of those values into theme.dark / .light — which is what gives them the per-half answer the old blocks never had.

"customisation": {
  "chrome":  { "rail": { "bg", "text" }, "header": { "bg" },
               "footer": { "bg" }, "sidebar": { "bg", "text", "icon" },
               "scrollbar": { "thumb" }, "input": { "bg", "text", "border" } },
  "buttons": { "save": {…}, "delete": {…}, "normal": {…}, "special": {…} },
  "borders": { "window", "app", "appBorder" }
}
legacytheme token
chrome.rail.bg / .textrail-bg / rail-text
chrome.header.bgheader-bg
chrome.footer.bgfooter-bg
chrome.sidebar.bg / .text / .iconsidebar-bg / sidebar-text / sidebar-icon
chrome.scrollbar.thumbscrollbar-thumb
chrome.input.bg / .text / .borderinput-bg / input-text / input-border
buttons.save.bg / .textbtn-save-bg / btn-save-text
buttons.delete.bg / .textbtn-delete-bg / btn-delete-text
buttons.normal.bg / .text / .borderbtn-normal-bg / btn-normal-text / btn-normal-border
buttons.special.textbtn-special-text
borders.window / .appwin-border / app-border
borders.appBorder(read as-is — the off switch has no token)

Three header tokens are new with the merge and have no legacy key: header-muted, sidebar-header-icon and input-label — plus the Close pair.

11Layouts, and what is saved per layout

data-layout="os" turns the navrail into a dock of marks: the rail's three-letter codes become a dock's marks on the edge chosen in Customisation › Dock, the app's own header controls become marks instead of words, HELP moves up into the header and the footer strip is not drawn at all. The absence of the attribute is the default layout — codes in the rail, a footer, and words in the header.

os is also the half of the app that owns most of the radii: its windows default to 8px, its dock corners, its live square is a circle, and its menus are drawn with a 4px corner. default's windows are square until the writer chooses a number, and its menus stay square — but neither layout is welded to its furniture any more: the navrail, the dock and the sidebars each float, fit and round on their own, in either arrangement (§4). Nothing in the interface casts a shadow, and nothing animates except a drag and the three dots after SAVING — motion is what a press never gets, and the dots are the interface reporting work it has not finished.

Furniture is per layout; look is per project

A layout is not a skin over one arrangement — it is a second arrangement, and some numbers only mean anything inside one of them. So they are stored there (customisation.layouts.<layout>, §9):

per layout — layouts.<layout>shared — the project's
windows — each kind's header, border, title colour and radius
icons — all five sizes, each on the page of the thing it sizes
menus — row text and marks
dock — float · position · radius · width · icons position
navrail — float · radius · width · icons position
sidebar — float · length · radius
headerIcons · iconBoxes
theme — both halves
fonts — app · markdown · rail (the sidebar's face is its own top-level field)
characters — the card's type and indent
editor.autoSave · layout · themeName

🔴 A change is written to the layout it was made in. Leave os set to rounded windows with 24px dock marks, switch to default, and the windows there are still square with the rail's own sizes — because nothing about the first arrangement was a statement about the second. Colours and fonts do not move, for the mirror-image reason: a navy navrail is not a decision about furniture.

Switching layouts writes no furniture. The patch is a single { layout }, so the normaliser re-derives the flat mirror from the map the new layout already owns. That is what makes the switch a read rather than a merge that invents values for a layout nobody has configured.

Restyling the rail — the hooks a stylesheet gets

Both layouts draw the same element, and a stylesheet can restyle or replace it without knowing which one is on screen. Every entry carries two slots, and which one is visible is a variable rather than a branch in the markup — so a CSS theme can swap them for one entry, for one layout, or for the whole app.

hookwhat it is
.crea-rail-entryone rail cell, in both layouts. data-rail-code is the entry's code (FLS, CHR, …), data-rail-live marks the one whose sidebar is open, and data-rail-kind is mark or code — the latter on the two entries that have no glyph, so the dock does not draw empty squares
.crea-rail-codethe three-letter code. Shown in default, hidden in os
.crea-rail-markthe glyph. Hidden in default, shown in os. Its SVG takes --icon-rail, so Dock icons reaches a mark a theme introduced as well as the app's own
--rail-code-display · --rail-mark-displaygrid / none per slot. Declared at :root and flipped by html[data-layout="os"], which is exactly why a theme can flip them back — layer 2 is above the base stylesheet

A theme that wants marks in default needs two declarations and nothing else:

--rail-code-display: none;
--rail-mark-display: grid;

Naming an entry is the same idea one step down. data-rail-code is stable across both layouts and both themes, because it is the control's identity rather than its drawing — but a rule that re-points --icon-rail on one entry stops listening to the writer's own picker for that entry. "Dock icons resizes all icons except gallery icons" was exactly this: a theme had pinned GAL to a fixed size. Scale from the token instead, and the setting still reaches it:

.crea-rail-entry[data-rail-code="GAL"] .crea-rail-mark > svg {
  width: calc(var(--icon-rail, 20px) * 1.2);
  height: calc(var(--icon-rail, 20px) * 1.2);
}

Scope a size to an entry's mark rather than to :root. The app writes --icon-rail inline on <html> from Dock icons, and inline wins over any stylesheet — so a global value would silently overrule the writer's own picker, while a nearer one is a deliberate exception.

🔴 This is the one part of the rail that is a contract. The classes and the three data attributes are what a stylesheet is allowed to rely on; everything inside an entry besides the two slots is the app's own and may change. Tokens are still preferable to either (§15) — but a dock you can redesign is the point of a dock that is drawn from CSS rather than from a component's opinion.

The rail's length is a setting now, not its text. Navrail › Navrail width answers Full height (the default — the whole left edge), Fit (a strip around its own contents) or a px value, written as --navrail-span. Its width across the edge is still its content's: the codes are the rail's content, so a rail font whose codes run wider never scrolls horizontally. A theme that hides the codes takes the marks' own width instead.

Restyling the dock — the bar, its entries, the sidebars

The five Dock shape settings say whether the bar floats, where it is and how big it is (a sixth sizes its marks). Everything else about it is CSS, and it is published on purpose: a taskbar, a launcher, a shelf and a floating pill are four different objects with one shared skeleton, and a setting for each of them would be a settings pane nobody could finish. So the markup is a fixed set of hooks, the placement is keyed by an attribute rather than written into a Tailwind class, and a stylesheet is allowed to take it from there.

hookwhat it is
.crea-dock-slotthe box the bar is placed in, inside the desk's canvas. Its padding is --dock-gap — the inset a floating dock holds off the edge, taken to zero by html[data-dock-float="off"] — and it is pointer-events: none with auto on the bar, so the gap around a pill still belongs to the desk. Placement for all four edges lives here, keyed by html[data-dock-position]
nav.crea-os-dockthe bar. background: var(--rail-bg), border: 1px solid var(--app-line), border-radius: var(--dock-radius, 10px) and overflow: hidden (which is what clips a fill to the writer's radius). The 10px fallback belongs to a floating bar; html[data-dock-float="off"] takes the fallback to 0 and an explicit radius always wins either way. It turns with the edge: a left/right dock is a column
.crea-os-dock-bodythe entries' row or column, and the part that scrolls when a full-width bar has more entries than fit — with justify-content: safe center, so the first entry never becomes unreachable. It is also where html[data-dock-align] (start · center · end) places the entries along a full-width bar's own axis; a pill sized to its entries has no spare length, so the attribute does nothing there
.crea-os-dock-entryone cell. It carries the same data-rail-* attributes as a rail entry (above) and its box is --dock-cell, which grows with --icon-rail — so a bigger mark makes a bigger cell and a deeper bar, and nothing can clip a glyph. 🔴 .is-live means this entry is on — an open window, or an open sidebar on the two toggles — and the toggles alone also carry aria-pressed="true", so "the sidebar is showing" has a selector of its own
.crea-os-dock-dotthe live marker. Its side follows the screen edge the bar is pinned to — below the mark on a bottom dock, above it on a top one, on the left of a left dock and on the right of a right dock. A mark that moved inward would be describing the canvas rather than the bar it belongs to
.crea-navrail · .crea-navrail-bodythe default layout's column. Same ideas, one third of the width — and only since 2026-10-27 does it have a body of its own: its width used to be an inline style, so no rule could reach it. It is keyed by html[data-navrail-float], html[data-navrail-fill] (full · fit · custom) and html[data-navrail-align], sized by --navrail-span, inset by --navrail-gap and cornered by --navrail-radius. 🔴 It floats in flow (a margin and a full frame), never absolutely — the rail is the shell's first flex child, so an absolute one would be painted over the desk instead of pushing it across
.crea-shell-drawerthe framed box each sidebar is pulled into, on either side of the desk. The panel's inset comes from --sidebar-gap, which is only read while html[data-sidebar-float="on"]; the offset that clears a dock reads in both states, because an attached panel can bury the bar just as easily as a floating one. html[data-sidebar-span="fit"] frees the bottom (bottom: auto; height: auto) so the panel is a card as tall as its list, with a per-float max-height so a long list scrolls instead of running off the desk
.crea-dock-aside + data-dock-side="left" | "right"the sidebar panel itself — Files and Characters on the left, Help on the right. This is the hook for a translucent or blurred sidebar, and the attribute is how a rule tells the two sides apart
.crea-resize-handle + data-sidethe 7px divider each panel is dragged by. Its data-side is left or right, and the handle is inset to match a floating panel, so the grab strip sits on the paper rather than in the gap beside it
--dock-gap · --navrail-gap · --sidebar-gap8px each at :root. The inset a floating dock, navrail or sidebar holds; html[data-dock-float="off"] takes the dock's to zero, because a bar welded to its edge has no gap to hold. 🔴 These three are the only layout gaps a theme may set globally — the app never writes them inline, so there is no picker for them to win over
--dock-cell · --dock-depththe cell's box and the bar's depth across its edge. Both derived from --icon-rail: there is no Dock length setting any more, so a bigger mark makes a deeper bar. A theme may re-point --dock-depth at nav.crea-os-dock (a taskbar wants to be shallower than its cells), never at :root
--dock-radius · --dock-span · --navrail-radius · --navrail-span · --sidebar-radiuswritten inline on <html> from the Dock, Navrail and Sidebar settings — so a theme must scope them to the bar or the panel, never set them at :root (the same rule as --icon-rail). A value the writer has not chosen is removed rather than written as 0px, so the stylesheet's fallback is what answers "not chosen"
html[data-dock-float] · data-dock-position · data-dock-fill · data-dock-align · data-navrail-float · data-navrail-fill · data-navrail-align · data-sidebar-float · data-sidebar-spanthe nine states a rule can key on. 🔴 data-dock-float is the one that decides a gap or a radius default — data-dock-fill="edge" now means only "runs the whole edge", because it used to mean that and zero gap and a square corner, which is why choosing Full width silently overruled a radius. All nine are written by one function, so the markup, the settings and the stylesheet cannot disagree about them
--dock-edge · --dock-edge-righta left dock's width and a right one's, so a sidebar is pushed clear of the bar instead of being drawn under it. Each is 0 for every other edge and scoped to html[data-layout="os"], so default — which never draws a dock — cannot inherit a phantom gap. 🔴 They are declared unlayered: :root's --dock-edge: 0px is unlayered too, and unlayered CSS beats every @layer whatever its specificity — so a copy inside @layer components lost, and a left dock was covered by whichever sidebar was open, with nothing on screen to say why
🔴 There is no --dock-entry-cap any more. It existed to squeeze a cell into a bar whose depth the writer had pinned shorter than its entries; with the depth derived from the cell, the two cannot disagree, so the cap had nothing left to do and was deleted rather than left as a number that only mattered if something went wrong.

A full-width taskbar

Set Dock width → Full width and untick Float the dock — a full-width bar can float (a shelf with a gap), but a taskbar is welded to its edge, and the two settings are separate questions. Then set Dock icons position — that is the one mode where the bar has spare length to distribute, and html[data-dock-align] is what a theme can lean on for the rest. Give it a taskbar's body — a raised strip rather than a floating pill:

/* a taskbar, whether or not the writer asked for full width */
nav.crea-os-dock {
  background: linear-gradient(to bottom, #3b6ea5 0%, #245edb 8%, #1f4fbf 92%, #16357f 100%);
  border: 1px solid #0f2c66;
  border-radius: 0;
  box-shadow: inset 0 1px 0 #6f9fd8;
}
nav.crea-os-dock .crea-os-dock-entry {
  border-radius: 2px;
}
nav.crea-os-dock .crea-os-dock-entry:hover {
  background: rgba(255 255 255 / 0.28);
}
nav.crea-os-dock .crea-os-dock-entry.is-live {
  background: rgba(0 0 0 / 0.28);
  box-shadow: inset 1px 1px 0 rgba(0 0 0 / 0.5);
}
Where the icons sit is a setting, and a theme can still overrule it. Dock icons position is written as html[data-dock-align] and read by .crea-os-dock-body; a theme that wants, say, a Start button's worth of lead-in on every taskbar writes padding-inline-start or its own justify-content there and the attribute stops deciding. That is the point of the split: the app owns a control the writer can find, and CSS owns everything the control does not reach.

A left launcher, the way Ubuntu draws one

Dock position → Left, then take the bar's own frame away and let the marks sit on a dark rail with a coloured live border:

html[data-dock-position="left"] nav.crea-os-dock {
  background: #2c001e;
  border: 0;
  border-radius: 0;
  padding: 4px 2px;
}
html[data-dock-position="left"] .crea-os-dock-entry {
  border-radius: 8px;
}
html[data-dock-position="left"] .crea-os-dock-entry.is-live {
  background: rgba(255 255 255 / 0.12);
}
/* the launcher's running marker: a bar on the *inner* edge, not a dot */
html[data-dock-position="left"] .crea-os-dock-dot {
  border-radius: 0;
  width: 3px;
  height: 28px;
}

A macOS shelf — translucent, blurred, and a little round

Nothing in the app casts a shadow by default; a shelf is the one object that earns one, and a theme is exactly where that argument belongs. backdrop-filter needs no content policy change — it reads the pixels behind the element, it does not fetch anything.

nav.crea-os-dock {
  background: color-mix(in srgb, var(--rail-bg) 55%, transparent);
  backdrop-filter: blur(18px) saturate(160%);
  border: 1px solid rgba(255 255 255 / 0.18);
  border-radius: 18px;
  box-shadow: 0 10px 30px rgba(0 0 0 / 0.35);
}
nav.crea-os-dock .crea-os-dock-entry {
  border-radius: 12px;
  transition: transform 120ms ease-out;
}
nav.crea-os-dock .crea-os-dock-entry:hover {
  transform: translateY(-2px);
}
A transition is a theme's business now. The app's own rule is that nothing animates but a drag and the save dots — the interface answering a press has to be instant. A look may disagree: a shelf that grows under the pointer is the idiom it is quoting. Keep it short and respect prefers-reduced-motion if it moves.

Blurred, transparent sidebars

The two drawers are siblings of the desk, not of any window, so a translucent sidebar shows the canvas through it — and the same treatment on both sides is one rule, because the hook is shared and the side is an attribute:

.crea-dock-aside {
  background: color-mix(in srgb, var(--sidebar-bg) 72%, transparent);
  backdrop-filter: blur(14px);
}
/* the right drawer reads a shade cooler than the two on the left */
.crea-dock-aside[data-dock-side="right"] {
  background: color-mix(in srgb, var(--sidebar-bg) 60%, #0b1220);
}

🔴 Two things a theme must not do here. It must not set --dock-radius, --dock-span, --dock-depth, --navrail-radius, --navrail-span, --sidebar-radius or --icon-rail at :root — the app writes those inline from the writer's own pickers, and a stylesheet that wins over them makes a live control do nothing. And it must not remove overflow: hidden from the bar: that property is what clips a fill to the radius the writer chose. Scope to nav.crea-os-dock, .crea-navrail, .crea-dock-aside or an entry's data-rail-code, and both stay true.

A theme that wants its paper to look floating specifically — a card on the desk rather than a panel fixed to the wall — says so with the attribute, so an attached sidebar keeps its own treatment:

/* only while the writer has them floating (Sidebar › Float the sidebars) */
html[data-sidebar-float="on"] .crea-dock-aside {
  background: var(--sidebar-bg);
  box-shadow: 0 2px 0 color-mix(in srgb, var(--border) 45%, transparent);
}

🔴 Do not paint the gap yourself. A floating panel's inset is --sidebar-gap; on the left the app adds --dock-edge and on the right --dock-edge-right, in either float state, so a sidebar clears a dock parked on the edge it is drawn from. Every one of those is read by unlayered rules in the app's own stylesheet; a theme that writes inset or left on the drawer overrules the dock's own width and the two objects land on each other — the one thing the geometry exists to prevent.

A floating navrail, in the other layout

The default layout's rail is the same furniture as the dock, so it takes the same four answers — Customisation › Navrail — with the opposite defaults: attached, full height, start. Its body is .crea-navrail, and a theme reaches it by attribute rather than by overriding the writer's numbers:

/* only while the writer has the navrail floating (Navrail › Float the navrail) */
html[data-navrail-float="on"] .crea-navrail {
  background: color-mix(in srgb, var(--rail-bg) 88%, transparent);
  backdrop-filter: blur(12px);
}

/* a fitted strip is as long as its codes; a full-height one has length to place them in */
html[data-navrail-fill="fit"] .crea-navrail {
  align-self: start;
}
html[data-navrail-align="end"] .crea-navrail-body {
  justify-content: end;
}

🔴 The rail keeps its box in the flow. A floating navrail is a margin and a frame, not an absolutely positioned overlay: the rail is the shell's first flex child, so taking it out of the flow would paint it over the desk instead of pushing the desk across. A theme that makes it position: absolute will bury whatever window sits at the left edge — and it will look correct in a project with no windows open.

12Icon packs

Customisation › Icon packs. A pack is a zip bundle installed from a URL to .genesis/icons/.packs/<id>.zip, with a central-directory index beside it (<id>.json). Downloading that zip is the only network request the app makes outside its own media scheme, it is a plain GET, and it goes to a file: nothing is installed or executed at render time.

An icon the writer chooses is extracted once to .genesis/icons/<pack>/, and its whole entry path is kept in the filename (outline--book.svg) because packs routinely ship an outline and a filled copy of the same name. Removing a pack deletes the bundle and keeps the picks — a zip is cache, the picked files are the writer's, and deleting them would strip the glyph from every row wearing one.

Lucide is built in and is not a pack: it has nothing to download and cannot be removed. A pack's preview SVG is sanitised twice — once on the way in, once before it reaches the DOM — because an icon is markup and a pack is a file from the internet.

13CSS themes — a stylesheet you author

The theme table edits values Crea knows about. A CSS theme is the other half of that idea: a stylesheet, written by hand, that re-points the app's tokens. One folder per theme, one file inside it.

.genesis/themes/retroma/theme.css
/* @name Retroma */
:root,
[data-theme="dark"] {
  --bg: #241c22;
  --surface: #32272f;
  --surface-elevated: #402f3b;
  --border: #54404d;
  --text-primary: #f2e2ea;
  --accent: #f08bb5;
}

[data-theme="light"] {
  --bg: #f0dae5;   /* the same theme answers both halves */
  --surface: #fdfafc;
  --text-primary: #3a2a31;
  --accent: #d55c8c;
}
A theme is one file, and its name is a comment. @name Retroma anywhere in the first 4 KB is what the picker calls it; the folder id is the fallback, and a theme that says nothing lists under its folder. There is no manifest.json beside it — a theme is one stylesheet, and a second file is a second thing to keep in step.

A theme folder id may only use a–z, A–Z, 0–9, space, _ and -. Anything else is not listed, because the id is used as a folder name and a name is a promise. A folder with no theme.css is skipped rather than listed as a broken entry: the list is what can be applied.

Installing one

  1. Settings › Project › Theme, then Open CSS themes folder.
  2. Drop the theme's folder in — <id>/theme.css, one level down.
  3. Press Rescan. It appears under CSS themes in the same dropdown as Genesis and your saved looks.
  4. Pick it. To go back, pick Genesis — deleting the folder is not required.

🔴 Nothing in the interface prints that path except this button. .genesis is hidden from the file tree, refused by the generic filesystem bridge, and .genesis/themes/ is dot-prefixed inside a dot-prefixed folder — so without one door the writer would have no way to install a theme at all. The button creates the folder if it is missing, so the first press opens an empty one rather than failing.

14The three layers

Every colour in Crea resolves through three layers, lowest first. Each is a place a value can come from, and knowing the order is the whole of authoring one.

  1. Base tokens — src/index.css, :root and [data-theme="dark|light"]. What an untouched project wears.
  2. Your theme's CSS — the file above, injected verbatim as one <style id="crea-theme-css"> element.
  3. The writer's colour pickers — the --project-* variables the Theme table writes inline on <html>.

The utilities are what make this work without any component knowing: every Tailwind colour is mapped to var(--project-*, var(--token)) in one @theme inline block, so bg-surface reads all three layers and cannot be pointed anywhere else.

A CSS theme keeps the writer's pickers; a saved look replaces them. A stylesheet is a layer under the customisation, so wiping the customisation would mean that choosing a theme deleted a colour the writer had chosen for this project. So applying a CSS theme is a merge (it stores css:<id> and injects); applying a saved look is a replace, because a saved look is a customisation.
Where the injected element goes is load-bearing. Stylesheets are ordered by tree order, and :root { --bg: … } in your theme ties the base :root, [data-theme="dark"] rule on specificity — one class-level selector each — so source order is the only thing deciding it. The development server re-injects its own <style> elements into <head> on every hot update, so a theme appended there would be quietly overtaken the next time a file was saved. It lives in <body>, is re-appended on every apply, and its text is written as textContent — never HTML, which would parse a </style> in a comment as the end of the element.

15What a theme may say

The authoring surface is the nine palette tokens from §2:

--bg  --surface  --surface-elevated  --border
--text-primary  --text-secondary
--accent  --electric  --danger

Everything else in the interface is a projection whose default already points at one of those nine:

chrome tokenits default
rail-bg · sidebar-bg · footer-bg · input-bgsurface
header-bgbg
border · scrollbar-thumb · win-border · app-borderborder
rail-text · sidebar-icon · input-labeltext-secondary
sidebar-text · input-texttext-primary
btn-save-bgelectric
btn-delete-bgdanger
btn-special-textaccent

So re-pointing the nine recolours the whole application — rail, header, footer, both sidebars, every field, every button, the scroll bar and every window frame — and it keeps doing so as the interface grows, because no utility name is involved.

A theme may also re-point a chrome name directly (--rail-bg, --btn-save-bg, …) when it wants, say, a rail that is not its --surface. Read the warning below first.

Naming a chrome token takes it away from the picker. The app's own stylesheet already declares --rail-bg as var(--project-rail-bg, var(--surface)). A theme that writes --rail-bg ties that declaration and wins on source order, which means it replaces the projection — so the Theme table's Navrail row would then write a value and paint nothing. The nine palette tokens do not have this problem, because they are the bottom of the chain. Prefer the nine; name a chrome token only when the theme genuinely needs it.

A theme may style anything else too — spacing, borders, type, states, even ::selection. Tokens are what survive a change to the interface; a rule aimed at a class is a rule that stops matching the day the class is renamed — with one deliberate exception: the rail's and the dock's hooks are published for this. .crea-rail-entry, the two slots, data-rail-code / data-rail-kind / data-rail-live and the --rail-*-display pair are a contract you may build on — and so are nav.crea-os-dock, .crea-os-dock-body, .crea-os-dock-entry, .crea-os-dock-dot, .crea-dock-slot, .crea-dock-aside with data-dock-side, and the --dock-* names — see §11 and §11.1. That is what makes a dock you can redesign rather than a dock you can only recolour: a taskbar, a launcher and a shelf are one skeleton under three stylesheets.

Both halves, in full

Declare every token you mean in each half. :root matches a light document as well, and :root and [data-theme="light"] tie on specificity, so a token set only in the dark block would leak into the light one.

base tokenno --project-* counterpart, so a theme may always set it
--on-electricthe ink on a primary button — set it whenever --electric is light enough to need dark text
--success / --warningstatus colours, read straight through the theme map

16Rescan, reset, limits

Reset means "no override"

A CSS theme has no customisation block to be a reset baseline, and it does not need one. Each Reset to default in Customisation writes null, which removes the --project-* override — and the value underneath is your theme's colour. "Reset to this theme's value" and "stop overriding" are the same act, so the reset machinery had to learn nothing about CSS themes.

When a theme is read

A theme is read when it is picked, when the project opens, and when you press Rescan — not while you type. A half-written stylesheet repainting the interface on every save would show a look that never existed. Rescan re-lists the folder and re-reads the theme currently on screen, which is the whole authoring loop: edit theme.css, press it, look. It is also the only way to re-read a file that was deleted and restored behind the project's back.

Switching projects drops the stylesheet first

A window outlives the realm it is showing — that is what makes switching projects instant — so the injected element is removed before the new realm's preferences are read. Left behind, one project's theme would paint the next one until the read resolved, and for ever if it failed.

A missing theme is an error, not an empty stylesheet

If the folder is gone, reading it fails rather than answering "" — an empty stylesheet is indistinguishable from a theme that changes nothing, so the writer would see the previous theme still on screen with no reason why. The app removes the element and keeps its own look instead.

What is not permitted

A theme is not sanitised — it is a local file installed on purpose, and CSS is the point. What constrains it is the document's own policy: style-src 'self' 'unsafe-inline' with no remote origin, and img-src / font-src limited to self, data:, blob: and crea-media:. So a theme may be as opinionated as it likes about colour, spacing and type, and an @import from the internet, a remote url() or a web-font fetch simply will not load.

17Worked example: Retroma

A complete theme ships beside this guide, in the Crea folder itself — not inside a realm, so nothing is applied until you copy it in:

Crea/themes/retroma/theme.css

Copy the retroma/ folder into <realm>/.genesis/themes/ and pick Retroma under CSS themes. It is a pastel-retro look after the Retroma theme for Obsidian: a pink gingham desk, near-white paper panels with a mauve hairline, deep maroon ink, and buttons that look like they can be pressed. Daylight is bubblegum and cream; night is plum and rose.

It names the nine palette tokens plus --on-electric, --success and --warning — the tokens with no --project-* counterpart — and it names no projection at all, so every picker in Settings keeps working on top of it. The two exceptions to that caution are deliberate and safe: --dock-gap and --sidebar-gap at :root, which the app never writes inline, so there is no picker for them to win over (§11.1).

tokennight (dark)day (light)
bg#241c22 · deep plum#f0dae5 · pink gingham
surface#32272f · plum, lifted#fdfafc · near-white paper
surface-elevated#402f3b · a mauve strip#f6cfe0 · the pink title bar
border#54404d#c9a4b6 · mauve, never grey
text-primary#f2e2ea · warm white#3a2a31 · deep maroon
text-secondary#bda2b1#7c6470
accent#f08bb5 · rose#d55c8c · rose
electric#8f97e8 · periwinkle#6a72c9 · periwinkle
danger#ff6b83#c23a52

Two things in it are worth copying as habits. Its --on-electric is dark in the night half, because #8f97e8 is light enough that white text on it fails contrast — the button group is a pair, and a theme that sets a fill without its ink has a button nobody can read. And it paints ::selection from --accent, so selecting text is rose in both halves instead of the system's blue.

A third habit is the desk texture, and it is the one rule in the file that is shape rather than colour. .crea-window already carries bg-bg, so a background-image there is drawn over the token rather than replacing it: three radial-gradients — a 1.5px dot every 16px, a second dot offset half a cell, and one soft star every 180px at a few percent — make the gingham and its confetti with no image file and no request. The colour the writer picked still shows underneath, and the bg row in the theme table still moves. A backdrop-filter would have been the wrong channel here: the desk is the furthest thing back and there is nothing behind it to blur.

The rail and the dock are treated as surfaces. The codes are letterpressed (letter-spacing plus a 1px highlight text-shadow), the live entry wears the rose, the marks are drawn at stroke-width: 1.75 because two-pixel strokes read as bled ink on pale paper, and one entry is scaled from --icon-rail with a calc() multiple rather than pinned to a literal, so Dock icons still reaches it. It deliberately leaves the sizes on :root alone — those belong to the writer's pickers — and the commented-out display swap at the end of the rail section is the invitation: uncomment --rail-code-display / --rail-mark-display and the navrail becomes a dock in both layouts. That is §11's contract doing its job.

The dock is a shelf of pressable chips: a pale pink tray with an inset top highlight and an inset bottom shade, which is what makes it read as pressed tin rather than as a rectangle; a 4px corner on each entry; and a live entry held down — rose text on a rose wash with the highlight moved to the bottom edge. Its marker is a square pip, because a round dot beside letterpressed type would be the one shape in the theme that came from somewhere else. It is all §11.1's hooks, and it touches none of the writer's position, radius, float, width or icons position.

The floating sidebars get the paper, the app gets the geometry. The panels float in os unless the writer attached them, so the theme's sidebar rules are keyed on html[data-sidebar-float="on"]: a card with a soft inner rule and a shade along its bottom, and the heading strip painted in --surface-elevated — the pink title bar Retroma draws on every box. It never writes inset or left, because the inset is --sidebar-gap and the app adds --dock-edge on the left and --dock-edge-right on the right, so a panel clears a dock on either edge whether it floats or is attached.

Two further desks are written out at the end of the file and commented out — a Windows-style taskbar (set Dock width → Full width, untick Float the dock, then Dock icons position) and an Ubuntu-style left launcher — because a published contract is only worth publishing if the file that uses it shows how far it goes.

18Folder colours are not a theme

.genesis/folder-colors.json is { palette, folders }: a named palette the writer assigns to individual files and folders from Change color. That is content — "this folder is the red one" — and it is deliberately separate from the theme.

The separation is visible in the controls: a folder colour is chosen from a grid of named swatches, while every colour in the Theme table is a wheel with a hue/saturation disc, a value slider and a hex field. Interface customisation is not a palette pick, and a project with three folder colours must not be able to paint only three of its own tokens.

Absence is the flag: a folder with no entry existed before Crea and renders grey; a folder Crea created has an entry and renders white until recoloured. Removing a palette entry leaves the folders that used it falling back to grey, rather than uncolouring them.

19Files and channels

The renderer has no filesystem access. Everything here goes through the preload bridge, and .genesis is refused by the generic file channels on purpose — each kind of state has its own, so a malformed path cannot wander into one and a bad write cannot corrupt another.

filechannelwhat it is
ui-prefs.jsonprefs:read / prefs:writethe whole customisation block
themes/<name>.jsonthemes:list|read|write|deletesaved looks; hidden from the file tree
themes/<id>/themes:css:list|read|revealCSS themes; reveal opens the folder
fonts/fonts:list|read|revealfont files; read returns bytes for a blob: URL
folder-colors.jsoncolours:read|writethe palette and its assignments
icons.jsonicons:read|writewhich glyph a path wears
screens.jsonscreens:read|writethe desk's layout, per screen

Every JSON write is atomic: Crea writes a sibling temporary file and renames it over the target. A plain write truncates first and writes second, so a process that dies between the two leaves a zero-byte file — and an empty layout does not complain, it silently throws the writer's desk away and restores blank screens.

🔴 .genesis is not live-watched, on purpose. The app rewrites its own layout inside it on every change, so treating those events as the writer's work would be pure churn — and patching a file tree for a preference makes a sidebar redraw for a reason nobody can see. What follows is that a font or theme dropped in from a file manager appears the next time the picker opens (fonts) or when you press Rescan (themes), rather than instantly. Both lists are re-read at exactly those moments for this reason.


Crea · local-first · no server, no database, no network