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>
453 lines
17 KiB
CSS
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;
|
|
}
|
|
}
|