/* Design tokens. Every colour, space and radius in the application resolves through a variable declared here. Component CSS must never hard-code a hex value -- that is what makes adding a theme a matter of writing one new block rather than auditing every stylesheet. Themes are selected with data-theme on . `moria` is the default and is declared on :root so the page is styled even before the theme script runs. */ :root { /* --- Type ------------------------------------------------------------- */ --font-display: "Iowan Old Style", "Palatino Linotype", Palatino, Palladio, "URW Palladio L", "Book Antiqua", Baskerville, Georgia, serif; --font-body: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --font-mono: ui-monospace, "SF Mono", "JetBrains Mono", "Fira Code", "Cascadia Code", Menlo, Consolas, monospace; --text-xs: 0.75rem; --text-sm: 0.8125rem; --text-base: 0.9375rem; --text-md: 1rem; --text-lg: 1.125rem; --text-xl: 1.375rem; --text-2xl: 1.75rem; --leading-tight: 1.25; --leading-normal: 1.6; --leading-relaxed: 1.75; /* --- Space (4px scale) ------------------------------------------------ */ --sp-1: 0.25rem; --sp-2: 0.5rem; --sp-3: 0.75rem; --sp-4: 1rem; --sp-5: 1.25rem; --sp-6: 1.5rem; --sp-8: 2rem; --sp-10: 2.5rem; --sp-12: 3rem; /* --- Radius & shadow -------------------------------------------------- */ --radius-sm: 4px; --radius: 8px; --radius-md: 10px; --radius-lg: 12px; --radius-xl: 18px; --radius-full: 999px; /* --- Controls ---------------------------------------------------------- Every button, input and select resolves its height from these. That is the whole reason things line up: a row of mixed controls has one height, not whatever each element's padding and font happened to add up to. */ --control-h: 2.25rem; --control-h-sm: 1.75rem; --control-h-lg: 2.75rem; --control-px: 0.75rem; --control-px-sm: 0.5rem; /* --- Layout ----------------------------------------------------------- */ --sidebar-width: 17.5rem; --inspector-width: 24rem; /* Wider than the inspector because the content is not prose: eighty columns of --font-mono do not fit in 24rem, and a terminal narrower than eighty re-wraps everything a program prints. */ --terminal-width: 34rem; --terminal-width-min: 24rem; /* Wider again: a source line is longer than eighty columns once nothing is re-wrapping it, and this one holds prose as well. The minimum is 24rem = 384px and must equal both `data-resize-min` in chat/_canvas.html and the lower bound in api/preferences.py:LAYOUT_BOUNDS -- a width outside those bounds is silently dropped, so the three are pinned equal by a test. */ --canvas-width: 40rem; --canvas-width-min: 24rem; --thread-max-width: 48rem; --header-height: 3.5rem; /* What `--header-height` does at the top of the shell, this does at the bottom. The sidebar's footer and the composer sit either side of the same vertical line and both are content-sized, so without this they end at different heights -- and neither can be made to match the other by accident: the footer's height depends on which entries the reader's permissions allow, and the composer's on how much they have typed. This began as the fix for a broken *line*: both carried a top border, and the two met the sidebar's edge at different heights. The borders are gone now -- an edge that content scrolls under reads better undrawn than drawn and aligned -- and the token stays, because the two ends of the shell sitting at different heights is visible without any border to prove it. A calc of the pieces the footer is actually built from -- four rows at `--control-h`, the gaps between them, and its own padding -- so it stays true if those tokens move. Applied as a `min-height` to both: it holds the footer at full height even for somebody who sees fewer entries, and lifts the composer to meet it. A composer that grows past this as somebody types is expected; the input is getting bigger, and nothing is pretending the sidebar should follow it. */ --footer-height: calc(4 * var(--control-h) + 3 * var(--sp-1) + 2 * var(--sp-2)); /* The terminal's own type. xterm holds this as a number rather than reading it from CSS, so terminal.js parses it back out -- it must stay a plain pixel value. */ --terminal-font-size: 13px; /* --- Stacking ---------------------------------------------------------- These were four bare numbers scattered across two stylesheets, which is fine until something new has to sit between two of them and nobody can say what is already there. */ --z-raised: 5; --z-handle: 10; --z-dropdown: 30; --z-panel: 40; --z-toast: 60; --transition-fast: 120ms ease; --transition: 200ms ease; /* --- Borders ----------------------------------------------------------- A hairline was a literal `1px` in about ninety places, which made it the largest category of hard-coded value left in the codebase -- and the one thing a theme cannot currently change. */ --border-w: 1px; --border-w-thick: 2px; --border-w-accent: 3px; /* The focus outline's own width. Not `--border-w-thick`, though they are the same number today: an outline is drawn outside the box and takes no space, a border is part of the box and does. Making one of them follow the other means a theme that wants a heavier border gets a heavier focus ring too, which is two decisions tied together by a coincidence. */ --outline-w: 2px; /* --- Touch -------------------------------------------------------------- A control a thumb has to hit is 44px. `--control-h` is 2.25rem, which is 36 -- comfortable with a pointer and under every published minimum for a finger -- so the coarse-pointer block at the foot of this file raises the control tokens to this rather than patching components one at a time. Raising the token is the only version that reaches all of them, and it is what `--control-h` exists for. */ --tap-min: 2.75rem; /* --- The window's own edges --------------------------------------------- Installed on a phone, the page runs under the notch and the home indicator: base.html asks iOS for `black-translucent`, which is what puts it there, and `viewport-fit=cover` is what lets these resolve to anything but zero. Declared here so no component spells `env()` out -- and so a desktop browser, where all four are 0, costs nothing. */ --safe-top: env(safe-area-inset-top, 0px); --safe-right: env(safe-area-inset-right, 0px); --safe-bottom: env(safe-area-inset-bottom, 0px); --safe-left: env(safe-area-inset-left, 0px); /* --- Breakpoints -------------------------------------------------------- A media query cannot read a custom property, so these cannot be *used* here. They are declared anyway so the numbers have one home and a grep for one lands somewhere that says what it means -- and `tests/test_layout_bounds.py` refuses a width in any stylesheet that is not declared here, so a fourth breakpoint invented in passing fails the suite rather than joining the set unannounced. --bp-admin 44rem 704px a two-column reference row stacks --bp-narrow 48rem 768px the sidebar becomes a drawer, and controls grow to a thumb's size --bp-wide 64rem 1024px the right-hand panels become overlays */ --bp-admin: 44rem; --bp-narrow: 48rem; --bp-wide: 64rem; /* --- Motion ------------------------------------------------------------- Durations and curves, so the `prefers-reduced-motion` block at the foot of this file keeps covering everything by construction: a literal `1.6s` in a component is a value that block can still neutralise, but one nobody can tune. `--ease-out` is the one to reach for -- something arriving should decelerate; `--ease-spring` overshoots slightly and belongs on a thing that appears, never on a thing that moves under the pointer. */ --ease-out: cubic-bezier(0.22, 0.61, 0.36, 1); --ease-in-out: cubic-bezier(0.65, 0.05, 0.36, 1); --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); --dur-1: 120ms; --dur-2: 200ms; --dur-3: 320ms; --dur-slow: 1.6s; /* --- Panel minimums ----------------------------------------------------- `api/preferences.py:LAYOUT_BOUNDS` allows four panels' widths to be stored against an account and only two of them -- the two with a drag handle -- had a `-min` token or a `min-width` to clamp with. The other two are not draggable, so nothing in the interface could produce a bad value; but the endpoint takes one from anybody signed in, `base.html` applies stored widths to before first paint, and with no clamp a stored 800px sidebar is one nothing in the application can drag back. */ --sidebar-width-min: 12.5rem; --inspector-width-min: 17.5rem; /* The focus treatment, written once. Three components spelled it out. It resolves --accent-soft at the point of use, so it follows the theme even though it is declared above them. */ --ring: 0 0 0 3px var(--accent-soft); } /* --------------------------------------------------------------------------- MORIA (default, dark) Deep stone and lamplight: the halls under the mountain. Surfaces are cool and near-neutral so the mallorn and mithril accents carry all the colour. --------------------------------------------------------------------------- */ :root, :root[data-theme="moria"] { color-scheme: dark; --bg: #101317; --bg-sunken: #0B0E11; --surface: #171B21; --surface-raised: #1E242B; --surface-hover: #232A32; --surface-active: #2A323B; --border: #2A313A; --border-strong: #3A434E; --ink: #E4E8EC; --ink-muted: #A2ADB8; --ink-faint: #7A848F; --ink-inverse: #0B0E11; /* Mithril: the cool primary, used for focus and interactive accents. */ --accent: #8FB3CC; --accent-hover: #A9C6DA; --accent-ink: #0B0E11; --accent-soft: rgba(143, 179, 204, 0.14); /* Mallorn: the brand accent, and the assistant's mark. A yellow-leaning leaf green, so it stays warm against the mithril blue rather than turning the palette into two cool accents that compete. */ --leaf: #9BCC5A; --leaf-hover: #B1DD74; --leaf-soft: rgba(155, 204, 90, 0.14); /* Ember: destructive actions and errors. */ --danger: #E2795A; --danger-hover: #EC8E72; --danger-soft: rgba(226, 121, 90, 0.14); /* Success leans teal rather than leaf. Two greens a hue apart read as one colour rendered inconsistently -- an unread dot beside a brand badge has to be tellable from it at a glance. */ --success: #5FBFA0; --success-soft: rgba(95, 191, 160, 0.14); --warning: #DFAE58; --warning-soft: rgba(223, 174, 88, 0.14); --bubble-user: #232B34; --bubble-assistant: transparent; --code-bg: #0C0F13; --code-border: #262D36; --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); --shadow: 0 4px 14px rgba(0, 0, 0, 0.45); --shadow-lg: 0 12px 34px rgba(0, 0, 0, 0.55); --scrim: rgba(6, 8, 10, 0.66); /* The sixteen ANSI colours, for the terminal panel. A shell picks its own colours -- `ls --color`, a git diff, htop's meters -- and until these existed it got xterm's defaults, which are a different palette from this one and read as a foreign window pasted into the application. Where a slot has an obvious counterpart above it takes it, so an error in the terminal is the same ember as an error anywhere else. "black" is not #000: it is what a program picks for a dim background or faint text, and on a near-black surface a true black is invisible. */ --ansi-black: #1A1F26; --ansi-red: #E2795A; --ansi-green: #9BCC5A; --ansi-yellow: #DFAE58; --ansi-blue: #8FB3CC; --ansi-magenta: #C08FCC; --ansi-cyan: #5FBFA0; --ansi-white: #C6CDD4; --ansi-bright-black: #4A535E; --ansi-bright-red: #EC8E72; --ansi-bright-green: #B1DD74; --ansi-bright-yellow: #EFC87A; --ansi-bright-blue: #A9C6DA; --ansi-bright-magenta: #D4A9DE; --ansi-bright-cyan: #7FD4BA; --ansi-bright-white: #E4E8EC; } /* --------------------------------------------------------------------------- SHIRE (light) Parchment, ink and moss: warm, low-contrast, easy to read for a long time. Backgrounds are deliberately off-white -- pure #FFF next to the leaf accent reads as clinical rather than as paper. --------------------------------------------------------------------------- */ /* `[data-base="shire"]` as well as `[data-theme="shire"]`, and that second selector is what makes a custom theme possible. A custom theme is a handful of token overrides served from /branding.css; everything it does not set has to come from somewhere, and Moria's block above matches bare `:root` so it always applies. Without this a custom *light* theme would be four light colours on near-black surfaces. `` carries both attributes -- see base.html and app.js:applyTheme. */ :root[data-theme="shire"], :root[data-base="shire"] { color-scheme: light; --bg: #F6F1E4; --bg-sunken: #EDE6D4; --surface: #FDFBF5; --surface-raised: #FFFFFF; --surface-hover: #F1EADA; --surface-active: #E7DEC9; --border: #DED3BB; --border-strong: #C6B896; --ink: #2C2419; --ink-muted: #6A5C48; --ink-faint: #756A55; --ink-inverse: #FDFBF5; /* Hobbit-door blue-green: the cool primary. */ --accent: #3E6B7A; --accent-hover: #325867; --accent-ink: #FDFBF5; --accent-soft: rgba(62, 107, 122, 0.12); /* The same mallorn, darkened until it holds its own as text on parchment. */ --leaf: #4C7A22; --leaf-hover: #3C6318; --leaf-soft: rgba(76, 122, 34, 0.13); --danger: #A6432B; --danger-hover: #8C3722; --danger-soft: rgba(166, 67, 43, 0.11); --success: #2C7360; --success-soft: rgba(44, 115, 96, 0.12); --warning: #98701A; --warning-soft: rgba(152, 112, 26, 0.13); --bubble-user: #EDE4CF; --bubble-assistant: transparent; --code-bg: #F2EBD9; --code-border: #DED3BB; --shadow-sm: 0 1px 2px rgba(72, 58, 34, 0.09); --shadow: 0 4px 14px rgba(72, 58, 34, 0.11); --shadow-lg: 0 12px 34px rgba(72, 58, 34, 0.16); --scrim: rgba(44, 36, 25, 0.4); /* The same sixteen on parchment, and the reason they are not the Moria set lightened: every one of these has to be readable as *text* on #F2EBD9, so the whole palette is darkened rather than brightened. "bright" therefore means more emphatic here, not lighter -- a light terminal theme that made bright yellow actually bright would render it invisible. */ --ansi-black: #2C2419; --ansi-red: #A6432B; --ansi-green: #4C7A22; --ansi-yellow: #98701A; --ansi-blue: #3E6B7A; --ansi-magenta: #7A3E6B; --ansi-cyan: #2C7360; --ansi-white: #6A5C48; --ansi-bright-black: #94856D; --ansi-bright-red: #8C3722; --ansi-bright-green: #3C6318; --ansi-bright-yellow: #7A5A14; --ansi-bright-blue: #325867; --ansi-bright-magenta: #63325A; --ansi-bright-cyan: #235C4D; --ansi-bright-white: #453A2A; } /* --- Touch ----------------------------------------------------------------- A pointer is precise and a finger is about 9mm across, so the same control cannot be the right size for both. `--control-h` is 36px, which is comfortable with a mouse and under every published minimum for a thumb; `--control-h-sm` is 28px, which is a target most people miss. Raised here rather than patched per component, because there are upwards of forty of them and the next one added would be 36px again. `--control-h` is what every button, input and select resolves its height from, so one block moves all of them -- which is the reason that token exists. Two conditions, either of which is enough. `(pointer: coarse)` is the honest one: it is the input device that decides how big a target has to be, and a touchscreen laptop at 1440px has the same thumb as a phone. But a layout below the phone breakpoint is a one-column, drawer- navigated layout whatever is pointing at it -- there is room for bigger controls and every reason to use it -- and that half is also the half a headless browser can be made to prove, which is not nothing: a rule that can only be checked by holding a phone is a rule that quietly rots. */ @media (pointer: coarse), (max-width: 48rem) { :root { --control-h: var(--tap-min); --control-h-sm: 2.25rem; --control-px: var(--sp-4); --control-px-sm: var(--sp-3); } } /* Respect a stated preference for reduced motion everywhere, at once. */ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } /* The motion tokens too, for anything that composes a duration rather than declaring one -- a `transition: transform var(--dur-3)` is neutralised by the rule above, but an `animation-delay` built from one is not. */ :root { --dur-1: 0.01ms; --dur-2: 0.01ms; --dur-3: 0.01ms; --dur-slow: 0.01ms; --transition-fast: 0.01ms; --transition: 0.01ms; --transition-slow: 0.01ms; } }