@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.
@@ -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, '&amp;')
48
+ .replace(/</g, '&lt;')
49
+ .replace(/>/g, '&gt;')
50
+ .replace(/"/g, '&quot;')
51
+ .replace(/'/g, '&#39;');
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>