| <!-- |
| Copyright 2026 The Fuchsia Authors. All rights reserved. |
| Use of this source code is governed by a BSD-style license that can be |
| found in the LICENSE file. |
| --> |
| <!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| <title>Testing Metadata Visualizer</title> |
| <style> |
| :root { |
| --bg-color: #0d1117; |
| --card-bg: rgba(22, 27, 34, 0.8); |
| --card-border: rgba(48, 54, 61, 0.5); |
| --text-primary: #c9d1d9; |
| --text-secondary: #8b949e; |
| --accent-primary: #58a6ff; |
| --accent-secondary: #bc85ff; |
| --font-family: |
| "Outfit", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, |
| Arial, sans-serif; |
| --shadow-primary: 0 8px 32px 0 rgba(0, 0, 0, 0.37); |
| } |
| |
| :root.light { |
| --bg-color: #f6f8fa; |
| --card-bg: rgba(255, 255, 255, 0.9); |
| --card-border: rgba(216, 222, 228, 0.8); |
| --text-primary: #24292f; |
| --text-secondary: #57606a; |
| --accent-primary: #0969da; |
| --accent-secondary: #8250df; |
| --shadow-primary: 0 4px 12px rgba(0, 0, 0, 0.1); |
| } |
| |
| body { |
| font-family: var(--font-family); |
| background-color: var(--bg-color); |
| color: var(--text-primary); |
| margin: 0; |
| padding: 20px; |
| display: flex; |
| flex-direction: column; |
| height: 100vh; |
| box-sizing: border-box; |
| transition: |
| background-color 0.3s, |
| color 0.3s; |
| } |
| |
| header { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| padding-bottom: 20px; |
| border-bottom: 1px solid var(--card-border); |
| margin-bottom: 20px; |
| flex-shrink: 0; |
| } |
| |
| h1 { |
| font-size: 1.5rem; |
| margin: 0; |
| background: linear-gradient( |
| 135deg, |
| var(--accent-primary), |
| var(--accent-secondary) |
| ); |
| -webkit-background-clip: text; |
| -webkit-text-fill-color: transparent; |
| } |
| |
| .controls { |
| display: flex; |
| gap: 15px; |
| align-items: center; |
| } |
| |
| select, |
| input { |
| background-color: var(--card-bg); |
| border: 1px solid var(--card-border); |
| color: var(--text-primary); |
| padding: 8px 12px; |
| border-radius: 6px; |
| font-family: var(--font-family); |
| font-size: 0.9rem; |
| outline: none; |
| transition: |
| border-color 0.2s, |
| box-shadow 0.2s; |
| } |
| |
| select:focus, |
| input:focus { |
| border-color: var(--accent-primary); |
| box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.3); |
| } |
| |
| .breadcrumbs { |
| display: flex; |
| gap: 5px; |
| align-items: center; |
| font-size: 0.9rem; |
| color: var(--text-secondary); |
| margin-bottom: 15px; |
| flex-wrap: wrap; |
| } |
| |
| .breadcrumbs span { |
| cursor: pointer; |
| color: var(--accent-primary); |
| } |
| |
| .breadcrumbs span:hover { |
| text-decoration: underline; |
| } |
| |
| .breadcrumbs .current { |
| color: var(--text-primary); |
| cursor: default; |
| } |
| |
| .breadcrumbs .current:hover { |
| text-decoration: none; |
| } |
| |
| main { |
| flex-grow: 1; |
| display: flex; |
| flex-direction: row; |
| gap: 20px; |
| overflow: hidden; |
| background: var(--card-bg); |
| border: 1px solid var(--card-border); |
| border-radius: 12px; |
| backdrop-filter: blur(8px); |
| box-shadow: var(--shadow-primary); |
| padding: 20px; |
| box-sizing: border-box; |
| } |
| |
| .sidebar { |
| flex: 0 0 320px; |
| display: flex; |
| flex-direction: column; |
| gap: 20px; |
| overflow-y: auto; |
| border-right: 1px solid var(--card-border); |
| padding-right: 20px; |
| box-sizing: border-box; |
| } |
| |
| .sidebar h2 { |
| font-size: 1.1rem; |
| margin: 0; |
| color: var(--accent-primary); |
| } |
| |
| .sidebar-section { |
| display: flex; |
| flex-direction: column; |
| gap: 10px; |
| } |
| |
| .sidebar-section-title { |
| font-size: 0.9rem; |
| font-weight: 600; |
| color: var(--text-secondary); |
| margin-top: 20px; |
| margin-bottom: 5px; |
| border-bottom: 1px solid var(--card-border); |
| padding-bottom: 3px; |
| } |
| |
| .sidebar-item { |
| font-size: 0.85rem; |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| gap: 10px; |
| } |
| |
| .sidebar-item .chip { |
| max-width: 180px; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| white-space: nowrap; |
| margin: 0; |
| } |
| |
| .tree-container { |
| flex: 1; |
| display: flex; |
| flex-direction: column; |
| gap: 15px; |
| overflow-y: auto; |
| padding-left: 10px; |
| } |
| |
| .box { |
| background: rgba(255, 255, 255, 0.03); |
| border: 1px solid var(--card-border); |
| border-radius: 8px; |
| padding: 15px; |
| box-sizing: border-box; |
| display: flex; |
| flex-direction: column; |
| gap: 10px; |
| transition: |
| transform 0.2s, |
| background-color 0.2s, |
| border-color 0.2s; |
| cursor: pointer; |
| width: 100%; |
| max-width: 800px; |
| border-left: 4px solid transparent; |
| } |
| |
| .box:hover { |
| transform: translateY(-2px); |
| background: rgba(255, 255, 255, 0.05); |
| border-color: var(--accent-primary); |
| } |
| |
| .box-title { |
| font-weight: 600; |
| font-size: 1rem; |
| color: var(--text-primary); |
| word-break: break-all; |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| } |
| |
| .box-title .icon { |
| font-size: 1.2rem; |
| color: var(--text-secondary); |
| } |
| |
| .box-meta { |
| font-size: 0.85rem; |
| color: var(--text-secondary); |
| display: flex; |
| flex-direction: row; |
| gap: 20px; |
| flex-wrap: wrap; |
| } |
| |
| .box-meta-section { |
| display: flex; |
| flex-direction: column; |
| gap: 4px; |
| flex: 1; |
| min-width: 150px; |
| } |
| |
| .box-meta-section span.label { |
| font-weight: 600; |
| font-size: 0.8rem; |
| color: var(--text-secondary); |
| } |
| |
| .chip { |
| display: inline-block; |
| padding: 3px 8px; |
| border-radius: 4px; |
| font-size: 0.75rem; |
| font-weight: 600; |
| width: fit-content; |
| margin-right: 5px; |
| margin-bottom: 5px; |
| border: 1px solid transparent; |
| } |
| |
| .chip .count { |
| border-radius: 3px; |
| padding: 1px 4px; |
| margin-left: 5px; |
| font-size: 0.7rem; |
| } |
| |
| .theme-toggle { |
| cursor: pointer; |
| background: none; |
| border: none; |
| color: var(--text-primary); |
| font-size: 1.2rem; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| padding: 8px; |
| border-radius: 50%; |
| transition: background-color 0.2s; |
| } |
| |
| .theme-toggle:hover { |
| background-color: rgba(255, 255, 255, 0.1); |
| } |
| |
| /* Loading and Empty State */ |
| .empty-state { |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| justify-content: center; |
| height: 100%; |
| color: var(--text-secondary); |
| gap: 15px; |
| } |
| |
| .empty-state h2 { |
| margin: 0; |
| color: var(--text-primary); |
| } |
| |
| /* Custom Scrollbar */ |
| ::-webkit-scrollbar { |
| width: 8px; |
| height: 8px; |
| } |
| |
| ::-webkit-scrollbar-track { |
| background: transparent; |
| } |
| |
| ::-webkit-scrollbar-thumb { |
| background: var(--card-border); |
| border-radius: 4px; |
| } |
| |
| ::-webkit-scrollbar-thumb:hover { |
| background: var(--text-secondary); |
| } |
| </style> |
| </head> |
| <body> |
| <header> |
| <h1>Test Coverage Visualizer</h1> |
| <div class="controls"> |
| <button |
| class="theme-toggle" |
| onclick="toggleTheme()" |
| title="Toggle Theme" |
| > |
| 🌓 |
| </button> |
| </div> |
| </header> |
| |
| <div id="breadcrumbs" class="breadcrumbs"></div> |
| |
| <main> |
| <div id="sidebar" class="sidebar"> |
| <h2>Summary</h2> |
| <div id="sidebar-content" class="sidebar-section"> |
| <!-- Recursive summary will be inserted here --> |
| </div> |
| </div> |
| <div id="graph-container" class="tree-container"> |
| <div class="empty-state"> |
| <h2>No Data Loaded</h2> |
| <p>Ensure a valid testing_metadata.json is loaded.</p> |
| </div> |
| </div> |
| </main> |
| |
| <script> |
| let metadata = {}; |
| let originalData = {}; |
| let currentPath = ""; |
| |
| // Theme Management |
| function toggleTheme() { |
| const isLight = document.documentElement.classList.toggle("light"); |
| localStorage.setItem("theme", isLight ? "light" : "dark"); |
| renderGraph(currentPath, false); // Re-render to update colors for light mode |
| } |
| |
| (function () { |
| const savedTheme = localStorage.getItem("theme"); |
| if (savedTheme === "light") { |
| document.documentElement.classList.add("light"); |
| } |
| })(); |
| |
| // Load Data from URL |
| document.addEventListener("DOMContentLoaded", () => { |
| const urlParams = new URLSearchParams(window.location.search); |
| const filesParam = urlParams.get("files"); |
| const pathParam = urlParams.get("path") || ""; |
| |
| if (filesParam) { |
| loadData(filesParam.split(",")[0].trim(), pathParam); |
| } |
| |
| // Handle back/forward buttons |
| window.addEventListener("popstate", (event) => { |
| const params = new URLSearchParams(window.location.search); |
| const path = params.get("path") || ""; |
| renderGraph(path, false); // Don't pushState again |
| }); |
| }); |
| |
| function loadData(url, initialPath = "") { |
| fetch(url) |
| .then((response) => { |
| if (!response.ok) |
| throw new Error(`HTTP error! status: ${response.status}`); |
| return response.json(); |
| }) |
| .then((data) => { |
| originalData = data; |
| metadata = data.metadata || {}; |
| renderGraph(initialPath, false); // Initial render, don't pushState |
| }) |
| .catch((error) => { |
| console.error("Failed to load data:", error); |
| showEmptyState(`Failed to load data: ${error.message}`); |
| }); |
| } |
| |
| function showEmptyState(message) { |
| const container = document.getElementById("graph-container"); |
| container.innerHTML = ` |
| <div class="empty-state"> |
| <h2>Error</h2> |
| <p>${message}</p> |
| </div> |
| `; |
| } |
| |
| function updateBreadcrumbs(path) { |
| const container = document.getElementById("breadcrumbs"); |
| container.innerHTML = ""; |
| |
| const spanAll = document.createElement("span"); |
| spanAll.textContent = "All Directories"; |
| spanAll.onclick = () => renderGraph(""); |
| container.appendChild(spanAll); |
| |
| if (!path) { |
| spanAll.className = "current"; |
| return; |
| } |
| |
| const segments = path.split("/"); |
| let accumulatedPath = ""; |
| |
| segments.forEach((segment, index) => { |
| const sep = document.createTextNode(" / "); |
| container.appendChild(sep); |
| |
| accumulatedPath += (accumulatedPath ? "/" : "") + segment; |
| |
| const span = document.createElement("span"); |
| span.textContent = segment; |
| |
| if (index === segments.length - 1) { |
| span.className = "current"; |
| } else { |
| const targetPath = accumulatedPath; |
| span.onclick = () => renderGraph(targetPath); |
| } |
| |
| container.appendChild(span); |
| }); |
| } |
| |
| // Generate consistent HSL color based on string and theme |
| function getCategoryColor(text) { |
| if ( |
| !text || |
| text === "None" || |
| text.toLowerCase() === "uncategorized" |
| ) { |
| const isLight = document.documentElement.classList.contains("light"); |
| return { |
| bg: isLight ? "#e4e6eb" : "#2a261a", |
| text: "var(--text-secondary)", |
| border: "var(--card-border)", |
| }; |
| } |
| |
| let hash = 0; |
| for (let i = 0; i < text.length; i++) { |
| hash = text.charCodeAt(i) + ((hash << 5) - hash); |
| } |
| |
| const hue = Math.abs(hash % 360); |
| const sat = 70 + Math.abs((hash >> 8) % 30); // 70% to 100% |
| |
| const isLight = document.documentElement.classList.contains("light"); |
| |
| if (isLight) { |
| const lightBg = 80 + Math.abs((hash >> 16) % 15); |
| const lightText = 15 + Math.abs((hash >> 24) % 15); |
| return { |
| bg: `hsl(${hue}, ${sat}%, ${lightBg}%)`, |
| text: `hsl(${hue}, ${sat}%, ${lightText}%)`, |
| border: `hsl(${hue}, ${sat}%, 65%)`, |
| }; |
| } else { |
| const darkBg = 8 + Math.abs((hash >> 16) % 8); |
| const darkText = 70 + Math.abs((hash >> 24) % 15); |
| return { |
| bg: `hsl(${hue}, ${sat}%, ${darkBg}%)`, |
| text: `hsl(${hue}, ${sat}%, ${darkText}%)`, |
| border: `hsl(${hue}, ${sat}%, 35%)`, |
| }; |
| } |
| } |
| |
| function renderGraph(filterPath, updateHistory = true) { |
| // Before pushing a new state, save the scroll position of the CURRENT view. |
| if (updateHistory && history.state) { |
| const oldContainer = document.getElementById("graph-container"); |
| const oldScroll = oldContainer ? oldContainer.scrollTop : 0; |
| history.replaceState({ ...history.state, scrollTop: oldScroll }, ""); |
| } |
| |
| currentPath = filterPath; |
| updateBreadcrumbs(filterPath); |
| |
| // Update URL query string |
| if (updateHistory) { |
| const url = new URL(window.location); |
| url.searchParams.set("path", filterPath); |
| history.pushState({ path: filterPath, scrollTop: 0 }, "", url); |
| } |
| |
| const container = document.getElementById("graph-container"); |
| container.innerHTML = ""; |
| |
| // Restore scroll position if available in history state |
| const state = history.state; |
| let targetScroll = 0; |
| if ( |
| state && |
| state.path === filterPath && |
| state.scrollTop !== undefined |
| ) { |
| targetScroll = state.scrollTop; |
| } |
| |
| const sidebar = document.getElementById("sidebar"); |
| if (sidebar) sidebar.scrollTop = 0; // Always reset sidebar to top |
| |
| const keys = Object.keys(metadata); |
| |
| // 1. Calculate recursive summary for sidebar |
| const recursiveCategories = new Map(); |
| const recursiveSubcats = new Map(); |
| const recursiveTags = new Map(); |
| let totalDescendants = 0; |
| |
| keys.forEach((key) => { |
| if ( |
| filterPath === "" || |
| key === filterPath || |
| key.startsWith(filterPath + "/") |
| ) { |
| totalDescendants++; |
| const cov = metadata[key].coverage || {}; |
| |
| const cat = cov.category || "None"; |
| recursiveCategories.set( |
| cat, |
| (recursiveCategories.get(cat) || 0) + 1, |
| ); |
| |
| const subcat = cov.subcategory || "None"; |
| recursiveSubcats.set( |
| subcat, |
| (recursiveSubcats.get(subcat) || 0) + 1, |
| ); |
| |
| const tags = Array.isArray(cov.tags) ? cov.tags : []; |
| tags.forEach((tag) => { |
| recursiveTags.set(tag, (recursiveTags.get(tag) || 0) + 1); |
| }); |
| } |
| }); |
| |
| // Render sidebar |
| const sidebarContent = document.getElementById("sidebar-content"); |
| const sortedRecCats = Array.from(recursiveCategories.entries()).sort( |
| (a, b) => b[1] - a[1], |
| ); |
| const sortedRecSubcats = Array.from(recursiveSubcats.entries()).sort( |
| (a, b) => b[1] - a[1], |
| ); |
| const sortedRecTags = Array.from(recursiveTags.entries()).sort( |
| (a, b) => b[1] - a[1], |
| ); |
| |
| document.querySelector(".sidebar h2").textContent = filterPath |
| ? `Summary: ${filterPath}` |
| : "Summary: All Root"; |
| |
| sidebarContent.innerHTML = ` |
| <div class="sidebar-item" style="font-weight: 600; color: var(--text-primary);"> |
| <span>Total Directories</span> |
| <span>${totalDescendants}</span> |
| </div> |
| |
| <div class="sidebar-section-title" style="margin-top: 5px;">Categories</div> |
| ${ |
| sortedRecCats |
| .slice(0, 5) |
| .map(([cat, count]) => { |
| const colors = getCategoryColor(cat); |
| return ` |
| <div class="sidebar-item"> |
| <span class="chip" style="background: ${colors.bg}; color: ${colors.text}; border-color: ${colors.border}">${cat}</span> |
| <span>${count}</span> |
| </div> |
| `; |
| }) |
| .join("") || '<div class="sidebar-item">None</div>' |
| } |
| |
| <div class="sidebar-section-title">Subcategories</div> |
| ${ |
| sortedRecSubcats |
| .slice(0, 5) |
| .map(([subcat, count]) => { |
| const colors = getCategoryColor(subcat); |
| return ` |
| <div class="sidebar-item"> |
| <span class="chip" style="background: ${colors.bg}; color: ${colors.text}; border-color: ${colors.border}">${subcat}</span> |
| <span>${count}</span> |
| </div> |
| `; |
| }) |
| .join("") || '<div class="sidebar-item">None</div>' |
| } |
| |
| <div class="sidebar-section-title">Tags</div> |
| ${ |
| sortedRecTags |
| .slice(0, 10) |
| .map(([tag, count]) => { |
| const colors = getCategoryColor(tag); |
| return ` |
| <div class="sidebar-item"> |
| <span class="chip" style="background: ${colors.bg}; color: ${colors.text}; border-color: ${colors.border}">${tag}</span> |
| <span>${count}</span> |
| </div> |
| `; |
| }) |
| .join("") || '<div class="sidebar-item">None</div>' |
| } |
| `; |
| |
| // 2. Find immediate children of currentPath for the main view |
| const childrenMap = new Map(); // segment -> { count, categories: Map, subcategories: Map, tags: Map, isLeaf, data } |
| |
| keys.forEach((key) => { |
| if (key === filterPath) { |
| const cov = metadata[key].coverage || {}; |
| const cat = cov.category || "None"; |
| const subcat = cov.subcategory || "None"; |
| const tags = Array.isArray(cov.tags) ? cov.tags : []; |
| |
| const catMap = new Map([[cat, 1]]); |
| const subcatMap = new Map([[subcat, 1]]); |
| const tagsMap = new Map(); |
| tags.forEach((t) => tagsMap.set(t, 1)); |
| |
| childrenMap.set(".", { |
| count: 1, |
| categories: catMap, |
| subcategories: subcatMap, |
| tags: tagsMap, |
| isLeaf: true, |
| data: metadata[key], |
| }); |
| return; |
| } |
| |
| if (filterPath === "" || key.startsWith(filterPath + "/")) { |
| const relativePart = |
| filterPath === "" ? key : key.slice(filterPath.length + 1); |
| const segments = relativePart.split("/"); |
| const immediateSegment = segments[0]; |
| |
| if (!childrenMap.has(immediateSegment)) { |
| childrenMap.set(immediateSegment, { |
| count: 0, |
| categories: new Map(), |
| subcategories: new Map(), |
| tags: new Map(), |
| isLeaf: segments.length === 1, |
| data: null, |
| }); |
| } |
| |
| const entry = childrenMap.get(immediateSegment); |
| entry.count++; |
| |
| const cov = metadata[key].coverage || {}; |
| |
| const cat = cov.category || "None"; |
| entry.categories.set(cat, (entry.categories.get(cat) || 0) + 1); |
| |
| const subcat = cov.subcategory || "None"; |
| entry.subcategories.set( |
| subcat, |
| (entry.subcategories.get(subcat) || 0) + 1, |
| ); |
| |
| const tags = Array.isArray(cov.tags) ? cov.tags : []; |
| tags.forEach((tag) => { |
| entry.tags.set(tag, (entry.tags.get(tag) || 0) + 1); |
| }); |
| |
| if (segments.length === 1) { |
| entry.isLeaf = true; |
| entry.data = metadata[key]; |
| } |
| } |
| }); |
| |
| if (childrenMap.size === 0) { |
| container.innerHTML = ` |
| <div class="empty-state"> |
| <h2>No Data Found</h2> |
| <p>No directories match the filter.</p> |
| </div> |
| `; |
| return; |
| } |
| |
| // Sort so current dir '.' is first, then branches, then leaves |
| const sortedEntries = Array.from(childrenMap.entries()).sort((a, b) => { |
| if (a[0] === ".") return -1; |
| if (b[0] === ".") return 1; |
| return a[0].localeCompare(b[0]); |
| }); |
| |
| sortedEntries.forEach(([segment, info]) => { |
| const box = document.createElement("div"); |
| box.className = "box"; |
| |
| const titlePath = segment === "." ? filterPath || "/" : segment; |
| |
| box.classList.add("directory"); |
| |
| // Sort and get dominant color from category |
| const sortedCats = Array.from(info.categories.entries()).sort( |
| (a, b) => b[1] - a[1], |
| ); |
| const dominantCat = sortedCats[0]?.[0] || "None"; |
| const dominantColors = getCategoryColor(dominantCat); |
| box.style.borderLeftColor = dominantColors.border; |
| |
| // Sort subcats and tags |
| const sortedSubcats = Array.from(info.subcategories.entries()).sort( |
| (a, b) => b[1] - a[1], |
| ); |
| const sortedTags = Array.from(info.tags.entries()).sort( |
| (a, b) => b[1] - a[1], |
| ); |
| |
| box.innerHTML = ` |
| <div class="box-title" title="${titlePath}"> |
| <span>${titlePath}</span> |
| <span class="icon">📁</span> |
| </div> |
| <div class="box-size">${info.count} ${info.count === 1 ? "directory" : "directories"}</div> |
| <div class="box-meta"> |
| <div class="box-meta-section"> |
| <span class="label">Categories</span> |
| <div> |
| ${ |
| sortedCats |
| .map(([cat, count]) => { |
| const colors = getCategoryColor(cat); |
| return ` |
| <span class="chip" style="background: ${colors.bg}; color: ${colors.text}; border-color: ${colors.border}"> |
| ${cat} <span class="count" style="background: ${colors.text}; color: var(--bg-color)">${count}</span> |
| </span> |
| `; |
| }) |
| .join("") || "None" |
| } |
| </div> |
| </div> |
| |
| <div class="box-meta-section"> |
| <span class="label">Subcategories</span> |
| <div> |
| ${ |
| sortedSubcats |
| .map(([subcat, count]) => { |
| const colors = getCategoryColor(subcat); |
| return ` |
| <span class="chip" style="background: ${colors.bg}; color: ${colors.text}; border-color: ${colors.border}"> |
| ${subcat} <span class="count" style="background: ${colors.text}; color: var(--bg-color)">${count}</span> |
| </span> |
| `; |
| }) |
| .join("") || "None" |
| } |
| </div> |
| </div> |
| |
| <div class="box-meta-section"> |
| <span class="label">Tags</span> |
| <div> |
| ${ |
| sortedTags |
| .map(([tag, count]) => { |
| const colors = getCategoryColor(tag); |
| return ` |
| <span class="chip" style="background: ${colors.bg}; color: ${colors.text}; border-color: ${colors.border}"> |
| ${tag} <span class="count" style="background: ${colors.text}; color: var(--bg-color)">${count}</span> |
| </span> |
| `; |
| }) |
| .join("") || "None" |
| } |
| </div> |
| </div> |
| </div> |
| `; |
| |
| box.onclick = () => { |
| if (segment === ".") return; |
| const nextPath = filterPath ? `${filterPath}/${segment}` : segment; |
| renderGraph(nextPath); |
| }; |
| |
| container.appendChild(box); |
| }); |
| |
| // Apply restored scroll target after DOM construction |
| container.scrollTop = targetScroll; |
| } |
| </script> |
| </body> |
| </html> |