diff --git a/docs/product-contract.md b/docs/product-contract.md index 7323418..9e11721 100644 --- a/docs/product-contract.md +++ b/docs/product-contract.md @@ -21,33 +21,35 @@ Vapora runs locally on Node.js 24 and uses TypeScript and Effect 4. The Python i - Diagnose damaged saved runs individually while continuing to list healthy runs and allowing new scans. Never repair or delete damaged files automatically. - Fetch summary and ban information in batches of at most 100. Optional owned-game overlap and shared-group links must use real provider data and expose denied or unavailable access. - Export canonical `scan.json`, `analysis.json`, `probable-friends.csv`, `gephi/nodes.csv`, `gephi/edges.csv`, and timestamped `run.log` under a unique run directory. Escape CSV fields correctly and prevent spreadsheet formulas in untrusted text fields. -- Offer an Obsidian vault ZIP from saved results without provider requests. Include an index and a Markdown note for every admitted profile and referenced friend, with stable Steam-ID filenames, properties and internal links for observed friendships. Preserve unknown/partial collection states, dated historical observations and warnings. Escape untrusted names and comments, include no credentials, and require no Obsidian plugin. Exporting must not rewrite the checkpoint. Reconstruct graph metrics and compress the vault off the server thread. Terminate its worker when the download connection closes, including during server shutdown. Include group and game collection states so disabled, private, skipped, unavailable and unknown signals remain distinct. Keep observed current names; dated history can name unobserved profile placeholders. +- Offer an Obsidian vault ZIP directly from the main Results actions as well as Exports, without provider requests. Show the shortcut only while its selected report is visible; background report updates must not restore it over the saved-run library. Include an index and a Markdown note for every admitted profile and referenced friend, with stable Steam-ID filenames, properties and internal links for observed friendships. Preserve unknown/partial collection states, dated historical observations and warnings. Escape untrusted names and comments, include no credentials, and require no Obsidian plugin. Exporting must not rewrite the checkpoint. Reconstruct graph metrics and compress the vault off the server thread. Terminate its worker when the download connection closes, including during server shutdown. Include group and game collection states so disabled, private, skipped, unavailable and unknown signals remain distinct. Keep observed current names; dated history can name unobserved profile placeholders. - User-facing labels, tooltips, errors, generated note text and documentation call historical data History or account history, without the provider's brand. Apply the same display wording to diagnostics in saved captures, including partial-coverage warnings. Preserve original imported bytes, wire-format identifiers and provenance URLs unchanged. - Compute degree, normalized betweenness, deterministic Louvain communities, and percentile-based hubs. Treat friendships as undirected; group links remain a distinct kind. Graph metrics use friendship edges, so group co-membership does not distort friendship centrality. - Rank direct friends with configurable mutual, Jaccard, shared-group, and shared-game signals. Explain missing observations and graph truncation. Display friend and location estimates as heuristic scores, never calibrated real-world probabilities. - Support local normalized account history JSON and NDJSON imports with validated timestamps and individual Steam IDs. Preserve profile/history details and rank friendship durations separately from network scores. - Provide a CLI with scan, estimate, resume, history, analyze, serve, profiles, and recent-run operations. Support --skip-private and depth 1-5. Estimates respect the private-profile policy and are labeled sampling estimates bounded by the cap. Estimates at depth 3-5 explicitly describe first-two-level coverage rather than predicting the full selected depth. - Provide a working local browser UI for configuration, estimation, scanning, cancellation, resume, reports, graph exploration, imports, saved profiles, and export downloads. Render names as text, support keyboard navigation, show actual progress, and retain UI state through polling. -- Polish the existing `feature/gui-and-analysis` composition: compact toolbar, scan parameters above mode/output on the left, and target/profile/actions on the right. Use its classic Steam olive surfaces, inset fields, beveled controls, compact type, and mustard selection accents. Use classic VGUI page tabs that join their panels with an open bottom edge, and full 1px bevels on action buttons. Bundle the original Motiva Sans regular, medium and bold files and original checkbox bitmaps. Use 16px scan labels and a 210px target avatar at the 1078px desktop width. Put repeated identifiers and run timestamps in profile links, tooltips and run details. Show connection status behind the toolbar key button. Keep one stylesheet, visible focus and scrollbars, and no looping animation. +- Polish the existing `feature/gui-and-analysis` composition: compact toolbar, scan parameters above mode/output on the left, and target/profile/actions on the right. Use its classic Steam olive surfaces, inset fields, beveled controls, compact type, and mustard selection accents. Use classic VGUI page tabs that join their panels with an open bottom edge, and full 1px bevels on action buttons. Bundle the original Motiva Sans regular, medium and bold files and original checkbox bitmaps. Use 16px scan labels and a 210px target avatar at the 1078px desktop width. Put repeated identifiers and run timestamps in profile links, tooltips and run details. Show connection status behind the toolbar key button. Keep one stylesheet, visible focus and square beveled Steam scrollbars on every scrolling surface, and no looping animation. - Size the Steam window to its content, with a compact green toolbar rather than a black banner. Keep recent-run lists bounded and avoid stretching empty panels. Omit repeated helper text and native disclosure sections; keep method and file-format documentation in the README and short contextual tooltips. Keep run failures visible. - Use `assets/vapora.svg`, cropped to the original white eye only, without its outer circle, with a tightly cropped eye-shaped viewBox and a 20x11px toolbar size. Blue regions must be transparent so the green toolbar shows through. The SVG contains vector paths, not an embedded raster or runtime font. Preserve the source PNG and existing favicon. Bundle local visual assets and serve them through an explicit allowlist. Resolve a target only through an explicit check-button action using Steam summaries; this does not create a scan or fetch friend lists. No lookup runs while typing. -- Provide two main tabs, Scan and Results. Results includes a searchable saved-run list with names, avatars, timestamps, node counts and statuses; the avatar rail remains a shortcut. Open History from the third rail button beneath Save settings and Load settings, even when no run is selected, and connection settings from the key button. Switching views preserves inputs and reports. Abort a pending saved-run request when another run is selected or the user leaves its view, allowing its analysis worker to stop; opening a saved or completed run shows Results unless the user navigates elsewhere while it loads. Progress and cancellation stay accessible in every view. Stack the layout at narrow widths without page-level horizontal overflow. +- Provide two main tabs, Scan and Results. Results includes a searchable saved-run list with names, avatars, timestamps, node counts and statuses; the avatar rail remains a shortcut. Open History from the third rail button beneath Save settings and Load settings, even when no run is selected, and connection settings from the key button. Switching views preserves inputs and reports. Failed History refreshes retain both the saved capture and the latest error with Retry when the viewer reopens. Abort a pending saved-run request when another run is selected or the user leaves its view, allowing its analysis worker to stop; opening a saved or completed run shows Results unless the user navigates elsewhere while it loads. Progress and cancellation stay accessible in every view. Stack the layout at narrow widths without page-level horizontal overflow. - Report separate public, private, skipped, unavailable and pending friend-list counts. Show a concise partial-results notice naming the actual causes, omitting zero counts. Disabled signals display Off, private comparisons display Private, unfinished observations display Not scanned, and absent location fields display Not provided. Preserve nulls in exports rather than inventing observations. Open run metadata and detailed warnings in a Run info dialog. - Enter an API key in a compact Steam-style dialog without replacing the workspace or repeating the input heading. Make setup visible when no key exists; scan, lookup, estimate and resume actions can open the dialog and continue after successful validation. Mask input by default, support Show/Hide and trimmed paste, verify the candidate with Steam before replacing the active key, and show failures inside the dialog. Clear the input on close. Browser keys stay in session memory. Desktop offers opt-in Remember API key using OS-backed Electron safeStorage encryption; reject unavailable encryption and Linux basic_text storage. Save only ciphertext in the desktop data directory. Forget saved key removes that file while keeping the current session usable. Never return keys in state, runs or exports. - The recent-avatar rail preselects a scan target using its saved name and avatar without fetching a report or making Steam requests. Selecting an account can load its account history capture independently. Keep scan settings unchanged. Saved results open through the Results run list. Show estimates as aligned direct-friend, approximate-profile and sample counts, with the complete sampling caveat available through in-app help rather than a paragraph under the actions. -- Contextual help uses an in-app tooltip with the existing olive colors and 1px bevels. Keep help adjacent to its trigger, choosing among its four sides and clamping within the viewport; never detach it to an unrelated window edge to avoid controls. Prefer the adjacent position with the least control overlap. Support pointer hover, keyboard focus, click/tap and Escape dismissal. Help buttons have accessible names and descriptions; tooltips stay readable while hovered, scroll within short viewports and never leave a native title tooltip on their triggers. +- Contextual help uses an in-app tooltip with the existing olive colors and 1px bevels. Give help a descriptive heading and separate instructions or facts into readable lines; avoid an undifferentiated paragraph. Keep help adjacent to its trigger, choosing among its four sides and clamping within the viewport; never detach it to an unrelated window edge to avoid controls. Prefer the adjacent position with the least control overlap. Support pointer hover, keyboard focus, click/tap and Escape dismissal. Help buttons have accessible names and descriptions; tooltips stay readable while hovered, scroll within short viewports and never leave a native title tooltip on their triggers. - Keep ranking labels short and consistent in both forms: Count scale, Location rule, Location scale and Hub cutoff. All nine ranking controls offer in-app help on hover and keyboard focus. Explain what each setting changes and the effect of higher, lower or zero values where applicable. Define betweenness and overlap, clarify that Top N sets the score reference rather than the number of displayed results, and explain the all-zero weights behavior. Keep labels and 32px controls aligned, including wrapped labels and narrow dialogs. Dialog errors stay inside the active dialog, with an announced message and field association where applicable. Network failures identify the failed action and how to retry. Informational headings and table columns expose explanations through focusable help buttons. - Keep report and history tabs on one scrollable row with visible overflow controls. Selecting a tab brings it into view without moving the page. Format summary dates, boolean values and singular/plural durations for reading; retain exact values in original records and exports. Comments use names and avatars already retained in that history report, with Steam IDs available on their links and no new identity requests. - Background connection errors replace stale status notices, including success messages. Preserve action-error notices until the next action so polling cannot erase failed-save guidance. - Open profile Details from ranked friends and graph nodes in independent, movable, non-modal Steam-style windows. Several profiles or history records can remain open without replacing one another or narrowing the report. Support keyboard movement, close controls and Escape dismissal of the focused window. Return focus to the opening control or its redrawn graph node when it remains visible. Show avatar, Steam link, collection depth, privacy, ban records, observation availability, degree, betweenness, community and hub status. Distinguish missing ban data from confirmed records. - Show Network as a compact, fitted preview with profile search and Maximize view. Expand the existing panel in-app without opening a browser tab, retaining the same olive Steam fonts, controls and detail windows. Use one Sigma/Graphology renderer for both sizes and represent every observed connection without an arbitrary edge cap. Clearly distinguish total counts, filtered counts and the selected profile's connections. +- Save image redraws the current filtered camera view as a PNG with a 4,096-pixel long edge, independent of window size or display density. Draw graph geometry and text at export resolution and use loaded original avatar images rather than enlarging the viewport bitmap. Keep the current camera, filters, selection and layout intact; show export failures visibly. +- Align the Friends search field directly above its table, with its compact label beside the input rather than lifting the input into a separate stacked row. - The explorer supports pan, zoom, node dragging/pinning, profile search, community and friend-list filters, minimum connections of the selected connection type across the collected graph, one/two-hop neighbourhoods, label/size controls, image export and existing graph-data exports. Show the selected profile's avatar and currently visible connection count; Details opens independent movable windows. Offer a paginated keyboard-accessible profile list alongside the canvas. Keep filtering distinct from selection highlighting and preserve unknown/private coverage. - Render saved profile pictures inside circular nodes with community-coloured borders when zoom/size makes them readable. Load only visible readable pictures, bound concurrent image loads, use the existing local placeholder for absent or failed images, and release image work with its renderer. Pictures share the GPU renderer and PNG export; never fetch Steam API data for them. -- Update hover/selection styles without rebuilding graph indexes. Coalesce search updates per frame, keep appearance changes separate from graph filtering, and avoid repainting unchanged idle views. Verify interaction costs against the saved run and a denser fixture. -- Compute an edge-based ForceAtlas2 layout in a bundled worker, with a bounded run, pause/restart controls and visible failures. Stop layout work on hidden/unloaded pages and release replaced renderers and workers. Resume layout interrupted by hiding the view when it becomes visible again; preserve an explicit user pause. Build the preview only when Network is visible; preserve selection/search/camera across tab switches. Graph exploration loads saved observations without Steam requests, uploads or checkpoint edits. Restore view returns to the preview without losing camera, selection or filters. Escape restores the preview when no detail window or tooltip owns it. +- Update hover/selection styles without rebuilding graph indexes. Hover must not resize or reorder node hit targets, and graph labels must not steal pointer hover from circles. Coalesce search updates per frame, keep appearance changes separate from graph filtering, and avoid repainting unchanged idle views. Verify interaction costs against the saved run and a denser fixture. +- Compute an edge-based ForceAtlas2 layout in a bundled worker, automatically when Network first opens, with a bounded run, one Pause/Resume control and visible failures. A settled layout needs no Arrange button. Stop layout work on hidden/unloaded pages and release replaced renderers and workers. Resume layout interrupted by hiding the view when it becomes visible again; preserve an explicit user pause. Build the preview only when Network is visible; preserve selection/search/camera across tab switches. Graph exploration loads saved observations without Steam requests, uploads or checkpoint edits. Restore view returns to the preview without losing camera, selection or filters. Escape restores the preview when no detail window or tooltip owns it. - Provide Rebuild exports for completed runs without Steam authority or requests. Regenerate analysis/CSV exports from saved observations and ranking, retain attached history, and leave checkpoint bytes unchanged. Reject rebuilds for incomplete runs or while another Steam/analysis operation is active; report filesystem failures visibly. - Completed saved runs can be reranked from the Ranking report tab through an explicit Save ranking action without a Steam key or provider calls. Validate ranking weights and hub percentile, preserve all collected observations and collection settings, and persist the new ranking in the canonical checkpoint and regenerated exports. Reject changes while a Steam or analysis operation is active and reject incomplete runs. -- Reopen an attached history report from its saved run and expose history.json in exports. Standalone history imports remain available without a selected run. +- Reopen an attached history report as a real Results tab, keeping the run heading, other report tabs and Results navigation visible. Reuse the same History viewer for the standalone rail destination and attached tab, with the correct account and attachment context. Keep its heading, account, capture date and actions in one compact toolbar when space permits; wrap into aligned rows on narrow screens, without spreading metadata across the full width. Expose history.json in exports. Summarize partial coverage with section counts and in-app help for causes, without implying missing comments are necessarily deleted. Select the latest capture by the same parsed-time rule for both counts and diagnostics, keeping undated captures behind dated captures. Standalone history imports remain available without a selected run. - Give each scan/resume operation a unique operation ID, independent of its run ID and server instance. Process each terminal result once, including repeated failures before creating a run and repeated cancellation of the same run. - Show actionable failure messages in the browser and saved report; keep stack traces in the run log. - Provide browser and Electron desktop launches using the same server, scanner and renderer. Desktop window buttons minimize, maximize/restore and close the real window, using aligned window-frame glyphs and the actual native maximized state for the icon and action label. Open output folder opens the selected run directory or outputs root. Browser mode hides native window controls and provides export downloads. Its Exports button opens the selected run's downloads; without a selected run, show a notice to open a saved run and retain the current screen. Isolate the desktop renderer from Node and expose only fixed, main-frame-authorized IPC operations. Closing desktop cancels active collection and saves the checkpoint. diff --git a/docs/release-runbook.md b/docs/release-runbook.md index 25a0abb..937aff3 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -4,7 +4,7 @@ This runbook covers desktop downloads and source archives on GitHub. The package ## release candidate -The candidate is **2.2.4**, with [release notes](releases/2.2.4.md). It adds the in-app network explorer, profile pictures, nearby help, scan ETA and responsive cancellation. **2.2.3** remains the published release until this candidate passes review and native package checks. **2.2.1** was withdrawn; its tag remains fixed. Do not reuse either tag. +The candidate is **2.2.5**, with [release notes](releases/2.2.5.md). It improves graph image quality, hover stability, contextual help and Results navigation, and compacts the History toolbar. **2.2.4** remains the published release until this candidate passes review and native package checks. **2.2.1** was withdrawn; its tag remains fixed. Do not reuse either tag. Use the existing tag convention without a `v` prefix. Desktop installers start at `2.0.1`; `2.0.0` contains source archives only, and `1.0.2` contains the legacy Python executable. Publish only after authorization, resolved review findings and green CI on the pinned main commit. @@ -27,8 +27,8 @@ Use the existing tag convention without a `v` prefix. Desktop installers start a 5. Confirm that the intended tag does not already exist locally or remotely. Never move an existing release tag. ```sh - git tag --list 2.2.4 - git ls-remote --tags origin refs/tags/2.2.4 + git tag --list 2.2.5 + git ls-remote --tags origin refs/tags/2.2.5 gh release list --repo Microck/vapora ``` @@ -43,8 +43,8 @@ For a committed candidate, create an archive from its exact commit: ```sh VAPORA_RELEASE_COMMIT=REPLACE_WITH_REVIEWED_MAIN_COMMIT -git archive --format=tar.gz --prefix=vapora-2.2.4/ \ - --output=/tmp/vapora-2.2.4-source.tar.gz "$VAPORA_RELEASE_COMMIT" +git archive --format=tar.gz --prefix=vapora-2.2.5/ \ + --output=/tmp/vapora-2.2.5-source.tar.gz "$VAPORA_RELEASE_COMMIT" ``` Extract it into a fresh directory and run: @@ -81,21 +81,21 @@ Proceed only after explicit approval of the tag and notes, all findings are reso 4. Create an annotated tag at the pinned commit. Git is used here because Jujutsu does not create annotated tags: ```sh - git -c user.name=Microck -c user.email=contact@micr.dev tag -a 2.2.4 \ - "$VAPORA_RELEASE_COMMIT" -F docs/releases/2.2.4.md - git push origin refs/tags/2.2.4 + git -c user.name=Microck -c user.email=contact@micr.dev tag -a 2.2.5 \ + "$VAPORA_RELEASE_COMMIT" -F docs/releases/2.2.5.md + git push origin refs/tags/2.2.5 ``` 5. Wait for core CI and Desktop packages CI triggered by the new tag to pass on the pinned commit. Download that tag run's verified artifacts into `/tmp/vapora-desktop-downloads`. Compute `SHA256SUMS.txt` from the exact EXE, AppImage and DMG bytes, using their basenames. Confirm there are four tested downloads: Windows x64 installer and portable EXEs, Linux x64 AppImage, and macOS ARM64 DMG. Publish a normal GitHub release with those files attached from the start: ```sh - gh release create 2.2.4 \ + gh release create 2.2.5 \ /tmp/vapora-desktop-downloads/*/*.exe \ /tmp/vapora-desktop-downloads/*/*.AppImage \ /tmp/vapora-desktop-downloads/*/*.dmg \ /tmp/vapora-desktop-downloads/SHA256SUMS.txt \ --repo Microck/vapora --verify-tag \ - --title 'vapora 2.2.4' --notes-file docs/releases/2.2.4.md --latest + --title 'vapora 2.2.5' --notes-file docs/releases/2.2.5.md --latest ``` 6. GitHub also provides the source ZIP and tar.gz. Do not publish an empty source-only release while waiting for desktop builds. Do not attach the legacy executable, replace existing public assets with `--clobber`, publish to npm or mark the release as a prerelease. @@ -105,15 +105,15 @@ Proceed only after explicit approval of the tag and notes, all findings are reso 1. Confirm the release is published, not a draft, and points to the intended tag: ```sh - gh release view 2.2.4 --repo Microck/vapora \ + gh release view 2.2.5 --repo Microck/vapora \ --json url,tagName,isDraft,isPrerelease,publishedAt,assets - git ls-remote --tags origin refs/tags/2.2.4 'refs/tags/2.2.4^{}' + git ls-remote --tags origin refs/tags/2.2.5 'refs/tags/2.2.5^{}' ``` 2. Verify the annotated tag's peeled commit equals `VAPORA_RELEASE_COMMIT`. 3. Download a public source archive for the tag into a fresh directory. Repeat the source checks above against the actual public download. 4. Confirm both core and desktop tag CI passed. Download each attached installer and verify its checksum against `SHA256SUMS.txt` and the tested CI artifact. Check the release has both installer and portable EXEs, an AppImage and a DMG before calling it complete. Report a failed or incomplete platform check honestly. -5. Verify main and the historical `legacy`, `feature/gui-and-analysis` and `rewrite/typescript-effect` branches remain at their expected commits. +5. Verify main points to the released commit and the protected historical `legacy` branch remains at `a42279957cebe997a609f19da77bc13167be7205`. Previously removed feature branches must not be recreated. 6. Report the release URL, tagged commit, completed checks and outstanding platform limits. ## recover from a publishing problem diff --git a/docs/releases/2.2.5.md b/docs/releases/2.2.5.md new file mode 100644 index 0000000..5ebc309 --- /dev/null +++ b/docs/releases/2.2.5.md @@ -0,0 +1,15 @@ +# vapora 2.2.5 + +Graph images now export with a 4,096-pixel long edge, independent of the window size. Geometry and labels render at export resolution, using loaded original profile pictures. Camera position, filters and selection stay unchanged. + +- Network arranges automatically. Pause and Resume control the layout; hover no longer resizes or reorders circles. +- Help uses headings and short points beside its control, without covering the control itself. Scrollbars keep the square classic Steam bevels. +- Friends search sits directly above the table. The Obsidian vault download is also available in the main Results actions. +- Attached history stays in a Results tab. The standalone viewer remains available from the rail, with account states kept separate. Failed refreshes retain the saved capture and retry guidance when reopened. +- History uses a compact account/date toolbar and grouped actions. Coverage notices show counts, with details in contextual help. + +Image resolution does not add detail absent from an original avatar. Dense graphs can still take time to arrange and render. + +Windows downloads include an installer and a portable EXE. The portable app keeps data in `Vapora-data` beside the EXE and needs about 1 GB free in Windows TEMP, plus space for scan data. Move the EXE and data folder together. Downloads are unsigned and not notarized; verify them against `SHA256SUMS.txt`. + +Native package tests use local provider fixtures. This release does not change live Steam or history fetching behavior. diff --git a/docs/screenshots/ui-refinements/attached-history-after.png b/docs/screenshots/ui-refinements/attached-history-after.png new file mode 100644 index 0000000..fb85e10 Binary files /dev/null and b/docs/screenshots/ui-refinements/attached-history-after.png differ diff --git a/docs/screenshots/ui-refinements/friends-after.png b/docs/screenshots/ui-refinements/friends-after.png new file mode 100644 index 0000000..6c7dddb Binary files /dev/null and b/docs/screenshots/ui-refinements/friends-after.png differ diff --git a/docs/screenshots/ui-refinements/graph-help-after.png b/docs/screenshots/ui-refinements/graph-help-after.png new file mode 100644 index 0000000..888b921 Binary files /dev/null and b/docs/screenshots/ui-refinements/graph-help-after.png differ diff --git a/docs/screenshots/ui-refinements/history-after.png b/docs/screenshots/ui-refinements/history-after.png new file mode 100644 index 0000000..75ea35d Binary files /dev/null and b/docs/screenshots/ui-refinements/history-after.png differ diff --git a/docs/screenshots/ui-refinements/history-before.png b/docs/screenshots/ui-refinements/history-before.png new file mode 100644 index 0000000..f3c570b Binary files /dev/null and b/docs/screenshots/ui-refinements/history-before.png differ diff --git a/docs/screenshots/ui-refinements/history-header-before.png b/docs/screenshots/ui-refinements/history-header-before.png new file mode 100644 index 0000000..7650874 Binary files /dev/null and b/docs/screenshots/ui-refinements/history-header-before.png differ diff --git a/docs/screenshots/ui-refinements/network-after.png b/docs/screenshots/ui-refinements/network-after.png new file mode 100644 index 0000000..fbef272 Binary files /dev/null and b/docs/screenshots/ui-refinements/network-after.png differ diff --git a/docs/screenshots/ui-refinements/network-before.png b/docs/screenshots/ui-refinements/network-before.png new file mode 100644 index 0000000..831cad7 Binary files /dev/null and b/docs/screenshots/ui-refinements/network-before.png differ diff --git a/package-lock.json b/package-lock.json index 4827860..ded9720 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vapora", - "version": "2.2.4", + "version": "2.2.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vapora", - "version": "2.2.4", + "version": "2.2.5", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 7caaef6..f96b620 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vapora", - "version": "2.2.4", + "version": "2.2.5", "private": true, "description": "Explore public Steam friend networks", "type": "module", diff --git a/scripts/history-worker.py b/scripts/history-worker.py index 2ea90f5..726976c 100644 --- a/scripts/history-worker.py +++ b/scripts/history-worker.py @@ -111,7 +111,8 @@ async def navigate(): if __name__ == "__main__": try: asyncio.run(serve()) - except Exception: + except Exception as error: # Browser paths, cookies, challenge URLs and private page content stay out of IPC. - emit({"id": 0, "error": "History's browser could not load the profile. Retry or import a saved capture."}) + # The exception class gives support a safe failure code without exposing the provider response. + emit({"id": 0, "error": f"History's browser could not load the profile ({type(error).__name__}). Retry or import a saved capture."}) sys.exit(1) diff --git a/src/history.ts b/src/history.ts index 2341b87..ed051c9 100644 --- a/src/history.ts +++ b/src/history.ts @@ -68,14 +68,18 @@ const captureTime = (capturedAt: string) => { export function diagnosticText(message: string): string { return message.replace(/\bSteamHistory(?:\.net)?\b/gi, "History"); } -export function coverageError(bundle: Bundle): string | null { +/** Select coverage by parsed capture time, keeping undated sources behind dated captures. */ +export function latestCoverageSource(bundle: Bundle) { let latest: Bundle["sources"][number] | undefined; let latestDate = -Infinity; for (const source of bundle.sources) { if (!source.coverage.length) continue; const date = captureTime(source.capturedAt); if (!latest || date > latestDate) { latest = source; latestDate = date; } } - const issues = latest?.coverage.flatMap((section) => section.error ? [section.error] : []) ?? []; + return latest; +} +export function coverageError(bundle: Bundle): string | null { + const issues = latestCoverageSource(bundle)?.coverage.flatMap((section) => section.error ? [section.error] : []) ?? []; return issues.length ? diagnosticText(issues.join("\n")) : null; } diff --git a/test/browser-e2e.ts b/test/browser-e2e.ts index 659b06d..5c9f2c7 100644 --- a/test/browser-e2e.ts +++ b/test/browser-e2e.ts @@ -42,6 +42,7 @@ async function checkAdjacentHelp(page: Page, selector: string) { const horizontalGap = Math.max(0, box.left - trigger.right, trigger.left - box.right); const verticalGap = Math.max(0, box.top - trigger.bottom, trigger.top - box.bottom); return horizontalGap <= 7 && verticalGap <= 7 + && (box.right <= trigger.left || box.left >= trigger.right || box.bottom <= trigger.top || box.top >= trigger.bottom) && box.left >= 8 && box.right <= innerWidth - 8 && box.top >= 8 && box.bottom <= innerHeight - 8; }), true, "Help must stay beside its trigger and inside the viewport"); } @@ -59,7 +60,10 @@ async function checkSavedHistoryDiagnostics(page: Page, root: string, downloadDi const file = await page.$("input#history-file"); assert.ok(file); await file.uploadFile(path); await page.click('#history-form button[type="submit"]'); await page.waitForSelector("#history-import-dialog[open]", { hidden: true }); assert.doesNotMatch(await visibleText(page, "#history-warnings") ?? "", /steamhistory/i); - assert.ok((await visibleText(page, "#history-warnings"))?.includes(`8 comments records; this session returned ${comments.captured}`)); + assert.match(await visibleText(page, "#history-warnings") ?? "", /Comments:/); + const details = await page.$$eval("#history-warnings [data-tooltip]", (items) => items.map((item) => item.getAttribute("data-tooltip")).join("\n")); + assert.ok(details.includes(`8 comments records; this session returned ${comments.captured}`)); + assert.doesNotMatch(details, /steamhistory/i); const cachePath = join(root, "history", `${seed}.json`); const saved = Schema.decodeUnknownSync(Schema.fromJsonString(History.HistoryReport))(await readFile(cachePath, "utf8")); await writeFile(cachePath, JSON.stringify({ ...saved, warnings: [diagnostic] })); @@ -81,6 +85,8 @@ async function checkSavedHistoryDiagnostics(page: Page, root: string, downloadDi await page.click("#history-sources button:last-child"); assert.equal(await readDownload(downloadPath), contents); assert.match(await readFile(join(root, "history", `${seed}.json`), "utf8"), /SteamHistory's profile summary/); + // The imported capture is deliberately newer; subsequent refreshes must occur after its timestamp. + await delay(Math.max(0, Date.parse(capture.capturedAt) + 1001 - Date.now())); } async function checkUndatedRecords(page: Page) { for (const tab of ["persona", "realName", "url", "pfp"]) { @@ -367,6 +373,13 @@ test("browser recovers identity after denied keys, cancels/resumes, persists set await page.waitForFunction(() => !document.querySelector("#history-download")?.hidden); await page.reload(); await page.waitForFunction(() => document.querySelector("#report-status")?.textContent === "complete"); await page.click("#open-history"); assert.equal(await page.$eval("#history-rows", (rows) => rows.children.length), 1); + assert.equal(await page.$eval("#history-screen", (element) => element.checkVisibility()), false); + assert.equal(await page.$eval("#results-screen", (element) => element.checkVisibility()), true); + assert.equal(await page.$eval("#open-history", (element) => element.getAttribute("aria-current")), "page"); + assert.equal(await page.$eval("#history-view #history-content", (element) => element.checkVisibility()), true); + const shortcut = await page.$eval("#obsidian-shortcut", (element) => element instanceof HTMLAnchorElement && element.checkVisibility() ? element.href : ""); + assert.match(shortcut, /api\/obsidian\?id=/); + assert.equal(shortcut, await page.$eval("#obsidian-export", (element) => element instanceof HTMLAnchorElement ? element.href : "")); await page.click('[data-screen="results"]'); await page.click("#toggle-runs"); await page.click("#output-folder"); assert.equal(await page.$eval("#run-library", (element) => element instanceof HTMLElement && element.hidden), true); assert.equal(await page.$eval("#exports-view", (element) => element instanceof HTMLElement && element.hidden), false); @@ -486,11 +499,18 @@ test("history account selection, all viewer tabs, filters, original downloads an assert.match(capture.profile.contents, /customField/); history.counts.set("comments", 8); await page.click("#history-refresh"); await page.waitForFunction(() => document.querySelector("#target-history-status")?.textContent === "History partial"); - assert.match(await visibleText(page, "#history-warnings") ?? "", /supporter access/); + assert.match(await visibleText(page, "#history-warnings") ?? "", /Comments: 4 \/ 8 captured/); + assert.doesNotMatch(await visibleText(page, "#history-warnings") ?? "", /supporter access/); + await page.hover("#history-warnings .info"); await checkAdjacentHelp(page, "#history-warnings .info"); + assert.match(await visibleText(page, "#app-tooltip") ?? "", /supporter access/); + assert.equal(await page.$$eval("#app-tooltip li", (items) => items.length >= 2), true); + await page.keyboard.press("Escape"); assert.equal(await page.$eval("#history-warnings", (element) => element.getBoundingClientRect().bottom <= innerHeight), true); assert.equal(await page.$eval("#history-fetch-status", (element) => element instanceof HTMLElement && element.hidden), true); await page.click('[data-history="comments"]'); assert.equal(await page.$eval("#history-rows", (rows) => rows.children.length), 4); + // Account metadata and actions fit one toolbar at the normal desktop width. + assert.equal(await page.$eval(".history-header", (header) => header.getBoundingClientRect().height <= 36), true); if (process.env.VAPORA_BROWSER_SCREENSHOTS) await page.screenshot({ path: join(process.env.VAPORA_BROWSER_SCREENSHOTS, "history-partial-comments.png") }); history.setStatus(403); await page.click("#history-refresh"); await page.waitForFunction(() => document.querySelector("#history-fetch-status")?.textContent?.includes("blocked")); assert.equal(await page.$eval("#history-result", (element) => element instanceof HTMLElement && element.hidden), false); @@ -574,6 +594,19 @@ test("history account selection, all viewer tabs, filters, original downloads an assert.equal(history.requests(), requestsBeforeReopen); const cachedRequests = history.requests(); await checkSavedHistoryDiagnostics(page, root, downloadDirectory); assert.equal(history.requests(), cachedRequests); + history.setStatus(200); history.counts.clear(); await page.click("#history-refresh"); + await page.waitForFunction(() => document.querySelector("#target-history-status")?.textContent === "History ready"); + // A transport failure retains the capture and its error when the viewer reopens. + await page.setOfflineMode(true); await page.click("#history-refresh"); + await page.waitForFunction(() => document.querySelector("#target-history-status")?.textContent === "History unavailable"); + const refreshError = await visibleText(page, "#history-fetch-status"); assert.ok(refreshError); + await page.click('[data-screen="scan"]'); await page.click("#target-history"); + assert.equal(await visibleText(page, "#history-fetch-status"), refreshError); + assert.equal(await page.$eval("#history-retry", (button) => button.checkVisibility()), true); + assert.equal(await page.$eval("#history-result", (report) => report.checkVisibility()), true); + await page.setOfflineMode(false); await page.click("#history-retry"); + await page.waitForFunction(() => document.querySelector("#target-history-status")?.textContent === "History ready"); + assert.equal(await page.$eval("#history-fetch-status", (element) => element.checkVisibility()), false); await checkDuplicateHistoryOpeners(page, root); assert.deepEqual(errors, []); }); @@ -669,6 +702,15 @@ test("Steam UI keeps ranking fields aligned, errors inside dialogs and help cont await page.click("#scan-button"); await page.waitForFunction(() => document.querySelector("#report-status")?.textContent === "complete"); const help = '[aria-label="About evidence / 100"]'; await page.focus(help); await page.waitForSelector("#app-tooltip:popover-open"); assert.match(await visibleText(page, "#app-tooltip"), /not a friendship probability/); + assert.match(await visibleText(page, "#app-tooltip .tooltip-heading"), /evidence/); + if (process.env.VAPORA_BROWSER_SCREENSHOTS) await page.screenshot({ path: join(process.env.VAPORA_BROWSER_SCREENSHOTS, "friends-filter.png") }); + await page.keyboard.press("Escape"); + assert.equal(await page.$eval("#friend-search", (input) => { + const box = input.getBoundingClientRect(); const label = input.parentElement?.getBoundingClientRect(); const table = document.querySelector("#friends-view .table-wrap")?.getBoundingClientRect(); + return label && table && Math.abs(box.top - label.top) < 2 && table.top - box.bottom >= 0 && table.top - box.bottom <= 10; + }), true); + assert.equal(await page.$eval("#results", (element) => getComputedStyle(element).scrollbarColor), "auto"); + assert.equal(await page.$eval("#results", (element) => getComputedStyle(element, "::-webkit-scrollbar-thumb").borderRadius), "0px"); await page.keyboard.press("Escape"); assert.match(await page.$eval(help, (button) => (button.getAttribute("aria-describedby") ?? "").split(" ").map((id) => document.getElementById(id)?.textContent).join(" ")), /not a friendship probability/); await page.focus("#friend-rows .player-link"); await page.click("#friend-rows button"); @@ -680,6 +722,16 @@ test("Steam UI keeps ranking fields aligned, errors inside dialogs and help cont assert.equal(await page.$eval('[data-view="ranking"]', (tab) => { const box = tab.getBoundingClientRect(); const strip = tab.parentElement?.getBoundingClientRect(); return strip && box.left >= strip.left - 1 && box.right <= strip.right + 1; }), true); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), true); await page.setViewport({ width: 1078, height: 700 }); await page.click("#open-history"); await page.click('[data-history="comments"]'); + assert.equal(await page.$eval("#results-screen", (element) => element.checkVisibility()), true); + if (process.env.VAPORA_BROWSER_SCREENSHOTS) await page.screenshot({ path: join(process.env.VAPORA_BROWSER_SCREENSHOTS, "attached-history.png") }); + // A real held history response updates the report after the library has opened. + const pendingHistory = history.hold(`/id/${seed}/__data.json`); + await page.click("#history-refresh"); await pendingHistory; + await page.click("#toggle-runs"); history.release(`/id/${seed}/__data.json`); + await page.waitForFunction(() => document.querySelector("#target-history-status")?.textContent === "History ready"); + assert.equal(await page.$eval("#run-library", (element) => element.checkVisibility()), true); + assert.equal(await page.$eval("#obsidian-shortcut", (element) => element.checkVisibility()), false); + await page.click("#toggle-runs"); assert.match(await visibleText(page, "#history-rows"), /Bob/); await fill(page, "#history-search", "Bob"); assert.equal(await page.$eval("#history-rows", (rows) => rows.children.length), 1); await fill(page, "#history-search", "Undated unknown author"); assert.equal(await page.$$eval("#history-rows tr", (rows) => rows.some((row) => row.textContent?.includes("Undated unknown author") && row.cells[0]?.textContent === "Unknown author")), true); @@ -695,6 +747,88 @@ test("Steam UI keeps ranking fields aligned, errors inside dialogs and help cont await page.click('#history-form button[type="submit"]'); await page.waitForSelector("#history-import-dialog-error:not([hidden])"); assert.match(await visibleText(page, "#history-import-dialog-error"), /not valid JSON/); assert.equal(await page.evaluate(() => document.activeElement?.id), "history-import-dialog-error"); + await page.keyboard.press("Escape"); + // A failed lookup for another target must not hide or contaminate the saved attachment. + history.setStatus(403); await page.click('[data-screen="scan"]'); + await fill(page, "#target", second); await page.click("#lookup-target"); + await page.waitForFunction(() => document.querySelector("#target-history-status")?.textContent === "History unavailable"); + await page.click('[data-screen="results"]'); await page.click("#open-history"); + assert.equal(await page.$eval("#history-result", (element) => element.checkVisibility()), true); + assert.equal(await visibleText(page, "#history-name"), "Alice"); + assert.equal(await page.$eval("#history-fetch-status", (element) => element.hasAttribute("hidden")), true); + assert.equal(await page.$eval("#history-retry", (element) => element.hasAttribute("hidden")), true); + await page.click('[data-screen="scan"]'); await page.click("#target-history"); + assert.equal(await page.$eval("#history-result", (element) => element.checkVisibility()), false); + assert.equal(await page.$eval("#history-fetch-status", (element) => element.checkVisibility()), true); + assert.match(await visibleText(page, "#history-fetch-status"), /blocked/); + await page.click('[data-screen="results"]'); await page.click("#open-history"); + assert.equal(await page.$eval("#history-result", (element) => element.checkVisibility()), true); + assert.equal(await page.$eval("#history-fetch-status", (element) => element.hasAttribute("hidden")), true); + // Refreshing Alice's attachment must preserve Bob's independent target state. + history.setStatus(200); + history.rows.set("comments", [...history.document.historic.comments, + { ID: "isolated-refresh", Commenter: second, Message: "Alice attachment refreshed", Timestamp: 1791360000 }]); + const attachedRefresh = history.hold(`/id/${seed}/__data.json`); + await page.click("#history-refresh"); await attachedRefresh; + assert.equal(await visibleText(page, "#target-history-status"), "History unavailable"); + history.release(`/id/${seed}/__data.json`); + await page.click('[data-history="comments"]'); + await page.waitForFunction(() => document.querySelector("#history-rows")?.textContent?.includes("Alice attachment refreshed")); + await page.click('[data-screen="scan"]'); await page.click("#target-history"); + assert.equal(await page.$eval("#history-result", (element) => element.checkVisibility()), false); + assert.match(await visibleText(page, "#history-fetch-status"), /blocked/); + assert.equal(await visibleText(page, "#target-history-status"), "History unavailable"); + await page.click('[data-screen="results"]'); await page.click("#open-history"); + assert.equal(await visibleText(page, "#history-name"), "Alice"); + // A new ranking projection must replace the attachment shown by the cached refresh state. + await page.click('[data-history="ranking"]'); + const previousIndices = await page.$$eval("#history-rows tr", (rows) => rows.map((row) => row.lastElementChild?.textContent)); + await page.click('[data-view="ranking"]'); await fill(page, "#ranking-countBaseline", "1"); + await page.click("#save-ranking"); + await page.waitForFunction(() => document.querySelector("#notice")?.textContent?.includes("Saved ranking")); + await page.click("#open-history"); await page.click('[data-history="ranking"]'); + const rebuiltIndices = await page.$$eval("#history-rows tr", (rows) => rows.map((row) => row.lastElementChild?.textContent)); + assert.notDeepEqual(rebuiltIndices, previousIndices); + const historyUrl = await page.$eval("#history-download", (link) => link instanceof HTMLAnchorElement ? link.href : ""); + const projected = Schema.decodeUnknownSync(History.HistoryReport)(await (await fetch(historyUrl, { headers: { "user-agent": userAgent } })).json()); + assert.deepEqual(rebuiltIndices, projected.commenters.map((author) => author.inReference ? author.index?.toFixed(1) ?? "Unknown" : "Outside reference")); + // Switching linked saved runs must also switch the standalone target's History owner. + const firstRun = await page.evaluate(() => location.hash.slice(1)); + const bobRun = { ...scan([player(second, [])]), seed: second }; + const bobDirectory = join(root, "outputs", bobRun.id); await mkdir(bobDirectory); + await writeFile(join(bobDirectory, "scan.json"), JSON.stringify(bobRun)); + const importedBob = await fetch(`${server.origin}/api/history`, { method: "POST", + headers: { "content-type": "application/json", origin: server.origin, "user-agent": userAgent }, + body: JSON.stringify({ runId: bobRun.id, contents: JSON.stringify({ ...history.document, steamID64: second, name: "Bob" }) }) }); + assert.equal(importedBob.status, 200); + await page.click('[data-screen="scan"]'); await fill(page, "#target", ""); + await page.click('[data-screen="results"]'); await page.click("#toggle-runs"); + await page.evaluate(() => window.dispatchEvent(new Event("focus"))); + await page.waitForFunction((id) => document.querySelector("#run-rows")?.textContent?.includes(id), {}, bobRun.id); + for (const id of [firstRun, bobRun.id]) { + await page.$$eval("#run-rows tr", (rows, id) => rows.find((row) => row.lastElementChild?.textContent === id)?.querySelector("button")?.click(), id); + await page.waitForFunction((id) => location.hash === `#${id}` && document.querySelector("#target-history-status")?.textContent === "History ready", {}, id) + .catch(async (error: Error) => { throw new Error(`Saved-run History switch failed: ${JSON.stringify(await page.evaluate(() => ({ + run: location.hash, target: document.querySelector("#target")?.value, + status: document.querySelector("#target-history-status")?.textContent, notice: document.querySelector("#notice")?.textContent, + })))}`, { cause: error }); }); + if (id === firstRun) await page.click("#toggle-runs"); + } + await page.click('[data-screen="scan"]'); await page.click("#target-history"); + assert.equal(await visibleText(page, "#history-name"), "Bob"); + // A cancelled run cannot inherit another attachment's failed-refresh message. + await page.click('[data-screen="results"]'); await page.click("#open-history"); + await page.setOfflineMode(true); await page.click("#history-refresh"); + await page.waitForFunction(() => document.querySelector("#history-fetch-status")?.textContent?.includes("Cannot reach Vapora")); + await page.setOfflineMode(false); + const firstPath = join(root, "outputs", firstRun, "scan.json"); + const savedFirst = Schema.decodeUnknownSync(Contracts.RunView.fields.scan)(JSON.parse(await readFile(firstPath, "utf8"))); + await writeFile(firstPath, JSON.stringify({ ...savedFirst, status: "cancelled" })); + await page.click("#toggle-runs"); + await page.$$eval("#run-rows tr", (rows, id) => rows.find((row) => row.lastElementChild?.textContent === id)?.querySelector("button")?.click(), firstRun); + await page.waitForFunction((id) => location.hash === `#${id}` && document.querySelector("#history-name")?.textContent === "Alice", {}, firstRun); + assert.equal(await page.$eval("#history-fetch-status", (element) => element.checkVisibility()), false); + assert.equal(await page.$eval("#history-retry", (element) => element.checkVisibility()), false); assert.deepEqual(errors, []); }); @@ -797,14 +931,46 @@ test("network preview expands in-app, keeps all connections and supports explora assert.equal((await page.$$("#network-profiles button")).length, 50); await page.click("#network-next"); assert.match(await visibleText(page, "#network-profile-count") ?? "", /51.*100/); await page.click("#network-previous"); + await page.setViewport({ width: 1078, height: 599 }); + await page.hover("#network-export"); await checkAdjacentHelp(page, "#network-export"); + if (process.env.VAPORA_BROWSER_SCREENSHOTS) await page.screenshot({ path: join(process.env.VAPORA_BROWSER_SCREENSHOTS, "network-export-help.png") }); + const statusBeforeExport = await visibleText(page, "#network-layout-status"); + const cameraBeforeExport = await page.$eval("#graph", (element) => element.getAttribute("data-ratio")); await page.click("#network-export"); + // Encoding the 4K image precedes the download and has its own visible Saving state. + await page.waitForFunction(() => !document.querySelector("#network-export")?.disabled); const image = join(downloads, `vapora-${saved.id}-network.png`); await readDownload(image); const bytes = await readFile(image); assert.equal(bytes.subarray(0, 8).toString("hex"), "89504e470d0a1a0a"); assert.ok(bytes.length > 10000); + const width = bytes.readUInt32BE(16); const height = bytes.readUInt32BE(20); + assert.equal(Math.max(width, height), 4096); + assert.equal(await page.$eval("#graph", (element) => element.getAttribute("data-ratio")), cameraBeforeExport); + assert.equal(await visibleText(page, "#network-layout-status"), statusBeforeExport); if (process.env.VAPORA_BROWSER_SCREENSHOTS) await copyFile(image, join(process.env.VAPORA_BROWSER_SCREENSHOTS, "network-export.png")); - await page.click("#network-arrange"); await page.click("#network-pause"); + await page.setViewport({ width: 1078, height: 700 }); + assert.equal(await page.$("#network-arrange"), null); + // A stationary pointer and small movements within a circle must not oscillate its hit target. + await page.click(`#network-profiles [data-node-id="${seed}"]`); + const bounds = await page.$eval("#graph", (graph) => { const box = graph.getBoundingClientRect(); return { x: box.x, y: box.y, width: box.width, height: box.height }; }); + await page.mouse.click(bounds.x + bounds.width - 3, bounds.y + bounds.height - 3); + assert.equal(await page.$eval("#graph", (graph) => graph.getAttribute("data-selected")), ""); + await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); + await page.waitForFunction((id) => document.querySelector("#graph")?.dataset.hovered === id, {}, seed); + const hoverChanges = page.$eval("#graph", (graph) => new Promise((resolve) => { + const changes: string[] = []; const observer = new MutationObserver(() => changes.push(graph.getAttribute("data-hovered") ?? "")); + observer.observe(graph, { attributes: true, attributeFilter: ["data-hovered"] }); + setTimeout(() => { observer.disconnect(); resolve(changes); }, 700); + })); + for (const offset of [1, -1, 2, -2, 0]) await page.mouse.move(bounds.x + bounds.width / 2 + offset, bounds.y + bounds.height / 2); + assert.equal((await hoverChanges).some((id) => id !== seed), false, "Hover must stay on the circle without flickering"); + assert.equal(await page.$eval("#graph", (graph) => graph.getAttribute("data-selected")), "", "Hover must not select a profile"); + if (process.env.VAPORA_BROWSER_SCREENSHOTS) await page.screenshot({ path: join(process.env.VAPORA_BROWSER_SCREENSHOTS, "network-hover.png") }); + // Pinning makes the current layout resumable; Pause/Resume is the only layout action. + await page.click(`#network-profiles [data-node-id="${seed}"]`); + await page.click("#network-selection button:last-child"); + await page.evaluate(() => { document.querySelector("#network-pause")?.click(); document.querySelector("#network-pause")?.click(); }); assert.equal(await visibleText(page, "#network-layout-status"), "Layout paused"); - await page.click("#network-arrange"); + await page.click("#network-pause"); await page.waitForFunction(() => document.querySelector("#network-layout-status")?.textContent === "Layout settled"); await page.setViewport({ width: 390, height: 844 }); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); @@ -818,13 +984,15 @@ test("network preview expands in-app, keeps all connections and supports explora assert.equal(await page.$eval(".toolbar", (element) => element instanceof HTMLElement && element.inert), false); // Perform the tab switch in one event turn, before the real worker can deliver its frame. await page.evaluate(() => { - document.querySelector("#network-arrange")?.click(); + document.querySelector("#network-selection button:last-child")?.click(); + document.querySelector("#network-pause")?.click(); document.querySelector('[data-view="friends"]')?.click(); document.querySelector('[data-view="network"]')?.click(); }); await page.waitForFunction(() => document.querySelector("#network-layout-status")?.textContent === "Layout settled"); await page.evaluate(() => { - document.querySelector("#network-arrange")?.click(); + document.querySelector("#network-selection button:last-child")?.click(); + document.querySelector("#network-pause")?.click(); document.querySelector("#network-pause")?.click(); document.querySelector('[data-view="friends"]')?.click(); document.querySelector('[data-view="network"]')?.click(); @@ -866,7 +1034,9 @@ test("unavailable WebGL shows recovery guidance and leaves saved results usable" context.after(async () => { await browser.close(); await server.close(); await history.close(); await rm(root, { recursive: true, force: true }); }); const page = await browser.newPage(); const errors: string[] = []; page.on("pageerror", (error) => errors.push(String(error))); await page.setUserAgent(userAgent); await page.goto(`${server.origin}/#${saved.id}`); - await page.waitForSelector("#report-status", { visible: true }); await page.click('[data-view="network"]'); + await page.waitForSelector("#report-status", { visible: true }); + // Finish the automatic attachment before exercising recovery controls; it re-renders the report. + await page.waitForSelector("#open-history", { visible: true }); await page.click('[data-view="network"]'); assert.equal(await visibleText(page, "#graph-count"), "Graph unavailable"); assert.match(await visibleText(page, "#graph") ?? "", /Enable graphics acceleration/); await page.click("#graph button"); assert.equal(await visibleText(page, "#graph-count"), "Graph unavailable"); diff --git a/test/history.test.ts b/test/history.test.ts index b7334d7..c7e06c9 100644 --- a/test/history.test.ts +++ b/test/history.test.ts @@ -103,6 +103,7 @@ test("profile-date ties and current coverage use capture time independently of i const bundle = Schema.decodeUnknownSync(History.Bundle)({ sources }); assert.equal(History.view(bundle).profile.name, "Recent"); assert.equal(History.view(bundle).profile.fields.vacBanned, true); assert.equal(History.coverageError(bundle), null); + assert.equal(History.latestCoverageSource(bundle)?.capturedAt, newer.capturedAt); assert.equal(bundle.sources.length, 2); } const latestPartial = Schema.decodeUnknownSync(History.Bundle)({ sources: [newer, { ...older, capturedAt: "2026-10-08T09:00:00Z" }] }); diff --git a/ui/app.ts b/ui/app.ts index 685f85e..192b3d6 100644 --- a/ui/app.ts +++ b/ui/app.ts @@ -77,6 +77,8 @@ function showScreen(name: typeof Screen.Type) { if (tab.dataset.screen === name) tab.setAttribute("aria-current", "page"); else tab.removeAttribute("aria-current"); } + if (name === "history") get("history-home").append(get("history-content")); + else if (name === "results" && !get("history-view").hidden) showAttachedHistory(); window.scrollTo({ top: 0, behavior: "instant" }); get("results").scrollTo({ top: 0, behavior: "instant" }); if (name === "results") renderGraph(); @@ -280,6 +282,7 @@ function renderReport() { const obsidian = document.createElement("a"); obsidian.id = "obsidian-export"; obsidian.textContent = "↓ Obsidian vault (.zip)"; obsidian.href = `/api/obsidian?${new URLSearchParams({ id: scan.id })}`; exports.append(obsidian); + const shortcut = get("obsidian-shortcut"); shortcut.setAttribute("href", obsidian.href); shortcut.hidden = !get("run-library").hidden; renderFriends(); renderLocations(); renderGraph(); renderTarget(); renderOutput(); } function renderCoverageNotice() { @@ -295,6 +298,8 @@ function renderCoverageNotice() { const warning = get("coverage-warning"); warning.hidden = !partial; warning.textContent = ["Partial results", ...reasons].join(" · "); get("open-history").hidden = !selected.history; + if (!selected.history && !get("history-view").hidden) document.querySelector('[data-view="friends"]')?.click(); + else if (!get("history-view").hidden && !get("results-screen").hidden) showAttachedHistory(); } function renderRanking() { if (!selected) return; @@ -334,6 +339,7 @@ function toggleRuns(visible: boolean) { get("run-library").hidden = !visible; buttons("toggle-runs").setAttribute("aria-expanded", String(visible)); buttons("toggle-runs").textContent = visible && selected ? "Back to report" : "Saved runs"; get("report").hidden = visible || !selected; get("empty").hidden = visible || Boolean(selected); + get("obsidian-shortcut").hidden = visible || !selected; if (!visible) renderGraph(); else explorer?.hide(); } @@ -361,39 +367,73 @@ function renderRuns() { let accountHistory: HistoryState | null = null; let historyRequest = 0; let historyAccount: SteamId | null = null; +let attachedHistory: { runId: string; status: HistoryState["status"]; error: string | null } | null = null; +let attachedHistoryRequest = 0; +function ownsTargetHistory(id: SteamId, runId?: string) { + return !runId || inputs.target.value.trim() === id || historyAccount === id; +} +function showAttachedHistory() { + if (!selected?.history) return; + const content = get("history-content"); const changedContext = content.parentElement !== get("history-view"); + if (changedContext) { get("history-view").append(content); get("history-fetch-status").hidden = true; get("history-retry").hidden = true; } + if (attachedHistory?.runId === selected.scan.id) renderAccountHistory({ id: selected.scan.seed, + status: attachedHistory.status, error: attachedHistory.error, report: selected.history }, attachedHistory.runId, false); + else { + get("history-fetch-status").hidden = true; get("history-retry").hidden = true; + renderHistory(selected.history, selected.scan.id); + } +} function renderHistory(report: HistoryReport, runId: string | null) { const matching = runId && selected?.scan.seed === report.profile.steamID64 ? selected.scan : undefined; - HistoryUI.render(report, matching); historyAccount = report.profile.steamID64; + get("history-result").hidden = false; + HistoryUI.render(report, matching); const link = get("history-download"); link.hidden = !runId; if (runId) link.setAttribute("href", downloadLink(runId, "history.json").href); } -function renderAccountHistory(state: HistoryState, runId?: string) { - accountHistory = state; historyAccount = state.id; +function historyMatchesContext(id: SteamId, runId?: string, target = ownsTargetHistory(id, runId)) { + return get("history-content").parentElement === get("history-view") ? runId === selected?.scan.id : target; +} +function renderAccountHistory(state: HistoryState, runId?: string, target = ownsTargetHistory(state.id, runId)) { const message = diagnosticText(state.error ?? ""); - get("target-history-status").textContent = state.status === "partial" ? "History partial" : state.error ? "History unavailable" : "History ready"; - get("target-history-status").dataset.tooltip = message || "Saved history capture"; - get("target-history-retry").hidden = !state.error; get("history-retry").hidden = !state.error; - get("history-fetch-status").textContent = message; get("history-fetch-status").hidden = !state.error || state.status === "partial" && state.report !== null; + // Keep only refresh diagnostics here. The selected run owns the current projected report. + if (runId) attachedHistory = { runId, status: state.status, error: state.error }; + if (target) { + accountHistory = state; historyAccount = state.id; + get("target-history-status").textContent = state.status === "partial" ? "History partial" : state.error ? "History unavailable" : "History ready"; + get("target-history-status").dataset.tooltip = message || "Saved history capture"; + get("target-history-retry").hidden = !state.error; + } + // The target account can load in the background while another run owns the History tab. + if (historyMatchesContext(state.id, runId, target)) { + get("history-retry").hidden = !state.error || state.status === "partial" && state.report !== null; + get("history-fetch-status").textContent = message; get("history-fetch-status").hidden = !state.error || state.status === "partial" && state.report !== null; + } if (state.report) { - renderHistory(state.report, runId ?? null); - if (runId && selected?.scan.id === runId) { selected = { ...selected, history: state.report }; renderReport(); } + // Background captures must not replace the saved run's attached History tab. + if (historyMatchesContext(state.id, runId, target)) renderHistory(state.report, runId ?? null); + if (runId && selected?.scan.id === runId && selected.history !== state.report) { selected = { ...selected, history: state.report }; renderReport(); } } } async function loadAccountHistory(id: SteamId, refresh = false, runId?: string) { - const request = ++historyRequest; - if (historyAccount !== id) { accountHistory = null; get("history-result").hidden = true; } - historyAccount = id; - get("history-fetch-status").hidden = true; get("history-retry").hidden = true; - get("target-history-status").textContent = "Loading history"; + // Freeze ownership for this request: a later target lookup must not inherit an older attachment response. + const target = ownsTargetHistory(id, runId); + const request = target ? ++historyRequest : ++attachedHistoryRequest; + if (target) { + if (historyAccount !== id) { accountHistory = null; if (historyMatchesContext(id, runId)) get("history-result").hidden = true; } + historyAccount = id; + get("target-history-status").textContent = "Loading history"; + } + if (historyMatchesContext(id, runId)) { get("history-fetch-status").hidden = true; get("history-retry").hidden = true; } + const current = () => target ? request === historyRequest : request === attachedHistoryRequest && runId === selected?.scan.id; try { const state = await api("/api/history/account", HistoryState, { id, refresh, runId }); - if (request !== historyRequest) return; - renderAccountHistory(state, runId); + if (!current()) return; + renderAccountHistory(state, runId, target); } catch (error) { - if (request !== historyRequest) return; - get("target-history-status").textContent = "History unavailable"; get("target-history-retry").hidden = false; get("history-retry").hidden = false; - const message = diagnosticText(error instanceof Error ? error.message : "History could not be loaded."); - get("target-history-status").dataset.tooltip = message; get("history-fetch-status").textContent = message; get("history-fetch-status").hidden = false; + if (!current()) return; + const message = error instanceof Error ? error.message : "History could not be loaded."; + renderAccountHistory({ id, status: "unavailable", error: message, + report: target ? accountHistory?.id === id ? accountHistory.report : null : selected?.history ?? null }, runId, target); } } async function openRun(id: string, navigation = navigationVersion) { @@ -700,6 +740,7 @@ for (const tab of document.querySelectorAll("[data-view]")) t for (const other of document.querySelectorAll("[data-view]")) other.removeAttribute("aria-current"); tab.setAttribute("aria-current", "page"); for (const view of document.querySelectorAll(".view")) view.hidden = view.id !== `${tab.dataset.view}-view`; + if (tab.dataset.view === "history") showAttachedHistory(); if (tab.dataset.view === "network") renderGraph(); else explorer?.hide(); }); @@ -714,9 +755,6 @@ buttons("rebuild-exports").addEventListener("click", () => task(async () => { })); inputs.runSearch.addEventListener("input", renderRuns); select("run-status").addEventListener("change", renderRuns); buttons("toggle-runs").addEventListener("click", () => toggleRuns(get("run-library").hidden)); -buttons("open-history").addEventListener("click", () => { - if (selected?.history) { renderHistory(selected.history, selected.scan.id); showScreen("history"); } -}); get("ranking-form").addEventListener("submit", (event) => { event.preventDefault(); task(async () => { if (!selected) return; const id = selected.scan.id; @@ -738,22 +776,32 @@ get("history-form").addEventListener("submit", (event) => { event.preventDefault const report = await api("/api/history", HistoryReport, { contents: new TextDecoder("utf-8", { ignoreBOM: true, fatal: true }).decode(await file.arrayBuffer()), runId }); // An import supersedes any automatic history response still in flight. historyRequest++; + attachedHistoryRequest++; const error = coverageError(report); + if (!runId && get("history-content").parentElement === get("history-view")) showScreen("history"); renderAccountHistory({ id: report.profile.steamID64, status: error ? "partial" : "ready", error, report }, runId); dialog("history-import-dialog").close(); }); }); window.addEventListener("focus", () => void refresh()); -buttons("open-history-import").addEventListener("click", () => dialog("history-import-dialog").showModal()); +buttons("open-history-import").addEventListener("click", () => { + inputs.attach.checked = get("history-content").parentElement === get("history-view"); + dialog("history-import-dialog").showModal(); +}); buttons("target-history").addEventListener("click", () => { - if (accountHistory?.report) renderHistory(accountHistory.report, null); showScreen("history"); + get("history-result").hidden = !accountHistory?.report; + if (accountHistory) renderAccountHistory(accountHistory); + else { get("history-fetch-status").hidden = true; get("history-retry").hidden = true; } }); function refreshAccountHistory(id: SteamId | null) { if (id) void loadAccountHistory(id, true, selected?.scan.seed === id ? selected.scan.id : undefined); } buttons("target-history-retry").addEventListener("click", () => refreshAccountHistory(preview?.id ?? historyAccount)); -for (const id of ["history-refresh", "history-retry"]) buttons(id).addEventListener("click", () => refreshAccountHistory(historyAccount)); +for (const id of ["history-refresh", "history-retry"]) buttons(id).addEventListener("click", () => { + const attached = get("history-content").parentElement === get("history-view"); + refreshAccountHistory(attached ? selected?.scan.seed ?? null : historyAccount); +}); HistoryUI.initialize(); installTabStrips(); applySettings(defaults); diff --git a/ui/explorer.ts b/ui/explorer.ts index 3058c2e..ab2490a 100644 --- a/ui/explorer.ts +++ b/ui/explorer.ts @@ -37,8 +37,7 @@ export class Explorer { this.click("zoom-out", () => { this.viewer.pause(); this.viewer.zoom(1 / 1.5); }); this.click("zoom-reset", () => this.viewer.fit()); this.click("network-filters", () => { this.filtersOpen = !this.filtersOpen; this.resize(); }); - this.click("network-arrange", () => this.viewer.layout()); - this.click("network-pause", () => this.viewer.pause()); + this.click("network-pause", () => { if (this.viewer.isRunning()) this.viewer.pause(); else this.viewer.layout(); }); this.click("network-clear", () => this.reset()); this.click("network-export", () => this.exportImage()); this.click("network-previous", () => { this.page--; this.renderProfiles(); }); @@ -97,7 +96,8 @@ export class Explorer { private render() { this.get("graph-count").textContent = this.viewer.summary(); this.get("network-layout-status").textContent = this.viewer.container.dataset.nodes === "0" ? "No profiles match these filters." : this.viewer.status(); - this.button("network-pause").disabled = !this.viewer.isRunning(); + this.button("network-pause").disabled = this.viewer.isSettled(); + this.button("network-pause").textContent = this.viewer.isRunning() || this.viewer.isSettled() ? "Pause" : "Resume"; this.select("network-scope").disabled = !this.viewer.selected; this.select("network-scope").value = this.viewer.filters.scope; this.renderSelection(); this.renderProfiles(); @@ -188,19 +188,18 @@ export class Explorer { hide() { if (this.expanded) this.maximize(false); this.suspendLayout(); } destroy() { this.hide(); cancelAnimationFrame(this.updateFrame); this.events.abort(); this.viewer.destroy(); this.get("graph").replaceChildren(); } private exportImage() { - this.viewer.pause(); - // Composite the actual WebGL layers without adding a second graph renderer or remote service. + const button = this.button("network-export"); button.disabled = true; button.textContent = "Saving…"; + const fail = () => { this.get("network-layout-status").textContent = "Could not save the image. Try Save image again."; }; + const finish = () => { button.disabled = false; button.textContent = "Save image"; }; + // Resolve pending styles before reading display geometry. Export never changes the camera or layout. this.viewer.renderer.once("afterRender", () => { - const graph = this.get("graph"); const canvas = document.createElement("canvas"); const ratio = devicePixelRatio; - canvas.width = Math.round(graph.clientWidth * ratio); canvas.height = Math.round(graph.clientHeight * ratio); - const context = canvas.getContext("2d"); if (!context) return; - context.fillStyle = "#3e4637"; context.fillRect(0, 0, canvas.width, canvas.height); - for (const layer of graph.querySelectorAll("canvas")) if (layer.style.display !== "none") context.drawImage(layer, 0, 0, canvas.width, canvas.height); - canvas.toBlob((blob) => { - if (!blob) return; - const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; - link.download = `vapora-${this.view.scan.id}-network.png`; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); - }); + try { + this.viewer.image().toBlob((blob) => { + if (!blob) { fail(); finish(); return; } + const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; + link.download = `vapora-${this.view.scan.id}-network.png`; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); finish(); + }); + } catch { fail(); finish(); } }); this.viewer.renderer.scheduleRender(); } diff --git a/ui/history-view.ts b/ui/history-view.ts index 684c651..9c45a1a 100644 --- a/ui/history-view.ts +++ b/ui/history-view.ts @@ -153,10 +153,33 @@ function download(contents: string, filename: string) { const blob = new Blob([contents], { type: "application/json" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); } +function renderWarnings(report: History.HistoryReport) { + const container = get("history-warnings"); container.replaceChildren(); + const latest = History.latestCoverageSource(report); + const issues = latest?.coverage.filter((entry) => entry.status !== "complete") ?? []; + const labels = { friends: "Friends", comments: "Comments", persona: "Names", realName: "Real names", url: "URLs", pfp: "Avatars" } satisfies Record; + const add = (summary: string, explanation: string) => { + const line = document.createElement("span"); line.className = "history-warning"; + const text = document.createElement("strong"); text.textContent = summary; + const help = document.createElement("button"); help.type = "button"; help.className = "info"; help.textContent = "i"; + help.setAttribute("aria-label", `About ${summary}`); + help.dataset.tooltip = History.diagnosticText(explanation); line.append(text, help); container.append(line); + }; + for (const entry of issues) { + const total = entry.expected ?? entry.total; + add(`${labels[entry.section]}: ${entry.captured}${total === null ? " captured" : ` / ${total} captured`}`, + entry.error ?? "The capture is incomplete. Missing records are not evidence that no records exist."); + } + const coverage = History.coverageError(report); + const other = report.warnings.filter((warning) => History.diagnosticText(warning) !== coverage); + if (other.length) add(`${other.length === 1 ? "Capture note" : `${other.length} capture notes`}`, other.join("\n")); + container.hidden = !container.childElementCount; +} export function render(report: History.HistoryReport, selectedScan?: Scan) { + if (current === report && scan === selectedScan) return; current = report; scan = selectedScan; page = 0; - get("history-result").hidden = false; get("history-name").textContent = report.profile.name; - get("history-asof").textContent = `History as of ${date(report.profile.lastChecked)}`; + get("history-name").textContent = report.profile.name; + get("history-asof").textContent = `As of ${date(report.profile.lastChecked)}`; get("history-asof").dataset.tooltip = History.diagnosticText(report.warning); const sources = get("history-sources"); sources.replaceChildren(); report.sources.forEach((source, index) => { @@ -164,7 +187,7 @@ export function render(report: History.HistoryReport, selectedScan?: Scan) { button.dataset.tooltip = `Captured ${source.capturedAt}`; button.addEventListener("click", () => download(source.contents, `history-${report.profile.steamID64}-${index + 1}.json`)); sources.append(button); }); - get("history-warnings").textContent = History.diagnosticText(report.warnings.join("\n")); get("history-warnings").hidden = !report.warnings.length; + renderWarnings(report); renderTable(); } export function initialize() { diff --git a/ui/index.html b/ui/index.html index 1634a71..22a5855 100644 --- a/ui/index.html +++ b/ui/index.html @@ -151,7 +151,7 @@

Run info

+ diff --git a/ui/network-images.ts b/ui/network-images.ts index 0b6c19d..eaf7993 100644 --- a/ui/network-images.ts +++ b/ui/network-images.ts @@ -9,6 +9,8 @@ export class NodePictures { size: { mode: "force", value: 48 }, maxTextureSize: 2048, debounceTimeout: 120, crossOrigin: "anonymous", } }); private readonly requested = new Set(); + private readonly originals = new Map(); + original(source: string) { return this.originals.get(source); } private readonly sources = new Map>(); private readonly active = new Map>(); private readonly queue: { source: string; resolve: (source: string) => void }[] = []; @@ -64,6 +66,7 @@ export class NodePictures { const finish = (source: string) => { clearTimeout(this.active.get(image)); this.active.delete(image); image.onload = null; image.onerror = null; if (source !== image.src) image.src = ""; + if (image.complete && image.naturalWidth) this.originals.set(source, image); next.resolve(source); this.load(); }; // A stalled CDN or failed local placeholder must not occupy a request slot forever. @@ -81,6 +84,6 @@ export class NodePictures { if (this.timer !== null) clearTimeout(this.timer); cancelAnimationFrame(this.applyFrame); this.pending.clear(); for (const [image, timer] of this.active) { clearTimeout(timer); image.onload = null; image.onerror = null; image.src = ""; } - this.active.clear(); this.queue.length = 0; this.sources.clear(); this.requested.clear(); + this.active.clear(); this.originals.clear(); this.queue.length = 0; this.sources.clear(); this.requested.clear(); } } diff --git a/ui/network.ts b/ui/network.ts index db4c620..9132ded 100644 --- a/ui/network.ts +++ b/ui/network.ts @@ -67,10 +67,10 @@ export class Viewer { const styles: StylesDeclaration = { nodes: { x: { attribute: "x" }, y: { attribute: "y" }, label: { attribute: "label" }, - size: { whenState: "focus", then: 14, else: { attribute: "size" } }, + size: { whenState: "selected", then: 14, else: { attribute: "size" } }, color: { whenState: "selected", then: "#f3e497", else: { attribute: "color" } }, opacity: { whenState: "dim", then: .2, else: 1 }, visibility: { whenState: "isHidden", then: "hidden", else: "visible" }, - depth: { whenState: "focus", then: "topNodes", else: "nodes" }, labelDepth: { whenState: "focus", then: "topNodes", else: "nodes" }, + depth: { whenState: "selected", then: "topNodes", else: "nodes" }, labelDepth: { whenState: "focus", then: "topNodes", else: "nodes" }, labelColor: "#d8ded3", labelFont: "MotivaSans, Arial, sans-serif", labelVisibility: { whenState: "focus", then: "visible", else: { whenState: "match", then: "visible", else: { whenState: "labels", then: "auto", else: "hidden" } } }, backdropVisibility: { whenState: "focus", then: "visible", else: "hidden" }, @@ -88,7 +88,7 @@ export class Viewer { this.renderer = new Sigma(this.graph, container, { customNodeState: { dim: false, selected: false, focus: false, match: false, labels: true }, customEdgeState: { dim: false, active: false }, - settings: { allowInvalidContainer: true, enableNodeDrag: true, autoRescale: "once", autoRescaleContent: "nodes", stagePadding: 24, + settings: { allowInvalidContainer: true, nodeLabelEvents: false, enableNodeDrag: true, autoRescale: "once", autoRescaleContent: "nodes", stagePadding: 24, zoomDuration: 0, inertiaDuration: 0, doubleClickZoomingDuration: 0, minCameraRatio: .015, maxCameraRatio: 8, zoomToSizeRatioFunction: Math.sqrt, labelDensity: .3, labelRenderedSizeThreshold: 5, hideLabelsOnMove: true, gestureTarget: "graph" }, primitives: { nodes: { variables: { image: { type: "string", default: "" } }, @@ -115,7 +115,7 @@ export class Viewer { this.renderer.on("clickStage", () => { if (this.selected) this.select(null); }); this.renderer.on("enterNode", ({ node }) => this.hover(node)); this.renderer.on("leaveNode", () => this.hover(null)); - this.renderer.on("nodeDragStart", ({ node }) => { this.pause(); this.graph.setNodeAttribute(node, "fixed", true); this.onChange(); }); + this.renderer.on("nodeDragStart", ({ node }) => { this.settled = false; this.pause(); this.graph.setNodeAttribute(node, "fixed", true); this.onChange(); }); this.renderer.getCamera().on("updated", (state) => { container.dataset.ratio = String(state.ratio); }); this.renderer.on("webglContextLost", () => { this.pause(); this.layoutStatus = "Graphics context lost. Reload this page to retry."; this.onChange(); }); container.dataset.ratio = "1"; @@ -160,7 +160,7 @@ export class Viewer { this.highlight(); } private hover(id: string | null) { - this.hovered = id; + this.hovered = id; this.container.dataset.hovered = id ?? ""; // Leaving A and entering B happen together. Apply their final state once per frame. if (!this.hoverFrame) this.hoverFrame = requestAnimationFrame(() => { this.hoverFrame = 0; this.highlight(); }); } @@ -219,8 +219,9 @@ export class Viewer { } status() { return this.layoutStatus; } isRunning() { return this.worker !== null; } + isSettled() { return this.settled; } isPinned() { return this.selected ? this.graph.getNodeAttribute(this.selected, "fixed") : false; } - pin() { if (this.selected) { this.pause(); this.graph.setNodeAttribute(this.selected, "fixed", !this.isPinned()); this.onChange(); } } + pin() { if (this.selected) { this.settled = false; this.pause(); this.graph.setNodeAttribute(this.selected, "fixed", !this.isPinned()); this.onChange(); } } zoom(factor: number) { const camera = this.renderer.getCamera(); camera.setState({ ratio: camera.getBoundedRatio(camera.ratio / factor) }); } fit() { this.renderer.getCamera().setState({ x: .5, y: .5, angle: 0, ratio: 1 }); } appearance(labels: boolean, size: number) { @@ -253,7 +254,7 @@ export class Viewer { }; worker.onerror = () => { if (this.disposed || this.worker !== worker) return; - this.pause(); this.layoutStatus = "Layout failed. Choose Arrange to retry."; this.onChange(); + this.pause(); this.layoutStatus = "Layout failed. Choose Resume to retry."; this.onChange(); }; // Layout follows observed friendships, not the potentially dense shared-group projection. const layoutGraph = new Graph({ type: "undirected" }); @@ -261,5 +262,58 @@ export class Viewer { this.graph.forEachEdge((_key, edge, from, to) => { if (edge.kind === "friend") layoutGraph.mergeEdge(from, to); }); worker.postMessage(layoutGraph.export()); this.onChange(); } + /** Draw retained display geometry at export resolution, without a second GPU context or viewport bitmap. */ + image() { + const { width, height } = this.renderer.getDimensions(); + const scale = 4096 / Math.max(width, height); + const canvas = document.createElement("canvas"); canvas.width = Math.round(width * scale); canvas.height = Math.round(height * scale); + const context = canvas.getContext("2d"); if (!context) throw new Error("Could not create the image. Try Save image again."); + context.fillStyle = "#3e4637"; context.fillRect(0, 0, canvas.width, canvas.height); context.scale(scale, scale); + const nodes = new Map([...this.visible].flatMap((id) => { + const data = this.renderer.getNodeDisplayData(id); + return data ? [[id, { data, point: this.renderer.framedGraphToViewport(data), radius: this.renderer.scaleSize(data.size) }] as const] : []; + })); + // Batch paths by their resolved visual style instead of issuing one stroke per connection. + const paths = new Map(); + this.graph.forEachEdge((key, _edge, from, to) => { + const data = this.renderer.getEdgeDisplayData(key); const source = nodes.get(from); const target = nodes.get(to); + if (!data || data.visibility === "hidden" || !source || !target) return; + const style = JSON.stringify([data.color, data.opacity, data.size, data.depth]); + let batch = paths.get(style); + if (!batch) { batch = { path: new Path2D(), color: data.color, opacity: data.opacity, size: data.size, depth: data.depth }; paths.set(style, batch); } + batch.path.moveTo(source.point.x, source.point.y); batch.path.lineTo(target.point.x, target.point.y); + }); + for (const batch of [...paths.values()].sort((a, b) => Number(a.depth === "topEdges") - Number(b.depth === "topEdges"))) { + context.strokeStyle = batch.color; context.globalAlpha = batch.opacity; + context.lineWidth = Math.max(.5, this.renderer.scaleSize(batch.size)); context.stroke(batch.path); + } + const labels = this.renderer.getNodeDisplayedLabels(); + const ordered = [...nodes].sort(([a], [b]) => Number(a === this.selected) - Number(b === this.selected)); + for (const [id, { data, point, radius }] of ordered) { + if (point.x + radius < 0 || point.x - radius > width || point.y + radius < 0 || point.y - radius > height) continue; + context.globalAlpha = data.opacity; context.fillStyle = data.color; + context.beginPath(); context.arc(point.x, point.y, radius, 0, Math.PI * 2); context.fill(); + const original = this.pictures.original(this.graph.getNodeAttribute(id, "image")); + if (original) { + // Use original loaded image pixels, not the small live GPU atlas thumbnail. + context.save(); context.clip(); + const side = Math.min(original.naturalWidth, original.naturalHeight); + context.drawImage(original, (original.naturalWidth - side) / 2, (original.naturalHeight - side) / 2, side, side, + point.x - radius, point.y - radius, radius * 2, radius * 2); context.restore(); + } + context.strokeStyle = this.graph.getNodeAttribute(id, "color"); context.lineWidth = 1.5; context.stroke(); + } + // Text is painted after circles so labels remain legible at every export scale. + context.font = "12px MotivaSans, Arial, sans-serif"; context.textBaseline = "middle"; + for (const [id, { data, point, radius }] of ordered) { + if (!labels.has(id) || !data.label) continue; + context.globalAlpha = data.opacity; + if (data.backdropVisibility === "visible") { + context.fillStyle = "#3e4637"; context.fillRect(point.x + radius, point.y - 10, context.measureText(data.label).width + 8, 20); + } + context.fillStyle = data.labelColor; context.fillText(data.label, point.x + radius + 4, point.y); + } + return canvas; + } destroy() { this.disposed = true; this.onChange = () => {}; this.pause(); cancelAnimationFrame(this.hoverFrame); this.pictures.destroy(); this.renderer.kill(); } } diff --git a/ui/style.css b/ui/style.css index e5bd90e..6ddc569 100644 --- a/ui/style.css +++ b/ui/style.css @@ -14,6 +14,10 @@ color: var(--OffWhite); background: #000; scrollbar-color: var(--LightGreenBG) var(--DarkGreenBG); } +/* Reset standard colors only when the browser supports the full scrollbar skin. */ +@supports selector(::-webkit-scrollbar-thumb) { + :root, * { scrollbar-color: auto; } +} * { box-sizing: border-box; } body { margin: 0; min-height: 599px; background: var(--GreenBG); border: 1px solid; border-color: var(--Outset); } body.desktop { height: 100dvh; min-height: 0; display: flex; flex-direction: column; } @@ -78,8 +82,9 @@ progress::-moz-progress-bar { background: var(--FullGreen); } main { min-width: 0; min-height: 502px; margin: 0 10px 15px; padding: 19px 24px 13px; background: var(--DarkGreenBG); border: 1px solid; border-color: var(--Inset); overflow-y: scroll; scrollbar-gutter: stable; } body.desktop main { flex: 1; min-height: 0; } ::-webkit-scrollbar { width: 14px; height: 14px; } -::-webkit-scrollbar-track { background: var(--DarkGreenBG); border: 1px solid; border-color: var(--Inset); } -::-webkit-scrollbar-thumb { background: var(--GreenBG); border: 1px solid; border-color: var(--Outset); } +::-webkit-scrollbar-corner { background: var(--DarkGreenBG); } +::-webkit-scrollbar-track { border-radius: 0; background: var(--DarkGreenBG); border: 1px solid; border-color: var(--Inset); } +::-webkit-scrollbar-thumb { border-radius: 0; min-height: 24px; background: var(--GreenBG); border: 1px solid; border-color: var(--Outset); } .screen { min-width: 0; } .scan-grid { display: grid; grid-template-columns: minmax(0, 1fr) 44px 260px; grid-template-areas: 'form rail target'; gap: 16px; align-items: start; } #scan-form { grid-area: form; display: grid; grid-template-rows: minmax(192px, auto) minmax(250px, auto); gap: 26px; min-width: 0; } @@ -95,6 +100,9 @@ body.desktop main { flex: 1; min-height: 0; } .info { position: relative; display: inline-flex; align-items: center; justify-content: center; width: 14px; min-height: 14px; height: 14px; padding: 0; border: 0; color: var(--GreenBG); background: var(--OffWhite); border-radius: 50%; font: bold 11px/1 Arial, sans-serif; vertical-align: middle; cursor: help; } .info::before { content: ''; position: absolute; inset: -5px; } .info:hover, .info:active { color: var(--DarkGreenBG); background: var(--Maize); } +.tooltip-heading { display: block; color: var(--Maize); font-weight: 500; margin-bottom: 5px; } +.tooltip-lines { margin: 0; padding-left: 16px; } +.tooltip-lines li + li { margin-top: 4px; } .app-tooltip { position: fixed; inset: auto; margin: 0; width: max-content; max-width: min(320px, calc(100vw - 16px)); max-height: calc(100dvh - 16px); overflow-y: auto; padding: 7px 10px; border: 1px solid; border-color: var(--Outset); background: var(--DarkGreenBG); color: var(--OffWhite); font-size: 13px; line-height: 1.4; white-space: normal; overflow-wrap: anywhere; box-shadow: 2px 2px #0006; } #apply-settings { position: absolute; right: 16px; bottom: 14px; font-size: 14px; } #open-scan-ranking { position: absolute; left: 20px; bottom: 14px; font-size: 14px; } @@ -185,6 +193,8 @@ dd { margin-top: 2px; color: var(--Maize); font-size: 18px; font-variant-numeric .view > h2 { margin-bottom: 8px; } .view > .hint { margin-top: 8px; } .section-line { display: flex; align-items: end; justify-content: space-between; gap: 12px; margin-bottom: 8px; } +#friends-view .search-label { display: flex; align-items: center; justify-content: end; gap: 8px; width: auto; font-size: 14px; } +#friend-search { width: 220px; max-width: 100%; } .section-line label { display: grid; gap: 3px; color: var(--Label); } .search-label { width: 220px; } .table-wrap { max-width: 100%; overflow: auto; background: var(--DarkGreenBG); border: 1px solid; border-color: var(--Inset); } @@ -222,8 +232,8 @@ tbody tr:hover { background: var(--GreenBG); } .key-footer .dialog-actions { margin: 0; } .key-footer a { white-space: nowrap; } .key-footer button { padding-inline: 10px; white-space: nowrap; } -#history-result { margin-top: 12px; } -#history-download { display: inline-block; margin: 4px 0 8px; color: var(--Maize); } +#history-result { margin-top: 0; } +#history-download { display: inline-block; color: var(--Maize); } .page-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; } @@ -272,7 +282,9 @@ tbody tr:hover { background: var(--GreenBG); } } /* Analysis grows beyond the scan reference while retaining the same VGUI controls. */ -.results-actions { display: flex; gap: 8px; margin-bottom: 12px; } +.results-actions { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; } +.action-link { display: inline-block; padding: 3px 14px; min-height: 28px; text-decoration: none; background: var(--GreenBG); border: 1px solid; border-color: var(--Outset); } +#obsidian-shortcut { margin-left: auto; } #run-library { margin-bottom: 12px; } #run-library .section-line label { font-size: 14px; } #run-library .section-line label:first-of-type { margin-left: auto; } @@ -306,6 +318,18 @@ tbody tr:hover { background: var(--GreenBG); } .target-history { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin: 8px 0; } .target-history button { padding: 2px 8px; } .target-history span { font-size: 14px; } +.history-header { flex-wrap: wrap; justify-content: start; gap: 8px 16px; margin-bottom: 8px; } +.history-identity { display: flex; flex: 1 1 auto; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; min-width: 0; } +#history-heading { font-size: 16px; font-weight: 500; } +#history-name { font-weight: 700; overflow-wrap: anywhere; } +.history-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } +.history-actions button { padding-inline: 8px; font-size: 14px; } +.history-actions a { font-size: 13px; } +/* The toolbar shares the report's visibility without keeping a second state flag. */ +#history-content:has(#history-result[hidden]) .history-data { display: none; } +#history-view #history-back { display: none; } +.history-warning { display: inline-flex; align-items: center; gap: 6px; margin-right: 16px; } +.history-warning strong { font-weight: 500; } .history-filters { flex-wrap: wrap; } .history-scope-line { display: flex; align-items: center; gap: 6px; margin: 8px 0; } .history-scope-line .info { flex: none; } diff --git a/ui/tooltips.ts b/ui/tooltips.ts index 0d14cd4..ea94d6a 100644 --- a/ui/tooltips.ts +++ b/ui/tooltips.ts @@ -30,6 +30,20 @@ export function install(tooltip: HTMLElement) { trigger = null; pinned = false; } + function contents(anchor: HTMLElement) { + const heading = document.createElement("strong"); heading.className = "tooltip-heading"; + const label = anchor instanceof HTMLInputElement || anchor instanceof HTMLSelectElement ? anchor.labels?.[0]?.textContent : null; + heading.textContent = anchor.dataset.tooltipHeading ?? anchor.getAttribute("aria-label")?.replace(/^About /, "") + ?? label?.trim() ?? anchor.textContent?.trim() ?? "Details"; + const lines = (anchor.dataset.tooltip ?? "").split(/\n|(?<=[.!?])\s+(?=[A-Z0-9])/).filter(Boolean); + const body = document.createElement(lines.length > 1 ? "ul" : "p"); + if (lines.length > 1) { + body.className = "tooltip-lines"; + for (const line of lines) { const item = document.createElement("li"); item.textContent = line; body.append(item); } + } else body.textContent = lines[0] ?? ""; + return [heading, body]; + } + function show(anchor: HTMLElement) { clearTimeout(hideTimer); if (anchor === trigger) return; @@ -38,14 +52,21 @@ export function install(tooltip: HTMLElement) { // Keep help inside its dialog so hovering and scrolling remain usable. const container = anchor.closest("dialog:modal") ?? document.body; if (tooltip.parentElement !== container) container.append(tooltip); - tooltip.textContent = anchor.dataset.tooltip ?? ""; + tooltip.replaceChildren(...contents(anchor)); const descriptions = anchor.getAttribute("aria-describedby"); if (!anchor.classList.contains("info") || !descriptions) { anchor.setAttribute("aria-describedby", descriptions ? `${descriptions} ${tooltip.id}` : tooltip.id); } tooltip.showPopover(); const bounds = anchor.getBoundingClientRect(); - const box = tooltip.getBoundingClientRect(); + tooltip.style.removeProperty("max-height"); + let box = tooltip.getBoundingClientRect(); + const verticalSpace = Math.max(bounds.top - 15, innerHeight - bounds.bottom - 15); + const horizontalSpace = Math.max(bounds.left - 15, innerWidth - bounds.right - 15); + // Long help scrolls within the available side rather than covering its own control. + if (box.width > horizontalSpace && box.height > verticalSpace) { + tooltip.style.maxHeight = `${verticalSpace}px`; box = tooltip.getBoundingClientRect(); + } // Consider only adjacent positions. Empty space elsewhere in the window must // not pull help away from the control that explains it. const placements = [[bounds.right + 7, bounds.top], [bounds.left - box.width - 7, bounds.top], @@ -59,6 +80,7 @@ export function install(tooltip: HTMLElement) { for (const [x, y] of placements) { const left = Math.max(8, Math.min(x, innerWidth - box.width - 8)); const top = Math.max(8, Math.min(y, innerHeight - box.height - 8)); + if (left < bounds.right && left + box.width > bounds.left && top < bounds.bottom && top + box.height > bounds.top) continue; const overlap = controls.reduce((total, rect) => total + Math.max(0, Math.min(left + box.width, rect.right) - Math.max(left, rect.left)) * Math.max(0, Math.min(top + box.height, rect.bottom) - Math.max(top, rect.top)), 0); diff --git a/website/content/docs/guides/network.mdx b/website/content/docs/guides/network.mdx index 55f169d..49627ef 100644 --- a/website/content/docs/guides/network.mdx +++ b/website/content/docs/guides/network.mdx @@ -33,15 +33,15 @@ Community colors describe detected clusters; they do not identify real-world gro | Show | Inspect the selected account's direct neighbours or two hops | | Min. connections | Keep profiles with at least this many links of the selected type in the collected graph | | Node size / Show labels | Adjust how profiles appear | -| Arrange / Pause | Run or stop the layout worker | -| Save image | Download the current view as a PNG | +| Pause / Resume | Stop or resume the automatic layout | +| Save image | Download the current view as a PNG with a 4,096-pixel long edge | | Nodes CSV / Edges CSV | Download graph data for [Gephi](/docs/guides/gephi) | Search highlights matches without hiding other profiles. Selection highlights its connections; the footer counts links visible under the current filters. Group links stay separate from friendship centrality. Drag a node to pin its position. Choose **Unpin** to let it move during the next arrangement. Drag empty space to pan, use the wheel or **+ / −** to zoom, and choose **Fit** to reset the camera. The **Profiles** list supports keyboard selection; with the graph focused, use arrow keys to pan and **Enter** for Details. -Layout pauses while Network is hidden and resumes when you return if it was still running. A manual pause stays paused. Large or dense graphs can still render slowly; hide labels, use filters or pause layout. Pictures load only when their circles are large enough to read. Exploration uses saved observations and makes no new Steam API requests. +Layout starts automatically when Network opens and stops when settled. It pauses while Network is hidden and resumes when you return if it was still running. A manual pause stays paused. Large or dense graphs can still render slowly; hide labels, use filters or pause layout. Pictures load only when their circles are large enough to read. Exploration uses saved observations and makes no new Steam API requests. ## Compare coverage first