@open330/kiwimu 0.7.1 β†’ 1.1.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,423 @@
1
+ (function() {
2
+ 'use strict';
3
+
4
+ const authToken = document.querySelector('meta[name="kiwi-auth"]')?.content;
5
+ if (!authToken) return; // Static build β€” feature disabled
6
+
7
+ const pageSlug = document.querySelector('[data-page-slug]')?.dataset.pageSlug;
8
+ const pageId = document.querySelector('[data-page-id]')?.dataset.pageId;
9
+ if (!pageSlug || !pageId) return; // Not a wiki page
10
+
11
+ // Create popover element
12
+ const popover = document.createElement('div');
13
+ popover.className = 'qa-popover';
14
+ popover.innerHTML = `
15
+ <div class="qa-popover-header">
16
+ <span>πŸ“ μƒˆ κ°œλ… νŽ˜μ΄μ§€ 생성</span>
17
+ <button class="qa-popover-close" aria-label="λ‹«κΈ°">&times;</button>
18
+ </div>
19
+ <div class="qa-popover-selected"></div>
20
+ <div class="qa-popover-related" style="display:none">
21
+ <div class="qa-related-label">πŸ“š κ΄€λ ¨ λ¬Έμ„œ</div>
22
+ <div class="qa-related-list"></div>
23
+ </div>
24
+ <div class="qa-popover-body">
25
+ <div class="qa-body-row">
26
+ <input type="text" class="qa-popover-input" placeholder="질문 μž…λ ₯ (선택사항)..." />
27
+ </div>
28
+ <div class="qa-body-row">
29
+ <button class="qa-popover-btn qa-generate-btn">✨ κ°œλ… νŽ˜μ΄μ§€ 생성</button>
30
+ </div>
31
+ </div>
32
+ <div class="qa-popover-loading" style="display:none">
33
+ <span class="qa-spinner"></span> 생성 쀑...
34
+ </div>
35
+ <div class="qa-popover-result" style="display:none"></div>
36
+ <div class="qa-popover-error" style="display:none"></div>
37
+ `;
38
+ document.body.appendChild(popover);
39
+
40
+ const generateBtn = popover.querySelector('.qa-generate-btn');
41
+ const loading = popover.querySelector('.qa-popover-loading');
42
+ const result = popover.querySelector('.qa-popover-result');
43
+ const errorDiv = popover.querySelector('.qa-popover-error');
44
+ const selectedDiv = popover.querySelector('.qa-popover-selected');
45
+ const questionInput = popover.querySelector('.qa-popover-input');
46
+ let selectedText = '';
47
+ let isGenerating = false;
48
+ let highlightMark = null;
49
+ let popoverTimer = null;
50
+
51
+ popover.querySelector('.qa-popover-close').addEventListener('click', hidePopover);
52
+
53
+ // Enter key in question input triggers generate
54
+ questionInput.addEventListener('keydown', (e) => {
55
+ if (e.key === 'Enter') {
56
+ e.preventDefault();
57
+ generateConcept();
58
+ }
59
+ });
60
+
61
+ function highlightSelection(range) {
62
+ try {
63
+ removeHighlight();
64
+ highlightMark = document.createElement('mark');
65
+ highlightMark.className = 'qa-highlight';
66
+ range.surroundContents(highlightMark);
67
+ } catch {
68
+ highlightMark = null;
69
+ }
70
+ }
71
+
72
+ function removeHighlight() {
73
+ if (highlightMark && highlightMark.parentNode) {
74
+ const parent = highlightMark.parentNode;
75
+ while (highlightMark.firstChild) {
76
+ parent.insertBefore(highlightMark.firstChild, highlightMark);
77
+ }
78
+ parent.removeChild(highlightMark);
79
+ highlightMark = null;
80
+ }
81
+ }
82
+
83
+ function showPopover(text, rect, range) {
84
+ selectedText = text;
85
+ loading.style.display = 'none';
86
+ result.style.display = 'none';
87
+ errorDiv.style.display = 'none';
88
+ popover.querySelector('.qa-popover-body').style.display = 'flex';
89
+ popover.querySelector('.qa-popover-header').style.display = 'flex';
90
+
91
+ // Show selected text preview (truncated)
92
+ const preview = text.length > 100 ? text.slice(0, 100) + '...' : text;
93
+ selectedDiv.textContent = `"${preview}"`;
94
+ selectedDiv.style.display = 'block';
95
+
96
+ // Highlight selected text in the document
97
+ highlightSelection(range);
98
+
99
+ // Fetch related pages
100
+ const relatedDiv = popover.querySelector('.qa-popover-related');
101
+ const relatedList = popover.querySelector('.qa-related-list');
102
+ relatedDiv.style.display = 'none';
103
+ relatedList.innerHTML = '';
104
+
105
+ fetch(`/api/search?q=${encodeURIComponent(text.slice(0, 100))}&token=${authToken}`)
106
+ .then(r => r.json())
107
+ .then(data => {
108
+ if (data.results && data.results.length > 0) {
109
+ relatedList.innerHTML = data.results.map(r => {
110
+ const icon = r.origin === 'user' ? 'πŸ’¬' : r.page_type === 'source' ? 'πŸ“–' : 'πŸ“';
111
+ const preview = r.preview ? r.preview.slice(0, 80) + '...' : '';
112
+ return `<a href="/wiki/${encodeURIComponent(r.slug)}.html" class="qa-related-item">
113
+ <span class="qa-related-icon">${icon}</span>
114
+ <span class="qa-related-title">${esc(r.title)}</span>
115
+ <span class="qa-related-preview">${esc(preview)}</span>
116
+ </a>`;
117
+ }).join('');
118
+ relatedDiv.style.display = 'block';
119
+ }
120
+ })
121
+ .catch(() => {}); // Silently fail
122
+
123
+ // Position below selection
124
+ const top = rect.bottom + window.scrollY + 8;
125
+ const left = Math.max(8, Math.min(rect.left + window.scrollX, window.innerWidth - 320));
126
+ popover.style.top = top + 'px';
127
+ popover.style.left = left + 'px';
128
+ popover.style.display = 'block';
129
+ }
130
+
131
+ function hidePopover() {
132
+ popover.style.display = 'none';
133
+ selectedText = '';
134
+ selectedDiv.style.display = 'none';
135
+ removeHighlight();
136
+ clearTimeout(popoverTimer);
137
+ popoverTimer = null;
138
+ }
139
+
140
+ function esc(s) {
141
+ const d = document.createElement('div');
142
+ d.textContent = s;
143
+ return d.innerHTML;
144
+ }
145
+
146
+ async function generateConcept() {
147
+ if (isGenerating || !selectedText) return;
148
+
149
+ isGenerating = true;
150
+ popover.querySelector('.qa-popover-body').style.display = 'none';
151
+ loading.style.display = 'flex';
152
+ errorDiv.style.display = 'none';
153
+
154
+ try {
155
+ // Start background generation
156
+ const resp = await fetch('/api/ask', {
157
+ method: 'POST',
158
+ headers: {
159
+ 'Content-Type': 'application/json',
160
+ 'Authorization': 'Bearer ' + authToken
161
+ },
162
+ body: JSON.stringify({
163
+ selected_text: selectedText,
164
+ question: questionInput.value.trim() || undefined,
165
+ page_slug: pageSlug,
166
+ page_id: parseInt(pageId)
167
+ })
168
+ });
169
+
170
+ const data = await resp.json();
171
+
172
+ if (data.task_id) {
173
+ // Background task started β€” poll for completion
174
+ pollForResult(data.task_id);
175
+ } else if (data.ok) {
176
+ // Synchronous result (fallback)
177
+ showResult(data);
178
+ } else {
179
+ showError(data.error || '였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€');
180
+ }
181
+ } catch (e) {
182
+ showError('μ„œλ²„ 연결에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€');
183
+ }
184
+ }
185
+
186
+ async function pollForResult(taskId) {
187
+ const maxAttempts = 60; // 60 * 2s = 2 min max
188
+ for (let i = 0; i < maxAttempts; i++) {
189
+ await new Promise(r => setTimeout(r, 2000));
190
+ try {
191
+ const resp = await fetch(`/api/ask/status?task_id=${taskId}`, {
192
+ headers: { 'Authorization': 'Bearer ' + authToken }
193
+ });
194
+ const data = await resp.json();
195
+
196
+ if (data.status === 'completed') {
197
+ showResult(data.result);
198
+ return;
199
+ } else if (data.status === 'error') {
200
+ showError(data.error || '생성 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€');
201
+ return;
202
+ }
203
+ // status === 'processing' β€” continue polling
204
+ loading.querySelector('span:last-child')?.remove();
205
+ const dots = '.'.repeat((i % 3) + 1);
206
+ loading.innerHTML = `<span class="qa-spinner"></span> 생성 쀑${dots}`;
207
+ } catch {
208
+ // Network error during poll β€” keep trying
209
+ }
210
+ }
211
+ showError('생성 μ‹œκ°„μ΄ μ΄ˆκ³Όλ˜μ—ˆμŠ΅λ‹ˆλ‹€. λ‚˜μ€‘μ— λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”.');
212
+ }
213
+
214
+ function showResult(data) {
215
+ loading.style.display = 'none';
216
+ isGenerating = false;
217
+
218
+ let html = `<a href="${data.url}" class="qa-result-link">πŸ“ ${esc(data.title)}</a><span class="qa-result-hint">μƒˆ κ°œλ… νŽ˜μ΄μ§€κ°€ μƒμ„±λ˜μ—ˆμŠ΅λ‹ˆλ‹€</span>`;
219
+
220
+ // Show "Save to Wiki" button if the answer is promotable
221
+ if (data.isPromotable) {
222
+ html += `<button class="qa-promote-btn" title="ν€΄μ¦ˆμ™€ μœ„ν‚€ 링크가 ν¬ν•¨λœ 영ꡬ νŽ˜μ΄μ§€λ‘œ μ €μž₯ν•©λ‹ˆλ‹€">πŸ’Ύ μœ„ν‚€μ— μ €μž₯</button>`;
223
+ }
224
+
225
+ result.innerHTML = html;
226
+ result.style.display = 'block';
227
+
228
+ // Attach promote handler
229
+ const promoteBtn = result.querySelector('.qa-promote-btn');
230
+ if (promoteBtn) {
231
+ promoteBtn.addEventListener('click', () => promoteToWiki(data));
232
+ }
233
+
234
+ // Replace highlight with a link to the new page
235
+ if (highlightMark && highlightMark.parentNode) {
236
+ const link = document.createElement('a');
237
+ link.href = data.url;
238
+ link.textContent = highlightMark.textContent;
239
+ link.className = 'wiki-link dynamic-link';
240
+ link.title = 'πŸ“ ' + data.title;
241
+ highlightMark.parentNode.replaceChild(link, highlightMark);
242
+ highlightMark = null;
243
+ }
244
+ }
245
+
246
+ async function promoteToWiki(data) {
247
+ const promoteBtn = result.querySelector('.qa-promote-btn');
248
+ if (promoteBtn) {
249
+ promoteBtn.disabled = true;
250
+ promoteBtn.textContent = '⏳ μ €μž₯ 쀑...';
251
+ }
252
+
253
+ try {
254
+ const resp = await fetch('/api/promote', {
255
+ method: 'POST',
256
+ headers: {
257
+ 'Content-Type': 'application/json',
258
+ 'Authorization': 'Bearer ' + authToken
259
+ },
260
+ body: JSON.stringify({
261
+ question: data.question || '',
262
+ answer: data.content || '',
263
+ title: data.suggestedTitle || data.title,
264
+ sourcePageId: data.sourcePageId || parseInt(pageId),
265
+ selectedText: data.selectedText || selectedText,
266
+ })
267
+ });
268
+
269
+ const promoteData = await resp.json();
270
+
271
+ if (promoteData.ok) {
272
+ if (promoteBtn) {
273
+ promoteBtn.remove();
274
+ }
275
+ const notice = document.createElement('div');
276
+ notice.className = 'qa-promote-success';
277
+ const statusText = promoteData.updated ? 'κΈ°μ‘΄ νŽ˜μ΄μ§€μ— λ‚΄μš© 좔가됨' : 'μœ„ν‚€ νŽ˜μ΄μ§€λ‘œ μ €μž₯됨 (ν€΄μ¦ˆ 포함)';
278
+ notice.innerHTML = `<span>βœ… ${esc(statusText)}</span> <a href="${promoteData.url}">β†’ ${esc(promoteData.title)}</a>`;
279
+ result.appendChild(notice);
280
+
281
+ const toastMsg = promoteData.updated ? 'μœ„ν‚€ νŽ˜μ΄μ§€κ°€ μ—…λ°μ΄νŠΈλ˜μ—ˆμŠ΅λ‹ˆλ‹€' : 'μœ„ν‚€ νŽ˜μ΄μ§€κ°€ μΆ”κ°€λ˜μ—ˆμŠ΅λ‹ˆλ‹€';
282
+ showToast(toastMsg, 'success', { href: promoteData.url, label: promoteData.title });
283
+
284
+ if (!promoteData.updated && promoteData.slug) {
285
+ injectSidebarEntry(promoteData.slug, promoteData.title);
286
+ }
287
+ } else {
288
+ if (promoteBtn) {
289
+ promoteBtn.disabled = false;
290
+ promoteBtn.textContent = 'πŸ’Ύ μœ„ν‚€μ— μ €μž₯';
291
+ }
292
+ showToast(promoteData.error || 'μ €μž₯에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€', 'error');
293
+ }
294
+ } catch {
295
+ if (promoteBtn) {
296
+ promoteBtn.disabled = false;
297
+ promoteBtn.textContent = 'πŸ’Ύ μœ„ν‚€μ— μ €μž₯';
298
+ }
299
+ showToast('μ„œλ²„ 연결에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€', 'error');
300
+ }
301
+ }
302
+
303
+ function showToast(msg, kind, link) {
304
+ const existing = document.querySelector('.kiwi-toast');
305
+ if (existing) existing.remove();
306
+
307
+ const toast = document.createElement('div');
308
+ toast.className = 'kiwi-toast ' + (kind === 'error' ? 'error' : 'success');
309
+ const icon = kind === 'error' ? '⚠️' : 'βœ…';
310
+ let inner = `<span class="kiwi-toast-icon">${icon}</span><span class="kiwi-toast-text">${esc(msg)}</span>`;
311
+ if (link && link.href) {
312
+ inner += ` <a class="kiwi-toast-link" href="${link.href}">β†’ ${esc(link.label || 'λ°”λ‘œκ°€κΈ°')}</a>`;
313
+ }
314
+ toast.innerHTML = inner;
315
+ document.body.appendChild(toast);
316
+
317
+ requestAnimationFrame(() => toast.classList.add('show'));
318
+ setTimeout(() => {
319
+ toast.classList.remove('show');
320
+ setTimeout(() => toast.remove(), 400);
321
+ }, 3500);
322
+ }
323
+
324
+ function injectSidebarEntry(slug, title) {
325
+ const conceptList = document.querySelector('#tab-concept .page-list');
326
+ if (!conceptList) return;
327
+ if (conceptList.querySelector(`a[href="/wiki/${CSS.escape(slug)}.html"]`)) return;
328
+
329
+ const li = document.createElement('li');
330
+ li.className = 'sidebar-new-entry';
331
+ li.innerHTML = `<a href="/wiki/${esc(slug)}.html">πŸ’¬ ${esc(title)}</a>`;
332
+ conceptList.prepend(li);
333
+
334
+ const conceptTab = document.querySelector('.sidebar-tab[data-tab="concept"]');
335
+ if (conceptTab) {
336
+ const m = conceptTab.textContent.match(/\((\d+)\)/);
337
+ if (m) {
338
+ conceptTab.textContent = conceptTab.textContent.replace(/\(\d+\)/, `(${parseInt(m[1]) + 1})`);
339
+ }
340
+ }
341
+ }
342
+
343
+ function showError(msg) {
344
+ loading.style.display = 'none';
345
+ isGenerating = false;
346
+ errorDiv.textContent = msg;
347
+ errorDiv.style.display = 'block';
348
+ // Show retry button
349
+ popover.querySelector('.qa-popover-body').style.display = 'flex';
350
+ }
351
+
352
+ // Event: text selection with 500ms delay
353
+ document.addEventListener('mouseup', (e) => {
354
+ if (popover.contains(e.target)) return;
355
+
356
+ clearTimeout(popoverTimer);
357
+
358
+ const selection = window.getSelection();
359
+ const text = selection?.toString().trim();
360
+
361
+ if (!text || text.length < 3) {
362
+ if (!popover.contains(e.target) && result.style.display !== 'block') {
363
+ setTimeout(() => {
364
+ if (!popover.contains(document.activeElement)) hidePopover();
365
+ }, 200);
366
+ }
367
+ return;
368
+ }
369
+
370
+ // Check if selection is within .page-body
371
+ const range = selection.getRangeAt(0);
372
+ const ancestor = range.commonAncestorContainer;
373
+ const pageBody = ancestor.nodeType === 1
374
+ ? ancestor.closest('.page-body')
375
+ : ancestor.parentElement?.closest('.page-body');
376
+ if (!pageBody) return;
377
+
378
+ // Delay popover by 500ms to allow editing the selection
379
+ const savedRange = range.cloneRange();
380
+ const savedRect = range.getBoundingClientRect();
381
+ popoverTimer = setTimeout(() => {
382
+ // Verify selection still exists after delay
383
+ const currentSelection = window.getSelection();
384
+ const currentText = currentSelection?.toString().trim();
385
+ if (currentText && currentText.length >= 3) {
386
+ showPopover(currentText, savedRect, savedRange);
387
+ }
388
+ }, 500);
389
+ });
390
+
391
+ // Mobile: use selectionchange with debounce (already has delay)
392
+ let selectionTimer;
393
+ document.addEventListener('selectionchange', () => {
394
+ clearTimeout(selectionTimer);
395
+ selectionTimer = setTimeout(() => {
396
+ const selection = window.getSelection();
397
+ const text = selection?.toString().trim();
398
+ if (!text || text.length < 3) return;
399
+
400
+ const range = selection.getRangeAt(0);
401
+ const ancestor = range.commonAncestorContainer;
402
+ const pageBody = ancestor.nodeType === 1
403
+ ? ancestor.closest('.page-body')
404
+ : ancestor.parentElement?.closest('.page-body');
405
+ if (!pageBody) return;
406
+
407
+ showPopover(text, range.getBoundingClientRect(), range.cloneRange());
408
+ }, 800); // Slightly longer delay for mobile
409
+ });
410
+
411
+ // Event: generate button
412
+ generateBtn.addEventListener('click', generateConcept);
413
+
414
+ // Event: Escape key
415
+ document.addEventListener('keydown', (e) => {
416
+ if (e.key === 'Escape') hidePopover();
417
+ });
418
+
419
+ // Prevent popover clicks from dismissing
420
+ popover.addEventListener('mousedown', (e) => {
421
+ e.stopPropagation();
422
+ });
423
+ })();
@@ -0,0 +1,58 @@
1
+ (function() {
2
+ 'use strict';
3
+ const token = document.querySelector('meta[name="kiwi-auth"]')?.content;
4
+ if (!token) return; // Static build - editing disabled
5
+
6
+ const slug = document.querySelector('[data-page-slug]')?.dataset.pageSlug;
7
+ if (!slug) return;
8
+
9
+ const editBtn = document.querySelector('.edit-btn');
10
+ const modal = document.getElementById('edit-modal');
11
+ const textarea = document.getElementById('edit-textarea');
12
+ if (!editBtn || !modal || !textarea) return;
13
+
14
+ // Show the edit button only when auth token is present
15
+ editBtn.style.display = 'inline';
16
+
17
+ // Fetch current markdown content
18
+ editBtn.addEventListener('click', async () => {
19
+ try {
20
+ const resp = await fetch('/api/page/' + encodeURIComponent(slug), {
21
+ headers: { 'Authorization': 'Bearer ' + token }
22
+ });
23
+ const data = await resp.json();
24
+ textarea.value = data.content;
25
+ modal.style.display = 'flex';
26
+ } catch (e) {
27
+ alert('νŽ˜μ΄μ§€ λ‚΄μš©μ„ 뢈러올 수 μ—†μŠ΅λ‹ˆλ‹€');
28
+ }
29
+ });
30
+
31
+ // Close modal
32
+ modal.querySelector('.edit-modal-close')?.addEventListener('click', () => {
33
+ modal.style.display = 'none';
34
+ });
35
+ modal.querySelector('.edit-cancel')?.addEventListener('click', () => {
36
+ modal.style.display = 'none';
37
+ });
38
+
39
+ // Save
40
+ modal.querySelector('.edit-save')?.addEventListener('click', async () => {
41
+ const content = textarea.value;
42
+ try {
43
+ const resp = await fetch('/api/page/edit', {
44
+ method: 'POST',
45
+ headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
46
+ body: JSON.stringify({ slug: slug, content: content })
47
+ });
48
+ const data = await resp.json();
49
+ if (data.ok) {
50
+ location.reload(); // Reload to see updated page
51
+ } else {
52
+ alert(data.error || 'μ €μž₯에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€');
53
+ }
54
+ } catch (e) {
55
+ alert('μ„œλ²„ 연결에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€');
56
+ }
57
+ });
58
+ })();
@@ -0,0 +1,201 @@
1
+ /* Notion-style peek panel: right-side slide-in for /wiki/* links.
2
+ All rules are scoped under .peek-* to avoid leaking into the page. */
3
+
4
+ /* z-index: stays above topbar (100) and sidebar (200), below qa-popover (1000)
5
+ and edit-modal (1001) so popovers/modals triggered inside the panel surface
6
+ correctly above it. */
7
+ .peek-backdrop {
8
+ position: fixed;
9
+ inset: 0;
10
+ background: rgba(0, 0, 0, 0.35);
11
+ opacity: 0;
12
+ pointer-events: none;
13
+ transition: opacity 200ms ease;
14
+ z-index: 500;
15
+ }
16
+ .peek-backdrop.is-open {
17
+ opacity: 1;
18
+ pointer-events: auto;
19
+ }
20
+
21
+ .peek-panel {
22
+ position: fixed;
23
+ top: 0;
24
+ right: 0;
25
+ bottom: 0;
26
+ width: min(720px, max(480px, 40vw));
27
+ background: var(--bg, #fff);
28
+ color: var(--text, #1a1a1a);
29
+ border-left: 1px solid var(--border, #dcdcdc);
30
+ box-shadow: -8px 0 24px rgba(0, 0, 0, 0.12);
31
+ transform: translateX(100%);
32
+ transition: transform 250ms ease;
33
+ z-index: 501;
34
+ display: flex;
35
+ flex-direction: column;
36
+ outline: none;
37
+ }
38
+ .peek-panel.is-open {
39
+ transform: translateX(0);
40
+ }
41
+
42
+ /* Resize handle on the left edge β€” invisible 8px hit area with a visible
43
+ 1px line on hover/active. Disabled on mobile (panel is full-screen). */
44
+ .peek-resize-handle {
45
+ position: absolute;
46
+ top: 0;
47
+ left: -4px;
48
+ width: 8px;
49
+ height: 100%;
50
+ cursor: ew-resize;
51
+ z-index: 1;
52
+ touch-action: none;
53
+ }
54
+ .peek-resize-handle::after {
55
+ content: "";
56
+ position: absolute;
57
+ top: 0;
58
+ left: 3px;
59
+ width: 2px;
60
+ height: 100%;
61
+ background: transparent;
62
+ transition: background 150ms ease;
63
+ }
64
+ .peek-resize-handle:hover::after,
65
+ body.peek-resizing .peek-resize-handle::after {
66
+ background: var(--namu-green, #00a495);
67
+ }
68
+ /* Suspend transition + select while dragging so resize feels immediate. */
69
+ body.peek-resizing {
70
+ cursor: ew-resize;
71
+ user-select: none;
72
+ }
73
+ body.peek-resizing .peek-panel {
74
+ transition: none;
75
+ }
76
+
77
+ .peek-header {
78
+ display: flex;
79
+ align-items: center;
80
+ gap: 8px;
81
+ padding: 8px 12px;
82
+ background: var(--bg-alt, #f6f6f6);
83
+ border-bottom: 1px solid var(--border, #dcdcdc);
84
+ flex: 0 0 auto;
85
+ min-height: var(--topbar-height, 44px);
86
+ }
87
+
88
+ /* Defeat .page-body h2 (green pill) inheritance for the header title β€” the
89
+ title isn't article content, so it should stay as plain header text. */
90
+ .peek-panel .peek-title {
91
+ flex: 1 1 auto;
92
+ font-size: 0.95rem;
93
+ font-weight: 600;
94
+ margin: 0;
95
+ padding: 0;
96
+ background: transparent;
97
+ color: var(--text, #1a1a1a);
98
+ white-space: nowrap;
99
+ overflow: hidden;
100
+ text-overflow: ellipsis;
101
+ }
102
+
103
+ .peek-actions {
104
+ display: flex;
105
+ align-items: center;
106
+ gap: 4px;
107
+ flex: 0 0 auto;
108
+ }
109
+
110
+ .peek-button {
111
+ background: transparent;
112
+ border: 1px solid transparent;
113
+ color: var(--text, #1a1a1a);
114
+ font: inherit;
115
+ font-size: 0.95rem;
116
+ line-height: 1;
117
+ padding: 6px 10px;
118
+ border-radius: 4px;
119
+ cursor: pointer;
120
+ }
121
+ .peek-button:hover {
122
+ background: var(--bg-hover, #eaeaea);
123
+ }
124
+ .peek-button:focus-visible {
125
+ outline: 2px solid var(--namu-green, #00a495);
126
+ outline-offset: 1px;
127
+ }
128
+ .peek-button[hidden] {
129
+ display: none;
130
+ }
131
+
132
+ .peek-back {
133
+ font-size: 0.95rem;
134
+ }
135
+
136
+ .peek-content {
137
+ flex: 1 1 auto;
138
+ overflow-y: auto;
139
+ overflow-x: hidden;
140
+ padding: 20px 28px 40px;
141
+ background: var(--bg, #fff);
142
+ -webkit-overflow-scrolling: touch;
143
+ }
144
+ /* Article inside the panel uses the same .page-body styles as full pages,
145
+ but drops the top margin on the first heading so it hugs the header. */
146
+ .peek-content .peek-article {
147
+ margin: 0;
148
+ }
149
+ .peek-content .page-body > :first-child {
150
+ margin-top: 0;
151
+ }
152
+
153
+ .peek-loading,
154
+ .peek-error {
155
+ padding: 24px;
156
+ text-align: center;
157
+ color: var(--text-muted, #6b6b6b);
158
+ }
159
+
160
+ .peek-error {
161
+ color: #c0392b;
162
+ }
163
+ .peek-error .peek-button {
164
+ margin-top: 12px;
165
+ border-color: var(--border, #dcdcdc);
166
+ }
167
+
168
+ .peek-spinner {
169
+ display: inline-block;
170
+ width: 14px;
171
+ height: 14px;
172
+ border: 2px solid currentColor;
173
+ border-right-color: transparent;
174
+ border-radius: 50%;
175
+ vertical-align: -2px;
176
+ margin-right: 6px;
177
+ animation: peek-spin 0.8s linear infinite;
178
+ }
179
+ @keyframes peek-spin {
180
+ to { transform: rotate(360deg); }
181
+ }
182
+
183
+ @media (max-width: 768px) {
184
+ .peek-panel {
185
+ width: 100vw !important;
186
+ border-left: none;
187
+ }
188
+ .peek-resize-handle {
189
+ display: none;
190
+ }
191
+ }
192
+
193
+ @media (prefers-reduced-motion: reduce) {
194
+ .peek-panel,
195
+ .peek-backdrop {
196
+ transition: none;
197
+ }
198
+ .peek-spinner {
199
+ animation: none;
200
+ }
201
+ }