diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e10ef8..d46e93d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,6 +85,9 @@ jobs: - name: Multi-window history smoke run: npm run test:multi-window-history-smoke + - name: Split view animation smoke + run: npm run test:split-smoke + - name: Keyboard guards smoke run: npm run test:keyboard-guards-smoke diff --git a/README.md b/README.md index 23b6d54..7e81bb0 100644 --- a/README.md +++ b/README.md @@ -77,14 +77,14 @@ Prefer to browse one board at a time? Switch to **Focus Flow** for large preview ### More than a collection of images -Keep the thinking alongside the references. Add text notes, lists, drawings, and arrows; tag images to find them again; and pull a color palette from a reference. +Keep the thinking alongside the references. Add text notes, lists, drawings, and arrows; label images to find them again; and pull a color palette from a reference. | Tool | What you can do | |---|---| | **Arrange & group** | Resize with proportions intact, rotate, snap to nearby items, and organize references in named groups. | | **Notes & drawing** | Add formatted notes, checklists, pen marks, and arrows to explain an idea. | | **Image tools** | Crop, flip, view in grayscale, and sample colors with the eyedropper. | -| **Tags & search** | Label references, filter by tag, and search within a board. | +| **Labels & search** | Label references, filter by label, and search within a board. | | **Navigation** | Zoom at the cursor, pan across the canvas, fit the selection or whole board, and jump around with the minimap. | | **Appearance** | Choose from six dark themes, toggle the dot grid, and keep tools compact or always visible. | @@ -104,9 +104,10 @@ like any other document. Saving adds only new or changed images to the file, so a save on a large board takes a moment instead of rewriting everything. Boards from older -versions open as before and become the current format on their next save, -with the previous file kept once as `.refboard.bak`. Boards saved by 2.1.2 or -later need **RefBoard 2.1.2 or later**. See [board files](docs/BOARD_FILES.md). +versions open as before and become the current format on their next save. +A temporary `.refboard.bak` protects file replacement and is removed after a +successful save; an interrupted replacement keeps it available for recovery. +Boards saved by 2.1.2 or later need **RefBoard 2.1.2 or later**. See [board files](docs/BOARD_FILES.md). ## Keyboard shortcuts @@ -120,7 +121,7 @@ later need **RefBoard 2.1.2 or later**. See [board files](docs/BOARD_FILES.md). | Pan / zoom | **Space+drag** / **Mouse wheel** | | Add a text note | **Shift+T** | | Group / ungroup | **Ctrl+G** / **Ctrl+Shift+G** | -| Search / tags | **Ctrl+F** / **Ctrl+Shift+T** | +| Search / labels | **Ctrl+F** / **Ctrl+Shift+T** | | Toggle the minimap | **M** | | Undo / redo | **Ctrl+Z** / **Ctrl+Shift+Z** | | Export board / images | **Ctrl+E** / **Ctrl+Shift+I** | diff --git a/bootstrapper/main.js b/bootstrapper/main.js index 046347f..eea2345 100644 --- a/bootstrapper/main.js +++ b/bootstrapper/main.js @@ -1,6 +1,6 @@ 'use strict'; -const { app, BrowserWindow, ipcMain, shell } = require('electron'); +const { app, BrowserWindow, ipcMain } = require('electron'); const path = require('path'); const { spawn } = require('child_process'); const fs = require('fs'); @@ -113,8 +113,11 @@ ipcMain.handle('installer:start', async () => { } }); -// Launch the freshly installed RefBoard, then quit the bootstrapper. -ipcMain.handle('installer:launch', async () => { +// Only leave the installer once Windows has accepted the launch. A missing or +// blocked executable must leave the Launch button available for another try. +let launchInFlight = null; + +function launchInstalledApp() { // Per-user install path used by electron-builder NSIS (perMachine:false). const localAppData = process.env.LOCALAPPDATA || ''; const guesses = [ @@ -123,15 +126,33 @@ ipcMain.handle('installer:launch', async () => { ]; const exe = guesses.find((p) => p && fs.existsSync(p)); - if (exe) { - spawn(exe, [], { detached: true, stdio: 'ignore' }).unref(); - } else if (localAppData) { - // Fallback: open the Programs folder so the user can find it. - shell.openPath(path.join(localAppData, 'Programs', 'RefBoard')); - } + if (!exe) return Promise.resolve({ launched: false, reason: 'app-not-found' }); + + return new Promise(resolve => { + let child; + try { + child = spawn(exe, [], { detached: true, stdio: 'ignore', windowsHide: true }); + } catch (err) { + resolve({ launched: false, reason: 'spawn-failed', error: String(err) }); + return; + } + child.once('error', err => { + resolve({ launched: false, reason: 'process-error', error: String(err) }); + }); + child.once('spawn', () => { + child.unref(); + resolve({ launched: true }); + }); + }); +} - setTimeout(() => app.quit(), 400); - return { launched: Boolean(exe) }; +ipcMain.handle('installer:launch', async () => { + if (launchInFlight) return launchInFlight; + launchInFlight = launchInstalledApp(); + const result = await launchInFlight; + if (result.launched) setTimeout(() => app.quit(), 400); + else launchInFlight = null; + return result; }); // Window chrome controls (your app.js calls minimize / close). diff --git a/build/installer-ui/app.js b/build/installer-ui/app.js index 62011e0..5bb319f 100644 --- a/build/installer-ui/app.js +++ b/build/installer-ui/app.js @@ -62,6 +62,7 @@ let autoTimer = null; let paused = false; let installing = false; let installComplete = false; +let launching = false; const sceneElements = features.map((feature, index) => { const scene = document.createElement('div'); @@ -169,10 +170,29 @@ function animateInstallPhase(from, to, duration, label, meta) { async function simulateInstall() { if (installComplete) { - if (window.RefBoardInstaller?.launch) window.RefBoardInstaller.launch(); - else { + if (launching) return; + if (!window.RefBoardInstaller?.launch) { installState.textContent = 'Launch is ready'; installMeta.textContent = 'The packaged installer will now open RefBoard.'; + return; + } + launching = true; + installButton.disabled = true; + installButton.querySelector('.button-label').textContent = 'Launching\u2026'; + let result; + try { + result = await window.RefBoardInstaller.launch(); + } catch { + result = { launched: false }; + } + if (result?.launched !== true) { + launching = false; + installButton.disabled = false; + installButton.querySelector('.button-label').textContent = 'Retry launch'; + installState.textContent = 'Could not launch RefBoard'; + installMeta.textContent = result?.reason === 'app-not-found' + ? 'The installed app could not be found. Run setup again to repair the installation.' + : 'Windows could not start the app. Please try again.'; } return; } diff --git a/docs/BOARD_FILES.md b/docs/BOARD_FILES.md index d53d2ab..f42e4da 100644 --- a/docs/BOARD_FILES.md +++ b/docs/BOARD_FILES.md @@ -13,15 +13,21 @@ indexes leave dead bytes in place until enough have piled up (4 MB and a quarter of the file), when the next save writes a fresh compact file. A save interrupted by a crash leaves the previous state intact. +When replacing a board file, RefBoard temporarily keeps the previous file as +`Name.refboard.bak`. A successful replacement removes this temporary backup. +If replacement is interrupted and the main file is missing, RefBoard restores +the backup when opening the board or loading recent boards. It never promotes +unfinished `.saving-*` files. Backups left by older versions are removed after +the board's next successful save or preview update. + ## Older boards - Boards saved by RefBoard 2.0.x kept every image inside one JSON file. They - open as before and become the current format on their next save, with the - previous file kept once as `Name.refboard.bak`. + open as before and become the current format on their next successful save. - Boards saved by RefBoard 2.1.0 and 2.1.1 came as a pair: `Name.refboard` plus `Name.refboard.images`. Keep the pair together until the board has been opened and saved once in RefBoard 2.1.2 or later; that save folds the - images into the single file, keeps the previous index as - `Name.refboard.bak`, and removes the `.images` file. + images into the single file and removes the `.images` file and temporary + `.bak` once conversion succeeds. Boards saved by RefBoard 2.1.2 or later open only in 2.1.2 or later. diff --git a/docs/RELEASE_AUDIT.md b/docs/RELEASE_AUDIT.md new file mode 100644 index 0000000..1e6a6e0 --- /dev/null +++ b/docs/RELEASE_AUDIT.md @@ -0,0 +1,89 @@ +# Release audit — 2026-09-27 + +Scope: RefBoard 2.1.3 development changes, including the label workflow, +temporary backups and split animation. Two agents independently covered runtime smoke tests +and security/dead code. This records tested behavior; it is not a guarantee that +every bug or vulnerability has been found. No release was published. + +## Issues fixed during this audit + +- Restore label colors when opening another board, clear inherited colors when + the opened file has none, and include colors in crash-recovery session data. + The label smoke now leaves the current board before reopening its saved file; + opening the current path had previously bypassed the actual load. +- Close container and legacy sidecar handles when new-file initialization fails. + Forty-two fault-injection cases exercise truncate, write, sync, cleanup failure, + and caller ownership after success across both formats. +- Handle synchronous and asynchronous installer launch failures. Wait for the + process to spawn before quitting; failed launches keep the installer open with + Retry launch. Tests cover failed/rejected calls, retries and duplicate requests. +- Remove six unused main-process imports and the unused bootstrapper shell import. + Dynamically used renderer functions and compatibility modules were retained. + +## Verification passed + +- `npm test`: all 68 commands passed after the fixes, including file replacement, + crash recovery, concurrent saves, session abort, clipboard, container/sidecar + integrity, installer retry, and the new handle lifecycle checks. +- Twenty isolated Electron smoke invocations covered keyboard guards, autosave + failure, undo memory, multiple windows, split view, save/open, view/theme + persistence, image edit isolation, lossless JPEG/WebP crop exports, content-aware + worker cancellation, clipboard, landing layout, search, groups, grid rendering, + drag-out staging and zoom. History checks used 2,000 items; the 500-image zoom + check reported zero renderer errors or quality downgrades. +- Six additional final smoke runs passed: labels from source and the packaged + executable, label context menus, legacy/sidecar conversion, incremental saves, + and aborted saves. Label checks include real file reload, session color data, + mixed selections, explicit removal, Undo/Redo, filtering and keyboard controls. +- Root and bootstrapper `npm audit --json`: zero known dependency vulnerabilities + reported. Reviewed navigation restrictions, external URL handling, IPC session + ownership, file boundaries, Electron isolation and dynamic text rendering; + no confirmed exploitable issue was identified in the inspected paths. +- An isolated Windows unpacked build completed with publishing disabled. Its + label smoke passed, final runtime files matched source, and inline script + syntax checks passed. The build stayed outside the release output directory. +- `git diff --check` passed. + +## Split animation follow-up + +Two agents separately investigated frame pacing and renderer layout. On the +tested Windows display, a nominal 16 ms main-process timer produced roughly +31 ms layout updates while display frames arrived every 6.3 ms. Changing canvas +width also reset its unchanged height, clearing the backing surface twice per +step. After closing, a separate CSS height transition briefly squeezed the board. + +The slide now uses primary-renderer animation frames, a monotonic clock, a +400 ms easing curve with smooth acceleration, and a single completion deadline +for hidden or stalled renderers. Sender and animation identity checks reject +unrelated or stale callbacks. Native bounds are applied only when changed. +The primary canvas keeps its full backing surface while clipping follows the +divider; its top moves with the slide without stretching its height. The +minimap updates independently of full-board redraws. + +The final 121-item development smoke measured 6.2/6.3 ms median opening/closing +layout intervals, with 43/43 and 52/52 observed display frames advancing the +slide. Canvas dimension writes dropped from 26/22 to 2/2. These measurements +describe this machine, not a guaranteed refresh rate on every device. +Motion and 450 ms of post-close samples showed no canvas stretching beyond +one CSS pixel of DPI rounding. Reopening, divider movement, item preservation, +and renderer error checks passed. The rebuilt Windows app also passed the +enhanced split smoke. CI now runs this regression check on pushes. + +Additional unit cases cover stale frame IDs, wrong senders, reversal, cancellation, +failed loading, fallback completion, frame-loop cleanup, stable canvas sizing, +and both titlebar policies. + +## Remaining release checks + +- Run the actual installer and upgrade an existing installation on Windows. + Installer error handling has automated coverage; end-to-end installation and + auto-update execution were not exercised by this audit. +- Manually complete native drag-and-drop into another application. Automated + coverage verifies the staged files, not another application's drop handling. +- Follow `MAINTAINING.md` for versioning, release notes, installer payload refresh + and packaging before publishing. This audit did not update release artifacts. + +Local final test/build logs are in the Windows temporary directory as +`refboard-release-final-tests.log` and `refboard-release-final-build.log`. +Split follow-up logs use `refboard-smooth-final-tests.log` and +`refboard-smooth-final-build.log`. diff --git a/index.html b/index.html index 3946109..e1ddf51 100644 --- a/index.html +++ b/index.html @@ -481,10 +481,6 @@ } .tag-chip-label{ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .tag-chip-count{ opacity:.6; font-variant-numeric:tabular-nums; } -/* A partial chip marks a tag only some of the selection carries, so removing - it does not look like it is doing nothing to the rest. */ -.tag-chip.partial .tag-chip-label{ opacity:.65; font-style:italic; } - /* A tag row is a colour well joined to the filter chip, so setting a colour and filtering by the tag are one control rather than two lists. */ .tag-row{ @@ -525,13 +521,17 @@ .tag-color-sw.on{ box-shadow:0 0 0 2px var(--panel), 0 0 0 4px #fff; } .tag-color-none{ width:100%; } -.tag-panel{ width:266px; } +.tag-panel{ width:min(340px, calc(100vw - 96px)); overflow-y:auto; } +.tag-panel,.tag-suggestions,#ctxSub:has(.mi-tag){ scrollbar-width:thin; scrollbar-color:var(--line-strong) transparent; } +.tag-panel > *{ flex-shrink:0; } .tag-sel{ display:flex; flex-direction:column; gap:7px; - padding-bottom:9px; border-bottom:1px solid var(--line); + padding-bottom:9px; } .tag-sel.empty .tag-pop-chips, -.tag-sel.empty .tag-pop-input{ display:none; } +.tag-sel.empty .tag-input-row, +.tag-sel.empty .tag-suggestions, +.tag-sel.empty .tag-applied-title{ display:none; } .tag-sel.empty #tagSelClear{ display:none; } .tag-more{ border-top:1px solid var(--line); } .tag-more-head{ @@ -549,16 +549,37 @@ } .tag-more-badge:empty{ display:none; } .tag-more-body{ display:flex; flex-direction:column; gap:8px; padding-top:4px; } -.tag-pop-chips{ display:flex; flex-wrap:wrap; gap:6px; margin-bottom:8px; } +.tag-pop-chips{ display:flex; flex-direction:column; gap:6px; } .tag-pop-chips:empty{ display:none; } +.tag-input-row{ display:flex; gap:6px; } +.tag-input-row .tag-pop-input{ min-width:0; flex:1; } +.tag-add{ border:0; border-radius:8px; padding:0 12px; background:var(--acc); color:var(--acc-contrast); font-weight:600; } +.tag-add:disabled{ opacity:.45; cursor:default; } +.tag-suggestions{ display:flex; flex-direction:column; gap:3px; max-height:150px; overflow-y:auto; } +.tag-suggestion{ display:flex; align-items:center; gap:8px; width:100%; text-align:left; padding:7px 8px; border:0; border-radius:7px; background:var(--surface-2); color:var(--txt); font-size:11.5px; } +.tag-suggestion > span:first-child{ flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.tag-suggestion > span:last-child{ color:var(--dim); font-size:10.5px; flex-shrink:0; } +.tag-suggestion:hover:not(:disabled){ background:color-mix(in srgb, var(--acc) 16%, var(--surface-2)); } +.tag-suggestion:disabled{ opacity:.6; cursor:default; } +.tag-applied-title{ font-size:11px; color:var(--dim); margin-top:5px; } +.tag-assignment{ display:flex; align-items:center; gap:7px; padding:6px; border:1px solid var(--line); border-radius:8px; color:var(--txt); } +.tag-assignment-info{ flex:1; min-width:0; display:flex; flex-direction:column; gap:2px; } +.tag-assignment-name{ font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.tag-assignment-scope{ font-size:10.5px; color:var(--dim); } +.tag-apply,.tag-remove{ flex-shrink:0; border:1px solid var(--line); border-radius:6px; background:transparent; color:var(--txt); padding:5px 7px; font-size:11px; } +.tag-remove{ width:28px; height:28px; padding:0; font-size:17px; } +.tag-apply:hover{ background:color-mix(in srgb, var(--acc) 18%, transparent); } +.tag-remove:hover{ color:var(--danger); background:color-mix(in srgb, var(--danger) 12%, transparent); } +#tagSelClear{ align-self:flex-start; margin-top:5px; text-align:left; } +.tag-panel button:focus-visible,.tag-assignment button:focus-visible{ outline:2px solid var(--acc); outline-offset:2px; } +.tag-panel-labels{ flex-direction:column; align-items:flex-start; border:0; padding:8px 0 2px; } .tag-pop-input{ width:100%; box-sizing:border-box; background:var(--void); border:1px solid var(--line); border-radius:8px; - color:#e7eaf2; font-size:12px; padding:7px 9px; outline:none; + color:var(--txt); font-size:12px; padding:7px 9px; outline:none; } .tag-pop-input:focus{ border-color:color-mix(in srgb, var(--acc) 55%, transparent); } .tag-pop-hint{ font-size:10.5px; color:var(--dim); margin-top:7px; line-height:1.5; } -/* Empty most of the time now, and an empty line still takes up a line. */ .tag-pop-hint:empty{ display:none; } #selbar .sel-multi-only{ display:none; } @@ -2119,14 +2140,8 @@ .mi.has-sub .sub-arrow{ font-size:11px; color:var(--dim); margin-left:8px; } .mi.on{ color:var(--txt); } .mi.on .k{ color:var(--acc); } -/* A tag row is dot, label, then the tick — the label takes the slack so the - tick stays on the right whatever the tag is called. */ -.mi-tag{ gap:0; padding-left:6px; } -.mi-tag > span:not(.k){ - flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; - margin-right:18px; -} -.mi-tag .tag-dot{ padding:0 8px 0 2px; } +.mi-tag{ width:300px; max-width:calc(100vw - 40px); cursor:default; margin:3px 0; } +.mi-tag .tag-dot{ padding:0 3px; } .mhr{ height:1px; background:var(--line); margin:5px 8px; } /* ---------- context submenus (alignment, normalize, …) ---------- */ @@ -2136,6 +2151,7 @@ padding:5px; box-shadow:0 14px 44px rgba(0,0,0,.55); backdrop-filter:blur(16px); } #ctxSub.show{ display:block; } +#ctxSub:has(.mi-tag){ max-height:min(70vh, 560px); overflow-y:auto; } /* ---------- note link hover tooltip ---------- */ #noteLinkPop{ @@ -3039,6 +3055,11 @@ body.split-active:not(.pane-secondary){ --split-inset-right:calc(100vw - var(--split-left, 100vw)); } +body.split-active.split-animating #board{ + /* Move the visible edge without reallocating or stretching the canvas. */ + clip-path:inset(0 var(--split-inset-right) 0 0); + transition:none; +} body.split-active:not(.pane-secondary) #titlebar, body.split-active:not(.pane-secondary).board-active:not(.titlebar-revealed) #titlebar{ transform:none !important; opacity:1 !important; pointer-events:auto !important; @@ -3053,6 +3074,10 @@ body.split-active:not(.pane-secondary).board-active:not(.titlebar-revealed) #board{ top:var(--titlebar-h); height:calc(100vh - var(--titlebar-h)); } +body.split-active.split-animating.board-active:not(.titlebar-revealed) #board{ + /* Slide under the shared titlebar without squeezing the painted surface. */ + top:calc(var(--titlebar-h) * var(--split-progress, 0)); +} body.split-active:not(.pane-secondary) #selbar, body.split-active:not(.pane-secondary) #toast, body.split-active:not(.pane-secondary) #saveToast{ @@ -3279,7 +3304,7 @@

Paste, drop, or add images to start your board

- @@ -3420,7 +3445,7 @@

Paste, drop, or add images to start your board

- @@ -3479,45 +3504,52 @@

Paste, drop, or add images to start your board

-