diff --git a/zddc/internal/apps/embedded/archive.html b/zddc/internal/apps/embedded/archive.html index 95d9d15..cf153a5 100644 --- a/zddc/internal/apps/embedded/archive.html +++ b/zddc/internal/apps/embedded/archive.html @@ -2012,6 +2012,15 @@ input[type="checkbox"] { cursor: pointer; } +/* Folder-name hint after the friendly title — shown only when the + project's .zddc declares a different `title:`. Muted so the title + reads first; the folder name is reference info. */ +.preset-project-folder { + color: var(--text-muted); + font-size: 0.78rem; + font-family: var(--font-mono); +} + .preset-footer-actions { padding: 0.5rem 0.75rem; border-top: 1px solid var(--border); @@ -2461,7 +2470,7 @@ td[data-field="trackingNumber"] {
ZDDC Archive - v0.0.17-beta · 2026-05-11 · lens-mesa-chalk + v0.0.17-beta · 2026-05-12 · candle-mast-pearl
@@ -4476,6 +4485,276 @@ X.B(E,Y);return E}return J}()) }; })(typeof window !== 'undefined' ? window : globalThis); +// shared/zip-source.js — present the contents of a .zip as a tree of +// File System Access API handles, so tools written against +// FileSystemDirectoryHandle / FileSystemFileHandle (archive's scanner, +// browse's tree) can navigate into a zip with no special-casing. +// +// Mirrors shared/zddc-source.js's HttpDirectoryHandle / HttpFileHandle +// pair, but read-only and backed by a JSZip instance instead of HTTP. +// Online tools that talk to zddc-server should use the server's +// "<…>.zip/" virtual-directory route instead (no whole-zip download); +// this adapter is for the offline (file://) path where the zip bytes +// are already in hand, and for zips nested inside other zips. +// +// Requires window.JSZip (vendored at shared/vendor/jszip.min.js and +// concatenated by the tool's build.sh) — referenced lazily, only from +// fromBlob / fromFileHandle, so this module is harmless to include in +// a build that doesn't bundle JSZip (it just won't be usable there). +(function () { + 'use strict'; + + if (!window.zddc) window.zddc = {}; + + // Minimal extension → media-type map so getFile() returns a Blob + // with a usable `type` (iframes/img tags need it to render inline). + var MIME = { + pdf: 'application/pdf', + html: 'text/html', htm: 'text/html', + txt: 'text/plain', md: 'text/markdown', csv: 'text/csv', + json: 'application/json', xml: 'application/xml', + yaml: 'application/yaml', yml: 'application/yaml', + png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', + gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', + bmp: 'image/bmp', tif: 'image/tiff', tiff: 'image/tiff', + zip: 'application/zip', + doc: 'application/msword', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + xls: 'application/vnd.ms-excel', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }; + function mimeFor(name) { + var dot = name.lastIndexOf('.'); + if (dot < 0) return ''; + return MIME[name.slice(dot + 1).toLowerCase()] || ''; + } + + function baseName(p) { + var s = p.replace(/\/+$/, ''); + var i = s.lastIndexOf('/'); + return i >= 0 ? s.slice(i + 1) : s; + } + + // Reject zip entry names that aren't safe to surface: absolute + // paths, backslash separators, or anything that escapes via "..". + // Returns the cleaned forward-slash path (trailing "/" preserved + // for directory entries) or null. + function cleanEntryName(name) { + if (!name || name.indexOf('\\') !== -1 || name[0] === '/') return null; + var isDir = name.endsWith('/'); + var parts = []; + var segs = name.split('/'); + for (var i = 0; i < segs.length; i++) { + var s = segs[i]; + if (s === '' || s === '.') continue; + if (s === '..') return null; + parts.push(s); + } + if (parts.length === 0) return null; + return parts.join('/') + (isDir ? '/' : ''); + } + + // ----------------------------------------------------------------- + // ZipFileHandle — FileSystemFileHandle polyfill (read-only) + // ----------------------------------------------------------------- + + function ZipFileHandle(jszip, fullPath, size, modTime) { + this.kind = 'file'; + this.name = baseName(fullPath); + this._zip = jszip; + this._path = fullPath; // path within the zip (no trailing /) + this._size = size || 0; + this._modTime = modTime || null; + } + ZipFileHandle.prototype.getFile = async function () { + var entry = this._zip.file(this._path); + if (!entry) { + var err = new Error('NotFoundError: ' + this._path); + err.name = 'NotFoundError'; + throw err; + } + var buf = await entry.async('arraybuffer'); + return new File([buf], this.name, { + type: mimeFor(this.name), + lastModified: this._modTime ? this._modTime.getTime() : Date.now() + }); + }; + ZipFileHandle.prototype.createWritable = async function () { + var err = new Error('Zip archives are read-only'); + err.name = 'NoModificationAllowedError'; + throw err; + }; + ZipFileHandle.prototype.queryPermission = async function () { return 'granted'; }; + ZipFileHandle.prototype.requestPermission = async function () { return 'granted'; }; + ZipFileHandle.prototype.isZipEntry = true; + + // ----------------------------------------------------------------- + // ZipDirectoryHandle — FileSystemDirectoryHandle polyfill (read-only) + // ----------------------------------------------------------------- + + // jszip: the JSZip instance. prefix: "" for the zip root, else + // "/". name: the label for this level. + function ZipDirectoryHandle(jszip, prefix, name) { + this.kind = 'directory'; + this._zip = jszip; + this._prefix = prefix || ''; + this.name = name != null ? name : (this._prefix ? baseName(this._prefix) : ''); + } + + // Walk the flat entry list once, returning a Map of immediate child + // name → { isDir, size, modTime, fullPath }. Synthesises directory + // children that have no explicit "/" entry. + ZipDirectoryHandle.prototype._children = function () { + var prefix = this._prefix; + var seen = new Map(); + var zip = this._zip; + Object.keys(zip.files).forEach(function (rawName) { + var clean = cleanEntryName(rawName); + if (clean === null) return; + var entryIsDir = clean.endsWith('/'); + var bare = entryIsDir ? clean.slice(0, -1) : clean; + if (prefix) { + if (bare === prefix.slice(0, -1)) return; // the prefix dir itself + if (bare.indexOf(prefix) !== 0) return; + } + var rest = prefix ? bare.slice(prefix.length) : bare; + if (rest === '') return; + var slash = rest.indexOf('/'); + var seg = slash === -1 ? rest : rest.slice(0, slash); + var nested = slash !== -1; + var existing = seen.get(seg); + if (nested) { + if (!existing) { + seen.set(seg, { isDir: true, size: 0, modTime: null, fullPath: prefix + seg }); + } else { + existing.isDir = true; + } + } else if (entryIsDir) { + var entry = zip.files[rawName]; + seen.set(seg, { + isDir: true, size: 0, + modTime: entry && entry.date instanceof Date ? entry.date : null, + fullPath: prefix + seg + }); + } else { + if (!existing || existing.isDir !== false) { + var fent = zip.files[rawName]; + seen.set(seg, { + isDir: false, + size: (fent && fent._data && fent._data.uncompressedSize) || 0, + modTime: fent && fent.date instanceof Date ? fent.date : null, + fullPath: prefix + seg + }); + } + } + }); + return seen; + }; + + ZipDirectoryHandle.prototype._handleFor = function (seg, info) { + if (info.isDir) { + return new ZipDirectoryHandle(this._zip, this._prefix + seg + '/', seg); + } + return new ZipFileHandle(this._zip, info.fullPath, info.size, info.modTime); + }; + + ZipDirectoryHandle.prototype.values = function () { + var self = this; + return (async function* () { + var children = self._children(); + var names = Array.from(children.keys()).sort(); + for (var i = 0; i < names.length; i++) { + yield self._handleFor(names[i], children.get(names[i])); + } + })(); + }; + ZipDirectoryHandle.prototype.entries = function () { + var iter = this.values(); + return (async function* () { + for (;;) { + var step = await iter.next(); + if (step.done) return; + yield [step.value.name, step.value]; + } + })(); + }; + ZipDirectoryHandle.prototype.keys = function () { + var iter = this.values(); + return (async function* () { + for (;;) { + var step = await iter.next(); + if (step.done) return; + yield step.value.name; + } + })(); + }; + ZipDirectoryHandle.prototype.getDirectoryHandle = async function (name) { + var children = this._children(); + var info = children.get(name); + if (!info || !info.isDir) { + var err = new Error('NotFoundError: ' + name); + err.name = 'NotFoundError'; + throw err; + } + return this._handleFor(name, info); + }; + ZipDirectoryHandle.prototype.getFileHandle = async function (name) { + var children = this._children(); + var info = children.get(name); + if (!info || info.isDir) { + var err = new Error('NotFoundError: ' + name); + err.name = 'NotFoundError'; + throw err; + } + return this._handleFor(name, info); + }; + ZipDirectoryHandle.prototype.queryPermission = async function () { return 'granted'; }; + ZipDirectoryHandle.prototype.requestPermission = async function () { return 'granted'; }; + ZipDirectoryHandle.prototype.isZipEntry = true; + + // ----------------------------------------------------------------- + // Constructors + // ----------------------------------------------------------------- + + function requireJSZip() { + if (!window.JSZip) { + throw new Error('JSZip is not available — this build does not bundle it'); + } + return window.JSZip; + } + + // Build a ZipDirectoryHandle rooted at the top level of `blob` + // (an ArrayBuffer, Blob, Uint8Array, or anything JSZip.loadAsync + // accepts). `name` labels the root level (default: empty). + async function fromBlob(blob, name) { + var JSZip = requireJSZip(); + var src = blob; + if (blob && typeof blob.arrayBuffer === 'function') { + src = await blob.arrayBuffer(); + } + var zip = await JSZip.loadAsync(src); + return new ZipDirectoryHandle(zip, '', name || ''); + } + + // Build a ZipDirectoryHandle from a FileSystemFileHandle (or this + // adapter's own ZipFileHandle — so a zip nested inside a zip works + // by recursion). The handle's basename labels the root level. + async function fromFileHandle(fileHandle) { + var f = await fileHandle.getFile(); + return fromBlob(f, fileHandle.name || (f && f.name) || ''); + } + + window.zddc.zip = { + ZipDirectoryHandle: ZipDirectoryHandle, + ZipFileHandle: ZipFileHandle, + fromBlob: fromBlob, + fromFileHandle: fromFileHandle, + // True for handles produced by this adapter (vs. real FS Access + // handles or the HTTP polyfill). + isZipHandle: function (h) { return !!(h && h.isZipEntry === true); } + }; +})(); + /** * ZDDC shared theme toggle — light / dark / auto. * Persists choice to localStorage under 'zddc-theme'. @@ -4638,28 +4917,28 @@ X.B(E,Y);return E}return J}()) } })(); -// shared/nav.js — lateral navigation strip across the four canonical -// project stages (archive · working · staging · reviewing). Renders -// only when: -// 1. location.protocol is http: or https: (online — file:// has no -// project structure to navigate within), AND -// 2. a project segment can be detected from location.pathname (the -// first path segment, when it isn't a tool HTML file). +// shared/nav.js — lateral navigation strip across the project's +// cascade-declared stages. Mounted as a sibling of
on DOMContentLoaded, hydrated from the project root's +// directory listing. // -// The strip is inserted as a sibling of
-// on DOMContentLoaded — no template changes required. Each tool just -// needs ../shared/nav.{js,css} in its build.sh. +// Stage discovery is cascade-driven (Phase 4c): fetch the project +// root's JSON listing, filter to entries with `declared: true` +// (server stamps these from the .zddc cascade's paths: tree), and +// render in canonical workflow order with display_name overrides +// honored. An operator who edits the project's .zddc paths: to add +// a new declared child sees it in the strip; one who removes a +// canonical entry sees the strip drop it. // -// Stage URLs follow the canonical workflow folders documented at -// zddc.varasys.io/reference.html#transmittal-workflow: -// archive → /archive.html (archive tool, project-root mode) -// working → /working/ (directory listing → mdedit auto-serves) -// staging → /staging/ (directory listing → transmittal auto-serves) -// reviewing → /reviewing/ (directory listing) +// When the fetch fails (offline / no-server / file://), the strip +// falls back to the hardcoded four-stage list so existing +// deployments don't lose chrome. Hardcoded labels in this file are +// the LAST resort — the cascade is the source of truth in normal +// operation. // -// If a deployment doesn't have one of these folders the link will 404 — -// the strip is convention-driven, not probed. Operators on non-standard -// layouts can override by setting window.zddc.nav.disabled = true before +// Stage URLs follow the slash/no-slash convention: no slash opens +// the stage's default tool. Operators on non-standard layouts can +// override by setting window.zddc.nav.disabled = true before // DOMContentLoaded. (function () { 'use strict'; @@ -4667,33 +4946,37 @@ X.B(E,Y);return E}return J}()) if (!window.zddc) window.zddc = {}; if (window.zddc.nav) return; // already loaded - var STAGES = [ - { key: 'archive', label: 'Archive', target: 'archive.html' }, - { key: 'working', label: 'Working', target: 'working/' }, - { key: 'staging', label: 'Staging', target: 'staging/' }, - { key: 'reviewing', label: 'Reviewing', target: 'reviewing/' }, + // Hardcoded fallback for offline / file:// / fetch-error contexts. + // Server-driven discovery (FETCH_STAGES below) is the normal path. + var FALLBACK_STAGES = [ + { name: 'archive', label: 'Archive' }, + { name: 'working', label: 'Working' }, + { name: 'staging', label: 'Staging' }, + { name: 'reviewing', label: 'Reviewing' }, ]; + // Canonical workflow order. Stages appearing in this list are + // rendered in this order; any extras the cascade declares are + // appended alphabetically. + var WORKFLOW_ORDER = ['archive', 'working', 'staging', 'reviewing']; + function projectSegment(pathname) { var parts = pathname.split('/').filter(Boolean); if (parts.length === 0) return null; var first = parts[0]; - // At deployment root (e.g. /archive.html?projects=A,B or - // /index.html) the first segment is a tool HTML — no single - // project to scope the strip to. if (first.indexOf('.') !== -1) return null; return first; } - function currentStage(pathname) { + function currentStage(pathname, stages) { var parts = pathname.split('/').filter(Boolean); if (parts.length < 2) return null; var second = parts[1]; - // /working/... | staging/... | reviewing/... | archive/... - for (var i = 0; i < STAGES.length; i++) { - if (second === STAGES[i].key) return STAGES[i].key; + for (var i = 0; i < stages.length; i++) { + if (second.toLowerCase() === stages[i].name.toLowerCase()) { + return stages[i].name; + } } - // /archive.html → still the archive stage if (second === 'archive.html') return 'archive'; return null; } @@ -4705,7 +4988,53 @@ X.B(E,Y);return E}return J}()) return projectSegment(location.pathname) !== null; } - function buildStrip(project, active) { + function titleCase(s) { + if (!s) return s; + return s.charAt(0).toUpperCase() + s.slice(1); + } + + function sortByWorkflow(stages) { + return stages.slice().sort(function (a, b) { + var ia = WORKFLOW_ORDER.indexOf(a.name.toLowerCase()); + var ib = WORKFLOW_ORDER.indexOf(b.name.toLowerCase()); + if (ia >= 0 && ib >= 0) return ia - ib; + if (ia >= 0) return -1; + if (ib >= 0) return 1; + return a.name.localeCompare(b.name); + }); + } + + // Fetch the project root listing and extract declared stage + // entries. Returns [] on any error so callers fall back to the + // hardcoded list. Each stage entry is {name, label} — label + // honors the cascade's display: override when present. + async function fetchStagesFor(project) { + try { + var resp = await fetch('/' + encodeURIComponent(project) + '/', { + headers: { 'Accept': 'application/json' }, + credentials: 'same-origin', + }); + if (!resp.ok) return []; + var data = await resp.json(); + if (!Array.isArray(data)) return []; + var stages = []; + for (var i = 0; i < data.length; i++) { + var e = data[i]; + if (!e || !e.declared || !e.is_dir) continue; + var bare = (e.name || '').replace(/\/$/, ''); + if (!bare) continue; + stages.push({ + name: bare, + label: e.display_name || titleCase(bare), + }); + } + return sortByWorkflow(stages); + } catch (_e) { + return []; + } + } + + function buildStrip(project, active, stages) { var nav = document.createElement('nav'); nav.className = 'zddc-stage-strip'; nav.setAttribute('aria-label', 'Project stage'); @@ -4721,19 +5050,19 @@ X.B(E,Y);return E}return J}()) sep0.textContent = '/'; nav.appendChild(sep0); - for (var i = 0; i < STAGES.length; i++) { - var s = STAGES[i]; + for (var i = 0; i < stages.length; i++) { + var s = stages[i]; var a = document.createElement('a'); a.className = 'zddc-stage'; - a.href = '/' + encodeURIComponent(project) + '/' + s.target; + a.href = '/' + encodeURIComponent(project) + '/' + s.name; a.textContent = s.label; - if (s.key === active) { + if (s.name === active) { a.classList.add('zddc-stage--active'); a.setAttribute('aria-current', 'page'); } nav.appendChild(a); - if (i < STAGES.length - 1) { + if (i < stages.length - 1) { var sep = document.createElement('span'); sep.className = 'zddc-stage-strip__sep'; sep.setAttribute('aria-hidden', 'true'); @@ -4745,34 +5074,44 @@ X.B(E,Y);return E}return J}()) return nav; } - function mount() { - if (!shouldRender()) return; + function mountWith(project, stages) { var header = document.querySelector('.app-header'); if (!header) return; - // Don't double-mount if a tool's main.js calls us a second time. if (header.previousElementSibling && header.previousElementSibling.classList && header.previousElementSibling.classList.contains('zddc-stage-strip')) { - return; + return; // already mounted } - var project = projectSegment(location.pathname); - var active = currentStage(location.pathname); - var strip = buildStrip(project, active); - // Mount ABOVE the header — the strip is project-level chrome - // (where in the project), the header is tool-level chrome (which - // tool, theme, help). Reading order matches outer-to-inner scope. + var active = currentStage(location.pathname, stages); + var strip = buildStrip(project, active, stages); header.parentNode.insertBefore(strip, header); } - // Expose for tests + opt-out. + async function mount() { + if (!shouldRender()) return; + var project = projectSegment(location.pathname); + if (!project) return; + + // Render the hardcoded fallback immediately so the strip + // appears with no flicker, then upgrade to cascade-resolved + // stages once the fetch completes. + mountWith(project, FALLBACK_STAGES); + + var fetched = await fetchStagesFor(project); + if (fetched.length === 0) return; // fetch failed → keep fallback + + // Replace the strip with the cascade-driven one. Remove the + // existing strip first so mountWith re-mounts cleanly. + var existing = document.querySelector('.zddc-stage-strip'); + if (existing && existing.parentNode) existing.parentNode.removeChild(existing); + mountWith(project, fetched); + } + window.zddc.nav = { mount: mount, - // Internals visible for unit tests; do not call from tools. _projectSegment: projectSegment, _currentStage: currentStage, - _stages: STAGES, - // Set to true before DOMContentLoaded to suppress mounting on - // deployments where the canonical folder layout doesn't apply. + _fallbackStages: FALLBACK_STAGES, disabled: false, }; @@ -5469,6 +5808,15 @@ X.B(E,Y);return E}return J}()) return !!(parsed && parsed.valid); } + // A .zip whose name (minus the .zip extension) parses as a + // transmittal-folder name is treated as that transmittal folder — + // its members are scanned the same as an uncompressed folder's + // files. (A plain `archive.zip` etc. is just a file.) + function isTransmittalFolderZip(name) { + var parts = zddc.splitExtension(name); + return parts.extension === 'zip' && isTransmittalFolder(parts.name); + } + function groupFilesByTrackingNumber(files) { const groups = {}; files.forEach(file => { @@ -5520,6 +5868,7 @@ X.B(E,Y);return E}return J}()) window.app.modules.parser = { isTransmittalFolder, + isTransmittalFolderZip, groupFilesByTrackingNumber, sortGroupedFiles, }; @@ -5712,6 +6061,29 @@ X.B(E,Y);return E}return J}()) console.warn('Could not process directory ' + entry.name + ':', err); } } else if (entry.kind === 'file') { + // A zipped transmittal folder (e.g. + // "2025-05-12_DOC-001 (IFI) - Title.zip") is treated as + // that transmittal folder: open the zip in the browser + // and scan its members like an uncompressed folder's + // files. The .zip stays in the recorded path so it's + // unambiguous; the displayed name drops it. + if (window.app.modules.parser.isTransmittalFolderZip(entry.name)) { + const base = zddc.splitExtension(entry.name).name; + const zipPath = currentPath + '/' + entry.name; + try { + const zh = await window.zddc.zip.fromFileHandle(entry); + callbacks.onTransmittalFolder({ + name: base, + path: zipPath, + displayPath: getDisplayPath(zipPath), + handle: zh + }); + await scanLocalTransmittalFolder(zh, zipPath, 0, zipPath, callbacks); + } catch (zipErr) { + console.warn('Could not open zip transmittal ' + entry.name + ':', zipErr); + } + continue; + } // File directly in a grouping folder — assign to the Outstanding virtual transmittal. // actualPath records the real containing folder for grouping-folder-scoped filtering. try { @@ -6007,6 +6379,27 @@ X.B(E,Y);return E}return J}()) } } else { // It's a file + // A zipped transmittal folder at the grouping level: + // zddc-server serves "<…>.zip/" as a virtual directory + // of the zip's members, so recurse into it like an + // uncompressed transmittal folder. Members come back + // with URLs like "<…>.zip/" that the server + // extracts on demand — no whole-zip download. + if (transmittalPath === null && window.app.modules.parser.isTransmittalFolderZip(rawName)) { + const base = zddc.splitExtension(rawName).name; + const zipDirUrl = itemUrl + '/'; // itemUrl is the .zip file URL (no trailing slash) + callbacks.onTransmittalFolder({ + name: base, + path: logicalPath, + displayPath: getDisplayPath(logicalPath), + handle: null, + url: zipDirUrl + }); + subdirPromises.push( + scanHttpRecursive(zipDirUrl, rootUrl, depth + 1, logicalPath, callbacks) + ); + continue; + } if (transmittalPath === null) { // File directly in a grouping folder — assign to Outstanding virtual transmittal. // actualPath records the real containing folder for grouping-folder-scoped filtering. @@ -8455,13 +8848,24 @@ window.app.modules.filtering = { var selected = new Set(window.app.visibleProjects || []); var known = getKnownProjects().slice().sort(); + // Show the human-friendly title from each project's .zddc + // when present (captured during auto-detect into + // window.app.projectTitles), falling back to the folder name. + // The data-name attribute always carries the canonical folder + // name so URL state stays stable regardless of label. + var titles = window.app.projectTitles || {}; var projectsHtml = known.map(name => { var checked = selected.has(name) ? ' checked' : ''; - var n = escapeHtml(name); + var label = titles[name] || name; + var nAttr = escapeHtml(name); + var nLabel = escapeHtml(label); + var hint = (label !== name) + ? ' (' + escapeHtml(name) + ')' + : ''; return '
' + '' + '
'; }).join(''); @@ -9490,6 +9894,9 @@ window.app.modules.filtering = { // Fetch the server's ACL-filtered project list so we can drop any // listed names the user doesn't actually have access to (and so // the empty-projects= "include everything" mode has a list to use). + // ProjectInfo carries an optional `title` field sourced from each + // project's .zddc — capture it so the dropdown can show the + // human-friendly label instead of the folder name. var serverNames = null; try { var resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' } }); @@ -9498,6 +9905,13 @@ window.app.modules.filtering = { if (Array.isArray(serverProjects) && serverProjects.length > 0 && serverProjects[0] && typeof serverProjects[0].name === 'string') { serverNames = new Set(serverProjects.map(function(p) { return p.name; })); + var titles = {}; + serverProjects.forEach(function (p) { + if (p && typeof p.title === 'string' && p.title) { + titles[p.name] = p.title; + } + }); + window.app.projectTitles = titles; } } } catch (e) { diff --git a/zddc/internal/apps/embedded/browse.html b/zddc/internal/apps/embedded/browse.html index 749fec7..a49d4c9 100644 --- a/zddc/internal/apps/embedded/browse.html +++ b/zddc/internal/apps/embedded/browse.html @@ -1252,6 +1252,57 @@ html, body { font-weight: 500; } +/* ── Drag-drop upload overlay ─────────────────────────────────────────────── */ +/* Shown only while a drag is active over the page AND the current scope + accepts uploads. Pointer-events:none below dragover so the underlying + drop event still reaches the document handlers. */ +.upload-overlay { + position: fixed; + inset: 0; + z-index: 50; + pointer-events: none; + background: rgba(42, 90, 138, 0.18); + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.12s ease; +} +.upload-overlay.is-active { + opacity: 1; +} +.upload-overlay__panel { + background: var(--bg); + border: 2px dashed var(--primary); + border-radius: var(--radius); + padding: 1.5rem 2.25rem; + text-align: center; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18); + pointer-events: none; + color: var(--text); + max-width: 80vw; +} +.upload-overlay__icon { + font-size: 2.5rem; + line-height: 1; + color: var(--primary); +} +.upload-overlay__title { + font-family: var(--font-display); + font-size: 1.15rem; + font-weight: 600; + margin-top: 0.5rem; +} +.upload-overlay__path { + margin-top: 0.35rem; + font-family: var(--font-mono); + font-size: 0.82rem; + color: var(--text-muted); + word-break: break-all; +} + /* Virtual rows: synthesized client-side for folders that aren't on disk yet (canonical project folders). Rendered muted so the user reads them as "available but empty" rather than ordinary entries. @@ -1299,80 +1350,43 @@ html, body { .status-bar.is-info { color: var(--text); } /* ── Markdown plugin (right-pane internals when a .md is selected) ──────── */ -/* CSS-Grid shell. Two columns (editor | sidebar) and two rows (toolbar - | body). The grid gives every cell a definite size, which Toast UI - needs to compute its scroll regions correctly. A 4-px resizer sits - between the editor and sidebar; JS updates grid-template-columns on - drag. */ +/* CSS-Grid shell mirroring mdedit's layout: sidebar on the LEFT + (front matter top + TOC bottom), content on the RIGHT (informational + header above the Toast UI editor). The grid gives every cell a + definite size, which Toast UI needs to compute its scroll regions + correctly. */ .md-shell { display: grid; - grid-template-rows: auto 1fr; - grid-template-columns: 1fr 260px; /* JS overrides on resize */ - grid-template-areas: - "toolbar toolbar" - "editor sidebar"; + grid-template-rows: 1fr; + grid-template-columns: 280px 1fr; /* JS overrides on resize */ + grid-template-areas: "sidebar content"; height: 100%; min-height: 0; background: var(--bg); overflow: hidden; } -/* Toolbar spans both columns; subtle row above the editor. */ -.md-shell__toolbar { - grid-area: toolbar; - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.35rem 0.75rem; - background: var(--bg-secondary); - border-bottom: 1px solid var(--border); - font-size: 0.85rem; -} -.md-shell__dirty { - color: var(--text-muted); - font-size: 0.85rem; - min-width: 5.5rem; -} -.md-shell__status { - flex: 1; - text-align: right; - color: var(--text-muted); - font-size: 0.85rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.md-shell__source { - color: var(--text-muted); - font-size: 0.75rem; - font-style: italic; - margin-left: 0.5rem; - padding: 0.15rem 0.4rem; - border-radius: var(--radius); - background: var(--bg); - border: 1px solid var(--border); -} - -/* Editor host: a single grid cell with overflow:hidden so Toast UI's - internal scrollers handle the content. */ -.md-shell__editor { - grid-area: editor; - min-width: 0; +/* Sidebar (col 1): two stacked sections — Front matter (top, fixed + default 180 px, drag-resizable) and TOC (bottom, takes the rest). */ +.md-shell__sidebar { + grid-area: sidebar; + display: grid; + grid-template-rows: 180px 1fr; /* JS overrides on resize */ min-height: 0; overflow: hidden; - /* Toast UI mounts a .toastui-editor-defaultUI element here; give - it a definite height via height:100% in the JS. */ + border-right: 1px solid var(--border); + background: var(--bg); + position: relative; } -/* Resizer sits on the grid border between editor (col 1) and sidebar - (col 2). Positioned absolutely over the boundary so it doesn't take - up a grid track itself. */ +/* Vertical sidebar/content resizer. Sits absolutely on the column + boundary so it doesn't occupy a grid track. */ .md-shell__resizer { - grid-area: editor; + grid-area: sidebar; align-self: stretch; justify-self: end; width: 6px; - margin-right: -3px; /* center on the column boundary */ + margin-right: -3px; cursor: col-resize; background: transparent; z-index: 2; @@ -1385,17 +1399,91 @@ html, body { outline: none; } -/* Sidebar (right column): grid of two stacked sections — Outline - (1fr) takes the bulk of the height, Front matter (auto, capped) is - below. */ -.md-shell__sidebar { - grid-area: sidebar; +/* Horizontal resizer between front-matter and TOC inside the sidebar. + Spans both rows by placement, then absolutely positioned to overlay + the grid-row boundary. */ +.md-shell__fmresizer { + grid-column: 1; + grid-row: 1; + align-self: end; + justify-self: stretch; + height: 6px; + margin-bottom: -3px; + cursor: row-resize; + background: transparent; + z-index: 2; + transition: background 0.12s; +} +.md-shell__fmresizer:hover, +.md-shell__fmresizer.is-dragging, +.md-shell__fmresizer:focus-visible { + background: var(--primary); + outline: none; +} + +/* Content (col 2): informational header above the Toast UI editor. */ +.md-shell__content { + grid-area: content; display: grid; - grid-template-rows: 1fr auto; + grid-template-rows: auto 1fr; + min-width: 0; min-height: 0; overflow: hidden; - border-left: 1px solid var(--border); +} + +/* Informational header above the editor: file name on the left, then + dirty marker, status, source hint, save button. Reads as a header + for the content panel — file metadata at a glance. */ +.md-shell__infohdr { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.75rem; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + font-size: 0.85rem; +} +.md-shell__title { + flex: 1; + font-family: var(--font-display); + font-size: 1rem; + font-weight: 600; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} +.md-shell__dirty { + color: var(--text-muted); + font-size: 0.85rem; + min-width: 5.5rem; + text-align: right; +} +.md-shell__status { + color: var(--text-muted); + font-size: 0.85rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 14rem; +} +.md-shell__source { + color: var(--text-muted); + font-size: 0.75rem; + font-style: italic; + padding: 0.15rem 0.4rem; + border-radius: var(--radius); background: var(--bg); + border: 1px solid var(--border); +} + +/* Editor host: a single grid cell with overflow:hidden so Toast UI's + internal scrollers handle the content. */ +.md-shell__editor { + min-width: 0; + min-height: 0; + overflow: hidden; } .md-side { @@ -1404,10 +1492,8 @@ html, body { min-height: 0; overflow: hidden; } -.md-side--fm { +.md-side--toc { border-top: 1px solid var(--border); - /* Front matter doesn't dominate — cap it so the outline keeps room. */ - max-height: 40%; } .md-side__header { padding: 0.35rem 0.75rem; @@ -1554,7 +1640,7 @@ html, body {
ZDDC Browse - v0.0.17-beta · 2026-05-11 · lens-mesa-chalk + v0.0.17-beta · 2026-05-12 · candle-mast-pearl
@@ -1585,12 +1671,11 @@ html, body {