Skip to content

Release/1.17.0 - #10

Merged
Mnikley merged 182 commits into
mainfrom
release/1.17.0
Aug 11, 2026
Merged

Release/1.17.0#10
Mnikley merged 182 commits into
mainfrom
release/1.17.0

Conversation

@Mnikley

@Mnikley Mnikley commented Aug 11, 2026

Copy link
Copy Markdown
Member

Rebuilds the interface around three surfaces and lifts the caps off bubble groups. Saved graphs, workspaces, filters and styles load unchanged.

Highlights

  • Rail / stage / inspector. The filter sidebar, styling sidebar, selection HUD and workspace bar are replaced by a 52 px rail on top, one resizable inspector on the right (Filters / Overlays / Selection), and a workbench of ever opens itself.
  • ⌘K / Ctrl+K palette over every control plus every node and edge, printing where each control lives (Inspector › Overlays › Density Heatmap).
  • Bubble groups: unbounded and in one place. Overlays › Groups is the whole feature - named groups, + New/From selection, filter+manual membership spelled out, auto-detect no longer overwrites, layout-aware initial settings
    with ✨ Re-tune.
  • Workspace-wide undo/redo (Ctrl+Z/Ctrl+Y) for styles, filters, arrange, re-layout and group membership.
  • Neo4j connector with expand / add-query / stitch, Cypher visible in the activity log, and a zero-setup Stack Overflow demo server.
  • Boolean filters, mixed-type columns kept visible, app-native tooltips, WebGL2 checked before the data-source choice, presentation mode, SVG export as a first-class row.

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

  • The Neo4j work originally shipped as 1.16.0–1.16.3 on feat/neo4j-connector; renumbered here because main published its own 1.16.x line.
  • 2269 tests green; two audit sweeps executed (161 of 170 findings closed).
  • Merge commit, then tag v1.17.0 on main to trigger the release build.

Mnikley added 30 commits July 15, 2026 15:17
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.
Mnikley 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.
@Mnikley
Mnikley merged commit cca123f into main Aug 11, 2026
2 checks passed
@Mnikley
Mnikley deleted the release/1.17.0 branch August 11, 2026 14:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant