From c95f07966d748a8faf389948616fa670abc4d335 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Wed, 29 Apr 2026 12:52:27 -0500 Subject: [PATCH] feat(tools,build): in-flight HTML-tool reworks and build-infra updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles a stretch of in-progress work across the SPA tools so the tree returns to a coherent shippable state ahead of cutting a new zddc-server stable image: - landing: substantial rework of the project picker (sortable/filterable table, presets refactor, ?projects= filter, ?v= channel propagation, loading/error states) - archive: presets cleanup, source.js refactor, filtering/url-state alignment with the landing page - mdedit: file-system module split, resizer, file-tree improvements, base/toc styling tweaks - transmittal/classifier: small template touch-ups for shared chrome - shared: build-lib.sh helpers, new favicon.svg - bootstrap, build.sh: pick up the channel-aware install/track zip generation - tests: new landing.spec.js, expanded archive/mdedit/build-label specs - docs: CLAUDE.md picks up the zddc-server section and freshens the alpha-build exception note - regenerated artifacts: install.zip, track-{alpha,beta,stable}.zip, *_alpha.html — these are produced by `sh build.sh` and per project convention are committed alongside the source changes Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 11 +- archive/build.sh | 7 +- archive/css/table.css | 16 + archive/js/app.js | 226 +++- archive/js/directory.js | 6 +- archive/js/filtering.js | 18 +- archive/js/init.js | 6 +- archive/js/presets.js | 430 +------ archive/js/source.js | 242 +++- archive/js/table.js | 57 +- archive/js/url-state.js | 26 +- archive/template.html | 5 +- bootstrap/level1.html.tmpl | 1 + bootstrap/level2.html.tmpl | 1 + build.sh | 9 +- classifier/build.sh | 7 +- classifier/template.html | 1 + landing/build.sh | 9 +- landing/css/landing.css | 274 +++- landing/js/landing.js | 702 +++++++--- landing/template.html | 37 +- mdedit/build.sh | 6 + mdedit/css/base.css | 123 +- mdedit/css/toc.css | 16 +- mdedit/js/app.js | 19 + mdedit/js/editor.js | 96 +- mdedit/js/file-system.js | 218 +++- mdedit/js/file-tree.js | 62 +- mdedit/js/main.js | 12 +- mdedit/js/resizer.js | 44 + mdedit/js/utils.js | 9 + mdedit/template.html | 9 +- playwright.config.js | 4 + shared/build-lib.sh | 32 +- shared/favicon.svg | 8 + tests/archive.spec.js | 382 +++++- tests/build-label.spec.js | 17 +- tests/landing.spec.js | 139 ++ tests/mdedit.spec.js | 5 +- transmittal/build.sh | 7 +- transmittal/template.html | 1 + website/install.zip | Bin 511507 -> 512746 bytes website/releases/archive_alpha.html | 1034 +++++++-------- website/releases/classifier_alpha.html | 3 +- website/releases/landing_alpha.html | 1555 ++++++++++++++++++++--- website/releases/mdedit_alpha.html | 3 +- website/releases/transmittal_alpha.html | 3 +- website/track-alpha.zip | Bin 5264 -> 6801 bytes website/track-beta.zip | Bin 5255 -> 6795 bytes website/track-stable.zip | Bin 5264 -> 6801 bytes 50 files changed, 4329 insertions(+), 1569 deletions(-) create mode 100644 shared/favicon.svg create mode 100644 tests/landing.spec.js diff --git a/CLAUDE.md b/CLAUDE.md index c4d9620..ba9f1d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,17 +28,24 @@ sh build.sh # build all five HTML tools (dist/ o sh tool/build.sh # build one (archive|transmittal|classifier|mdedit|landing) sh tool/build.sh --release [version] # cut stable; tag, write website/releases/_v.html, refresh _stable symlink sh tool/build.sh --release alpha|beta # cut channel build; overwrites website/releases/_.html (mutable, no tag) +./freshen-channel # rebuild alpha/beta from current stable tag (run after every stable release) npm test # all Playwright specs (build first!) npx playwright test # one spec ./dev-server start # ./dev-server stop # cache-busting HTTP on :8000 + +# zddc/ Go server (separate sub-project, not part of sh build.sh) +(cd zddc && go test ./...) # unit tests (Go 1.24+) +podman build -t zddc-server zddc/ # build container image +sh release-image.sh [alpha|beta|stable] # canonical image release (default: alpha; cascades down channels) ``` -No lint/typecheck/format commands exist — vanilla JS + POSIX sh by design. +No lint/typecheck/format commands exist for the HTML tools — vanilla JS + POSIX sh by design. ## Things that bite if you forget - **`dist/` is gitignored but force-committed** (`git add -f tool/dist/tool.html`). Never hand-edit a `dist/` file. -- **Never write to `website/index.html`, `website/releases/_v*.html`, `website/releases/_stable.html`, or `website/releases/_beta.html` directly** — promote via `sh tool/build.sh --release [version|alpha|beta]`. Stable releases write `website/releases/_v.html` (immutable) and refresh `_stable.html`; alpha/beta overwrite `_.html` in place. **Exception: `_alpha.html` files** — every plain `tool/build.sh` mirrors the dist file there as a real copy (not symlink — the canonical Caddy serves only `website/` and can't follow `../` paths). Side-effect: dev builds dirty those files; commit alongside the source change or `git checkout` to discard. +- **Never write to `website/index.html`, `website/releases/_v*.html`, `website/releases/_stable.html`, or `website/releases/_beta.html` directly** — promote via `sh tool/build.sh --release [version|alpha|beta]`. Stable releases write `website/releases/_v.html` (immutable) and refresh `_stable.html`; alpha/beta overwrite `_.html` in place. +- **Alpha exception — every plain build dirties `website/releases/_alpha.html`.** Every `tool/build.sh` (no flags) mirrors the just-built dist file into `_alpha.html` as a real copy (not symlink — the canonical Caddy serves only `website/` and can't follow `../` paths). Side-effect: dev builds dirty those files; commit alongside the source change or `git checkout` to discard. - **Always build before running tests** — Playwright opens `dist/tool.html` via `file://`. - **``** embedding. `shared/build-lib.sh` provides `escape_js_close_tags`; every tool's `build.sh` runs JS through it before inlining. - **All ZDDC parsing/formatting/hashing goes through `window.zddc`** (from `shared/zddc.js` + `shared/hash.js` + `shared/zddc-filter.js`). API: `parseFilename`, `parseFolder`, `parseRevision`, `formatFilename`, `formatFolder`, `compareRevisions`, `isValidStatus`, `splitExtension`, `joinExtension`, `crypto.{sha256Hex, sha256String, sha256File, bytesToHex}`, `filter.{parse, matches}`. File objects across tools use `trackingNumber` (string) and `extension` (string, **no leading dot** — use `zddc.joinExtension(name, ext)` to build a filename). Add edge cases to `tests/zddc.spec.js`, not per-tool tests. diff --git a/archive/build.sh b/archive/build.sh index 8428f96..96558cb 100644 --- a/archive/build.sh +++ b/archive/build.sh @@ -56,7 +56,7 @@ escape_js_close_tags "$js_raw" "$js_temp" compute_build_label "archive" "${1:-}" "${2:-}" # Process template: inject CSS/JS, substitute build label, strip CDN refs. -awk -v css_file="$css_temp" -v js_file="$js_temp" -v build_label="$build_label" -v is_red="$is_red" ' +awk -v css_file="$css_temp" -v js_file="$js_temp" -v build_label="$build_label" -v is_red="$is_red" -v favicon_uri="$favicon_data_uri" ' /\{\{CSS_PLACEHOLDER\}\}/ { while ((getline line < css_file) > 0) print line close(css_file) @@ -76,6 +76,11 @@ awk -v css_file="$css_temp" -v js_file="$js_temp" -v build_label="$build_label" print next } + /\{\{FAVICON\}\}/ { + gsub(/\{\{FAVICON\}\}/, favicon_uri) + print + next + } / @@ -20,33 +21,43 @@
+ +
+

Welcome to the ZDDC Archive

+

+ Pick the projects you want to view, then open the archive. Filter by + project number or title, and save your selection as a preset to + share or come back to later. +

+
+ +
-

Select Projects

-
- -
- - -
- - +
+

Projects

+
-
- +
+ +
Loading projects…
diff --git a/mdedit/build.sh b/mdedit/build.sh index 283f8f9..e146e30 100644 --- a/mdedit/build.sh +++ b/mdedit/build.sh @@ -74,6 +74,7 @@ awk \ -v toastui_css="$toastui_css" \ -v build_label="$build_label" \ -v is_red="$is_red" \ + -v favicon_uri="$favicon_data_uri" \ ' /\{\{CSS_PLACEHOLDER\}\}/ { while ((getline line < css_file) > 0) print line @@ -94,6 +95,11 @@ awk \ print next } + /\{\{FAVICON\}\}/ { + gsub(/\{\{FAVICON\}\}/, favicon_uri) + print + next + } / @@ -49,11 +50,9 @@
-
-

Welcome to ZDDC Markdown

-

All files are edited on your local computer.

-

Click Scratchpad to start editing,
or Select Directory to work with files.

- + are safe @@ -122,7 +136,17 @@ compute_build_label() { build_version="" if [ "$_flag" != "--release" ]; then - build_label="Built: ${build_timestamp} BETA" + # Plain builds mirror to website/releases/_alpha.html, so they ARE + # alpha builds. Label format matches `--release alpha` but includes the + # full timestamp (more granular than date) and a -dirty marker when the + # working tree has uncommitted changes — useful when iterating before + # commit. + _sha=$(git -C "$root_dir" rev-parse --short=7 HEAD 2>/dev/null || echo "unknown") + if ! git -C "$root_dir" diff --quiet HEAD 2>/dev/null; then + _sha="${_sha}-dirty" + fi + channel="alpha" + build_label="alpha · ${build_timestamp} · ${_sha}" return 0 fi diff --git a/shared/favicon.svg b/shared/favicon.svg new file mode 100644 index 0000000..b545f3b --- /dev/null +++ b/shared/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tests/archive.spec.js b/tests/archive.spec.js index 209980b..1a7c1fb 100644 --- a/tests/archive.spec.js +++ b/tests/archive.spec.js @@ -87,41 +87,46 @@ test.describe('Archive Browser', () => { expect(fileCountText).toBeTruthy(); }); - test('projectFilter filters local-mode scan at root depth (virtual merge)', async ({ page }) => { - // The corresponding URL flow is `archive.html?projects=A,B` → - // url-state.restore() → window.app.projectFilter Set. We set the Set - // directly here because the existing test environment hits an - // unrelated init() error before url-state.restore() runs (a - // getElementById returning null in events.js); a separate test would - // be needed to re-confirm the URL-parsing path. The behavior under - // test is the source.js depth-0 filter check, which only reads - // window.app.projectFilter. + test('Mode 1: ?projects=A,B enters each project\'s Archive subfolder', async ({ page }) => { + // Multi-project layout: server root holds project folders, each containing an + // Archive/ subfolder with third-party folders. ?projects=A,B (set as + // window.app.projectFilter; url-state.restore() handles parsing in real flow) + // selects the projects to scan. Each is descended into /Archive/. await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 }); - // Three top-level projects under one root; only A and B should be scanned. await page.evaluate(() => { window.__setMockDirectoryTree('combined-root', { 'Project-A': { - '2025-01-15_123456-EM-TRN-0001 (IFC) - First': { - '123456-EL-SPC-0001_A (IFC) - SpecA.pdf': '%PDF', + 'Archive': { + 'ACME': { + '2025-01-15_123456-EM-TRN-0001 (IFC) - First': { + '123456-EL-SPC-0001_A (IFC) - SpecA.pdf': '%PDF', + }, + }, }, }, 'Project-B': { - '2025-02-10_789012-EM-TRN-0001 (IFC) - Second': { - '789012-EL-SPC-0002_A (IFC) - SpecB.pdf': '%PDF', + 'Archive': { + 'Beta': { + '2025-02-10_789012-EM-TRN-0001 (IFC) - Second': { + '789012-EL-SPC-0002_A (IFC) - SpecB.pdf': '%PDF', + }, + }, }, }, 'Project-C': { - '2025-03-01_345678-EM-TRN-0001 (IFC) - Third': { - '345678-EL-SPC-0003_A (IFC) - SpecC.pdf': '%PDF', + 'Archive': { + 'Gamma': { + '2025-03-01_345678-EM-TRN-0001 (IFC) - Third': { + '345678-EL-SPC-0003_A (IFC) - SpecC.pdf': '%PDF', + }, + }, }, }, }); }); - // Set projectFilter to {A, B}, mimicking what url-state.restore() - // would do for a URL like archive.html?projects=Project-A,Project-B. await page.evaluate(() => { window.app.projectFilter = new Set(['Project-A', 'Project-B']); }); @@ -129,7 +134,17 @@ test.describe('Archive Browser', () => { await page.locator('#addDirectoryBtn').click(); await page.waitForTimeout(2000); - // Surface all files into the table. + // Third-party (grouping) folders should be ACME and Beta — never project IDs or "Archive" + const groupingFolders = await page.evaluate(() => + window.app.groupingFolders.map(f => f.name) + ); + expect(groupingFolders).toContain('ACME'); + expect(groupingFolders).toContain('Beta'); + expect(groupingFolders).not.toContain('Project-A'); + expect(groupingFolders).not.toContain('Project-B'); + expect(groupingFolders).not.toContain('Archive'); + expect(groupingFolders).not.toContain('Gamma'); + await page.evaluate(() => { const cb = document.getElementById('selectAllGroupingCheckbox'); if (cb && !cb.checked) cb.click(); @@ -142,6 +157,335 @@ test.describe('Archive Browser', () => { expect(tableText).not.toContain('SpecC'); }); + test('same-name third-parties across projects merge into one party row', async ({ page }) => { + // Both projects have a "BM" third-party folder. The parties pane must + // show "BM" once, and selecting it must surface files from both projects. + await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 }); + + await page.evaluate(() => { + window.__setMockDirectoryTree('combined-root', { + '176109': { + 'Archive': { + 'BM': { + 'Issued': { + '2025-01-15_123456-EM-TRN-0001 (IFC) - First': { + '123456-EL-SPC-0001_A (IFC) - SpecFromA.pdf': '%PDF', + }, + }, + }, + }, + }, + '197072': { + 'Archive': { + 'BM': { + 'Issued': { + '2025-02-10_789012-EM-TRN-0001 (IFC) - Second': { + '789012-EL-SPC-0002_A (IFC) - SpecFromB.pdf': '%PDF', + }, + }, + }, + }, + }, + }); + }); + + await page.evaluate(() => { + window.app.projectFilter = new Set(['176109', '197072']); + }); + + await page.locator('#addDirectoryBtn').click(); + await page.waitForTimeout(2000); + + // The DOM should render exactly one BM row, not two. + const renderedNames = await page.locator('#groupingFoldersList .folder-item-name').allTextContents(); + const bmRows = renderedNames.filter(n => n.trim() === 'BM'); + expect(bmRows.length).toBe(1); + + // Both projects' files should be visible under the single BM party. + const tableText = await page.locator('#filesTableBody').textContent(); + expect(tableText).toContain('SpecFromA'); + expect(tableText).toContain('SpecFromB'); + }); + + test('toggling visibleProjects hides files without re-scanning', async ({ page }) => { + // Two projects scanned. Toggling 197072 out of visibleProjects should + // hide its files; window.app.files (the in-memory scan result) must NOT + // shrink — only the visible/filtered set changes. + await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 }); + + await page.evaluate(() => { + window.__setMockDirectoryTree('combined-root', { + '176109': { + 'Archive': { + 'BM': { + 'Issued': { + '2025-01-15_123456-EM-TRN-0001 (IFC) - First': { + '123456-EL-SPC-0001_A (IFC) - SpecFromA.pdf': '%PDF', + }, + }, + }, + }, + }, + '197072': { + 'Archive': { + 'BM': { + 'Issued': { + '2025-02-10_789012-EM-TRN-0001 (IFC) - Second': { + '789012-EL-SPC-0002_A (IFC) - SpecFromB.pdf': '%PDF', + }, + }, + }, + }, + }, + }); + }); + + await page.evaluate(() => { + window.app.projectFilter = new Set(['176109', '197072']); + window.app.visibleProjects = new Set(['176109', '197072']); + window.app.isMultiProject = true; + }); + + await page.locator('#addDirectoryBtn').click(); + await page.waitForTimeout(2000); + + const beforeFileCount = await page.evaluate(() => window.app.files.length); + expect(beforeFileCount).toBeGreaterThanOrEqual(2); + + // Toggle 197072 out of the visible set — must NOT shrink window.app.files. + await page.evaluate(() => { + window.app.visibleProjects = new Set(['176109']); + window.app.modules.app.updateUI(); + window.app.modules.filtering.applyFilters(); + }); + + const afterFileCount = await page.evaluate(() => window.app.files.length); + expect(afterFileCount).toBe(beforeFileCount); + + const tableText = await page.locator('#filesTableBody').textContent(); + expect(tableText).toContain('SpecFromA'); + expect(tableText).not.toContain('SpecFromB'); + + // Toggle 197072 back — file reappears immediately, no rescan needed. + await page.evaluate(() => { + window.app.visibleProjects = new Set(['176109', '197072']); + window.app.modules.app.updateUI(); + window.app.modules.filtering.applyFilters(); + }); + const tableTextAfter = await page.locator('#filesTableBody').textContent(); + expect(tableTextAfter).toContain('SpecFromA'); + expect(tableTextAfter).toContain('SpecFromB'); + }); + + test('?show= URL param round-trips: serialize emits it, restore reads it', async ({ page }) => { + await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 }); + + // Serialize: visibleProjects ⊊ projectFilter → ?show= present + const qsWithSubset = await page.evaluate(() => { + window.app.projectFilter = new Set(['A', 'B', 'C']); + window.app.visibleProjects = new Set(['A']); + return window.app.modules.urlState.serialize(); + }); + expect(qsWithSubset).toContain('projects=A%2CB%2CC'); + expect(qsWithSubset).toContain('show=A'); + + // Serialize: visibleProjects equals projectFilter → ?show= omitted + const qsAllVisible = await page.evaluate(() => { + window.app.projectFilter = new Set(['A', 'B']); + window.app.visibleProjects = new Set(['A', 'B']); + return window.app.modules.urlState.serialize(); + }); + expect(qsAllVisible).not.toContain('show='); + + // Restore: ?show= populates visibleProjects from URL. + await page.goto(`file://${HTML_PATH}?projects=A,B,C&show=A,B`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 }); + const restored = await page.evaluate(() => { + // Re-run restore in case the early init() raced with DOM availability — + // matches how the existing projectFilter test bypasses this path too. + window.app.modules.urlState.restore(); + return { + search: location.search, + projectFilter: Array.from(window.app.projectFilter).sort(), + visibleProjects: Array.from(window.app.visibleProjects).sort(), + }; + }); + expect(restored.search).toBe('?projects=A,B,C&show=A,B'); + expect(restored.projectFilter).toEqual(['A', 'B', 'C']); + expect(restored.visibleProjects).toEqual(['A', 'B']); + }); + + test('Mode 2: project-root with Archive child auto-descends into Archive only', async ({ page }) => { + // Picked directory is a project root containing Archive/ plus Reviewing/. + // The app should enter Archive/ automatically and ignore Reviewing/. + await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 }); + + await page.evaluate(() => { + window.__setMockDirectoryTree('project-176109', { + 'Archive': { + 'ACME': { + '2025-01-15_123456-EM-TRN-0001 (IFC) - First': { + '123456-EL-SPC-0001_A (IFC) - InArchive.pdf': '%PDF', + }, + }, + }, + 'Reviewing': { + 'ShouldNotShow': { + '2025-02-10_789012-EM-TRN-0001 (IFC) - HiddenStage': { + '789012-EL-SPC-0002_A (IFC) - InReviewing.pdf': '%PDF', + }, + }, + }, + }); + }); + + await page.locator('#addDirectoryBtn').click(); + await page.waitForTimeout(2000); + + const groupingFolders = await page.evaluate(() => + window.app.groupingFolders.map(f => f.name) + ); + expect(groupingFolders).toContain('ACME'); + expect(groupingFolders).not.toContain('Archive'); + expect(groupingFolders).not.toContain('Reviewing'); + expect(groupingFolders).not.toContain('ShouldNotShow'); + + const fileNames = await page.evaluate(() => window.app.files.map(f => f.name)); + expect(fileNames.some(n => n.includes('InArchive'))).toBe(true); + expect(fileNames.some(n => n.includes('InReviewing'))).toBe(false); + }); + + test('Mode 3: in-archive layout (no projects=, no Archive child) uses today\'s scan', async ({ page }) => { + // Picked directory IS the Archive folder — children are third-parties directly. + // Behavior must match pre-change baseline. + await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 }); + + await page.evaluate(() => { + window.__setMockDirectoryTree('Archive', { + 'ACME': { + 'Issued': { + '2025-01-15_123456-EM-TRN-0001 (IFC) - First': { + '123456-EL-SPC-0001_A (IFC) - SpecA.pdf': '%PDF', + }, + }, + }, + 'Beta': { + 'Received': { + '2025-02-10_789012-EM-TRN-0001 (IFC) - Second': { + '789012-EL-SPC-0002_A (IFC) - SpecB.pdf': '%PDF', + }, + }, + }, + }); + }); + + await page.locator('#addDirectoryBtn').click(); + await page.waitForTimeout(2000); + + const groupingFolders = await page.evaluate(() => + window.app.groupingFolders.map(f => f.name) + ); + expect(groupingFolders).toContain('ACME'); + expect(groupingFolders).toContain('Beta'); + + await page.evaluate(() => { + const cb = document.getElementById('selectAllGroupingCheckbox'); + if (cb && !cb.checked) cb.click(); + }); + await page.waitForTimeout(500); + + const tableText = await page.locator('#filesTableBody').textContent(); + expect(tableText).toContain('SpecA'); + expect(tableText).toContain('SpecB'); + }); + + test('disabled folder types are skipped at scan time (not just hidden in UI)', async ({ page }) => { + // Default enabledFolderTypes is {issued, received}. The Incoming folder should + // never be listed — its files must not appear in window.app.files at all. + await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 }); + + await page.evaluate(() => { + window.__setMockDirectoryTree('Archive', { + 'ACME': { + 'Issued': { + '2025-01-15_123456-EM-TRN-0001 (IFC) - First': { + '123456-EL-SPC-0001_A (IFC) - SpecIssued.pdf': '%PDF', + }, + }, + 'Incoming': { + '2025-02-10_789012-EM-TRN-0001 (IFC) - Second': { + '789012-EL-SPC-0002_A (IFC) - SpecIncoming.pdf': '%PDF', + }, + }, + }, + }); + }); + + await page.locator('#addDirectoryBtn').click(); + await page.waitForTimeout(2000); + + const fileNames = await page.evaluate(() => window.app.files.map(f => f.name)); + expect(fileNames.some(n => n.includes('SpecIssued'))).toBe(true); + // Incoming is in FOLDER_TYPE_NAMES but not in default enabledFolderTypes — must be skipped + expect(fileNames.some(n => n.includes('SpecIncoming'))).toBe(false); + }); + + test('Preview toggle is checked by default', async ({ page }) => { + await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#filePreviewToggle', { timeout: 15000 }); + await expect(page.locator('#filePreviewToggle')).toBeChecked(); + }); + + test('default modifier filter selects only base and +C revisions', async ({ page }) => { + // Files with mixed revision modifiers — by default only base + +C should + // surface in the visible file table; +B (and any other non-+C modifier) + // should be hidden until the user explicitly opts in. + await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 }); + + await page.evaluate(() => { + window.__setMockDirectoryTree('test-project', { + '2025-01-15_123456-EM-TRN-0001 (IFC) - Mixed Mods': { + '123456-EL-SPC-0001_A (IFC) - SpecBase.pdf': '%PDF', + '123456-EL-SPC-0001_A+C1 (IFC) - SpecComment.pdf': '%PDF', + '123456-EL-SPC-0001_A+B1 (IFC) - SpecScratch.pdf': '%PDF', + '123456-EL-SPC-0001_A+D1 (IFC) - SpecDraft.pdf': '%PDF', + }, + }); + }); + + await page.locator('#addDirectoryBtn').click(); + await page.waitForTimeout(2000); + + await page.evaluate(() => { + const cb = document.getElementById('selectAllGroupingCheckbox'); + if (cb && !cb.checked) cb.click(); + }); + await page.waitForTimeout(300); + + const state = await page.evaluate(() => ({ + available: Array.from(window.app.availableModifiers).sort(), + selected: Array.from(window.app.selectedModifiers).sort(), + visibleNames: window.app.filteredFiles.map(f => f.name), + })); + + // The dropdown still LISTS all modifiers found in the data… + expect(state.available).toEqual(['+B', '+C', '+D', 'base']); + // …but only base + +C are pre-selected by default. + expect(state.selected).toEqual(['+C', 'base']); + // Files with hidden modifier types must be absent from the table. + expect(state.visibleNames.some(n => n.includes('SpecBase'))).toBe(true); + expect(state.visibleNames.some(n => n.includes('SpecComment'))).toBe(true); + expect(state.visibleNames.some(n => n.includes('SpecScratch'))).toBe(false); + expect(state.visibleNames.some(n => n.includes('SpecDraft'))).toBe(false); + }); + test('parser module uses shared zddc helpers (not its own wrappers)', async ({ page }) => { await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('#appContainer', { timeout: 15000 }); diff --git a/tests/build-label.spec.js b/tests/build-label.spec.js index b653177..651498b 100644 --- a/tests/build-label.spec.js +++ b/tests/build-label.spec.js @@ -37,16 +37,21 @@ for (const tool of tools) { await expect(el).toBeVisible({ timeout: 10000 }); }); - test(`dist file: label is a valid timestamp or version string`, async () => { + test(`dist file: label is a valid channel or version string`, async () => { const html = fs.readFileSync(distPath, 'utf8'); - // BETA labels are wrapped in an inner ; non-BETA are bare text. + // Channel labels (alpha/beta) are wrapped in an inner ; + // stable version labels are bare text. const match = html.match(/class="build-timestamp">(?:]*>)?([^<]+?)(?:<\/span>)? { window.__mockProjects = p; }, projects); + await page.goto(FILE_URL, { waitUntil: 'domcontentloaded' }); +} + +test.describe('Landing page', () => { + + test('renders the welcome hero and a project table when projects come back', async ({ page }) => { + await loadLandingWithProjects(page, SAMPLE_PROJECTS); + await page.waitForSelector('.project-table', { timeout: 5000 }); + + await expect(page.locator('.landing-hero h1')).toContainText(/Welcome/i); + const rowCount = await page.locator('.project-table tbody tr').count(); + expect(rowCount).toBe(3); + // Title column is shown because at least one project has a title. + await expect(page.locator('th.project-table-title-col')).toBeVisible(); + await expect(page.locator('.project-table tbody')).toContainText('Greenfield Substation'); + await expect(page.locator('.project-table tbody')).toContainText('Brownfield Tap'); + // 210045 has no title — should render a dash placeholder, not the empty string. + await expect(page.locator('.project-table-no-title')).toHaveCount(1); + }); + + test('column filters narrow the table; URL is updated', async ({ page }) => { + await loadLandingWithProjects(page, SAMPLE_PROJECTS); + await page.waitForSelector('.project-table', { timeout: 5000 }); + + await page.locator('input.column-filter[data-column="pn"]').fill('176'); + await page.waitForTimeout(150); + const rowsAfterPn = await page.locator('.project-table tbody tr').count(); + expect(rowsAfterPn).toBe(1); + await expect(page.locator('.project-table tbody')).toContainText('176109'); + + const search = await page.evaluate(() => location.search); + expect(search).toContain('pn=176'); + + await page.locator('input.column-filter[data-column="pn"]').fill(''); + await page.locator('input.column-filter[data-column="pt"]').fill('Brown'); + await page.waitForTimeout(150); + const rowsAfterPt = await page.locator('.project-table tbody tr').count(); + expect(rowsAfterPt).toBe(1); + await expect(page.locator('.project-table tbody')).toContainText('Brownfield Tap'); + }); + + test('selecting projects enables Open Archive and writes ?projects=', async ({ page }) => { + await loadLandingWithProjects(page, SAMPLE_PROJECTS); + await page.waitForSelector('.project-table', { timeout: 5000 }); + + await expect(page.locator('#openArchiveBtn')).toBeDisabled(); + + await page.locator('.project-table-row[data-name="176109"]').click(); + await expect(page.locator('#openArchiveBtn')).toBeEnabled(); + await expect(page.locator('#selectionSummary')).toContainText('1 project selected'); + + const search = await page.evaluate(() => location.search); + expect(search).toContain('projects=176109'); + }); + + test('shows a friendly empty state when the server returns no projects', async ({ page }) => { + await loadLandingWithProjects(page, []); + await page.waitForSelector('.project-list-empty', { timeout: 5000 }); + + await expect(page.locator('.project-list-empty')).toContainText(/No projects to show/); + await expect(page.locator('.project-list-empty')).toContainText(/access/i); + await expect(page.locator('#openArchiveBtn')).toBeDisabled(); + }); + + test('save / load named preset round-trips selection + filters', async ({ page }) => { + await loadLandingWithProjects(page, SAMPLE_PROJECTS); + await page.waitForSelector('.project-table', { timeout: 5000 }); + + // Set up state: pick 176109 and filter title by "Green". + await page.locator('.project-table-row[data-name="176109"]').click(); + await page.locator('input.column-filter[data-column="pt"]').fill('Green'); + await page.waitForTimeout(150); + + // Save preset (open the split-button caret menu). + await page.locator('#openArchiveMenuBtn').click(); + await page.locator('button:has-text("Save current as preset")').click(); + await page.locator('#presetNameInput').fill('My View'); + await page.locator('button:has-text("Save")').click(); + + // Clear state so we can verify preset application restores it. + // (Manual clear: unclick the row and empty the title filter.) + await page.locator('.project-table-row[data-name="176109"]').click(); + await page.locator('input.column-filter[data-column="pt"]').fill(''); + await page.waitForTimeout(150); + // Sanity: nothing selected, no filter. + const cleared = await page.evaluate(() => ({ + selectedRows: document.querySelectorAll('.project-table-row.is-selected').length, + ptValue: document.querySelector('input.column-filter[data-column="pt"]').value, + })); + expect(cleared.selectedRows).toBe(0); + expect(cleared.ptValue).toBe(''); + + // Open the menu and click "Load" on the preset (apply-stay variant — + // clicking the preset name itself would navigate to archive.html). + await page.locator('#openArchiveMenuBtn').click(); + await page.waitForTimeout(100); + await page.locator('.preset-menu-item:has(.preset-menu-item-name:has-text("My View")) .preset-load-btn').click(); + await page.waitForTimeout(150); + + await expect(page.locator('.project-table-row[data-name="176109"]')).toHaveClass(/is-selected/); + const ptVal = await page.locator('input.column-filter[data-column="pt"]').inputValue(); + expect(ptVal).toBe('Green'); + }); +}); diff --git a/tests/mdedit.spec.js b/tests/mdedit.spec.js index f55e100..2fb0f27 100644 --- a/tests/mdedit.spec.js +++ b/tests/mdedit.spec.js @@ -15,8 +15,9 @@ test.describe('Markdown Editor', () => { await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' }); await page.waitForSelector('#app', { timeout: 15000 }); - // Welcome screen is shown before any directory is opened - await expect(page.locator('#welcome-screen')).toBeVisible(); + // Scratchpad opens by default with welcome content seeded into the editor. + await expect(page.locator(`.file-item[data-path="__scratchpad__"]`)).toBeVisible(); + await expect(page.locator('#content-container')).toBeVisible(); // Select Directory button is present and enabled const selectDirBtn = page.locator('#select-directory'); diff --git a/transmittal/build.sh b/transmittal/build.sh index c0de714..c63e4fd 100755 --- a/transmittal/build.sh +++ b/transmittal/build.sh @@ -81,7 +81,7 @@ escape_js_close_tags "$readme_file" "$md_temp" compute_build_label "transmittal" "${1:-}" "${2:-}" -awk -v css_file="$css_temp" -v js_file="$js_temp" -v md_file="$md_temp" -v build_label="$build_label" -v is_red="$is_red" ' +awk -v css_file="$css_temp" -v js_file="$js_temp" -v md_file="$md_temp" -v build_label="$build_label" -v is_red="$is_red" -v favicon_uri="$favicon_data_uri" ' BEGIN { css_inserted = 0 js_inserted = 0 @@ -140,6 +140,11 @@ awk -v css_file="$css_temp" -v js_file="$js_temp" -v md_file="$md_temp" -v build print next } + /\{\{FAVICON\}\}/ { + gsub(/\{\{FAVICON\}\}/, favicon_uri) + print + next + } { print } END { if (!css_inserted) { diff --git a/transmittal/template.html b/transmittal/template.html index 9faf8e0..bfda20b 100644 --- a/transmittal/template.html +++ b/transmittal/template.html @@ -12,6 +12,7 @@ conventions at https://codeberg.org/VARASYS/ZDDC#file-naming-convention. ZDDC Transmittal + diff --git a/website/install.zip b/website/install.zip index 6c78cc4d8c0dd717e26d0902b2d50b7112752d08..84bbdeb3ec9455f410326649561ffb97054d2282 100644 GIT binary patch delta 7120 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 diff --git a/website/releases/archive_alpha.html b/website/releases/archive_alpha.html index 78379e4..b169508 100644 --- a/website/releases/archive_alpha.html +++ b/website/releases/archive_alpha.html @@ -4,6 +4,7 @@ ZDDC Archive + @@ -683,7 +884,7 @@ body {
ZDDC Archive - Built: 2026-04-28 23:40:19 BETA + alpha · 2026-04-29 17:45:13 · cf4101b-dirty
@@ -691,38 +892,590 @@ body {
+ +
+

Welcome to the ZDDC Archive

+

+ Pick the projects you want to view, then open the archive. Filter by + project number or title, and save your selection as a preset to + share or come back to later. +

+
+ +
-

Select Projects

-
- -
- - -
- - +
+

Projects

+
-
- +
+ +
Loading projects…