Files
pagerite/frontend/src/assets/style.css
T
LeoVasanko 3dc438f8df Fix structure tree drag feedback: stable rows, on-row child drops
Rework the site structure tree's drag-and-drop to match Sortable's native
model instead of mid-drag drop zones:

- Drop the dashed empty-list drop zones that appeared on drag start
  (flicker, space reservation, and mis-targeted drops: the last item's
  child zone sat exactly where 'end of parent list' was).
- Every non-empty list (and the root) ends with a non-draggable  footer
  row (vuedraggable #footer slot): click to start a new page at that
  level, and while dragging it is the 'end of this list' drop target.
  It renders last even mid-drag via flex order, since Sortable appends
  end-of-list previews after it in the DOM.
- Dropping ON the lower part of a row makes the page that row's first
  child: the child list's container invisibly overlaps its own row's
  bottom (negative margin + equal padding, no layout effect) and becomes
  hit-testable only while dragging. A row's exposed top strip remains
  the 'sibling before' target. Leaf rows work too, creating a sublist.
- Indentation is structural (each nested list margin-indents itself), so
  a dragged row previews its whole subtree at the target list's depth.
  Rows get a little vertical padding to widen the drop zones.
2026-08-16 18:05:33 +00:00

683 lines
14 KiB
CSS

/* Shared styles for server-rendered pages and Vue components. */
@import url("./fonts/fonts.css");
@import url("./pygments.css");
:root {
color-scheme: dark;
--bg: #12101c;
--surface: #1b1830;
--text: #e8e6f2;
--muted: #9a94b8;
--accent: #00d4c8;
--accent2: #7c5cff;
--line: #ffffff1a;
/* Width of the docked editor panel (used both here for shifting the page
and in the Vue editor's own styles). */
--editor-w: min(46rem, 50vw);
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
font-family: "Literata", Georgia, serif;
font-size: 1.05rem;
line-height: 1.65;
margin: 0;
background: var(--bg);
color: var(--text);
/* Full-bleed elements (.wide) size to 100vw, which counts the vertical
scrollbar; clip the few stray pixels instead of scrolling. */
overflow-x: clip;
/* Full height even on short pages: the footer sits at the bottom and the
docked editor (sized by #content) never collapses. */
min-height: 100vh;
display: flex;
flex-direction: column;
}
#content {
flex: 1;
}
::selection {
background: var(--accent2);
}
/* Full-width banner: image header with the brand and nav overlaid. */
#banner {
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-end;
min-height: 11rem;
background: url("./banner.svg") center 40% / cover;
border-bottom: 1px solid var(--line);
}
/* Per-page banner content (img, styled div, canvas...) overlays the
default artwork; swapped along with #nav/#main on fetch-navigation. */
#page-banner {
position: absolute;
inset: 0;
overflow: hidden;
}
#page-banner>* {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
#brand,
#nav {
position: relative;
}
#brand {
font-family: "Fraunces", serif;
font-weight: 700;
font-size: 2.4rem;
text-decoration: none;
margin: auto 1.25rem 0;
padding-top: 1.5rem;
background: linear-gradient(90deg, var(--accent), var(--accent2));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-shadow: none;
filter: drop-shadow(0 0 0.1rem #000);
}
/* Nav overlaid at the bottom of the banner */
#nav {
font-size: 1.3em;
text-shadow: 0 0 0.1em black;
padding: 0.35rem 1.25rem;
}
#nav ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
gap: 0.25rem 1.5rem;
flex-wrap: wrap;
align-items: baseline;
}
#nav a {
text-decoration: none;
color: var(--text);
}
#nav ul ul a {
color: var(--muted);
}
#nav a:hover {
color: var(--accent);
}
#nav span {
color: var(--muted);
font-size: 0.95rem;
}
#nav .current {
color: var(--accent);
font-weight: 500;
}
/* Sidebar + main row. A symmetric grid: the article column is sized by the
viewport alone (never by content), with equally sized flexible gutters
on both sides. The sidebar sits in the left gutter, so it appearing or
disappearing never shifts the article; the right gutter balances it. */
#content {
position: relative;
display: grid;
grid-template-columns: 1fr minmax(0, 78rem) 1fr;
/* The docked editor pushes the content (not the header) right. */
transition: margin-left 0.25s ease;
}
body.editing #content {
margin-left: var(--editor-w);
padding-left: 1rem;
/* gap between the docked editor and the content */
/* No overflow clipping here: .editor-host lives outside this box
(negative left), and .wide shrink-wraps to the remaining space. */
}
/* The sidebar's gutter space is needed by the editor instead. */
body.editing #sidebar {
display: none;
}
/* The editor host lives inside #content: it starts below the banner and
ends above the footer. The panel itself sticks to the viewport while
scrolling (but never taller than the content area). */
.editor-host {
position: absolute;
top: 0;
bottom: 0;
left: calc(0px - var(--editor-w));
width: var(--editor-w);
}
.editor-root.overlay {
position: sticky;
top: 0;
height: 100vh;
max-height: 100%;
background: var(--bg);
animation: editor-slide-in 0.25s ease;
}
.editor-root.overlay.closing {
transform: translateX(-100%);
transition: transform 0.25s ease;
}
@keyframes editor-slide-in {
from {
transform: translateX(-100%);
}
}
/* The banner's own pen: opens the site editor (banner + structure).
Qualified with `button` to beat the later .edit-link rule's left offset
(both classes apply to the same element). */
button.banner-edit-link {
position: absolute;
top: 0.6rem;
right: 1.25rem;
left: auto;
z-index: 10;
font: inherit;
border: none;
cursor: pointer;
background: none;
padding: 0;
}
#sidebar {
grid-column: 1;
/* Pinned to the page's left edge (not the article's) and kept in view
while scrolling. Translucent + blurred rather than an opaque box, so
full-bleed .wide images can pass underneath without a hard edge. */
justify-self: start;
align-self: start;
position: sticky;
top: 0;
z-index: 1;
width: 12rem;
max-height: 100vh;
overflow-y: auto;
padding: 1rem 1rem 1rem 1.25rem;
border-radius: 0 0 0.5rem 0;
background: color-mix(in srgb, var(--bg) 75%, transparent);
backdrop-filter: blur(0.5rem);
}
#sidebar:empty {
display: none;
}
#sidebar ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.8em;
line-height: 1.0;
}
#sidebar a {
text-decoration: none;
color: var(--muted);
}
#sidebar a:hover {
color: var(--accent);
}
#sidebar .current {
color: var(--accent);
font-weight: 500;
}
main {
grid-column: 2;
/* No top padding: a leading wide image sits flush under the banner, and
text-first pages get their spacing from the h1's top margin instead. */
padding: 0 1.25rem 3rem;
}
article h1,
article h2,
article h3 {
font-family: "Fraunces", serif;
font-weight: 600;
line-height: 1.25;
}
article h1 {
font-size: 2.2rem;
margin: 2rem 0 1.2rem;
color: var(--accent);
}
/* Margin strategy: bottom-only inside articles. Top margins misalign
column tops and collapse unpredictably; spacing comes from below. */
article p,
article ul,
article ol,
article dl,
article blockquote,
article pre,
article figure {
margin-top: 0;
margin-bottom: 1rem;
}
article h3 {
margin: 1.4rem 0 0.4rem;
color: color-mix(in oklab, var(--accent2) 60%, var(--muted));
}
/* Lists: small diamond emoji markers — blue 🔹 on odd nesting levels,
orange 🔸 on even. The marker occupies a 1em outdented box so wrapped
lines align. */
article ul {
list-style: none;
padding-inline-start: 1em;
}
article ul li::before {
content: "🔹";
display: inline-block;
margin-left: -1.3em;
width: 1.3em;
}
article ul ul li::before {
content: "🔸";
}
article ul ul ul li::before {
content: "🔹";
}
/* Task lists render emoji checkmarks (see markdown.py), no diamond. */
article .task-list-item::before {
content: none;
}
article {
position: relative;
}
.edit-link {
position: absolute;
top: 0.2rem;
/* In the left gutter, on the same side as the docked editor panel. */
left: -2.2rem;
z-index: 2;
/* stay above full-bleed .wide images */
font: inherit;
background: none;
border: none;
padding: 0;
cursor: pointer;
opacity: 0.7;
text-shadow: 0 0 0.1em black;
}
/* pagerite.js tucks the pen at the end of the article's first h1. */
article h1 .edit-link {
position: static;
font-size: 1.1rem;
vertical-align: 0.3em;
margin-left: 0.4rem;
}
.edit-link:hover {
opacity: 1;
}
article p,
article li,
article dd {
text-align: justify;
hyphens: auto;
}
/* Multi-column reading on wide displays, but only for long articles
(pagerite.js adds .multicol based on content length and splits the body
into .colseg segments separated by full-width h2s and wide figures;
only segments with enough text get .cols and thus columns). Columns only
reflow text inside the article; the article's width never changes. */
@media (min-width: 100rem) {
.multicol .colseg.cols {
columns: 2;
column-gap: 3.5rem;
column-rule: 1px solid var(--line);
}
}
.multicol .colseg {
margin-bottom: 1rem;
p,
li {
break-inside: avoid-column;
}
figure,
pre,
blockquote,
table,
dl {
break-inside: avoid;
}
}
article h2 {
font-size: 1.5rem;
margin: 2.2rem 0 0.6rem;
color: var(--accent2);
}
article a {
color: var(--accent);
text-decoration: none;
}
article a:hover {
text-decoration: underline;
}
/* Blockquotes: inner paragraphs carry no margins (spacing comes from the
blockquote itself, bottom-only like everything else in articles). The
negative left margin pushes the bar out past the text edge, so quoted
text aligns with the surrounding paragraphs — same trick as code blocks. */
blockquote {
margin: 0 0 1rem -0.5rem;
padding: 0 0 0 0.25rem;
border-left: 0.25rem solid var(--accent2);
color: var(--muted);
}
blockquote p {
margin: 0;
}
pre {
overflow-x: auto;
padding: 0.5rem 0.8rem;
/* Code text aligns with the surrounding paragraphs: the box extends
past them by its own padding. */
margin-left: -0.8rem;
margin-right: -0.8rem;
background: #ffffff09;
border-radius: 4px;
position: relative;
}
/* Inline code integrates with the text, no box of its own */
p code,
li code {
font-size: 0.85em;
}
/* Click-to-copy button (added by pagerite.js) */
.copy {
position: absolute;
top: 0.35rem;
right: 0.35rem;
font: inherit;
font-size: 0.75rem;
padding: 0.15rem 0.6rem;
color: var(--muted);
background: var(--bg);
border: 1px solid var(--line);
border-radius: 4px;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
}
pre:hover .copy,
.copy:focus-visible {
opacity: 1;
}
.copy.copied {
color: var(--accent);
}
code {
font-family: "Fira Code", ui-monospace, monospace;
font-size: 0.88em;
}
table {
border-collapse: collapse;
}
th,
td {
border: 1px solid var(--line);
padding: 0.35rem 0.8rem;
}
/* Images and figures */
img {
max-width: 100%;
height: auto;
border-radius: 6px;
}
figure {
margin: 0 0 1.5rem;
}
figcaption {
color: var(--muted);
font-size: 0.85rem;
margin-top: 0.4rem;
}
/* Captions of full-bleed images: centered and kept to a readable width. */
figure:has(.wide) figcaption {
max-width: 65ch;
margin-inline: auto;
text-align: center;
}
/* Positioning via brace-attribute classes: {.right}, {.left}, {.wide} */
figure:has(.right),
img.right {
float: right;
margin: 0.3rem 0 1rem 1.5rem;
max-width: 45%;
}
figure:has(.left),
img.left {
float: left;
margin: 0.3rem 1.5rem 1rem 0;
max-width: 45%;
}
/* .wide is full bleed: edge to edge of the viewport (or of the space left
of the docked editor). Centered on the article column — which is itself
centered in the available space — via margin-left: 50% + translateX(-50%).
The sidebar stacks above it (z-index + opaque background). */
figure:has(.wide),
img.wide {
display: block;
/* no inline strut/descender gaps around the image */
width: 100vw;
max-width: none;
margin-left: 50%;
margin-right: 0;
/* kill the UA figure margin, it overflowed the body */
transform: translateX(-50%);
}
/* A paragraph wrapping only a wide image must not add its line height. */
p:has(> img.wide:only-child) {
line-height: 0;
}
body.editing figure:has(.wide),
body.editing img.wide {
width: calc(100vw - var(--editor-w) - 1rem);
}
/* Scroll reveal (pagerite.js adds .reveal/.in; JS off = fully visible) */
.reveal {
opacity: 0;
translate: 0 14px;
transition:
opacity 0.6s ease,
translate 0.6s ease;
}
.reveal.in {
opacity: 1;
translate: 0 0;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
.reveal {
opacity: 1;
translate: none;
transition: none;
}
}
.task-list-item {
list-style: none;
}
.footnote {
font-size: 0.85rem;
color: var(--muted);
}
/* The footer element is kept (the editor host ends above it) but currently
empty and zero-height. */
footer {
padding: 0;
}
/* Rotating-cube page transition, adapted from termotohtori.fi.
FRAGILE: do not tweak; the view-transition pseudo-tree is picky. */
::view-transition {
perspective: 1000px;
background: #000;
inset: 0;
}
::view-transition-group(root),
::view-transition-image-pair(root) {
transform-style: preserve-3d;
isolation: auto;
}
::view-transition-old(root),
::view-transition-new(root) {
mix-blend-mode: normal;
backface-visibility: hidden;
animation: none;
}
@keyframes group-rotate {
to {
transform: rotateY(-90deg);
}
}
@keyframes fade-out-a-bit {
to {
opacity: 0.5;
}
}
@keyframes fade-in-a-bit {
from {
opacity: 0.5;
}
}
::view-transition-group(root) {
transform-origin: 50% 50% -50vw;
animation: 300ms ease-in-out forwards group-rotate;
}
::view-transition-old(root) {
animation: 300ms ease-in-out forwards fade-out-a-bit;
}
::view-transition-new(root) {
transform-origin: 0 0;
transform: rotateY(90deg);
inset: 0 auto 0 100%;
animation: 300ms ease-in-out forwards fade-in-a-bit;
}
/* Reverse direction for browser back navigation (same geometry, mirrored). */
@keyframes group-rotate-back {
to {
transform: rotateY(90deg);
}
}
html.nav-back::view-transition-group(root) {
animation-name: group-rotate-back;
}
html.nav-back::view-transition-new(root) {
transform-origin: 100% 0;
transform: rotateY(-90deg);
inset: 0 100% 0 auto;
}
/* Same-section navigation: a plain crossfade instead of the cube. These
rules only override animation/geometry, leaving the FRAGILE block's
perspective and layering untouched. The old snapshot stays fully opaque
underneath while the new one fades in on top — never a dip to black. */
@keyframes nav-fade-in {
from {
opacity: 0;
}
}
html.nav-fade::view-transition-group(root) {
animation: none;
}
html.nav-fade::view-transition-old(root) {
animation: none;
}
html.nav-fade::view-transition-new(root) {
transform: none;
inset: 0;
animation: 200ms ease-in-out nav-fade-in;
}