Release/1.17.0 - #10
Merged
Merged
Conversation
The AST character map lived as a private method on StringDemoDataLoader; hoist it to StaticUtilities so other data loaders can share it. The demo loader delegates to keep its call sites unchanged.
Fetch a graph straight from a Neo4j server: connection popup (URL, credentials, optional database, Cypher query), a row-count preflight that warns before large fetches, and a property-exclusion checklist (large arrays such as embeddings start deselected) before the data enters the shared applyGraph pipeline.
Uses plain fetch against /db/{name}/tx/commit with the graph result format — no driver dependency, works in both browser and Electron builds. Labels, relationship types, and list properties map onto pipe-separated multi-value categoricals so the existing filter UI works on them directly. Connection settings persist in localStorage; the password is never stored.
Remove an unused venue_dir assignment and four placeholder-less f-strings flagged by ruff (F841, F541).
Popup.createPopup moves the .p-footer out of the content element into the popup root, so querying the form for the buttons after construction returned null and the Fetch/Cancel listeners were never attached. Capture the button references before constructing the Popup; add regression tests that click the buttons through the live document.
Connection modal: theme-token styling (dark-mode correct), inline error box (role=alert) that keeps the popup open with inputs intact, in-button spinner while counting/fetching, buttons disabled during work. executeNeo4jImport gains injectable progress/onError/onFetched hooks so the popup drives its own feedback while the default flow keeps the global overlay. Property checklist: value type badge (number/text/boolean/list/object/mixed, 'large list' for embedding-sized arrays), up to two truncated example values per property, per-section select-all with indeterminate state, scrollable themed list. Mapping: the last label is now the primary one (class-hierarchy tooling stores ancestors first, leaf last), the synthetic 'Neo4j > Labels'/'Type' filter group is dropped as redundant with per-label property groups, and nodes/edges are auto-colored per label/relationship type (brand SLICE_PALETTE first, golden-angle hues beyond, only when >=2 categories).
Raw booleans pass the importer's isNaN numeric check (isNaN(true) === false) and became degenerate [1,1] range sliders whose generated 'BETWEEN 1 AND 1' condition never validates — the query AST requires typeof number. Under an OR filter join, which includes every active filter, that hid all edges carrying only boolean properties; the AND join appeared to work only because nothing was narrowed, yielding an empty (unconstrained) query. Booleans now coerce to 'true'/'false' strings, giving a categorical true/false checklist that IN-condition evaluation matches correctly.
Raw booleans from any source (Excel boolean cells, hand-written JSON payloads, live API pushes) mis-classify as numeric (isNaN(true) === false) and become degenerate [1,1] sliders whose BETWEEN condition never validates, hiding their carriers under an OR filter join — the same defect just fixed source-locally for the Neo4j loader. preProcessData now stringifies boolean D4Data values to 'true'/'false' so every import path yields categorical true/false filters.
Grow a Neo4j graph without re-importing, gated behind the active session (password in memory only; buttons hide when another source stamps the data-source label): - Expand (selection HUD): preflight counts neighbors per (relationship type, leaf label) pair, checklist with sum warning, exact pair-filtered fetch via parameterized elementId() Cypher (Neo4j 5+). - Add query (workspace toolbar): slim popup against the authenticated session with the same row-count confirm as the import. - Merge primitive unions raw entities per session accumulator (colors stay stable, re-fetched ids refresh properties), re-applies import-time property exclusions, and declares the current workspace with full positions so the apply pipeline skips the Excel-path force pass — existing nodes render exactly where they were, in one paint. - New nodes seed near a positioned neighbor (else centroid) and float into place via settlePinnedForce: per-iteration FA2 over the full graph with all pre-existing nodes pinned (iterate-and-restore; bundled FA2 has no native pinning). - Optional stitch pass (checkbox in both dialogs, default on) fetches relationships among all loaded nodes; USING JOIN ON m forces a NodeHashJoin after a 372k-degree supernode made the naive per-row IN filter take minutes on a live database. - 🛢️ replaces 🗄️ as the Neo4j glyph everywhere (legible at toolbar size).
Property ids hash as mainGroup::subGroup::propName, but the pie picker stripped everything before the last '::' — so same-named properties on different subGroups (Cell::score vs Document::score, common after Neo4j imports) rendered as identical rows with no way to tell them apart. Colliding names now get a '(subGroup)' suffix in the property list and the numeric slice-color rows (labelsFor builds the collision map once per render), each property row carries a 'subGroup > propName' tooltip, and availableProperties sorts by short name first so twins sit adjacent. Display-only; unique names and the propId format are unchanged.
A link in the connection dialog fills in Neo4j Labs' public read-only demo server (published credentials) with a query fetching the best-answered neo4j-tagged Stack Overflow questions and their 1-hop neighborhood (~1.5k nodes / ~1.8k rels, sized under the row-count confirm threshold), with CC BY-SA attribution links. Edge auto-colors no longer reuse the node pie palette, whose pale tones (#EFB0AA, #8CA6D9) vanished at the 90 alpha over light backgrounds: edges now walk the golden-angle hue wheel at mid HSL lightness, legible on both themes. Landing Neo4j card mentions the demo; .neo4j-info links use --brand-text for dark-theme legibility.
Every statement passing through runCypher (fetch, preflights, expand, join, stitch) now emits a grey 🛢️-prefixed line in the sidebar status log, whitespace-collapsed and truncated at 160 chars. No-op outside the app (globalThis.cache guard keeps tests silent).
Resolves the parallel 1.16.x release lines: main's 1.16.0/1.16.1 stay canonical; the branch-local Neo4j releases (1.16.0-1.16.3) move to an Unreleased changelog section for the next minor. Version pinned to 1.16.1. Keeps both appended CSS blocks (Neo4j connector + Excel merge preview) and accepts main's deletion of package_latex_submission.py from the manuscript flatten.
…oggle Concept C phase 1 (redesign_and_mocks/ui-redesign-concept-c.md §8): - "?" hotkey opens a keyboard cheat sheet popup listing every global shortcut; acts as a toggle and mirrors the hotkey switch in graph/core.js. - Selection HUD's Tools panel starts expanded (spec: no hidden surfaces). - The filter panel's ⚙ Details toggle is deleted (§6.3): exact value inputs, per-row group/selection actions, and the OR/AND join cluster are always rendered; the gll.filterDetails localStorage preference is retired. (SVG as a visible export row already shipped with the export rework.)
Concept C phase 2 (redesign_and_mocks/ui-redesign-concept-c.md §6.1/§6.2):
- Two-stage type classification: populateFilterPropsLowsAndHighs only
accumulates (numeric bounds, categories, boolean candidacy, per-type
counts); a new finalizeFilterClassification pass resolves each property
to numeric, categorical, boolean, or unusable after all values are seen.
- Boolean columns (true/TRUE/1 vs false/FALSE/0, per the Excel template's
encoding) become a refined categorical with canonical categories
{'true','false'} and render as a three-state Any/True/False segment
(new BooleanToggle in ui_components.js).
- New query DSL forms IS TRUE / IS FALSE evaluate against raw D4Data with
encoding normalization; generated boolean conditions use them, manual
queries and the assistant AST (new IS_TRUE/IS_FALSE ops) can too.
- 0/1-numeric columns are override-eligible ('treat as 0/1 numbers' link);
the choice lives in cache.data.booleanTypeOverrides, persists in the
workspace JSON, and survives data-table/Excel-merge/Neo4j rebuilds.
- Mixed numeric+text columns are no longer deleted: they stay visible as
disabled rows stating the reason and per-type counts, and are skipped by
query generation, scale/pie pickers, and the assistant context.
- Saved workspaces migrate: pre-inference categorical booleans get their
categories canonicalized, stale-typed filters reset to the new default.
42 new tests (classification, override round-trip, migration, DSL
evaluation, widget behavior, query generation); 1617 green.
…r widgets User feedback on phase 2: - The 'treat as 0/1 numbers' override is gone, including its workspace-JSON persistence and the rebuild-path carry-overs. Classification is purely data-driven: two distinct boolean-encoded values → boolean toggle; a rebuild that sees a third distinct value (e.g. added via the data editor) reclassifies the column back to a numeric slider automatically (covered by a new test). Spec §6.1/§7.2 updated to match. - Filter control column gets a 150px floor and long property names wrap (grid minmax + overflow-wrap), so sliders/dropdowns stay readable next to very long labels now that details are always rendered.
…election counts Concept C phase 3 (redesign_and_mocks/ui-redesign-concept-c.md §8): - Permanent 52px rail replaces the sidebar header-row, app toolbar, workspace bar and the selection HUD's counts/undo-redo/lasso: ◆ app menu, workspace chip (live counts + switch/new/rename/delete), ⛶, ↻ layout menu labelled with the current algorithm (algorithms + remove overlaps + hide disconnected), ➰ lasso, ✨ hover, selection chip (one focus/clear pair per decision 3, undo/redo, 'N hidden' warning per decision 1), Data/Query/Metrics/Assist tabs, 🛢 join, ⤓ export menu (PNG 1×/2×/4×, SVG, JSON, Excel), theme, ? sheet. - New src/managers/rail.js: RailMenu disclosure popovers (aria-expanded, Escape closes, aria-disabled rows inert). - relayoutWorkspace(layoutType) now requires a type; the rail menu is the picker — Popup.layoutSelectDialog deleted with its tests. - Workspace rename added (lm.renameSelectedLayout, Default protected). - gcm.focusSelection / sm.clearSelection back the chip's single 🔍/×. - showOnLoad/hideOnLoad mechanism + duplicate launch block retired (decision 6); body is a flex column, bottom bar no longer manages #mainContent heights; export-resolution popover and manageDynamicWidgets deleted; tour steps rewritten for the rail. - HUD keeps Style/groups/Tools until phase 4 absorbs them. 1622 tests green (rail.test.js, rail-selection-chip.test.js; relayout-workspace.test.js rewritten).
Concept C phase 4. The filter sidebar, the styling sidebar and the floating selection HUD collapse into a single right-hand column with two contexts: Workspace (filters, groups, overlays, find, metrics) and Selection (act, arrange, appearance). New src/managers/inspector.js owns the router; its selection sync is edge-triggered so a stray recompute never yanks the panel away from a user who switched contexts deliberately. createStyleDiv still builds every config card in one pass — UIManager.CARD_MOUNTS is now the whole "which panel owns what" map and re-parents each card to its single home. "Select Elements" was split: the card that *creates* a selection moves into the rail's new ◈ Select menu, while the set operations that *grow* one become a new "Act on Selection" card in the inspector. Rail gains ◐ Overlays (density heatmap on/off — removed from the heatmap card so there is one switch, not two — minimap show/hide, and presentation mode). Presentation mode (⇧F, Escape to leave) replaces the HUD's ✕ hide. Deleted: #sidebar, #rightSidebar, #selectedElementsContainer, selection_hud.js, toggleStylingPanel, toggleSelectionEditor, #stylingSelectionStatus, the 🎨 Style button and ~440 lines of HUD/sidebar CSS. Y now scrolls the inspector to Appearance. Tour rewritten for the new shell; its stale ⚙ Details paragraph fixed. 1637 tests green, lint clean, verified live in Chromium with no page errors.
Concept C phase 5. The bottom bar's exclusive Query-or-Data slot and the docked assistant sidebar become one bottom surface with four tabs: Data, Query, Metrics, Assistant. No tab closes another. #workbench is positioned inside #outerGraphContainer, so it occludes the canvas and geometrically cannot reach the inspector. New src/managers/workbench.js owns tab state, per-tab remembered height (localStorage gll.workbench.heights, read defensively) and the ⤢ full-height toggle. Its TABS map is pure wiring; visibility side effects live on the managers that own the state — metrics.setWorkbenchVisible drives the lazy compute gate, assistant.setWorkbenchVisible drives the budget poller and the first-run setup prompt. The assistant reads in a centred 720px column with ⚙ Settings / 🗑 Clear in the workbench toolbar. Its empty state and message list both claimed flex:1, which was fine in a tall dock but pushed the input row off a short workbench — found live, fixed with min-height/overflow and safe centring. Deleted: #bottomBar, #assistantSidebar and its resize drag, ui.showEditor, hideBottomBar, closeBottomBar, makeBottomBarResizable, the .nw-root max-height animation. The tour's close-the-other-editor dances collapse into single workbench.show(tab) calls. 1656 tests green, lint clean, verified live in Chromium against the airport template with no page errors.
Four defects from the phase-5 eyeball, all consequences of the frame changing shape under things that assumed the old one. - The drag skeleton was position:fixed left:0 right:0, a leftover from the full-width bottom bar, so it painted across the inspector. It is now absolute inside the stage, which is exactly what it resizes. - The minimap sat under the workbench. workbench.js publishes --workbench-height on the stage and .gll-minimap offsets bottom by it, updated live during the drag — the panel stays preview-only, but a custom property costs nothing to repaint. - The assistant is full-width. The centred 720px column read as two empty margins in a wide short frame; readability is capped on the bubble instead (min(92%, 82ch)), which is where the prose is. - Metrics rebuilt for the frame rather than just relocated: two columns, ranked list filling the height (~23 rows, was 6) beside a fixed-width graph-level summary, picker and selection verbs on one row above it. Kept <select multiple> — ctrl/shift ranges and keyboard nav come free from the platform. Dropped the redundant h3 and the <hr>s. 1656 tests green, lint clean, all four re-verified in Chromium.
…stage Switching to a metric worked the first time and never again: updateMetricUI's cache guard skipped the render along with the recompute, so going back to an already-computed metric left the previous one's ranking, graph-level table and 🛈 popup on screen. A first visit had no cache entry and took the full path, which disguised the bug as 'cannot navigate backwards'. Pre-existing — the workbench move only made it easy to hit. Render is now split from compute. The cache entry carries the whole render payload (scores, graphLevelMetrics, popup content) rather than just nodeValues, #renderMetric paints it, and renderedMetric tracks what is on screen so a cache hit repaints only when it disagrees with the dropdown. The expensive calculation still runs at most once per metric per visible subgraph. Scale pickers read label/valueLabel/values only, so the wider entry is compatible. Separately, ⤢ now expands to the full stage instead of 92%. The mockup wanted a strip of canvas left visible as reassurance, but ~76px of a dense graph reads as a rendering artefact — it was reported as a suspected bug. The minimap's offset is capped so a full-height workbench cannot push it out over the rail. 1660 tests green (4 new, one of which reproduced the switching bug first), lint clean, both verified in Chromium.
…first Phase 6 of the Concept C redesign. The inspector's 356px column cannot show 47 properties, so a new surface lifts the filters over the stage. The surface re-parents #filterContainer between the inspector and the overlay rather than rendering a second copy, so every listener and live widget object survives the move and there is only ever one source of truth. It bottoms out at --workbench-height, letting the filters and the query editor coexist. The reflow is column-width on .filter-section-body: sections span, sub-groups become the column items. Two layout traps came with that — .filter-row[hidden] needs an explicit rule to beat display:contents, and .filter-input-row's absolutely-positioned separator painted a stray glyph adrift from its row inside the multicol, so it is now an in-flow flex item. Search matches a data-search attribute stamped on each row, hides emptied groups and un-collapses matching ones; active filters float to the top of their sub-group on open, never while the surface is up.
Found by the axe-core pass over the expanded filter surface; both defects predate it and are equally present in the compact panel. The two range inputs behind every numeric filter had no accessible name at all, so a screen reader announced two anonymous sliders per property with no way to tell the lower threshold from the upper (WCAG 2.0 A, 4.1.2). White on the dark theme's lighter --accent-text is 3.43:1 on .insp-pill.active and .filter-join-segment.active (WCAG 2.0 AA, 1.4.3). Dark foreground on the brand fill, as the Concept C mockups specify for dark mode. Still failing and deliberately untouched: target-size (2.5.8), 69 nodes of 14-18px icon buttons across the app, which wants a global sizing decision.
The strict AND join built one condition group per element type and OR-ed them together. With filters narrowed on only one type there was a single group, so every element of the other type was judged against a condition it can never satisfy — a foreign main group evaluates false. Narrowing a node property hid every edge; narrowing an edge property hid every node, and the edges followed once their endpoints were gone. Strict now emits the same per-conjunct shape as non-strict, differing by one keyword: non-strict excuses a conjunct when the value is absent OR foreign (IS MISSING), strict only when it is foreign (IS FOREIGN). A type with no narrowed filter of its own is unconstrained; an element of a filtered type that lacks the value is still excluded, which is the point of the setting. IS FOREIGN is a new unary predicate in the query DSL, modelled on IS MISSING across the evaluator, encoder, decoder, syntax colouring and help. Verified live on the airport template: complete cases went from 0/240 edges to 191/240.
Under AND a filter only constrains once it is narrowed away from the range or values it loaded with; one left at its default means "don't care". The rule was invisible, and it surfaced as two separate-looking bugs: switching an untouched panel to AND emptied the query for no apparent reason, and a filter's checkbox did nothing when toggled. The toolbar now carries the census — "3 of 15 filters constrain the graph", or the all-defaults case named outright, which is what makes the empty query legible — and rows that carry no constraint have their name muted. Hidden under OR, where active and constraining coincide. isFilterNarrowed moves out of QueryManager into a shared function so the census and the derived query apply one definition. The hint updates from updateQueryTextArea, which every filter change funnels through, so the two are computed from the same state in the same pass. Only the label is muted, not the row: opacity on the cells also dims the slider value bubbles, whose white-on-brand text drops from 4.08:1 to 2.18:1, and a descendant cannot opt out of an ancestor's opacity. The muted label measures 5.22:1 light / 6.30:1 dark.
The last Concept C phase. One dialog searches every control in the app plus every node and edge, and each row prints where the control lives and what its accelerator is — the palette teaches the layout rather than replacing it. The index is derived from the live DOM on every open instead of a hand-written registry of ~120 descriptors: rail buttons, rail menu rows, both inspector panels, the workbench toolbars and the filter container all carry their own name, tooltip and accelerator already. A control added later is searchable the day it lands, with no entry to forget. Three verbs: run clicks the real control so its own guards apply, reveal walks it back into view (opens its menu, switches inspector context, un-collapses its card, rings it), focus centres a node or edge. A disabled control is indexed but downgraded to reveal, because running it would be a silent no-op. Deletes the Focus Elements card, two datalists under an inspector section, which the palette absorbs off the same lookup maps. Also fixed in passing: the filter sections' bare ✔/✗/⟳ buttons had no accessible name identifying the group they act on.
added 28 commits
August 7, 2026 10:57
The floor was unbounded while uv.lock pins 2.2.3 and PyPI is a full major ahead of anything this has been run against.
…dle to the keyboard Workbench tabs and the boolean Any/True/False segments carried their state in a class alone; group membership rows showed a three-state ✓/–/blank with nothing behind it; the palette match count and the filter search's "no properties match" changed silently. The workbench resize handle was drag-only and is now an arrow-key separator (Shift for coarse steps). The selection chip's 11px warning gets a dark-theme colour, 4.19:1 → 5.9:1.
…d out Expand put the checked relationship types only in the WHERE, so the server expanded EVERY relationship of every selected node and then discarded the unchecked ones — keeping 1 of 20 neighbour groups still traversed all 20. The types now go into the pattern as backtick-escaped literals (types cannot be parameterised); the WHERE stays, since it is what matches exact pairs. Stitch fell back to a whole-graph sweep the moment one merge was made with the box unchecked, and stayed there forever. It now keeps the incremental form and simply treats the skipped batch as another uncovered endpoint. Also: the preflight counted a relationship internal to the selection once per endpoint, and expand had no size gate at all while the import path confirms above the same threshold.
The tour's executeAction dispatch (0% covered, and it drives real workbench and inspector side effects), the new y/⇧F/? shortcut mappings, the community "Groups" clamp, uncheckAllCheckboxes, and the inspector resize clamp. Two source changes came with them: the community clamp is now one function rather than two inline copies disagreeing with the hardcoded "2"/"50" on the input two lines above, and StaticUtilities gains the clamp and truncate that roughly nineteen files hand-inline — the inspector resize is the first caller, the rest is a follow-up sweep.
Renderer group state and the change-detection baseline are global while groups belong to one workspace, but every reset loop iterated traverseBubbleSets() — the SELECTED layout's keys. Creating a workspace from a template selects it before clearing, so the loops walked its empty group set and never touched the groups being left behind: the layer kept painting them (refitted to the new positions) with nothing in the Groups panel to explain them. updateBubbleSetIfChanged() now drops layer, instance and baseline state for any group the current workspace does not own — one guard on the path both changeLayout() and addLayout() already route through. Dropping the baseline matters too: without it, switching back compared against a stale "already drawn" set and never repainted. clearBubbleSetInstanceMembers() and the baseline re-seed loop in addLayout() go with it; both were no-ops on a fresh template for the same reason, and the former would have thrown on un-materialized instances.
…h size
Avoid Other Nodes silently did nothing on any graph over 1000 nodes:
io.preProcessData set a flag, getAvoidMembers returned [], and the switch
stayed on with nothing anywhere saying why.
The threshold was on the wrong variable. Fitting a hull around non-members
costs O(members x avoid) - measured against computeOutlineGeometry today:
5 members x 2 000 avoid -> 48 ms 100 x 3 000 -> 935 ms
20 members x 10 000 avoid -> 540 ms 200 x 2 000 -> 1 939 ms
10 members x 50 000 avoid -> 1 646 ms 300 x 5 700 -> 20 345 ms
So a 5-member group on a 10 000-node graph cost a quarter of a second and
was refused, while a 300-member group - the only shape that ever needed
refusing - was refused for the same reason on a far smaller graph.
Groups now estimate their own fit (~6 us per pair, within ~2-3x across that
whole range) and only ask when it exceeds AVOID_FIT_CONFIRM_MS. Most groups
never see a prompt and simply start working again. An expensive one names
the wait and lets the user accept it; the answer is remembered per group,
declining turns the switch back off rather than leaving it lying, and an
accepted fit runs behind the loading overlay instead of a dead app.
Turning the switch on also re-supplies the obstacle list, which it never
did - the list only arrived on a membership change, so even under the old
threshold the switch alone could not start avoidance.
CSS: the old style-div layout system, 24 unreferenced utility selectors, the four stale classes in the locked-filter rule, and the size-xl/ data-table-apply-button/dataTextArea leftovers — 235 lines. JS: computeOutlinePoints (a test-only wrapper over computeOutlineGeometry), the virtualEdges option (no UI writer, no import/export mapping), the rail-menu-app/-wide classes nothing styles, a leftover console.log and two commented-out blocks, the __BUBBLE_DEBUG gate (the union failure now warns instead of being unconditionally silent), afterMembershipChange's duplicate syncGroupRows, the never-passed storage parameters, the neo4j_loader sanitizeForAST re-export, and EXPANDED_HEIGHT_FRACTION = 1. vendor-libs now strips the sourceMappingURL from the copied ESM builds: the maps are not copied, so vite logged a failed-source-map stack on every test run and dev serve.
The tour's Back button was the brand red on hover: it set only background-color, so .p-button:hover outranked it. It is a p-button-secondary now, whose hover already exists — the bespoke class is gone. The filter range input's focus ring hardcoded the light value of --accent-ring, the token added this release precisely so the ring lifts to 0.45 alpha in dark, where 0.25 barely reads over --input-bg. .layer-switch was the only interactive control with no hover affordance; .annotation-note:focus-visible differed from the global rule by 1px of offset and a stale fallback for a token that is always defined; .cmdk-flash ringed a 7px row with a 6px radius. Rail: one radius (8px) across mark/tab/mini, and the 32px mark joins the 34px control height. rail-mini stays 24px (a deliberately mini pair inside one slot) and rail-vb stays 40px. Type scale: the 15 half-pixel sizes this release added collapse onto their integer neighbours, rounding UP so nothing gets smaller — 12.5→13, 11.5→12, 10.5→11.
…eing evicted Popup.confirm focused OK, so Enter on a confirm the user had not read yet took the destructive branch — including "Opening a file replaces the loaded graph, discarding every workspace, style and group". Cancel takes the focus now, and callers name the action instead of "OK": Delete workspace, Discard and continue, Reload, Clear, Connect anyway, Compute. Deleting a workspace also strands every undo entry taken in it (history.js drops entries whose workspace is gone), which "are you sure you want to delete workspace X?" named as little as it named the positions, styles, groups and notes going with it. Popup.prompt takes an initial value and selects it, so renaming a workspace starts from its name; an empty submit now says why nothing happened instead of returning silently. Toasts evict the oldest NON-error first — four routine info toasts used to push a red one off screen, and the error is the one that has to be read.
The user bubble is appended AFTER every gate, not before the budget check, so cancelling the over-budget modal leaves no orphan bubble; send() returns false when nothing went out and sendFromInput puts the question back in the box (unless the user typed something else meanwhile); the cancel path refreshes the budget meter it was arguing about. A turn that streams nothing visible says "(no reply)" like the cancel branch instead of finalizing as a blank bubble, and neither half of the empty turn goes into history — it was being replayed on the wire on every later request. _friendlyError matched any message containing "model", so "model requires more system memory" was reported as a missing model with an ollama pull instruction. It keys off the 404 status the client now attaches, and generic failures carry the backend's own message instead of "check the console" — packaged Electron has no console. Open in query editor confirms before replacing a hand-typed query; the sibling Select path already preserved it. The 🎨 action button says "Show the appearance settings", which is what its handler does.
…til it is runCypher tags transport failures at the throw site (err.transport) instead of letting connectionHint infer them from the JS type — the callers' try blocks wrap the whole import, so an ordinary TypeError in the merge path was reported as a network problem. Non-2xx statuses name their likely cause, with the database in the 404 case; Cypher errors show the server's sentence and keep the driver constant as data; a timeout suggests a next step. All four terminal catches now log the Error object — the user's message never carried a cause. The Fetch/Fetch-and-merge submit stays disabled across the size confirm and the property checklist, not just while a spinner label is up: those are separate modals with no focus trap, so a second concurrent fetch was one click away. Expand says "Select nodes first" instead of returning in silence — its button is class-disabled and .disabled[title] keeps pointer events, so the click does arrive. The Neo4j data-source label is stamped whether or not the apply rendered: applyGraph writes "Live (API)" before it starts, so a failed import left that describing a Neo4j graph. Required fields are marked as such, and the rail button says "Add" — its aria-label, tooltip and popup title all say "Add Neo4j query", the rail already has a Query tab, and "join" means an Excel merge elsewhere.
The palette's "run" path opens the workbench tab a toolbar control belongs to before clicking it — cmd.tab was honoured on the reveal path only, so + Node, ⤒ Import and ✔ Apply landed on an invisible pane. Overlay switches go dead by CLASS, not by the disabled attribute: an attribute-disabled control is out of the delegated tooltip layer's reach, so emptyHint — the only text explaining why Notes/Groups cannot toggle — was unreachable. toggleOverlay says the hint for the routes that still reach it. group_list.js already used the class for exactly this reason. The filter census is shown under OR too (it was the one thing explaining an empty graph, and the active filter may be in the section that is not on screen), and each scope segment badges how many of ITS filters constrain. ✗ Clear is "✗ Clear text": it empties the box without re-applying, and ⟳ Sync is what hands filtering back to the panel. The title, the query help and the tour step say so. Filter/Select swap their titles for "Fix the highlighted syntax error first" while the query does not parse.
Escape on a freshly placed note discarded it and everything typed with no undo entry and no word — cancelPlacement already toasts, so this one does too. Loading a workspace silently dropped notes past MAX_ANNOTATIONS (and malformed records) while the same feature warns when it trims one note's text. parseLayouts counts what did not survive, across workspaces, and says so with the limit. The 14-step tour has a Skip tour control in its footer, and the popup's × says "Exit tour" instead of "Close popup" — Popup takes a closeTitle for that. The ✎ Note tooltip now mentions that a single click opens the style popover, which is the only pointer route to Delete note.
A non-numeric threshold now says so instead of reverting on blur with no word. Metrics' Add/Remove to Selection — always enabled — says to highlight rows first rather than no-opping. The pie picker's greyed Apply carries the reason in its title (.disabled[title] keeps pointer events so the tooltip can be read). "Empty all groups" counts what it is about to clear and reports that, or says no group has any members instead of claiming it cleared them. The group-chip menu awaits afterMembershipChange, so a rejection reaches group_menu's catch as an error toast instead of an unhandled promise, and rapid toggles no longer interleave redraw with history.commit.
…he atlas bubble_layer caches each ring's graph-space bbox and skips a group whose bbox misses the viewport (plus 200px of screen slack for stroke and label standoff). 1.17 made the group count unbounded — auto-group mints up to 50 — so zoomed into one of them the other 49 still built a Path2D and stroked/filled entirely off-canvas. Culling is a PAINT decision, not a fit one: panning back needs no refit. shapeTextureURI quantizes the bake size to half a pixel before keying. size only sets the px→viewBox scale for stroke and halo widths, so the step is imperceptible, while unquantized float radii (degree scaling) multiplied the keyspace by every distinct radius on top of distinct colours × 20 fade steps — and the cache clears WHOLESALE at MAX_TEXTURE_CACHE, re-minting an SVG per shape node and rechurning the image atlas on the next reducer pass. selectionMembership iterates cache.selectedNodes in place: syncGroupRows calls it once per group on every selection change, and the spread was one copy of the whole selection per group.
… HTML
resetNodeToolTipMetricTexts + one updateNodeToolTipMetricText per score was an
innerHTML parse AND serialize of every cached tooltip, twice per repaint — so
returning to an already-computed metric (the cache-hit repaint added this
release) cost 2N HTML round trips where it previously returned early.
The metric line now lives in a nodeId → {header, text} Map and is written onto
the live tooltip element in showTooltip, right after the innerHTML it already
sets. Painting a metric is Map writes; the stored tooltip HTML is never
rewritten, which also keeps metric text out of the sanitize path.
…p-style map The command palette had its own trailing-accelerator regex, and it disagreed with the tooltip layer's: "(⇧F)" rendered a kbd chip on hover and yielded no accelerator in the palette. ui_tooltip exports splitShortcut and both use it. computeDropdownPlacement moves from StaticUtilities into popover_position.js, which was already the placement home with four callers. Its tests move with it. The per-group style labels were listed three times — a 20-case switch in updateBubbleSetStyle, a second run of sync calls in refreshBubbleStyleElements, and again in ui_style_div's builders. GROUP_STYLE_FIELDS is now the one map both READERS derive from (ui_style_div still authors the DOM, since each row carries its own min/max/step). Fill and stroke opacity gained read-back in the process: nothing synced them before, so a load or a ✨ Re-tune left those two sliders showing the old value.
… map idsKey / positionsChecksum / styleKey move to src/graph/overlay_keys.js. They are geometry-free "has the cached fit gone stale?" helpers, and heatmap_layer was importing positionsChecksum out of the BUBBLE module for want of a home; bubble_geometry drops to 871 lines of actual geometry. Their tests move with them. ARCHITECTURE.md's exhaustive lists had lost annotation_layer, annotation_geometry, bubble_tuning, bubble_smoothing, ui_tooltip — and, once a test checked, also dpr_watch, overlay_frame, shortest_path, excel_schema and excel_template. architecture-doc-sync.test.js now fails whenever a module is added without a map entry, or the map names a module that no longer exists. referenceRects — the seam feeding bubble_tuning — has a real test: the hidden/missing skip, the default node size, and the ratio-1 mapping being zoom-invariant (mutation-checked by breaking the ratio cancel). ui.debug()'s console.log moves inside the debugEnabled gate: production never sets the flag, so 19 call sites were writing to a console nobody had asked for while only the in-app log respected it.
The "member that counts" loop (present in the graph, not hidden) existed three times in bubble_layer — #syncGroupOutline, exportOutlines, referenceRects — and a disagreement between them means the fit and the export draw different hulls. One #visibleNodes now answers it. resolveBubbleLabel joins it in bubble_geometry.js (node-safe), returning the label's position, rotation and background box; #drawLabel and export_svg's bubbleLabelPrimitives only paint it. OUTLINE_STROKE_WIDTH and LABEL_STANDOFF_PX come from there too — export_svg carried its own copies annotated "// bubble_layer.js", and a comment is not a dependency. The unnamed alphabetic-baseline fudge in export_svg (size * 0.35, five sites) is ALPHABETIC_BASELINE_SHIFT with the reason written down: SVG text sits on the alphabetic baseline where canvas centres it.
Twelve --z-* tokens replace 20 ad-hoc z-index values from 0 to 10010. Every value is unchanged, only named, and the two +1 pairs (dialog over its own scrim, resize grip over a menu) stay relative so a bump cannot separate them. Purely local stacking — a slider thumb over its track, a caret over a textarea, the notes layer over sigma's canvases — keeps its small integers, with a note on the one that looks app-level but is not. Unnamed stacking is what produced this release's menu-clamping and stacking fixes, and the invisible-toast bug: toasts at 900 behind a 10000 landing page. Eight raw #C33D35 / #015C0C on border-color become var(--accent-text) / var(--success-text) — a fixed brand hex on a border stays light while everything around it flips, which is the root cause of the release's one-off dark-mode fixes. The four survivors trace their OWN same-coloured background, where the fill is deliberately fixed; the file header now says so.
…istory MIGRATION.md is cited by 11 files in src/graph and does not exist; "Concept C" is cited by phase, section and decision number in 9 more places and is not tracked either; §6.1/§6.2 name subsections API.md does not have. All of it is a reader following a reference to nowhere, so the references are gone and the sentences that needed them say the thing directly. The module headers for rail, inspector, workbench and command_palette described what they REPLACED rather than what they are — including "four dropdown menus" for a rail that builds five. Same for the comments that narrated a fix instead of the code (annotation_layer's Escape flag, bubble_sets' chip refresh and settings pane, group_list's source line) and two docstrings still pointing at the selection HUD this release deleted.
…checklist checklist_popup emits checklist-* classes instead of neo4j-* ones — it is the shared checklist modal, and it was wearing its first caller's namespace (CSS and tests renamed with it). The palette's reveal unfolds a filter group through ui.setFilterGroupCollapsed instead of re-deriving the class and the chevron glyph itself, and no longer assumes where the chevron sits in the header. The palette test uses the real method rather than a copy of it. seedMergedPositions returns a new payload instead of stamping the one it is handed, and mergeAndApply — which is exported — guards the session it dereferences on every line. The inspector header claimed changes elsewhere flash their pill; only Selection does, and filter/overlay changes are nearly always made from their own panel where a flash would be noise. The header says that now.
…he clamp ui.setDataSourceLabel dispatches gll:datasourcechange and the Neo4j connector subscribes, so core UIManager no longer imports a data-source connector — that import pulled neo4j_loader, and api_client behind it, into every consumer of ui.js to reach one function. annotation_layer declares its six remaining mutable fields in the constructor instead of materialising them mid-method. The clamps and truncations in modules that already import StaticUtilities go through .clamp / .truncate. Both truncate copies did — and they disagreed: the Cypher log line was one character over its own constant, which the helper's contract (the ellipsis counts toward the budget) fixes. The node-safe geometry modules keep their local clamps rather than taking the import.
… paint path Popup.layoutCreationDialog was a ~170-line app-specific "Create New Workspace" dialog living inside the generic modal utility, which made that utility carry a feature. It is src/graph/workspace_dialog.js now, beside the layout manager that opens it; popup.js drops from 655 to 485 lines. The heatmap layer's core paint path had no test — its settings and its CSS size each had a file, the thing it exists to do did not. tests/heatmap-layer-paint.js covers the contract that matters: pan/zoom re-composites the cached offscreen WITHOUT re-splatting (mutation-checked by putting camera.ratio in the heat key), a node move and a field-shaping setting do re-splat, opacity does not, a theme flip recolours, the no-drawable-field verdict is cached, and the disabled branch clears once and then does nothing.
openNeo4jPopup and openNeo4jJoinPopup hand-rolled the same ~55 lines each: the
settled/dataFetched/controller trio, dismiss, the same four Popup options, the
onClose abort, the spinner painter, the detached-error re-route, and the
lock/controller/finally dance around the fetch. Every fix to that scaffolding
this release had to be made twice — the submit lock and the error re-routing
both landed as two copies.
src/utilities/fetch_popup.js owns it now. Each dialog keeps its own form, its
ids and its fetch, and gets {signal, setBusy, showError, isSettled, close} to
work with; whatever its fetch returns after calling close() becomes the dialog's
result. The join flow's console.error moved in with it, so both dialogs log the
error object and show the sentence.
Enter-to-submit routes through the button rather than a second handler, so it
takes the same disabled check.
annotationLayout returns the three shapes a repaint needs — the fill box, the border-inset stroke box (with the corner radius shrunk to keep the OUTER curve at the note's radius) and each line's origin. drawExport and export_svg's annotationPrimitives had their own copy of all three, so the PNG and the SVG could disagree about a note's border; they only paint now.
The Louvain configurator hand-rolled its own anchored positioning and outside-pointerdown dismissal, and so was missing what RailMenu already gives every other menu: Escape, aria-expanded, close-on-scroll and focus restore. It also put 90 lines of raw DOM inside the graph-model manager, against that file's own note that DOM belongs in managers/. managers/community_menu.js owns it now, attached like group_menu.js (one menu per anchor, re-attached with the card). bubble_sets keeps communityOptions — the menu writes it, detectCommunities defaults from it — and loses 149 lines. The CSS keeps only the form's own metrics, since the element IS a .rail-menu.
The 62 commits since the last changelog update — the whole of audit sweep #2 — were unrecorded. Appended to the 1.17.0 section in its existing style: * Features: the scope-segment constraint badges, Skip tour, Forget saved connection, and the keyboard-resizable workbench handle. * A new Performance subsection with the measured numbers: outlines painted under the camera transform plus off-screen culling, snapshot signatures hashed (19.15 → 9.93 ms at 10k nodes), the metric repaint off the tooltip HTML, the Neo4j count preflight no longer re-running the query, expand/stitch scoped to what was actually asked for, and quantized texture bake sizes. * 15 grouped fixes: toasts above the landing page, parse-before-destroy on load, the Data Editor reset, confirmations that name what they destroy with Cancel as the default, the controls that used to no-op in silence, the palette opening the pane it acts on, ✗ Clear text, note losses reported, assistant turns that leave nothing behind, Neo4j failures naming their cause, group settings reading back, an unscorable metric view, and the accessibility and security passes. The version header moves to 2026-08-07, the date of the last commit in it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Rebuilds the interface around three surfaces and lifts the caps off bubble groups. Saved graphs, workspaces, filters and styles load unchanged.
Highlights
Inspector › Overlays › Density Heatmap).+ New/From selection, filter+manual membership spelled out, auto-detect no longer overwrites, layout-aware initial settingswith
✨ Re-tune.Ctrl+Z/Ctrl+Y) for styles, filters, arrange, re-layout and group membership.Performance
Bubble outlines painted under the camera transform instead of rebuilt per frame (12.08 ms → 1.90 ms at 50 groups; a 60-step drag on a 120-node group went 49 s → ~3 s), undo snapshots no longer scale with graph size (19.15 → 9.93 ms at 10k nodes), Neo4j imports cost one round trip instead of two.
Notes
feat/neo4j-connector; renumbered here becausemainpublished its own 1.16.x line.v1.17.0onmainto trigger the release build.