@dmthepm/commune 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -405
- package/package.json +3 -7
- package/src/components/Backlinks.astro +5 -1
- package/src/components/BacklinksScript.astro +11 -1
- package/src/components/Header.astro +76 -1
- package/src/components/HeaderStarScript.astro +41 -3
- package/src/components/SearchModal.astro +165 -32
- package/src/components/SlidingPanes.astro +782 -0
- package/src/styles/design-system.css +88 -6
- package/src/styles/notes.css +36 -0
- package/src/components/PlausibleScript.astro +0 -13
- package/src/components/panes.ts +0 -61
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* The sliding-pane reading surface.
|
|
4
|
+
*
|
|
5
|
+
* Two pages render notes into panes: the site root (`src/pages/index.astro`)
|
|
6
|
+
* and every note (`src/pages/notes/[...slug].astro`). They carried separate
|
|
7
|
+
* copies of this script and this stylesheet, and the copies drifted in both
|
|
8
|
+
* directions: the note page grew pane widths that respond at 1600px and
|
|
9
|
+
* 1280px, a fallback layout for when the script does not run, and a container
|
|
10
|
+
* that positions against `var(--header-height)` rather than a literal `64px`;
|
|
11
|
+
* the home page grew the pane's own scrollbar and lost the rest. This is the
|
|
12
|
+
* one implementation both of them use, and where they disagreed the note
|
|
13
|
+
* page's version is the one kept.
|
|
14
|
+
*
|
|
15
|
+
* Expects the page to render `<main id="pane-container">` containing a first
|
|
16
|
+
* `.pane > .body-wrapper > .body > .pane-content`. Page-specific typography —
|
|
17
|
+
* `.prose`, the note metadata, the home page's cards — stays on the page.
|
|
18
|
+
*
|
|
19
|
+
* The per-pane initialisers it calls (`window.initializeBacklinks`,
|
|
20
|
+
* `initializeStarredLinks`, `initializeHeaderStar`, `updateRelativeDates`,
|
|
21
|
+
* and the home page's `updateRecentUpdateDates`) are all optional: each is
|
|
22
|
+
* checked for before it is called, so a page that does not render backlinks or
|
|
23
|
+
* dates simply does not define one.
|
|
24
|
+
*/
|
|
25
|
+
---
|
|
26
|
+
<script is:inline>
|
|
27
|
+
// === CLEAN WIKI ARCHITECTURE ===
|
|
28
|
+
// Single initialization, event delegation, no duplicates
|
|
29
|
+
|
|
30
|
+
if (window.wikiInitialized) {
|
|
31
|
+
console.log('Wiki already initialized, skipping...');
|
|
32
|
+
} else {
|
|
33
|
+
console.log('Initializing wiki with clean architecture...');
|
|
34
|
+
|
|
35
|
+
// --- Global State ---
|
|
36
|
+
const previewCache = new Map();
|
|
37
|
+
let isInitialized = false;
|
|
38
|
+
|
|
39
|
+
// --- Utility Functions ---
|
|
40
|
+
const normalizeUrl = (url) => new URL(url, window.location.origin).pathname;
|
|
41
|
+
// scroll-behavior in CSS does not reach scrollIntoView/scrollTo options.
|
|
42
|
+
const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
43
|
+
|
|
44
|
+
// Note titles are the vault author's prose and go into markup below; a stray
|
|
45
|
+
// <, & or " in one should read as that character rather than break the card.
|
|
46
|
+
const escapeHtml = (value) => String(value ?? '')
|
|
47
|
+
.replace(/&/g, '&')
|
|
48
|
+
.replace(/</g, '<')
|
|
49
|
+
.replace(/>/g, '>')
|
|
50
|
+
.replace(/"/g, '"')
|
|
51
|
+
.replace(/'/g, ''');
|
|
52
|
+
|
|
53
|
+
const showPreview = (el, html, x, y) => {
|
|
54
|
+
try {
|
|
55
|
+
let card = document.getElementById('note-hover');
|
|
56
|
+
if(!card){
|
|
57
|
+
card = document.createElement('div');
|
|
58
|
+
card.id = 'note-hover';
|
|
59
|
+
card.style.position = 'fixed';
|
|
60
|
+
card.style.maxWidth = '380px';
|
|
61
|
+
card.style.background = 'var(--c-bg)';
|
|
62
|
+
card.style.boxShadow = 'var(--c-shadow-xl)';
|
|
63
|
+
card.style.border = '1px solid var(--c-border)';
|
|
64
|
+
card.style.borderRadius = 'var(--c-radius-lg)';
|
|
65
|
+
card.style.padding = '1rem 1.2rem';
|
|
66
|
+
card.style.zIndex = 'var(--z-hover)';
|
|
67
|
+
card.style.pointerEvents = 'none';
|
|
68
|
+
document.body.appendChild(card);
|
|
69
|
+
}
|
|
70
|
+
card.innerHTML = html;
|
|
71
|
+
|
|
72
|
+
// Viewport-aware positioning
|
|
73
|
+
const padding = 16;
|
|
74
|
+
const offsetFromCursor = 16;
|
|
75
|
+
|
|
76
|
+
// Measure card dimensions
|
|
77
|
+
card.style.opacity = '0';
|
|
78
|
+
card.hidden = false;
|
|
79
|
+
const rect = card.getBoundingClientRect();
|
|
80
|
+
const cardWidth = rect.width;
|
|
81
|
+
const cardHeight = rect.height;
|
|
82
|
+
card.style.opacity = '1';
|
|
83
|
+
|
|
84
|
+
// Calculate position with edge detection
|
|
85
|
+
let left = x + offsetFromCursor;
|
|
86
|
+
let top = y + offsetFromCursor;
|
|
87
|
+
|
|
88
|
+
// Check right edge
|
|
89
|
+
if (left + cardWidth + padding > window.innerWidth) {
|
|
90
|
+
left = x - cardWidth - offsetFromCursor;
|
|
91
|
+
if (left < padding) left = window.innerWidth - cardWidth - padding;
|
|
92
|
+
}
|
|
93
|
+
if (left < padding) left = padding;
|
|
94
|
+
|
|
95
|
+
// Check bottom edge
|
|
96
|
+
if (top + cardHeight + padding > window.innerHeight) {
|
|
97
|
+
top = y - cardHeight - offsetFromCursor;
|
|
98
|
+
if (top < padding) top = window.innerHeight - cardHeight - padding;
|
|
99
|
+
}
|
|
100
|
+
if (top < padding) top = padding;
|
|
101
|
+
|
|
102
|
+
card.style.left = left + 'px';
|
|
103
|
+
card.style.top = top + 'px';
|
|
104
|
+
card.hidden = false;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
console.error('Error showing hover card:', error);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const hidePreview = () => {
|
|
111
|
+
try {
|
|
112
|
+
const card = document.getElementById('note-hover');
|
|
113
|
+
if (card) card.hidden = true;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.error('Error hiding hover card:', error);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
// --- Preview Event Handler ---
|
|
121
|
+
const handlePreviewHover = async (e) => {
|
|
122
|
+
// Declared outside the try: the catch below needs it to place the failure
|
|
123
|
+
// card, and a `const` inside the block is not in scope there.
|
|
124
|
+
const a = e.target?.closest?.('a[href^="/notes/"]');
|
|
125
|
+
if(!a) return;
|
|
126
|
+
try {
|
|
127
|
+
const url = a.getAttribute('href');
|
|
128
|
+
if(previewCache.has(url)){
|
|
129
|
+
showPreview(a, previewCache.get(url), e.clientX, e.clientY);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Show immediate loading state
|
|
134
|
+
showPreview(a, '<div style="font-weight:600;color:var(--c-text-muted)">Loading preview...</div>', e.clientX, e.clientY);
|
|
135
|
+
|
|
136
|
+
// Reduced timeout for faster response
|
|
137
|
+
const controller = new AbortController();
|
|
138
|
+
const timeoutId = setTimeout(() => controller.abort(), 2000);
|
|
139
|
+
|
|
140
|
+
const r = await fetch(url, {
|
|
141
|
+
signal: controller.signal,
|
|
142
|
+
headers: { 'Accept': 'text/html' }
|
|
143
|
+
});
|
|
144
|
+
clearTimeout(timeoutId);
|
|
145
|
+
|
|
146
|
+
if (!r.ok) {
|
|
147
|
+
showPreview(a, '<div style="color:var(--c-text-muted)">Preview unavailable</div>', e.clientX, e.clientY);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const t = await r.text();
|
|
152
|
+
const doc = new DOMParser().parseFromString(t, 'text/html');
|
|
153
|
+
const content = doc.querySelector('.pane .pane-content') ?? doc.body;
|
|
154
|
+
const paras = Array.from(content.querySelectorAll('p')).slice(0,3).map(p => p.outerHTML).join('');
|
|
155
|
+
const title = escapeHtml(doc.title?.replace(/ \|.*/, '') || 'Untitled');
|
|
156
|
+
const heading = `<div style="font-size:1.1rem;font-weight:600;line-height:1.3">${title}</div>`;
|
|
157
|
+
// A note with nothing to quote is not a broken preview. Show the title
|
|
158
|
+
// on its own rather than a heading above an empty box.
|
|
159
|
+
const html = paras
|
|
160
|
+
? `<div style="font-size:1.1rem;font-weight:600;margin-bottom:.5rem;line-height:1.3">${title}</div><div style="font-size:0.85rem;line-height:1.5;color:var(--c-text-accent)">${paras}</div>`
|
|
161
|
+
: heading;
|
|
162
|
+
previewCache.set(url, html);
|
|
163
|
+
showPreview(a, html, e.clientX, e.clientY);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (error.name !== 'AbortError') {
|
|
166
|
+
showPreview(a, '<div style="color:var(--c-text-muted)">Preview failed</div>', e.clientX, e.clientY);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const handlePreviewLeave = (e) => {
|
|
172
|
+
try {
|
|
173
|
+
const a = e.target?.closest?.('a[href^="/notes/"]');
|
|
174
|
+
if(a) setTimeout(hidePreview, 100);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
console.error('Error in mouseleave handler:', error);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// --- Pane Management Functions ---
|
|
181
|
+
const container = document.getElementById('pane-container');
|
|
182
|
+
if (!container) {
|
|
183
|
+
console.error('Pane container not found!');
|
|
184
|
+
} else {
|
|
185
|
+
|
|
186
|
+
const updateContainerLayout = () => {
|
|
187
|
+
const paneCount = container.querySelectorAll('.pane').length;
|
|
188
|
+
container.classList.toggle('multi-pane', paneCount > 1);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const openPane = async (url) => {
|
|
192
|
+
const normalizedUrl = normalizeUrl(url);
|
|
193
|
+
// The address bar may only claim the note once the pane holding it
|
|
194
|
+
// actually exists. Anything that stops that — a dead wikilink, a truncated
|
|
195
|
+
// response, a timeout — hands the URL to the browser so the reader lands
|
|
196
|
+
// on the real 404 instead of on a page that lies about which note it is
|
|
197
|
+
// showing.
|
|
198
|
+
const recordVisit = () => history.pushState({ pane: normalizedUrl }, '', normalizedUrl);
|
|
199
|
+
const giveUp = () => { window.location.href = url; };
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
console.log(`Opening pane for: ${url}`);
|
|
203
|
+
|
|
204
|
+
// Check for existing pane to prevent duplicates
|
|
205
|
+
const existingPane = container.querySelector(`[data-url="${normalizedUrl}"]`);
|
|
206
|
+
if (existingPane) {
|
|
207
|
+
console.log('Pane already exists, focusing existing pane');
|
|
208
|
+
focusPane(existingPane);
|
|
209
|
+
recordVisit();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Fetch content
|
|
214
|
+
const controller = new AbortController();
|
|
215
|
+
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
|
216
|
+
|
|
217
|
+
const res = await fetch(url, {
|
|
218
|
+
headers: { 'Accept': 'text/html;charset=utf-8' },
|
|
219
|
+
signal: controller.signal
|
|
220
|
+
});
|
|
221
|
+
clearTimeout(timeoutId);
|
|
222
|
+
|
|
223
|
+
if (!res.ok) {
|
|
224
|
+
console.error(`Failed to fetch pane content: ${res.status}`);
|
|
225
|
+
giveUp();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const html = await res.text();
|
|
230
|
+
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
231
|
+
const fetchedContent = doc.querySelector('.pane .pane-content');
|
|
232
|
+
|
|
233
|
+
if (!fetchedContent) {
|
|
234
|
+
console.error('No .pane-content found in fetched page');
|
|
235
|
+
giveUp();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Create new pane
|
|
240
|
+
const pane = document.createElement('div');
|
|
241
|
+
pane.className = 'pane';
|
|
242
|
+
pane.dataset.url = normalizedUrl;
|
|
243
|
+
pane.setAttribute('tabindex', '-1');
|
|
244
|
+
|
|
245
|
+
pane.innerHTML = `
|
|
246
|
+
<button class="close" aria-label="Close">×</button>
|
|
247
|
+
<div class="body-wrapper">
|
|
248
|
+
<div class="body">
|
|
249
|
+
<div class="pane-content">${fetchedContent.innerHTML}</div>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
`;
|
|
253
|
+
|
|
254
|
+
// Attach close listener
|
|
255
|
+
pane.querySelector('.close').addEventListener('click', (e) => {
|
|
256
|
+
e.stopPropagation();
|
|
257
|
+
closePane(pane);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Append and focus
|
|
261
|
+
container.appendChild(pane);
|
|
262
|
+
void pane.offsetHeight;
|
|
263
|
+
updateContainerLayout();
|
|
264
|
+
|
|
265
|
+
// Record the visit here, before the per-pane initialisers run:
|
|
266
|
+
// initializeHeaderStar decides whether to star the pane's title by
|
|
267
|
+
// reading window.location.pathname, so the URL has to be the new note's
|
|
268
|
+
// by the time it looks.
|
|
269
|
+
recordVisit();
|
|
270
|
+
|
|
271
|
+
// Initialize backlinks, starred links, and header star for the new pane
|
|
272
|
+
if (typeof window.initializeBacklinks === 'function') {
|
|
273
|
+
window.initializeBacklinks(pane);
|
|
274
|
+
}
|
|
275
|
+
if (typeof window.initializeStarredLinks === 'function') {
|
|
276
|
+
window.initializeStarredLinks(pane);
|
|
277
|
+
}
|
|
278
|
+
if (typeof window.initializeHeaderStar === 'function') {
|
|
279
|
+
window.initializeHeaderStar(pane);
|
|
280
|
+
}
|
|
281
|
+
// Update relative dates for the new pane
|
|
282
|
+
if (typeof window.updateRelativeDates === 'function') {
|
|
283
|
+
window.updateRelativeDates();
|
|
284
|
+
}
|
|
285
|
+
// Home page only: its recent-updates card carries relative dates too.
|
|
286
|
+
// Undefined on note pages, so this is a no-op there.
|
|
287
|
+
if (typeof window.updateRecentUpdateDates === 'function') {
|
|
288
|
+
window.updateRecentUpdateDates();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
setTimeout(() => focusPane(pane), 0);
|
|
292
|
+
console.log('Pane opened successfully');
|
|
293
|
+
} catch (error) {
|
|
294
|
+
// Includes the 10s AbortError: a click that produced nothing at all is
|
|
295
|
+
// worse than a full navigation the browser can report on.
|
|
296
|
+
console.error('Error opening pane:', error);
|
|
297
|
+
giveUp();
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const focusPane = (pane) => {
|
|
302
|
+
requestAnimationFrame(() => {
|
|
303
|
+
if (document.activeElement && document.activeElement !== pane && document.activeElement.blur) {
|
|
304
|
+
document.activeElement.blur();
|
|
305
|
+
}
|
|
306
|
+
pane.focus({ preventScroll: true });
|
|
307
|
+
// Only scroll pane into view on desktop (>= 1024px)
|
|
308
|
+
// On mobile, panes fill the viewport so scrollIntoView is not needed
|
|
309
|
+
if (window.innerWidth >= 1024) {
|
|
310
|
+
requestAnimationFrame(() => {
|
|
311
|
+
pane.scrollIntoView({ behavior: reducedMotion() ? 'auto' : 'smooth', block: 'nearest', inline: 'start' });
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const closePane = (pane) => {
|
|
318
|
+
if (pane === container.firstElementChild) return;
|
|
319
|
+
const previousPane = pane.previousElementSibling;
|
|
320
|
+
pane.remove();
|
|
321
|
+
updateContainerLayout();
|
|
322
|
+
if (previousPane && previousPane.classList.contains('pane')) {
|
|
323
|
+
focusPane(previousPane);
|
|
324
|
+
if (previousPane.dataset.url) {
|
|
325
|
+
history.replaceState({ pane: previousPane.dataset.url }, '', previousPane.dataset.url);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// --- Single Event Delegation Handler ---
|
|
331
|
+
const handleWikiClick = (e) => {
|
|
332
|
+
// Handle link clicks within content
|
|
333
|
+
const inContent = e.target?.closest?.('article.prose, aside.backlinks');
|
|
334
|
+
if (inContent) {
|
|
335
|
+
const a = e.target?.closest?.('a[href^="/notes/"]');
|
|
336
|
+
if (a) {
|
|
337
|
+
// Don't intercept if modifier keys are pressed
|
|
338
|
+
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
|
|
339
|
+
|
|
340
|
+
// On desktop (>= 1024px), intercept. On mobile, allow normal navigation.
|
|
341
|
+
if (window.innerWidth < 1024) return;
|
|
342
|
+
|
|
343
|
+
// Intercept the link and open in a pane. openPane owns the history
|
|
344
|
+
// entry, so the URL only changes if the pane opened.
|
|
345
|
+
e.preventDefault();
|
|
346
|
+
openPane(a.href);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Handle pane focusing (only if we didn't handle a content link)
|
|
352
|
+
if (e.target?.closest('a, button, input, textarea')) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const clickedPane = e.target?.closest?.('.pane');
|
|
357
|
+
if (clickedPane && document.activeElement !== clickedPane) {
|
|
358
|
+
focusPane(clickedPane);
|
|
359
|
+
if (clickedPane.dataset.url) {
|
|
360
|
+
history.pushState({ pane: clickedPane.dataset.url }, '', clickedPane.dataset.url);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
// Handle popstate (browser history navigation)
|
|
366
|
+
const handlePopstate = () => {
|
|
367
|
+
const targetUrl = normalizeUrl(window.location.href);
|
|
368
|
+
const panes = Array.from(container.querySelectorAll('.pane'));
|
|
369
|
+
const targetIndex = panes.findLastIndex(p => p.dataset.url === targetUrl);
|
|
370
|
+
|
|
371
|
+
if (targetIndex !== -1) {
|
|
372
|
+
for (let i = panes.length - 1; i > targetIndex; i--) {
|
|
373
|
+
panes[i].remove();
|
|
374
|
+
}
|
|
375
|
+
updateContainerLayout();
|
|
376
|
+
focusPane(panes[targetIndex]);
|
|
377
|
+
} else {
|
|
378
|
+
window.location.reload();
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
// Initialize the first pane
|
|
383
|
+
const firstPane = container.querySelector('.pane');
|
|
384
|
+
if (firstPane) {
|
|
385
|
+
firstPane.dataset.url = normalizeUrl(window.location.href);
|
|
386
|
+
// Deliberately not focused: focusing the pane on load put the first tab
|
|
387
|
+
// stop inside the note, so the skip link and the header controls were only
|
|
388
|
+
// reachable by tabbing backwards.
|
|
389
|
+
updateContainerLayout();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// --- SINGLE EVENT DELEGATION SETUP ---
|
|
393
|
+
// Remove any existing listeners to prevent duplicates
|
|
394
|
+
document.removeEventListener('mouseenter', handlePreviewHover, true);
|
|
395
|
+
document.removeEventListener('mouseleave', handlePreviewLeave, true);
|
|
396
|
+
container.removeEventListener('click', handleWikiClick);
|
|
397
|
+
window.removeEventListener('popstate', handlePopstate);
|
|
398
|
+
|
|
399
|
+
// Add hover preview listeners ONLY on desktop (>= 1024px)
|
|
400
|
+
// This prevents the brief preview flash on mobile/tablet when tapping links
|
|
401
|
+
if (window.innerWidth >= 1024) {
|
|
402
|
+
document.addEventListener('mouseenter', handlePreviewHover, true);
|
|
403
|
+
document.addEventListener('mouseleave', handlePreviewLeave, true);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Add navigation listeners on all devices
|
|
407
|
+
container.addEventListener('click', handleWikiClick);
|
|
408
|
+
window.addEventListener('popstate', handlePopstate);
|
|
409
|
+
|
|
410
|
+
// Mark as successfully initialized
|
|
411
|
+
window.wikiInitialized = true;
|
|
412
|
+
console.log('Wiki initialized with clean architecture');
|
|
413
|
+
} // Close the else block for container check
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
</script>
|
|
418
|
+
|
|
419
|
+
<style is:global>
|
|
420
|
+
/* Ensure body scroll is locked on desktop as the pane container handles it */
|
|
421
|
+
@media (min-width: 1024px) {
|
|
422
|
+
body {
|
|
423
|
+
overflow: hidden;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/* Define variables and responsive adjustments */
|
|
428
|
+
:root {
|
|
429
|
+
--header-height: 73px; /* first paint only; Header.astro measures it */
|
|
430
|
+
--transition-timing: 0.35s cubic-bezier(0.22, 1, 0.36, 1);
|
|
431
|
+
--container-padding-h: 3vw; /* Horizontal padding when left-aligned */
|
|
432
|
+
|
|
433
|
+
/* Default (Large Desktop > 1600px): Optimized for readability, aims for ~3 panes */
|
|
434
|
+
--pane-width: clamp(500px, 40vw, 700px);
|
|
435
|
+
--collapsed-visible-width: 120px;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/* Medium Desktop (e.g., 1440px laptop) - Adjust for better multi-pane view, aims for ~2-3 panes */
|
|
439
|
+
@media (max-width: 1600px) {
|
|
440
|
+
:root {
|
|
441
|
+
--pane-width: clamp(480px, 45vw, 680px);
|
|
442
|
+
--collapsed-visible-width: 100px;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/* Small Desktop (e.g., 1280px) - Aims for ~2 panes */
|
|
447
|
+
@media (max-width: 1280px) {
|
|
448
|
+
:root {
|
|
449
|
+
--pane-width: clamp(450px, 50vw, 650px);
|
|
450
|
+
--collapsed-visible-width: 80px;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/* --- Pane Container --- */
|
|
455
|
+
#pane-container {
|
|
456
|
+
display: flex;
|
|
457
|
+
flex-direction: row;
|
|
458
|
+
align-items: flex-start;
|
|
459
|
+
overflow-x: auto;
|
|
460
|
+
/* Position absolutely to cover the viewport below the header */
|
|
461
|
+
position: absolute;
|
|
462
|
+
top: var(--header-height);
|
|
463
|
+
bottom: 0;
|
|
464
|
+
left: 0;
|
|
465
|
+
right: 0;
|
|
466
|
+
padding: 1.5rem 0; /* Vertical padding for the container */
|
|
467
|
+
scroll-behavior: smooth;
|
|
468
|
+
|
|
469
|
+
/* FIX: Ensure pane area sits above page content but below header */
|
|
470
|
+
z-index: var(--z-panes);
|
|
471
|
+
|
|
472
|
+
/* Default (Single Pane): Center the pane */
|
|
473
|
+
/* Calculates required padding to center the pane, ensuring a minimum standard padding */
|
|
474
|
+
padding-left: max(var(--container-padding-h), calc(50vw - var(--pane-width) / 2));
|
|
475
|
+
|
|
476
|
+
/* Add transition for the alignment shift */
|
|
477
|
+
transition: padding-left var(--transition-timing);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/* When multiple panes are open (via JS class), shift alignment to the left */
|
|
481
|
+
#pane-container.multi-pane {
|
|
482
|
+
padding-left: var(--container-padding-h);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
/* Hide the horizontal scrollbar on the main container */
|
|
487
|
+
#pane-container::-webkit-scrollbar {
|
|
488
|
+
display: none;
|
|
489
|
+
}
|
|
490
|
+
#pane-container {
|
|
491
|
+
-ms-overflow-style: none; /* IE and Edge */
|
|
492
|
+
scrollbar-width: none; /* Firefox */
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
/* --- Individual Pane Styling & Cascading Logic --- */
|
|
497
|
+
.pane {
|
|
498
|
+
/* FIX: flex-basis is more reliable than width for flex children */
|
|
499
|
+
flex: 0 0 var(--pane-width);
|
|
500
|
+
width: var(--pane-width);
|
|
501
|
+
min-width: 0;
|
|
502
|
+
max-width: var(--pane-width);
|
|
503
|
+
|
|
504
|
+
background: var(--c-bg);
|
|
505
|
+
|
|
506
|
+
/* Height management */
|
|
507
|
+
height: calc(100vh - var(--header-height) - 3rem);
|
|
508
|
+
|
|
509
|
+
/* Clip content that overflows the pane boundary horizontally */
|
|
510
|
+
overflow: hidden;
|
|
511
|
+
|
|
512
|
+
/* FIX: Set position relative so the absolute close button anchors to it */
|
|
513
|
+
position: relative;
|
|
514
|
+
outline: none; /* Remove default focus outline */
|
|
515
|
+
|
|
516
|
+
/* FIX: Z-index layering ensures active pane always paints on top with negative margin stack */
|
|
517
|
+
z-index: 1; /* Baseline for all panes */
|
|
518
|
+
|
|
519
|
+
/* Cascading effect: Negative margin pulls the next pane closer */
|
|
520
|
+
margin-right: calc(var(--pane-width) * -1 + var(--collapsed-visible-width));
|
|
521
|
+
|
|
522
|
+
/* Smooth transition for the sliding/collapsing effect */
|
|
523
|
+
transition: margin-right var(--transition-timing),
|
|
524
|
+
box-shadow 0.3s ease,
|
|
525
|
+
border-color 0.3s ease;
|
|
526
|
+
|
|
527
|
+
/* Default styles for subsequent panes (cards) */
|
|
528
|
+
border-radius: var(--c-radius-md);
|
|
529
|
+
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1); /* Softer shadow when collapsed */
|
|
530
|
+
border: 1px solid var(--c-border);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/* FIX: Bump z-index on hover and focus to ensure proper paint order */
|
|
534
|
+
.pane:hover {
|
|
535
|
+
z-index: 2;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
.pane:focus-within {
|
|
539
|
+
z-index: 3;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/* Differentiate the first pane: Make it look like the main page content (no card styles) */
|
|
543
|
+
.pane:first-child {
|
|
544
|
+
box-shadow: none;
|
|
545
|
+
border: none;
|
|
546
|
+
border-radius: 0;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/* When a pane is focused (clicked or scrolled to), expand it */
|
|
550
|
+
.pane:focus-within {
|
|
551
|
+
/* Expand the focused pane to have normal spacing */
|
|
552
|
+
margin-right: 1.5rem;
|
|
553
|
+
border-color: var(--c-accent-border);
|
|
554
|
+
/* Stronger shadow when focused (for depth) */
|
|
555
|
+
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.2), 0 5px 15px rgba(0, 0, 0, 0.1);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/* Ensure the first pane doesn't get card styles even when focused */
|
|
559
|
+
.pane:first-child:focus-within {
|
|
560
|
+
box-shadow: none;
|
|
561
|
+
border: none;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/* The last pane should always have spacing on the right edge of the screen */
|
|
565
|
+
/* Default (Single Pane): Needs centering margin on the right */
|
|
566
|
+
.pane:last-child {
|
|
567
|
+
margin-right: max(var(--container-padding-h), calc(50vw - var(--pane-width) / 2));
|
|
568
|
+
transition: margin-right var(--transition-timing); /* Animate the margin shift */
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/* When multiple panes are open, the last pane only needs standard spacing */
|
|
572
|
+
#pane-container.multi-pane .pane:last-child {
|
|
573
|
+
margin-right: var(--container-padding-h);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
/* --- Pane Content Wrapper (Handles internal scrolling) --- */
|
|
578
|
+
.pane .body-wrapper {
|
|
579
|
+
height: 100%;
|
|
580
|
+
overflow-y: auto;
|
|
581
|
+
overflow-x: hidden;
|
|
582
|
+
/* Optional: Style the vertical scrollbar inside the pane */
|
|
583
|
+
scrollbar-width: thin;
|
|
584
|
+
scrollbar-color: var(--c-border) var(--c-bg-soft);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/* --- Pane Content --- */
|
|
588
|
+
.pane .body {
|
|
589
|
+
/* FIX: Adjusted padding. Increased right padding slightly to ensure text doesn't run under the absolute button. */
|
|
590
|
+
padding: 2rem 2.5rem 4rem 1.5rem;
|
|
591
|
+
max-width: 65ch;
|
|
592
|
+
margin: 0 auto;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/* --- Close Button --- */
|
|
596
|
+
/* Hide close button on the first pane */
|
|
597
|
+
.pane:first-child .close {
|
|
598
|
+
display: none;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
.pane .close {
|
|
602
|
+
/* FIX: Revert to absolute positioning, anchored to the .pane */
|
|
603
|
+
position: absolute;
|
|
604
|
+
top: 1rem;
|
|
605
|
+
right: 1rem;
|
|
606
|
+
z-index: 10; /* Ensure it's above content */
|
|
607
|
+
|
|
608
|
+
/* Reset float styles that caused the issue */
|
|
609
|
+
float: none;
|
|
610
|
+
margin: 0;
|
|
611
|
+
|
|
612
|
+
width: 32px;
|
|
613
|
+
height: 32px;
|
|
614
|
+
background: var(--c-bg-soft);
|
|
615
|
+
border: 1px solid var(--c-border);
|
|
616
|
+
border-radius: 50%; /* Circular button */
|
|
617
|
+
padding: 0;
|
|
618
|
+
display: flex;
|
|
619
|
+
align-items: center;
|
|
620
|
+
justify-content: center;
|
|
621
|
+
cursor: pointer;
|
|
622
|
+
font-size: 1.3rem;
|
|
623
|
+
line-height: 1;
|
|
624
|
+
color: var(--c-text-muted);
|
|
625
|
+
transition: all 0.15s ease;
|
|
626
|
+
opacity: 0.8;
|
|
627
|
+
flex-shrink: 0;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
.pane .close:hover {
|
|
631
|
+
background: var(--c-bg-muted);
|
|
632
|
+
color: var(--c-text);
|
|
633
|
+
opacity: 1;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/* The pane's own scrollbar, so a long note does not paint a stock grey bar
|
|
637
|
+
down the middle of the reading column. WebKit needs the pseudo-elements;
|
|
638
|
+
Firefox takes the two standard properties above. */
|
|
639
|
+
.pane .body-wrapper::-webkit-scrollbar {
|
|
640
|
+
width: 8px;
|
|
641
|
+
}
|
|
642
|
+
.pane .body-wrapper::-webkit-scrollbar-track {
|
|
643
|
+
background: var(--c-bg-soft);
|
|
644
|
+
}
|
|
645
|
+
.pane .body-wrapper::-webkit-scrollbar-thumb {
|
|
646
|
+
background: var(--c-border);
|
|
647
|
+
border-radius: 4px;
|
|
648
|
+
}
|
|
649
|
+
.pane .body-wrapper::-webkit-scrollbar-thumb:hover {
|
|
650
|
+
background: var(--c-text-muted);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
/* --- Fallback mode when JavaScript fails --- */
|
|
655
|
+
body.wiki-fallback-mode #pane-container {
|
|
656
|
+
padding-left: 0 !important;
|
|
657
|
+
flex-direction: column;
|
|
658
|
+
position: relative;
|
|
659
|
+
top: 0;
|
|
660
|
+
overflow-x: hidden;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
body.wiki-fallback-mode .pane {
|
|
664
|
+
width: 100%;
|
|
665
|
+
margin-right: 0 !important;
|
|
666
|
+
height: auto;
|
|
667
|
+
min-height: auto;
|
|
668
|
+
box-shadow: none !important;
|
|
669
|
+
border: none !important;
|
|
670
|
+
border-radius: 0 !important;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
body.wiki-fallback-mode .pane .body-wrapper {
|
|
674
|
+
height: auto;
|
|
675
|
+
overflow-y: visible;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/* --- Mobile/Tablet Breakpoint (<= 1023px) --- */
|
|
679
|
+
@media (max-width: 1023px) {
|
|
680
|
+
/* Switch to optimized single column layout on mobile */
|
|
681
|
+
#pane-container {
|
|
682
|
+
position: static;
|
|
683
|
+
padding: 0;
|
|
684
|
+
flex-direction: column;
|
|
685
|
+
align-items: stretch;
|
|
686
|
+
overflow-x: hidden;
|
|
687
|
+
}
|
|
688
|
+
.pane {
|
|
689
|
+
width: 100vw;
|
|
690
|
+
--pane-width: 100vw;
|
|
691
|
+
margin-right: 0 !important;
|
|
692
|
+
height: auto;
|
|
693
|
+
overflow: visible;
|
|
694
|
+
box-shadow: none !important;
|
|
695
|
+
border: none !important;
|
|
696
|
+
border-radius: 0 !important;
|
|
697
|
+
}
|
|
698
|
+
/* Ensure the wrapper doesn't constrain height on mobile */
|
|
699
|
+
.pane .body-wrapper {
|
|
700
|
+
height: auto;
|
|
701
|
+
overflow: visible;
|
|
702
|
+
}
|
|
703
|
+
.pane .body {
|
|
704
|
+
padding: 2rem 1.5rem;
|
|
705
|
+
}
|
|
706
|
+
/* Hide dynamically loaded panes on mobile. */
|
|
707
|
+
.pane:not(:first-child) {
|
|
708
|
+
display: none;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
/* --- Print ---
|
|
712
|
+
The pane layout is a fixed-height viewport widget: the container is
|
|
713
|
+
absolutely positioned, body scroll is locked, and each pane's text lives
|
|
714
|
+
inside an `overflow: auto` wrapper. Printed as-is, a note stops at
|
|
715
|
+
whatever fitted on one screen. This unwinds the widget back into document
|
|
716
|
+
flow so the whole note prints, and starts each open pane on its own sheet
|
|
717
|
+
so the reader gets exactly the trail they had open. */
|
|
718
|
+
@media print {
|
|
719
|
+
body {
|
|
720
|
+
overflow: visible !important;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
#pane-container {
|
|
724
|
+
position: static;
|
|
725
|
+
display: block;
|
|
726
|
+
height: auto;
|
|
727
|
+
padding: 0 !important;
|
|
728
|
+
overflow: visible;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
.pane {
|
|
732
|
+
display: block;
|
|
733
|
+
flex: none;
|
|
734
|
+
width: auto !important;
|
|
735
|
+
max-width: none !important;
|
|
736
|
+
height: auto !important;
|
|
737
|
+
margin: 0 !important;
|
|
738
|
+
overflow: visible !important;
|
|
739
|
+
border: none !important;
|
|
740
|
+
border-radius: 0 !important;
|
|
741
|
+
box-shadow: none !important;
|
|
742
|
+
break-inside: auto;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/* Paper is narrower than 1024px, so the mobile rules apply while printing
|
|
746
|
+
and would drop every pane but the first. The reader asked to print what
|
|
747
|
+
they had open, so each open pane prints, one per sheet. */
|
|
748
|
+
.pane:not(:first-child) {
|
|
749
|
+
display: block;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
.pane + .pane {
|
|
753
|
+
break-before: page;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
.pane .body-wrapper {
|
|
757
|
+
height: auto !important;
|
|
758
|
+
overflow: visible !important;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
.pane .body {
|
|
762
|
+
max-width: none;
|
|
763
|
+
padding: 0;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
.pane .close {
|
|
767
|
+
display: none !important;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
.prose h1,
|
|
771
|
+
.prose h2,
|
|
772
|
+
.prose h3 {
|
|
773
|
+
break-after: avoid;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
.prose pre,
|
|
777
|
+
.prose blockquote,
|
|
778
|
+
.prose li {
|
|
779
|
+
break-inside: avoid;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
</style>
|