First commit
This commit is contained in:
78
frontend/app/assets/css/_admin.scss
Normal file
78
frontend/app/assets/css/_admin.scss
Normal 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; }
|
||||
225
frontend/app/assets/css/_article.scss
Normal file
225
frontend/app/assets/css/_article.scss
Normal 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; }
|
||||
67
frontend/app/assets/css/_articles.scss
Normal file
67
frontend/app/assets/css/_articles.scss
Normal 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;
|
||||
}
|
||||
119
frontend/app/assets/css/_auth.scss
Normal file
119
frontend/app/assets/css/_auth.scss
Normal 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); }
|
||||
65
frontend/app/assets/css/_base.scss
Normal file
65
frontend/app/assets/css/_base.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
300
frontend/app/assets/css/_book.scss
Normal file
300
frontend/app/assets/css/_book.scss
Normal 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; }
|
||||
}
|
||||
62
frontend/app/assets/css/_buttons.scss
Normal file
62
frontend/app/assets/css/_buttons.scss
Normal 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); }
|
||||
}
|
||||
80
frontend/app/assets/css/_cards.scss
Normal file
80
frontend/app/assets/css/_cards.scss
Normal 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); }
|
||||
295
frontend/app/assets/css/_editor.scss
Normal file
295
frontend/app/assets/css/_editor.scss
Normal 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; }
|
||||
19
frontend/app/assets/css/_fonts.scss
Normal file
19
frontend/app/assets/css/_fonts.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
68
frontend/app/assets/css/_graph.scss
Normal file
68
frontend/app/assets/css/_graph.scss
Normal 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; }
|
||||
}
|
||||
152
frontend/app/assets/css/_home.scss
Normal file
152
frontend/app/assets/css/_home.scss
Normal 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; }
|
||||
}
|
||||
}
|
||||
193
frontend/app/assets/css/_langs.scss
Normal file
193
frontend/app/assets/css/_langs.scss
Normal 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); }
|
||||
}
|
||||
283
frontend/app/assets/css/_layout.scss
Normal file
283
frontend/app/assets/css/_layout.scss
Normal 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; }
|
||||
211
frontend/app/assets/css/_mixins.scss
Normal file
211
frontend/app/assets/css/_mixins.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
91
frontend/app/assets/css/_profile.scss
Normal file
91
frontend/app/assets/css/_profile.scss
Normal 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
|
||||
);
|
||||
167
frontend/app/assets/css/_prose.scss
Normal file
167
frontend/app/assets/css/_prose.scss
Normal 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; }
|
||||
182
frontend/app/assets/css/_search.scss
Normal file
182
frontend/app/assets/css/_search.scss
Normal 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
|
||||
);
|
||||
73
frontend/app/assets/css/_settings.scss
Normal file
73
frontend/app/assets/css/_settings.scss
Normal 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
|
||||
);
|
||||
46
frontend/app/assets/css/_states.scss
Normal file
46
frontend/app/assets/css/_states.scss
Normal 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%; }
|
||||
}
|
||||
110
frontend/app/assets/css/_tokens.scss
Normal file
110
frontend/app/assets/css/_tokens.scss
Normal 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; }
|
||||
}
|
||||
10
frontend/app/assets/css/_transitions.scss
Normal file
10
frontend/app/assets/css/_transitions.scss
Normal 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; }
|
||||
28
frontend/app/assets/css/main.scss
Normal file
28
frontend/app/assets/css/main.scss
Normal 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
|
||||
Reference in New Issue
Block a user