| version | alpha | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| name | outl | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| description | Local-first outliner. One Rust-owned palette, three clients — terminal, desktop, mobile. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| colors |
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| typography |
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| rounded |
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| spacing |
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| components |
|
outl is a local-first outliner: markdown files on disk, a tree CRDT underneath, and three clients that paint the same graph — a terminal TUI, a Tauri desktop app, and a Tauri mobile app.
The visual identity is deep-purple canvas, lavender accent, lemon signal.
Dark by default (outl), with a purple-tinted light counterpart (outl-light).
The accent violet #a78bfa is the brand; the lemon #d6ff47 is deliberately a different hue
so code, success and DONE never vanish against a selection painted in the accent.
The one rule that outranks every aesthetic preference:
outl_theme::Paletteis the single owner of every colour on every client. RootCLAUDE.mdinvariant 13.
A colour is not a hex you type into a stylesheet.
It is a field on a Rust struct (crates/outl-theme/src/palette.rs) that every preset must fill,
that the compiler enforces, that rides the Tauri wire as JSON, and that becomes a CSS custom property
named --color-outl-<kebab(field)> at runtime.
Fields are named for what the surface is, never what it looks like.
ref_link_fg means "the foreground of a [[ref]]" on all three clients — the TUI underlines it,
the desktop paints it as an underlined link with a 40 %-opacity decoration, mobile renders the same
token as a filled chip. The hue is one fact; the treatment is per-client.
This file owns what it looks like.
Its counterpart UX.md owns what happens — who the user is, what they are in the middle of,
how an interaction behaves, and what we say when a client cannot deliver it.
An affordance has both halves; when they meet, the visual half is specified here and the behavioural half
links back to it.
Design reasoning that this file does not own:
UX.md— user and world models, interaction patterns, voice, the domain glossary.docs/theming.md— how a user picks a theme, all ten presets, per-client consumption.docs/rfcs/0022-unified-design-tokens.md— why one namespace, rejected alternatives.docs/shortcuts.md— every chord.docs/client-parity.md— generated per-client verdict per action.
crates/outl-theme/src/palette.rs Palette { accent: String, … } ← the only definition
crates/outl-theme/src/presets.rs pub fn outl() -> Palette ← ten presets fill every field
↓ serde JSON over the Tauri wire (get_theme)
@outl/shared/theme::applyPaletteToRoot --color-outl-accent: #a78bfa ← the only writer
↓
BlockRow.tsx class="bg-(--color-outl-accent)" ← Tailwind v4 shorthand
The TUI skips the CSS half entirely: theme_from_palette (crates/outl-tui/src/theme.rs) turns each
#rrggbb into ratatui::Color::Rgb(r, g, b) and re-applies a fixed modifier formula — hard-coded once,
never per-preset, so only the hues vary between themes:
UNDERLINEDon the three link roles (ref_link,tag_link,md_link) — the only "clickable" things in pretty-render mode, and the underline is the affordance — plus the Insert-mode caret, which reaches it throughTheme::cursor_caret_on_charrather than a palette field. The caret is the one modifier applied outsidetheme_from_palette, and it still lives intheme.rsso the formula keeps one owner. It underlines because it paints the character it sits before instead of printing a glyph of its own (#320), and a foreground colour paints nothing on a space. The two roles never collide on the same cell: a caret on a link char also swaps the hue tocursor_caret_fgand addsBOLD.CROSSED_OUTonstrikeandtodo_done_body.ITALIConitalicalone.BOLDon emphasis, cursors and every reverse-video chip:bold,selected_bullet,cursor_block,cursor_caret,todo_open,todo_done,heading,status_normal/_insert/_visual,help_title,list_selected.
A malformed hex degrades to Color::Reset rather than panicking, so a bad config never blocks boot —
every_palette_field_is_hex is what catches the typo before it gets that far.
applyPaletteToRoot (crates/outl-frontend-shared/src/theme/palette.ts) walks Object.entries(palette)
rather than naming fields, so a new Palette field propagates to both GUI clients with no extra wiring:
for (const [field, value] of Object.entries(palette)) {
if (field === "name") continue;
set(`--color-outl-${kebab(field)}`, value);
}Every role below is a real field. Grep it in crates/outl-theme/src/palette.rs.
Hex values shown are the outl (brand, dark) preset.
Canvas — the reading surface.
| Field | Hex | What it means |
|---|---|---|
bg |
#0c0814 |
The main canvas. In the two ANSI TUI presets this is Color::Reset instead, so the terminal's own background shows through. |
bg_elev |
#15101f |
Anything that floats: popovers, modals, sheets, the picker, toasts. The only elevation colour — there is no second step. |
fg |
#f4f1fa |
Body text. Also the source of every translucent chrome layer (bg-(--color-outl-fg)/10), so chrome adapts to light and dark presets without a second hue. |
fg_dim |
#b4adc7 |
Secondary metadata — timestamps, counts, breadcrumb tails. |
fg_dimmer |
#7b7390 |
Placeholders, separators, struck-through DONE bodies, property keys. |
border |
#382c54 |
Panel and popover borders, indent guides, the skeleton shimmer's base. |
hint |
#b4adc7 |
Footer hint text. Aliases fg_dim in the brand presets, but stays a separate field so a preset can pull them apart. |
Accent rail — the four hues that carry meaning.
| Field | Hex | What it means |
|---|---|---|
accent |
#a78bfa |
Primary. Selection, active state, the ref-link hue, the caret. |
accent_soft |
#c4b5fd |
Lighter accent: caret, property values, italic, help titles. |
accent_alt |
#d6ff47 |
Secondary hue, deliberately not a shade of accent. Code and DONE live here so they never disappear against an accent-painted selection. |
warn |
#fbbf24 |
"Look at this" — open TODOs, transient status messages, the highlighter fill. |
destructive |
#fb7185 |
"This cannot be undone" — delete confirmations, remove-peer, error toasts. Added by RFC 0022; before it the TUI and the desktop each picked an ad-hoc red and mobile had a token no other client could see. |
warn and destructive are separate on purpose. Collapsing them is how a "3 blocks archived" toast
ends up the same colour as "delete this page permanently".
Inline markdown — ref_link_fg, tag_link_fg, md_link_fg, bold_fg, italic_fg, strike_fg,
highlight_bg / highlight_fg, code_fg.
TODO prefixes — todo_open_fg, todo_done_fg, todo_done_body_fg.
Structural — property_key_fg, property_value_fg, heading_fg, dim_fg.
Selection / cursor — selected_bullet_bg / _fg, cursor_block_bg / _fg, cursor_caret_fg.
Chrome — status_normal_bg / _fg, status_insert_bg / _fg, status_visual_bg / _fg,
status_message_fg, list_selected_bg / _fg, help_title_fg.
Two pairings worth stating because a preset author will get them wrong:
highlight_bg/highlight_fgmust read against each other, not againstbg.outl-lightproves the point: it keeps a bright#fde68afill on a light canvas, with the comment "a real highlighter pen doesn't get darker on light paper" — it does not mirroroutl()'swarn-on-bgpairing.- Every
status_*_bgis paired with astatus_*_fgequal tobg, so mode badges are reverse-video chips. Insert mode is lemon, Normal is violet, Visual is magenta — three hues a user reads at a glance without reading the word.
Before RFC 0022 the desktop wrote two namespaces:
set("--color-ios-bg", palette.bg);
set("--color-iosd-bg", palette.bg_elev); // desktop: "iosd" means ELEVATEDMobile's stylesheet used the same prefix for something else:
--color-ios-bg: #f6f4fb; /* light */
--color-iosd-bg: #0c0814; /* mobile: "iosd" means DARK */MarkdownInline.tsx — one shared component — read both namespaces (18 ios-, 17 iosd-) and reached
the iosd set through Tailwind's dark: variant, which resolves off prefers-color-scheme.
So on the desktop, the operating system appearance setting changed the elevation of markdown blocks, with no relationship to the theme the user picked. One token name, two meanings, and the wrong one selected by a signal that had no business voting.
The guard: no_client_references_the_legacy_ios_namespace (crates/outl-theme/tests/tokens.rs).
It checks functional use, not mention — var(--color-ios…), Tailwind's (--color-ios…),
setProperty("--color-ios…, and a --color-ios…: declaration — because a test that fails on a clean
tree gets deleted by the first person who hits it, taking the real guard with it.
The desktop's @theme block declared --color-outl-ref-link where the Palette field is ref_link_fg.
What that did not cost: the utility still resolved. Tailwind v4's text-(--var) shorthand reads a
CSS custom property at paint time whether or not @theme declares it — --color-outl-status-normal-bg,
--color-outl-help-title-fg and --color-outl-status-insert-bg are absent from @theme today and
render correctly in the built bundle.
What it actually cost: the first painted frame. Before applyPaletteToRoot() runs over the wire,
@theme is the only source of a value. Refs, tags, markdown links, inline code and TODO markers
rendered with that one property unset for one frame, then repainted correctly.
The guard is the_theme_tokens_match_the_palette (same file), which reverse-maps every
--color-outl-* name in each client's @theme block to a Palette field and fails on a name with no owner.
It caught this on its first run.
One family, system-native, declared once as --font-sans in each client's @theme block and
identical in both:
--font-sans:
-apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display",
system-ui, "Helvetica Neue", Helvetica, Arial, sans-serif;No web fonts. Nothing is fetched at boot, so there is no FOUT and no network dependency in an app whose whole premise is local-first.
Mobile sets the base in crates/outl-mobile/src/styles.css:
body {
font-family: var(--font-sans);
font-size: 17px; /* iOS body size, not 16 */
line-height: 1.4;
font-synthesis: none; /* never fake a weight the family doesn't have */
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}font-synthesis: none is load-bearing: MarkdownInline renders **bold** as font-semibold (600),
and a synthesised 600 on a family that has a real one looks smeared next to the real one on the next line.
The scale, as it actually appears in the code:
| Role | Size | Where |
|---|---|---|
| Page title | 28px, font-mono, semibold, leading-[1.15] tracking-tight |
OutlineView.tsx — the one place monospace is used at display size, on both the journal and page branches |
| Body | 17px / 1.4 | mobile body and BlockRow (text-[17px] leading-[1.42]); desktop has no CSS base and takes DesktopSettings.font_size, default 15 |
| Inline chip, inline code | 14px | MarkdownInline.tsx — block-ref chip, `code` |
| Ref / tag chip (mobile) | 15px, weight 500 | MarkdownInline.tsx chip variant |
| Popover / autocomplete row | 13px | BlockRow.tsx (desktop) — the four text-[13px] dropdowns |
| Secondary meta | 12px | truncated paths, mono |
| Chrome label | 10px, uppercase, mono | code-fence language label |
| Gutter | 9px, mono | BlockRow.tsx left gutter |
Monospace is a role, not a decoration. It marks "this is a literal": inline code, code-fence language labels, block ids, file paths, keyboard chords. Prose never uses it.
Weight carries only two values: normal, and font-semibold for **bold**.
Emphasis beyond that is carried by hue (italic_fg, strike_fg), which is what lets the TUI express
the same distinctions with Modifier::ITALIC and a colour and get an identical reading.
The base unit is 4px (Tailwind's default scale, unmodified). Every spacing value in the clients is a multiple of it except two deliberate odd numbers, both listed below.
This is the single most load-bearing measurement in the product, and the two clients arrive at it differently while agreeing on the number.
Mobile computes it (crates/outl-mobile/src/components/BlockRow.tsx):
const INDENT_PX = 22;
const padLeft = () => 16 + props.depth * INDENT_PX;Desktop renders it structurally — one hairline guide element per ancestor level
(crates/outl-desktop/src/components/BlockRow.tsx):
<For each={Array.from({ length: props.depth })}>
{() => (
<span
aria-hidden="true"
class="ml-[10px] w-3 shrink-0 self-stretch border-l border-(--color-outl-border)/20"
/>
)}
</For>ml-[10px] + w-3 (12px) = 22px per level. Mobile's hairline sits at 16 + depth * 22 + 5 px, at 35 % border
opacity; the desktop's at 20 %. Both are aria-hidden, and both are deliberately not .outl-row-chrome.
A guide is a column cue, read as a line down the whole page, so revealing it per row makes it flicker under
the pointer and never shows the structure it exists to show. The fold chevron is per-row chrome and does hide.
That is the layout principle in one element: structure visible on demand, prose visible always.
.outl-row-chrome is defined in the desktop's styles.css, unlayered so it wins over Tailwind's utilities:
opacity: 0 at rest, 1 on :hover, :focus-within, and the row's data-selected / data-visual /
data-editing states. focus-within is load-bearing rather than decorative, since without it the chevron is
unreachable by keyboard and "quieter at rest" becomes "fold is mouse-only". The fade is dropped under
prefers-reduced-motion.
It marks the fold chevron and nothing else. The bullet carried it once, which would have hidden the primary affordance of an outliner; the class had been applied to three elements and only one of them was chrome.
- 3px — the selected-block rail:
absolute top-[4px] bottom-[4px] -left-[2px] w-[3px] rounded-full bg-(--color-outl-accent). At 4px it reads as a border; at 2px it disappears on a non-Retina display. - 16px — mobile's base left padding before indentation starts, so depth 0 is not flush to the safe-area edge.
The desktop pins the height chain to the viewport rather than letting it grow
(crates/outl-desktop/src/styles.css, inside @layer base):
html, body, #root { height: 100%; }
body, #root { overflow: hidden; }height: 100%, not min-height: 100vh. With min-height, every descendant h-full resolves against an
unbounded parent, the outline's inner overflow-y-auto concludes it already fits, and the whole document
scrolls instead of the column. Exactly one element opts into scrolling, explicitly.
@layer base is required so Tailwind's preflight does not win the cascade and paint a white window.
Every bottom-anchored surface reserves the home indicator, with a floor so it is never flush on a device that has no inset:
style={{ bottom: "max(env(safe-area-inset-bottom), 16px)" }} // SelectionToolbar.tsx
style="padding-top: max(env(safe-area-inset-top), 12px);" // JournalChrome.tsxThere is exactly one elevation step. bg is the canvas; bg_elev is everything that floats.
A third level would need a field every one of the ten presets — including nord, monokai,
solarized-dark — has to answer honestly, and a terminal has no answer for "two steps above the canvas".
Depth is carried by border + shadow + translucency, never by a second background hue:
// BlockRow.tsx (desktop) — every autocomplete popover
class="absolute top-full left-0 z-30 mt-1 max-h-56 w-72 overflow-y-auto rounded-md
border border-(--color-outl-border) bg-(--color-outl-bg-elev) py-1 text-[13px] shadow-lg"Translucent chrome derives from fg, not from a dedicated token — bg-(--color-outl-fg)/10,
border-(--color-outl-fg)/15, hover:bg-(--color-outl-fg)/20. That is why chrome inverts correctly on a
light preset with no dark: variant anywhere.
Mobile adds three non-colour chrome tokens in its @theme block, all derived from the palette:
--radius-capsule: 9999px;
--shadow-capsule: 0 4px 16px color-mix(in srgb, var(--color-outl-fg) 10%, transparent);
--blur-chrome: 24px;--shadow-capsule composes the active fg rather than a frozen rgba(0,0,0,…). A hardcoded black shadow
would be invisible on outl (dark canvas) and heavy-handed on outl-light.
RFC 0022 deleted a prefers-color-scheme block that carried the skeleton shimmer's two raw rgba values.
That left the shimmer with no colour at all — the shimmer had to be re-derived:
.outl-skeleton {
background: linear-gradient(90deg,
color-mix(in srgb, var(--color-outl-border) 40%, transparent) 0%,
color-mix(in srgb, var(--color-outl-border) 70%, transparent) 50%,
color-mix(in srgb, var(--color-outl-border) 40%, transparent) 100%);
background-size: 200% 100%;
animation: outl-skeleton-shimmer 1400ms ease-in-out infinite;
}color-mix() over a palette token is the sanctioned way to express an alpha. Palette deliberately does
not carry rgba values: an alpha is a webview concept, and the terminal that also reads this struct has
no answer for it.
| Token | Value | Applied to |
|---|---|---|
rounded |
0.25rem | inline code, small chips, ghost buttons |
rounded-md |
0.375rem | popovers, ref chips, autocomplete panels |
rounded-lg |
0.5rem | toasts, sheets |
rounded-[0.2em] |
em-relative | the <mark> highlight — scales with the text it wraps, so a highlight in a 13px popover is not visually rounder than one in 17px body |
--radius-capsule |
9999px | mobile floating capsules (header actions, edit toolbar), the selection rail |
| 28px | absolute | the barcode scan frame, an overlay drawn on a live camera feed and not on a themed surface |
Borders are hairlines: 1px at reduced opacity against a palette token
(border-(--color-outl-border)/20 for indent guides, full border-(--color-outl-border) for popovers).
outl never draws a heavy divider; separation comes from whitespace first, hairline second, shadow third.
Two ease curves, defined once in crates/outl-mobile/src/styles.css:
--ease-spring-out: cubic-bezier(0.16, 1.08, 0.38, 1); /* sheets arriving — overshoots a hair */
--ease-spring-in: cubic-bezier(0.32, 0.72, 0, 1); /* settles to rest, no bounce */Durations: fade-in 200ms, toast 320ms, sheet 360ms, press 80ms, skeleton loop 1400ms.
Press feedback is scale(0.96) + opacity 0.7 — the smallest transform that still reads as a tap.
The component surface is deliberately unequal across clients, and that inequality is a recorded fact,
not an accident: outl_shortcuts::support and outl_shortcuts::capability_support are two exhaustive
matches, so a new Action or Capability does not compile until all three clients declare a verdict,
and docs/client-parity.md is generated from them.
What each verdict promises the user, how the nudge is worded, and why the wording lives in the catalog
rather than in a client are UX.md → When a client cannot do the thing.
What follows here is only what those components look like.
Pure, stateless, identical on both GUI clients. Chrome stays in the client.
MarkdownInline.tsx is the single owner of inline token painting. It takes a variant prop with two
values — "inline" (desktop: underlined text, mouse-hover affordances) and "pill" (mobile: filled chips
sized for a fingertip). Same tokens, two treatments, one component:
| Token | inline |
pill |
|---|---|---|
[[ref]] |
text-(--color-outl-ref-link-fg) underline decoration-(--color-outl-ref-link-fg)/40 underline-offset-2 hover:decoration-(--color-outl-ref-link-fg) — the 40 % decoration makes the underline read as an affordance, not as emphasis, and hover resolves it to full |
rounded-md bg-(--color-outl-accent)/12 px-1.5 py-0.5 text-[15px] font-medium text-(--color-outl-accent) active:opacity-60 |
#tag |
same pattern on --color-outl-tag-link-fg |
text-(--color-outl-accent) active:opacity-60 |
[text](url) |
same pattern on --color-outl-md-link-fg |
text-(--color-outl-accent) underline active:opacity-60 |
`code` |
rounded bg-(--color-outl-border)/30 px-1 py-0.5 font-mono text-[14px] |
identical |
==highlight== |
<mark class="rounded-[0.2em] bg-(--color-outl-highlight-bg) px-[0.15em] text-(--color-outl-highlight-fg)"> |
identical |
**bold** |
font-semibold — weight only, no colour override |
identical |
*italic* |
italic |
identical |
~~strike~~ |
line-through opacity-70 |
identical |
((block-ref)), orphaned |
rounded bg-(--color-outl-border)/30 px-1 font-mono text-[13px] text-(--color-outl-fg-dim) |
identical |
Two things this table admits. Inline code is tinted by its background only — code_fg exists in Palette
and in both @theme blocks, and MarkdownInline does not read it (the TUI does).
And TODO state is not painted here: MarkdownInline prefixes a bare glyph (✓ , ◐ , ☐ ), while the
hue comes from each client's own BlockRow — desktop maps ▣ / ▨ / ▢ to todo_done_fg / todo_open_fg
and strikes the DONE body with line-through opacity-60.
Tailwind only emits these classes because both clients declare
@source "../../outl-frontend-shared/src/**/*.{ts,tsx}" in styles.css. Without that glob the scanner never
reads the shared component and silently drops every one of its utilities.
Also shared: ParseWarningsBanner / PageAheadOfLogBanner (invariant 8's refusal must reach the user —
a client that swallows it into a log line ships a page that silently stopped syncing), PairingQR,
PeerList, and the toolbar action catalog with its most-frequently-used ordering.
Three-pane chrome, mouse + keyboard. Sidebar (page list + month calendar), OutlineView
(zoom path, page history), BlockRow (gutter, fold chevron, indent guides, four autocomplete popovers,
property editor, code fences), StatusBar (vim mode badge, transient message), ErrorToast,
SyncPanel / SyncIndicator, PropertyEditor, InlineBacklinks, ChromeToggleBar, SettingsModal.
SettingsModal owns the entire [theme] triple — mode selector plus a picker per side — and each change
reinstalls the draft pair through installTheme, so the preview is the real thing rather than an approximation.
Cancel reinstalls the configuration captured when the modal opened.
Single pane, touch. Journal (the primary surface — journal-first is the product), JournalChrome,
BlockRow, SelectionToolbar, KeyboardToolbar, Calendar, Onboarding, and a family of bottom sheets
(DevicesSheet, RemindersSheet, PropertiesSheet, PluginSheet), each reserving the safe-area inset.
Do add the field to Palette and give all ten presets a value.
Don't write a hex literal into a client stylesheet — that is a second definition of a colour.
The one exception is a client's @theme boot block, which exists so the first painted frame is branded
before the palette arrives over the wire. Both clients declare the same 19 boot tokens, byte-identical.
Every name in it must reverse-map to a real Palette field; the_theme_tokens_match_the_palette enforces it.
Do derive translucency from a token with color-mix() or a Tailwind opacity suffix.
Don't put an rgba in Palette. The terminal reads that struct and has no answer for an alpha.
Do let one token mean one fact everywhere it appears.
Don't let a client-local prefix acquire a second meaning — that is the ios / iosd bug, and the OS
appearance setting is what ended up casting the deciding vote.
Do name a field for the surface it paints (ref_link_fg).
Don't invent compound names (inner_bold_in_quote). If two surfaces genuinely share a style, share the field.
Do honour prefers-reduced-motion — mobile kills every keyframe animation under it and keeps only the
press feedback, so a tap still confirms itself.
Don't reintroduce a dark: variant to express a colour. The OS selects which preset; it never
selects which token name.
Behavioural do's and don'ts — declaring which clients lack a capability, never shipping a chord with no
handler, where a refusal has to land — are UX.md → Do's and don'ts.
The rule above is the design. These are the places the shipped code does not yet meet it. They are listed so nobody has to rediscover them, and so nobody cites one as precedent.
| Where | What | Standing |
|---|---|---|
@outl/shared/highlight/styles.css |
11 hex literals — a complete single-theme syntax palette | Deliberate. Code blocks read against the brand-dark canvas on every preset; the file says so. A syntax theme is its own vocabulary, not a Palette role. |
@outl/shared/peers/styles.css |
#fff on the pairing QR |
Deliberate. The quiet zone must stay white or the code stops scanning. |
mobile styles.css .scan-*, both index.html files |
#fff, #000, raw rgba(), #0c0814 / #f6f4fb |
Deliberate. The scan overlay sits on a live camera feed, not a themed surface; index.html values are the pre-JS boot frame, and the desktop's are var() fallbacks rather than overrides. |
ErrorToast.tsx |
uses --color-outl-status-message-fg, absent from both @theme blocks |
The utility resolves; only the first painted frame is unstyled. Same shape as the ref-link incident above. |
[theme] takes three keys, owned by docs/theming.md:
[theme]
preset = "outl-light" # the light side
preset_dark = "outl" # the dark side; falls back to `preset` when absent
mode = "auto" # light | dark | auto (default)mode names which side of the pair to use, not a colour.
Backwards compatibility comes from preset_dark defaulting to preset, not from the mode default:
a config carrying only preset = "dracula" resolves dracula on both sides, so auto alternates between
dracula and dracula — today's behaviour byte for byte.
Both GUI clients call the shared get_theme_config command, hold both Palette objects in memory, and
repaint locally on a prefers-color-scheme flip. The in-memory requirement is part of the design, not an
optimisation: a backend round-trip mid-repaint is a visible stall at the exact moment the user is watching.
applyPaletteToRoot also flips color-scheme from the palette's BT.601 luminance over bg, so native
scrollbars and <select> popups follow the preset.
mode = "auto" resolves to the dark side on the TUI, always. A terminal has no API for the OS
appearance setting; probing (OSC 11, COLORFGBG) is wrong under tmux and unimplemented in several emulators.
The TUI declares the gap rather than guessing — a permanent behaviour, recorded in docs/client-parity.md.
Visual divergence only.
Divergence in what a client does — no chords on mobile, no character cursor on the desktop, no automatic
backups on iOS — is UX.md → What each client can do.
| Divergence | Why |
|---|---|
default-dark and light bypass the RGB path and build on ANSI named colours (Color::Reset, Color::DarkGray) |
So the user's own terminal palette shows through. That is the point of those two presets, not a gap. |
| GUI clients read 28 of the 43 colour fields | The other 15 — bold_fg, italic_fg, strike_fg, heading_fg, dim_fg, property_key_fg, property_value_fg, cursor_block_bg / _fg, cursor_caret_fg, list_selected_bg / _fg, hint, todo_done_body_fg, selected_bullet_fg — are consumed by the TUI only. The GUI expresses those distinctions with weight (font-semibold), style (italic), opacity (line-through opacity-70) and the native caret. The fields stay in Palette because the TUI is a first-class client, not a fallback. |
Only the desktop resolves keystrokes through outl_shortcuts::lookup() |
The TUI still dispatches Normal mode from its own match in input/normal.rs. Finishing that migration is open work, not a settled decision. |
- Reduced motion is honoured on mobile:
@media (prefers-reduced-motion: reduce)setsanimation: noneon.outl-fade-in,.outl-sheet-up,.outl-toast-inand.outl-skeleton, drops the presstransform, and keeps a 80ms opacity transition so a tap still confirms itself. - Whether an affordance can be reached at all — labelling, hidden-but-keyboard-reachable chrome, touch
targets — is the behavioural half:
UX.md→ Accessibility is a reach question. 159 accessibility attributes across the two GUI clients today. - Contrast is a preset-author obligation.
outl-lightdarkens the brand violet from#a78bfato#7c3aedand replaces the lemon#d6ff47with#65a30d(lime-600) — the comment incrates/outl-theme/src/presets.rssays the lemon is "unreadable on light bg". Reusing the dark palette's hues on a light canvas is the most common preset mistake. - Selection is never colour-only. The selected block gets both an accent rail and a background change;
vim mode is both a hue and the word (
NORMAL/INSERT/VISUAL) inStatusBar. - Caret and selection are branded, which is also a legibility choice:
caret-color: var(--color-outl-accent)and::selection { background: color-mix(in srgb, var(--color-outl-accent) 25%, transparent) }— the default iOS blue caret on a deep-purple canvas is low contrast.
A new colour role:
- Add the field to
Palette(crates/outl-theme/src/palette.rs) with a doc comment saying what the surface is. - Add it to
Palette::fields()— the installer and the hex test both walk that list. - Fill it in all ten presets (
crates/outl-theme/src/presets.rs). The compiler forces the field to exist;every_preset_defines_destructiveis the pattern for forcing it to mean something rather than be"". - Map it in
crates/outl-tui/src/theme.rsif the TUI paints it. - GUI clients need no change —
applyPaletteToRootwalks every field. Reference it asbg-(--color-outl-<kebab-name>). - Add a boot value to both
@themeblocks only if the token must be right on the very first painted frame. - Run
/check. The Rust half and the TypeScript half are both required: invariants 12 and 13 are enforced partly by TS parity tests.
A new preset:
- Write the constructor in
presets.rs; fill every field with#rrggbb. - Add the name to
PRESETSincrates/outl-theme/src/lib.rsand a match arm toby_name(case- and separator-insensitive:Solarized Dark,solarized_dark,SOLARIZED-DARKall resolve). - Add the one-line TUI delegate in
crates/outl-tui/src/theme.rs, unless it is deliberately ANSI-based. - The CLI, the desktop Settings picker and mobile pick it up automatically — there is no second list.
There used to be: the TUI kept its own
PRESETS, sooutl theme listadvertised eight presets while the desktop offered nine and told usersoutl-lightdid not exist whileoutl --theme outl-lightresolved it anyway. That const was deleted;outl-tuinow re-exportsoutl_theme::PRESETS. - If the preset is meant as one half of a pair, check
Palette::is_light()agrees —outl doctorvalidates that a configured pair has one light side and one dark side.