First commit

This commit is contained in:
2026-09-30 01:49:35 +02:00
commit 6edd03aace
109 changed files with 27020 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
// ---------- Admin dashboard: the list of accounts ----------
// One row per account, read left to right: who, what they may do, when they
// arrived, and the one thing an admin can change about them.
.account-list {
display: grid;
gap: 10px;
margin: 0;
padding: 0;
list-style: none;
}
.account-entry {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding: 12px 16px;
border-radius: 14px;
border: 1px solid var(--border);
background: var(--surface);
box-shadow: var(--shadow-sm);
}
.account-entry-name {
flex: 1;
min-width: 0;
font-size: 0.95rem;
font-weight: 650;
letter-spacing: -0.01em;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// the marker on the row that is the person looking at the page
.account-entry-you {
margin-left: 6px;
font-size: 0.72rem;
font-weight: 600;
color: var(--faint);
}
.account-entry-since { flex-shrink: 0; font-size: 0.78rem; color: var(--faint); }
// where the ⋮ goes on the row of the admin looking at the page: their own role
// and their own account are the two things they cannot change, so the row says
// it instead of offering a menu that could only fail
.account-entry-note {
margin-left: auto;
flex-shrink: 0;
font-size: 0.76rem;
font-style: italic;
color: var(--faint);
}
.role-pill {
flex-shrink: 0;
padding: 3px 11px;
border-radius: 999px;
background: var(--surface-2);
color: var(--muted);
font-size: 0.75rem;
font-weight: 700;
// an admin's row is the accent one, so the few of them stand out at a glance
&.is-admin {
background: color-mix(in srgb, var(--accent) 14%, transparent);
color: var(--accent);
}
}
// on a wide row the ⋮ sits at the end of the line; once the row wraps (a narrow
// window) it starts a line of its own, still at the end of what came before
.account-entry-kebab { margin-left: auto; flex: none; }
// the article cards' skeleton, in the shape of one row of the list
.skeleton-row { height: 62px; }

View File

@@ -0,0 +1,225 @@
@use 'mixins' as *;
// ---------- Article page ----------
.article-page { padding-block: 40px 80px; }
.article-head { margin-bottom: 8px; }
.article-eyebrow {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
font-size: 0.8rem;
color: var(--faint);
margin-bottom: 10px;
}
// the title and the ⋮ share one line: the title takes what is left and wraps
// its own text, the actions keep their place at the side of it
.article-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.article-title {
flex: 1;
min-width: 0;
margin: 0;
font-size: clamp(1.9rem, 4vw, 2.6rem);
font-weight: 830;
letter-spacing: -0.03em;
line-height: 1.15;
}
.article-actions {
flex: none;
display: flex;
gap: 9px;
padding-top: 10px;
}
// The ⋮ menu: the quiet button that folds several actions into one place. The
// article's title row uses it for Edit, languages and Delete; the admin
// dashboard uses it for the role and the account itself.
.kebab { position: relative; }
.kebab-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 9px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--muted);
transition: background-color 0.15s, border-color 0.15s, color 0.15s;
&:hover,
&.open {
border-color: var(--border-strong);
background: var(--surface-2);
color: var(--text);
}
}
.kebab-dropdown {
@include dropdown-panel($z: 30, $overflow: visible);
right: 0;
min-width: 190px;
text-align: left;
// the language menu rides inside as a row of the ⋮ menu — flat, full-width,
// its own list dropping over the rows below
.lang-menu {
width: 100%;
> .btn {
justify-content: flex-start;
width: 100%;
padding: 8px 10px;
border: 0;
border-radius: 8px;
background: none;
color: var(--text);
font-size: 0.85rem;
font-weight: 600;
&:hover { background: color-mix(in srgb, var(--accent) 12%, transparent); }
}
}
}
.kebab-item {
@include menu-row(8px 10px, 9px);
text-decoration: none;
svg { flex: none; color: var(--faint); transition: color 0.12s; }
// a menu row whose request is still in flight: readable, but no longer a way
// to ask twice
&:disabled {
color: var(--faint);
cursor: default;
background: none;
svg { color: var(--faint); }
}
&:hover {
background: color-mix(in srgb, var(--accent) 12%, transparent);
svg { color: var(--accent); }
}
}
.kebab-danger:hover {
background: color-mix(in srgb, var(--danger) 12%, transparent);
svg { color: var(--danger); }
}
.kebab-sep { margin: 5px 0; border-top: 1px solid var(--border); }
// Topics line at the foot of an article: “Topics: Probability, Calculus”.
// The comma joins the topics; a break stays tidy after it.
.article-topics {
margin-top: 36px;
padding-top: 14px;
border-top: 1px solid var(--border);
font-size: 0.85rem;
color: var(--muted);
}
.article-topics-label { font-weight: 600; margin-right: 0.35em; }
.article-topics .topic-link {
color: var(--accent);
text-decoration: none;
margin-right: 0.5em;
&:not(:last-child)::after { content: ','; }
&:hover { text-decoration: underline; }
}
.article-body {
margin-top: 34px;
border-top: 1px solid var(--border);
padding-top: 8px;
min-width: 0;
}
// TOC
.toc {
position: sticky;
top: 24px;
font-size: 0.855rem;
@media (max-width: 1024px) {
display: none;
}
}
.toc-title { @include eyebrow; margin-bottom: 10px; }
.toc ol {
list-style: none;
margin: 0;
padding: 0;
border-left: 2px solid var(--border);
}
.toc li a {
display: block;
padding: 5px 0 5px 14px;
margin-left: -2px;
border-left: 2px solid transparent;
color: var(--muted);
text-decoration: none;
line-height: 1.4;
transition: color 0.15s, border-color 0.15s;
&:hover { color: var(--text); }
&.active { color: var(--accent); border-left-color: var(--accent); }
}
.toc li.level-3 a { padding-left: 28px; font-size: 0.8rem; }
// Recently viewed (article sidebar)
.recently-viewed {
margin-top: 26px;
padding-top: 18px;
border-top: 1px solid var(--border);
font-size: 0.855rem;
ul { list-style: none; margin: 0; padding: 0; }
li {
+ li { margin-top: 2px; }
a {
display: block;
padding: 6px 9px;
border-radius: 7px;
color: var(--muted);
text-decoration: none;
line-height: 1.35;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: background 0.15s, color 0.15s;
&:hover {
background: color-mix(in srgb, var(--accent) 8%, var(--bg-elev));
color: var(--text);
}
}
}
}
.rv-title { @include eyebrow; margin-bottom: 8px; }

View File

@@ -0,0 +1,67 @@
@use 'mixins' as *;
// ---------- Articles index ----------
.topic-tally { font-size: 0.85rem; color: var(--muted); }
.topic-dropdown {
@include dropdown-panel;
left: 0;
right: 0;
max-height: 280px;
}
.topic-option {
@include menu-row(8px 12px, 12px, 0.88rem);
justify-content: space-between;
text-align: left;
&:hover, &.active { background: color-mix(in srgb, var(--accent) 12%, transparent); }
}
.topic-dropdown-none {
padding: 8px 12px;
margin: 0;
font-size: 0.85rem;
color: var(--muted);
}
.topic-search-wrap {
position: relative;
margin-bottom: 14px;
svg {
position: absolute;
left: 14px;
top: 50%;
translate: 0 -50%;
color: var(--faint);
pointer-events: none;
}
}
.topic-search {
width: 100%;
height: 40px;
padding: 0 14px 0 40px;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--surface);
box-shadow: var(--shadow-sm);
font-size: 0.9rem;
transition: border-color 0.15s, box-shadow 0.2s;
&:focus {
outline: none;
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);
}
}
.topic-count {
padding: 1px 7px;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 14%, transparent);
color: var(--accent);
font-size: 0.72rem;
font-weight: 700;
}

View File

@@ -0,0 +1,119 @@
@use 'mixins' as *;
// ---------- Sign in ----------
// Anyone may read Mathew; writing it takes an account. The gate is what a
// signed-out visitor of the editor finds in the form's place (AuthGate.vue), and
// the popup is the way in from anywhere (AuthModal.vue). The popup sits above the
// fullscreen editor (1000) and its own popups (1100, 1150): a save that comes
// back refusing an expired session has to be answered where the editor already
// fills the screen.
.auth-overlay { @include overlay(1160); }
.auth-modal {
@include sheet(420px);
&:focus, &:focus-visible { outline: none; }
}
.auth-modal-head { @include sheet-head; }
.auth-title { @include sheet-title; }
.auth-modal-body { padding: 18px; }
// the two ways in, side by side
.auth-tabs { @include segmented; margin-bottom: 18px; }
.auth-tab { @include segmented-tab(7px 14px, 0.84rem); }
// why the popup turned up on its own — a session that expired mid-draft
.auth-notice {
margin: -6px 0 16px;
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--danger) 30%, var(--border));
border-radius: 10px;
background: color-mix(in srgb, var(--danger) 7%, var(--surface));
color: var(--muted);
font-size: 0.84rem;
line-height: 1.5;
}
.auth-modal .field input {
width: 100%;
padding: 12px 14px;
border-radius: 11px;
border: 1px solid var(--border);
background: var(--bg-elev);
}
.auth-submit { width: 100%; margin-top: 2px; }
.auth-switch {
margin: 16px 0 0;
text-align: center;
font-size: 0.84rem;
color: var(--muted);
button {
color: var(--accent);
font-weight: 650;
&:hover { text-decoration: underline; }
}
}
@include popup-anim(
'auth-popup',
'.auth-modal',
0.16s,
transform 0.2s cubic-bezier(0.22, 0.61, 0.36, 1),
-10px
);
// the editor's stand-in until there is a session
.auth-gate {
max-width: 480px;
margin: clamp(36px, 8vh, 92px) auto;
padding: 34px 30px 30px;
border: 1px solid var(--border);
border-radius: 18px;
background: var(--bg-elev);
box-shadow: var(--shadow-lg);
text-align: center;
}
.auth-gate-mark {
display: grid;
place-items: center;
width: 46px;
height: 46px;
margin: 0 auto 16px;
border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border));
border-radius: 50%;
background: color-mix(in srgb, var(--accent) 10%, transparent);
color: var(--accent);
}
.auth-gate-title {
margin: 0 0 8px;
font-size: 1.3rem;
letter-spacing: -0.01em;
color: var(--text);
}
.auth-gate-text {
max-width: 38ch;
margin: 0 auto 22px;
color: var(--muted);
font-size: 0.93rem;
line-height: 1.6;
}
.auth-gate-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}
.auth-gate-fine { margin: 18px 0 0; font-size: 0.8rem; color: var(--faint); }

View File

@@ -0,0 +1,65 @@
// ---------- Base ----------
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
body {
margin: 0;
font-family:
'Computer Modern',
'CMU Serif',
Georgia,
'Times New Roman',
serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
transition: background-color 0.25s ease, color 0.25s ease;
}
::selection { background: color-mix(in srgb, var(--accent) 30%, transparent); }
a { color: var(--accent); }
button {
font: inherit;
color: inherit;
background: none;
border: 0;
padding: 0;
cursor: pointer;
}
input, textarea, select { font: inherit; color: inherit; }
:focus-visible { outline: none; box-shadow: var(--ring); border-radius: 8px; }
.container {
width: 100%;
max-width: 1160px;
margin-inline: auto;
padding-inline: clamp(16px, 4vw, 32px);
}
.mono,
code,
pre,
textarea.md-input {
font-family: ui-monospace, 'JetBrains Mono', 'Cascadia Code', 'Fira Code',
Menlo, Consolas, monospace;
}
// ---------- Scrollbars (subtle, webkit) ----------
::-webkit-scrollbar { width: 11px; height: 10px; }
::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--text) 18%, transparent);
border-radius: 8px;
border: 3px solid transparent;
background-clip: content-box;
&:hover {
background: color-mix(in srgb, var(--text) 30%, transparent);
background-clip: content-box;
}
}

View File

@@ -0,0 +1,300 @@
@use 'mixins' as *;
// ---------- Reader: an open book of article pages ----------
// Every article opened in this session is a page, laid out beside the last
// in a track you move along sideways (ArticleBookStack.vue). Pages are solid
// paper, and each sticks a spine's width further from the left edge than the
// one before it — left: calc(var(--i) * var(--spine-w)) — so turning to a
// newer page slides it over the older ones, which park along the edge with
// nothing but their spines showing: a stack of pages, titled along the edge
// like books on a shelf. A page is as wide as its share of the reading
// surface — minus the spines of every other page, but never more than a
// comfortable measure, and open across the whole desk when it is the only
// page in the book — so on a wide desk the next page waits fully in view
// beside the one you read, not only its spine; on a narrow one the rest wait
// out at the right edge. A spine the desk has no room for is copied to that
// edge — the dock, .book-dock below — so no page is ever more than a click
// away, though its own spine is off the end of the row. Click a spine to turn
// back to that page, the × to take it out of the book. Below 900px the book
// folds to the one page in view and its neighbours become chips along the
// top. Routes stay the source of truth — every page is a plain /wiki/<slug>
// URL, and useArticleTabs keeps the stack. (The --sidebar-w, --spine-w and
// --pane-max-w tokens live in _tokens.scss.)
// the surface the book lies open on
.book-layer {
position: fixed;
inset: 0 0 0 var(--sidebar-w);
transition: left 0.22s ease;
z-index: 70;
display: flex;
flex-direction: column;
background: var(--bg-elev);
box-shadow: -10px 0 26px rgb(5 7 13 / 9%);
}
// nothing lives at this URL: the route host says so over the open book
.reader-note {
position: fixed;
inset: 0 0 0 var(--sidebar-w);
transition: left 0.22s ease;
z-index: 74;
background: var(--bg-elev);
box-shadow: -10px 0 26px rgb(5 7 13 / 4%);
}
// the row of pages, moved along sideways
.book-track {
--pane-w: calc(100% - (var(--panes, 1) - 1) * var(--spine-w));
position: relative;
flex: 1;
min-height: 0;
display: flex;
align-items: stretch;
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior: contain;
// the desk under the pages: once a page stops short of the reading
// surface, what's left of the desk shows beside it
background: var(--bg);
@include no-scrollbar;
}
// one page: solid paper, parked beside the pages under it
.book-pane {
position: sticky;
left: calc(var(--i) * var(--spine-w));
z-index: calc(var(--i) + 1);
flex: 0 0 var(--pane-w);
// A page takes every inch of the reading surface it can — the one in view
// opens across the whole desk, not just a column of it. Only once so many
// are open that each page's share falls below a comfortable reading width
// does it hold at that width and the book scroll sideways instead.
min-width: min(46rem, 100%);
// ...and on a desk wide enough for it to stretch past a page of paper, it
// holds at --pane-max-w and leaves the rest of the desk bare. The single
// page of a one-page book is exempt below.
max-width: var(--pane-max-w);
display: flex;
background: var(--bg-elev);
// the fore-edge of the paper, against the desk and the next page
border-right: 1px solid var(--border);
// the edge of this page lying on top of the ones beneath it
box-shadow: -10px 0 22px rgb(5 7 13 / 7%);
animation: page-turned-in 0.3s cubic-bezier(0.22, 0.8, 0.36, 1) both;
}
// a book with a single page in it lies open across the whole desk: there is
// no other page waiting for that space, so the page takes all of it
.book-pane:only-child { max-width: none; }
// a page that isn't in view asks to be turned to: the whole of it is
// clickable (clicks on its inert body retarget to the pane itself), not
// just the spine
.book-pane:not(.active) {
cursor: pointer;
.pane-body, .pane-body * { cursor: pointer; }
}
// a new page is laid down at the right and eased into the stack
@keyframes page-turned-in {
from { transform: translateX(56px); opacity: 0; }
}
// the page's fore-edge: its title standing on end, and the × that closes it.
// Once a newer page is turned over this one, the spine is all that's left in
// sight — so it is the handle for turning back. A page whose spine is off the
// edge of the desk has it copied at the edge itself, in the dock below, which
// borrows .spine-title and .spine-close as its own standing type.
.pane-spine {
flex: 0 0 var(--spine-w);
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 6px 0 10px;
background: var(--surface);
border-right: 1px solid var(--border);
color: var(--muted);
cursor: pointer;
user-select: none;
transition: color 0.15s, background-color 0.15s;
&:hover { color: var(--text); background: var(--surface-2); }
}
// the page in view has its spine marked, the way a ribbon marks a book
.book-pane.active .pane-spine {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
}
.spine-close {
@include icon-target(15px, 5px);
color: var(--faint);
transition: color 0.15s, background-color 0.15s;
@include close-target;
svg { display: block; }
}
.spine-title {
writing-mode: vertical-rl;
flex: 1;
min-height: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.78rem;
font-weight: 700;
line-height: 1.35;
letter-spacing: 0.01em;
padding-top: 0;
}
// the page itself: the article, scrolling on its own
.pane-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.pane-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding-inline: clamp(16px, 3.5vw, 36px);
// the article takes the page as a single centered column
.article-page { max-width: 78ch; margin-inline: auto; }
}
.pane-skeleton { padding-top: 32px; }
// folded up narrow: the neighbours of the page in view, as chips along the
// top. Wide screens read the spines instead.
.book-chips {
display: none;
flex-shrink: 0;
gap: 8px;
padding: 9px 10px;
overflow-x: auto;
overscroll-behavior: contain;
background: var(--surface);
border-bottom: 1px solid var(--border);
@include no-scrollbar;
}
.book-chip {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6px;
max-width: 46vw;
padding: 5px 7px 5px 11px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface-2);
color: var(--muted);
font-size: 0.79rem;
font-weight: 600;
cursor: pointer;
user-select: none;
transition: color 0.15s, background-color 0.15s, border-color 0.15s;
&:hover { color: var(--text); }
&.active {
color: var(--accent);
background: var(--bg-elev);
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
}
}
.chip-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chip-close { @include icon-target(16px, 5px); color: var(--faint); @include close-target; }
// ---------- The dock: pages waiting beyond the right edge of the desk -----
// A page further along the book than the one in view lies somewhere off the
// right edge of the desk, reachable by dragging the row sideways. While it is
// out there, its spine is kept at the edge itself: a stack one spine wide, in
// the order the pages were opened, each one the standing title and × of the
// page it stands for. Click it and the book turns to that page as though you
// had scrolled to it; the page's own spine comes into view and its copy in the
// dock goes away (ArticleBookStack measures which pages are out there). It
// rides above every page of the book — the pages are numbered one apiece by
// --panes — and, being inside the reading surface, below anything popped over
// it.
.book-dock {
position: absolute;
inset-block: 0;
right: 0;
z-index: calc(var(--panes, 1) + 2);
display: flex;
flex-direction: column;
width: var(--spine-w);
padding: 6px 0;
background: var(--surface);
border-left: 1px solid var(--border);
box-shadow: -10px 0 22px rgb(5 7 13 / 7%);
color: var(--muted);
}
// the waiting spines share the height of the desk, so however many pages are
// out there every one of them keeps a place at the edge, its title cut short
// to fit — the whole title is in the tooltip
.dock-spine {
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 4px 0;
cursor: pointer;
user-select: none;
transition: color 0.15s, background-color 0.15s;
+ .dock-spine { border-top: 1px solid var(--border); }
&:hover { color: var(--text); background: var(--surface-2); }
}
@media (max-width: 900px) {
.book-layer, .reader-note { inset: 0; box-shadow: none; }
.book-track { overflow-x: hidden; }
// folded: only the page in view is out, the rest are chips along the top
.book-pane {
position: static;
flex: 1 1 100%;
width: 100%;
min-width: 0;
max-width: 100%;
box-shadow: none;
&:not(.active) { display: none; }
}
.book-chips { display: flex; }
// folded, the book has no edges to stack spines against: the chips along
// the top are every page of it
.book-dock { display: none; }
.pane-scroll { padding-inline: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.book-pane { animation: none; }
}

View File

@@ -0,0 +1,62 @@
// ---------- Buttons ----------
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 18px;
border-radius: 10px;
border: 1px solid transparent;
font-size: 0.9rem;
font-weight: 650;
text-decoration: none;
white-space: nowrap;
transition: filter 0.15s, background-color 0.15s, border-color 0.15s, transform 0.1s,
box-shadow 0.15s;
&:active { transform: translateY(1px); }
&[disabled] { opacity: 0.55; pointer-events: none; }
}
.btn-primary {
background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 55%, var(--accent-2)));
color: #fff;
box-shadow: 0 6px 18px -6px color-mix(in srgb, var(--accent) 65%, transparent);
&:hover { filter: brightness(1.1); }
}
.btn-ghost {
border-color: var(--border);
background: var(--surface);
color: var(--text);
&:hover { border-color: var(--border-strong); background: var(--surface-2); }
}
.btn-danger {
border-color: color-mix(in srgb, var(--danger) 45%, transparent);
color: var(--danger);
background: transparent;
&:hover { background: color-mix(in srgb, var(--danger) 12%, transparent); }
}
.btn-sm { padding: 7px 13px; font-size: 0.82rem; border-radius: 8px; }
.icon-btn {
display: grid;
place-items: center;
width: 38px;
height: 38px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--muted);
transition: color 0.15s, border-color 0.15s, transform 0.15s;
&:hover { color: var(--text); border-color: var(--border-strong); }
&:active { transform: scale(0.94); }
}

View File

@@ -0,0 +1,80 @@
// ---------- Article cards ----------
.section { position: relative; padding-bottom: clamp(56px, 9vh, 96px); }
.articles-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.article-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 22px;
border-radius: 16px;
border: 1px solid var(--border);
background: var(--surface);
text-decoration: none;
color: inherit;
box-shadow: var(--shadow-sm);
transition: transform 0.18s ease, border-color 0.18s, box-shadow 0.18s;
&:hover {
transform: translateY(-3px);
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
box-shadow: var(--shadow-lg);
}
&:focus-visible { box-shadow: var(--ring); }
}
.card-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.card-kicker {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent);
}
.card-title {
margin: 0;
font-size: 1.14rem;
font-weight: 720;
letter-spacing: -0.015em;
line-height: 1.3;
}
.card-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 4px;
font-size: 0.78rem;
color: var(--faint);
}
.tags { display: flex; flex-wrap: wrap; gap: 6px; }
.tag {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 11%, transparent);
color: var(--accent);
font-size: 0.75rem;
font-weight: 600;
text-decoration: none;
transition: background-color 0.15s;
}
a.tag:hover { background: color-mix(in srgb, var(--accent) 22%, transparent); }

View File

@@ -0,0 +1,295 @@
@use 'mixins' as *;
// ---------- Editor ----------
.editor-page { padding-block: 36px 90px; max-width: 1080px; }
.editor-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
margin-bottom: 24px;
h1 {
margin: 0;
font-size: 1.5rem;
letter-spacing: -0.025em;
font-weight: 800;
}
}
.editor-head-actions { display: flex; gap: 10px; align-items: center; }
.field {
margin-bottom: 18px;
label {
display: block;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 7px;
}
.hint {
text-transform: none;
font-weight: 500;
letter-spacing: 0;
color: var(--faint);
}
input[type='text'],
select {
width: 100%;
padding: 12px 14px;
border-radius: 11px;
border: 1px solid var(--border);
background: var(--bg-elev);
transition: border-color 0.15s, box-shadow 0.2s;
}
// `input:focus` (not just the styled types) so every field input —
// the sign-in popup's password among them — gets the same ring
input:focus,
select:focus {
outline: none;
border-color: var(--accent);
box-shadow: var(--ring);
}
input.title-input {
font-size: 1.25rem;
font-weight: 720;
letter-spacing: -0.02em;
padding: 14px 16px;
}
select { cursor: pointer; }
}
.field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
@media (max-width: 640px) {
grid-template-columns: 1fr;
}
}
.slug-preview {
font-size: 0.8rem;
color: var(--faint);
margin-top: 6px;
code { color: var(--accent); }
}
// editor body
.editor-body {
border: 1px solid var(--border);
border-radius: 15px;
background: var(--bg-elev);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.editor-toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 9px 11px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.mode-btn {
padding: 7px 14px;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 620;
color: var(--muted);
transition: background-color 0.15s, color 0.15s;
&:hover { color: var(--text); }
&.active {
background: var(--surface-2);
color: var(--text);
box-shadow: inset 0 0 0 1px var(--border);
}
}
.toolbar-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 4px;
}
.toolbar-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 7px 9px;
flex: 0 0 auto;
}
.editor-grid {
display: grid;
grid-template-columns: 1fr 1fr;
&.mode-write {
grid-template-columns: 1fr;
.preview-pane { display: none; }
}
&.mode-preview {
grid-template-columns: 1fr;
.write-pane { display: none; }
}
}
textarea.md-input {
display: block;
width: 100%;
min-height: 480px;
padding: 22px;
border: 0;
background: transparent;
resize: vertical;
font-size: 0.9rem;
line-height: 1.75;
tab-size: 2;
&:focus { outline: none; }
&::placeholder { color: var(--faint); }
}
.preview-pane {
border-left: 1px solid var(--border);
padding: 22px 26px;
min-width: 0;
overflow-wrap: break-word;
}
.preview-label { @include eyebrow; margin-bottom: 14px; }
@media (max-width: 900px) {
.editor-grid { grid-template-columns: 1fr; }
.preview-pane { border-left: 0 !important; border-top: 1px solid var(--border); }
}
// editor writing-cheatsheet popup — same shape as the settings modal,
// but above the fullscreen editor (z-index 1000)
.cheatsheet-overlay { @include overlay(1100, clamp(40px, 10vh, 110px)); }
.cheatsheet-modal { @include sheet(680px, min(620px, calc(100vh - 90px))); }
.cheatsheet-head { @include sheet-head; }
.cheatsheet-title { @include sheet-title; }
.cheatsheet-body {
@include sheet-body(20px);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 24px 28px;
align-items: start;
}
.cheatsheet-group-title {
@include eyebrow(0.74rem, 750, 0.08em, var(--accent));
margin: 0 0 10px;
}
.cheat-row {
display: flex;
align-items: baseline;
gap: 10px;
font-size: 0.84rem;
color: var(--muted);
& + & { margin-top: 7px; }
code {
flex: 0 0 auto;
padding: 2px 7px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-2);
color: var(--text);
font-size: 0.78rem;
}
.katex { font-size: 1em; }
}
@include popup-anim('cheatsheet-popup', '.cheatsheet-modal', 0.18s, transform 0.18s ease, 10px);
// discard-changes popup — small sibling of the settings modal, raised above
// the fullscreen editor (z-index 1000)
.confirm-overlay { @include overlay(1150, clamp(56px, 18vh, 200px)); }
.confirm-modal {
width: min(430px, 100%);
border: 1px solid var(--border);
border-radius: 16px;
background: var(--bg-elev);
box-shadow: var(--shadow-lg);
padding: 22px;
}
.confirm-title { margin: 0; font-size: 1.02rem; font-weight: 700; }
.confirm-message {
margin: 8px 0 0;
font-size: 0.88rem;
line-height: 1.55;
color: var(--muted);
}
.confirm-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 22px;
}
@include popup-anim('confirm-popup', '.confirm-modal', 0.18s, transform 0.18s ease, 10px);
// fullscreen editor
.editor-body.is-fullscreen {
position: fixed;
inset: 0;
z-index: 1000;
border-radius: 0;
border-left: 0;
border-right: 0;
display: flex;
flex-direction: column;
.editor-toolbar { flex: 0 0 auto; }
.editor-grid { flex: 1; min-height: 0; }
.write-pane, .preview-pane { min-height: 0; overflow-y: auto; }
textarea.md-input { height: 100%; min-height: 0; resize: none; }
}
// below 900px the split stacks vertically — give each pane half the screen
@media (max-width: 900px) {
.editor-body.is-fullscreen .editor-grid:not(.mode-write):not(.mode-preview) {
grid-template-rows: 1fr 1fr;
}
}
.form-error { color: var(--danger); font-size: 0.88rem; margin: 12px 2px 0; }

View File

@@ -0,0 +1,19 @@
// ---------- Fonts: Computer Modern (CMU) ----------
// CMU ships only two weights, so we map the design's intermediate
// weights onto real faces: <=550 → regular, >550 → bold extended.
$cmu-faces: (
('cmunrm', 100 550, normal),
('cmunti', 100 550, italic),
('cmunbx', 551 900, normal),
('cmunbi', 551 900, italic),
);
@each $file, $weight, $style in $cmu-faces {
@font-face {
font-family: 'Computer Modern';
src: url('/fonts/#{$file}.ttf') format('truetype');
font-weight: $weight;
font-style: $style;
font-display: swap;
}
}

View File

@@ -0,0 +1,68 @@
// ---------- Graph view (the link map) ----------
// desktop: the map owns the space beside the sidebar the way the open
// book does — a full surface with its toolbar floating over it.
// (The page transition only fades, so no transform steals the
// fixed positioning.)
.graph-stage {
position: fixed;
inset: 0 0 0 var(--sidebar-w);
transition: left 0.22s ease;
z-index: 1;
background: var(--bg);
}
.graph-canvas-wrap { position: absolute; inset: 0; }
.graph-canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none; // the map owns drags and pinches, not the page
}
.graph-toolbar {
position: absolute;
top: 16px;
right: 18px;
z-index: 2;
display: flex;
gap: 2px;
padding: 4px;
border: 1px solid var(--border);
border-radius: 11px;
background: color-mix(in srgb, var(--bg-elev) 84%, transparent);
backdrop-filter: blur(10px);
box-shadow: var(--shadow-sm);
}
.graph-state {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 24px;
.banner { width: min(520px, 100%); }
}
// the top bar is in the flow here, so the map folds into a panel of
// the page instead of covering the screen
@media (max-width: 900px) {
.graph-stage { position: static; inset: auto; background: transparent; }
.graph-canvas-wrap {
position: relative;
height: min(66dvh, 560px);
margin: 20px 0 28px;
border: 1px solid var(--border);
border-radius: 14px;
overflow: hidden;
background: var(--bg-elev);
box-shadow: var(--shadow-sm);
}
.graph-toolbar { top: 10px; right: 10px; }
.graph-state { position: static; display: block; padding: 8px 0 32px; }
}

View File

@@ -0,0 +1,152 @@
// ---------- Hero (home) ----------
.home-main {
position: relative;
&::before {
content: '';
position: absolute;
inset: -120px -20% auto -20%;
height: 480px;
pointer-events: none;
background:
radial-gradient(38% 60% at 30% 30%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 70%),
radial-gradient(34% 55% at 72% 22%, color-mix(in srgb, var(--accent-2) 14%, transparent), transparent 70%);
filter: blur(30px);
}
}
.hero {
position: relative;
text-align: center;
padding: clamp(56px, 11vh, 104px) 0 44px;
h1 {
margin: 0;
font-size: clamp(2.4rem, 6vw, 3.7rem);
font-weight: 850;
letter-spacing: -0.035em;
line-height: 1.08;
// the headline's ink drifts between the two accents — the gradient is
// painted twice as wide as the text and slides through it; both ends are
// the accent colour, so the loop has no seam
.gradient {
background: linear-gradient(
92deg,
var(--accent) 10%,
var(--accent-2) 50%,
var(--accent) 90%
);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: hero-gradient-pan 4.5s linear infinite;
}
}
.sub {
margin: 18px auto 0;
max-width: 56ch;
color: var(--muted);
font-size: clamp(1rem, 1.6vw, 1.13rem);
}
}
@keyframes hero-gradient-pan {
to { background-position-x: 200%; }
}
.search-wrap {
position: relative;
max-width: 620px;
margin: 34px auto 0;
svg {
position: absolute;
left: 19px;
top: 50%;
translate: 0 -50%;
color: var(--faint);
pointer-events: none;
}
}
.search-input {
width: 100%;
height: 58px;
padding: 0 52px 0 52px;
border-radius: 16px;
border: 1px solid var(--border);
background: var(--surface);
box-shadow: var(--shadow-sm);
font-size: 1rem;
transition: border-color 0.15s, box-shadow 0.2s;
&::placeholder { color: var(--faint); }
&:focus { outline: none; border-color: var(--accent); box-shadow: var(--ring); }
}
.search-kbd {
position: absolute;
right: 16px;
top: 50%;
translate: 0 -50%;
font-size: 0.72rem;
font-weight: 600;
color: var(--faint);
border: 1px solid var(--border);
border-bottom-width: 2px;
border-radius: 6px;
padding: 2px 7px;
background: var(--surface-2);
pointer-events: none;
}
.hero-actions {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 22px;
flex-wrap: wrap;
}
// ---------- Section / results meta ----------
.results-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin: 10px 0 18px;
h2 {
margin: 0;
font-size: 1.05rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--text);
}
}
.filter-chip {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 5px 12px;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 12%, transparent);
color: var(--accent);
font-size: 0.82rem;
font-weight: 600;
button {
color: inherit;
opacity: 0.75;
display: grid;
place-items: center;
&:hover { opacity: 1; }
}
}

View File

@@ -0,0 +1,193 @@
@use 'mixins' as *;
// ---------- Languages ----------
// the round accent badge a language code wears, on its own or beside a globe
@mixin lang-pill {
display: inline-flex;
align-items: center;
padding: 2px 9px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
color: var(--accent);
font-weight: 700;
white-space: nowrap;
}
.lang-badge { @include lang-pill; font-size: 0.72rem; letter-spacing: 0.02em; }
// the language link in an article's eyebrow — click to see that language only
.article-lang {
color: var(--accent);
font-weight: 600;
text-decoration: none;
border-bottom: 1px dashed color-mix(in srgb, var(--accent) 45%, transparent);
&:hover { border-bottom-style: solid; }
}
// the quiet label that introduces a row of language/version chips
.lang-row-label,
.article-langs-label,
.version-bar-label {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--faint);
margin-right: 2px;
}
// language filter chips on the articles index
.lang-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin: 2px 0 18px;
}
.lang-chip {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 5px 12px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-size: 0.82rem;
font-weight: 600;
transition: border-color 0.15s, background-color 0.15s;
&:hover { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }
&.active {
background: color-mix(in srgb, var(--accent) 12%, transparent);
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
color: var(--accent);
}
}
// the version switcher of an article in the reader (links, not buttons)
a.lang-chip { text-decoration: none; }
.article-langs {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 16px;
}
// the editor's notice while translating an article
.translate-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
margin-bottom: 18px;
border-radius: 12px;
border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border));
background: color-mix(in srgb, var(--accent) 7%, transparent);
color: var(--muted);
font-size: 0.9rem;
svg { color: var(--accent); flex: none; }
a {
color: var(--accent);
font-weight: 650;
text-decoration: none;
&:hover { text-decoration: underline; }
}
}
// the editor's own version bar: the article's languages as chips, with the
// unsaved ones wearing a dot, and a dropdown to add another language
.version-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 20px;
}
.dirty-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
flex: none;
}
.version-add { position: relative; }
.version-dropdown {
@include dropdown-panel;
left: 0;
min-width: 190px;
max-height: 280px;
}
// the card of an article that exists in several languages: the codes ride
// together beside a globe, so one card says the whole story
.card-langs {
@include lang-pill;
gap: 5px;
font-size: 0.66rem;
letter-spacing: 0.04em;
svg { opacity: 0.85; }
}
// the reader's language menu: a button on the article's actions, opening a
// list of every language — the ones to read in, and the ones left to write
.lang-menu { position: relative; }
.lang-menu-caret { opacity: 0.6; }
.lang-menu-dropdown {
@include dropdown-panel($z: 30);
right: 0;
min-width: 220px;
max-height: 340px;
text-align: left;
}
.lang-menu-section { @include eyebrow(0.68rem, 700, 0.06em); padding: 7px 10px 4px; }
.lang-menu-gap { margin-top: 5px; border-top: 1px solid var(--border); padding-top: 9px; }
.lang-menu-item {
@include menu-row(7px 10px, 9px);
text-decoration: none;
&:hover { background: color-mix(in srgb, var(--accent) 12%, transparent); }
.lmi-sub {
margin-left: auto;
max-width: 112px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.72rem;
font-weight: 500;
color: var(--faint);
}
.lmi-check { flex: none; color: var(--accent); }
}
.lang-menu-write {
.lmi-pencil {
margin-left: auto;
flex: none;
color: var(--faint);
transition: color 0.12s;
}
&:hover .lmi-pencil { color: var(--accent); }
}

View File

@@ -0,0 +1,283 @@
@use 'mixins' as *;
// ---------- Layout shell ----------
.app-shell { display: flex; min-height: 100vh; }
.app-main { flex: 1; min-width: 0; }
// ---------- Sidebar (site navigation) ----------
.site-sidebar {
position: sticky;
top: 0;
z-index: 60;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 8px;
width: var(--sidebar-w);
height: 100vh;
padding: 22px 18px;
backdrop-filter: blur(14px);
background: color-mix(in srgb, var(--bg) 78%, transparent);
border-right: 1px solid var(--border);
transition: width 0.22s ease, padding 0.22s ease;
}
.sidebar-nav {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 6px;
margin-top: 26px;
.btn { justify-content: center; margin-top: 6px; }
}
.sidebar-footer {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
margin-top: auto;
// the footer buttons read as nav rows — icon then label, centred, taking the
// sidebar's width so they grow and shrink with it (both classes are grid, which
// would drop the label under the icon, and the label shows on mobile too, so
// the row layout lives outside the desktop block)
.panel-icon-btn,
.icon-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 0.86rem;
font-weight: 550;
white-space: nowrap;
svg { flex-shrink: 0; }
}
}
// The account row: who the wiki is being written as. It reads as one of the
// sidebar's rows — an initial in a circle, the name while the sidebar has room
// for it, and the way out at the end of the line.
.account-row {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 10px;
color: var(--muted);
font-size: 0.86rem;
font-weight: 550;
white-space: nowrap;
}
.account-avatar { @include avatar(30px, 0.84rem); }
// the row's avatar and name together form the button that opens the profile
// popup; the exit button beside it stays its own
.account-open {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
flex: 1;
min-width: 0;
border-radius: 9px;
text-align: left;
&:hover .account-avatar { background: color-mix(in srgb, var(--accent) 24%, transparent); }
&:hover .account-name { color: var(--accent); }
}
.account-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text);
}
.account-exit {
@include icon-target(24px, 7px);
color: var(--faint);
transition: color 0.15s, background-color 0.15s;
&:hover { background: var(--surface-2); color: var(--danger); }
}
@media (max-width: 900px) {
.app-shell { flex-direction: column; }
.site-sidebar {
width: 100%;
height: auto;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
row-gap: 10px;
padding: 12px clamp(16px, 4vw, 32px);
border-right: 0;
border-bottom: 1px solid var(--border);
}
.sidebar-nav {
flex-direction: row;
align-items: center;
margin-top: 0;
.btn { margin-top: 0; }
}
.sidebar-search { width: auto; min-width: 132px; max-width: 240px; }
.sidebar-footer { margin-top: 0; flex-direction: row; align-items: center; }
// the rail is a desktop affordance — the top bar is always expanded
.sidebar-collapse-btn { display: none; }
}
// ---------- Collapsed sidebar: an icon rail (desktop only) ----------
.sidebar-collapse-btn svg { transition: transform 0.22s ease; }
@media (min-width: 901px) {
// desktop: stack above the reading surface (book layer, z 70/74) so the
// book's edge shadow falls behind the nav instead of darkening it
.site-sidebar { z-index: 75; }
// on desktop they fill the sidebar's width instead of their own: the collapsing
// sidebar relayouts them every frame, the rail drops their labels (.hide-collapsed)
// and their fixed heights hold, so the icons ride the centre the whole way
.sidebar-footer .panel-icon-btn,
.sidebar-footer .icon-btn,
.sidebar-footer .account-row {
width: 100%;
}
// the root class also moves the fixed reading surface in step, since
// --sidebar-w feeds the book layer's left inset
html.sidebar-collapsed { --sidebar-w: 68px; }
.site-sidebar.collapsed {
padding-inline: 12px;
.sidebar-collapse-btn svg { transform: rotate(180deg); }
// with the labels gone, center what's left: the icons. The buttons keep
// the height the labels used to give them (font-size x line-height 1.6
// + their vertical padding), so the rail doesn't shrink them
.sidebar-search {
justify-content: center;
padding-inline: 0;
min-height: 40px; // 9+9 padding + 13.76px x 1.6 label line
}
.nav-link {
justify-content: center;
padding-inline: 0;
min-height: 39px; // 8+8 padding + 14.4px x 1.6 label line
}
.sidebar-nav .btn {
min-height: 35px; // 7+7 padding + 13.12px x 1.6 label line
}
// the mark is all that's left of the wordmark, so it rides the rail's
// centre like the icons beside it
.logo { justify-content: center; }
// the way out drops away with the labels, leaving just the avatar centred
// on the rail; the avatar sets the row's height now, so the rail doesn't
// shrink it
.account-row {
min-height: 44px; // 1+1 border + 6+6 padding + 30px avatar
}
}
// collapsing drops the labels at once; expanding waits for the rail to
// open before fading them back in, so they never render squished at
// half width mid-transition
.hide-collapsed { transition: opacity 0.16s ease 0.1s, display 0.26s allow-discrete; }
@starting-style {
.hide-collapsed { opacity: 0; }
}
html.sidebar-collapsed .hide-collapsed { display: none; transition: none; }
}
// ---------- "Open" articles: the count beside the button that shows them ----------
.sidebar-open.is-current {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 9%, transparent);
}
.tabs-count {
margin-left: auto;
min-width: 18px;
height: 18px;
padding: 0 5px;
display: inline-grid;
place-items: center;
border-radius: 9px;
background: color-mix(in srgb, var(--accent) 14%, transparent);
color: var(--accent);
font-size: 0.68rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.logo {
display: flex;
align-items: center;
gap: 11px;
text-decoration: none;
color: var(--text);
font-weight: 800;
font-size: 1.15rem;
letter-spacing: -0.02em;
white-space: nowrap;
}
// a 40px square — the height the nav rows settle at (the search bar's own
// padding + label line), so the sigma reads as one of the sidebar's buttons
.logo-mark {
width: 40px;
height: 40px;
border-radius: 11px;
display: grid;
place-items: center;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
color: #fff;
font-size: 1.4rem;
font-weight: 700;
box-shadow: 0 4px 14px -4px color-mix(in srgb, var(--accent) 60%, transparent);
}
.logo-accent { @include gradient-text; }
.nav-link {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 8px 13px;
border-radius: 9px;
color: var(--muted);
text-decoration: none;
font-size: 0.9rem;
font-weight: 550;
@include hover-row;
&.router-link-active {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 9%, transparent);
}
}
// small icon button used in modal/popup headers
.panel-icon-btn { @include icon-target(30px, 8px); color: var(--muted); @include hover-row; }

View File

@@ -0,0 +1,211 @@
// Shared shapes the rest of the design is cut from. Colours stay CSS custom
// properties throughout — [data-theme]/[data-accent] swap them at runtime.
// the dark backdrop a popup opens onto ($pad-y: how far down the sheet sits)
@mixin overlay($z, $pad-y: clamp(56px, 13vh, 132px)) {
position: fixed;
inset: 0;
z-index: $z;
display: flex;
justify-content: center;
align-items: flex-start;
padding: $pad-y 16px 16px;
background: color-mix(in srgb, #05070d 55%, transparent);
backdrop-filter: blur(3px);
}
// the paper dropped under a backdrop
@mixin sheet($w, $max-h: null) {
display: flex;
flex-direction: column;
width: min($w, 100%);
@if $max-h {
max-height: $max-h;
}
border: 1px solid var(--border);
border-radius: 16px;
background: var(--bg-elev);
box-shadow: var(--shadow-lg);
overflow: hidden;
}
// a popup's title row: the title, and the × at the end of it
@mixin sheet-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 13px 14px 13px 18px;
border-bottom: 1px solid var(--border);
}
@mixin sheet-title {
margin: 0;
font-size: 1.02rem;
font-weight: 700;
}
// the scrolling middle of a popup
@mixin sheet-body($pad: 18px) {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: $pad;
scrollbar-width: thin;
}
// the opening animation every popup shares: the backdrop fades, the sheet
// slides in from the edge it hangs nearest ($offset is where it starts)
@mixin popup-anim($name, $sheet, $dur, $sheet-move, $offset) {
.#{$name}-enter-active,
.#{$name}-leave-active {
transition: opacity $dur ease;
#{$sheet} { transition: $sheet-move; }
}
.#{$name}-enter-from,
.#{$name}-leave-to {
opacity: 0;
#{$sheet} { transform: translateY($offset) scale(0.98); }
}
}
// a menu that drops from a button: the popups' paper, on a hinge
// ($overflow: `auto` once it caps at a max-height; `visible` may not clip
// a nested dropdown)
@mixin dropdown-panel($z: 20, $overflow: auto) {
position: absolute;
z-index: $z;
top: calc(100% + 6px);
padding: 6px;
overflow-y: $overflow;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--bg-elev);
box-shadow: var(--shadow-lg);
}
// a row inside a dropdown: full width, icon then label, an accent wash on hover
@mixin menu-row($pad, $gap, $fs: 0.85rem) {
display: flex;
align-items: center;
gap: $gap;
width: 100%;
padding: $pad;
border-radius: 8px;
color: var(--text);
font-size: $fs;
font-weight: 600;
transition: background-color 0.12s;
}
// the small caps label over a section of the interface
@mixin eyebrow($fs: 0.72rem, $fw: 750, $ls: 0.1em, $color: var(--faint)) {
font-size: $fs;
font-weight: $fw;
text-transform: uppercase;
letter-spacing: $ls;
color: $color;
}
// the account's face in a circle: an initial, or a picture laid over it
@mixin avatar($size, $fs) {
display: grid;
place-items: center;
flex-shrink: 0;
width: $size;
height: $size;
border-radius: 50%;
background: color-mix(in srgb, var(--accent) 14%, transparent);
color: var(--accent);
font-size: $fs;
font-weight: 800;
line-height: 1;
overflow: hidden;
img {
display: block;
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
}
// a square target that centres its icon
@mixin icon-target($size, $radius) {
display: grid;
place-items: center;
flex-shrink: 0;
width: $size;
height: $size;
border-radius: $radius;
}
// a quiet row that warms on hover (nav links, icon buttons)
@mixin hover-row {
transition: color 0.15s, background-color 0.15s;
&:hover { color: var(--text); background: var(--surface-2); }
}
// a row of choices styled as one segmented control
@mixin segmented {
display: inline-flex;
gap: 4px;
padding: 4px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface-2);
}
@mixin segmented-tab($pad, $fs) {
padding: $pad;
border-radius: 9px;
background: transparent;
color: var(--muted);
font-size: $fs;
font-weight: 600;
transition: color 0.15s, background-color 0.15s, box-shadow 0.15s;
&:hover { color: var(--text); }
&.active { background: var(--surface); color: var(--text); box-shadow: var(--shadow-sm); }
}
// the loading shimmer a skeleton wears
@mixin shimmer {
background: linear-gradient(
100deg,
var(--surface) 40%,
color-mix(in srgb, var(--surface) 55%, var(--text) 9%) 50%,
var(--surface) 60%
);
background-size: 200% 100%;
animation: shimmer 1.4s infinite linear;
}
// paint text with a gradient between the two accents
@mixin gradient-text($angle: 90deg) {
background: linear-gradient($angle, var(--accent), var(--accent-2));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
// a row of the book: no gutter along its edge (a book has none)
@mixin no-scrollbar {
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
// the × that closes a page or a chip: faint, and turns danger on hover
@mixin close-target {
&:hover {
color: var(--danger);
background: color-mix(in srgb, var(--danger) 10%, transparent);
}
}

View File

@@ -0,0 +1,91 @@
@use 'mixins' as *;
// ---------- Profile popup ----------
// The account row in the sidebar opens it; it draws and closes in the same
// shape as the settings popup. Holds the profile picture (a data URL kept on
// the account), the username, the password, and the way out.
.profile-overlay { @include overlay(120); }
.profile-modal {
@include sheet(460px, min(620px, calc(100vh - 110px)));
&:focus, &:focus-visible { outline: none; }
}
.profile-modal-head { @include sheet-head; }
.profile-title { @include sheet-title; }
.profile-modal-body { @include sheet-body; }
.profile-group + .profile-group {
margin-top: 22px;
padding-top: 22px;
border-top: 1px solid var(--border);
}
.profile-group-head {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 12px;
}
.profile-group-title { font-size: 0.92rem; font-weight: 700; color: var(--text); }
.profile-group-hint { font-size: 0.8rem; color: var(--muted); }
// the big avatar beside its buttons
.profile-avatar-row { display: flex; align-items: center; gap: 18px; }
.profile-avatar { @include avatar(72px, 1.7rem); }
.profile-avatar-actions {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
// the file chooser is driven by the buttons above it, never shown itself
.profile-file-input { display: none; }
.profile-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
input {
flex: 1;
min-width: 0;
padding: 10px 13px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-elev);
font-size: 0.88rem;
transition: border-color 0.15s, box-shadow 0.2s;
&:focus { outline: none; border-color: var(--accent); box-shadow: var(--ring); }
}
// the password pair stack one per line, the button below them
&-password input { flex: 1 1 100%; }
}
.profile-saved {
margin: 8px 0 0;
font-size: 0.8rem;
font-weight: 600;
color: var(--accent);
}
.profile-signout { margin-top: 2px; }
@include popup-anim(
'profile-popup',
'.profile-modal',
0.16s,
transform 0.2s cubic-bezier(0.22, 0.61, 0.36, 1),
-10px
);

View File

@@ -0,0 +1,167 @@
@use 'mixins' as *;
// ---------- Prose (rendered markdown) ----------
.prose {
font-size: 1.02rem;
line-height: 1.75;
color: var(--text);
overflow-wrap: break-word;
> :first-child { margin-top: 0; }
> :first-child > :first-child, h1:first-child { margin-top: 0.6em; }
h1,
h2,
h3,
h4 {
scroll-margin-top: 90px;
line-height: 1.25;
letter-spacing: -0.02em;
font-weight: 730;
}
h1 { font-size: 1.85rem; margin: 1.4em 0 0.5em; }
h2 {
font-size: 1.42rem;
margin: 2.1em 0 0.55em;
padding-bottom: 0.32em;
border-bottom: 1px solid var(--border);
}
h3 { font-size: 1.17rem; margin: 1.7em 0 0.45em; }
h4 { font-size: 1.02rem; margin: 1.5em 0 0.4em; }
p { margin: 0.95em 0; }
a {
color: var(--accent);
text-decoration: none;
border-bottom: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
transition: border-color 0.15s, color 0.15s;
&:hover { color: var(--accent-strong); border-bottom-color: currentColor; }
// internal wiki links written as [[Article Title]]
&.wikilink { border-bottom-style: dotted; }
}
strong { font-weight: 730; }
ul, ol { padding-left: 1.45em; margin: 0.9em 0; }
li {
margin: 0.35em 0;
&::marker { color: var(--faint); }
}
blockquote {
margin: 1.4em 0;
padding: 0.75em 1.2em;
border-left: 3px solid var(--accent);
background: color-mix(in srgb, var(--accent) 6%, transparent);
border-radius: 0 12px 12px 0;
color: var(--muted);
p { margin: 0.2em 0; }
}
hr { border: 0; border-top: 1px solid var(--border); margin: 2.4em 0; }
table {
display: block;
width: max-content;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
margin: 1.5em 0;
font-size: 0.94em;
border: 1px solid var(--border);
border-radius: 12px;
}
thead { background: var(--surface-2); }
th, td { padding: 0.58em 0.95em; border: 1px solid var(--border); text-align: left; }
tbody tr:nth-child(even) { background: color-mix(in srgb, var(--surface) 55%, transparent); }
img { max-width: 100%; border-radius: 12px; }
kbd {
font-family: inherit;
font-size: 0.8em;
border: 1px solid var(--border);
border-bottom-width: 2px;
border-radius: 6px;
padding: 1px 6px;
background: var(--surface-2);
}
// inline code
code {
background: color-mix(in srgb, var(--text) 8%, transparent);
border: 1px solid var(--border);
padding: 0.12em 0.42em;
border-radius: 6px;
font-size: 0.86em;
}
pre {
margin: 1.4em 0;
padding: 17px 20px;
border-radius: 13px;
border: 1px solid var(--border);
background: var(--code-bg);
overflow-x: auto;
line-height: 1.65;
font-size: 0.875rem;
code {
background: none;
border: 0;
padding: 0;
font-size: inherit;
}
}
}
// code tokens (highlight.js)
.hljs-keyword, .hljs-selector-tag, .hljs-doctag { color: var(--tok-key); }
.hljs-string, .hljs-regexp, .hljs-addition { color: var(--tok-str); }
.hljs-comment { color: var(--tok-com); font-style: italic; }
.hljs-number, .hljs-variable, .hljs-template-variable { color: var(--tok-num); }
.hljs-title, .hljs-title.function_, .hljs-section { color: var(--tok-fn); }
.hljs-built_in,
.hljs-type,
.hljs-class .hljs-title,
.hljs-title.class_ {
color: var(--tok-built);
}
.hljs-literal, .hljs-symbol, .hljs-bullet, .hljs-link { color: var(--tok-lit); }
.hljs-attr, .hljs-attribute, .hljs-property, .hljs-meta { color: var(--tok-meta); }
.hljs-deletion { color: var(--danger); }
// ---------- KaTeX ----------
.katex { font-size: 1.06em; }
.prose .katex-display {
margin: 1.35em 0;
padding: 0.3em 0.2em;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
}
.prose .katex-error { color: var(--danger); font-family: inherit; }

View File

@@ -0,0 +1,182 @@
@use 'mixins' as *;
// ---------- Search (sidebar bar + centered popup) ----------
.sidebar-search {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 9px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
color: var(--faint);
font-size: 0.86rem;
font-weight: 550;
text-align: left;
box-shadow: var(--shadow-sm);
transition: border-color 0.15s, color 0.15s;
&:hover { border-color: var(--border-strong); color: var(--muted); }
svg { flex-shrink: 0; }
}
.sidebar-search-label {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.kbd-hint {
font-family: inherit;
font-size: 0.68rem;
font-weight: 600;
color: var(--faint);
border: 1px solid var(--border);
border-bottom-width: 2px;
border-radius: 5px;
padding: 0 5px;
background: var(--surface-2);
white-space: nowrap;
}
.search-overlay { @include overlay(120); }
.search-modal { @include sheet(620px, min(560px, calc(100vh - 110px))); }
.search-modal-field {
display: flex;
align-items: center;
gap: 11px;
padding: 13px 16px;
border-bottom: 1px solid var(--border);
svg { flex-shrink: 0; color: var(--faint); }
}
.search-modal-input {
flex: 1;
min-width: 0;
border: 0;
background: transparent;
font-size: 1.02rem;
&:focus, &:focus-visible { outline: none; box-shadow: none; }
&::placeholder { color: var(--faint); }
}
.search-spinner {
flex-shrink: 0;
width: 15px;
height: 15px;
border-radius: 50%;
border: 2px solid color-mix(in srgb, var(--accent) 25%, transparent);
border-top-color: var(--accent);
animation: search-spin 0.7s linear infinite;
}
@keyframes search-spin {
to { transform: rotate(1turn); }
}
.search-modal-body {
@include sheet-body(8px);
.banner { margin: 8px 8px 10px; }
}
.search-results { list-style: none; margin: 0; padding: 0; }
.search-result {
display: flex;
flex-direction: column;
gap: 3px;
padding: 10px 12px;
border-radius: 11px;
text-decoration: none;
color: inherit;
transition: background-color 0.12s;
&.active { background: color-mix(in srgb, var(--accent) 12%, transparent); }
}
.sr-title {
font-weight: 650;
font-size: 0.94rem;
line-height: 1.35;
mark {
background: color-mix(in srgb, var(--accent) 30%, transparent);
color: inherit;
border-radius: 3px;
padding: 0 1px;
}
}
.sr-meta {
display: flex;
align-items: center;
gap: 10px;
margin-top: 1px;
font-size: 0.74rem;
color: var(--faint);
}
.search-quick-title { @include eyebrow; margin: 6px 8px 4px; }
.search-none {
text-align: center;
padding: 32px 16px;
color: var(--muted);
font-size: 0.88rem;
strong { color: var(--text); }
}
.search-skeleton {
height: 54px;
margin: 4px;
border-radius: 11px;
@include shimmer;
}
.search-modal-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 9px 14px;
border-top: 1px solid var(--border);
background: var(--surface);
font-size: 0.75rem;
color: var(--faint);
}
.foot-hints {
display: flex;
align-items: center;
gap: 12px;
.kbd-hint { margin-right: 4px; }
}
.foot-all {
color: var(--accent);
text-decoration: none;
font-weight: 600;
white-space: nowrap;
&:hover { text-decoration: underline; }
}
@include popup-anim(
'search-popup',
'.search-modal',
0.16s,
transform 0.2s cubic-bezier(0.22, 0.61, 0.36, 1),
-10px
);

View File

@@ -0,0 +1,73 @@
@use 'mixins' as *;
// ---------- Settings popup ----------
.settings-overlay { @include overlay(120); }
.settings-modal {
@include sheet(460px, min(560px, calc(100vh - 110px)));
&:focus, &:focus-visible { outline: none; }
}
.settings-modal-head { @include sheet-head; }
.settings-title { @include sheet-title; }
.settings-modal-body { @include sheet-body; }
.settings-group + .settings-group {
margin-top: 22px;
padding-top: 22px;
border-top: 1px solid var(--border);
}
.settings-group-head {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 12px;
}
.settings-group-title { font-size: 0.92rem; font-weight: 700; color: var(--text); }
.settings-group-hint { font-size: 0.8rem; color: var(--muted); }
// segmented light / dark switch
.theme-switch { @include segmented; }
.theme-option {
display: inline-flex;
align-items: center;
gap: 7px;
@include segmented-tab(8px 16px, 0.86rem);
}
// accent colour swatches
.accent-swatches { display: flex; flex-wrap: wrap; gap: 14px; }
.accent-swatch {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: 50%;
border: 0;
padding: 0;
color: #fff;
background: linear-gradient(135deg, var(--swatch), var(--swatch-2));
transition: transform 0.12s, box-shadow 0.15s;
&:hover { transform: scale(1.1); }
&:active { transform: scale(0.94); }
&.active { box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 4px var(--swatch); }
}
@include popup-anim(
'settings-popup',
'.settings-modal',
0.16s,
transform 0.2s cubic-bezier(0.22, 0.61, 0.36, 1),
-10px
);

View File

@@ -0,0 +1,46 @@
@use 'mixins' as *;
// ---------- States ----------
.empty {
text-align: center;
padding: 72px 20px;
color: var(--muted);
.glyph {
font-size: 3.4rem;
line-height: 1;
margin-bottom: 14px;
opacity: 0.65;
}
h3 { margin: 0 0 6px; color: var(--text); font-size: 1.15rem; }
p { margin: 0 auto 22px; max-width: 42ch; font-size: 0.93rem; }
}
.banner {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 15px 18px;
border-radius: 13px;
border: 1px solid color-mix(in srgb, var(--danger) 35%, var(--border));
background: color-mix(in srgb, var(--danger) 7%, var(--surface));
font-size: 0.9rem;
margin: 14px 0;
.glyph { color: var(--danger); font-weight: 800; }
code {
background: color-mix(in srgb, var(--text) 8%, transparent);
padding: 0.1em 0.4em;
border-radius: 5px;
font-size: 0.85em;
}
}
.skeleton { border-radius: 16px; height: 172px; @include shimmer; }
@keyframes shimmer {
to { background-position-x: -200%; }
}

View File

@@ -0,0 +1,110 @@
@use 'sass:list';
// ---------- Theme tokens ----------
// [data-theme] swaps the surfaces, [data-accent] the accents (no attribute
// = violet, the defaults below). Reading-surface sizes live here too: the
// sidebar's width feeds the book layer's left inset (see _book.scss).
:root {
color-scheme: light;
--bg: #f7f8fb;
--bg-elev: #ffffff;
--surface: #ffffff;
--surface-2: #eef1f7;
--border: #e2e6ef;
--border-strong: #cfd5e3;
--text: #1b2130;
--muted: #5b6478;
--faint: #8a93a8;
--accent: #4f5fe8;
--accent-strong: #3d4dd6;
--accent-2: #0ea5c9;
--danger: #d1344b;
--code-bg: #f4f6fa;
--shadow-sm: 0 1px 2px rgb(16 24 40 / 6%), 0 1px 3px rgb(16 24 40 / 8%);
--shadow-lg: 0 4px 12px rgb(16 24 40 / 8%), 0 16px 40px -12px rgb(16 24 40 / 18%);
--ring: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
// code token colors (light)
--tok-key: #8250df;
--tok-str: #0a3069;
--tok-com: #6e7781;
--tok-num: #0550ae;
--tok-fn: #6639ba;
--tok-built: #953800;
--tok-lit: #cf222e;
--tok-meta: #7d4e00;
// the reader's book (see _book.scss)
--sidebar-w: 232px;
--spine-w: 34px;
// how wide a page of the book is allowed to get: past this the desk shows
// beside the paper rather than the page stretching on and on. The one page
// of a book with nothing else in it is exempt (see .book-pane:only-child).
--pane-max-w: 60rem;
}
:root[data-theme='dark'] {
color-scheme: dark;
--bg: #0a0c12;
--bg-elev: #10131c;
--surface: #141826;
--surface-2: #1a2030;
--border: #232a3c;
--border-strong: #313a52;
--text: #e7eaf3;
--muted: #99a1b5;
--faint: #6b7488;
--accent: #7c8cff;
--accent-strong: #99a5ff;
--accent-2: #38d3ee;
--danger: #f47085;
--code-bg: #0c0f16;
--shadow-sm: 0 1px 2px rgb(0 0 0 / 30%);
--shadow-lg: 0 4px 16px rgb(0 0 0 / 40%), 0 24px 48px -16px rgb(0 0 0 / 55%);
// code token colors (dark)
--tok-key: #c792ea;
--tok-str: #9ece6a;
--tok-com: #565f89;
--tok-num: #ff9e64;
--tok-fn: #7aa2f7;
--tok-built: #2ac3de;
--tok-lit: #ff757f;
--tok-meta: #e0af68;
}
// ---------- Accent colors ----------
// [data-accent] on <html> swaps the accent tokens (accent, accent-strong,
// accent-2). Every colour needs a light and a dark variant — the paired
// [data-theme='dark'] selector outranks the plain one, so it follows it.
$accents: (
'purple': ((#7c3aed, #6d28d9, #c026d3), (#a78bfa, #c4b5fd, #e879f9)),
'ocean': ((#2563eb, #1d4ed8, #06b6d4), (#60a5fa, #93c5fd, #22d3ee)),
'teal': ((#0d9488, #0f766e, #10b981), (#2dd4bf, #5eead4, #34d399)),
'forest': ((#16a34a, #15803d, #84cc16), (#4ade80, #86efac, #a3e635)),
'amber': ((#d97706, #b45309, #f97316), (#fbbf24, #fcd34d, #fb923c)),
'rose': ((#e11d48, #be123c, #ec4899), (#fb7185, #fda4af, #f472b6)),
'slate': ((#334155, #1e293b, #64748b), (#94a3b8, #cbd5e1, #7b8aa1)),
);
@each $name, $pair in $accents {
$light: list.nth($pair, 1);
$dark: list.nth($pair, 2);
:root[data-accent='#{$name}'] {
--accent: #{list.nth($light, 1)};
--accent-strong: #{list.nth($light, 2)};
--accent-2: #{list.nth($light, 3)};
}
:root[data-theme='dark'][data-accent='#{$name}'] {
--accent: #{list.nth($dark, 1)};
--accent-strong: #{list.nth($dark, 2)};
--accent-2: #{list.nth($dark, 3)};
}
}
// the desk is the page itself: no sidebar width to stand aside for
@media (max-width: 900px) {
:root { --sidebar-w: 0px; --spine-w: 30px; }
}

View File

@@ -0,0 +1,10 @@
// ---------- Nuxt route transitions ----------
.fade-enter-active, .fade-leave-active { transition: opacity 0.16s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
// shared between the plain pages — the reader doesn't use it: its pages
// move inside the open book (see .book-pane in _book.scss)
.page-enter-active, .page-leave-active { transition: opacity 0.16s ease; }
.page-enter-from, .page-leave-to { opacity: 0; }

View File

@@ -0,0 +1,28 @@
// ============================================================
// Mathew — the mathematics wiki
// Hand-written design system: light/dark themes, layout,
// article typography (prose), editor, code + KaTeX styling.
// Split into partials, one per section; order mirrors the old CSS.
// ============================================================
@use 'fonts';
@use 'tokens';
@use 'base';
@use 'layout'; // shell, sidebar, account row, collapsed rail
@use 'search'; // sidebar bar + the ⌘K popup
@use 'settings';
@use 'profile';
@use 'auth'; // sign-in popup + the editor's gate
@use 'buttons'; // .btn family + .icon-btn
@use 'home'; // hero, results bar
@use 'cards'; // article cards, tags
@use 'articles'; // topics index bits
@use 'langs'; // badges, chips, version & language menus
@use 'states'; // empty, banner, skeleton
@use 'admin'; // the accounts dashboard
@use 'article'; // article page, ⋮ menu, TOC, recently viewed
@use 'prose'; // rendered markdown, highlight.js, KaTeX
@use 'editor'; // editor, cheatsheet, confirm, fullscreen
@use 'book'; // the reader: an open book of article pages
@use 'graph'; // the link map
@use 'transitions'; // route fades, scrollbars