1import { useEffect, useMemo, useRef, useState } from 'react'; 2import { html } from '../html.js?v=3.8.36'; 3import { 4 AlertTriangle, 5 Archive, 6 ArrowDown, 7 ArrowLeft, 8 ArrowRight, 9 ArrowUp, 10 BarChart3, 11 BookOpen, 12 Compass, 13 ExternalLink, 14 FileQuestion, 15 FileText, 16 FolderOpen, 17 Info, 18 Minus, 19 Move, 20 Network, 21 RotateCcw, 22 Search, 23 Sparkles, 24 Users, 25 Wrench, 26 X, 27} from 'lucide-react'; 28import { resolveSitePath } from '../utils/pathResolver.js?v=3.8.36'; 29import DesktopArchivePanel from './DesktopArchivePanel.js?v=3.8.36'; 30import DesktopAnalyticsPanel from './DesktopAnalyticsPanel.js?v=3.8.36'; 31import DesktopDissertationPanel from './DesktopDissertationPanel.js?v=3.8.36'; 32import DesktopEntityPanel from './DesktopEntityPanel.js?v=3.8.36'; 33import DesktopStartPanel from './DesktopStartPanel.js?v=3.8.36'; 34import { 35 DESKTOP_TOOL_LINKS, 36 getDesktopApp, 37 getReadyDesktopApps, 38} from './desktopRegistry.js?v=3.8.36'; 39import { 40 DESKTOP_LAYOUT_STORAGE_KEY, 41 activateDesktopWindow, 42 clampDesktopWindowPosition, 43 closeDesktopWindow, 44 emptyDesktopLayout, 45 minimizeDesktopWindow, 46 moveDesktopWindow, 47 nextVisibleDesktopWindow, 48 parseDesktopLayout, 49 serializeDesktopLayout, 50} from './desktopWindowState.js?v=3.8.36'; 51 52const ICONS = { 53 archive: Archive, 54 folders: FolderOpen, 55 start: Compass, 56 entities: Network, 57 dissertation: BookOpen, 58 analytics: BarChart3, 59 tools: Wrench, 60 about: Info, 61 report: AlertTriangle, 62 readme: FileText, 63 findings: Sparkles, 64 method: Search, 65 participate: Users, 66}; 67 68const iconFor = (key, className = 'desktop-icon-svg') => { 69 const Icon = ICONS[key] || FileQuestion; 70 return html` 71 <span className=${`desktop-icon-layer ${className}`} aria-hidden="true"> 72 <${Icon} className="desktop-icon-glyph" aria-hidden="true" /> 73 </span> 74 `; 75}; 76 77const launchModeLabel = (app) => { 78 if (app.launch.kind === 'shell') return 'Desktop window'; 79 if (app.launch.kind === 'action') return 'Opens dialog'; 80 if (app.launch.kind === 'path') return 'Standalone page'; 81 return 'Standard view'; 82}; 83 84const COMPACT_DESKTOP_QUERY = '(max-width: 700px), (max-width: 900px) and (max-height: 520px)'; 85const DRAG_THRESHOLD = 5; 86const MOVE_STEP = 32; 87const MOVE_FINE_STEP = 8; 88const WINDOW_EDGE_MARGIN = 8; 89const MINIMUM_VISIBLE_WINDOW_HEIGHT = 340; 90 91const DesktopShell = ({ 92 activeAppId = null, 93 autoFocusWindow = true, 94 onSelectApp, 95 onNavigate, 96 onOpenBugReport, 97 onExit, 98 onOpenAppsChange, 99 archiveView, 100 analyticsView, 101 dissertationView, 102 entityView, 103 startView, 104}) => { 105 const readyApps = useMemo(() => getReadyDesktopApps(), []); 106 const shortcutApps = useMemo( 107 () => readyApps.filter((app) => app.surfaces.includes('desktop')), 108 [readyApps], 109 ); 110 const startApps = useMemo( 111 () => readyApps.filter((app) => app.surfaces.includes('start')), 112 [readyApps], 113 ); 114 const shellApps = useMemo( 115 () => readyApps.filter((app) => app.launch.kind === 'shell'), 116 [readyApps], 117 ); 118 const shellAppIds = useMemo(() => shellApps.map((app) => app.id), [shellApps]); 119 const activeApp = activeAppId ? getDesktopApp(activeAppId) : null; 120 const shellApp = activeApp?.availability === 'ready' && activeApp.launch.kind === 'shell' 121 ? activeApp 122 : null; 123 const hasUnknownApp = Boolean(activeAppId && !shellApp); 124 125 const [shortcutFocusIndex, setShortcutFocusIndex] = useState(0); 126 const [startOpen, setStartOpen] = useState(false); 127 const [menuFocusIndex, setMenuFocusIndex] = useState(0); 128 const [statusMessage, setStatusMessage] = useState(''); 129 const [resetFocusRequest, setResetFocusRequest] = useState(0); 130 const [dragFocusRequest, setDragFocusRequest] = useState(0); 131 const [moveControlsAppId, setMoveControlsAppId] = useState(null); 132 const [layout, setLayout] = useState(() => { 133 try { 134 return parseDesktopLayout(localStorage.getItem(DESKTOP_LAYOUT_STORAGE_KEY), shellAppIds); 135 } catch { 136 return emptyDesktopLayout(); 137 } 138 }); 139 const activeShellWindowVisible = Boolean( 140 shellApp 141 && layout.windows.some((entry) => entry.id === shellApp.id && !entry.minimized), 142 ); 143 144 const desktopTitleRef = useRef(null); 145 const shortcutPanelRef = useRef(null); 146 const windowStackRef = useRef(null); 147 const windowTitleRefs = useRef({}); 148 const windowFrameRefs = useRef({}); 149 const moveButtonRefs = useRef({}); 150 const movePanelRefs = useRef({}); 151 const shortcutRefs = useRef([]); 152 const taskButtonRefs = useRef({}); 153 const startButtonRef = useRef(null); 154 const startMenuRef = useRef(null); 155 const menuItemRefs = useRef([]); 156 const lastShellAppRef = useRef(null); 157 const reportedOpenAppsRef = useRef(''); 158 const pointerWindowControlRef = useRef(null); 159 const requestedActiveAppRef = useRef(null); 160 const pendingDragFocusRef = useRef(null); 161 const dragSessionRef = useRef(null); 162 const dragFrameRef = useRef(null); 163 const resetFocusPendingRef = useRef(false); 164 const previousMinimizedByIdRef = useRef(new Map( 165 layout.windows.map((entry) => [entry.id, entry.minimized]), 166 )); 167 const layoutRef = useRef(layout);
168 const activeAppIdRef = useRef(activeAppId); 169 170 layoutRef.current = layout; 171 activeAppIdRef.current = activeAppId; 172 173 useEffect(() => { 174 const stylesheetId = 'archive-desktop-styles'; 175 if (document.getElementById(stylesheetId)) return undefined; 176 177 const link = document.createElement('link'); 178 link.id = stylesheetId; 179 link.rel = 'stylesheet'; 180 link.href = resolveSitePath('frontend/desktop/desktop.css?v=3.8.36'); 181 link.addEventListener('error', () => { 182 setStatusMessage('Desktop styling could not load. All destinations remain available.'); 183 }, { once: true }); 184 document.head.appendChild(link); 185 186 return undefined; 187 }, []); 188 189 useEffect(() => { 190 // Registration happens after the first document load. Asking the active 191 // root worker to warm this optional module graph means even a first-ever 192 // direct desktop visit is available on the next offline load, while a 193 // standard archive visitor never downloads desktop-only assets. 194 if ('serviceWorker' in navigator) { 195 navigator.serviceWorker.ready 196 .then((registration) => registration.active?.postMessage({ action: 'cacheDesktop' })) 197 .catch(() => {}); 198 } 199 return undefined; 200 }, []); 201 202 useEffect(() => { 203 if (!shellApp) return; 204 setLayout((current) => activateDesktopWindow(current, shellApp.id, shellAppIds)); 205 }, [shellApp, shellAppIds]); 206 207 useEffect(() => { 208 if (requestedActiveAppRef.current === activeAppId) { 209 requestedActiveAppRef.current = null; 210 } 211 }, [activeAppId]); 212 213 useEffect(() => { 214 try { 215 if (layout.windows.length === 0) { 216 localStorage.removeItem(DESKTOP_LAYOUT_STORAGE_KEY); 217 return; 218 } 219 localStorage.setItem( 220 DESKTOP_LAYOUT_STORAGE_KEY, 221 serializeDesktopLayout(layout, shellAppIds), 222 ); 223 } catch { 224 // Private browsing and storage policies may reject persistence. Window 225 // behavior remains fully usable for the current session. 226 } 227 }, [layout, shellAppIds]); 228 229 useEffect(() => { 230 const visibleAppIds = layout.windows 231 .filter((entry) => !entry.minimized) 232 .map((entry) => entry.id); 233 const signature = visibleAppIds.join('|'); 234 if (reportedOpenAppsRef.current === signature) return; 235 reportedOpenAppsRef.current = signature; 236 onOpenAppsChange?.(visibleAppIds); 237 }, [layout.windows, onOpenAppsChange]); 238 239 useEffect(() => { 240 let focusTarget = null; 241 let revealFocusTarget = false;
242 const compactDesktop = window.matchMedia?.(COMPACT_DESKTOP_QUERY).matches === true; 243 if (dragSessionRef.current) return undefined; 244 if (resetFocusPendingRef.current) { 245 if (shellApp || layout.windows.length > 0) return undefined; 246 resetFocusPendingRef.current = false; 247 const resetFrame = requestAnimationFrame(() => { 248 startButtonRef.current?.focus({ preventScroll: true }); 249 }); 250 return () => cancelAnimationFrame(resetFrame); 251 } 252 if (shellApp) { 253 lastShellAppRef.current = shellApp.id; 254 if (!autoFocusWindow) return undefined; 255 const activeWindow = document.querySelector(`[data-window-id="${shellApp.id}"]`); 256 if (activeWindow?.contains(document.activeElement)) return undefined; 257 focusTarget = windowTitleRefs.current[shellApp.id]; 258 } else if (activeAppId) { 259 setStatusMessage('That desktop item is unavailable. Showing the desktop home.'); 260 focusTarget = windowTitleRefs.current.home; 261 revealFocusTarget = compactDesktop; 262 } else if (lastShellAppRef.current && layout.windows.length === 0) { 263 const index = shortcutApps.findIndex((app) => app.id === lastShellAppRef.current); 264 if (index >= 0) { 265 setShortcutFocusIndex(index); 266 focusTarget = shortcutRefs.current[index]; 267 } 268 lastShellAppRef.current = null; 269 } else { 270 focusTarget = compactDesktop 271 ? desktopTitleRef.current 272 : windowTitleRefs.current.home || desktopTitleRef.current; 273 } 274 275 const frame = requestAnimationFrame(() => { 276 if (shellApp) { 277 const activeWindow = document.querySelector(`[data-window-id="${shellApp.id}"]`); 278 // A foreground overlay can restore its exact opener after this effect 279 // has scheduled chrome focus. Re-check at paint time so the window 280 // title cannot overwrite that more precise focus destination. 281 if (activeWindow?.contains(document.activeElement)) return; 282 } 283 focusTarget?.focus({ preventScroll: true }); 284 if (revealFocusTarget) { 285 focusTarget?.scrollIntoView({ block: 'start', inline: 'nearest' }); 286 } 287 }); 288 return () => cancelAnimationFrame(frame); 289 }, [ 290 activeAppId, 291 activeShellWindowVisible, 292 autoFocusWindow, 293 shellApp, 294 shortcutApps, 295 layout.windows.length, 296 dragFocusRequest, 297 resetFocusRequest, 298 ]); 299 300 useEffect(() => { 301 if (!shellApp) return undefined; 302 const frame = requestAnimationFrame(() => { 303 taskButtonRefs.current[shellApp.id]?.scrollIntoView({ 304 block: 'nearest', 305 inline: 'nearest', 306 }); 307 }); 308 return () => cancelAnimationFrame(frame); 309 }, [shellApp, layout.windows]); 310 311 useEffect(() => { 312 if (!window.matchMedia) return undefined; 313 const compactQuery = window.matchMedia(COMPACT_DESKTOP_QUERY); 314 const preserveVisibleFocus = (event) => { 315 if (!event.matches) return; 316 cancelWindowDrag(); 317 if (moveControlsAppId) { 318 setMoveControlsAppId(null); 319 requestAnimationFrame(() => { 320 windowTitleRefs.current[shellApp?.id || 'home']?.focus({ preventScroll: true }); 321 }); 322 } 323 const hiddenMoveFocus = document.activeElement?.closest?.( 324 '.desktop-window-move, .desktop-window-move-panel', 325 ); 326 if ( 327 shellApp 328 && (shortcutPanelRef.current?.contains(document.activeElement) || hiddenMoveFocus) 329 ) { 330 requestAnimationFrame(() => { 331 windowTitleRefs.current[shellApp.id]?.focus({ preventScroll: true }); 332 }); 333 } else if (!shellApp && document.activeElement === windowTitleRefs.current.home) { 334 requestAnimationFrame(() => { 335 if (activeAppId) { 336 windowTitleRefs.current.home?.scrollIntoView({ block: 'start', inline: 'nearest' }); 337 } else { 338 desktopTitleRef.current?.focus({ preventScroll: true }); 339 } 340 }); 341 } 342 }; 343 compactQuery.addEventListener('change', preserveVisibleFocus); 344 return () => compactQuery.removeEventListener('change', preserveVisibleFocus); 345 }, [activeAppId, moveControlsAppId, shellApp]); 346 347 useEffect(() => { 348 if (!startOpen) return undefined; 349 setMenuFocusIndex(0); 350 const frame = requestAnimationFrame(() =>
350 menuItemRefs.current[0]?.focus()); 351 352 const closeFromOutside = (event) => { 353 if ( 354 !startMenuRef.current?.contains(event.target) 355 && !startButtonRef.current?.contains(event.target) 356 ) { 357 setStartOpen(false); 358 } 359 }; 360 document.addEventListener('pointerdown', closeFromOutside); 361 return () => { 362 cancelAnimationFrame(frame); 363 document.removeEventListener('pointerdown', closeFromOutside); 364 }; 365 }, [startOpen]); 366 367 useEffect(() => { 368 if (!moveControlsAppId) return undefined; 369 const frame = requestAnimationFrame(() => { 370 movePanelRefs.current[moveControlsAppId] 371 ?.querySelector('button') 372 ?.focus({ preventScroll: true }); 373 }); 374 return () => cancelAnimationFrame(frame); 375 }, [moveControlsAppId]); 376 377 useEffect(() => { 378 const appId = pendingDragFocusRef.current; 379 if (!appId || activeAppId !== appId || dragSessionRef.current) return undefined; 380 const frame = requestAnimationFrame(() => { 381 windowTitleRefs.current[appId]?.focus({ preventScroll: true }); 382 pendingDragFocusRef.current = null; 383 }); 384 return () => cancelAnimationFrame(frame); 385 }, [activeAppId, dragFocusRequest]); 386 387 const isCompactDesktop = () => ( 388 window.matchMedia?.(COMPACT_DESKTOP_QUERY).matches === true 389 ); 390 391 const positionForWindow = (appId, sourceLayout = layoutRef.current) => ( 392 sourceLayout.windows.find((entry) => entry.id === appId)?.position || { x: 0, y: 0 } 393 ); 394 395 const workAreaBounds = () => { 396 const visualViewport = window.visualViewport;
397 const left = visualViewport?.offsetLeft || 0; 398 const top = visualViewport?.offsetTop || 0; 399 const right = left + (visualViewport?.width || window.innerWidth); 400 const viewportBottom = top + (visualViewport?.height || window.innerHeight); 401 const taskbarTop = document.querySelector('.desktop-taskbar') 402 ?.getBoundingClientRect().top; 403 return { 404 left, 405 right, 406 top, 407 bottom: Number.isFinite(taskbarTop) ? Math.min(viewportBottom, taskbarTop) : viewportBottom, 408 }; 409 }; 410 411 const clampWindowPosition = ( 412 appId, 413 position, 414 currentPosition = positionForWindow(appId), 415 capturedGeometry = null, 416 ) => { 417 const frame = windowFrameRefs.current[appId]; 418 const titlebar = frame?.querySelector('.desktop-window-titlebar'); 419 if (!frame || !titlebar) return currentPosition; 420 return clampDesktopWindowPosition(position, { 421 currentPosition, 422 frameRect: capturedGeometry?.frameRect || frame.getBoundingClientRect(), 423 titlebarRect: capturedGeometry?.titlebarRect || titlebar.getBoundingClientRect(), 424 workArea: capturedGeometry?.workArea || workAreaBounds(), 425 margin: WINDOW_EDGE_MARGIN, 426 minimumVisibleHeight: MINIMUM_VISIBLE_WINDOW_HEIGHT, 427 }); 428 }; 429 430 const applyWindowPosition = (appId, position) => { 431 const frame = windowFrameRefs.current[appId]; 432 if (!frame) return; 433 frame.style.setProperty('--desktop-window-position-x', `${position.x}px`); 434 frame.style.setProperty('--desktop-window-position-y', `${position.y}px`); 435 }; 436 437 const scheduleWindowPositionPreview = (appId, position) => { 438 if (dragFrameRef.current !== null) cancelAnimationFrame(dragFrameRef.current); 439 dragFrameRef.current = requestAnimationFrame(() => { 440 dragFrameRef.current = null; 441 applyWindowPosition(appId, position); 442 }); 443 }; 444 445 const releaseDragCapture = (session, event) => { 446 try { 447 if (session.captureTarget.hasPointerCapture(event.pointerId)) { 448 session.captureTarget.releasePointerCapture(event.pointerId); 449 } 450 } catch { 451 // A canceled or disconnected pointer may already have lost capture. 452 } 453 }; 454 455 const cancelWindowDrag = (restorePosition = true) => { 456 const session = dragSessionRef.current; 457 if (!session) return; 458 dragSessionRef.current = null; 459 if (dragFrameRef.current !== null) { 460 cancelAnimationFrame(dragFrameRef.current); 461 dragFrameRef.current = null; 462 } 463 session.frame.classList.remove('is-dragging'); 464 if (restorePosition) applyWindowPosition(session.app.id, session.startPosition); 465 try { 466 if (session.captureTarget.hasPointerCapture(session.pointerId)) { 467 session.captureTarget.releasePointerCapture(session.pointerId); 468 } 469 } catch { 470 // Reflow and unmount can disconnect the original capture target. 471 } 472 }; 473 474 const activateWindow = (appId, { focusIfActive = true } = {}) => { 475 if ( 476 requestedActiveAppRef.current === appId 477 && activeAppIdRef.current !== appId 478 ) return; 479 setLayout((current) => activateDesktopWindow(current, appId, shellAppIds)); 480 if (activeAppIdRef.current === appId) { 481 if (focusIfActive && !dragSessionRef.current) { 482 windowTitleRefs.current[appId]?.focus({ preventScroll: true }); 483 } 484 } else { 485 requestedActiveAppRef.current = appId; 486 onSelectApp?.(appId); 487 } 488 }; 489 490 const beginWindowDrag = (event, app) => { 491 if ( 492 isCompactDesktop() 493 || event.isPrimary === false 494 || (event.pointerType === 'mouse' && event.button !== 0) 495 || event.target.closest('.desktop-window-controls') 496 ) return; 497 498 const frame = windowFrameRefs.current[app.id]; 499 if (!frame) return; 500 const startPosition = positionForWindow(app.id); 501 const titlebarRect = event.currentTarget.getBoundingClientRect(); 502 setStartOpen(false); 503 try { 504 event.currentTarget.setPointerCapture(event.pointerId); 505 } catch { 506 return; 507 } 508 dragSessionRef.current = { 509 app, 510 pointerId: event.pointerId, 511 captureTarget: event.currentTarget, 512 frame, 513 startClientX: event.clientX, 514 startClientY: event.clientY, 515 startPosition, 516 position: startPosition, 517 moved: false, 518 frameRect: frame.getBoundingClientRect(), 519 titlebarRect, 520 workArea: workAreaBounds(), 521 }; 522 activateWindow(app.id, { focusIfActive: false }); 523 event.stopPropagation(); 524 }; 525 526 const moveWindowDrag = (event) => { 527 const session = dragSessionRef.current; 528 if (!session || session.pointerId !== event.pointerId) return; 529 const deltaX = event.clientX - session.startClientX; 530 const deltaY = event.clientY - session.startClientY; 531 if (!session.moved && Math.hypot(deltaX, deltaY) < DRAG_THRESHOLD) return; 532 event.preventDefault(); 533 if (!session.moved) { 534 session.moved = true; 535 session.frame.classList.add('is-dragging'); 536 } 537 session.position = clampWindowPosition( 538 session.app.id, 539 { 540 x: session.startPosition.x + deltaX, 541 y: session.startPosition.y + deltaY, 542 }, 543 session.startPosition, 544 session, 545 ); 546 scheduleWindowPositionPreview(session.app.id, session.position); 547 }; 548 549 const finishWindowDrag = (event, canceled = false) => { 550 const session = dragSessionRef.current; 551 if (!session || session.pointerId !== event.pointerId) return; 552 dragSessionRef.current = null; 553 if (dragFrameRef.current !== null) { 554 cancelAnimationFrame(dragFrameRef.current); 555 dragFrameRef.current = null; 556 } 557 session.frame.classList.remove('is-dragging'); 558 releaseDragCapture(session, event); 559 560 if (canceled) { 561 applyWindowPosition(session.app.id, session.startPosition); 562 setStatusMessage(`Canceled moving ${session.app.label}.`); 563 } else if (session.moved) { 564 applyWindowPosition(session.app.id, session.position); 565 setLayout((current) => moveDesktopWindow( 566 current, 567 session.app.id, 568 session.position, 569 shellAppIds, 570 )); 571 setStatusMessage(`Moved ${session.app.label} window.`); 572 } else { 573 setStatusMessage(`Activated ${session.app.label}.`); 574 } 575 576 pendingDragFocusRef.current = session.app.id; 577 setDragFocusRequest((request) => request + 1); 578 requestAnimationFrame(() => { 579 requestAnimationFrame(() => {
580 windowTitleRefs.current[session.app.id]?.focus({ preventScroll: true }); 581 }); 582 }); 583 }; 584 585 const closeMoveControls = (appId, returnFocus = true) => { 586 setMoveControlsAppId((current) => (current === appId ? null : current)); 587 if (returnFocus) { 588 requestAnimationFrame(() => { 589 moveButtonRefs.current[appId]?.focus({ preventScroll: true }); 590 }); 591 } 592 }; 593 594 const openMoveControls = (app) => { 595 if (isCompactDesktop()) return; 596 activateWindow(app.id, { focusIfActive: false }); 597 setMoveControlsAppId(app.id); 598 setStatusMessage(`Move controls opened for ${app.label}.`); 599 }; 600 601 const commitWindowPosition = (app, nextPosition, message) => { 602 const currentPosition = positionForWindow(app.id); 603 const clamped = clampWindowPosition(app.id, nextPosition, currentPosition); 604 applyWindowPosition(app.id, clamped); 605 setLayout((current) => moveDesktopWindow(current, app.id, clamped, shellAppIds)); 606 setStatusMessage(message); 607 }; 608 609 const moveWindowBy = (app, deltaX, deltaY, direction) => { 610 const currentPosition = positionForWindow(app.id); 611 commitWindowPosition(app, { 612 x: currentPosition.x + deltaX, 613 y: currentPosition.y + deltaY, 614 }, `${app.label} moved ${direction}.`); 615 }; 616 617 const handleMoveControlsKeyDown = (event, app) => { 618 if (event.key === 'Escape') { 619 event.preventDefault(); 620 closeMoveControls(app.id); 621 return; 622 } 623 const step = event.shiftKey ? MOVE_FINE_STEP : MOVE_STEP; 624 const direction = { 625 ArrowLeft: [-step, 0, 'left'], 626 ArrowUp: [0, -step, 'up'], 627 ArrowDown: [0, step, 'down'], 628 ArrowRight: [step, 0, 'right'], 629 }[event.key]; 630 if (!direction) return; 631 event.preventDefault(); 632 moveWindowBy(app, ...direction); 633 }; 634 635 const reclampVisibleWindows = () => { 636 if (isCompactDesktop()) return; 637 setLayout((current) => { 638 let next = current; 639 for (const entry of current.windows) { 640 if (entry.minimized || !windowFrameRefs.current[entry.id]) continue; 641 const clamped = clampWindowPosition(entry.id, entry.position, entry.position); 642 if (clamped.x === entry.position.x && clamped.y === entry.position.y) continue; 643 applyWindowPosition(entry.id, clamped); 644 next = moveDesktopWindow(next, entry.id, clamped, shellAppIds); 645 } 646 return next; 647 }); 648 }; 649 650 const minimizedStateSignature = layout.windows 651 .map((entry) => `${entry.id}:${entry.minimized ? 1 : 0}`) 652 .join('|'); 653 useEffect(() => { 654 const previous = previousMinimizedByIdRef.current; 655 const restoredWindow = layout.windows.some((entry) => ( 656 entry.minimized === false && previous.get(entry.id) === true 657 )); 658 previousMinimizedByIdRef.current = new Map( 659 layout.windows.map((entry) => [entry.id, entry.minimized]), 660 ); 661 if (!restoredWindow || isCompactDesktop()) return undefined; 662 663 let reclampFrame = null; 664 const mountedFrame = requestAnimationFrame(() => { 665 reclampFrame = requestAnimationFrame(reclampVisibleWindows); 666 }); 667 return () => { 668 cancelAnimationFrame(mountedFrame); 669 if (reclampFrame !== null) cancelAnimationFrame(reclampFrame); 670 }; 671 }, [minimizedStateSignature]); 672 673 useEffect(() => { 674 const cancelFromKeyboard = (event) => { 675 if (event.key !== 'Escape' || !dragSessionRef.current) return; 676 event.preventDefault(); 677 const app = dragSessionRef.current.app; 678 cancelWindowDrag(); 679 setStatusMessage(`Canceled moving ${app.label}.`); 680 requestAnimationFrame(() => { 681 windowTitleRefs.current[app.id]?.focus({ preventScroll: true }); 682 }); 683 }; 684 document.addEventListener('keydown', cancelFromKeyboard); 685 return () => document.removeEventListener('keydown', cancelFromKeyboard); 686 }, []); 687 688 useEffect(() => { 689 let frame = null; 690 const handleViewportChange = () => { 691 cancelWindowDrag(); 692 if (frame !== null) cancelAnimationFrame(frame); 693 frame = requestAnimationFrame(() => { 694 frame = null; 695 if (isCompactDesktop()) { 696 setMoveControlsAppId(null); 697 return; 698 } 699 reclampVisibleWindows(); 700 }); 701 }; 702 703 const observer = typeof ResizeObserver === 'function' 704 ? new ResizeObserver(handleViewportChange) 705 : null; 706 if (windowStackRef.current) observer?.observe(windowStackRef.current); 707 window.addEventListener('resize', handleViewportChange); 708 window.addEventListener('orientationchange', handleViewportChange);
709 window.visualViewport?.addEventListener('resize', handleViewportChange); 710 handleViewportChange(); 711 return () => { 712 if (frame !== null) cancelAnimationFrame(frame); 713 observer?.disconnect(); 714 window.removeEventListener('resize', handleViewportChange); 715 window.removeEventListener('orientationchange', handleViewportChange); 716 window.visualViewport?.removeEventListener('resize', handleViewportChange); 717 }; 718 }, [layout.windows.map((entry) => entry.id).join('|')]); 719 720 const openApp = (app, source = 'desktop') => { 721 const index = shortcutApps.findIndex((candidate) => candidate.id === app.id); 722 if (index >= 0) setShortcutFocusIndex(index); 723 setStartOpen(false); 724 725 if (app.launch.kind === 'shell') { 726 setStatusMessage(`Opening ${app.label}.`); 727 activateWindow(app.launch.destination); 728 return; 729 } 730 if (app.launch.kind === 'action') { 731 setStatusMessage('Opening the archive problem report.'); 732 // A Start menu item disappears in the same render that mounts the 733 // canonical report dialog. Move focus to the durable Start button first 734 // so the shared modal can capture and later restore a connected trigger. 735 if (source === 'start-menu') { 736 startButtonRef.current?.focus({ preventScroll: true }); 737 } 738 onOpenBugReport?.(); 739 return; 740 } 741 if (app.launch.kind === 'path') { 742 setStatusMessage(`${app.label} opens as a standalone archive page.`); 743 window.location.assign(resolveSitePath(app.launch.destination)); 744 return; 745 } 746 747 setStatusMessage(`${app.label} opens in the standard archive view.`); 748 onNavigate?.(app.launch.destination); 749 }; 750 751 const moveShortcutFocus = (nextIndex) => { 752 const clamped = Math.max(0, Math.min(shortcutApps.length - 1, nextIndex)); 753 setShortcutFocusIndex(clamped); 754 shortcutRefs.current[clamped]?.focus(); 755 }; 756 757 const handleShortcutKeyDown = (event, app, index) => { 758 const columns = 2; 759 const lastIndex = shortcutApps.length - 1; 760 const rowStart = Math.floor(index / columns) * columns; 761 const rowEnd = Math.min(rowStart + columns - 1, lastIndex); 762 let nextIndex = index; 763 764 if (event.key === 'Enter' || event.key === ' ') { 765 event.preventDefault(); 766 openApp(app); 767 return; 768 } 769 if (event.key === 'ArrowLeft') nextIndex = Math.max(rowStart, index - 1); 770 else if (event.key === 'ArrowRight') nextIndex = Math.min(rowEnd, index + 1); 771 else if (event.key === 'ArrowUp') nextIndex = index - columns >= 0 ? index - columns : index; 772 else if (event.key === 'ArrowDown') nextIndex = index + columns <= lastIndex ? index + columns : index; 773 else if (event.key === 'Home') nextIndex = 0; 774 else if (event.key === 'End') nextIndex = lastIndex; 775 else return; 776 777 event.preventDefault(); 778 moveShortcutFocus(nextIndex); 779 }; 780 781 const menuEntryCount = startApps.length + 2; 782 const handleMenuKeyDown = (event, index) => { 783 let nextIndex = index; 784 if (event.key === 'ArrowDown') nextIndex = (index + 1) % menuEntryCount; 785 else if (event.key === 'ArrowUp') nextIndex = (index - 1 + menuEntryCount) % menuEntryCount; 786 else if (event.key === 'Home') nextIndex = 0; 787 else if (event.key === 'End') nextIndex = menuEntryCount - 1; 788 else if (event.key === 'Escape') { 789 event.preventDefault(); 790 setStartOpen(false); 791 startButtonRef.current?.focus(); 792 return; 793 } else if (event.key === 'Tab') { 794 setStartOpen(false); 795 return; 796 } else { 797 return; 798 } 799 800 event.preventDefault(); 801 setMenuFocusIndex(nextIndex); 802 menuItemRefs.current[nextIndex]?.focus(); 803 }; 804 805 const closeWindow = (app) => { 806 if (dragSessionRef.current?.app.id === app.id) cancelWindowDrag(); 807 if (moveControlsAppId === app.id) closeMoveControls(app.id, false); 808 const nextLayout = closeDesktopWindow(layout, app.id, shellAppIds); 809 setLayout(nextLayout); 810 setStatusMessage(`Closed ${app.label}.`); 811 if (activeAppId === app.id) { 812 onSelectApp?.(nextVisibleDesktopWindow(nextLayout) || null); 813 } else { 814 requestAnimationFrame(() => { 815 windowTitleRefs.current[shellApp?.id || 'home']?.focus({ preventScroll: true }); 816 }); 817 } 818 }; 819 820 const minimizeWindow = (app) => { 821 if (dragSessionRef.current?.app.id === app.id) cancelWindowDrag(); 822 if (moveControlsAppId === app.id) closeMoveControls(app.id, false); 823 const nextLayout = minimizeDesktopWindow(layout, app.id, shellAppIds);
824 setLayout(nextLayout); 825 setStatusMessage(`Minimized ${app.label}. Use its taskbar button to restore it.`); 826 if (activeAppId === app.id) { 827 onSelectApp?.(nextVisibleDesktopWindow(nextLayout, app.id) || null); 828 } else { 829 requestAnimationFrame(() => { 830 windowTitleRefs.current[shellApp?.id || 'home']?.focus({ preventScroll: true }); 831 }); 832 } 833 }; 834 835 const resetLayout = () => { 836 cancelWindowDrag(); 837 setMoveControlsAppId(null); 838 resetFocusPendingRef.current = true; 839 lastShellAppRef.current = null; 840 setResetFocusRequest((request) => request + 1); 841 setLayout(emptyDesktopLayout()); 842 setStartOpen(false); 843 setStatusMessage('Desktop layout reset. All app windows were closed.'); 844 try { 845 localStorage.removeItem(DESKTOP_LAYOUT_STORAGE_KEY); 846 } catch { 847 // The in-memory reset still succeeds when storage is unavailable. 848 } 849 onSelectApp?.(null); 850 }; 851 852 const renderWelcome = () => html` 853 <div className="desktop-document"> 854 ${hasUnknownApp && html` 855 <div className="desktop-notice archive-notice archive-notice--warning" role="note"> 856 <${AlertTriangle} className="desktop-inline-icon" aria-hidden="true" /> 857 <span>That desktop item is unavailable. Showing the desktop home.</span> 858 </div> 859 `} 860 <p className="desktop-eyebrow">A second front door</p> 861 <h3>Welcome to Rosen 98</h3> 862 <p className="desktop-lede"> 863 This optional view arranges the same Rosen Archive as shortcuts, folders, and windows. It is a map of the collection, not a separate copy of it. 864 </p> 865 <div className="desktop-principles"> 866 <article> 867 <span className="desktop-principle-number">01</span> 868 <h4>Choose a path</h4> 869 <p>Open a shortcut once, or use Start for the complete destination list.</p> 870 </article> 871 <article> 872 <span className="desktop-principle-number">02</span> 873 <h4>Keep canonical links</h4> 874 <p>Archive records and research areas reuse maintained content and preserve canonical links.</p> 875 </article> 876 <article> 877 <span className="desktop-principle-number">03</span> 878 <h4>Leave at any time</h4> 879 <p>The taskbar always provides a direct route back to the standard archive.</p> 880 </article> 881 </div> 882 </div> 883 `; 884 885 const renderReadme = () => html` 886 <div className="desktop-document"> 887 <p className="desktop-eyebrow">Read me</p> 888 <h3>A familiar shape for a large archive</h3> 889 <p className="desktop-lede"> 890 Personal-computing and early-web history overlap with many of the archive's questions about publishing, publics, platforms, and control. This shell uses that history to make routes visible and the collection feel containable. 891 </p> 892
893 <dl className="desktop-definition-list"> 894 <div><dt>Shortcut</dt><dd>A maintained way into the collection. One click is enough.</dd></div> 895 <div><dt>Desktop window</dt><dd>A live archive surface that can remain open while you compare another path.</dd></div> 896 <div><dt>Standard view</dt><dd>The existing canonical archive route, with its full current behavior.</dd></div> 897 <div><dt>Start</dt><dd>The complete list of destinations and the dependable way out.</dd></div> 898 </dl> 899 900 <section className="desktop-layout-help" aria-labelledby="desktop-layout-help-title"> 901 <h4 id="desktop-layout-help-title">Window memory</h4> 902 <p> 903 On wider screens, drag a title bar or choose its Move button. Move offers click, tap, and keyboard arrow controls. Open, minimized, and moved app windows are saved on this device. Active state stays in the URL, so Back and Forward move between the windows you activated; position changes do not add history entries. 904 </p> 905 <button type="button" className="desktop-standard-link archive-action archive-action--secondary" onClick=${resetLayout}> 906 <${RotateCcw} aria-hidden="true" /> 907 Reset desktop layout 908 </button> 909 </section> 910 911 </div> 912 `; 913 914 const renderTools = () => html` 915 <div className="desktop-document"> 916 <p className="desktop-eyebrow">Maintained tools</p> 917 <h3>Reading and research tools</h3> 918 <p className="desktop-lede"> 919 These pages are maintained alongside the archive. Each opens as its own full page and keeps a route back to the collection. 920 </p> 921 <div className="desktop-tool-grid"> 922 ${DESKTOP_TOOL_LINKS.map((tool) => html` 923 <a 924 key=${tool.id} 925 className="desktop-tool-link" 926 href=${resolveSitePath(tool.href)} 927 aria-label=${`${tool.label}. Opens a standalone archive page.`} 928 > 929 <span className="desktop-tool-icon">${iconFor(tool.icon)}</span> 930 <span className="desktop-tool-copy"> 931 <strong>${tool.label}</strong> 932 <small>${tool.description}</small> 933 <span className="desktop-tool-mode"> 934 Open page <${ExternalLink} className="desktop-external-icon" aria-hidden="true" /> 935 ${tool.status === 'beta' ? html`<em>Beta</em>` : ''} 936 ${tool.status === 'experimental' ? html`<em>Experimental</em>` : ''} 937 </span> 938 </span> 939 </a> 940 `)} 941 </div> 942 </div> 943 `; 944 945 const renderDataStatus = (view, loadingMessage, errorTitle) => { 946 if (view.error) { 947 return html` 948 <div className="desktop-data-status archive-notice archive-notice--danger is-error" role="alert"> 949 <${AlertTriangle} aria-hidden="true" /> 950 <div> 951 <strong>${errorTitle}</strong> 952 <p>${view.error}</p> 953 <button type="button" className="archive-action archive-action--danger" onClick=${() => window.location.reload()}> 954 <${RotateCcw} aria-hidden="true" /> 955 Reload page 956 </button> 957 </div> 958 </div> 959 `; 960 } 961 962 if (view.loading) { 963 return html`<p className="desktop-data-status archive-notice" role="status">${loadingMessage}</p>`; 964 } 965 966 return null; 967 }; 968 969 const renderArchive = (appId) => { 970 const windowViewMode = appId === 'folders' ? 'folder' : 'grid'; 971 return html` 972 <${DesktopArchivePanel} 973 viewMode=${windowViewMode} 974 loading=${archiveView.loading} 975 error=${archiveView.error} 976 filters=${archiveView.filters} 977 setFilters=${archiveView.setFilters} 978 facets=${archiveView.facets} 979 autocompleteIndex=${archiveView.autocompleteIndex} 980 sortBy=${archiveView.sortBy} 981 setSortBy=${archiveView.setSortBy} 982 filteredRecords=${archiveView.filteredRecords} 983 paginatedRecords=${archiveView.paginatedRecords} 984 folderGroups=${archiveView.folderGroups} 985 currentPage=${archiveView.currentPage} 986 totalPages=${archiveView.totalPages} 987 activeFilterCount=${archiveView.activeFilterCount} 988 onSetViewMode=${archiveView.onSetViewMode} 989 onSelectRecord=${archiveView.onSelectRecord} 990 onOpenFolder=${archiveView.onOpenFolder} 991 onPageChange=${archiveView.onPageChange} 992 onClearFilters=${archiveView.onClearFilters} 993 onOpenStandard=${() => archiveView.onOpenStandard(windowViewMode)} 994 /> 995 `; 996 }; 997 998 const renderEntities = () => html` 999 <${DesktopEntityPanel} 1000 records=${entityView.records} 1001 queryActive=${entityView.queryActive} 1002 onClearQuery=${entityView.onClearQuery} 1003 dataStatus=${renderDataStatus( 1004 entityView, 1005 'Loading connected archive recordsâ¦', 1006 'Connected records are unavailable.', 1007 )} 1008 onSelectRecord=${entityView.onSelectRecord} 1009 selectedEntityId=${entityView.selectedEntityId} 1010 onSelectEntity=${entityView.onSelectEntity} 1011 autoFocusSelection=${entityView.autoFocusSelection} 1012 onOpenStandard=${entityView.onOpenStandard} 1013 /> 1014 `; 1015 1016 const renderDissertation = () => html` 1017 <${DesktopDissertationPanel} 1018 onOpenStandard=${dissertationView.onOpenStandard} 1019 /> 1020 `; 1021 1022 const renderAnalytics = () => html` 1023 <${DesktopAnalyticsPanel} 1024 onRecordResults=${analyticsView.onRecordResults} 1025 onOpenStandard=${analyticsView.onOpenStandard} 1026 /> 1027 `; 1028 1029 const renderGuidedPath = (appId) => html` 1030 <${DesktopStartPanel} 1031 mode=${appId} 1032 records=${startView.records} 1033 dataStatus=${renderDataStatus( 1034 startView, 1035 'Loading the guideâs archive recordsâ¦', 1036 'Guide records are unavailable.', 1037 )} 1038 onNavigate=${startView.onNavigate} 1039 onSelectRecord=${startView.onSelectRecord} 1040 onOpenBugReport=${startView.onOpenBugReport} 1041 onOpenStandard=${startView.onOpenStandard} 1042 /> 1043 `; 1044 1045 const renderWindowContent = (app) => { 1046 if (!app) return renderWelcome(); 1047 if (app.id === 'archive' || app.id === 'folders') return renderArchive(app.id); 1048 if (app.id === 'entities') return renderEntities(); 1049 if (app.id === 'dissertation') return renderDissertation(); 1050 if (app.id === 'analytics') return renderAnalytics(); 1051 if (app.id === 'start' || app.id === 'findings') return renderGuidedPath(app.id); 1052 if (app.id === 'readme') return renderReadme(); 1053 if (app.id === 'tools') return renderTools(); 1054 return renderWelcome(); 1055 }; 1056 1057 const windowStatusText = (app, isActive) => {
1058 const stackStatus = isActive ? 'Active' : 'Background'; 1059 let context = 'One archive, two ways in'; 1060 1061 if (app?.id === 'archive' || app?.id === 'folders') { 1062 if (archiveView.loading) context = 'Loading canonical archive records'; 1063 else if (archiveView.error) context = 'Canonical archive records unavailable'; 1064 else { 1065 const filterLabel = archiveView.activeFilterCount === 1 ? 'filter' : 'filters'; 1066 context = `${archiveView.filteredRecords.length} records; ${archiveView.activeFilterCount} active ${filterLabel}; page ${archiveView.currentPage} of ${archiveView.totalPages}`; 1067 } 1068 } else if (app?.id === 'entities') { 1069 context = entityView.selectedEntityId 1070 ? 'Selected entity context stays open' 1071 : 'Canonical entity index; connected records open in place'; 1072 } else if (app?.id === 'analytics') { 1073 context = 'Aggregate index ready; larger queries load on request'; 1074 } else if (app?.id === 'dissertation') { 1075 context = 'One dissertation, many routes through it'; 1076 } else if (app?.id === 'start' || app?.id === 'findings') { 1077 context = 'Guide records come from the canonical archive'; 1078 } else if (app?.id === 'tools') { 1079 context = `${DESKTOP_TOOL_LINKS.length} maintained archive tools`; 1080 } else if (app?.id === 'readme') { 1081 context = 'Layout memory: this device only'; 1082 } 1083 1084 return `${stackStatus}; ${context}`; 1085 }; 1086 1087 const renderWindowFrame = (app) => { 1088 const appId = app?.id || 'home'; 1089 const windowTitle = app?.label || 'Welcome to Rosen 98'; 1090 const windowEntry = app 1091 ? layout.windows.find((entry) => entry.id === app.id) 1092 : null; 1093 const isActive = app ? shellApp?.id === app.id : !shellApp; 1094 const isWideWindow = [ 1095 'archive', 1096 'folders', 1097 'entities', 1098 'dissertation', 1099 'analytics', 1100 'start', 1101 'findings', 1102 ].includes(app?.id); 1103 const titleId = `desktop-window-title-${appId}`; 1104 const movePanelId = `desktop-window-move-panel-${appId}`; 1105 const moveInstructionsId = `desktop-window-move-instructions-${appId}`; 1106 const style = app ? { 1107 '--desktop-window-position-x': `${windowEntry?.position.x || 0}px`, 1108 '--desktop-window-position-y': `${windowEntry?.position.y || 0}px`, 1109 zIndex: layout.zOrder.indexOf(app.id) + 3, 1110 } : { 1111 // Restored windows remain available in the taskbar on a desktop-home or 1112 // unknown-app URL, but home is the active surface in both cases. Keep it 1113 // above the saved stack so its visible content agrees with URL and focus 1114 // state; an activated app sends home back behind the stack. 1115 zIndex: shellApp ? 1 : layout.zOrder.length + 3, 1116 }; 1117 1118 return html` 1119 <section 1120 key=${appId} 1121 ref=${(element) => { windowFrameRefs.current[appId] = element; }} 1122 className=${`desktop-window ${isWideWindow ? 'desktop-window-wide' : ''} ${isActive ? 'is-active' : 'is-inactive'} ${app ? '' : 'desktop-home-window'}`} 1123 style=${style} 1124 role="region" 1125 aria-labelledby=${titleId} 1126 data-window-id=${appId} 1127 onPointerDown=${(event) => { 1128 if (!app || isActive) return; 1129 if (event.target.closest('.desktop-window-controls')) { 1130 pointerWindowControlRef.current = app.id; 1131 requestAnimationFrame(() => { 1132 if (pointerWindowControlRef.current === app.id) { 1133 pointerWindowControlRef.current = null; 1134 } 1135 }); 1136 return; 1137 } 1138 activateWindow(app.id); 1139 }} 1140 onFocusCapture=${(event) => { 1141 const pointerControlFocus = pointerWindowControlRef.current === app?.id 1142 && event.target.closest('.desktop-window-controls'); 1143 const dragFocus = dragSessionRef.current?.app.id === app?.id; 1144 if (app && !isActive && !pointerControlFocus && !dragFocus) activateWindow(app.id); 1145 }} 1146 > 1147 <div 1148 className=${`desktop-window-titlebar ${app ? 'is-movable' : ''}`} 1149 onPointerDown=${app ? (event) => beginWindowDrag(event, app) : undefined} 1150 onPointerMove=${app ? moveWindowDrag : undefined} 1151 onPointerUp=${app ? (event) => finishWindowDrag(event) : undefined} 1152 onPointerCancel=${app ? (event) => finishWindowDrag(event, true) : undefined} 1153 onLostPointerCapture=${app ? (event) => finishWindowDrag(event, true) : undefined} 1154 > 1155 <div className="desktop-window-title-copy">
1156 <span aria-hidden="true">${iconFor(app?.icon || 'archive', 'desktop-titlebar-icon')}</span> 1157 <h2 1158 id=${titleId} 1159 ref=${(element) => { windowTitleRefs.current[appId] = element; }} 1160 tabIndex="-1" 1161 >${windowTitle}</h2> 1162 ${isActive && html`<span className="desktop-active-window-label">Active</span>`} 1163 </div> 1164 ${app && html` 1165 <div className="desktop-window-controls"> 1166 <button 1167 ref=${(element) => { moveButtonRefs.current[app.id] = element; }} 1168 type="button" 1169 className="desktop-window-move" 1170 aria-label=${`Move ${windowTitle}`} 1171 aria-expanded=${moveControlsAppId === app.id} 1172 onClick=${() => { 1173 if (moveControlsAppId === app.id) closeMoveControls(app.id); 1174 else openMoveControls(app); 1175 }} 1176 > 1177 <${Move} aria-hidden="true" /> 1178 </button> 1179 <button 1180 type="button" 1181 className="desktop-window-minimize" 1182 onClick=${() => minimizeWindow(app)} 1183 aria-label=${`Minimize ${windowTitle}`} 1184 > 1185 <${Minus} aria-hidden="true" /> 1186 </button> 1187 <button 1188 type="button" 1189 className="desktop-window-close" 1190 onClick=${() => closeWindow(app)} 1191 aria-label=${`Close ${windowTitle}`} 1192 > 1193 <${X} aria-hidden="true" /> 1194 </button> 1195 </div> 1196 `} 1197 </div> 1198 ${app && moveControlsAppId === app.id && html` 1199 <div 1200 id=${movePanelId} 1201 ref=${(element) => { movePanelRefs.current[app.id] = element; }} 1202 className="desktop-window-move-panel" 1203 role="group" 1204 aria-label=${`Move ${windowTitle} window`} 1205 aria-describedby=${moveInstructionsId} 1206 onKeyDown=${(event) => handleMoveControlsKeyDown(event, app)} 1207 > 1208 <p id=${moveInstructionsId}>Use the arrow buttons or arrow keys. Hold Shift for smaller keyboard steps.</p> 1209 <div className="desktop-window-move-arrows"> 1210 <button 1211 type="button" 1212 aria-label=${`Move ${windowTitle} left`} 1213 onClick=${() => moveWindowBy(app, -MOVE_STEP, 0, 'left')} 1214 ><${ArrowLeft} aria-hidden="true" /></button> 1215 <button 1216 type="button" 1217 aria-label=${`Move ${windowTitle} up`} 1218 onClick=${() => moveWindowBy(app, 0, -MOVE_STEP, 'up')} 1219 ><${ArrowUp} aria-hidden="true" /></button> 1220 <button 1221 type="button" 1222 aria-label=${`Move ${windowTitle} down`} 1223 onClick=${() => moveWindowBy(app, 0, MOVE_STEP, 'down')} 1224 ><${ArrowDown} aria-hidden="true" /></button> 1225 <button 1226 type="button" 1227 aria-label=${`Move ${windowTitle} right`} 1228 onClick=${() => moveWindowBy(app, MOVE_STEP, 0, 'right')} 1229 ><${ArrowRight} aria-hidden="true" /></button> 1230 </div> 1231 <button 1232 type="button" 1233 className="desktop-window-center" 1234 aria-label=${`Center ${windowTitle}`} 1235 onClick=${() => commitWindowPosition(app, { x: 0, y: 0 }, `${app.label} centered.`)} 1236 >Center</button> 1237 <button 1238 type="button" 1239 className="desktop-window-move-done" 1240 aria-label=${`Done moving ${windowTitle}`} 1241 onClick=${() => closeMoveControls(app.id)} 1242 >Done</button> 1243 </div> 1244 `} 1245 <div className="desktop-window-body">${renderWindowContent(app)}</div> 1246 <div className="desktop-window-status"> 1247 ${windowStatusText(app, isActive)} 1248 </div> 1249 </section> 1250 `; 1251 }; 1252 1253 const openWindows = layout.windows 1254 .filter((entry) => !entry.minimized) 1255 .map((entry) =>
1255 getDesktopApp(entry.id)) 1256 .filter((app) => app?.availability === 'ready' && app.launch.kind === 'shell'); 1257 const taskWindows = layout.windows 1258 .map((entry) => ({ ...entry, app: getDesktopApp(entry.id) })) 1259 .filter((entry) => entry.app?.availability === 'ready' && entry.app.launch.kind === 'shell'); 1260 1261 return html` 1262 <main id="main-content" className=${`archive-desktop archive-density--compact ${shellApp ? 'desktop-app-active' : ''}`}> 1263 ${shellApp && html`<h1 className="desktop-mobile-page-title">Archive desktop</h1>`} 1264 <div className="desktop-wallpaper-mark" aria-hidden="true">JR</div> 1265 <div className="desktop-workspace"> 1266 <section ref=${shortcutPanelRef} className="desktop-shortcut-panel" aria-labelledby="desktop-title"> 1267 <header className="desktop-brand"> 1268 <p>Jay Rosen's Internet Archive</p> 1269 <h1 id="desktop-title" ref=${desktopTitleRef} tabIndex="-1">Rosen 98</h1> 1270 <span>Optional exploration view</span> 1271 </header> 1272 1273 <div className="desktop-shortcut-grid" role="list" aria-label="Archive desktop shortcuts"> 1274 ${shortcutApps.map((app, index) => html` 1275 <div role="listitem" key=${app.id}> 1276 <button 1277 ref=${(element) => { shortcutRefs.current[index] = element; }} 1278 type="button" 1279 className=${`desktop-shortcut ${shellApp?.id === app.id ? 'is-active' : ''}`} 1280 tabIndex=${index === shortcutFocusIndex ? 0 : -1} 1281 aria-current=${shellApp?.id === app.id ? 'page' : undefined} 1282 aria-label=${`${app.label}. ${app.description} ${launchModeLabel(app)}.`} 1283 onFocus=${() => setShortcutFocusIndex(index)} 1284 onClick=${() => openApp(app)} 1285 onKeyDown=${(event) => handleShortcutKeyDown(event, app, index)} 1286 > 1287 <span className="desktop-shortcut-icon">${iconFor(app.icon)}</span> 1288 <span className="desktop-shortcut-label">${app.label}</span> 1289 <span className="desktop-shortcut-mode">${launchModeLabel(app)}</span> 1290 </button> 1291 </div> 1292 `)} 1293 </div> 1294 </section> 1295 1296 <div ref=${windowStackRef} className="desktop-window-stack"> 1297 ${renderWindowFrame(null)} 1298 ${openWindows.map((app) => renderWindowFrame(app))} 1299 </div> 1300 </div> 1301 1302 <p className="desktop-live-status" aria-live="polite" aria-atomic="true">${statusMessage}</p> 1303 1304 ${startOpen && html` 1305 <div 1306 id="archive-desktop-start-menu" 1307 ref=${startMenuRef} 1308 className="desktop-start-menu" 1309 role="menu" 1310 aria-label="Archive desktop Start menu" 1311 > 1312 <div className="desktop-start-rail" aria-hidden="true"><span>Rosen 98</span></div> 1313 <div className="desktop-start-content"> 1314 <div className="desktop-start-heading" role="presentation"> 1315 <strong>Rosen 98</strong> 1316 <small>Choose a destination</small> 1317 </div> 1318 ${startApps.map((app, index) => html` 1319 <button 1320 key=${app.id} 1321 ref=${(element) => { menuItemRefs.current[index] = element; }} 1322 type="button" 1323 className="desktop-menu-item" 1324 role="menuitem" 1325 tabIndex=${index === menuFocusIndex ? 0 : -1} 1326 onFocus=${() => setMenuFocusIndex(index)} 1327 onClick=${() => openApp(app, 'start-menu')} 1328 onKeyDown=${(event) => handleMenuKeyDown(event, index)} 1329 > 1330 <span className="desktop-menu-icon">${iconFor(app.icon)}</span> 1331 <span><strong>${app.label}</strong><small>${launchModeLabel(app)}</small></span> 1332 </button> 1333 `)} 1334 <div className="desktop-menu-divider" role="separator"></div> 1335 <button 1336 ref=${(element) => { menuItemRefs.current[startApps.length] = element; }} 1337 type="button" 1338 className="desktop-menu-item" 1339 role="menuitem" 1340 tabIndex=${menuFocusIndex === startApps.length ? 0 : -1} 1341 onFocus=${() => setMenuFocusIndex(startApps.length)} 1342 onClick=${resetLayout} 1343 onKeyDown=${(event) => handleMenuKeyDown(event, startApps.length)} 1344 >
1345 <span className="desktop-menu-icon"><${RotateCcw} aria-hidden="true" /></span> 1346 <span><strong>Reset desktop layout</strong><small>Close windows and clear saved state</small></span> 1347 </button> 1348 <button 1349 ref=${(element) => { menuItemRefs.current[startApps.length + 1] = element; }} 1350 type="button" 1351 className="desktop-menu-item desktop-menu-exit" 1352 role="menuitem" 1353 tabIndex=${menuFocusIndex === startApps.length + 1 ? 0 : -1} 1354 onFocus=${() => setMenuFocusIndex(startApps.length + 1)} 1355 onClick=${onExit} 1356 onKeyDown=${(event) => handleMenuKeyDown(event, startApps.length + 1)} 1357 > 1358 <span className="desktop-menu-icon"><${ArrowLeft} aria-hidden="true" /></span> 1359 <span><strong>Standard archive</strong><small>Leave the desktop view</small></span> 1360 </button> 1361 </div> 1362 </div> 1363 `} 1364 1365 <nav className="desktop-taskbar" aria-label="Archive desktop taskbar"> 1366 <button 1367 ref=${startButtonRef} 1368 type="button" 1369 className=${`desktop-start-button ${startOpen ? 'is-pressed' : ''}`} 1370 aria-haspopup="menu" 1371 aria-expanded=${startOpen} 1372 onClick=${() => setStartOpen((open) => !open)} 1373 > 1374 <span className="desktop-start-mark" aria-hidden="true">R98</span> 1375 <strong>Start</strong> 1376 </button> 1377 1378 <button type="button" className="desktop-task-button desktop-standard-button" onClick=${onExit}> 1379 <${ArrowLeft} aria-hidden="true" /> 1380 <span>Standard archive</span> 1381 </button> 1382 1383 ${taskWindows.length > 0 && html` 1384 <div className="desktop-task-window-list" role="list" aria-label="Open desktop windows" tabIndex="0"> 1385 ${taskWindows.map(({ app, minimized }) => html` 1386 <div role="listitem" key=${app.id}> 1387 <button 1388 ref=${(element) => { taskButtonRefs.current[app.id] = element; }} 1389 type="button" 1390 className=${`desktop-task-button ${shellApp?.id === app.id ? 'is-active' : ''} ${minimized ? 'is-minimized' : ''}`} 1391 aria-current=${shellApp?.id === app.id ? 'page' : undefined} 1392 aria-label=${`${minimized ? 'Restore' : 'Activate'} ${app.label}${minimized ? ', minimized' : ''}`} 1393 onClick=${() => { 1394 setStatusMessage(`${minimized ? 'Restored' : 'Activated'} ${app.label}.`); 1395 activateWindow(app.id); 1396 }} 1397 > 1398 ${iconFor(app.icon, 'desktop-task-icon')} 1399 <span>${app.label}${minimized ? html`<small>Minimized</small>` : ''}</span> 1400 </button> 1401 </div> 1402 `)} 1403 </div> 1404 `} 1405 1406 <div className="desktop-task-status" aria-hidden="true"> 1407 <span>Rosen 98</span> 1408 <small>desktop</small> 1409 </div> 1410 </nav> 1411 </main> 1412 `; 1413}; 1414 1415export default DesktopShell;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.