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
-2
View File
@@ -51,8 +51,6 @@ Non-existent paths without a trailing slash redirect to the slash form (GET only
are not redirected because `path.Clean` strips the trailing slash from `PostURL` and the
content would be lost).
Do not add new endpoints without a concrete stated need.
## Code Structure
When adding a new special folder type, create a new `.go` file. Do not add type-specific logic to `main.go` or `render.go`.
+17 -14
View File
@@ -13,7 +13,8 @@
<script src="/_/search-suggest.js" defer></script>
<script src="/_/tree-picker.js"></script>
<script src="/_/companion.js" defer></script>
{{if not .EditMode}}<script src="/_/tree-sidebar.js" defer></script>{{end}}
{{if not .EditMode}}<script src="/_/overlay.js" defer></script>
<script src="/_/tree-sidebar.js" defer></script>{{end}}
{{block "headScripts" .}}{{end}}
</head>
<body>
@@ -28,21 +29,23 @@
{{end}}
<div class="header-actions row">{{block "headerActions" .}}{{end}}</div>
</header>
<div class="page-wrap">
<div class="shell">
{{if not .EditMode}}<aside class="tree-sidebar"></aside>{{end}}
<main>
{{block "content" .}}{{end}}
</main>
<aside class="sidebar">{{block "sidebar" .}}{{end}}</aside>
<div class="center">
<main>
{{block "content" .}}{{end}}
</main>
<footer>
<span class="muted">Request: {{.RenderMS}} ms</span>
{{block "footerExtras" .}}{{end}}
<span class="dropdown companion-status" data-companion-status hidden>
<button type="button" class="btn btn-small companion-icon" data-action="companion-toggle" title="Companion status" aria-label="Companion status"></button>
<div class="dropdown-menu align-right open-up companion-flyout"></div>
</span>
</footer>
</div>
{{if not .EditMode}}<aside class="sidebar">{{block "sidebar" .}}{{end}}</aside>{{end}}
</div>
<footer>
<span class="muted">Request: {{.RenderMS}} ms</span>
{{block "footerExtras" .}}{{end}}
<span class="dropdown companion-status" data-companion-status hidden>
<button type="button" class="btn btn-small companion-icon" data-action="companion-toggle" title="Companion status" aria-label="Companion status"></button>
<div class="dropdown-menu align-right open-up companion-flyout"></div>
</span>
</footer>
{{block "extras" .}}{{end}}
</body>
</html>
+107
View File
@@ -0,0 +1,107 @@
// Full-viewport Overlay component (mobile). Distinct from the .modal dialog:
// this hosts a whole rail (the folder tree or the right-rail widgets/TOC) at
// full screen with its own scroll and a top-right close control. It reuses the
// low-level focus/Escape/bfcache patterns from modal.js rather than that
// component's panel chrome.
//
// openOverlay(node, opts) MOVES `node` (an existing rail <aside>) into the
// overlay body and restores it to its original DOM position on close, so the
// rail keeps its state (expanded tree folders, rendered TOC). Only one overlay
// is open at a time.
(function () {
var overlay = null;
var bodyEl = null;
var closeBtn = null;
var hosted = null; // the moved node
var placeholder = null; // marks where to restore it
var prevFocus = null;
var onKeydown = null;
function build() {
overlay = document.createElement('div');
overlay.className = 'overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
var bar = document.createElement('div');
bar.className = 'overlay-bar';
closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'btn btn-fab overlay-close';
closeBtn.title = 'Close';
closeBtn.setAttribute('aria-label', 'Close');
closeBtn.textContent = '×';
closeBtn.addEventListener('click', close);
bar.appendChild(closeBtn);
bodyEl = document.createElement('div');
bodyEl.className = 'overlay-body';
// Any navigation or anchor activation inside a rail dismisses the
// overlay: tree/calendar links navigate away, TOC links scroll the
// page underneath — either way the user should see the result, and
// closing synchronously (before navigation) avoids a bfcache snapshot
// that would restore the overlay open on back-nav.
bodyEl.addEventListener('click', function (e) {
if (e.target.closest('a')) close();
});
overlay.appendChild(bar);
overlay.appendChild(bodyEl);
}
function open(node, opts) {
if (overlay && overlay.parentNode) close();
if (!overlay) build();
opts = opts || {};
prevFocus = document.activeElement;
hosted = node;
placeholder = document.createComment('overlay-slot');
node.parentNode.insertBefore(placeholder, node);
bodyEl.appendChild(node);
document.body.appendChild(overlay);
overlay.classList.add('is-open');
onKeydown = function (e) {
if (e.key === 'Escape') { e.preventDefault(); close(); }
};
document.addEventListener('keydown', onKeydown);
setTimeout(function () { closeBtn.focus(); }, 0);
if (typeof opts.onOpen === 'function') opts.onOpen();
return { close: close };
}
function close() {
if (!overlay || !overlay.parentNode) return;
if (onKeydown) {
document.removeEventListener('keydown', onKeydown);
onKeydown = null;
}
// Restore the hosted rail to its original spot in the shell.
if (hosted && placeholder && placeholder.parentNode) {
placeholder.parentNode.insertBefore(hosted, placeholder);
placeholder.parentNode.removeChild(placeholder);
}
hosted = null;
placeholder = null;
overlay.classList.remove('is-open');
overlay.parentNode.removeChild(overlay);
var toRestore = prevFocus;
prevFocus = null;
if (toRestore && toRestore.focus) {
try { toRestore.focus(); } catch (e) {}
}
}
// Belt-and-suspenders bfcache guard: force the overlay closed (restoring
// the rail) if the page is being hidden while it is still open.
window.addEventListener('pagehide', close);
window.openOverlay = open;
window.closeOverlay = close;
})();
+16
View File
@@ -0,0 +1,16 @@
// Wires the header-right [ACTIONS] dropdown. The buttons inside keep calling
// the existing page/actions.js handlers (newPage / movePage / deletePage) and
// the ?edit link, so the N/E/M keyboard shortcuts continue to work whether or
// not the menu is open. Toggling reuses the generic wireDropdown mechanism
// (global-shortcuts.js), which also handles outside-click / Escape close.
(function () {
var trigger = document.querySelector('[data-action="page-actions"]');
if (!trigger || typeof wireDropdown !== 'function') return;
wireDropdown(trigger);
var menu = trigger.parentElement.querySelector('.dropdown-menu');
if (!menu) return;
// Keep aria-expanded in sync with the menu's open state.
trigger.addEventListener('click', function () {
trigger.setAttribute('aria-expanded', menu.classList.contains('is-open') ? 'true' : 'false');
});
})();
+19 -12
View File
@@ -1,5 +1,23 @@
{{define "headScripts"}}<script src="/_/page/actions.js"></script>{{end}}
{{define "headerActions"}}{{if .CanEdit}}
<span class="dropdown">
<button type="button" class="btn" data-action="page-actions" aria-haspopup="true" aria-expanded="false" title="Page actions">ACTIONS</button>
<div class="dropdown-menu align-right">
<button class="btn btn-block" onclick="newPage()" title="New page (N)">NEW PAGE</button>
<a class="btn btn-block" href="?edit" title="Edit page (E)">EDIT PAGE</a>
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager">REVEAL ON CLIENT</button>
{{if not .IsRoot}}
<button class="btn btn-block" onclick="movePage()" title="Move page (M)">MOVE PAGE</button>
{{end}}
{{if not .IsRoot}}
<button class="btn btn-block danger" onclick="deletePage()" title="Delete page">DELETE PAGE</button>
{{end}}
</div>
</span>
<script src="/_/page/header-actions.js"></script>
{{end}}{{end}}
{{define "content"}}
{{if .Content}}
<div class="content">{{.Content}}</div>
@@ -49,15 +67,4 @@
<script src="/_/page/sidebar-fab.js"></script>
{{end}}
{{define "sidebar"}}{{if .CanEdit}}<nav class="actions panel panel-sidebar">
<div class="panel-header">ACTIONS</div>
<button class="btn btn-block" onclick="newPage()" title="New page (N)">NEW PAGE</button>
<a class="btn btn-block" href="?edit" title="Edit page (E)">EDIT PAGE</a>
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager">REVEAL ON CLIENT</button>
{{if not .IsRoot}}
<button class="btn btn-block" onclick="movePage()" title="Move page (M)">MOVE PAGE</button>
{{end}}
{{if not .IsRoot}}
<button class="btn btn-block danger" onclick="deletePage()" title="Delete page">DELETE PAGE</button>
{{end}}
</nav>{{end}}{{if .SidebarWidget}}{{.SidebarWidget}}{{end}}{{end}}
{{define "sidebar"}}{{if .SidebarWidget}}{{.SidebarWidget}}{{end}}{{end}}
+9 -12
View File
@@ -1,23 +1,20 @@
// Mobile: the right-rail FAB is stacked ABOVE the tree FAB and opens the right
// rail (TOC + page widgets) in the full-viewport Overlay (overlay.js). It only
// appears when the rail actually has content — runs after toc.js has had a
// chance to populate the rail (script order in page/main.html), so an
// otherwise-empty rail shows no FAB. Hidden on desktop by .fab CSS.
document.addEventListener("DOMContentLoaded", function () {
var aside = document.querySelector("aside.sidebar");
if (!aside || !aside.children.length) return;
var fab = document.createElement("button");
fab.type = "button";
fab.className = "btn btn-fab fab";
fab.title = "Menu";
fab.setAttribute("aria-label", "Menu");
fab.setAttribute("aria-expanded", "false");
fab.className = "btn btn-fab fab fab-rail";
fab.title = "Contents";
fab.setAttribute("aria-label", "Contents");
fab.textContent = "≡";
fab.addEventListener("click", function () {
var open = aside.classList.toggle("is-open");
fab.setAttribute("aria-expanded", open ? "true" : "false");
});
aside.addEventListener("click", function (e) {
if (e.target.tagName === "A") {
aside.classList.remove("is-open");
fab.setAttribute("aria-expanded", "false");
}
if (typeof openOverlay === "function") openOverlay(aside);
});
document.body.appendChild(fab);
});
+2 -1
View File
@@ -28,6 +28,7 @@ document.addEventListener("DOMContentLoaded", function () {
});
nav.appendChild(list);
// Stack the TOC on top of any server-rendered widget(s) already in the rail.
var rail = document.querySelector("aside.sidebar");
rail.appendChild(nav);
rail.insertBefore(nav, rail.firstChild);
});
+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); }
+6 -11
View File
@@ -187,25 +187,20 @@
});
}
// Mobile: a smaller FAB stacked above the menu FAB toggles the rail as an
// overlay drawer; selecting any entry closes it (mirrors sidebar-fab.js).
// Mobile: the tree FAB sits at the bottom of the stacked FAB group and
// opens the rail's content in the full-viewport Overlay (overlay.js). The
// rail container always exists when not editing, so — per the layout spec —
// the FAB always renders; the overlay auto-closes when a folder/file link
// inside it navigates. Hidden on desktop by .fab CSS.
function setupFab() {
var fab = document.createElement('button');
fab.type = 'button';
fab.className = 'btn btn-fab fab fab-tree';
fab.title = 'Folder tree';
fab.setAttribute('aria-label', 'Folder tree');
fab.setAttribute('aria-expanded', 'false');
fab.innerHTML = '<svg viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter"><path d="M1 6h14v8H1zm0 0V4h5l1 2"/></svg>';
fab.addEventListener('click', function () {
var open = container.classList.toggle('is-open');
fab.setAttribute('aria-expanded', open ? 'true' : 'false');
});
container.addEventListener('click', function (e) {
if (e.target.closest('a')) {
container.classList.remove('is-open');
fab.setAttribute('aria-expanded', 'false');
}
if (typeof openOverlay === 'function') openOverlay(container);
});
document.body.appendChild(fab);
}