Merge the load form and files tree into one "Load graph from Penpot" box (tree first, uuid + Load/Unload in a row); Loaded session carries HH:MM in its legend; the Live changes box stays hidden until the first change arrives; query fieldset reads "Query graph (LadybugDB Cypher)" with the link covering both terms. Drop the hover tooltips (distracting, useless zoomed out) and the resident-size estimate (per-table counts stay on hover); every toggle gets a "When set/checked ..." title. Depth fold: 0 now expands every container (no more hunting for max depth). Node inspector: two-column flow, structured or long values folded behind the file-tree disclosure triangle.
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
Since fold-unchanged and depth folding arrived, withCombos ORed them in, so unchecking "fold containers" could no longer remove the combo boxes. The checkbox is the gate again; the derived fold rules are dormant without it.
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
"fold >= depth" number input (root = 0, empty = off, localStorage): every combo whose container sits at that IsChildOf depth or deeper collapses, giving a top-of-file overview (e.g. 2 folds the containers hanging from a Page). Composes with fold-unchanged — depth folds first, changed ancestor paths are then drilled open. Derived fold state overrides manual folds while active.
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
click-select behavior with degree 1: the clicked element keeps a black ring, direct neighbors stay full-strength, everything else dims to 0.2 opacity (inactive state); clicking empty canvas clears. Works on edges too (selects both endpoints) and composes with the node inspector on the same click.
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
Remove grid, random, force, fruchterman, force-atlas2 (nothing over the kept set) and mds (stress layout degenerates to spokes on tree distances, no collision term to tune). Parameterize the keepers against node overlap — concentric/radial get preventOverlap+nodeSize, d3-force a collide radius — and shrink node labels to 7 px on those layouts (DENSE_LABEL_LAYOUTS), verified against variants_simple (72 nodes).
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
Toolbar gains an export item: graph.toDataURL({mode: "overall"}) downloads the whole laid-out graph as graph-<revn>.png — page-chrome-free captures, also the fast path for agents debugging the console. A hover tooltip (table, label, id) backs the reduced/absent labels on dense layouts.
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
Loaded-session fieldset: graph size gains a resident-memory estimate (fit to graph_sizes.md: ~1.1 MiB floor + ~5.4 KiB/node) with per-table counts on hover, replacing the load-time Projection stats; loaded-at compacts to local HH:MM with the full instant on hover. Edge rel labels drop to 7 px and lose the dashed stroke — the text label alone carries rel identity.
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
"Show result in graph view" moves into an actions bar above the results table; results scroll inside a 45vh container (client and server render paths); the Loaded-session fieldset gains a live "Graph size" line that stays fresh through skipped repaints; IsInstanceOf mid-edge label becomes the spelled-out rel name (∈ read as membership, not derivation) with the legend falling back to the dash-arrow for long syms.
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
Clicking a node fetches its full attribute row (MATCH (n:`Table` {id: uuid(...)}) RETURN n.*) through the query endpoint and renders non-null attrs into a panel under the canvas (count of empty attrs noted). Panel over tooltip: projected tables carry ~80 columns, and the panel persists for reading without obstructing the graph. Table/id are validated before Cypher interpolation; the listener is re-attached on every instance recreation.
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
Dash variants alone cannot carry the growing rel roster: EDGE_STYLES entries gain a sym rendered as a small mid-edge label with a white backing (IsInstanceOf = "∈"; IsChildOf stays unlabeled as the background structure), and the legend shows the symbol. Convention from the abacus viewer EDGE_SYM dict.
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
setData merges datum props by id on a live G6 instance, so omitting style.collapsed retained a previous true: with "fold unchanged" on, a change inside a folded combo pulsed but never expanded it. Write the boolean explicitly both ways.
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
Monochrome entity scheme: all node tables share one slate hue, lightness separates within-glyph siblings (validated, worst pair dE 17.5), SVGRaw becomes the hollow hexagon, both rels go grey with dash as the only separator. Diff marks now own all color: thick green/crimson stroke ring (dashed for removals) plus a larger, subtler halo; the legend gains +/- entries while marks are live. Two additions to guide the eye: a brief DOM-overlay pulse on age-0 elements (independent of the G6 animation gate) and a "fold unchanged" toggle that collapses every combo not on an ancestor path of a changed element.
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
Each display-changing refetch is a step: added nodes/edges get a green halo, removed ones stay as ghosts with a dashed crimson halo (nodes, fading opacity) or thicker crimson stroke (edges), re-entering layout and combos through their ghost IsChildOf edges. Marks fade linearly and drop after N steps; N is the new "fade" number input (localStorage, 0 = off). Dash + fade carry the added/removed distinction under red-green CVD (#40c057/#c2255c, deutan dE 17.4); diff is vs the previous display step, not arbitrary revisions.
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
Slice-3 export sends edges with a rel field. Derive tree ranking, combo derivation and fold-ability from IsChildOf only; draw other rels as overlay edges with per-rel styles (EDGE_STYLES: IsInstanceOf violet dashed, matching the new Component diamond in NODE_STYLES). Legend now lists only displayed node tables and rels, re-rendered per redraw; help text trimmed to essentials.
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
The default query is now multi-line with // comments that explain the filter_* column convention in place (Kuzu accepts comments and blank lines mid-statement; verified against an in-memory database through the console query path). The query fieldset is retitled 'LadybugDB Cypher' with the Cypher word linking to https://docs.ladybugdb.com/cypher/.
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
Root cause of 'graph flashes on load then disappears' plus unbounded horizontal growth of the graph panel: fieldsets default to min-inline-size: min-content, so #graph-view-panel sized to its content, and the new ResizeObserver->setSize path closed a feedback loop (setSize -> slightly wider G6 canvas -> wider fieldset -> wider .dashboard flex column -> observer fires) that grew the page ~10px per frame and wiped the painted canvas on every step. Fix severs the feedback path: #graph-view-panel gets min-inline-size: 0, #graph-canvas gets overflow: hidden, and the page section gets flex: 1 1 0 with min-width: 0 so column widths are viewport-driven, never content-driven. This also fixes the original narrow-window scrollbars defect for real. The observer stays (guarded by a current-size comparison) because G6's autoResize is inert on this UMD build (verified: window resizes left the canvas size untouched); the inert autoResize flag is dropped. Legend items now join with spaces so the nowrap spans can wrap between entries.
Also removes the header Expand button - the toolbar's expand/exit icons cover it, Esc still restores.
Verified against the running devenv with a logged-in profile and variants_simple loaded: graph renders and persists, widths stable over multiple seconds at 1400px and 1000px viewports with no horizontal overflow, canvas follows both window shrink and grow, toolbar expand gives a full-page canvas and Esc restores.
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
Graph view gains an on-canvas G6 toolbar (auto-fit, expand, restore - the fullscreen icons drive the existing in-page expand), an 'animate' checkbox that disables animation unconditionally when off (persisted, adaptive <=100-node rule applies only when on), and a ResizeObserver on the canvas so the panel follows window/flex resizes without touching the user's viewport. Preset tree positions are now only injected for the built-in tree layout, removing the tree-then-layout flash on animated re-renders under G6 layouts. Refetches skip the repaint when the display projection (nodes, edges, truncated) is byte-identical, so attribute-only change bursts no longer repaint.
Console: default query returns s/t name+label over all edges plus filter_src_id/filter_tgt_id columns; filter_* columns are hidden from the results table (client and server render) but still feed the 'Show result in graph view' id harvest, keeping the table legible while the graph filter stays available. The query text persists in localStorage across page reloads (restored only over the default, never over a server-rendered query). Legend shows colored Unicode glyphs matching node shapes instead of squares with textual annotations. Load/Unload buttons share one row (HTML5 form attribute), and the loaded file name links to the Penpot workspace via the legacy /#/workspace/<project-id>/<file-id> route resolved client-side from the files-tree payload.
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
The Cypher result pane now offers 'Show result in graph view': any UUID found in any result cell selects the matching nodes in the cached export and the view renders the induced subgraph (edges kept when both endpoints match); 'Show full graph' resets. No graph reconstruction from the query result is needed.
The notifications websocket reconnects automatically (3 s retry) and resubscribes + refetches on reopen, so backend restarts no longer permanently kill the live feed; a lost session now reports 'no graph session (backend restarted?) - reload a file' instead of a bare 404.
Animation is size-adaptive: graphs (or filtered subgraphs) up to 100 nodes render animated for didactics, larger ones stay animation-free; crossing the threshold recreates the instance like a layout switch.
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
Adds a layout <select> next to the fold toggle, populated from the LAYOUTS map in the template: 'tree' (the O(n) preset layout, default) plus 13 G6 layouts (antv-dagre, dagre, circular, concentric, radial, grid, force, d3-force, force-atlas2, fruchterman, mds, combo-combined, random), all smoke-tested against combo data on this UMD build. Layout and fold toggle are independent; switching layouts recreates the graph instance (cheap with animation off); both choices persist in localStorage. antv-dagre stays available for when non-tree edges arrive.
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
Root cause of the tab freeze on ~1700-node files was G6's default entrance animation: measured 1700 nodes at >2 min animated vs 1.5 s with animation: false. Secondary cost was antv-dagre (~7 s at that size); since IsChildOf is a tree, an O(n) tidy layout (depth = rank, post-order leaf slots, parents centered) computed client-side replaces it and renders the same file in ~1.4 s. A guard skips auto-render above 4000 nodes with an explicit Render-anyway button, so opening the console with a huge session loaded stays responsive.
Folding is now switchable ('fold containers' checkbox, persisted in localStorage) and generalized: any node with children folds except the IsChildOf root of the loaded graph, so Documents (and later Projects/Teams) fold automatically once they gain a parent node.
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
Non-empty containers (Page, Frame, Group, Boolean, SVGRaw) render as nested G6 rect combos holding their own node plus direct children; Document stays a plain node. Double-click folds/expands (collapse-expand behavior); collapsed combos show a member count and re-route child edges. Fold state is read back from getComboData and re-marked on every refetch, so it survives live redraws. Layout gains sortByCombo to keep same-rank nodes grouped by box.
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
Fullscreen API took over the whole output and broke window-manager splits (and is denied in some environments). The Expand button now toggles a fixed-position overlay covering the page while keeping browser chrome; Esc restores. Column positioning moved from inline style to the stylesheet so the expanded class can override it.
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
Graph view moves to its own sticky right column (overrides .widget max-width). New /dbg/actions/graph-files endpoint lists teams -> projects -> files for the profile; the console renders it as a collapsible tree where clicking a file loads it. Maximize button fullscreens the graph panel and resizes G6 on fullscreenchange.
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
COPY failed on any file with container shapes: list-typed DDL columns (shapes UUID[], points STRING[], strokes JSON[], ...) were JSON-encoded in staging CSVs, which Ladybug's list parser rejects. Write Kuzu list literals instead, typed per column. Also: value->clj no longer crashes on LIST/STRUCT values (binding lacks value_get_value support; fall back to string), and the debug session Connection is now guarded by a per-session lock — it was shared unsynchronized between the msgbus sync loop and HTTP query/export handlers, and one lost DETACH DELETE was observed under concurrent refetch load.
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
POC per work/g6/plan.md. New /dbg/actions/graph-data exports the in-memory Ladybug session as plain JSON (per-table node queries + multi-table IsChildOf match, row cap 100k with truncation flag). Console page renders it with AntV G6 v5 (jsDelivr CDN, antv-dagre BT layout, color+glyph per node table, validated palette) and refetches debounced on live :file-change messages.
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
Copy Current into DocAtlas and the tile atlas with Surface::draw
instead of image_snapshot_with_bounds, matching the interactive
path and removing a GPU sync stall on every completed tile.
Add end-to-end HTTP tests under backend/test/e2e/ using Node.js built-in
test runner (node:test) and native fetch. Tests run through the devenv
nginx proxy on port 3450.
Test suites (19 tests total):
- auth-flow: demo profile creation, login, session cookies, access tokens
- export-binfile: file creation, export to asset URL via SSE
- asset-download: download with cookie/token auth, 401 without auth,
S3 redirect behavior, full export-to-download flow
Key findings documented in tests:
- nginx @handle_redirect intercepts backend 307 and proxies to S3 directly,
stripping the client Authorization header (bug does not reproduce in devenv)
- SSE end event uses ~#uri tagged format for URLs
- Unauthenticated RPC returns uuid/zero profile (not null)
AI-assisted-by: mimo-v2.5-pro
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
* ✨ Add media-processor service for image and font processing
Externalizes ImageMagick and FontForge subprocess invocations into a
separate Node.js HTTP service (media-processor/). Backend dispatches
via feature flag :use-remote-media-processing.
Key changes:
- media-processor module (TypeScript, Express 5, Sharp, FontForge/woff)
- POST /api/image/info, /api/image/thumbnail, /api/font/generate
- Resource limits: 128MP rejection, prlimit (512MB + 30s CPU)
- Streaming multipart via SequenceInputStream
- app.media split into validation (leaf), local (shell impls), remote (HTTP)
- Schema enforcement: :upload and :input schemas in validation namespace
- Configurable timeout (PENPOT_MEDIA_PROCESSING_SERVICE_TIMEOUT)
- 78 tests across 4 files (image, font, middleware, config)
- FontForge path escaping for command injection prevention
- Parallel font variant conversions with Promise.all
AI-assisted-by: mimo-v2.5-pro
* 🐳 Revert docker-compose changes from media-processor commit
Remove docker-compose.yaml modifications that were part of the media-processor
service commit. The media-processor service definition, flags, and environment
variables are reverted to their previous state.
AI-assisted-by: qwen3.7-plus
* ⬆️ Update dependencies
* 🐛 Fix PR review issues in media-processor
- Font path bug: sfntToWoff and woff2ToSfnt now copy input to temp dir
when input is a file path, ensuring output lands in expected location
- Error preservation: execCommand preserves killed/signal/code properties
from child process errors for OOM detection
- Content-Length: service-multipart-request calculates and includes
Content-Length header for streaming multipart requests
AI-assisted-by: qwen3.7-plus
* 🐛 Fix code review issues in media-processor
- Rename PENPOT_MEDIA_PROCESSOR_SECRET_KEY to PENPOT_MEDIA_PROCESSOR_SHARED_KEY
in devenv to match backend config key
- Fix timeout middleware to destroy request AFTER response finishes,
preventing truncated 504 responses
- Fix quality=0 parsing to preserve explicit zero (was silently overridden to 85)
- Replace require('fs') with proper ES module import in upload-storage.ts
- Refactor font conversion temp-dir boilerplate into withTempInput helper
- Document FontForge escaping limitations (single quotes only)
- Fix misleading comment in image.ts about sharp metadata decoding
AI-assisted-by: qwen3.7-plus
* 🐛 Fix code review issues in media-processor (round 2)
- Fix queue middleware to skip next() when response already ended,
preventing orphaned work after timeout
- Fix hybrid storage to use disk when Content-Length is absent (chunked
transfer), preventing unbounded memory allocation
- Add source image format validation in generateThumbnail to reject
unsupported formats (TIFF, BMP, etc.) with 400 instead of 500
- Remove dead code in convertFont for unreachable woff→woff path
- Remove unused isEnabled() method from LokiLogTransport
- Fix sfntToWoff to use correct extension (.ttf/.otf) based on source type
- Extract queue middleware to separate file for testability
- Add comprehensive tests for queue middleware and upload storage
AI-assisted-by: qwen3.7-plus
* 🐛 Fix code review issues in media-processor (round 3)
- Fix disk-backed upload cleanup after successful requests by adding
cleanup middleware that removes temp files on response finish/close
- Wrap sharp metadata/decoding errors as 400 validation errors instead
of 500 internal errors
- Only apply flatten() for JPEG output to preserve alpha channel in
PNG and WebP outputs
AI-assisted-by: qwen3.7-plus
* ✨ Add comprehensive tests for media-processor
Phase 1 - Cleanup verification:
- Add cleanup middleware unit tests (6 tests)
- Add HTTP upload cleanup integration tests (5 tests)
Phase 2 - Error handling & alpha preservation:
- Add sharp error wrapping tests (4 tests)
- Add HTTP malformed image tests (2 tests)
- Add alpha preservation tests (3 tests)
Phase 3 - Edge cases:
- Add upload storage edge case tests (3 tests)
- Add queue middleware edge case tests (4 tests)
Phase 4 - Backend mock verification:
- Fix backend mocks to include :mtype field in image info responses
- Verify all error codes match actual service behavior
Total: 27 new tests added (160 tests passing)
AI-assisted-by: qwen3.7-plus
* 🐛 Fix code review issues in media-processor (round 4)
- Add Zod validation constraints for config values (int, positive, min)
- Fix auth middleware to compare Buffer byte lengths instead of string lengths
- Validate requested output dimensions in generateThumbnail (crop mode)
- Change queue middleware to release slot via callback in finally block
- Add comprehensive tests for all fixes
AI-assisted-by: qwen3.7-plus
* 🐛 Close HTTP response streams in backend media remote
- Wrap stream consumption in try/finally with .close() calls
- Add tests to verify stream closure for info, font-convert, and thumbnail
AI-assisted-by: qwen3.7-plus
* 🐛 Fix queue slot leak on upload failures
Make releaseQueue idempotent and attach fallback listener to release
slot when response finishes. This covers Multer errors that bypass
the route handler's finally block, preventing permanent queue stall.
AI-assisted-by: qwen3.7-plus
* 🐛 Cancel processing on timeout
Create AbortController in timeout middleware and abort signal when
timeout fires. Pass signal to Sharp and FontForge to cancel ongoing
processing and release resources when request is cancelled.
AI-assisted-by: qwen3.7-plus
* 🐛 Fix code review issues in media-processor (round 6)
- Error handler: check headersSent before writing response to prevent
ERR_HTTP_HEADERS_SENT when timeout already sent 504
- Timeout config: increase default requestTimeout from 60s to 180s to
match font processing timeout (120s) and backend request timeout
- Image processing: check abort signal before starting Sharp operations
to cancel processing when timeout fires
- Queue lifecycle: remove res.on('close', release) fallback to hold
queue slot until processing completes, preventing concurrency limit
violation when client disconnects
AI-assisted-by: qwen3.7-plus
* 🐛 Close HTTP response stream in download-image
Wrap response body in with-open to ensure stream is closed after
writing to temp file, preventing HTTP connection leaks on repeated
URL imports.
AI-assisted-by: qwen3.7-plus
* 🐛 Close HTTP response stream on validation errors in download-image
Move with-open to wrap the entire validation and processing block,
ensuring the response body stream is closed even when validation fails
(non-2xx status, missing size, invalid media type). This prevents
HTTP connection leaks on repeated failed downloads.
Add test to verify stream closure on validation errors.
AI-assisted-by: qwen3.7-plus
* 🐛 Pass abort signal to Sharp toBuffer for timeout cancellation
Wrap Sharp's toBuffer() with Promise.race to check abort signal during
processing. This ensures large thumbnails stop processing when the
request times out, preventing wasted CPU/memory and queue capacity.
Add test to verify abort during toBuffer operation.
AI-assisted-by: qwen3.7-plus
* 🐛 Hold queue slot until Sharp completes and handle client disconnect
- Remove Promise.race from generateThumbnail — Sharp processing now
completes fully before queue slot is released, preventing concurrency
limit violations under timeout conditions
- Remove res.on("finish", release) fallback from queue middleware —
error handler now explicitly calls releaseQueue in all error paths
- Add res.on("close") handler in timeout middleware to abort signal
when client disconnects, ensuring processing stops early
- Add tests for client disconnect handling and queue slot lifecycle
AI-assisted-by: qwen3.7-plus
* 🐛 Address round 9 review findings
- Document Sharp 0.35.3 cancellation limitation in image.ts
- Add integration test for timeout cleanup with large images
- Fix font tools (sfntToWoff, woffToSfnt, woff2ToSfnt) to throw
ProcessingError on resource limit kills instead of returning null
- Validate font signatures for same-format conversions to prevent
arbitrary files from being persisted as valid fonts
- Fix concurrent mkdtemp race in upload-storage by using shared
initialization promise
AI-assisted-by: qwen3.7-plus
* 🐛 Address round 10 review findings
- Add tmpdir assertion in font.ts to prevent path injection
- Preserve original error in queue middleware catch handler
- Change auth middleware response type from "internal" to "authorization"
- Add cleanup flag to prevent double cleanup in cleanup middleware
- Move quality clamping into parseQuality function for consistency
- Add integration tests for quality parameter clamping at route level
- Update existing tests to match new auth response type
AI-assisted-by: qwen3.7-plus
* 🐛 Address round 11 review findings
- Extract releaseSlot helper in error-handler to reduce duplication
- Remove redundant try/catch in font.ts withTempDir cleanup
- Improve font path validation error message for clarity
- Move path validation before try/catch to prevent swallowing
- Add debug logging for cleanup failures in cleanup middleware
- Inline TransportTargetSpec type alias in logger.ts
- Extract logging middleware to separate file for consistency
- Remove duplicate MIME validation in image thumbnail route
- Add test for font path validation (outside tmpdir rejection)
- Add tests for error handler queue release across all branches
AI-assisted-by: qwen3.7-plus
* 🐛 Remove Content-Length header from multipart requests
The JDK's HttpClient rejects Content-Length as a restricted header,
causing IllegalArgumentException when sending multipart requests to the
media-processor. Remove the explicit Content-Length header and let the
JDK use chunked transfer encoding. The media-processor will use disk
storage for all multipart requests (safe default behavior).
Remove unused size computations (file-size, header-bytes, footer-bytes,
total-size) that were only used for Content-Length.
Update test to verify Content-Length is not present in request headers.
AI-assisted-by: qwen3.7-plus
* 🐛 Fix pino ESM bundling for media-processor
Mark pino and its transports (pino-pretty, pino-loki) as external to
avoid bundling issues with worker thread modules that reference
__dirname (not available in ES modules).
AI-assisted-by: qwen3.7-plus