Rework app-shell layout

This commit is contained in:
2026-07-01 09:59:32 +02:00
parent 5d069683c4
commit 1055c110f4
9 changed files with 318 additions and 170 deletions
+142 -118
View File
@@ -49,24 +49,33 @@
/* Width of the persistent left folder-tree rail (desktop). */
--tree-width: 15rem;
/* Comfortable max reading width for the center column's content + footer.
The shell itself is edge-to-edge; this keeps prose lines readable on
wide monitors (content stays centered within the center column). */
--reading-width: 60rem;
}
/* Anchor / TOC jumps land below the sticky header instead of behind it.
Harmless on mobile view mode (header not sticky there) — adds only a
small gap above the target. */
html { scroll-padding-top: var(--header-h); }
/* Anchor / TOC jumps are handled by the .center scroll container's own
scroll-padding-top (the header no longer overlaps content in the app-shell),
so no html-level scroll padding is needed. */
/* === Base === */
/* App-shell: the body is a full-viewport, non-scrolling grid of two rows —
the fixed-height header and a 1fr shell that owns all scrolling internally.
100dvh (not 100vh) so the mobile browser's dynamic toolbar doesn't clip the
bottom row; overflow:hidden pins the header and lets each shell column scroll
on its own. */
body {
background: var(--bg);
color: var(--text);
min-height: 100vh;
height: 100dvh;
margin: 0;
padding: 0;
overflow: auto;
overflow: hidden;
font: 1rem "Iosevka Etoile", monospace;
display: flex;
flex-direction: column;
display: grid;
grid-template-rows: auto 1fr;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
@@ -91,30 +100,47 @@ hr { border: none; border-top: var(--border-dashed); margin: var(--space-4) 0; }
.space-between { justify-content: space-between; }
.divider-dashed { border-bottom: var(--border-dashed); }
/* === Page layout ===
Note: sticky positioning on .sidebar depends on no ancestor having
overflow: auto/hidden. If you add scroll containment above this, sticky
will silently break. */
.page-wrap {
display: grid;
grid-template-columns: var(--tree-width) minmax(0, 1fr) 14rem;
gap: var(--space-5);
max-width: 1440px;
margin: 0 auto;
padding: 0 var(--space-4);
width: 100%;
flex: 1;
align-items: start;
/* === Page layout (app-shell) ===
The shell is a full-height flex row below the header: left rail | center |
right rail. Each column owns its own vertical scroll (overflow-y:auto +
min-height:0), so nothing scrolls the page as a whole. Edge-to-edge: no
centered max-width container. Either rail collapses out of the flex flow when
empty (:empty { display:none }), letting the center reclaim the width. */
.shell {
display: flex;
min-height: 0;
overflow: hidden;
}
/* Center column: main content plus the footer beneath it, scrolling together.
Content and footer are held to a comfortable reading width and centered
within the (edge-to-edge) column. */
.center {
flex: 1 1 auto;
min-width: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
align-items: center;
scroll-padding-top: var(--space-4);
}
main {
width: 100%;
max-width: var(--reading-width);
padding: var(--space-5) var(--space-4);
flex: 1 0 auto;
min-width: 0;
}
main { padding: var(--space-5) var(--space-4); width: 100%; flex: 1; min-width: 0; }
/* === Header / footer ===
Three-column grid (breadcrumbs left, search centre, actions right) so the
centre stays reserved even when search is hidden in editor mode. Mobile
(≤1100px) collapses to a two-row layout — see responsive block below. */
Header is the app-shell's fixed top row (grid row 1 of <body>): full width,
never scrolls. Three-column grid (breadcrumbs left, search centre, actions
right) so the centre stays reserved even when search is hidden in editor
mode; the far-right actions column hosts the [ACTIONS] dropdown. position +
z-index keep the header (and its open dropdown) painting above the shell.
Mobile (≤1100px) collapses to a compact three-column layout — see responsive
block below. */
header {
position: sticky;
top: 0;
position: relative;
z-index: 40;
background: var(--bg);
padding: var(--space-3) var(--space-4);
@@ -125,7 +151,12 @@ header {
align-items: center;
gap: var(--space-2);
}
/* Footer rides at the end of the center column's content (scrolls with it, not
pinned to the viewport). Held to the same reading width as main so its top
border delimits the content, not the whole shell. */
footer {
width: 100%;
max-width: var(--reading-width);
padding: var(--space-3) var(--space-4);
border-top: var(--border-dashed);
display: flex;
@@ -396,9 +427,11 @@ a.heading-anchor:hover { color: var(--primary-hover); }
/* === Edit form === */
.edit-form { display: flex; flex-direction: column; }
/* The sidebar is always empty while editing, so the editor uses the full
viewport: drop the reserved 14rem sidebar track and the centered max-width. */
body.edit-mode .page-wrap { grid-template-columns: minmax(0, 1fr); max-width: none; }
/* No rails are rendered while editing, so the editor uses the full center
column: drop the reading-width cap on main (and the footer) so the toolbar
and CodeMirror mount span the whole width. */
body.edit-mode main,
body.edit-mode footer { max-width: none; }
/* CodeMirror mount. The .cm-editor visual treatment (border, bg, font, padding)
lives in the CM theme (editor-build/entry.js), keyed off the same :root
variables; this only sizes the container. */
@@ -430,10 +463,13 @@ body.edit-mode .page-wrap { grid-template-columns: minmax(0, 1fr); max-width: no
.search-card a:hover { color: var(--link-hover); }
/* === Floating action button ===
Standalone FAB buttons (page TOC) are mobile-only. Wrapped FABs (search
actions dropdown) stay visible on desktop. */
Standalone FAB buttons (the tree rail, the right rail) are mobile-only and
stack bottom-right: the tree FAB anchors the bottom, the right-rail FAB sits
above it. Wrapped FABs (the search actions dropdown) stay visible on desktop
and, on mobile, sit in the upper slot so they never overlap the tree FAB. */
.fab { position: fixed; bottom: var(--space-4); right: var(--space-4); z-index: 50; }
button.fab { display: none; }
.fab-rail { bottom: calc(var(--space-4) + 3rem + var(--space-2)); }
/* === Companion status === */
.companion-status { margin-left: auto; }
@@ -522,39 +558,33 @@ button.fab { display: none; }
::-webkit-scrollbar-thumb { background: var(--primary); }
::-webkit-scrollbar-thumb:hover { background: var(--primary-hover); }
/* === Sidebar === */
/* === Sidebar (right rail) ===
Full-height column in the app-shell that scrolls its own overflow (TOC on
top, page widget(s) below). Collapses out of the flex row when it has no
content so the center column widens. A dashed separator to main mirrors the
left tree rail; widgets within drop their panel outlines (see
.panel-sidebar). */
.sidebar {
position: sticky;
/* Park below the sticky header. The space-2 buffer also absorbs the small
difference between --header-h and the real rendered header height, so the
sidebar is already at its pinned offset at scroll 0 — no pre-pin travel.
Do NOT add margin-top: a top margin sits above the pin point and makes
the sidebar visibly jump up by that margin when scrolling starts. */
top: calc(var(--header-h) + var(--space-2));
align-self: start;
max-height: calc(100vh - var(--header-h) - var(--space-4));
width: 14rem;
flex-shrink: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--space-4);
padding-top: 1rem;
/* Mirror the left tree rail: a dashed separator to main, no panel outlines
on the widgets within (see .panel-sidebar). */
padding-left: var(--space-2);
padding: var(--space-4) 0 var(--space-4) var(--space-2);
border-left: var(--border-dashed);
}
aside.sidebar:empty { display: none; }
/* Density modifier for panels in the sidebar (smaller font, tighter padding).
Drops the .panel outline so the rail reads as a clean column separated from
main by the .sidebar dashed border, matching the left tree rail. The mobile
drawer re-adds a full border + bg (see responsive block) so it stays legible
floating over content. */
main by the .sidebar dashed border, matching the left tree rail. Inside the
mobile Overlay the widgets render as plain full-width content. */
.panel-sidebar {
padding: var(--space-2) var(--space-3);
font-size: var(--font-sm);
border: none;
}
.actions { display: flex; flex-direction: column; gap: 0.15rem; }
/* === Table of contents (floating variant) ===
Default rendering is a fixed floating panel; inside .sidebar it becomes
@@ -620,6 +650,48 @@ aside.sidebar:empty { display: none; }
padding: 0.4rem 0.6rem;
}
/* === Overlay (full-viewport rail host, mobile) ===
A distinct component from .modal: it hosts a whole rail (the folder tree or
the right rail's TOC/widgets) at full screen with its own scroll and a
top-right close control. overlay.js MOVES the rail node into .overlay-body,
so the rules below strip the rail's column chrome (fixed width, borders, the
mobile display:none) and let it render as plain full-width content. */
.overlay {
position: fixed;
inset: 0;
z-index: 200;
background: var(--bg);
display: flex;
flex-direction: column;
overflow: hidden;
}
.overlay-bar {
display: flex;
justify-content: flex-end;
padding: var(--space-2);
border-bottom: var(--border-dashed);
flex-shrink: 0;
}
.overlay-close { width: 2.5rem; height: 2.5rem; font-size: 1.5rem; }
.overlay-body {
flex: 1;
overflow-y: auto;
padding: var(--space-3) var(--space-4);
-webkit-overflow-scrolling: touch;
}
.overlay-body .tree-sidebar,
.overlay-body .sidebar {
display: block;
width: auto;
max-height: none;
border: none;
padding: 0;
overflow: visible;
}
/* Right rail keeps its column spacing (TOC above widgets) inside the overlay;
the base .sidebar already sets flex-direction + gap. */
.overlay-body .sidebar { display: flex; }
/* === Tree picker === */
/* Rows are click targets (navigate / toggle), not prose — suppress the
text-selection highlight that double/drag clicks would otherwise leave,
@@ -666,16 +738,14 @@ aside.sidebar:empty { display: none; }
/* === Tree sidebar (persistent left navigation rail) ===
Reuses the .tree-row / .tree-children / .tree-name / .tree-chevron modules.
Desktop: a sticky, scrollable rail parked below the header (mirrors .sidebar
offsets). Mobile: an overlay drawer toggled by .fab-tree (see responsive). */
Desktop: a full-height column in the app-shell that scrolls its own
overflow. Mobile: not laid out inline — its content is surfaced through the
Overlay via .fab-tree (see responsive). */
.tree-sidebar {
position: sticky;
top: calc(var(--header-h) + var(--space-2));
align-self: start;
max-height: calc(100vh - var(--header-h) - var(--space-4));
width: var(--tree-width);
flex-shrink: 0;
overflow-y: auto;
padding-top: 1rem;
padding-right: var(--space-2);
padding: var(--space-4) var(--space-2) var(--space-4) var(--space-4);
border-right: var(--border-dashed);
font-size: var(--font-sm);
/* Rows are click targets (navigate / toggle), not prose — suppress the
@@ -764,64 +834,20 @@ aside.tree-sidebar:empty { display: none; }
/* === Responsive === */
@media (max-width: 1100px) {
.page-wrap { grid-template-columns: 1fr; }
/* Single-row mobile header: the compact logo sits left so search can take
the middle flex column, with actions on the right. */
/* Rails are not laid out inline on mobile — their content is surfaced
through the Overlay via the stacked FABs. The <aside> elements stay in
the DOM (their JS renders into them and the Overlay moves them in/out);
we only pull them out of the shell flow here. */
.tree-sidebar, .sidebar { display: none; }
/* Compact three-column header: logo left, search stretches the middle,
actions right. */
header { grid-template-columns: auto 1fr auto; }
/* Mobile view mode: header scrolls away with the page (out of scope to
stick it). It re-sticks only in edit mode so SAVE/CANCEL stay reachable
on long documents. */
header { position: static; }
body.edit-mode header { position: sticky; top: 0; }
.search-form { width: 100%; max-width: none; justify-self: stretch; }
/* Sidebar on mobile is a floating overlay toggled by the FAB. The aside
itself is the scroll container; children render at natural height. The
full border + bg replace the per-panel outlines dropped on desktop so the
drawer stays legible floating over content. */
.sidebar {
position: fixed;
bottom: 5rem;
right: var(--space-4);
top: auto;
left: auto;
width: calc(100% - 2rem);
max-width: 20rem;
max-height: calc(100vh - 8rem);
overflow-y: auto;
display: none;
z-index: 60;
padding: var(--space-2);
border: var(--border);
background: var(--bg);
}
.sidebar.is-open { display: flex; }
/* Reveal the mobile FAB stack (tree at the bottom, right rail above it).
The search actions dropdown FAB moves to the upper slot too so it never
overlaps the tree FAB. */
button.fab { display: inline-flex; }
/* Tree rail becomes a left-anchored overlay drawer toggled by .fab-tree.
Off-grid (position: fixed) so it never steals horizontal space from
<main> on narrow viewports. */
.tree-sidebar {
position: fixed;
top: auto;
bottom: 5rem;
left: var(--space-4);
right: auto;
width: calc(100% - 2rem);
max-width: 20rem;
max-height: calc(100vh - 8rem);
padding: var(--space-2);
border: var(--border);
background: var(--bg);
display: none;
z-index: 60;
}
.tree-sidebar.is-open { display: block; }
/* Smaller FAB stacked just above the menu FAB (3rem tall at --space-4). */
.fab-tree {
bottom: calc(var(--space-4) + 3rem + var(--space-2));
width: 2.5rem;
height: 2.5rem;
font-size: 1.1rem;
}
.fab.dropdown { bottom: calc(var(--space-4) + 3rem + var(--space-2)); }
}
@media (max-width: 600px) {
@@ -829,10 +855,8 @@ aside.tree-sidebar:empty { display: none; }
main { padding: var(--space-4) var(--space-3); }
.app-name {display: none;}
.editor-cm { min-height: 50vh; }
.sidebar { width: calc(100% - 1.5rem); }
/* Editing on mobile is full-bleed: drop the page/main inset so the toolbar
and editor use the entire viewport width. */
body.edit-mode .page-wrap { padding: 0; gap: 0; }
/* Editing on mobile is full-bleed: drop the main inset so the toolbar and
editor use the entire viewport width. */
body.edit-mode main { padding: 0; }
/* Fingers, not cursors: give every toolbar control a ~44px tap target. */
.editor-toolbar { gap: var(--space-2); padding: var(--space-2); }