Files
LLeMbas/src/lembas/web/static/css/tokens.css
T
HomerandClaude Opus 5 28390095a9 A phone, and how much of this could not be used on one
The sidebar was a 280px panel laid over the page below the phone breakpoint,
opened from first paint, with the only control that closed it underneath it --
and that control existed on /chat and on none of the seven other pages carrying
a sidebar, Settings included. It starts closed at that width now, slides, dims
the page behind it, and closes by tapping beside it, by Escape, or by its own
button, which is inside the drawer where it can be reached.

Everything a finger has to hit was 36px, or 28 for renaming a chat, every action
on a message and every panel's close button. Raising --control-h under a coarse
pointer is the only fix that reaches all forty of them, which is what that token
is for. The row and message actions were also hover-only, so on a phone they did
not exist at all.

Installing: the splash and the browser chrome follow the instance's theme rather
than always being Moria's near-black; there are screenshots, so the install
offer is a dialog rather than a one-line bar; a new release no longer takes over
a page somebody is reading; the notification badge is a silhouette rather than a
grey square; and a browser rotating its own subscription no longer ends
notifications for good.

Every request now says it is happening -- nothing did before, so anything slower
than a few milliseconds looked like a click that had not registered.

A chat can be archived. The column has been filtered on in four places since
folders arrived and written by nothing, which is what made it look built.

chat.css may contain media queries. The ban protected the composer toolbar from
being "fixed" with a breakpoint; that guarantee is asserted directly now, and
the old test would have passed a version of the file that wrapped the toolbar
without one.

scripts/shoot.py is the instrument all of this was found with: it renders a page
through TestClient into a real headless browser at a real size and refuses to
run if an asset URL was left pointing at testserver.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 13:39:35 +00:00

453 lines
17 KiB
CSS

/*
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 <html>. `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 <html> 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. `<html>` 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;
}
}