Rework app-shell layout
This commit is contained in:
@@ -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
|
are not redirected because `path.Clean` strips the trailing slash from `PostURL` and the
|
||||||
content would be lost).
|
content would be lost).
|
||||||
|
|
||||||
Do not add new endpoints without a concrete stated need.
|
|
||||||
|
|
||||||
## Code Structure
|
## 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`.
|
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
@@ -13,7 +13,8 @@
|
|||||||
<script src="/_/search-suggest.js" defer></script>
|
<script src="/_/search-suggest.js" defer></script>
|
||||||
<script src="/_/tree-picker.js"></script>
|
<script src="/_/tree-picker.js"></script>
|
||||||
<script src="/_/companion.js" defer></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}}
|
{{block "headScripts" .}}{{end}}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -28,21 +29,23 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
<div class="header-actions row">{{block "headerActions" .}}{{end}}</div>
|
<div class="header-actions row">{{block "headerActions" .}}{{end}}</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="page-wrap">
|
<div class="shell">
|
||||||
{{if not .EditMode}}<aside class="tree-sidebar"></aside>{{end}}
|
{{if not .EditMode}}<aside class="tree-sidebar"></aside>{{end}}
|
||||||
<main>
|
<div class="center">
|
||||||
{{block "content" .}}{{end}}
|
<main>
|
||||||
</main>
|
{{block "content" .}}{{end}}
|
||||||
<aside class="sidebar">{{block "sidebar" .}}{{end}}</aside>
|
</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>
|
</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}}
|
{{block "extras" .}}{{end}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
})();
|
||||||
@@ -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
@@ -1,5 +1,23 @@
|
|||||||
{{define "headScripts"}}<script src="/_/page/actions.js"></script>{{end}}
|
{{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"}}
|
{{define "content"}}
|
||||||
{{if .Content}}
|
{{if .Content}}
|
||||||
<div class="content">{{.Content}}</div>
|
<div class="content">{{.Content}}</div>
|
||||||
@@ -49,15 +67,4 @@
|
|||||||
<script src="/_/page/sidebar-fab.js"></script>
|
<script src="/_/page/sidebar-fab.js"></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "sidebar"}}{{if .CanEdit}}<nav class="actions panel panel-sidebar">
|
{{define "sidebar"}}{{if .SidebarWidget}}{{.SidebarWidget}}{{end}}{{end}}
|
||||||
<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}}
|
|
||||||
|
|||||||
@@ -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 () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
var aside = document.querySelector("aside.sidebar");
|
var aside = document.querySelector("aside.sidebar");
|
||||||
if (!aside || !aside.children.length) return;
|
if (!aside || !aside.children.length) return;
|
||||||
|
|
||||||
var fab = document.createElement("button");
|
var fab = document.createElement("button");
|
||||||
fab.type = "button";
|
fab.type = "button";
|
||||||
fab.className = "btn btn-fab fab";
|
fab.className = "btn btn-fab fab fab-rail";
|
||||||
fab.title = "Menu";
|
fab.title = "Contents";
|
||||||
fab.setAttribute("aria-label", "Menu");
|
fab.setAttribute("aria-label", "Contents");
|
||||||
fab.setAttribute("aria-expanded", "false");
|
|
||||||
fab.textContent = "≡";
|
fab.textContent = "≡";
|
||||||
fab.addEventListener("click", function () {
|
fab.addEventListener("click", function () {
|
||||||
var open = aside.classList.toggle("is-open");
|
if (typeof openOverlay === "function") openOverlay(aside);
|
||||||
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");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
document.body.appendChild(fab);
|
document.body.appendChild(fab);
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-1
@@ -28,6 +28,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
});
|
});
|
||||||
nav.appendChild(list);
|
nav.appendChild(list);
|
||||||
|
|
||||||
|
// Stack the TOC on top of any server-rendered widget(s) already in the rail.
|
||||||
var rail = document.querySelector("aside.sidebar");
|
var rail = document.querySelector("aside.sidebar");
|
||||||
rail.appendChild(nav);
|
rail.insertBefore(nav, rail.firstChild);
|
||||||
});
|
});
|
||||||
|
|||||||
+142
-118
@@ -49,24 +49,33 @@
|
|||||||
|
|
||||||
/* Width of the persistent left folder-tree rail (desktop). */
|
/* Width of the persistent left folder-tree rail (desktop). */
|
||||||
--tree-width: 15rem;
|
--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.
|
/* Anchor / TOC jumps are handled by the .center scroll container's own
|
||||||
Harmless on mobile view mode (header not sticky there) — adds only a
|
scroll-padding-top (the header no longer overlaps content in the app-shell),
|
||||||
small gap above the target. */
|
so no html-level scroll padding is needed. */
|
||||||
html { scroll-padding-top: var(--header-h); }
|
|
||||||
|
|
||||||
/* === Base === */
|
/* === 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 {
|
body {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
min-height: 100vh;
|
height: 100dvh;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: auto;
|
overflow: hidden;
|
||||||
font: 1rem "Iosevka Etoile", monospace;
|
font: 1rem "Iosevka Etoile", monospace;
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-rows: auto 1fr;
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { 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; }
|
.space-between { justify-content: space-between; }
|
||||||
.divider-dashed { border-bottom: var(--border-dashed); }
|
.divider-dashed { border-bottom: var(--border-dashed); }
|
||||||
|
|
||||||
/* === Page layout ===
|
/* === Page layout (app-shell) ===
|
||||||
Note: sticky positioning on .sidebar depends on no ancestor having
|
The shell is a full-height flex row below the header: left rail | center |
|
||||||
overflow: auto/hidden. If you add scroll containment above this, sticky
|
right rail. Each column owns its own vertical scroll (overflow-y:auto +
|
||||||
will silently break. */
|
min-height:0), so nothing scrolls the page as a whole. Edge-to-edge: no
|
||||||
.page-wrap {
|
centered max-width container. Either rail collapses out of the flex flow when
|
||||||
display: grid;
|
empty (:empty { display:none }), letting the center reclaim the width. */
|
||||||
grid-template-columns: var(--tree-width) minmax(0, 1fr) 14rem;
|
.shell {
|
||||||
gap: var(--space-5);
|
display: flex;
|
||||||
max-width: 1440px;
|
min-height: 0;
|
||||||
margin: 0 auto;
|
overflow: hidden;
|
||||||
padding: 0 var(--space-4);
|
}
|
||||||
width: 100%;
|
/* Center column: main content plus the footer beneath it, scrolling together.
|
||||||
flex: 1;
|
Content and footer are held to a comfortable reading width and centered
|
||||||
align-items: start;
|
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 ===
|
/* === Header / footer ===
|
||||||
Three-column grid (breadcrumbs left, search centre, actions right) so the
|
Header is the app-shell's fixed top row (grid row 1 of <body>): full width,
|
||||||
centre stays reserved even when search is hidden in editor mode. Mobile
|
never scrolls. Three-column grid (breadcrumbs left, search centre, actions
|
||||||
(≤1100px) collapses to a two-row layout — see responsive block below. */
|
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 {
|
header {
|
||||||
position: sticky;
|
position: relative;
|
||||||
top: 0;
|
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
@@ -125,7 +151,12 @@ header {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
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 {
|
footer {
|
||||||
|
width: 100%;
|
||||||
|
max-width: var(--reading-width);
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
border-top: var(--border-dashed);
|
border-top: var(--border-dashed);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -396,9 +427,11 @@ a.heading-anchor:hover { color: var(--primary-hover); }
|
|||||||
|
|
||||||
/* === Edit form === */
|
/* === Edit form === */
|
||||||
.edit-form { display: flex; flex-direction: column; }
|
.edit-form { display: flex; flex-direction: column; }
|
||||||
/* The sidebar is always empty while editing, so the editor uses the full
|
/* No rails are rendered while editing, so the editor uses the full center
|
||||||
viewport: drop the reserved 14rem sidebar track and the centered max-width. */
|
column: drop the reading-width cap on main (and the footer) so the toolbar
|
||||||
body.edit-mode .page-wrap { grid-template-columns: minmax(0, 1fr); max-width: none; }
|
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)
|
/* 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
|
lives in the CM theme (editor-build/entry.js), keyed off the same :root
|
||||||
variables; this only sizes the container. */
|
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); }
|
.search-card a:hover { color: var(--link-hover); }
|
||||||
|
|
||||||
/* === Floating action button ===
|
/* === Floating action button ===
|
||||||
Standalone FAB buttons (page TOC) are mobile-only. Wrapped FABs (search
|
Standalone FAB buttons (the tree rail, the right rail) are mobile-only and
|
||||||
actions dropdown) stay visible on desktop. */
|
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; }
|
.fab { position: fixed; bottom: var(--space-4); right: var(--space-4); z-index: 50; }
|
||||||
button.fab { display: none; }
|
button.fab { display: none; }
|
||||||
|
.fab-rail { bottom: calc(var(--space-4) + 3rem + var(--space-2)); }
|
||||||
|
|
||||||
/* === Companion status === */
|
/* === Companion status === */
|
||||||
.companion-status { margin-left: auto; }
|
.companion-status { margin-left: auto; }
|
||||||
@@ -522,39 +558,33 @@ button.fab { display: none; }
|
|||||||
::-webkit-scrollbar-thumb { background: var(--primary); }
|
::-webkit-scrollbar-thumb { background: var(--primary); }
|
||||||
::-webkit-scrollbar-thumb:hover { background: var(--primary-hover); }
|
::-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 {
|
.sidebar {
|
||||||
position: sticky;
|
width: 14rem;
|
||||||
/* Park below the sticky header. The space-2 buffer also absorbs the small
|
flex-shrink: 0;
|
||||||
difference between --header-h and the real rendered header height, so the
|
overflow-y: auto;
|
||||||
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));
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
padding-top: 1rem;
|
padding: var(--space-4) 0 var(--space-4) var(--space-2);
|
||||||
/* 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);
|
|
||||||
border-left: var(--border-dashed);
|
border-left: var(--border-dashed);
|
||||||
}
|
}
|
||||||
aside.sidebar:empty { display: none; }
|
aside.sidebar:empty { display: none; }
|
||||||
|
|
||||||
/* Density modifier for panels in the sidebar (smaller font, tighter padding).
|
/* 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
|
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
|
main by the .sidebar dashed border, matching the left tree rail. Inside the
|
||||||
drawer re-adds a full border + bg (see responsive block) so it stays legible
|
mobile Overlay the widgets render as plain full-width content. */
|
||||||
floating over content. */
|
|
||||||
.panel-sidebar {
|
.panel-sidebar {
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
font-size: var(--font-sm);
|
font-size: var(--font-sm);
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
.actions { display: flex; flex-direction: column; gap: 0.15rem; }
|
|
||||||
|
|
||||||
/* === Table of contents (floating variant) ===
|
/* === Table of contents (floating variant) ===
|
||||||
Default rendering is a fixed floating panel; inside .sidebar it becomes
|
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;
|
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 === */
|
/* === Tree picker === */
|
||||||
/* Rows are click targets (navigate / toggle), not prose — suppress the
|
/* Rows are click targets (navigate / toggle), not prose — suppress the
|
||||||
text-selection highlight that double/drag clicks would otherwise leave,
|
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) ===
|
/* === Tree sidebar (persistent left navigation rail) ===
|
||||||
Reuses the .tree-row / .tree-children / .tree-name / .tree-chevron modules.
|
Reuses the .tree-row / .tree-children / .tree-name / .tree-chevron modules.
|
||||||
Desktop: a sticky, scrollable rail parked below the header (mirrors .sidebar
|
Desktop: a full-height column in the app-shell that scrolls its own
|
||||||
offsets). Mobile: an overlay drawer toggled by .fab-tree (see responsive). */
|
overflow. Mobile: not laid out inline — its content is surfaced through the
|
||||||
|
Overlay via .fab-tree (see responsive). */
|
||||||
.tree-sidebar {
|
.tree-sidebar {
|
||||||
position: sticky;
|
width: var(--tree-width);
|
||||||
top: calc(var(--header-h) + var(--space-2));
|
flex-shrink: 0;
|
||||||
align-self: start;
|
|
||||||
max-height: calc(100vh - var(--header-h) - var(--space-4));
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding-top: 1rem;
|
padding: var(--space-4) var(--space-2) var(--space-4) var(--space-4);
|
||||||
padding-right: var(--space-2);
|
|
||||||
border-right: var(--border-dashed);
|
border-right: var(--border-dashed);
|
||||||
font-size: var(--font-sm);
|
font-size: var(--font-sm);
|
||||||
/* Rows are click targets (navigate / toggle), not prose — suppress the
|
/* Rows are click targets (navigate / toggle), not prose — suppress the
|
||||||
@@ -764,64 +834,20 @@ aside.tree-sidebar:empty { display: none; }
|
|||||||
|
|
||||||
/* === Responsive === */
|
/* === Responsive === */
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.page-wrap { grid-template-columns: 1fr; }
|
/* Rails are not laid out inline on mobile — their content is surfaced
|
||||||
/* Single-row mobile header: the compact logo sits left so search can take
|
through the Overlay via the stacked FABs. The <aside> elements stay in
|
||||||
the middle flex column, with actions on the right. */
|
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; }
|
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; }
|
.search-form { width: 100%; max-width: none; justify-self: stretch; }
|
||||||
/* Sidebar on mobile is a floating overlay toggled by the FAB. The aside
|
/* Reveal the mobile FAB stack (tree at the bottom, right rail above it).
|
||||||
itself is the scroll container; children render at natural height. The
|
The search actions dropdown FAB moves to the upper slot too so it never
|
||||||
full border + bg replace the per-panel outlines dropped on desktop so the
|
overlaps the tree FAB. */
|
||||||
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; }
|
|
||||||
button.fab { display: inline-flex; }
|
button.fab { display: inline-flex; }
|
||||||
/* Tree rail becomes a left-anchored overlay drawer toggled by .fab-tree.
|
.fab.dropdown { bottom: calc(var(--space-4) + 3rem + var(--space-2)); }
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
@@ -829,10 +855,8 @@ aside.tree-sidebar:empty { display: none; }
|
|||||||
main { padding: var(--space-4) var(--space-3); }
|
main { padding: var(--space-4) var(--space-3); }
|
||||||
.app-name {display: none;}
|
.app-name {display: none;}
|
||||||
.editor-cm { min-height: 50vh; }
|
.editor-cm { min-height: 50vh; }
|
||||||
.sidebar { width: calc(100% - 1.5rem); }
|
/* Editing on mobile is full-bleed: drop the main inset so the toolbar and
|
||||||
/* Editing on mobile is full-bleed: drop the page/main inset so the toolbar
|
editor use the entire viewport width. */
|
||||||
and editor use the entire viewport width. */
|
|
||||||
body.edit-mode .page-wrap { padding: 0; gap: 0; }
|
|
||||||
body.edit-mode main { padding: 0; }
|
body.edit-mode main { padding: 0; }
|
||||||
/* Fingers, not cursors: give every toolbar control a ~44px tap target. */
|
/* Fingers, not cursors: give every toolbar control a ~44px tap target. */
|
||||||
.editor-toolbar { gap: var(--space-2); padding: var(--space-2); }
|
.editor-toolbar { gap: var(--space-2); padding: var(--space-2); }
|
||||||
|
|||||||
+6
-11
@@ -187,25 +187,20 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mobile: a smaller FAB stacked above the menu FAB toggles the rail as an
|
// Mobile: the tree FAB sits at the bottom of the stacked FAB group and
|
||||||
// overlay drawer; selecting any entry closes it (mirrors sidebar-fab.js).
|
// 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() {
|
function setupFab() {
|
||||||
var fab = document.createElement('button');
|
var fab = document.createElement('button');
|
||||||
fab.type = 'button';
|
fab.type = 'button';
|
||||||
fab.className = 'btn btn-fab fab fab-tree';
|
fab.className = 'btn btn-fab fab fab-tree';
|
||||||
fab.title = 'Folder tree';
|
fab.title = 'Folder tree';
|
||||||
fab.setAttribute('aria-label', '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.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 () {
|
fab.addEventListener('click', function () {
|
||||||
var open = container.classList.toggle('is-open');
|
if (typeof openOverlay === 'function') openOverlay(container);
|
||||||
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');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
document.body.appendChild(fab);
|
document.body.appendChild(fab);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user