ZDDC/website/releases/landing_v0.0.1.html
ZDDC ea385b5366 Initial commit
ZDDC — Zero Day Document Control. A file-naming convention plus five
single-file HTML tools (archive, transmittal, classifier, mdedit,
landing) and an optional Go HTTP server (zddc-server) with ACL and a
virtual archive index. Self-contained, offline-capable, dependency-free.

See README.md for an overview, AGENTS.md and ARCHITECTURE.md for the
build/release/architecture detail, bootstrap/README.md for the
two-level deployment install pattern, and zddc/README.md for the
HTTP server.
2026-04-27 11:05:47 -05:00

1029 lines
29 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZDDC Archive — Projects</title>
<style>
/* ==========================================================================
ZDDC Shared Base — single source of truth for tokens and primitives
Included first by every tool's build.sh via ../shared/base.css
========================================================================== */
/* ── CSS custom properties ────────────────────────────────────────────────── */
:root {
/* Brand / accent (matches zddc.varasys.io website --accent) */
--primary: #2a5a8a;
--primary-hover: #1d4060;
--primary-active: #163352;
--primary-light: #e8f0f7;
/* Semantic colours */
--success: #28a745;
--warning: #d97706;
--danger: #dc3545;
--info: #17a2b8;
/* Backgrounds */
--bg: #ffffff;
--bg-secondary: #f8f9fa;
--bg-hover: #f0f4f8;
--bg-selected: var(--primary-light);
/* Text */
--text: #212529;
--text-muted: #6c757d;
--text-light: #ffffff;
/* Borders */
--border: #dee2e6;
--border-dark: #adb5bd;
/* Shape */
--radius: 4px;
/* Typography */
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', 'Consolas', 'Courier New', monospace;
}
/* ── Dark mode tokens ─────────────────────────────────────────────────────── */
/* Applied via: OS preference (auto) or [data-theme="dark"] on <html> */
/* The [data-theme="light"] selector locks light mode regardless of OS pref. */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--primary: #4a90c4;
--primary-hover: #5ba3d9;
--primary-active: #6ab5e8;
--primary-light: #1a3550;
--bg: #1e1e1e;
--bg-secondary: #252526;
--bg-hover: #2d2d30;
--bg-selected: #1a3550;
--text: #d4d4d4;
--text-muted: #9d9d9d;
--text-light: #ffffff;
--border: #3e3e42;
--border-dark: #6e6e72;
}
}
/* Manual dark override — wins over media query */
[data-theme="dark"] {
--primary: #4a90c4;
--primary-hover: #5ba3d9;
--primary-active: #6ab5e8;
--primary-light: #1a3550;
--bg: #1e1e1e;
--bg-secondary: #252526;
--bg-hover: #2d2d30;
--bg-selected: #1a3550;
--text: #d4d4d4;
--text-muted: #9d9d9d;
--text-light: #ffffff;
--border: #3e3e42;
--border-dark: #6e6e72;
}
/* ── Reset ────────────────────────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* ── Base document ────────────────────────────────────────────────────────── */
html, body {
height: 100%;
font-family: var(--font);
font-size: 16px;
line-height: 1.5;
color: var(--text);
background-color: var(--bg-secondary);
}
/* ── Typography ───────────────────────────────────────────────────────────── */
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
line-height: 1.2;
}
a {
color: var(--primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* ── Utility ──────────────────────────────────────────────────────────────── */
.hidden {
display: none !important;
}
.truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Scrollbars (webkit) ──────────────────────────────────────────────────── */
::-webkit-scrollbar {
width: 7px;
height: 7px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a0a0a0;
}
/* ── Button primitive ─────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.4rem 0.85rem;
font-family: var(--font);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.4;
text-align: center;
text-decoration: none;
white-space: nowrap;
vertical-align: middle;
cursor: pointer;
border: 1px solid transparent;
border-radius: var(--radius);
transition: background 0.15s, box-shadow 0.15s, border-color 0.15s, color 0.15s;
background: var(--bg-secondary);
color: var(--text);
}
.btn:disabled,
.btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.btn:not(:disabled):hover {
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
}
.btn:not(:disabled):active {
box-shadow: none;
}
/* Variants */
.btn-primary {
background: var(--primary);
color: var(--text-light);
border-color: var(--primary);
}
.btn-primary:not(:disabled):hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
color: var(--text-light);
}
.btn-primary:not(:disabled):active {
background: var(--primary-active);
border-color: var(--primary-active);
}
.btn-secondary {
background: var(--bg);
color: var(--text);
border-color: var(--border);
}
.btn-secondary:not(:disabled):hover {
background: var(--bg-secondary);
}
.btn-success {
background: var(--success);
color: var(--text-light);
border-color: var(--success);
}
.btn-danger {
background: var(--danger);
color: var(--text-light);
border-color: var(--danger);
}
/* Sizes */
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.btn-lg {
padding: 0.6rem 1.4rem;
font-size: 1rem;
}
.btn-link {
background: transparent;
border-color: transparent;
color: var(--primary);
padding-left: 0;
padding-right: 0;
}
.btn-link:not(:disabled):hover {
text-decoration: underline;
box-shadow: none;
}
/* ── App header chrome ────────────────────────────────────────────────────── */
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.35rem 1rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
/* Tool name inside the header */
.app-header__title {
font-size: 17px;
font-weight: 600;
color: var(--text);
letter-spacing: 0.01em;
white-space: nowrap;
}
/* ── Build timestamp ──────────────────────────────────────────────────────── */
.build-timestamp {
font-size: 0.55rem;
color: var(--text-muted);
opacity: 0.7;
font-weight: 300;
white-space: nowrap;
padding-top: 0.15rem;
}
/* Title + timestamp stacked vertically on the left side of the header */
.header-title-group {
display: flex;
flex-direction: column;
gap: 0;
line-height: 1;
}
/* ── Icon buttons (help, theme, refresh) ─────────────────────────────────── */
/* Square, centered — overrides the asymmetric text-button padding/line-height */
#help-btn,
#theme-btn,
#refreshHeaderBtn {
width: 2rem;
height: 2rem;
padding: 0;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1rem;
}
/* Toast CSS lives in classifier/css/base.css — only that tool uses toasts. */
/* ── Theme and help icon buttons ─────────────────────────────────────────── */
#theme-btn,
#help-btn {
font-size: 1rem;
}
/* ── Help panel (shared slide-out drawer) ─────────────────────────────────── */
/* Used by all four tools. Toggle open/close via shared/help.js. */
.help-panel {
position: fixed;
top: 0;
right: 0;
width: min(420px, 85vw);
height: 100vh;
z-index: 1000;
background: var(--bg);
border-left: 1px solid var(--border);
box-shadow: -2px 0 12px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.25s ease;
}
.help-panel:not([hidden]) {
transform: translateX(0);
}
.help-panel[hidden] {
display: flex;
transform: translateX(100%);
pointer-events: none;
}
.help-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
background: var(--bg);
}
.help-panel__title {
font-size: 1rem;
font-weight: 700;
color: var(--text);
margin: 0;
}
.help-panel__close {
background: none;
border: none;
color: var(--text-muted);
font-size: 1.35rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: var(--radius);
line-height: 1;
transition: background 0.15s, color 0.15s;
}
.help-panel__close:hover {
color: var(--text);
background: var(--bg-secondary);
}
.help-panel__body {
flex: 1;
overflow-y: auto;
padding: 1rem 1rem 2rem;
font-size: 0.85rem;
line-height: 1.6;
color: var(--text);
}
.help-panel__body h3 {
font-size: 0.95rem;
font-weight: 700;
margin: 1.25rem 0 0.35rem;
color: var(--text);
border-bottom: 1px solid var(--border);
padding-bottom: 0.15rem;
}
.help-panel__body h3:first-child {
margin-top: 0;
}
.help-panel__body h4 {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 1.25rem 0 0.3rem;
padding-left: 0.5rem;
border-left: 3px solid var(--border-dark);
color: var(--text-muted);
}
.help-panel__body p {
margin: 0 0 0.5rem;
}
.help-panel__body ol,
.help-panel__body ul {
padding-left: 1.5rem;
margin: 0.3rem 0 0.5rem;
}
.help-panel__body li {
margin-bottom: 0.3rem;
}
.help-panel__body dl {
margin: 0.3rem 0;
}
.help-panel__body dt {
font-weight: 600;
color: var(--text);
}
.help-panel__body dd {
margin: 0 0 0.5rem 1rem;
color: var(--text-muted);
}
.help-panel__body code {
font-family: var(--font-mono);
font-size: 0.8em;
background: var(--bg-secondary);
padding: 0.1em 0.3em;
border-radius: 3px;
}
.help-badge {
font-size: 0.7rem;
font-weight: 600;
padding: 0.1rem 0.35rem;
border-radius: var(--radius);
vertical-align: middle;
letter-spacing: 0.02em;
}
.help-badge--draft {
color: #2563eb;
background: #eff6ff;
}
.help-badge--published {
color: #7c3aed;
background: #f5f3ff;
}
/* Shrink main content when help panel is open */
body.help-open .app-header {
margin-right: min(420px, 85vw);
}
/* ── Column filter inputs (shared across archive, classifier, transmittal) ─── */
.column-filter {
display: block;
width: 100%;
box-sizing: border-box;
margin-top: 0.25rem;
padding: 0.2rem 0.4rem;
font-size: 0.8rem;
font-family: var(--font);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
transition: border-color 0.15s;
}
.column-filter:focus {
border-color: var(--primary);
outline: none;
box-shadow: 0 0 0 1px rgba(42, 90, 138, 0.35);
}
.column-filter::placeholder {
color: var(--text-muted);
}
/* Landing page layout */
body {
margin: 0;
font-family: var(--font);
font-size: 14px;
background: var(--bg-secondary);
color: var(--text);
}
.landing-main {
max-width: 640px;
margin: 32px auto;
padding: 0 16px;
}
/* Access warning banner */
.access-warning-banner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: var(--radius);
color: #664d03;
font-size: 0.875rem;
margin-bottom: 16px;
gap: 12px;
}
.access-warning-banner.hidden { display: none; }
.warning-dismiss-btn {
background: none;
border: none;
cursor: pointer;
color: #664d03;
font-size: 1rem;
padding: 0 4px;
flex-shrink: 0;
}
/* Main card */
.landing-card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.landing-card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
gap: 12px;
flex-wrap: wrap;
}
.landing-card-header h2 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--text);
}
.landing-header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
/* Project list */
.project-list {
padding: 8px 0;
min-height: 80px;
}
.project-list-empty {
padding: 32px 16px;
text-align: center;
color: var(--text-muted);
font-size: 0.875rem;
}
.project-item {
display: flex;
align-items: center;
padding: 8px 16px;
gap: 10px;
cursor: pointer;
border-radius: 0;
transition: background 0.1s;
}
.project-item:hover { background: var(--bg-hover); }
.project-item input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
flex-shrink: 0;
accent-color: var(--primary);
}
.project-item-name {
font-size: 0.9375rem;
color: var(--text);
user-select: none;
}
/* Footer */
.landing-card-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-top: 1px solid var(--border);
gap: 8px;
}
/* Preset menu */
.preset-control {
position: relative;
}
.preset-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
min-width: 200px;
z-index: 100;
padding: 4px 0;
}
.preset-menu.hidden { display: none; }
.preset-menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
cursor: pointer;
font-size: 0.875rem;
gap: 8px;
}
.preset-menu-item:hover { background: var(--bg-hover); }
.preset-menu-item-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preset-delete-btn {
background: none;
border: none;
cursor: pointer;
color: var(--text-muted);
font-size: 0.875rem;
padding: 0 2px;
flex-shrink: 0;
}
.preset-delete-btn:hover { color: var(--danger); }
.preset-menu-empty {
padding: 8px 12px;
color: var(--text-muted);
font-size: 0.875rem;
}
.preset-menu-divider {
border: none;
border-top: 1px solid var(--border);
margin: 4px 0;
}
/* Loading state */
.project-list-loading {
padding: 32px 16px;
text-align: center;
color: var(--text-muted);
font-size: 0.875rem;
}
</style>
</head>
<body>
<header class="app-header">
<div class="header-left">
<span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp">v0.0.1</span>
</div>
<div class="header-right">
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
</div>
</header>
<main class="landing-main">
<!-- Access warning banner (shown when URL ?projects= contains inaccessible items) -->
<div id="accessWarningBanner" class="access-warning-banner hidden" role="alert">
<span id="accessWarningText"></span>
<button class="warning-dismiss-btn" onclick="LandingApp.dismissWarning()" aria-label="Dismiss">&times;</button>
</div>
<div class="landing-card">
<div class="landing-card-header">
<h2>Select Projects</h2>
<div class="landing-header-actions">
<!-- Presets dropdown -->
<div class="preset-control">
<button id="presetMenuBtn" class="btn btn-secondary btn-sm" onclick="LandingApp.togglePresetMenu()">▾ Presets</button>
<div id="presetMenu" class="preset-menu hidden"></div>
</div>
<button class="btn btn-secondary btn-sm" onclick="LandingApp.selectAll()">Select All</button>
<button class="btn btn-secondary btn-sm" onclick="LandingApp.selectNone()">Select None</button>
</div>
</div>
<div id="projectList" class="project-list">
<!-- Populated by JS -->
</div>
<div class="landing-card-footer">
<button id="savePresetBtn" class="btn btn-secondary" onclick="LandingApp.savePreset()">Save Preset…</button>
<button id="openArchiveBtn" class="btn btn-primary" onclick="LandingApp.openArchive()">Open Archive →</button>
</div>
</div>
</main>
<script>
/**
* ZDDC shared theme toggle — light / dark / auto.
* Persists choice to localStorage under 'zddc-theme'.
* Works with all four tools regardless of their module pattern.
* Expects: #theme-btn in the DOM (optional — skips gracefully if absent).
*
* Theme cycle: auto → light → dark → auto …
* 'auto' honours the OS prefers-color-scheme media query (CSS handles it).
* 'light' sets data-theme="light" on <html> (overrides dark media query).
* 'dark' sets data-theme="dark" on <html>.
*/
(function () {
'use strict';
var STORAGE_KEY = 'zddc-theme';
var THEMES = ['auto', 'light', 'dark'];
var LABELS = {
auto: '◐',
light: '☀',
dark: '☾'
};
var TITLES = {
auto: 'Theme: auto (follows OS)',
light: 'Theme: light',
dark: 'Theme: dark'
};
function load() {
var stored = localStorage.getItem(STORAGE_KEY);
return THEMES.indexOf(stored) !== -1 ? stored : 'auto';
}
function apply(theme) {
if (theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
} else if (theme === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
} else {
document.documentElement.removeAttribute('data-theme');
}
}
function save(theme) {
try { localStorage.setItem(STORAGE_KEY, theme); } catch (e) {}
}
function updateButton(btn, theme) {
btn.textContent = LABELS[theme];
btn.title = TITLES[theme];
btn.setAttribute('aria-label', TITLES[theme]);
}
function next(theme) {
return THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length];
}
function init() {
var current = load();
apply(current);
var btn = document.getElementById('theme-btn');
if (!btn) { return; }
updateButton(btn, current);
btn.addEventListener('click', function () {
current = next(current);
apply(current);
save(current);
updateButton(btn, current);
});
}
/* Apply theme immediately (before DOM ready) to avoid flash */
apply(load());
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
}());
(function() {
'use strict';
var accessibleProjects = []; // [{name, url}, ...] from server
var presets = []; // [{name, projects[]}, ...] from localStorage
var PRESETS_KEY = 'zddc_landing_presets';
// ── Initialise ──────────────────────────────────────────────────────────
async function init() {
loadPresets();
var urlProjects = getUrlProjects();
var projectList = document.getElementById('projectList');
projectList.innerHTML = '<div class="project-list-loading">Loading projects…<\/div>';
try {
var resp = await fetch(location.origin + location.pathname.replace(/\/[^\/]*$/, '/'), {
headers: { 'Accept': 'application/json' }
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
accessibleProjects = await resp.json();
} catch (e) {
projectList.innerHTML = '<div class="project-list-empty">Could not load project list: ' + escapeHtml(e.message) + '<\/div>';
return;
}
// Warn about URL projects that are not accessible
if (urlProjects.size > 0) {
var missing = Array.from(urlProjects).filter(function(p) {
return !accessibleProjects.some(function(ap) { return ap.name === p; });
});
if (missing.length > 0) {
showWarning('This link includes projects you don\'t have access to: ' + missing.map(escapeHtml).join(', '));
}
}
renderProjects(urlProjects);
renderPresetMenu();
// Close preset menu on outside click
document.addEventListener('click', function(e) {
var menu = document.getElementById('presetMenu');
var btn = document.getElementById('presetMenuBtn');
if (menu && !menu.classList.contains('hidden') && !menu.contains(e.target) && e.target !== btn) {
menu.classList.add('hidden');
}
});
}
function getUrlProjects() {
var params = new URLSearchParams(location.search);
var val = params.get('projects');
if (!val) return new Set();
return new Set(val.split(',').map(function(p) { return p.trim(); }).filter(Boolean));
}
// ── Rendering ────────────────────────────────────────────────────────────
function renderProjects(preCheck) {
var container = document.getElementById('projectList');
if (accessibleProjects.length === 0) {
container.innerHTML = '<div class="project-list-empty">No projects available.<\/div>';
return;
}
var html = accessibleProjects.map(function(p) {
var checked = (preCheck.size === 0 || preCheck.has(p.name)) ? ' checked' : '';
return '<div class="project-item" onclick="LandingApp.toggleProject(this)">' +
'<input type="checkbox" value="' + escapeHtml(p.name) + '"' + checked + ' onclick="event.stopPropagation()">' +
'<span class="project-item-name">' + escapeHtml(p.name) + '<\/span>' +
'<\/div>';
}).join('');
container.innerHTML = html;
}
function renderPresetMenu() {
var menu = document.getElementById('presetMenu');
if (!menu) return;
if (presets.length === 0) {
menu.innerHTML = '<div class="preset-menu-empty">No presets saved.<\/div>';
return;
}
menu.innerHTML = presets.map(function(preset) {
return '<div class="preset-menu-item">' +
'<span class="preset-menu-item-name" onclick="LandingApp.applyPreset(' + JSON.stringify(preset.name) + ')">' +
escapeHtml(preset.name) + '<\/span>' +
'<button class="preset-delete-btn" onclick="LandingApp.deletePreset(' + JSON.stringify(preset.name) + ')" title="Delete preset">&times;<\/button>' +
'<\/div>';
}).join('');
}
// ── Actions ──────────────────────────────────────────────────────────────
function selectAll() {
document.querySelectorAll('#projectList input[type="checkbox"]').forEach(function(cb) {
cb.checked = true;
});
}
function selectNone() {
document.querySelectorAll('#projectList input[type="checkbox"]').forEach(function(cb) {
cb.checked = false;
});
}
function toggleProject(row) {
var cb = row.querySelector('input[type="checkbox"]');
if (cb) cb.checked = !cb.checked;
}
function openArchive() {
var checked = Array.from(document.querySelectorAll('#projectList input[type="checkbox"]:checked'))
.map(function(cb) { return cb.value; });
if (checked.length === 0) {
alert('Select at least one project to open.');
return;
}
var base = location.pathname.replace(/\/[^\/]*$/, '/');
location.href = base + 'archive.html?projects=' + checked.map(encodeURIComponent).join(',');
}
function savePreset() {
var name = prompt('Enter a name for this preset:');
if (!name || !name.trim()) return;
name = name.trim();
var checked = Array.from(document.querySelectorAll('#projectList input[type="checkbox"]:checked'))
.map(function(cb) { return cb.value; });
// Replace existing preset with same name
presets = presets.filter(function(p) { return p.name !== name; });
presets.push({ name: name, projects: checked });
savePresets();
renderPresetMenu();
}
function togglePresetMenu() {
var menu = document.getElementById('presetMenu');
if (menu) menu.classList.toggle('hidden');
}
function applyPreset(name) {
var preset = presets.find(function(p) { return p.name === name; });
if (!preset) return;
var projectSet = new Set(preset.projects);
document.querySelectorAll('#projectList input[type="checkbox"]').forEach(function(cb) {
cb.checked = projectSet.has(cb.value);
});
document.getElementById('presetMenu').classList.add('hidden');
}
function deletePreset(name) {
presets = presets.filter(function(p) { return p.name !== name; });
savePresets();
renderPresetMenu();
}
function dismissWarning() {
var el = document.getElementById('accessWarningBanner');
if (el) el.classList.add('hidden');
}
// ── Warning ───────────────────────────────────────────────────────────────
function showWarning(message) {
var el = document.getElementById('accessWarningBanner');
var txt = document.getElementById('accessWarningText');
if (!el || !txt) return;
txt.textContent = message;
el.classList.remove('hidden');
}
// ── Persistence ───────────────────────────────────────────────────────────
function loadPresets() {
try {
var raw = localStorage.getItem(PRESETS_KEY);
presets = raw ? JSON.parse(raw) : [];
if (!Array.isArray(presets)) presets = [];
} catch (e) {
presets = [];
}
}
function savePresets() {
try {
localStorage.setItem(PRESETS_KEY, JSON.stringify(presets));
} catch (e) { /* quota exceeded or private browsing */ }
}
// ── Utilities ─────────────────────────────────────────────────────────────
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML;
}
// ── Bootstrap ─────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', init);
window.LandingApp = {
init: init,
selectAll: selectAll,
selectNone: selectNone,
toggleProject: toggleProject,
openArchive: openArchive,
savePreset: savePreset,
togglePresetMenu: togglePresetMenu,
applyPreset: applyPreset,
deletePreset: deletePreset,
dismissWarning: dismissWarning
};
})();
</script>
</body>
</html>