release: archive/transmittal/classifier/mdedit/landing v0.0.2 stable

First stable bump for the HTML tools since v0.0.1 — drags the stable
channel forward to absorb the months of work that has been riding
alpha (landing rework, presets cleanup, mdedit module split, shared
build-lib changes, etc.).

Each tool independently bumped to v0.0.2 (the tools are independently
versioned by git-tag prefix; their numbers do not need to align with
each other or with zddc-server's 0.0.6).

Per-tool changes:
  - website/releases/<tool>_v0.0.2.html         new immutable snapshot
  - website/releases/<tool>_stable.html         symlink → _v0.0.2.html
  - website/releases/<tool>_alpha.html          freshened from v0.0.2 tag
  - website/releases/<tool>_beta.html           freshened from v0.0.2 tag

Tags created locally and pushed alongside this commit:
  archive-v0.0.2, transmittal-v0.0.2, classifier-v0.0.2,
  mdedit-v0.0.2, landing-v0.0.2

Bootstrap zips (install.zip, track-{alpha,beta,stable}.zip) regenerated
by the same build pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-04-29 13:16:32 -05:00
parent c95f07966d
commit 4d6e497510
24 changed files with 35805 additions and 829 deletions

Binary file not shown.

View file

@ -2095,7 +2095,7 @@ td[data-field="trackingNumber"] {
<div class="header-left">
<div class="header-title-group">
<span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">alpha · 2026-04-29 17:45:13 · cf4101b-dirty</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">alpha · 2026-04-29 · c95f079</span></span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data" style="font-size:1.1rem;"></button>

File diff suppressed because it is too large Load diff

View file

@ -1 +1 @@
archive_v0.0.1.html
archive_v0.0.2.html

File diff suppressed because it is too large Load diff

View file

@ -1358,7 +1358,7 @@ body.help-open .app-header {
<div class="header-left">
<div class="header-title-group">
<span class="app-header__title">ZDDC Classifier</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">alpha · 2026-04-29 17:45:13 · cf4101b-dirty</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">alpha · 2026-04-29 · c95f079</span></span>
</div>
<button id="selectDirectoryBtn" class="btn btn-primary">Select Directory</button>
<button id="refreshBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory">Refresh</button>

View file

@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZDDC Classifier</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+CiAgPHJlY3Qgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiByeD0iMTIiIGZpbGw9IiMxZTNhNWYiLz4KICA8ZyBmaWxsPSIjZmZmIj4KICAgIDxyZWN0IHg9IjE0IiB5PSIxOCIgd2lkdGg9IjM2IiBoZWlnaHQ9IjciLz4KICAgIDxwb2x5Z29uIHBvaW50cz0iNDMsMjUgNTAsMjUgMjEsNDMgMTQsNDMiLz4KICAgIDxyZWN0IHg9IjE0IiB5PSI0MyIgd2lkdGg9IjM2IiBoZWlnaHQ9IjciLz4KICA8L2c+Cjwvc3ZnPgo=">
<style>
/* ==========================================================================
ZDDC Shared Base — single source of truth for tokens and primitives
@ -1357,7 +1358,7 @@ body.help-open .app-header {
<div class="header-left">
<div class="header-title-group">
<span class="app-header__title">ZDDC Classifier</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">beta · 2026-04-28 · 67f794e</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">beta · 2026-04-29 · c95f079</span></span>
</div>
<button id="selectDirectoryBtn" class="btn btn-primary">Select Directory</button>
<button id="refreshBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory">Refresh</button>

View file

@ -1 +1 @@
classifier_v0.0.1.html
classifier_v0.0.2.html

File diff suppressed because it is too large Load diff

View file

@ -884,7 +884,7 @@ body {
<header class="app-header">
<div class="header-left">
<span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">alpha · 2026-04-29 17:45:13 · cf4101b-dirty</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">alpha · 2026-04-29 · c95f079</span></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>

File diff suppressed because it is too large Load diff

View file

@ -1 +1 @@
landing_v0.0.1.html
landing_v0.0.2.html

File diff suppressed because it is too large Load diff

View file

@ -1650,7 +1650,7 @@ body.help-open .app-header {
<div class="header-left">
<div class="header-title-group">
<span class="app-header__title">ZDDC Markdown</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">alpha · 2026-04-29 17:45:13 · cf4101b-dirty</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">alpha · 2026-04-29 · c95f079</span></span>
</div>
<button id="select-directory" class="btn btn-primary" title="Select a Directory">Select Directory</button>
</div>

View file

@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZDDC Markdown</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+CiAgPHJlY3Qgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiByeD0iMTIiIGZpbGw9IiMxZTNhNWYiLz4KICA8ZyBmaWxsPSIjZmZmIj4KICAgIDxyZWN0IHg9IjE0IiB5PSIxOCIgd2lkdGg9IjM2IiBoZWlnaHQ9IjciLz4KICAgIDxwb2x5Z29uIHBvaW50cz0iNDMsMjUgNTAsMjUgMjEsNDMgMTQsNDMiLz4KICAgIDxyZWN0IHg9IjE0IiB5PSI0MyIgd2lkdGg9IjM2IiBoZWlnaHQ9IjciLz4KICA8L2c+Cjwvc3ZnPgo=">
<!-- Toast UI Editor v3.2.2 -->
<style>
/*!
@ -797,6 +798,15 @@ body.help-open .app-header {
opacity: 1;
}
/* Always-visible action buttons (e.g. scratchpad download) */
.tree-actions--always { opacity: 1; }
.tree-btn:disabled,
.tree-btn.is-disabled {
opacity: 0.35;
cursor: not-allowed;
}
.tree-btn {
display: inline-flex;
align-items: center;
@ -944,77 +954,59 @@ body.help-open .app-header {
.bg-white { background-color: var(--bg) !important; }
.bg-gray-100 { background-color: var(--bg-secondary) !important; }
/* ── Front matter nav bar ──────────────────────────────────────────────────── */
.front-matter-nav {
border-bottom: 1px solid var(--border);
background-color: var(--bg-secondary);
}
.front-matter-nav__header {
/* ── Section headers (YAML front matter, TOC, etc.) ───────────────────────── */
/* Shared style for all collapsible/section headers inside the side pane —
keeps font, padding, weight identical to the file-tree pane header. */
.pane-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 1rem;
cursor: pointer;
transition: background-color 0.15s ease;
background-color: var(--bg-secondary);
color: var(--text);
border-bottom: 1px solid var(--border);
font-size: 0.9rem;
font-weight: 500;
user-select: none;
}
.front-matter-nav__header:hover {
.pane-section-header .toggle-icon {
font-size: 0.75rem;
color: var(--text-muted);
width: 0.75rem;
text-align: center;
}
/* ── Front matter section ──────────────────────────────────────────────────── */
.front-matter-nav {
display: flex;
flex-direction: column;
overflow: hidden;
flex-shrink: 0;
background-color: var(--bg);
}
.front-matter-header:hover {
background-color: var(--bg-hover);
}
.front-matter-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
padding: 0;
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
border-radius: 4px;
transition: transform 0.2s ease, color 0.15s ease;
}
.front-matter-toggle:hover {
background-color: var(--bg-secondary);
color: var(--text);
}
.front-matter-toggle svg {
width: 0.75rem;
height: 0.75rem;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Toggle arrow rotation for collapsed state */
.front-matter-nav.collapsed .front-matter-toggle .arrow-down {
transform: rotate(-90deg);
}
/* Front matter content area */
.front-matter-content {
overflow: hidden;
transition: max-height 0.25s ease, padding 0.25s ease, opacity 0.25s ease;
max-height: 500px;
padding: 0 1rem;
flex: 1;
overflow: auto;
min-height: 0;
}
/* When collapsed, hide content completely */
/* When collapsed, hide content; height shrinks to header */
.front-matter-nav.collapsed {
border-bottom: none;
height: auto !important;
flex-shrink: 0;
}
.front-matter-nav.collapsed .front-matter-content {
display: none;
}
/* Front matter textarea */
/* Front matter textarea fills the content area */
.front-matter-textarea {
color: var(--text);
background-color: var(--bg);
@ -1022,14 +1014,34 @@ body.help-open .app-header {
resize: none;
font-family: var(--font-mono);
font-size: 0.8rem;
white-space: pre; /* preserve yaml structure, enables horiz scroll */
overflow-x: auto;
overflow-y: hidden; /* height is set by JS to fit content exactly */
white-space: pre;
overflow: auto;
width: 100%;
height: 100%;
padding: 0.5rem 1rem;
box-sizing: border-box;
display: block;
}
.front-matter-textarea:focus {
outline: none;
}
/* ── Horizontal pane resizer (height split) ─────────────────────────────── */
.pane-resizer.horizontal {
height: 4px;
width: 100%;
cursor: row-resize;
background-color: var(--border);
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.pane-resizer.horizontal:hover,
.pane-resizer.horizontal.active {
background-color: var(--primary);
}
/* ── Hidden utility (for disabled buttons) ─────────────────────────────────── */
.hide { display: none; }
@ -1130,22 +1142,26 @@ body.help-open .app-header {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.toc-section {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.toc-container,
.toc-content {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
/* Header layout — font/padding/weight come from .pane-section-header. */
.toc-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border);
font-weight: bold;
}
.toc-depth-selector {
@ -1634,7 +1650,7 @@ body.help-open .app-header {
<div class="header-left">
<div class="header-title-group">
<span class="app-header__title">ZDDC Markdown</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">beta · 2026-04-28 · 67f794e</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">beta · 2026-04-29 · c95f079</span></span>
</div>
<button id="select-directory" class="btn btn-primary" title="Select a Directory">Select Directory</button>
</div>
@ -1665,11 +1681,9 @@ body.help-open .app-header {
<div class="pane-resizer bg-gray-200 dark:bg-gray-700 transition-colors relative z-10 w-1 cursor-col-resize hover:bg-blue-500" data-resizer-for="file-nav"></div>
<div class="pane content-pane flex-1 relative flex flex-col bg-white dark:bg-gray-900 overflow-hidden" id="main-content">
<div id="welcome-screen" class="welcome-screen flex flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400 text-center p-6">
<h2 class="mb-2 text-xl">Welcome to ZDDC Markdown</h2>
<p class="mb-4">All files are edited on your local computer.</p>
<p id="welcome-hint" class="text-sm">Click <strong>Scratchpad</strong> to start editing,<br>or <strong>Select Directory</strong> to work with files.</p>
<p id="welcome-firefox" class="text-sm text-amber-600 hidden mt-2">Your browser doesn't support the File System API.<br>Use <strong>Scratchpad</strong> to edit markdown and save via download.</p>
<div id="welcome-screen" class="welcome-screen hidden flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400 text-center p-6">
<p id="welcome-hint" class="text-sm">Click <strong>Scratchpad</strong> in the file list to start editing,<br>or <strong>Select Directory</strong> to work with files.</p>
<p id="welcome-firefox" class="text-sm text-amber-600 hidden mt-2">Your browser doesn't support the File System API.<br>Use <strong>Scratchpad</strong> to edit markdown and download as a file.</p>
</div>
<div id="content-container" class="content-container flex flex-col h-full hidden">
@ -2247,6 +2261,10 @@ let directoryHandle = null;
let fileTree = {};
let currentFileHandle = null;
// True when the page is served over HTTP(S) and the file tree is sourced
// from the server's JSON directory listing instead of the local FS API.
let serverSourceMode = false;
// Map to store editor instances for each file
// Key: file path, Value: { editor, container, tocContainer, etc. }
const editorInstances = new Map();
@ -2257,10 +2275,34 @@ let tocMaxDepth = 3;
// Scratchpad ID constant
const SCRATCHPAD_ID = '__scratchpad__';
// Default scratchpad markdown — shown the first time mdedit loads.
// Acts as both a welcome message and a starter pad for quick notes.
const SCRATCHPAD_WELCOME = [
'# Welcome to ZDDC Markdown',
'',
'All editing happens locally on your computer — nothing is uploaded.',
'',
'Use this **Scratchpad** for quick notes. Download it any time with the ⬇',
'button on the Scratchpad row in the file list.',
'',
'Click **Select Directory** above to open a folder of Markdown files,',
'or just start typing here.',
'',
].join('\n');
/**
* Utility functions
*/
/**
* HTML-escape a string for safe insertion into innerHTML.
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text == null ? '' : String(text);
return div.innerHTML;
}
/**
* Debounce function calls
* @param {Function} func - Function to debounce
@ -2898,8 +2940,8 @@ function openScratchpad() {
document.getElementById('welcome-screen').classList.add('hidden');
document.getElementById('content-container').classList.remove('hidden');
// Initialize editor with no file handle
initializeEditor('', true, SCRATCHPAD_ID, 'Scratchpad', null, null);
// Initialize editor with the welcome text seeded as the starting content.
initializeEditor(SCRATCHPAD_WELCOME, true, SCRATCHPAD_ID, 'Scratchpad', null, null);
// Mark as scratchpad
const instance = editorInstances.get(SCRATCHPAD_ID);
@ -2907,9 +2949,63 @@ function openScratchpad() {
instance.isScratchpad = true;
}
// Reflect non-empty starting content on the scratchpad row's download button.
updateScratchpadDownloadState();
if (DEBUG) console.log('Opened scratchpad');
}
/**
* Enable/disable the scratchpad-row download button based on whether the
* scratchpad currently holds any content. Idempotent — safe to call from
* editor change listeners.
*/
function updateScratchpadDownloadState() {
const btn = document.getElementById('scratchpad-download-btn');
if (!btn) return;
const instance = editorInstances.get(SCRATCHPAD_ID);
let hasContent = false;
if (instance && instance.editor) {
try {
hasContent = (instance.editor.getMarkdown() || '').trim().length > 0;
} catch (_) { /* editor may not be ready yet */ }
}
btn.disabled = !hasContent;
btn.classList.toggle('is-disabled', !hasContent);
}
/**
* Trigger a browser download of the current scratchpad markdown.
* No-op if the scratchpad has no content.
*/
function downloadScratchpad() {
const instance = editorInstances.get(SCRATCHPAD_ID);
if (!instance || !instance.editor) return;
let content = '';
try { content = instance.editor.getMarkdown() || ''; } catch (_) { return; }
// Pull front matter from the textarea if any
if (instance.frontMatterTextarea) {
const fmText = instance.frontMatterTextarea.value.trim();
if (fmText) content = `---\n${fmText}\n---\n${content}`;
}
if (!content.trim()) return;
// Suggest a filename derived from the first H1 if present
let suggested = 'scratchpad.md';
const h1 = content.match(/^#\s+(.+)$/m);
if (h1) {
const slug = h1[1].trim().toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.substring(0, 60);
if (slug) suggested = `${slug}.md`;
}
saveFileAs(content, suggested);
}
/**
* Save file using Save As dialog (for scratchpads or new saves)
* @param {string} content - Content to save
@ -3138,6 +3234,17 @@ async function saveFile(filePath) {
? stringifyFrontMatter(markdownContent, frontMatterData)
: markdownContent;
// Server-mode files are read-only: fall back to a Save-As download.
if (typeof fileHandle.createWritable !== 'function') {
const downloadName = (fileHandle.name || filePath.split('/').pop() || 'untitled.md');
await saveFileAs(finalContent, downloadName);
editorInstance.isDirty = false;
updateFileDirtyStatus(filePath, false);
updateUnsavedCount();
if (editorInstance.saveButton) editorInstance.saveButton.disabled = true;
return true;
}
// Write to file
const writable = await fileHandle.createWritable();
await writable.write(finalContent);
@ -3332,6 +3439,10 @@ async function applyAfterSaveHooks(frontMatter, markdownContent, fileHandle) {
* Refresh directory from disk without losing unsaved work
*/
async function refreshDirectory() {
if (serverSourceMode) {
await loadServerDirectory();
return;
}
if (!directoryHandle) {
if (DEBUG) console.log('No directory selected, cannot refresh');
return;
@ -3366,6 +3477,144 @@ async function refreshDirectory() {
});
}
/**
* Build a synthetic, read-only "file handle" backed by a URL.
* Implements `getFile()` so the rest of the app (which only needs to read)
* works without changes. Lacks `createWritable()` — saveFile detects this
* and routes to a Save-As download.
*/
function createServerFileHandle(name, url) {
let cached = null;
return {
kind: 'file',
name,
_serverUrl: url,
_readOnly: true,
async getFile() {
if (cached) return cached;
const resp = await fetch(url, { cache: 'no-cache' });
if (!resp.ok) throw new Error(`HTTP ${resp.status} fetching ${url}`);
const lastMod = resp.headers.get('Last-Modified');
const lastModified = lastMod ? Date.parse(lastMod) : Date.now();
const blob = await resp.blob();
cached = new File([blob], name, { type: blob.type, lastModified });
return cached;
},
};
}
/**
* Build a synthetic directory handle (read-only) backed by a server URL.
* Returned for nested entries so existing code paths that probe for `.handle`
* still work; not currently used for traversal.
*/
function createServerDirectoryHandle(name, url) {
return {
kind: 'directory',
name,
_serverUrl: url,
_readOnly: true,
};
}
/**
* Recursively fetch the JSON directory listing for `dirUrl` and populate
* `parentNode.entries` with synthetic handles. Returns folder/file counts.
* Uses the same Caddy/zddc-server JSON shape archive's source.js consumes.
*/
async function readServerDirectory(dirUrl, parentNode, depth) {
if (depth > 10) return { folderCount: 0, fileCount: 0 };
let items;
try {
const resp = await fetch(dirUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
items = await resp.json();
if (!Array.isArray(items)) throw new Error('Expected JSON array');
} catch (err) {
if (DEBUG) console.warn(`Server listing failed for ${dirUrl}:`, err);
return { folderCount: 0, fileCount: 0 };
}
const stats = { folderCount: 0, fileCount: 0 };
const subdirPromises = [];
for (const item of items) {
const rawName = item.name.endsWith('/') ? item.name.slice(0, -1) : item.name;
if (rawName.startsWith('.') || rawName.startsWith('_')) continue;
const base = dirUrl.endsWith('/') ? dirUrl : dirUrl + '/';
const childUrl = base + encodeURIComponent(rawName) + (item.is_dir ? '/' : '');
if (item.is_dir) {
const dirNode = {
name: rawName,
type: 'directory',
handle: createServerDirectoryHandle(rawName, childUrl),
entries: {},
};
parentNode.entries[rawName] = dirNode;
stats.folderCount++;
subdirPromises.push(
readServerDirectory(childUrl, dirNode, depth + 1).then((s) => {
stats.folderCount += s.folderCount;
stats.fileCount += s.fileCount;
})
);
} else {
parentNode.entries[rawName] = {
name: rawName,
type: 'file',
handle: createServerFileHandle(rawName, childUrl),
};
stats.fileCount++;
}
}
await Promise.all(subdirPromises);
return stats;
}
/**
* Detect HTTP context, fetch the directory the page lives under, and render
* the resulting subtree in the file pane. Idempotent — safe to re-call.
*/
async function loadServerDirectory() {
if (!(location.protocol === 'http:' || location.protocol === 'https:')) return;
serverSourceMode = true;
let href = window.location.href.split('?')[0].split('#')[0];
const lastSlash = href.lastIndexOf('/');
const baseUrl = (lastSlash >= 0) ? href.substring(0, lastSlash + 1) : href + '/';
const rootName = (() => {
const path = baseUrl.replace(/\/$/, '');
const seg = path.substring(path.lastIndexOf('/') + 1);
return seg || baseUrl;
})();
fileTree = {
name: rootName,
type: 'directory',
handle: createServerDirectoryHandle(rootName, baseUrl),
entries: {},
};
// Surface refresh, hide write-only controls
const refreshBtn = document.getElementById('refresh-directory');
if (refreshBtn) refreshBtn.classList.remove('hidden');
const newFileRootBtn = document.getElementById('new-file-root');
if (newFileRootBtn) newFileRootBtn.classList.add('hidden');
const selectDirBtn = document.getElementById('select-directory');
if (selectDirBtn) {
selectDirBtn.classList.add('hidden');
}
const stats = await readServerDirectory(baseUrl, fileTree, 0);
renderFileTree();
updateStatusCounts(stats.folderCount, stats.fileCount);
}
/**
* Start monitoring files for external changes
*/
@ -3375,6 +3624,7 @@ function startFileChangeMonitoring() {
try {
const fileHandle = editorInstance.fileHandle;
if (!fileHandle) continue;
if (fileHandle._readOnly) continue;
const file = await fileHandle.getFile();
const currentLastModified = file.lastModified;
@ -3438,6 +3688,9 @@ function createActionButtons(filePath, type) {
const actionsDiv = document.createElement('div');
actionsDiv.className = 'tree-actions';
// Server mode is read-only: no rename, delete, or new-file actions.
if (serverSourceMode) return actionsDiv;
if (type === 'directory') {
// Directory: + (new file) + ✕ (delete)
const newFileBtn = document.createElement('button');
@ -3495,22 +3748,46 @@ function renderFileTree() {
// Always show scratchpad at top
const scratchpadElement = document.createElement('div');
scratchpadElement.className = 'file-item px-2 py-1 cursor-pointer rounded whitespace-nowrap overflow-hidden hover:bg-gray-100 dark:hover:bg-gray-800 border-b border-gray-200 dark:border-gray-700 mb-2';
scratchpadElement.className = 'file-item tree-row px-2 py-1 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-gray-800 border-b border-gray-200 dark:border-gray-700 mb-2';
scratchpadElement.dataset.type = 'file';
scratchpadElement.dataset.path = SCRATCHPAD_ID;
scratchpadElement.dataset.name = 'Scratchpad';
scratchpadElement.innerHTML = '<div class="filename-main">📝 Scratchpad</div><div class="filename-secondary">Quick editing (no file)</div>';
const scratchLabel = document.createElement('span');
scratchLabel.className = 'tree-row__label';
scratchLabel.innerHTML = '<div class="filename-main">📝 Scratchpad</div><div class="filename-secondary">Quick notes — no directory needed</div>';
scratchpadElement.appendChild(scratchLabel);
const scratchActions = document.createElement('div');
scratchActions.className = 'tree-actions tree-actions--always';
const scratchDownloadBtn = document.createElement('button');
scratchDownloadBtn.id = 'scratchpad-download-btn';
scratchDownloadBtn.className = 'tree-btn';
scratchDownloadBtn.title = 'Download scratchpad as a Markdown file';
scratchDownloadBtn.setAttribute('aria-label', 'Download scratchpad');
scratchDownloadBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><path d="M7 10l5 5 5-5"/><path d="M5 21h14"/></svg>';
scratchDownloadBtn.disabled = true;
scratchDownloadBtn.classList.add('is-disabled');
scratchDownloadBtn.onclick = (e) => {
e.stopPropagation();
if (scratchDownloadBtn.disabled) return;
downloadScratchpad();
};
scratchActions.appendChild(scratchDownloadBtn);
scratchpadElement.appendChild(scratchActions);
scratchpadElement.addEventListener('click', (event) => {
event.stopPropagation();
openScratchpad();
// Update active state
document.querySelectorAll('.file-item').forEach(el => el.classList.remove('active-file'));
scratchpadElement.classList.add('active-file');
updateScratchpadDownloadState();
});
fileTreeElement.appendChild(scratchpadElement);
// Sync button state with current scratchpad content (re-renders preserve it)
updateScratchpadDownloadState();
function createFileTreeHTML(directory, parentElement, path = '') {
if (!directory || !directory.entries) return;
@ -3540,7 +3817,14 @@ function renderFileTree() {
dirIcon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>';
const dirName = document.createElement('span');
dirName.textContent = `📁 ${name}`;
dirName.className = 'tree-row__name';
const parsedFolder = zddc.parseFolder(name);
if (parsedFolder && parsedFolder.valid) {
const meta = `${parsedFolder.trackingNumber} (${parsedFolder.status}) — ${parsedFolder.date}`;
dirName.innerHTML = `<div class="filename-main">📁 ${escapeHtml(parsedFolder.title)}</div><div class="filename-secondary">${escapeHtml(meta)}</div>`;
} else {
dirName.textContent = `📁 ${name}`;
}
const dirLabel = document.createElement('span');
dirLabel.className = 'tree-row__label';
@ -3582,18 +3866,16 @@ function renderFileTree() {
let fileNameDisplay;
const parsed = zddc.parseFilename(name);
if (parsed && parsed.valid) {
// Strip extension from title for display (it's already in the icon)
const titleDisplay = parsed.title;
const metaDisplay = `${parsed.trackingNumber}_${parsed.revision} (${parsed.status})`;
const titleDisplay = escapeHtml(parsed.title);
const metaDisplay = escapeHtml(`${parsed.trackingNumber}_${parsed.revision} (${parsed.status})`);
fileNameDisplay = `<div class="filename-main">${fileIcon} ${titleDisplay}</div><div class="filename-secondary">${metaDisplay}</div>`;
} else if (name.includes(' - ')) {
// Fallback: simple split for files with ' - ' but not fully ZDDC-compliant
const dashIdx = name.lastIndexOf(' - ');
const secondary = name.substring(0, dashIdx);
const primary = name.substring(dashIdx + 3).replace(/\.[^.]+$/, '');
const secondary = escapeHtml(name.substring(0, dashIdx));
const primary = escapeHtml(name.substring(dashIdx + 3).replace(/\.[^.]+$/, ''));
fileNameDisplay = `<div class="filename-main">${fileIcon} ${primary}</div><div class="filename-secondary">${secondary}</div>`;
} else {
fileNameDisplay = `<span>${fileIcon} ${name}</span>`;
fileNameDisplay = `<span>${fileIcon} ${escapeHtml(name)}</span>`;
}
const fileLabel = document.createElement('span');
@ -4200,21 +4482,23 @@ function initializeEditor(content, isMarkdown = true, filePath = '', fileName =
// Determine if this is a scratchpad (no file handle)
const isScratchpad = !fileHandle;
const isReadOnlyHandle = !!(fileHandle && fileHandle._readOnly);
// Save button (or Save As for scratchpads)
// Save button (or Save As for scratchpads / read-only server files)
const saveButton = document.createElement('button');
saveButton.className = 'btn btn-primary btn-sm';
saveButton.textContent = isScratchpad ? 'Save As...' : 'Save File';
saveButton.disabled = !isScratchpad; // Scratchpads can always save
saveButton.textContent = (isScratchpad || isReadOnlyHandle) ? 'Save As...' : 'Save File';
saveButton.disabled = !isScratchpad; // Scratchpads can always save; read-only enables on edit
buttonContainer.appendChild(saveButton);
// Reload button (only for files, not scratchpads)
// Reload button (only for files, not scratchpads) — icon to match file-tree refresh
let reloadButton = null;
if (!isScratchpad) {
reloadButton = document.createElement('button');
reloadButton.className = 'btn btn-secondary btn-sm';
reloadButton.textContent = 'Reload from Disk';
reloadButton.title = 'Reload file from disk (discards unsaved changes)';
reloadButton.textContent = '↻';
reloadButton.title = 'Reload from disk (discards unsaved changes)';
reloadButton.setAttribute('aria-label', 'Reload from disk');
buttonContainer.appendChild(reloadButton);
}
@ -4239,16 +4523,17 @@ function initializeEditor(content, isMarkdown = true, filePath = '', fileName =
tocPane.style.width = '325px';
tocPane.style.minWidth = '150px';
// Front matter nav bar (collapsible)
// Front matter section (collapsible, height-resizable)
const frontMatterNav = document.createElement('div');
frontMatterNav.className = 'front-matter-nav border-b border-gray-200 dark:border-gray-700';
frontMatterNav.className = 'front-matter-nav';
frontMatterNav.style.height = '180px';
const frontMatterHeader = document.createElement('div');
frontMatterHeader.className = 'front-matter-header px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700 cursor-pointer flex items-center gap-2';
frontMatterHeader.className = 'front-matter-header pane-section-header cursor-pointer';
const toggleIcon = document.createElement('span');
toggleIcon.textContent = '▼';
toggleIcon.className = 'toggle-icon text-sm';
toggleIcon.className = 'toggle-icon';
frontMatterHeader.appendChild(toggleIcon);
const headerText = document.createElement('span');
@ -4257,11 +4542,13 @@ function initializeEditor(content, isMarkdown = true, filePath = '', fileName =
frontMatterNav.appendChild(frontMatterHeader);
const frontMatterContent = document.createElement('div');
frontMatterContent.className = 'front-matter-content';
frontMatterTextarea = document.createElement('textarea');
frontMatterTextarea.className = 'front-matter-textarea w-full px-4 py-2 text-sm focus:outline-none resize-none overflow-x-auto';
frontMatterTextarea.className = 'front-matter-textarea';
frontMatterTextarea.placeholder = 'title: Document Title\ndate: 2024-01-01\ntags: [example]';
// Set front matter content
if (frontMatterData && Object.keys(frontMatterData).length > 0) {
try {
let yamlText = '';
@ -4279,11 +4566,21 @@ function initializeEditor(content, isMarkdown = true, filePath = '', fileName =
}
}
frontMatterNav.appendChild(frontMatterTextarea);
frontMatterContent.appendChild(frontMatterTextarea);
frontMatterNav.appendChild(frontMatterContent);
tocPane.appendChild(frontMatterNav);
// Horizontal resizer between front-matter and TOC
const fmTocResizer = document.createElement('div');
fmTocResizer.className = 'pane-resizer horizontal';
tocPane.appendChild(fmTocResizer);
// TOC section
const tocSection = document.createElement('div');
tocSection.className = 'toc-section';
const tocHeader = document.createElement('div');
tocHeader.className = 'toc-header px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
tocHeader.className = 'toc-header pane-section-header';
const tocTitle = document.createElement('span');
tocTitle.textContent = 'Table of Contents';
@ -4301,42 +4598,41 @@ function initializeEditor(content, isMarkdown = true, filePath = '', fileName =
`;
tocHeader.appendChild(tocDepthSelector);
tocPane.appendChild(tocHeader);
tocSection.appendChild(tocHeader);
tocContainer = document.createElement('div');
tocContainer.className = 'toc-container toc-content p-4 h-full overflow-auto';
tocPane.appendChild(tocContainer);
tocContainer.className = 'toc-container toc-content';
tocSection.appendChild(tocContainer);
// Set up TOC container overflow when front matter is toggled
tocPane.appendChild(tocSection);
// Toggle: collapsed only shows the header. Hide content + horizontal resizer.
let fmIsCollapsed = false;
frontMatterHeader.addEventListener('click', () => {
fmIsCollapsed = !fmIsCollapsed;
frontMatterNav.classList.toggle('collapsed', fmIsCollapsed);
toggleIcon.textContent = fmIsCollapsed ? '▶' : '▼';
fmTocResizer.style.display = fmIsCollapsed ? 'none' : '';
if (fmIsCollapsed) {
frontMatterNav.style.height = '';
} else {
frontMatterNav.style.height = '180px';
}
});
// Auto-size textarea: no vertical scroll, horizontal scroll for long lines
frontMatterTextarea.style.overflowY = 'hidden';
frontMatterTextarea.style.overflowX = 'auto';
const autoResizeFm = () => {
frontMatterTextarea.style.height = 'auto';
frontMatterTextarea.style.height = frontMatterTextarea.scrollHeight + 'px';
};
frontMatterTextarea.addEventListener('input', autoResizeFm);
// Defer initial resize until element is in the DOM and has layout
requestAnimationFrame(() => requestAnimationFrame(autoResizeFm));
editorArea.appendChild(tocPane);
// TOC resizer
// Vertical resizer between toc-pane and editor (placed inside editorArea)
const tocResizer = document.createElement('div');
tocResizer.className = 'pane-resizer bg-gray-200 dark:bg-gray-700 transition-colors relative z-10 w-1 cursor-col-resize hover:bg-blue-500';
tocResizer.setAttribute('data-resizer-for', 'toc-pane');
contentArea.appendChild(tocResizer);
editorArea.appendChild(tocResizer);
makeResizable(tocResizer, tocPane);
// TOC depth selector event
// Make the front-matter / TOC split height-adjustable
makeHeightResizable(fmTocResizer, frontMatterNav, tocPane);
tocDepthSelector.addEventListener('change', function () {
const depth = parseInt(this.value);
if (window.updateToc && editorInstance) {
@ -4410,6 +4706,8 @@ function initializeEditor(content, isMarkdown = true, filePath = '', fileName =
updateUnsavedCount();
}
saveButton.disabled = false;
if (filePath === SCRATCHPAD_ID) updateScratchpadDownloadState();
});
// Scroll listener for TOC highlighting
@ -4841,6 +5139,50 @@ function makeResizable(resizer, pane) {
resizer.addEventListener('mousedown', mouseDownHandler);
}
/**
* Make a horizontal split height-adjustable: the resizer drags the height
* of `topPane` while it remains a sibling of the bottom section inside `container`.
*
* @param {HTMLElement} resizer - The horizontal resizer between the panes
* @param {HTMLElement} topPane - The pane whose height is set
* @param {HTMLElement} container - The flex column containing both panes
*/
function makeHeightResizable(resizer, topPane, container) {
let y = 0;
let topHeight = 0;
let containerHeight = 0;
const mouseDownHandler = (e) => {
y = e.clientY;
topHeight = topPane.offsetHeight;
containerHeight = container.offsetHeight;
document.addEventListener('mousemove', mouseMoveHandler);
document.addEventListener('mouseup', mouseUpHandler);
resizer.classList.add('active');
document.body.style.cursor = 'row-resize';
document.body.style.userSelect = 'none';
};
const mouseMoveHandler = (e) => {
const dy = e.clientY - y;
// Reserve at least 80px for the bottom pane (TOC); cap top at containerHeight - 80.
const minTop = 60;
const maxTop = Math.max(minTop, containerHeight - 100);
const newHeight = Math.max(minTop, Math.min(maxTop, topHeight + dy));
topPane.style.height = `${newHeight}px`;
};
const mouseUpHandler = () => {
document.removeEventListener('mousemove', mouseMoveHandler);
document.removeEventListener('mouseup', mouseUpHandler);
resizer.classList.remove('active');
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
resizer.addEventListener('mousedown', mouseDownHandler);
}
/**
* Initialize the file navigation pane resizer
*/
@ -4988,7 +5330,17 @@ document.addEventListener('DOMContentLoaded', function () {
// Show scratchpad in file tree on startup
renderFileTree();
// Always start with scratchpad selected and loaded
openScratchpad();
const scratchpadEl = document.querySelector(`.file-item[data-path="${SCRATCHPAD_ID}"]`);
if (scratchpadEl) scratchpadEl.classList.add('active-file');
// In server (HTTP) mode, fetch and render the current directory subtree.
if (location.protocol === 'http:' || location.protocol === 'https:') {
loadServerDirectory().catch((err) => {
if (DEBUG) console.warn('Server directory load failed:', err);
});
}
});
/**

View file

@ -1 +1 @@
mdedit_v0.0.1.html
mdedit_v0.0.2.html

File diff suppressed because one or more lines are too long

View file

@ -2192,7 +2192,7 @@ dialog.modal--narrow {
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<div class="header-title-group">
<span class="app-header__title">ZDDC Transmittal</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">alpha · 2026-04-29 17:45:12 · cf4101b-dirty</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">alpha · 2026-04-29 · c95f079</span></span>
</div>
<div class="app-header__spacer"></div>
<div class="app-header__icons">

View file

@ -2179,6 +2179,7 @@ dialog.modal--narrow {
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZDDC Transmittal</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+CiAgPHJlY3Qgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiByeD0iMTIiIGZpbGw9IiMxZTNhNWYiLz4KICA8ZyBmaWxsPSIjZmZmIj4KICAgIDxyZWN0IHg9IjE0IiB5PSIxOCIgd2lkdGg9IjM2IiBoZWlnaHQ9IjciLz4KICAgIDxwb2x5Z29uIHBvaW50cz0iNDMsMjUgNTAsMjUgMjEsNDMgMTQsNDMiLz4KICAgIDxyZWN0IHg9IjE0IiB5PSI0MyIgd2lkdGg9IjM2IiBoZWlnaHQ9IjciLz4KICA8L2c+Cjwvc3ZnPgo=">
</head>
<body class="font-sans text-gray-900">
@ -2191,7 +2192,7 @@ dialog.modal--narrow {
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<div class="header-title-group">
<span class="app-header__title">ZDDC Transmittal</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">beta · 2026-04-28 · 67f794e</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">beta · 2026-04-29 · c95f079</span></span>
</div>
<div class="app-header__spacer"></div>
<div class="app-header__icons">

View file

@ -1 +1 @@
transmittal_v0.0.1.html
transmittal_v0.0.2.html

File diff suppressed because it is too large Load diff

Binary file not shown.

Binary file not shown.

Binary file not shown.