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 6c78cc4..84bbdeb 100644 Binary files a/website/install.zip and b/website/install.zip differ 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…