Files

3750 lines
82 KiB
CSS
Raw Permalink Normal View History

2026-09-18 12:03:35 +02:00
/* Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 */
/* Volumen admin · design system 2026
The interface sheet of the admin UI. It carries the same tokens, the same
components and the same calm as the author's website: colours are oklch
neutrals and exact matplotlib colour-map stops, derived further with
light-dark() and color-mix(); the cascade is ordered by @layer, so no
!important and no specificity war; type is rem on the user's browser
setting; motion respects prefers-reduced-motion; the popover, the dialog
and :has() replace the JavaScript they used to need. The palette of the
account rides on the html data-palette attribute, the pinned colour
scheme on data-scheme, and served as written nothing is compiled. */
@property --bg {
syntax: "<color>";
inherits: true;
initial-value: oklch(98.2% 0.008 280);
}
@layer reset, tokens, base, layout, components, utilities;
@layer tokens {
:root {
/* The scheme follows the system until the account pins one with the
toggle: the light-dark() tokens read the resolved colour scheme, and
a data-scheme attribute exists only to pin it. */
color-scheme: light dark;
--font-ui: "Ubuntu", ui-sans-serif, system-ui, "DejaVu Sans", sans-serif;
--font-mono:
"Ubuntu Mono",
ui-monospace,
"Cascadia Mono",
"DejaVu Sans Mono",
monospace;
/* VIRIDIS: the default palette, the official colour map. Every accent
is an exact entry of the table matplotlib publishes
(lib/matplotlib/_cm_listed.py, _viridis_data): #440154 is entry 0,
#26828e is 112, #5ec962 is 192 and #fde725 is 255. The neutrals are
tinted towards the map's dark end, hue 280 in the light scheme and
300 in the dark one. The other palettes below restate every token. */
--bg: light-dark(oklch(98.2% 0.008 280), oklch(17% 0.02 300));
--surface: light-dark(oklch(100% 0 0), oklch(21.5% 0.024 300));
--surface-2: light-dark(oklch(96.2% 0.01 280), oklch(25.5% 0.028 300));
--ink: light-dark(oklch(21% 0.03 300), oklch(94% 0.01 280));
--ink-2: light-dark(oklch(45% 0.025 300), oklch(78% 0.02 280));
--ink-3: light-dark(oklch(57% 0.02 300), oklch(64% 0.02 280));
--line: light-dark(oklch(91% 0.015 300), oklch(31% 0.025 300));
--line-strong: light-dark(oklch(83% 0.02 300), oklch(39% 0.03 300));
--accent: light-dark(#26828e, #5ec962);
--accent-2: light-dark(#440154, #fde725);
--on-accent: light-dark(oklch(99% 0.005 300), oklch(22% 0.04 130));
/* What derives from the palette, and what no palette owns. */
--accent-wash: light-dark(
color-mix(in oklab, var(--accent) 8%, transparent),
color-mix(in oklab, var(--accent) 16%, transparent)
);
--gradient: linear-gradient(
100deg,
var(--accent) 8%,
color-mix(in oklab, var(--accent) 45%, var(--accent-2)) 55%,
var(--accent-2) 100%
);
--glow: light-dark(
color-mix(in oklab, var(--accent) 26%, transparent),
color-mix(in oklab, var(--accent) 40%, transparent)
);
--danger: light-dark(oklch(50% 0.19 26), oklch(74% 0.14 26));
--ok: light-dark(oklch(48% 0.13 155), oklch(78% 0.13 155));
--warn: light-dark(oklch(50% 0.13 70), oklch(82% 0.14 85));
--text-xs: 0.8125rem;
--text-sm: 0.9062rem;
--text-base: 1.0312rem;
--text-lg: clamp(1.12rem, 0.3vw + 1.05rem, 1.25rem);
--text-xl: clamp(1.25rem, 0.6vw + 1.1rem, 1.5rem);
--text-2xl: clamp(1.5rem, 1.3vw + 1.16rem, 2rem);
--text-display: clamp(2.5rem, 5vw + 0.8rem, 4.35rem);
--space-3xs: 0.25rem;
--space-2xs: 0.4rem;
--space-xs: 0.6rem;
--space-sm: 0.9rem;
--space-md: 1.25rem;
--space-lg: 1.8rem;
--space-xl: 2.8rem;
--space-2xl: 5rem;
--space-3xl: 7.5rem;
--radius-sm: 10px;
--radius: 16px;
--radius-lg: 22px;
--content: 74rem;
--measure: 66ch;
--gutter: clamp(1.25rem, 4vw, 2.75rem);
--header-h: 4.25rem;
--rail-w: 15.5rem;
--shadow-sm: 0 1px 2px light-dark(
oklch(20% 0.02 268 / 0.05),
oklch(0% 0 0 / 0.3)
);
--shadow-md:
0 1px 2px light-dark(oklch(20% 0.02 268 / 0.05), oklch(0% 0 0 / 0.32)),
0 10px 30px -12px light-dark(oklch(20% 0.02 268 / 0.14), oklch(0% 0 0 / 0.5));
--shadow-lg:
0 2px 4px light-dark(oklch(20% 0.02 268 / 0.05), oklch(0% 0 0 / 0.34)),
0 22px 50px -16px light-dark(oklch(20% 0.02 268 / 0.2), oklch(0% 0 0 / 0.6));
}
/* PLASMA: the official colour map. The accents are exact entries
(#0d0887 is 0, #bc3587 is 112, #f89540 is 192, #f0f921 is 255) and the
neutrals tint to hue 267.5, the map's dark end. */
:root[data-palette="plasma"] {
--bg: light-dark(oklch(98.2% 0.008 267.5), oklch(17% 0.02 267.5));
--surface: light-dark(oklch(100% 0 0), oklch(21.5% 0.024 267.5));
--surface-2: light-dark(oklch(96.2% 0.01 267.5), oklch(25.5% 0.028 267.5));
--ink: light-dark(oklch(21% 0.03 267.5), oklch(94% 0.01 267.5));
--ink-2: light-dark(oklch(45% 0.025 267.5), oklch(78% 0.02 267.5));
--ink-3: light-dark(oklch(57% 0.02 267.5), oklch(64% 0.02 267.5));
--line: light-dark(oklch(91% 0.015 267.5), oklch(31% 0.025 267.5));
--line-strong: light-dark(oklch(83% 0.02 267.5), oklch(39% 0.03 267.5));
--accent: light-dark(#bc3587, #f89540);
--accent-2: light-dark(#0d0887, #f0f921);
--on-accent: light-dark(oklch(99% 0.005 267.5), oklch(22% 0.04 112));
}
/* MAGMA: the official colour map. The accents are exact entries
(#000004 is 0, #9c2e7f is 112, #fc8961 is 192, #fcfdbf is 255); the
neutrals tint to hue 306, the purple region, because entry 0 is black
and carries no hue of its own. */
:root[data-palette="magma"] {
--bg: light-dark(oklch(98.2% 0.008 306), oklch(17% 0.02 306));
--surface: light-dark(oklch(100% 0 0), oklch(21.5% 0.024 306));
--surface-2: light-dark(oklch(96.2% 0.01 306), oklch(25.5% 0.028 306));
--ink: light-dark(oklch(21% 0.03 306), oklch(94% 0.01 306));
--ink-2: light-dark(oklch(45% 0.025 306), oklch(78% 0.02 306));
--ink-3: light-dark(oklch(57% 0.02 306), oklch(64% 0.02 306));
--line: light-dark(oklch(91% 0.015 306), oklch(31% 0.025 306));
--line-strong: light-dark(oklch(83% 0.02 306), oklch(39% 0.03 306));
--accent: light-dark(#9c2e7f, #fc8961);
--accent-2: light-dark(#000004, #fcfdbf);
--on-accent: light-dark(oklch(99% 0.005 306), oklch(22% 0.04 108));
}
:root[data-scheme="light"] {
color-scheme: light;
}
:root[data-scheme="dark"] {
color-scheme: dark;
}
}
@layer reset {
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
-webkit-text-size-adjust: 100%;
scrollbar-width: thin;
scrollbar-color: var(--accent) transparent;
scrollbar-gutter: stable;
}
img,
svg,
video {
display: block;
max-width: 100%;
}
input,
button,
textarea,
select {
font: inherit;
color: inherit;
}
ul[class],
ol[class] {
list-style: none;
padding: 0;
}
fieldset {
border: 0;
margin: 0;
padding: 0;
min-width: 0;
}
[hidden] {
display: none !important;
}
}
@layer base {
body {
min-height: 100svh;
background: var(--bg);
color: var(--ink);
font-family: var(--font-ui);
font-size: var(--text-sm);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
transition:
background-color 0.3s ease,
color 0.3s ease;
}
h1,
h2,
h3,
h4 {
font-weight: 700;
line-height: 1.12;
letter-spacing: -0.022em;
text-wrap: balance;
}
h1 {
font-size: var(--text-2xl);
letter-spacing: -0.028em;
}
h2 {
font-size: var(--text-xl);
letter-spacing: -0.024em;
}
h3 {
font-size: var(--text-lg);
letter-spacing: -0.015em;
}
p {
text-wrap: pretty;
}
a {
color: var(--accent);
text-decoration: underline;
text-decoration-color: color-mix(in oklab, var(--accent) 38%, transparent);
text-decoration-thickness: 1.5px;
text-underline-offset: 0.2em;
transition:
color 0.18s ease,
text-decoration-color 0.18s ease;
}
@media (hover: hover) {
a:hover {
text-decoration-color: currentColor;
}
}
strong,
b {
font-weight: 650;
}
code,
kbd,
samp {
font-family: var(--font-mono);
font-size: 0.875em;
}
:not(pre) > code {
padding: 0.12em 0.4em;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--surface-2);
}
kbd {
display: inline-block;
padding: 0.1rem 0.45rem;
border: 1px solid var(--line-strong);
border-bottom-width: 2px;
border-radius: var(--radius-sm);
background: var(--surface-2);
font-size: var(--text-xs);
line-height: 1.3;
}
::selection {
background: color-mix(in oklab, var(--accent) 26%, transparent);
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}
}
@layer layout {
/* The shell: a full-width topbar on the first row, the rail and the
content column beneath it. */
.shell {
display: grid;
grid-template-columns: var(--rail-w) minmax(0, 1fr);
grid-template-rows: var(--header-h) minmax(0, 1fr);
min-height: 100svh;
}
.main {
display: flex;
flex-direction: column;
min-width: 0;
}
.content {
width: 100%;
max-width: var(--content);
margin-inline: auto;
padding: var(--space-xl) var(--gutter) var(--space-3xl);
}
.page-head {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-md);
margin-bottom: var(--space-xl);
}
.page-head__text {
display: grid;
gap: var(--space-3xs);
}
.page-head__lede {
color: var(--ink-2);
}
.page-head__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2xs);
}
.grid-2 {
display: grid;
gap: var(--space-md);
}
@media (min-width: 56rem) {
.grid-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
/* The dashboard strips: two cards ride side by side, one card takes the
whole row, the empty track collapses. */
.grid--fit {
display: grid;
gap: var(--space-md);
grid-template-columns: repeat(auto-fit, minmax(24rem, 1fr));
margin-bottom: var(--space-md);
}
/* The page head following a card row earns its own distance. */
.card + .page-head,
.grid--fit + .page-head {
margin-top: var(--space-xl);
}
/* A full-page empty state (the post-update wait) wears the centred
column, so one lone message never floats across the wide container. */
.narrow {
max-width: 46rem;
margin-inline: auto;
}
}
@layer components {
/* --- Chrome: rail, topbar, menu, skip link --------------------------- */
.skip-link {
position: absolute;
z-index: 30;
top: 0.6rem;
left: 0.6rem;
padding: 0.6rem 1rem;
translate: 0 -5rem;
border-radius: 999px;
background: var(--surface);
box-shadow: var(--shadow-lg);
font-size: var(--text-sm);
font-weight: 600;
text-decoration: none;
transition: translate 0.2s ease;
}
.skip-link:focus-visible {
translate: 0;
}
.rail {
display: flex;
flex-direction: column;
position: sticky;
top: var(--header-h);
height: calc(100svh - var(--header-h));
border-inline-end: 1px solid var(--line);
background: var(--surface);
}
/* The rail is the wide-screen chrome: below that width it gives way to
the popover menu, and the shell drops to one column. */
@media (max-width: 55.99rem) {
.shell {
grid-template-columns: minmax(0, 1fr);
}
.rail {
display: none;
}
}
/* The brand rides the topbar in the shape the site header wears it:
the icon badge beside a name stack, the name heavy and tight, the
role quiet beneath it. No wordmark image, no mono eyebrow. */
.brand {
display: flex;
align-items: center;
gap: var(--space-2xs);
text-decoration: none;
color: var(--ink);
white-space: nowrap;
}
.brand__mark {
width: 1.9rem;
height: 1.9rem;
border-radius: var(--radius-sm);
}
.brand__text {
display: grid;
gap: 0.05rem;
}
.brand__name {
font-size: var(--text-lg);
font-weight: 750;
letter-spacing: -0.025em;
line-height: 1.15;
}
.brand__role {
font-size: var(--text-xs);
color: var(--ink-3);
}
.rail__nav {
flex: 1;
overflow-x: hidden;
overflow-y: auto;
display: grid;
align-content: start;
gap: var(--space-2xs);
padding: var(--space-sm);
}
.rail__label {
padding: var(--space-xs) var(--space-xs) 0;
font-family: var(--font-mono);
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--ink-3);
}
.rail__link {
display: flex;
align-items: center;
gap: var(--space-2xs);
padding: 0.5rem 0.8rem;
border-radius: 999px;
font-weight: 520;
color: var(--ink-2);
text-decoration: none;
transition:
color 0.18s ease,
background-color 0.18s ease;
}
.rail__link svg {
width: 1.05rem;
height: 1.05rem;
flex-shrink: 0;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
@media (hover: hover) {
.rail__link:hover {
color: var(--ink);
background: var(--accent-wash);
}
}
.rail__link[aria-current="page"] {
color: var(--accent);
background: var(--accent-wash);
font-weight: 620;
}
/* The account anchors the topbar's right end: who this is, with the way
out standing apart to its left. The site header puts its controls
right, and so does this. */
.topbar__user {
display: flex;
align-items: center;
gap: var(--space-2xs);
}
.topbar__user-text {
display: grid;
gap: 0.05rem;
min-width: 0;
text-align: end;
}
.topbar__user-name {
font-weight: 620;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topbar__user-role {
font-size: var(--text-xs);
color: var(--ink-3);
text-transform: capitalize;
}
@media (max-width: 55.99rem) {
.topbar__user-text {
display: none;
}
}
.rail__version {
padding: var(--space-2xs) var(--space-md) var(--space-sm);
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--ink-3);
text-align: center;
}
/* The fold. The button lives at the head of the rail, and the head
script reads the device pin and sets data-rail before the first
paint; the attribute's very presence is the signal, so with no
JavaScript the toggle never shows and the rail stays full. Below
the rail's breakpoint the popover menu is the navigation and the
rail, toggle and all, is gone. */
.rail-toggle {
display: none;
flex-shrink: 0;
margin: var(--space-2xs) var(--space-sm) 0;
}
html[data-rail] .rail-toggle {
display: grid;
}
/* The icon points where clicking takes the rail: its 180-degree turn
in the compact state says so without a second icon. */
.rail-toggle svg {
transition: rotate 0.18s ease;
}
/* The column animates its whole width, so the fold slides. */
.shell {
transition: grid-template-columns 0.22s ease;
}
html[data-rail="compact"] {
--rail-w: 5.25rem;
}
html[data-rail="compact"] .rail-toggle {
margin-inline: auto;
}
html[data-rail="compact"] .rail-toggle svg {
rotate: 180deg;
}
html[data-rail="compact"] .rail__nav {
padding-inline: var(--space-3xs);
}
html[data-rail="compact"] .rail__link {
justify-content: center;
padding: 0.5rem;
}
/* The words outlive the last frame of the slide: display is only
surrendered after the fade, and @starting-style makes the fade run
in both directions. */
.rail__text,
.rail__version {
transition:
opacity 0.18s ease,
display 0.18s ease allow-discrete;
}
.rail__text {
white-space: nowrap;
}
html[data-rail="compact"] .rail__text,
html[data-rail="compact"] .rail__version {
display: none;
opacity: 0;
}
@starting-style {
html[data-rail="compact"] .rail__text,
html[data-rail="compact"] .rail__version {
opacity: 0;
}
}
/* The group eyebrow becomes the rule that separates the groups: its
words stay in the accessibility tree behind a zeroed font. The
first one has no group above it, so it folds away entirely, the
topbar's edge being the divider it would otherwise repeat. */
html[data-rail="compact"] .rail__label {
height: 0;
margin: var(--space-xs) var(--space-md) 0;
padding: 0;
border-top: 1px solid var(--line);
font-size: 0;
line-height: 0;
overflow: hidden;
}
html[data-rail="compact"] .rail__label:first-child {
margin: 0;
border: 0;
}
.avatar {
display: grid;
place-items: center;
width: 2.1rem;
height: 2.1rem;
flex-shrink: 0;
border-radius: 999px;
background: var(--accent-wash);
color: var(--accent);
font-weight: 650;
overflow: hidden;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* The topbar is an opaque surface band: it reads as one continuous
piece of chrome with the rail below it, and it stands apart from
the tinted canvas the content sits on. Glass would melt it into
the page background, which is the opposite of the point. */
.topbar {
position: sticky;
z-index: 20;
top: 0;
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: var(--space-xs);
min-height: var(--header-h);
padding-inline: var(--gutter);
border-bottom: 1px solid var(--line);
background: var(--surface);
}
/* The path rides the top of the page as a quiet line, not as a second
title in the topbar; only pages deeper than one section carry it. */
.crumbs {
display: flex;
align-items: center;
gap: var(--space-3xs);
min-width: 0;
overflow: hidden;
white-space: nowrap;
margin-bottom: var(--space-md);
font-size: var(--text-sm);
color: var(--ink-3);
}
.crumbs a {
color: var(--ink-3);
text-decoration: none;
}
@media (hover: hover) {
.crumbs a:hover {
color: var(--accent);
}
}
.crumbs__current {
color: var(--ink-2);
font-weight: 550;
}
.topbar__tools {
margin-inline-start: auto;
display: flex;
align-items: center;
gap: var(--space-xs);
}
/* The rule that parts the device controls from the account group: the
scheme is this device's, the session is this account's, and the chrome
reads calmer when the two are never mistaken for one cluster. */
.topbar__sep {
flex-shrink: 0;
width: 1px;
height: 1.5rem;
border-radius: 1px;
background: var(--line-strong);
}
.icon-btn {
display: grid;
place-items: center;
width: 2.3rem;
height: 2.3rem;
padding: 0;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--ink-2);
cursor: pointer;
text-decoration: none;
transition:
color 0.18s ease,
background-color 0.18s ease;
}
.icon-btn svg {
width: 1.15rem;
height: 1.15rem;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
@media (hover: hover) {
.icon-btn:hover {
color: var(--accent);
background: var(--accent-wash);
}
}
.icon-btn--danger {
color: var(--danger);
}
@media (hover: hover) {
.icon-btn--danger:hover {
background: color-mix(in oklab, var(--danger) 12%, transparent);
}
}
/* The menu toggle only earns its place when the rail is gone. */
.menu-toggle {
display: none;
}
@media (max-width: 55.99rem) {
.menu-toggle {
display: grid;
}
}
/* One icon per mode: the monitor stands for following the system, and
the sun and moon for the two pins. Exactly one shows at a time. */
.theme-toggle svg {
display: none;
}
:root:not([data-scheme]) .theme-toggle .icon--system {
display: block;
}
:root[data-scheme="light"] .theme-toggle .icon--sun {
display: block;
}
:root[data-scheme="dark"] .theme-toggle .icon--moon {
display: block;
}
/* The mobile navigation sheet: a popover in the top layer, so no
z-index war and no class juggling. */
.menu {
position: fixed;
inset: auto 0.6rem auto 0.6rem;
top: calc(var(--header-h) + 0.5rem);
width: auto;
margin: 0;
padding: var(--space-xs);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
box-shadow: var(--shadow-lg);
opacity: 0;
translate: 0 -0.5rem;
transition:
opacity 0.18s ease,
translate 0.18s ease,
display 0.18s allow-discrete,
overlay 0.18s allow-discrete;
}
.menu:popover-open {
opacity: 1;
translate: 0;
}
@starting-style {
.menu:popover-open {
opacity: 0;
translate: 0 -0.5rem;
}
}
.menu::backdrop {
background: color-mix(in oklab, oklch(18% 0.02 268) 40%, transparent);
}
.menu ul {
display: grid;
gap: 2px;
list-style: none;
padding-inline-start: 0;
}
.menu a {
display: flex;
align-items: center;
gap: var(--space-2xs);
padding: 0.7rem 0.9rem;
border-radius: var(--radius-sm);
font-weight: 550;
color: var(--ink);
text-decoration: none;
}
.menu a svg {
width: 1.1rem;
height: 1.1rem;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
@media (hover: hover) {
.menu a:hover {
background: var(--accent-wash);
}
}
.menu a[aria-current="page"] {
color: var(--accent);
background: var(--accent-wash);
}
/* --- Primitives: buttons, chips, badges, cards ----------------------- */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1.2rem;
border: 1px solid transparent;
border-radius: 999px;
background: var(--surface-2);
color: var(--ink);
font-size: var(--text-sm);
font-weight: 620;
text-decoration: none;
cursor: pointer;
transition:
translate 0.18s ease,
box-shadow 0.18s ease,
background-color 0.18s ease,
border-color 0.18s ease,
color 0.18s ease,
filter 0.18s ease;
}
.btn svg {
width: 1rem;
height: 1rem;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
@media (hover: hover) {
.btn:hover {
translate: 0 -1px;
}
}
.btn--primary {
background: var(--gradient);
color: var(--on-accent);
border-color: transparent;
box-shadow: 0 8px 26px -8px var(--glow);
}
@media (hover: hover) {
.btn--primary:hover {
filter: brightness(1.08);
box-shadow: 0 12px 32px -8px var(--glow);
}
}
.btn--ghost {
border-color: var(--line-strong);
background: var(--surface);
}
@media (hover: hover) {
.btn--ghost:hover {
border-color: var(--accent);
color: var(--accent);
background: var(--surface);
}
}
.btn--danger {
color: var(--danger);
border-color: color-mix(in oklab, var(--danger) 35%, transparent);
background: transparent;
}
@media (hover: hover) {
.btn--danger:hover {
background: var(--danger);
border-color: var(--danger);
color: var(--surface);
}
}
.btn--sm {
padding: 0.32rem 0.8rem;
font-size: var(--text-xs);
}
.btn[disabled] {
opacity: 0.55;
cursor: not-allowed;
translate: none;
}
/* The filter chip: a pill that toggles a list, and carries its weight in
a count when it has one. */
.chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.32rem 0.85rem;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface);
box-shadow: var(--shadow-sm);
color: var(--ink-2);
font: inherit;
font-size: var(--text-xs);
font-weight: 560;
cursor: pointer;
text-decoration: none;
transition:
border-color 0.15s ease,
background-color 0.15s ease,
color 0.15s ease;
}
@media (hover: hover) {
.chip:hover {
color: var(--ink);
border-color: var(--line-strong);
}
}
.chip[aria-pressed="true"] {
background: var(--accent-wash);
border-color: color-mix(in oklab, var(--accent) 35%, transparent);
color: var(--accent);
font-weight: 650;
}
.chip__count {
color: var(--ink-3);
font-size: 0.72em;
font-variant-numeric: tabular-nums;
}
/* The language switch inside a card: the chip in the small mono form
the site's tags wear. */
.post__langs {
display: inline-flex;
flex-wrap: wrap;
gap: 3px;
}
.chip--lang {
padding: 0.1rem 0.55rem;
font-family: var(--font-mono);
font-size: 0.7rem;
text-transform: lowercase;
}
/* The status badge: a tiny pill of meaning, mono where it is data. */
.tag {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.16rem 0.6rem;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface-2);
color: var(--ink-2);
font-family: var(--font-mono);
font-size: 0.7rem;
font-weight: 520;
letter-spacing: 0.03em;
text-transform: lowercase;
}
.tag--warn {
border-color: color-mix(in oklab, var(--warn) 40%, transparent);
background: color-mix(in oklab, var(--warn) 12%, var(--surface));
color: var(--warn);
}
.tag--accent {
border-color: color-mix(in oklab, var(--accent) 40%, transparent);
background: var(--accent-wash);
color: var(--accent);
}
.tag--ok {
border-color: color-mix(in oklab, var(--ok) 40%, transparent);
background: color-mix(in oklab, var(--ok) 12%, var(--surface));
color: var(--ok);
}
/* The card: the one surface every panel, tile and dialog body wears. */
.card {
position: relative;
display: grid;
align-content: start;
gap: var(--space-md);
padding: var(--space-lg);
border: 1px solid color-mix(in oklab, var(--line) 88%, transparent);
border-radius: var(--radius);
background: color-mix(in oklab, var(--surface) 88%, transparent);
box-shadow: var(--shadow-sm);
}
.card + .card {
margin-top: var(--space-md);
}
.card--flush {
padding: 0;
overflow: hidden;
}
/* The flush card's head is inset like in every other card; only the rows
beneath it run edge to edge. Without this the head sits on the border
and its note is clipped by the card's own overflow. */
.card--flush > .card__head {
padding: var(--space-lg) var(--space-lg) 0;
}
.card__head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: var(--space-xs);
}
.card__head h2 {
font-size: var(--text-lg);
}
.card__note {
font-size: var(--text-xs);
color: var(--ink-3);
}
.card__icon {
display: grid;
place-items: center;
width: 2.4rem;
height: 2.4rem;
flex-shrink: 0;
border-radius: 12px;
background: var(--accent-wash);
color: var(--accent);
}
.card__icon svg {
width: 1.25rem;
height: 1.25rem;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
/* The status strip: a sentence about what just happened. */
.status {
display: flex;
gap: var(--space-xs);
align-items: flex-start;
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--line);
border-left: 3px solid var(--line-strong);
border-radius: var(--radius-sm);
background: var(--surface);
font-size: var(--text-sm);
}
.status svg {
width: 1.15rem;
height: 1.15rem;
flex-shrink: 0;
margin-top: 0.15rem;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
/* A status separates from whatever follows it, not only from another
status or a form: the dashboard grid and the settings panels land
right beneath it too. */
.status + *,
form + .status {
margin-top: var(--space-2xs);
}
.status--ok {
border-color: color-mix(in oklab, var(--ok) 45%, transparent);
border-left-color: var(--ok);
background: color-mix(in oklab, var(--ok) 8%, var(--surface));
}
.status--error {
border-color: color-mix(in oklab, var(--danger) 45%, transparent);
border-left-color: var(--danger);
background: color-mix(in oklab, var(--danger) 8%, var(--surface));
}
.status--warn {
border-color: color-mix(in oklab, var(--warn) 45%, transparent);
border-left-color: var(--warn);
background: color-mix(in oklab, var(--warn) 10%, var(--surface));
}
.status--info {
border-color: color-mix(in oklab, var(--accent) 45%, transparent);
border-left-color: var(--accent);
background: var(--accent-wash);
}
.status__actions {
margin-inline-start: auto;
display: flex;
align-items: center;
gap: var(--space-2xs);
}
/* --- Forms ------------------------------------------------------------ */
/* A field packs to the top of whatever cell it sits in: the label
line, the control line and the notes below never drift, however
tall the neighbouring field grew (a stretched grid otherwise
distributes its free space into the rows and pushes the control
down). */
.field {
display: grid;
gap: var(--space-2xs);
align-content: start;
}
.field__label {
font-size: var(--text-sm);
font-weight: 620;
}
.field__hint {
font-size: var(--text-xs);
color: var(--ink-3);
}
/* The wizard's hints sit on the login panel over the dotted lattice,
where the quiet grey reads below the contrast floor: they take the
second ink there. */
.setup .field__hint {
color: var(--ink-2);
}
.control {
width: 100%;
padding: 0.62rem 0.9rem;
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--ink);
font-size: var(--text-sm);
/* One leading for every control face: an input and a select of the
same class must stand at the same height side by side, which the
engines' own metrics do not give them. */
line-height: 1.5;
transition:
border-color 0.18s ease,
box-shadow 0.18s ease;
}
.control:hover {
border-color: var(--ink-3);
}
.control:focus {
border-color: var(--accent);
outline: none;
box-shadow: 0 0 0 3px var(--accent-wash);
}
.control::placeholder {
color: var(--ink-3);
}
/* One face height for the single-line controls: the engines give an
input and a select of the same class different boxes, and a mono
input stands taller than its proportional neighbour. A fixed height
is the only thing that stays equal across them, so a form's controls
and its button sit on one line. */
select.control,
input.control:not([type="checkbox"]):not([type="radio"]):not([type="file"]) {
height: 2.75rem;
}
select.control.control--sm,
input.control.control--sm:not([type="checkbox"]):not([type="radio"]):not([type="file"]) {
height: 2.1rem;
}
.control[readonly],
.control:disabled {
background: var(--surface-2);
color: var(--ink-3);
}
textarea.control {
min-height: 7rem;
resize: vertical;
line-height: 1.6;
}
textarea.control--mono {
font-family: var(--font-mono);
font-size: var(--text-sm);
}
.control[aria-invalid="true"],
.control.is-invalid {
border-color: var(--danger);
}
.control.is-ok {
border-color: var(--ok);
}
.field:has(.control.is-ok, .control.is-invalid) .field__hint.is-live {
font-weight: 600;
}
.control--sm {
padding: 0.3rem 0.6rem;
font-size: var(--text-xs);
width: auto;
}
.check {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-xs);
align-items: start;
cursor: pointer;
}
.check input {
width: 1.05rem;
height: 1.05rem;
margin-top: 0.22rem;
accent-color: var(--accent);
}
.check span {
font-size: var(--text-sm);
}
.check-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-md);
}
input[type="file"] {
padding: 0.5rem 0.7rem;
width: 100%;
border: 1px dashed var(--line-strong);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--ink-2);
font-size: var(--text-sm);
cursor: pointer;
}
input[type="file"]::file-selector-button {
margin-inline-end: var(--space-xs);
padding: 0.3rem 0.8rem;
border: 0;
border-radius: 999px;
background: var(--surface-2);
color: var(--ink);
font: inherit;
font-weight: 620;
cursor: pointer;
}
/* --- Tables ------------------------------------------------------------ */
.table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
.table th,
.table td {
padding: var(--space-2xs) var(--space-sm);
border-bottom: 1px solid var(--line);
text-align: start;
vertical-align: middle;
}
.table th {
background: var(--surface-2);
font-size: var(--text-xs);
font-weight: 620;
color: var(--ink-2);
}
.table tbody tr:last-child td {
border-bottom: 0;
}
@media (hover: hover) {
.table tbody tr:hover td {
background: var(--accent-wash);
}
}
.table__actions {
text-align: end;
white-space: nowrap;
}
.table__actions form {
display: inline;
}
/* --- The dashboard ------------------------------------------------------ */
.stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--line);
box-shadow: var(--shadow-sm);
margin-bottom: var(--space-md);
}
@media (min-width: 48rem) {
.stats {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.stats > div {
display: grid;
gap: var(--space-3xs);
padding: var(--space-md);
background: color-mix(in oklab, var(--surface) 92%, transparent);
}
.stats dd {
font-family: var(--font-mono);
font-size: clamp(1.6rem, 2vw + 0.9rem, 2.1rem);
font-weight: 700;
line-height: 1.1;
background: var(--gradient);
background-clip: text;
color: transparent;
}
.stats dt {
order: 2;
font-size: var(--text-xs);
font-weight: 560;
/* The caption sits on the washed cell, so it takes the ink the body
text takes: the quiet grey reads below the contrast floor there. */
color: var(--ink-2);
}
/* The quick list: recent and upcoming posts on the dashboard. */
.quick {
display: grid;
gap: 1px;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--line);
}
.quick__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: var(--surface);
color: var(--ink);
text-decoration: none;
min-width: 0;
transition: background-color 0.15s ease;
}
@media (hover: hover) {
.quick__row:hover {
background: var(--accent-wash);
}
}
.quick__title {
font-weight: 550;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.quick__when {
flex-shrink: 0;
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--ink-3);
font-variant-numeric: tabular-nums;
}
/* --- The toolbar over a list -------------------------------------------- */
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2xs);
margin-block: var(--space-md);
}
.search {
position: relative;
flex: 1;
max-width: 24rem;
min-width: 12rem;
}
.search .control {
padding-inline-start: 2.4rem;
border-radius: 999px;
}
.search > svg {
position: absolute;
left: 0.85rem;
top: 50%;
translate: 0 -50%;
width: 1.05rem;
height: 1.05rem;
color: var(--ink-3);
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-3xs);
}
/* The filter groups carry a leading glyph naming what they filter by. */
.filters > svg {
width: 1.05rem;
height: 1.05rem;
flex-shrink: 0;
color: var(--ink-3);
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.toolbar__count {
margin-inline-start: auto;
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--ink-3);
font-variant-numeric: tabular-nums;
}
.toolbar__count.is-empty {
color: var(--warn);
}
/* --- The posts grid ------------------------------------------------------ */
.post-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
gap: var(--space-md);
}
.post {
position: relative;
display: flex;
flex-direction: column;
border: 1px solid color-mix(in oklab, var(--line) 88%, transparent);
border-radius: var(--radius);
background: color-mix(in oklab, var(--surface) 88%, transparent);
box-shadow: var(--shadow-sm);
overflow: hidden;
transition:
translate 0.22s ease,
box-shadow 0.22s ease,
border-color 0.22s ease;
}
@media (hover: hover) {
.post:hover {
translate: 0 -3px;
border-color: color-mix(in oklab, var(--accent) 30%, var(--line));
box-shadow: var(--shadow-md);
}
.post:hover .post__title {
color: var(--accent);
}
}
.post.is-hidden {
display: none;
}
.post__link {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
color: inherit;
text-decoration: none;
}
.post__cover {
aspect-ratio: 16 / 9;
background: var(--surface-2) var(--cover-url, none) center / cover;
border-bottom: 1px solid var(--line);
display: grid;
place-items: center;
color: var(--ink-3);
}
.post__cover svg {
width: 1.9rem;
height: 1.9rem;
fill: none;
stroke: currentColor;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.post__cover[data-cover] svg {
display: none;
}
.post__body {
display: grid;
gap: var(--space-2xs);
align-content: start;
padding: var(--space-md);
flex: 1;
}
.post__title {
font-size: var(--text-lg);
letter-spacing: -0.015em;
transition: color 0.18s ease;
}
.post__excerpt {
font-size: var(--text-sm);
color: var(--ink-2);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.post__meta {
display: flex;
flex-wrap: wrap;
gap: var(--space-3xs);
align-items: center;
margin-top: auto;
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--ink-3);
}
.post__date {
margin-inline-start: auto;
font-variant-numeric: tabular-nums;
/* The card's washed surface costs the quiet grey its contrast: the
date is text, so it takes the second ink to clear the floor. */
color: var(--ink-2);
}
.post__check {
position: absolute;
top: var(--space-2xs);
left: var(--space-2xs);
z-index: 2;
display: inline-flex;
padding: 0.32rem 0.42rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: color-mix(in oklab, var(--surface) 85%, transparent);
backdrop-filter: blur(6px);
opacity: 0;
transition: opacity 0.15s ease;
}
.post__check input {
width: 1rem;
height: 1rem;
margin: 0;
accent-color: var(--accent);
}
@media (hover: hover) {
.post:hover .post__check,
.post:focus-within .post__check {
opacity: 1;
}
}
.post__check:has(input:checked) {
opacity: 1;
}
/* Touch devices have no hover: keep the bulk checkboxes reachable. */
@media (hover: none) {
.post__check {
opacity: 1;
}
}
.post__foot {
display: flex;
align-items: center;
gap: var(--space-3xs);
padding: var(--space-2xs) var(--space-xs);
border-top: 1px solid var(--line);
background: color-mix(in oklab, var(--surface-2) 60%, var(--surface));
}
.post__foot .icon-btn {
width: 2rem;
height: 2rem;
}
.post__foot .icon-btn svg {
width: 1rem;
height: 1rem;
}
/* The select-all rides at the start of the toolbar. */
.bulk-all {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.45rem 0.55rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
}
.bulk-all input {
width: 1rem;
height: 1rem;
margin: 0;
accent-color: var(--accent);
}
.bulk-all.is-empty {
visibility: hidden;
}
/* The bulk bar: what the selection can do. */
.bulk-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2xs);
padding: var(--space-2xs) var(--space-sm);
border: 1px solid color-mix(in oklab, var(--accent) 30%, transparent);
border-radius: 999px;
background: var(--accent-wash);
margin-bottom: var(--space-md);
}
.bulk-bar__label {
padding-inline: var(--space-2xs);
color: var(--accent);
font-weight: 620;
}
.bulk-bar__spacer {
flex: 1;
}
/* --- The empty state ----------------------------------------------------- */
.empty {
display: grid;
place-items: center;
gap: var(--space-xs);
padding: var(--space-2xl) var(--space-md);
border: 1px dashed var(--line-strong);
border-radius: var(--radius);
color: var(--ink-2);
text-align: center;
}
.empty svg {
width: 2.2rem;
height: 2.2rem;
color: var(--ink-3);
fill: none;
stroke: currentColor;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.empty__note {
max-width: 42ch;
font-size: var(--text-sm);
color: var(--ink-3);
}
/* A no-results state inside a grid spans the whole row. */
.empty--span {
grid-column: 1 / -1;
}
/* --- The editor ----------------------------------------------------------- */
/* The tab strip: a pill track with the raised active face, the same
control language as the language switch. */
.tabs {
display: inline-flex;
gap: 2px;
align-self: start;
padding: 3px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface-2);
}
.tabs button {
padding: 0.35rem 0.95rem;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--ink-3);
font: inherit;
font-size: var(--text-sm);
font-weight: 560;
cursor: pointer;
transition:
color 0.15s ease,
background-color 0.15s ease;
}
@media (hover: hover) {
.tabs button:hover {
color: var(--ink);
}
}
.tabs button[aria-selected="true"] {
background: var(--surface);
color: var(--ink);
box-shadow: var(--shadow-sm);
font-weight: 620;
}
.editor {
display: grid;
gap: var(--space-md);
}
/* The writing column: one wide pane unless the live preview needs both. */
.editor:not(.is-preview) {
max-width: 68rem;
}
.narrow-start {
max-width: 20rem;
}
.editor.is-preview {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
@media (max-width: 70rem) {
.editor.is-preview {
grid-template-columns: minmax(0, 1fr);
}
}
.editor__pane {
position: relative;
display: flex;
flex-direction: column;
min-width: 0;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
overflow: hidden;
}
.editor__head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px;
padding: var(--space-3xs) var(--space-xs);
border-bottom: 1px solid var(--line);
background: color-mix(in oklab, var(--surface-2) 60%, var(--surface));
}
.editor__label {
padding-inline: var(--space-2xs);
font-family: var(--font-mono);
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--ink-3);
}
.editor__head .flex {
flex: 1;
}
.tool {
display: grid;
place-items: center;
min-width: 1.9rem;
height: 1.9rem;
padding: 0 0.5rem;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink-2);
font: inherit;
font-size: var(--text-sm);
cursor: pointer;
transition:
background-color 0.12s ease,
color 0.12s ease;
}
.tool svg {
width: 1rem;
height: 1rem;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
@media (hover: hover) {
.tool:hover {
background: var(--accent-wash);
color: var(--ink);
}
}
.tool.is-active {
background: var(--accent);
color: var(--on-accent);
}
.tool--bold {
font-weight: 700;
}
.tool--italic {
font-style: italic;
}
.tool--wide {
font-family: var(--font-mono);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.editor__input {
flex: 1;
border: 0;
border-radius: 0;
min-height: 32rem;
padding: var(--space-lg) var(--space-xl);
background: var(--surface);
color: var(--ink);
font-family: var(--font-mono);
font-size: var(--text-sm);
line-height: 1.7;
resize: vertical;
width: 100%;
}
.editor__input:focus {
outline: none;
box-shadow: inset 0 0 0 2px var(--accent-wash);
}
.editor__preview {
min-height: 32rem;
overflow: auto;
padding: var(--space-lg) var(--space-xl);
background: var(--surface);
}
.editor__status {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-sm);
padding: var(--space-2xs) var(--space-xs);
font-family: var(--font-mono);
font-size: var(--text-xs);
letter-spacing: 0.03em;
color: var(--ink-3);
font-variant-numeric: tabular-nums;
}
.editor__status .flex {
flex: 1;
}
.editor__saved {
color: var(--ok);
opacity: 0;
transition: opacity 0.4s ease;
}
.editor__pane.is-drop .editor__input {
box-shadow: inset 0 0 0 2px var(--accent);
}
.drop-overlay {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2xs);
background: color-mix(in oklab, var(--accent-wash) 92%, transparent);
border: 2px dashed var(--accent);
border-radius: var(--radius);
color: var(--accent);
font-weight: 700;
pointer-events: none;
}
.drop-overlay svg {
width: 1.2rem;
height: 1.2rem;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
/* The cover field: a row of control and actions, and the pick. */
.cover-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-2xs);
}
.cover-row .control {
flex: 1;
min-width: 12rem;
}
.cover-preview {
max-height: 12rem;
width: auto;
max-width: 100%;
border: 1px solid var(--line);
border-radius: var(--radius);
object-fit: cover;
}
/* The bibliography card rides the standard field, tag and button
vocabulary; the only thing it adds is the bounded list, which scrolls
inside itself so a volume with a hundred references never stretches
the editor page past its own content. */
.refs-scroll {
max-height: 32rem;
overflow-y: auto;
padding-block-end: var(--space-3xs);
}
.ref-row {
display: grid;
gap: var(--space-xs);
}
.ref-row + .ref-row {
margin-top: var(--space-md);
}
.ref-row__head {
display: flex;
align-items: center;
gap: var(--space-2xs);
}
/* The authors and the title run the full width inside the structured
grid; everything else pairs up in the two columns. */
.ref-row .field--wide {
grid-column: 1 / -1;
}
/* The rendered Markdown: one reading column, the article set. */
.prose {
max-width: var(--measure);
line-height: 1.75;
}
.prose > * + * {
margin-top: var(--space-md);
}
.prose h1,
.prose h2,
.prose h3,
.prose h4 {
margin-top: var(--space-xl);
}
.prose h2 {
font-size: var(--text-2xl);
}
.prose h3 {
font-size: var(--text-xl);
}
.prose ul,
.prose ol {
padding-left: 1.4rem;
}
.prose li + li {
margin-top: var(--space-2xs);
}
.prose blockquote {
padding: var(--space-sm) var(--space-md);
border-left: 3px solid var(--accent);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
background: var(--accent-wash);
color: var(--ink-2);
}
.prose pre {
overflow-x: auto;
padding: var(--space-md);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface-2);
font-size: var(--text-sm);
line-height: 1.6;
}
.prose pre code {
padding: 0;
border: 0;
background: none;
}
.prose table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--line);
}
.prose th,
.prose td {
padding: var(--space-2xs) var(--space-sm);
border-bottom: 1px solid var(--line);
text-align: start;
}
.prose th {
background: var(--surface-2);
font-weight: 620;
}
.prose img {
max-width: 100%;
border-radius: var(--radius-sm);
}
.prose hr {
border: 0;
border-top: 1px solid var(--line);
}
.prose [contenteditable]:focus {
outline: none;
}
/* --- Bibliography -------------------------------------------------------- */
/* The reference list the engine splices at the [[refs]] marker, and the
inline [n] citations that point into it. */
.refs {
font-size: var(--text-sm);
color: var(--ink-2);
}
.refs ol {
padding-left: 1.6rem;
display: grid;
gap: var(--space-2xs);
}
.refs li {
text-indent: -0.2rem;
}
.ref-cite {
font-family: var(--font-mono);
font-size: 0.82em;
text-decoration: none;
color: var(--accent);
vertical-align: 0.2em;
}
@media (hover: hover) {
.ref-cite:hover {
text-decoration: underline;
}
}
.refs-link {
font-family: var(--font-mono);
font-size: 0.86em;
white-space: nowrap;
}
/* --- The drop zone -------------------------------------------------------- */
.dropzone {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2xs);
padding: var(--space-2xl) var(--space-lg) var(--space-xl);
border: 2px dashed var(--line-strong);
border-radius: var(--radius);
background: var(--surface);
text-align: center;
cursor: pointer;
transition:
border-color 0.16s ease,
background-color 0.16s ease;
}
.dropzone--row {
flex-direction: row;
text-align: start;
padding: var(--space-md) var(--space-lg);
gap: var(--space-md);
}
.dropzone__input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
@media (hover: hover) {
.dropzone:hover {
border-color: var(--accent);
background: var(--accent-wash);
}
}
.dropzone:focus-visible {
border-color: var(--accent);
outline: none;
box-shadow: 0 0 0 3px var(--accent-wash);
}
.dropzone.is-over {
border-style: solid;
border-color: var(--accent);
background: var(--accent-wash);
}
.dropzone.has-file {
border-style: solid;
border-color: var(--ok);
}
.dropzone.is-rejected {
border-style: solid;
border-color: var(--danger);
}
.dropzone__icon {
display: grid;
place-items: center;
width: 3.2rem;
height: 3.2rem;
flex-shrink: 0;
border-radius: 999px;
background: var(--accent-wash);
color: var(--accent);
}
.dropzone__icon svg {
width: 1.5rem;
height: 1.5rem;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.dropzone--row .dropzone__icon {
width: 2.4rem;
height: 2.4rem;
}
/* The drop zone's text stack: spans, so they need the block flow. */
.dropzone > span:not(.dropzone__icon) {
display: grid;
gap: var(--space-3xs);
}
.dropzone__title {
font-size: var(--text-lg);
font-weight: 650;
letter-spacing: -0.015em;
}
.dropzone__sub {
font-size: var(--text-sm);
color: var(--ink-2);
}
.dropzone__hint {
font-family: var(--font-mono);
font-size: var(--text-xs);
letter-spacing: 0.04em;
color: var(--ink-3);
}
.dropzone__error {
font-size: var(--text-sm);
font-weight: 600;
color: var(--danger);
}
/* The file the drop zone accepted. */
.file-row {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.file-row__ok {
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
flex-shrink: 0;
border-radius: 999px;
background: color-mix(in oklab, var(--ok) 14%, var(--surface));
color: var(--ok);
}
.file-row__ok svg {
width: 1rem;
height: 1rem;
fill: none;
stroke: currentColor;
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.file-row__info {
flex: 1;
min-width: 0;
}
.file-row__name {
font-weight: 620;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-row__meta {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--ink-3);
font-variant-numeric: tabular-nums;
}
.kv {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: var(--space-3xs) var(--space-md);
font-family: var(--font-mono);
font-size: var(--text-sm);
}
.kv dt {
color: var(--accent);
}
.kv dd {
word-break: break-all;
}
.break-all {
word-break: break-all;
}
/* The hint that reports a state: the slug check, the schedule warning. */
.field__hint.hint--ok {
color: var(--ok);
font-weight: 550;
}
.field__hint.hint--error {
color: var(--danger);
font-weight: 550;
}
.field__hint.hint--warn {
color: var(--warn);
font-weight: 550;
}
/* --- Media ----------------------------------------------------------------- */
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
gap: var(--space-md);
}
/* The tiles are rows of this grid, not stacked cards: the sibling
margin the plain card flow earns would drop every row but the first
below its neighbours, and the grid gap already spaces them. */
.media-grid > .card + .card {
margin-top: 0;
}
.media-tile {
display: flex;
flex-direction: column;
gap: var(--space-2xs);
padding: var(--space-xs);
}
.media-tile.is-hidden {
display: none;
}
.media-tile__thumb {
aspect-ratio: 4 / 3;
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--surface-2);
}
.media-tile__thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.media-tile__name {
font-weight: 560;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-tile__meta {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--ink-3);
font-variant-numeric: tabular-nums;
}
.media-tile__actions {
display: flex;
align-items: center;
gap: 2px;
margin-top: auto;
}
.media-tile__actions .icon-btn {
width: 1.9rem;
height: 1.9rem;
}
.media-tile__actions .icon-btn svg {
width: 1rem;
height: 1rem;
}
/* --- Settings ---------------------------------------------------------------- */
.settings {
display: grid;
gap: var(--space-lg);
align-items: start;
}
@media (min-width: 56rem) {
.settings {
grid-template-columns: 15rem minmax(0, 1fr);
}
}
/* The panels switch one at a time, so the card rule's sibling margin
would push every section but the first 24 px below the nav even while
its hidden siblings carry no weight. The grid gap spaces the no-JS
page, where all the sections show at once. */
.settings__panels {
display: grid;
gap: var(--space-md);
}
.settings__panels > .card + .card {
margin-top: 0;
}
.settings__nav {
display: flex;
gap: var(--space-3xs);
overflow-x: auto;
padding: var(--space-3xs);
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface);
}
@media (min-width: 56rem) {
.settings__nav {
display: grid;
position: sticky;
top: calc(var(--header-h) + var(--space-sm));
overflow: visible;
padding: var(--space-2xs);
border-radius: var(--radius);
}
}
.settings__link {
display: flex;
align-items: center;
gap: var(--space-2xs);
padding: 0.5rem 0.9rem;
border-radius: 999px;
color: var(--ink-2);
font-weight: 550;
text-decoration: none;
white-space: nowrap;
transition:
color 0.15s ease,
background-color 0.15s ease;
}
.settings__link svg {
width: 1.05rem;
height: 1.05rem;
flex-shrink: 0;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
@media (hover: hover) {
.settings__link:hover {
color: var(--ink);
background: var(--accent-wash);
}
}
.settings__link[aria-current="true"] {
color: var(--accent);
background: var(--accent-wash);
font-weight: 620;
}
/* One setting: the icon square, the body, and its controls. */
.setting {
display: flex;
gap: var(--space-md);
padding: var(--space-md) 0;
border-top: 1px solid var(--line);
}
.card > .setting:first-of-type {
border-top: 0;
padding-top: 0;
}
@media (max-width: 40rem) {
.setting {
flex-direction: column;
gap: var(--space-xs);
}
}
.setting__body {
flex: 1;
min-width: 0;
display: grid;
gap: var(--space-xs);
max-width: 46rem;
}
.setting__title {
font-size: var(--text-lg);
}
.setting__desc {
font-size: var(--text-sm);
color: var(--ink-2);
}
/* A settings form is the site's form contract: fields stack, their
notes hang under the control they belong to, and the action closes
the form on its own line. Nothing ever sits beside a labelled
field, so no hint, meter or any element yet to come can push an
action off the control's line. */
.setting__row {
display: grid;
gap: var(--space-sm);
}
.setting__row > button {
justify-self: start;
}
/* The template list: one row per template, its name and pre-filled
fields on the left and the delete action on the right, parted by the
row rule the user rows wear. */
.kv-stack {
display: grid;
}
.kv-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: var(--space-sm) 0;
border-top: 1px solid var(--line);
}
.kv-row:first-child {
border-top: 0;
}
.kv-row > span {
min-width: 0;
}
/* The config example wears the code-block face the prose pre wears:
mono on the quiet surface, scrolling sideways rather than wrapping. */
.webhook-example {
overflow-x: auto;
padding: var(--space-md);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface-2);
font-family: var(--font-mono);
font-size: var(--text-sm);
line-height: 1.6;
}
.webhook-example code {
padding: 0;
border: 0;
background: none;
}
.pill-group {
display: inline-flex;
flex-wrap: wrap;
gap: var(--space-3xs);
}
/* A user of the instance, one row in the card. */
.user-row {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-lg);
border-top: 1px solid var(--line);
}
.user-row:first-child {
border-top: 0;
}
.user-row__info {
flex: 1;
min-width: 0;
display: grid;
}
.user-row__name {
font-weight: 620;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-row__username {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--ink-3);
}
.user-row__actions {
display: flex;
align-items: center;
gap: var(--space-3xs);
}
.user-row__reset {
position: relative;
}
.user-row__reset summary {
list-style: none;
cursor: pointer;
}
.user-row__reset summary::-webkit-details-marker {
display: none;
}
.user-row__reset[open] summary {
color: var(--accent);
}
/* The reset popover wears the same form contract as every other
form: the field, its meter, then the action on its own line. */
.user-row__reset-form {
position: absolute;
inset-inline-end: 0;
top: calc(100% + var(--space-3xs));
z-index: 5;
display: grid;
gap: var(--space-2xs);
min-width: 20rem;
padding: var(--space-xs);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
box-shadow: var(--shadow-lg);
}
.user-row__reset-form > button {
justify-self: start;
}
/* The second factor block inside Security: the QR is a white card
whatever the scheme, because a camera refuses a symbol that
follows the page's colours. The recovery codes sit on it once. */
.totp {
display: grid;
gap: var(--space-sm);
padding-top: var(--space-md);
border-top: 1px dashed var(--line);
}
.totp__title {
font-size: var(--text-base);
font-weight: 620;
}
.totp__setup {
display: grid;
gap: var(--space-md);
}
.totp__qr {
justify-self: start;
padding: var(--space-xs);
border-radius: var(--radius-sm);
background: #fff;
box-shadow: var(--shadow-sm);
}
.totp__qr svg {
display: block;
width: 11rem;
height: auto;
}
.totp__setup-side {
display: grid;
gap: var(--space-sm);
justify-items: start;
}
.totp__secret-label {
font-size: var(--text-sm);
font-weight: 620;
color: var(--ink-2);
}
.totp__secret {
padding: 0.3rem 0.6rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface-2);
font-size: var(--text-sm);
letter-spacing: 0.05em;
word-break: break-all;
}
.totp__form {
display: grid;
gap: var(--space-sm);
justify-items: start;
}
.totp__forms {
display: grid;
gap: var(--space-md);
}
@media (min-width: 40rem) {
.totp__setup {
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
}
.totp__forms {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.recovery {
display: grid;
gap: var(--space-2xs);
}
@media (min-width: 30rem) {
.recovery {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.recovery__code {
padding: 0.35rem 0.6rem;
border: 1px dashed var(--line-strong);
border-radius: var(--radius-sm);
font-size: var(--text-sm);
letter-spacing: 0.08em;
}
/* The add-user form closes the flush users card: it carries the row's
own padding and divider, so it reads as the last row of the list
rather than a form pasted to the card's edge. */
.user-add {
display: grid;
gap: var(--space-md);
padding: var(--space-sm) var(--space-lg) var(--space-lg);
border-top: 1px solid var(--line);
}
/* The button is a button, not a bar: it keeps its own width. */
.user-add > button {
justify-self: start;
}
/* The photo pick. */
.photo-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-md);
}
.photo-row__preview {
width: 5rem;
height: 5rem;
flex-shrink: 0;
border: 2px solid var(--line);
border-radius: 999px;
object-fit: cover;
}
.photo-row__placeholder {
display: grid;
place-items: center;
background: var(--accent-wash);
color: var(--accent);
font-size: var(--text-xl);
font-weight: 650;
}
.photo-row__fields {
flex: 1;
min-width: 14rem;
display: grid;
gap: var(--space-2xs);
}
.photo-row__buttons {
display: flex;
gap: var(--space-2xs);
}
/* --- History and diff ------------------------------------------------------- */
.diff {
padding: var(--space-sm) 0;
font-family: var(--font-mono);
font-size: var(--text-sm);
line-height: 1.6;
overflow-x: auto;
}
.diff__line {
padding: 0.05rem var(--space-md);
white-space: pre-wrap;
word-break: break-word;
}
.diff__sign {
display: inline-block;
width: 1.4em;
user-select: none;
}
.diff__line--removed {
background: color-mix(in oklab, var(--danger) 10%, transparent);
color: var(--danger);
}
.diff__line--added {
background: color-mix(in oklab, var(--ok) 10%, transparent);
color: var(--ok);
}
.diff__line--equal {
color: var(--ink-2);
}
.diff__line--skipped {
background: var(--surface-2);
color: var(--ink-3);
text-align: center;
font-size: var(--text-xs);
}
.diff-legend {
display: flex;
gap: var(--space-2xs);
margin-bottom: var(--space-md);
}
/* --- Toasts, dialog, spinner, date picker ------------------------------------ */
.toast-stack {
position: fixed;
inset-block-end: var(--space-md);
inset-inline-end: var(--space-md);
z-index: 100;
display: flex;
flex-direction: column;
gap: var(--space-2xs);
max-width: min(22rem, calc(100% - 2rem));
pointer-events: none;
}
.toast {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-2xs) var(--space-sm);
border: 1px solid var(--line);
border-inline-start: 3px solid var(--accent);
border-radius: var(--radius);
background: var(--surface);
box-shadow: var(--shadow-lg);
font-size: var(--text-sm);
opacity: 0;
translate: 0 0.5rem;
pointer-events: auto;
transition:
opacity 0.18s ease,
translate 0.18s ease;
}
.toast.is-visible {
opacity: 1;
translate: 0;
}
.toast--error {
border-inline-start-color: var(--danger);
}
.toast--warn {
border-inline-start-color: var(--warn);
}
.toast--ok {
border-inline-start-color: var(--ok);
}
.toast__text {
flex: 1;
}
.toast__action {
padding: 0.2rem 0.7rem;
border: 1px solid var(--line-strong);
border-radius: 999px;
background: transparent;
color: var(--accent);
font: inherit;
font-size: var(--text-xs);
font-weight: 620;
cursor: pointer;
}
@media (hover: hover) {
.toast__action:hover {
background: var(--accent-wash);
}
}
/* The native modal: focus handling, Escape and the top layer for free.
The reset zeroes every element's margin, which would also cancel the
user agent's dialog centring and park the modal in the corner, so the
centring is restated here. */
dialog {
width: min(100% - 2rem, 30rem);
margin: auto;
padding: 0;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: var(--surface);
color: var(--ink);
box-shadow: var(--shadow-lg);
}
dialog::backdrop {
background: color-mix(in oklab, oklch(18% 0.02 268) 45%, transparent);
backdrop-filter: blur(4px);
}
.dialog__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: var(--space-md) var(--space-lg);
border-bottom: 1px solid var(--line);
}
.dialog__head h2,
.dialog__head h3 {
font-size: var(--text-lg);
}
.dialog__body {
padding: var(--space-lg);
display: grid;
gap: var(--space-md);
}
.dialog__foot {
display: flex;
justify-content: flex-end;
gap: var(--space-2xs);
padding: var(--space-md) var(--space-lg);
border-top: 1px solid var(--line);
}
.spinner {
width: 1.75rem;
height: 1.75rem;
margin-inline: auto;
border: 3px solid var(--line);
border-top-color: var(--accent);
border-radius: 999px;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* The custom date picker: a trigger that opens a calendar popover built
by the page script, with the native input kept in the DOM. */
.date-input {
position: relative;
}
.date-input__native {
display: none;
}
.date-input__btn {
display: flex;
align-items: center;
gap: var(--space-2xs);
width: 100%;
padding: 0.62rem 0.9rem;
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--ink);
font: inherit;
font-size: var(--text-sm);
text-align: start;
cursor: pointer;
transition:
border-color 0.18s ease,
box-shadow 0.18s ease;
}
.date-input__btn:hover {
border-color: var(--ink-3);
}
.date-input__btn:focus-visible {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-wash);
}
.date-input__btn[data-empty="1"] {
color: var(--ink-3);
}
.date-input__label {
flex: 1;
min-width: 0;
}
.date-input__icon {
display: inline-flex;
color: var(--ink-3);
}
.date-input__icon svg {
width: 1rem;
height: 1rem;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.date-input__popover {
position: absolute;
z-index: 20;
inset-block-start: calc(100% + 6px);
inset-inline-start: 0;
min-width: 18rem;
padding: var(--space-sm);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
box-shadow: var(--shadow-lg);
user-select: none;
}
.date-input__nav {
display: flex;
align-items: center;
gap: var(--space-3xs);
margin-bottom: var(--space-2xs);
}
.date-input__nav-title {
flex: 1;
text-align: center;
font-size: var(--text-sm);
font-weight: 620;
}
.date-input__nav-btn {
display: grid;
place-items: center;
width: 1.8rem;
height: 1.8rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink-2);
cursor: pointer;
transition:
background-color 0.12s ease,
color 0.12s ease;
}
@media (hover: hover) {
.date-input__nav-btn:hover {
background: var(--accent-wash);
color: var(--accent);
}
}
.date-input__nav-btn svg {
width: 0.9rem;
height: 0.9rem;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.date-input__weekdays,
.date-input__days {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.date-input__weekday {
padding-block: var(--space-3xs);
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--ink-3);
text-align: center;
}
.date-input__day {
display: grid;
place-items: center;
height: 2.1rem;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--ink);
font: inherit;
font-size: var(--text-sm);
font-variant-numeric: tabular-nums;
cursor: pointer;
transition: background-color 0.1s ease;
}
@media (hover: hover) {
.date-input__day:hover {
background: var(--accent-wash);
}
}
.date-input__day[data-other-month="1"] {
color: var(--ink-3);
}
.date-input__day[data-today="1"] {
box-shadow: inset 0 0 0 1px var(--line-strong);
}
.date-input__day[data-selected="1"] {
background: var(--accent);
color: var(--on-accent);
font-weight: 650;
}
.date-input__footer {
display: flex;
gap: var(--space-2xs);
margin-top: var(--space-2xs);
padding-top: var(--space-2xs);
border-top: 1px solid var(--line);
}
.date-input__footer-btn {
flex: 1;
padding: 0.4rem 0.6rem;
border: 1px solid var(--line);
border-radius: 999px;
background: transparent;
color: var(--ink-2);
font: inherit;
font-size: var(--text-xs);
font-weight: 620;
cursor: pointer;
}
@media (hover: hover) {
.date-input__footer-btn:hover {
background: var(--accent-wash);
color: var(--accent);
}
}
.date-input__footer-btn--primary {
background: var(--accent);
border-color: var(--accent);
color: var(--on-accent);
}
@media (hover: hover) {
.date-input__footer-btn--primary:hover {
filter: brightness(1.08);
color: var(--on-accent);
}
}
/* --- The login page ----------------------------------------------------------- */
.login {
position: relative;
min-height: 100svh;
display: grid;
place-items: center;
padding: var(--space-xl) var(--gutter);
}
/* The printed dot lattice the site wears across the top of its pages. */
.login::before {
content: "";
position: absolute;
inset: 0 0 auto 0;
height: min(46rem, 90svh);
z-index: -1;
pointer-events: none;
background: radial-gradient(
light-dark(color-mix(in oklab, var(--ink-3) 62%, transparent), color-mix(in oklab, var(--ink-2) 42%, transparent)) 1.3px,
transparent 1.5px
);
background-size: 26px 26px;
mask-image: linear-gradient(to bottom, black 60%, transparent);
}
.login__tools {
position: fixed;
top: var(--space-md);
inset-inline-end: var(--space-md);
display: flex;
gap: var(--space-2xs);
}
.login__panel {
width: 100%;
max-width: 27rem;
display: grid;
gap: var(--space-lg);
padding: var(--space-xl);
border: 1px solid color-mix(in oklab, var(--line) 88%, transparent);
border-radius: var(--radius-lg);
background: color-mix(in oklab, var(--surface) 92%, transparent);
box-shadow: var(--shadow-lg);
}
.login__brand {
display: grid;
gap: var(--space-xs);
justify-items: start;
}
.login__mark {
display: flex;
align-items: center;
gap: var(--space-2xs);
font-size: var(--text-xl);
font-weight: 750;
letter-spacing: -0.025em;
color: var(--ink);
text-decoration: none;
}
.login__mark img {
width: 2rem;
height: 2rem;
border-radius: var(--radius-sm);
}
.login__pitch {
color: var(--ink-2);
font-size: var(--text-sm);
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.9rem;
border: 1px solid var(--line-strong);
border-radius: 999px;
background: var(--surface);
font-family: var(--font-mono);
font-size: 0.7rem;
font-weight: 560;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-2);
}
.eyebrow::before {
content: "";
width: 0.45rem;
height: 0.45rem;
border-radius: 999px;
background: var(--accent);
}
.grad {
background: var(--gradient);
background-clip: text;
color: transparent;
}
.login__title {
font-size: var(--text-2xl);
letter-spacing: -0.028em;
}
.login__card {
display: grid;
gap: var(--space-md);
}
.login__submit {
width: 100%;
justify-content: center;
}
.login__foot {
display: flex;
justify-content: center;
gap: var(--space-xs);
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--ink-3);
}
.login__foot a {
color: var(--ink-2);
text-decoration: none;
}
@media (hover: hover) {
.login__foot a:hover {
color: var(--accent);
}
}
.login__version {
text-align: center;
font-family: var(--font-mono);
font-size: var(--text-xs);
letter-spacing: 0.04em;
/* The panel sits on the dotted lattice, so the quiet grey reads
below the contrast floor: the version takes the second ink. */
color: var(--ink-2);
}
/* The password reveal rides inside its field. */
.pw {
position: relative;
}
.pw .control {
padding-inline-end: 2.7rem;
}
.pw__toggle {
position: absolute;
inset-block-start: 50%;
inset-inline-end: 0.4rem;
translate: 0 -50%;
width: 2rem;
height: 2rem;
display: grid;
place-items: center;
border: 0;
border-radius: var(--radius-sm);
background: none;
color: var(--ink-3);
cursor: pointer;
transition: color 0.15s ease;
}
@media (hover: hover) {
.pw__toggle:hover {
color: var(--accent);
}
}
.pw__toggle svg {
width: 1.05rem;
height: 1.05rem;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
/* The first-run wizard: fields gathered into groups, each named by a
quiet mono legend the way the rail labels its sections. The card
grows a little wider than the login's, its fields warrant it. */
.login__panel:has(.setup) {
max-width: 32rem;
}
.setup__group {
display: grid;
gap: var(--space-sm);
border: 0;
margin: 0;
padding: 0;
}
/* The two columns of the account row keep their inputs on one axis
whatever the language: the field is stretched by the row and its
label-and-input stack sits at the bottom, so a label that wraps
to a second line grows upward and the inputs stay level. */
.setup .grid-2 .field {
align-content: end;
}
.setup__group > legend {
padding: 0 0 var(--space-2xs);
font-family: var(--font-mono);
font-size: 0.7rem;
font-weight: 560;
text-transform: uppercase;
letter-spacing: 0.08em;
/* The legend names its group on the panel surface, so it takes the
second ink: the quiet grey reads below the contrast floor there. */
color: var(--ink-2);
}
/* The theme chips wear their palette: the gradient stops are the
official entries of each colour map, viridis 0/192/255, plasma
0/192/255 and magma 0/192/255, the same values the palette token
blocks above cite. */
.chip--theme {
gap: var(--space-2xs);
}
.pill-group--themes {
gap: var(--space-xs);
}
.chip__swatch {
flex: none;
width: 2rem;
height: 0.75rem;
border-radius: 999px;
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--ink) 18%, transparent);
}
.chip__swatch--viridis {
background: linear-gradient(100deg, #440154, #5ec962, #fde725);
}
.chip__swatch--plasma {
background: linear-gradient(100deg, #0d0887, #f89540, #f0f921);
}
.chip__swatch--magma {
background: linear-gradient(100deg, #000004, #fc8961, #fcfdbf);
}
/* The strength meter of a password field: four segments and a word.
The bar is decorative, the label carries the news for assistive
tech, and the server's policy remains the gate. */
.pw-level {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-block-start: var(--space-2xs);
}
.pw-level__bar {
display: flex;
flex: 1;
gap: 0.3rem;
}
.pw-level__bar i {
flex: 1;
height: 0.32rem;
border-radius: 999px;
background: var(--surface-2);
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--line) 60%, transparent);
transition: background 0.15s ease;
}
.pw-level[data-level="1"] .pw-level__bar i:nth-child(-n+1) {
background: var(--danger);
}
.pw-level[data-level="2"] .pw-level__bar i:nth-child(-n+2) {
background: var(--warn);
}
.pw-level[data-level="3"] .pw-level__bar i:nth-child(-n+3) {
background: var(--accent);
}
.pw-level[data-level="4"] .pw-level__bar i {
background: var(--ok);
}
.pw-level__label {
flex: none;
min-width: 5.5rem;
text-align: end;
font-size: var(--text-xs);
font-weight: 560;
color: var(--ink-3);
}
/* A native radio wearing the chip face: the input stays in the form
so the submit is the same shape without JavaScript, the checked
state answers through :has(). */
.chip--radio {
position: relative;
}
.chip--radio input {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.chip--radio:has(input:checked) {
background: var(--accent-wash);
border-color: color-mix(in oklab, var(--accent) 35%, transparent);
color: var(--accent);
font-weight: 650;
}
.chip--radio:has(input:focus-visible) {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* A chip that is a link choosing the page's language: the current
choice wears the same face the checked radios do, announced by
aria-current which the script moves with the swap. */
.chip--switch[aria-current="true"] {
background: var(--accent-wash);
border-color: color-mix(in oklab, var(--accent) 35%, transparent);
color: var(--accent);
font-weight: 650;
}
.chip--switch:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
@layer utilities {
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.mono {
font-family: var(--font-mono);
font-size: 0.9em;
font-variant-numeric: tabular-nums;
}
.muted {
color: var(--ink-2);
}
.danger-text {
color: var(--danger);
}
.ok-text {
color: var(--ok);
}
.flex {
flex: 1;
}
.stack > * + * {
margin-top: var(--space-md);
}
.gap-top {
margin-top: var(--space-md);
}
.form-inline {
display: inline;
}
.select-all {
user-select: all;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}