Skip to content

Releases: theredstring/redstring

0.13.0

Choose a tag to compare

@github-actions github-actions released this 07 Sep 02:35

Merging: two universes into one, two Things into one. A guard on the file a universe writes, and an update to The Wizard that expands and grounds what it builds with DOIs and abstraction lists.

Added

  • Merge Things. A duplicate scan bands pairs by what the evidence is: a shared Wikidata URI is an identity claim the user already made and clears in bulk (Certain); a shared name is a coincidence until a person says otherwise (Needs review); the rest is listed under Unlikely for auditing. Each pair offers the fields the losing side has and the survivor doesn't, and carry-over, definition webs and prototype merge land as one undoable action. Scanning is staged — link buckets, name buckets, then an O(n²) pass under a size cap — and runs in resumable chunks, so a big universe degrades to exact signals rather than hanging. Dismissals travel in the file. Replaces DuplicateManager.
  • Merge universes. A union, not a reconciliation: duplicates come through and are settled afterward in the Things merge, rather than a merge quietly deciding which description of the world is right. The destination wins any collision that must resolve, dropped scalars are banked in _preserved.merge, and graphs sharing an ID have their contents unioned — two forks of one universe is the case this exists for. owl:sameAs folding is opt-in. An external link can also merge into the open universe instead of making a new one.
  • An empty-write guard that asks the destination. Every other safeguard is keyed to universe identity and armed from device-local bookkeeping; the destructive unit is a file. When the outgoing state is empty it reads the destination and refuses to write over content. Universe targets are now unique the way slugs already were, and linking to a folder another universe owns raises a conflict dialog rather than creating two writers for one file — the shape that cost 518 Things on 2026-09-06.
  • findWork and linkIdentifier in the wizard. A DOI can't be recalled — asked for Laibson 1997 a model assembles 10.1093/qje/112.2.443 from the journal, volume and page, which looks like a DOI and is a 404. findWork searches for real ones and deliberately picks no winner; linkIdentifier rejects anything the registry doesn't recognize and attaches at the AUTO rung, never EXACT, since overstating travels into everyone else's data as skos:exactMatch. Both batch.
  • Is-a ladders while building. Node entries take an optional isA (broadest last), sketchGraph marks rungs with a caret, and abstractionChain build lays a whole ladder in one call — the level-at-a-time path kept creating the nodes and failing to wire them, leaving an empty carousel and orphan prototypes. New rungs get a bio and the carousel's shading; reused ones are left alone.
  • Debug is a Settings page, opened by five taps on About or the logo's context menu, with a gallery for previewing dialogs. The Debug top-level menu is gone.
  • Trackpad pan glide is its own setting rather than riding on Mouse Glide, and focus-on-select zoom is adjustable.
  • Inline universe rename, switching scrolls the universe to the top of the list, and right-panel tabs get a context menu.
  • Tests for duplicate scanning, the merges, universe targets, the empty-write guard, nested compaction, pinch calibration, label placement, and the new wizard tools.

Changed

  • The trackpad zoom glide carries momentum throughout instead of launching at a release that doesn't exist — a trackpad pinch has no finger-lift, only a stream that stops, and detecting the stop always left dead time in front of the coast. Each event now corrects the target and re-measures velocity; between events the target keeps moving under friction, so gesture and coast are one motion. The slider is friction, so it lengthens a coast rather than only enlarging a throw, and zoom sensitivity is rescaled to put the comfortable setting at its midpoint.
  • Layout Scale reaches the group allowances, not just force distances. Padding, node gaps, separation and corridors stayed at absolute pixels while interiors scaled, so on a nested web dialling the scale down made it bigger — compact at 0.5 measured 60% larger than balanced at 1.0 on a four-deep tree. Drawn geometry stays with the renderer.
  • Nested groups get a compaction pass. The pipeline is otherwise expansion-only, so nesting accumulated slack multiplicatively — a four-deep web drew at ~2.5× the area of the same graph ungrouped. It runs last, with every floor taken from the pass that enforces it, so enforceGroupClaims has nothing to undo and no labelled edge is pulled shorter than its text.
  • Settings and Help share one modal sheet built from the panel's tokens, replacing a hand-tuned light palette restated rule-for-rule in dark.
  • Multi-select offers only the actions that mean something for a set — Open Web, Decompose, Abstraction, Edit and Orbit would have acted on whichever Thing sorted first.
  • Enrichment follows a link it already has instead of re-searching the name that was ambiguous in the first place.
  • Panning with a node held down starts a connection draw, as dragging off it would, and an in-flight draw re-projects each frame so its free end stays under the cursor.
  • Carousel pie menus lay out on slots off the node's edges, wrapped rows staggered outward half a step so they don't stack into columns.
  • A group's interior takes a whisper of its defining Thing's color, mixed opaquely so nesting doesn't compound into mud. The panel switches to its low-width layout live during a resize drag. Connection width has a base scale, so what drew at 1.25× is now 1×. Multi-node deletes stagger their ghosts. The list toggle fades when idle. The bookmark fills only once the whole selection is saved. MaroonSlider's value chip holds a fixed width.

Fixed

  • Pinch drifted the content out from under the fingers. The anchor was re-derived from the live midpoint each frame and low-passed toward it, bleeding 10% of every midpoint movement out of the transform — fingers travelling 100px moved the content 90px. It is now the world point under the midpoint at the start, fixed for the gesture.
  • Two fingers landing on nodes produced no zoom: a node touchstart stops propagation and the move handler bailed while a node had a touch armed. A second finger now always means pinch, and any connection draw ends without making an edge or a self-loop dialog.
  • Backspace in the wizard chat and other unwired fields reached the canvas and deleted the selection. Focus is asked of the DOM, not only of fields that report it.
  • Lombardi labels slid off their own arcs. Overlap was measured on a rotated label's bounding box, whose empty corners hold no glyph — at 21° a 264×44 label has twice as much corner as text — so a hitbox catching one read as a collision. Candidates now discount the area their box overstates; zero for an axis-aligned label.
  • Group title tabs reserved about half the width they were drawn at: the worker can't take a measurement function, so the name.length * 12 fallback shipped against a renderer measuring real text at 45px.
  • Duplicate scanning called related-but-distinct Things identical — a shared "List of X characters" page is no longer identity evidence, and the prefix shortcut is gone ("Mario"/"Mario Kart" is containment, not sameness).
  • Ladder building matched names exactly in one path and loosely in every other, which is how a second "Company" appears beside the "Companies" already there.
  • Merging dropped semanticMetadata wholesale, losing the linkConfirmations behind the exactMatch/closeMatch ladder on export, and set-unioned abstractionChains into an order neither side asserted.
  • Right-panel tabs pointing at prototypes that no longer exist are dropped on load and after a merge.
  • Crossref's anonymous pool 429s after about three sequential requests; searches use the polite pool.

0.12.2

Choose a tag to compare

@github-actions github-actions released this 02 Sep 03:59

Canvas performance and touch, plus an Android build.

Added

  • Android app. The Capacitor Android project is checked in — Gradle wrapper, manifest, icons and light/dark splash sets at every density — with android-run.sh to build and install onto a connected device.
  • A one-time desktop-app pill on the web. Modelled on BackToCivilization: same pop, same panel-aware centering, anchored above the TypeList. Acting on it or dismissing it retires it for good. It holds back while first-run setup or a bottom control panel is up, and shares one device gate with the menu's Download entry rather than each keeping its own copy.
  • Tests for the touch view-catch.

Changed

  • Viewport culling is back on, off since April: grow-only while the view is moving (an unmounted node that entered the viewport is the flicker; removals are invisible and can wait), one prune when the gesture settles, and a containment gate that skips the O(nodes + edges) pass until the viewport has eaten half its own padding — so zooming in costs nothing. localStorage.setItem('redstring_disable_culling','true') turns it back off for A/B. Knock-on: settle renders are O(visible) again, the count budgets that decide whether labels curve are counting what's on screen rather than the whole universe, and the glow update is RAF-coalesced again.
  • The touch catch asks "is the view moving?" rather than "is the glide coasting?", measured from the transform itself so a pinch settling, an animated navigation and — the common one — the tail of a drag the user is about to continue all count. Momentum only exists above the launch threshold, so a drag-lift-replant traversal had none, which is exactly when nodes were stealing the gesture.
  • A yielding node or group title hands the gesture to the canvas explicitly instead of declining to stop propagation and hoping it bubbles.

Fixed

  • Node drag and pinch smoothing skipped culling outright, so a moving viewport stared at blank canvas until the finger lifted. They now only forbid the prune half.
  • A prune requested by a deletion or a resize was dropped if the tick that saw it wasn't allowed to prune.
  • Documentation and comments pointing at graphStore.jsx, which is graphStore.js.

0.12.1

Choose a tag to compare

@github-actions github-actions released this 30 Aug 04:56

Connections get the actions Things already had, images stop bloating every git commit, and the color picker gets an eyedropper.

Added

  • Content-addressed images over git. Full-resolution image bytes move to universes/<folder>/images/<sha256>.<ext> and the prototype keeps a sha256: ref, so autosaves stop re-uploading every photo. Thumbnails stay inline, so the canvas renders with no network and older builds still draw a correct graph. Additive and optional — no format version bump, and inline base64 stays valid.
  • Missing-image state. A node whose image can't be fetched shows a struck-through image glyph in the reserved slot instead of rendering as a plain text node. The panel says which case it is: a blob missing from the repo, or an unreachable source.
  • Thumbnail fetches retry — three attempts with backoff, skipping 4xx.
  • Palette, Copy and Paste on connections. Palette recolors the Thing that defines the connection. Copy takes the definition rather than the edge; Paste accepts another connection's definition or a single copied Thing, and only appears when the clipboard holds one.
  • Connection menus wrap into rows past five buttons, and a button that appears under an open menu animates in while the rest reflow.
  • Eyedropper in the color picker. Reads the document's own paint rather than window.EyeDropper, which is Chromium-desktop only — so it behaves the same in Safari, Electron and iOS. Pan and zoom stay live while picking; Escape cancels.
  • Grid snap haptic — one tick per grid line crossed while dragging.
  • Touch support in the abstraction carousel — drag to scroll, flick to coast, tap to focus, double-tap to open in the panel, tap off to close.
  • The connection control panel renders the canvas menu's buttons instead of its own transcription of them.
  • Tests for SHA-256 and ref round-trips, image externalization, the version gate, and screen color sampling.

Changed

  • A file stamped a newer minor version loads instead of being refused; only a newer major fails. Unrecognized fields are preserved, and refusing minors was a one-way trap for any build that rolled back.
  • Replacing an image clears its ref, so the panel can't resolve the previous picture. Deleting one leaves the blob in the repo — git keeps it in history anyway, and undo needs it.
  • Grid mode "On Move" is stored as move, not hover. Existing preferences migrate.
  • Disconnected clusters keep a real gap, measured node box to node box and shared by both layout pipelines, so the same graph no longer reads cramped under straight routing and scattered under Lombardi.
  • Lombardi hierarchies build both rings and a tidy tree and keep the smaller. Rings lose by roughly 4× on shallow trees, which is most of what "Lombardi is too spread out" meant.
  • Concept search merges hits naming the same subject, joined on Wikidata's enwiki sitelink rather than on matching labels, so one card carries all three identifiers.
  • Framing reserves the bottom control panel — measured rather than assumed — and drops the 10% upward nudge that was standing in for it.
  • Framing bounds tall image nodes vertically. The fit was derived from node width and ran ~1.4× too loose on portraits, cropping the name off the top.
  • Quick-search chips wrap inline; drag auto-pan slowed from 15 to 10; touch flicks get their own decay so a thrown carousel coasts; a non-member ejected from a group parks a node gap outside it rather than 30px.

Fixed

  • Disconnected clusters interleaved and settled at the node-collision minimum, dropping foreign nodes onto edge labels.
  • A satellite component stayed squeezed against its anchor group: the corridor was enforced as a ceiling but never as a floor.
  • Palette did nothing from the bottom control panels — the click reached document and dismissed the popover it had just opened.
  • Deleting a connection from the control panel left the panel up, pointing at a connection that no longer existed.
  • Carousel on touch: one swipe couldn't reach the next level, flicks that decelerated before the lift were discarded, and tapping off didn't close it.
  • The connection color picker coalesces a slider drag into one undo step, as the node picker already did.

0.12.0

Choose a tag to compare

@github-actions github-actions released this 28 Aug 06:35

Added

  • About — one section answering "what does the rest of the world call this, and where did it come from", replacing both the old Origin block and the "External References (owl:sameAs)" card that used to sit under Semantic Web. Those two rendered the same externalLinks array, so unlinking in one silently changed the other; and Origin's two branches were mutually exclusive, so a semantic node never saw its links and an ordinary node never saw its provenance. Nothing new is stored — the file still holds a plain list of URLs, which is the most interpretable thing it can hold and the shape a generic RDF consumer can read. Authority, identifier and link-through are all derived at render time.
    • Wikidata, Wikipedia and DBpedia each hold a permanent slot, present whether or not anything fills them. An empty Wikidata row is information: it says nobody has grounded this Thing yet. They are the three with a stable notion of "the entry for this subject" that other tools also resolve; anything else a user adds lands below them as a plain identifier. The earlier grouping that folded a Wikipedia article underneath its Wikidata row is gone — with a standing Wikipedia slot it would have hidden the article from its own row, and the two are separately wrong-able (Wikidata matching an artwork while Wikipedia matches the concept is the ordinary case, not an anomaly), so they need separate swap controls.
    • Each row shows what the authority says the thing actually is, fetched live and never written down. This is the whole answer to the case that motivated the section: Wikidata has an item called "Symptoms" that is a work of art and one called "symptom" that is the medical concept, and nothing about the URL tells them apart. A description under the row says so in Wikidata's own words, so a wrong match is visible without following the link. Cached copies would be the thing being caught being stale, so there are none. It covers more than the searchable three — a DOI resolves through Crossref, falling back to DataCite, and a PubMed id through eutils, because a row reading 10.1038/nature12373 alone is an identifier nobody can check.
    • The picker searches by name and shows the descriptions before you choose, because a name is not an identifier. Every request is a plain browser fetch against a CORS-open read API: no key, no proxy, no server hop.
    • Provenance always has an answer. The old block answered only when it could, so most Things showed a "Where this came from" heading followed by nothing. Either an outside service handed the Thing over — read from the source recorded at creation time, not inferred from the presence of a Wikidata link, which auto-enrichment adds to Things that were made here — or a person made it in Redstring. Derived, not stored, so no file changes; semanticMetadata.origin is left as the hook for when a creation path starts recording its own answer.
  • The sameness ladder is recorded per link. Three rungs — Auto-Populated (Redstring found it, nobody has checked), Close Match (a person looked: related, not the same subject), Exact Match (a person looked: the same subject) — chosen from a row's own menu and stored in semanticMetadata.linkConfirmations, keyed by canonical URL. Auto and Close both export as skos:closeMatch; who made the claim lives in the record's by field, which is the honest place for it, since a weaker predicate would say something weaker about the subjects rather than about the checking. Close is a real destination and not a way station: "Signs and symptoms" on Wikipedia genuinely is a close match for a Thing called Symptoms and never an exact one, and being able to say so is the point of having the rung.
    • owl:sameAs is no longer authored. The distinction from skos:exactMatch is real in OWL — exactMatch aligns two records about one subject, sameAs fuses them, so a reasoner pools every claim on both sides and the other record's mistakes become claims about your Thing — but it is not one anyone can act on from a panel row, and getting it wrong asserts more than the user meant. Import still reads it: other tools write it, and older files carry it.
  • Info buttons. A short explanation opens next to the control it belongs to, which is what lets the interface stay in plain language while the concepts underneath stay reachable — About says "matched automatically" rather than "skos:closeMatch", and a reader who wants to know there is a ladder at all can find out without the vocabulary being pushed at everyone who opens the panel. The floating box is the PieMenu bubble translated into DOM (off-white fill, maroon stroke, maroon text) and is deliberately theme-independent, because the pie-menu language is the same in both themes.
    • At most one is on screen at a time, across the whole app. Each popover's open state belongs to whatever opened it, so none of them can see the others; two of these overlapping is genuinely confusing, since they look identical and nothing on screen says which trigger either belongs to. Coordinating in the shared box rather than at the call sites means every popover is covered by construction, including ones written later.
    • Vertical placement is decided by the room that exists rather than by the caller's guess at the box's height. A list whose length depends on what a search returned is sometimes taller than its estimate, and when it was, the old placement ran off the bottom of the screen with no way to reach the rest. It now picks the side with more room and caps to it, and the box scrolls internally.
  • Undo and redo record by default. A change is recorded unless it opts out, and the patch filter is the backstop that makes that default safe: a settings or panel action that forgets to opt out simply produces no document patches, so nothing is recorded. This inverts a model where an action was recorded only if it both called setChangeContext and used a type someone had remembered to add to an allowlist — whole categories of work were silently missing because one of the two steps was skipped, connection types, arrow directionality, abstraction chains, group decomposition and graph/prototype deletion among them.
    • The recorded state roots are derived from PERSISTED_STORE_KEYS, minus the handful that are preferences rather than edits. A narrower set would need a post-undo reconciliation pass, because one updateNodePrototype rename cascades into graphs[].name and rightPanelTabs[].title, and deleting a prototype cascades into savedNodeIds, expandedGraphIds, openGraphIds and activeGraphId — and reconciliation would re-implement those cascades in a second place, where they rot. Recording the persisted set is closed under every cascade by construction: if the store writes it as a consequence of a document edit, it has to survive to the file, so it is in the list. Workspace navigation therefore has to say explicitly that it is not an edit, which it now does through a dedicated navSet.
    • withHistoryTransaction groups a multi-action gesture into one entry. The 50ms debounce cannot do this job: the batch is force-flushed whenever the domain changes regardless of timing, so any gesture mixing a global action (creating a prototype) with a graph-scoped one (adding the edge that uses it) split mid-gesture no matter how fast it ran. A transaction pins its domain to the first write and widens to global rather than flushing. It also adopts the housekeeping types that stay silent on their own — ensureGroupAnchor called from the startup repair sweep is housekeeping, called as part of converting a node to a node-group it is an inseparable piece of the user's gesture, and dropping it left undo with an anchorless group behind.
    • Live-committing editors coalesce. Typing a node title on canvas writes on every keystroke, because getNodeDimensions measures the committed name and that is what lets the box grow as you type; those writes are now keyed per prototype and collapse to one entry, so one Cmd+Z undoes the whole name. The colour picker does the same across a slider drag, committed when the picker closes. Escape during a rename is signalled as an abort, and the collapse pass then sees an edit that ended where it started and drops the whole thing rather than recording a round trip. A 2s idle timer exists only so a commit boundary that never arrives — a blur that doesn't fire, a component unmounted mid-edit — can't wedge a batch open.
    • Undo follows the change to the graph it affects. The timeline is global, so without this Cmd+Z while looking at one graph could silently rewrite a different one with no visible feedback — the failure most likely to be experienced as "undo corrupted my data". Global entries have no single home and are applied wherever the user is. The keyboard shortcut, the Edit menu and the history panel now all route through one module, which is what made this fixable once instead of three times; each of them was previously missing both this and the pending-edit flush.
    • Entries name what they touched: Deleted "Symptoms" rather than Deleted type, connection type changes, abstraction insertions and removals, duplications, merges, definition creation and removal, decomposition. Deletion names are snapshotted into the context, because descriptions are generated after the mutation and by then the thing being described is gone.
  • Concept search in Semantic Discovery — asks each authority's own search index what a word could refer to and answers in one round trip. Fast for a structural reason worth keeping in mind: these are prebuilt indexes, where the federation crawl beside them queries live SPARQL endpoints with exact-label matches, which is both slower per call and the wrong shape for "what might this word mean". Results interleave by rank so no single source owns the top of the list, and hits that share a label are...
Read more

0.11.2

Choose a tag to compare

@github-actions github-actions released this 27 Aug 01:19

Lots of work regarding orbit mode: what it cost to draw, how far out it threw its rings, and where it points the camera. The black-tile flicker that had been blamed on the animation for several releases turned out to be one translucent rectangle, and finding that took building instrumentation rather than another theory.

Added

  • Orbit mode frames itself when it opens — the orbit is drawn around the focus node at whatever zoom you happened to be at, which for anything but a two-item orbit meant most of it started off screen. The canvas now fits the whole orbit on open, against viewportBounds — the usable canvas region with the panels, the header and the type-list bar already subtracted, the same bounds the carousel framing and the edge glow use — so it centres in what is actually visible rather than in the raw window, with 6% breathing room a side.
    • Candidates arrive asynchronously and the orbit keeps growing after it opens, so this reframes as it grows. Two rules keep that from becoming a camera that won't sit still: nothing happens while the orbit still fits the frame at the zoom you are actually at (you may have adjusted it since), and each reframe re-eases from wherever the last one got to, so consecutive reframes read as one continuous pull-back rather than a series of jumps.
    • The extent is reported by the overlay, not recomputed by the canvas. Ring radii come out of the overlay's own layout, per-item label extensions included, and a second derivation of "where the orbit ends" would drift from the first. It reports null until something is actually placed — framing the bare focus node in the gap before the first results land would zoom hard in and then straight back out — and reports the orbit gone on the way out, so the next orbit isn't framed against the departed one's extent.
  • Canvas render diagnostics — window.__diag — built because repeated theorising about the canvas's raster and compositing cost kept producing plausible-but-wrong fixes, three of which shipped. Everything it does measures; nothing it does assumes. __diag.scan() walks the live render tree and reports what is actually in it — element counts by tag, the compositing triggers present (will-change, filter, backdrop-filter, mix-blend-mode, isolation, contain, clip-path, mask), and critically the split between group opacity (expensive: forces an offscreen isolation surface) and leaf opacity (cheap: just paint alpha). __diag.record(ms) collects frame timings and reports percentiles. __diag.suspect() bisects by disabling one suspect at a time. Import is side-effect only and costs nothing until a command runs.
    • In dev, Electron now mirrors the renderer's console into the terminal it was launched from (Vite's HMR chatter filtered out), so Chromium's own tile-memory and raster warnings and the app's diagnostics land in one copy-pasteable stream.
  • The GitHub App's callback and webhook are served on Cloudflare Pages — two endpoints that nothing in the SPA calls, which is exactly why they were easy to miss when moving hosts, and why losing them broke installation for new users only.
    • /github/app/callback is registered as both the Callback URL and the Setup URL of the App, so GitHub sends a user's browser there after they authorize or install. It has no /api prefix, so it sits outside the [[path]].ts router and needs its own entry in public/_routes.json — without that, Pages served the SPA's index.html and the install silently dead-ended on a page that never read the parameters. It forwards installation_id, setup_action and state to the app root and lets the client take over; 302 rather than 301, since the parameters differ on every install and a cached permanent redirect would strand a later install on a previous one's ids.
    • /api/github/app/webhook verifies the delivery's HMAC over the raw bytes (not a re-serialized object) with a constant-time compare, and acknowledges. It exists rather than 404ing because GitHub retries failed deliveries and eventually flags the endpoint as failing on the App's settings page. Verification mirrors what the old Node server did: enforce when GITHUB_APP_WEBHOOK_SECRET is bound, warn and accept when it isn't.
  • Test coverage for the new surfaces: the orbit overlay's rendering, entrance animation, hover dimming, label centring and extent reporting; the canvas transform hook's attribute writes, pan/zoom behaviour and coordinate-space consistency across both content groups; arrowhead placement and chord-distance walking on arcs; and groupless remainder layout.

Changed

  • Orbit rings stop flinging themselves to the edge of the canvas — a ring's radius has to satisfy two independent constraints, and they had been collapsed into one number. The radial gap is room between a ring and the one inside it, for the connection line, its arrow and its labels — labels lie along the radius, so a long predicate widens the gap. The tangential pad is side-to-side clearance between neighbouring items on the ring, and since pushing the ring outward is the only way to buy angular room, that term divides by sin(dθ/2) and grows fast. Feeding the label-sized radial gap into the chord term meant a ~430px predicate demanded ~430px of sideways clearance between items too: at ten items to a ring that inflated the radius by more than a thousand pixels, and each ring chained onto the last, which is why ring 4 ended up somewhere in the next postcode.
    • Outer rings no longer reserve label room in the gap in front of them. Now that a label is centred on the whole focus-border-to-item run, an outer ring's run spans every band inside it and already has far more room than any single gap could give it; reserving it twice only pushed the ring out. Ring 1 still reserves in full — its run is the short one, and it is the ring worth keeping perfectly circular.
    • A ring may sit half inside its neighbour's radial footprint. Chaining full item widths outward assumes an outer item could end up directly behind an inner one, which the angular stagger and the collision nudge specifically prevent.
    • One verbose relation moves its own item, not its whole ring. Ring radii are set by the widest label on the ring, so a single long predicate used to push its ring out and every ring beyond it chained onto that. An item whose label needs more run than its ring leaves now steps outward by exactly the shortfall — its connection line and arrowhead follow, since both derive from its centre — and the ring stays where it is.
  • Orbit connection labels are centred on the run you can actually see — focus-node border to item border, which is how NodeCanvas centres an edge label (it builds a separate border-to-border placement path for the purpose rather than using the drawn path). Outer rings used to start that run at the previous ring's outer edge, to keep labels out of the inner bands; that shoved every outer label hard up against its own item and read as badly off-centre. It was also unnecessary — the collision nudge already offsets each outer item angularly from every inner one, so an item's radial run is clear all the way back to the focus node and the label threads between the inner rings.
    • Arrow tips stop at the item border instead of penetrating it. Canvas nodes are opaque and hide the overlap; orbit items render at 0.85 and would let it show through.
    • Hovering a triplet pushes every other one back to 0.1 so the hovered subject-predicate-object reads on its own. The focus node isn't dimmed with them, which is right as well as convenient: it is the subject of every triplet on screen, so it belongs to the hovered one too.
    • The loading dots use a fixed light colour rather than the theme's primary text. They sit on the orbit scrim, which is the same dark overlay in either theme, and light mode's near-black #260000 vanished against it.
  • The orbit animates again — three separate mitigations for a flicker that turned out to have one cause (below) are lifted now that the cause is fixed. Rotation writes at 60Hz instead of the 15Hz it had been throttled to, and measured no worse. The rotation loop no longer stands down while you pan or zoom the canvas, and the overlay no longer sheds its text and images or hides itself for the duration. The loading dots travel around the node's border again instead of being parked in place with only an opacity pulse moving between them — eight attribute writes a frame is ordinary once they aren't being blended through a scrim.
    • The machinery for all three stays behind flags with live console overrides (window.__orbitHz, window.__orbitGesturePause, window.__orbitZoomMode, window.__orbitFreeze, window.__orbitRings, window.__orbitBlur), so any of it can be A/B'd against __diag.record() without a rebuild. That is deliberate: this is the part of the app where intuition has the worst track record.
    • Where a pause is still wanted, a zoom holds the animation longer than a pan does. Panning is continuous, so a short delay tracks the end of the drag closely; zoom arrives in discrete steps — wheel detents, trackpad increments — whose gaps are routinely longer than the pan delay, and resuming inside one of those gaps reads as the orbit stuttering back to life mid-zoom.
  • The compositor-zoom path is removed — during a zoom the canvas used to freeze the content group's transform attribute at a baseline and carry the remainder as a CSS transform on a promoted wrapper, to avoid re-rasterising the SVG every tick. It was built to fight the tile-memory flicker; once the actual cause was found it measured worse than plain attribute writes, because each of its recommits was one full re-raster and the ones landing mid-zoom — where the most elements are on screen at full detail — showed up as visible hitches. It was removed rather than tuned, with a note aga...
Read more

0.11.1

Choose a tag to compare

@github-actions github-actions released this 26 Aug 02:59

No application changes. This release exists only to re-ship the macOS build that v0.11.0 got wrong.

Fixed

  • macOS downloads and auto-update from v0.11.0 were invalid — v0.11.0's release workflow split the macOS build into two concurrent jobs, one per architecture, to overlap the ~3.5 minutes each spends waiting on Apple's notarization. The split does not work: --mac --x64 does not restrict architecture when electron-builder.json declares arch: ["x64", "arm64"] on the targets — the config wins over the flag — so both jobs built all four artifacts. They were slower than the single job they replaced (11m and 13m against 11m12s), and, worse, each produced its own latest-mac.yml. Merging two manifests that both describe all four artifacts shipped one build's hashes against another build's bytes, so the checksums in the update manifest did not match the files sitting next to them. Squirrel rejects an update whose hash doesn't verify, which is the correct behaviour and also the failure. macOS builds both architectures serially in one job again, as they did before v0.11.0, and electron-builder writes the single manifest that describes what it actually built.
    • The reason it went out is recorded in the workflow itself: any future attempt at splitting must restrict the architecture with CLI target:arch pairs (--mac dmg:arm64 zip:arm64) and be verified by inspecting what the artifacts actually contain, not by trusting what the flag appears to say.

0.11.0

Choose a tag to compare

@github-actions github-actions released this 26 Aug 02:36

Added

  • Haptics — Redstring's non-web experience now speaks through the phone as well as the screen. The service is built around the fact that the four substrates it runs on are not interchangeable: an iPhone's Taptic Engine has distinct feedback classes (a crisp near-silent selection tick, a weighted impact with light/medium/heavy mass) and can be prepared ahead of use, which is what removes the 50–100ms spin-up that otherwise desyncs the tap from the visual; Android under Capacitor has one motor where intensity is expressible but "shape" is not; mobile web has navigator.vibrate, which is duration only; and desktop, iOS Safari, and every iPad have no reachable haptic hardware at all. So a tier is detected once and each semantic event routes through a per-tier recipe. Call sites never name a waveform — they say haptic('nodeLift') and one table decides whether that is a tick, an 8ms buzz, or nothing.
    • The table is the design document. Lifting a node is a tick (an impact implies collision, and lifting is the opposite); dropping it is a light impact matching the settle animation; creating something — a connection completing, a prototype landing as a real instance — gets medium weight, while moving something that already exists stays light. Changing the active graph is medium because the entire canvas is replaced. nodeTouch fires nothing on the Taptic tier at all: it is the warm-up that makes the lift tick 450ms later land instantly.
    • Detent tracks turn continuous motion into texture: one tick per unit of distance travelled rather than per unit of time. The connection line ticks as it stretches, the header tab strip ticks as it scrolls (the same track covering both the finger drag and the auto-recenter that follows it — the recenter's deceleration isn't scripted, it falls out of detenting position instead of time), and the hurtle orb ticks against its eased progress so ticks bunch where the orb is actually moving fastest. Every stream is Taptic-only, and the absent entry is the routing: ten 8ms buzzes a second on a single motor is a continuous rattle rather than a texture, and it costs real battery.
    • Two haptics closer than 40ms fuse into one indistinct buzz on every tier, so the second is dropped. connectionTarget — the one signal that says "let go here" — is the deliberate exception and forces past the limit, because a stretch detent 20ms earlier must not swallow it. Everything is fire-and-forget: no call throws, awaits, or blocks a drag frame. Off switch in Settings.
  • Fullscreen landscape shell on phones — landscape on a phone leaves so little vertical room that the header bar and the TypeList footer together eat a third of the screen, so in this one mode the app drops both and hands the whole box to the canvas. The panels stay, since they're the only navigation left, and slide over the canvas from the edges starting at y=0. Safe-area treatment changes with it: only the notch-side inset is kept, painted black rather than the header colour, while top, bottom and right are released so the canvas runs to the physical edges. Deliberately narrow — Capacitor only (a desktop browser resized to a landscape strip keeps its chrome) and phones only, since an iPad's short side is 744pt or more and has plenty of height in landscape.
  • Connections can be tapped on touch — a tap that reaches bare canvas but lands inside a connection's finger-sized radius now selects that connection instead of deselecting everything. The transparent stroke around each path remains a narrow fast path; this catches everything inside the much larger radius that missed it. Nodes still win, since they paint on top and claim their own taps.
  • The AI wizard runs in the browser — the agent loop no longer requires wizard-server. POST /api/wizard streamed the generator's events to the wire verbatim and the client parsed them back, so the event contract on both sides was already identical and only the transport disappears; callers keep their event switch exactly as it was. Wikipedia enrichment moved in-process alongside it (the API sends origin=*, so it is CORS-safe from the browser). The practical effect is that the wizard, enrichment, and "test API key" all work with no server running at all — which is what BYOK always implied and what the deployment never actually delivered.
    • The cost clamps are shared, not duplicated — the per-tier iteration limit and the per-ask token budget are cost ceilings rather than preferences, so both sides now import one buildLlmConfig instead of the browser re-deriving them. A bad config must not be able to produce a runaway bill on either side.
  • Repository discovery is cached by content hash — listing a repository's .redstring files is cheap (a few directory requests, and GitHub hands back each file's blob sha for free); reading each one to count its webs, things and connections is a full download apiece. Counts are now keyed by that sha, so an unchanged file is never downloaded twice and a changed file invalidates itself with no TTL guessing. The discovery results are cached too, and at module level rather than in component state — the repository list used to die on every unmount, so every panel open began with an empty list behind a spinner. It now paints instantly from cache and refreshes behind that.
  • An unknown count renders as "?", not 0 — discovery lists a repository's universe files before it has read any of them, so counts arrive in a second pass. Until then a count is genuinely unknown, which is not the same as zero, and rendering it as zero is exactly the lie that made these lists untrustworthy.
  • Universes reconnect when you link a workspace folder — linking the folder only wrote the directory handle; nothing re-ran local file resolution, so universes sitting at needs_reconnect stayed disconnected and the canvas sat on its loading gate until the app was manually reloaded. Linking now walks every local-enabled universe and loads the active one if it reconnected and the store is still empty — a store you have already been working in is left alone.
  • brandText theme token — the brand maroon (#7A0000) is nearly invisible as text against the dark theme's #2E2A2A. Brand-coloured labels now read from a separate token that lifts to a warm rose in dark mode while still reading as Redstring red; brand stays what it was and continues to drive fills, borders and solid buttons.
  • Test coverage for the new surfaces: haptic tier detection and recipe routing, viewport measurement, universe discovery and the discovery cache, the LLM config clamps, the in-process wizard's event handling and error propagation, API key storage round-trips, theme tokens, the format-aware stats reader, and node-edge intersection geometry.

Changed

  • The app measures its own viewport instead of the window's — with viewport-fit=cover, window.innerHeight becomes the full screen including the bands behind the Dynamic Island and the home indicator. The app deliberately doesn't lay out there (<body> carries the safe-area insets as padding, so those bands show the header colour rather than app content), which made innerHeight the wrong number for anything positioning UI — the canvas coordinate system in particular, which places the canvas at HEADER_HEIGHT from the top and sizes it by the remainder. Measurement now comes from the padded box, and falls back to the window on every platform without insets, where the two are identical anyway.
    • Canvas→client point mapping stopped using getScreenCTM() for the same reason. That call maps into whatever screen space the UA happens to be using, and the two spaces only coincide when the page origin and the client origin do — which under Capacitor they don't, leaving it offset from getBoundingClientRect() by roughly the top inset.
    • The header colour is published outside the React tree so the safe-area gaps can paint it. It's dynamic in two ways — dark mode and the active graph's colour — so it's pushed on every change rather than read once.
  • Tap-to-select is 250ms faster — the double-tap window doubles as the single-tap defer, so tap-to-select cost contact time plus 400ms, roughly 2.7× the mouse path's 180ms (which is armed on press rather than release). 250ms matches the iOS system double-tap interval and is the floor for how responsive selection can feel while still recognising a double-tap.
  • Touch selects a connection on release, not on touchdown — selecting on touchdown meant a pan that merely started on a line left that line selected once your finger came up somewhere else entirely; the gesture was a pan, but the line had already claimed it. Mouse has never behaved this way. Touchdown now records a candidate, movement past 10px cancels it, and release commits it — with the release position re-checked, since a fast flick can outrun the move handler. Double-tap-to-open is counted on release for the same reason: a pan is not a tap and must not accumulate toward one.
  • The one-shot model helper is local-only by default — roughly a dozen tools call it on paths the user never asked for a model call on (name resolution, shape classification, duplicate checks), and every one already has a working heuristic fallback. Against a local model that is free and fast; against a cloud model it is a per-tool-call charge on a 3s timeout tuned for local latency that a cloud round-trip will often blow — billing for answers that then get thrown away. This matters more now that the agent loop runs in-process, since localStorage is reachable wherever the loop runs and the helper would otherwise have switched itself on for every cloud user as a side effect of that move. Opt back in with configureOneShot({ allowCloudProviders: true }).
  • Connection previews share one recipe — the connection control panel, the hover vision aid and the right panel'...
Read more

0.10.2

Choose a tag to compare

@github-actions github-actions released this 22 Aug 02:41

Added

  • Foundation for future app implementation via Capacitor — Redstring now builds as a native iOS shell (ios/ Xcode project, CocoaPods, launch and main storyboards, app icon and splash assets) alongside the web and Electron targets. A new capacitorAdapter handles file work through the app's own Documents container, with tests covering it.
    • Universe files are app-managed on iOS — there is no file picker and no workspace folder to choose; universes live at a deterministic path inside the app container. Because the location is derived from the universe rather than remembered, a lost link (WKWebView evicting IndexedDB, a reinstall changing the container UUID) is recoverable — the handle is rebuilt as long as the file is still there, and if it isn't, the next save creates it.
    • App keeps the touch UI without being forced into git-only mode — every device heuristic reads the phone as a device with no file access, which would have routed it down the git-only storage path. The native shell is now recognised as having real local storage while keeping the touch layout.
    • Saves flush before the app suspendsappStateChange (with visibilitychange as a second signal) flushes the save coordinator, which is the last chance to beat the 3s debounce before the system suspends the web view.
    • GitHub sign-in uses the device flow on iOS — the same path Electron already used, run directly against github.com with no OAuth server in between.
    • The native app points at no server by default — it is not a client of redstring.io or any other host. Server-backed features (the AI wizard, GitHub web sign-in) are opt-in against an endpoint you configure yourself, and until you set one they degrade with a clear "no endpoint configured" error instead of quietly reaching for a stranger's server.
  • Layout scale controls finally apply to pattern layouts — the Compact/Balanced/Spacious preset and the spacing slider were read only by the force solver, so neither did anything once a pattern, Lombardi, or orthogonal pipeline handled the graph — setting Compact on a Lombardi tree changed nothing. Both now scale the spacing knobs the pattern pipeline actually measures with. Node boxes and label text keep their real sizes; only the breathing room between them scales, so Compact can't push labels into each other.

Changed

  • Connection labels curve again on ordinary graphs — the curved-label budget was 40 and is fed the whole graph's edge count, so a 41-edge Lombardi graph drew every label as a straight, angle-snapped chord: the "quantized and never bent" look. The budget is now 250 and serves only as a backstop, because the cost model behind 40 no longer holds — curved glyphs snap into rotation buckets, so the atlas keys they can mint are bounded by (characters × buckets) regardless of how many labels curve.
    • A small screen-space threshold (2px of bow) comes back alongside it, so at fit-the-whole-graph zoom the labels whose bends have been compressed below visibility render as the straight runs they already look like — which is what keeps that view affordable now that far more labels are eligible to curve.
    • Glyph rotation buckets tightened from 4° to 2°. A curved label snaps each character independently, so the bucket size is the wobble; 4° was visible on gently-bent labels. The canvas-wide quantum is no longer folded in — a dense canvas's coarser setting made curved labels wobbly, exactly where it was worst.
    • The straight-label angle error budget came back down from 12px to 3px. At 12px the formula returned the full 9° ceiling at every zoom up to ~1.0, tilting every label up to 4.5° off its true angle at ordinary working zooms.
  • Lombardi spacing accounts for labels riding the arc — the arc/chord length factor used to inflate label spacing, which was correct when labels sat on the chord. They ride the curve now, so a chord of length L offers more text room, not less, and the label terms divide by the factor instead. Lombardi also gets a label-breathing trim: its constructions are rings and radial wedges that pay every unit of padding in radius, where area grows with the square, so the padding priced against straight rows read as inflated. The bow allowance stays at half strength, since a later pipeline stage clears the actual drawn arcs off nodes.
  • The AI bridge is single-tenant by construction, and says so — the bridge's in-memory state (store snapshot, pending actions, chat log, telemetry) has no per-user key, so it is only meaningful when the sole client is the local browser on the same machine. Multi-tenant is now the default, with the stateful endpoints refused there and the local mode opt-in via REDSTRING_SINGLE_TENANT=true — a deployment that loses its env config fails closed rather than open. Static tool definitions, which carry no user data, are still served.
  • Wikipedia enrichment confidence is scored honestly — a direct article hit contributed 0.40 against a 0.40 rejection threshold, so the floor was already a pass and no direct hit could ever be rejected however unrelated its title. The base drops to 0.15 and title agreement carries the rest, so an exact title still scores 1.00 while a poor one now falls below the threshold. The user-invoked enrichment path also no longer overrides the threshold to zero — it had the weakest matching of all.
  • Ambiguous node names are asked about, not guessed — the wizard's fallback name resolver kept the last substring match, which is Map insertion order rather than relevance, so "Cell" resolved to whichever cell-ish node happened to be created most recently. Candidates are now ranked by edit distance, and when the top ones are within a small margin of each other the resolver reports ambiguity with the candidate list instead of picking. deleteNode returns that list and asks which was meant rather than deleting on a coin flip.

Fixed

  • Nested groups no longer scatter across the canvas — several compounding faults in group-separated layout:
    • A nested group's anchor (its title tab) was laid out as a free node while the group's shell was already represented by a rigid block, which left the block itself edge-less. It was then classified as an isolated floater and flung out by the isolated-node placer. Anchor edges now route to the block, and the anchor is pinned to its shell's title spot.
    • Isolated-node spacing was one global figure — the largest floater's diameter — applied to every pair. A single 2000px nested-group block therefore set a ~2100px spacing between everything isolated. Spacing is now per node and per pair, with grid resolution following the median floater.
    • Clearance for those floaters was measured with circles, whose radius for a wide flat group is its half-diagonal. Group blocks are now measured as boxes, which is the difference between "tucked beside it" and "flung to the corner of the canvas".
    • The candidate grid's pitch was derived from floater size, so group-sized floaters got a 500–1200px grid and every placement overshot its nearest legal spot by up to a full step — indistinguishable from deliberate spacing, and the source of hundreds of pixels of unexplained gap around unconnected groups. The pitch is now a fixed fine resolution with a candidate cap.
  • Groups sit in the slot the solver reserved for them — both the nested-child and top-level composition steps aligned a group's member-bbox centre to its reserved rect's top-left corner, dropping every group half its own size up and to the left of where it belonged. Large groups bled into the neighbour that way while empty margin was left behind on the other side. Top-left is now aligned to top-left, with the meta-node padding accounted for.
  • Group anchors stop drifting and stacking — anchors carry no edges, so the refinement and ejection passes treated them as free bodies: they drifted, got ejected out of sibling boxes toward the same corridor exit, and piled up there, stretching bounding boxes and landing on top of real nodes. Every laid-out group's anchor is re-pinned to its title spot as a finishing pass, and the force solver's group-exclusion no longer expels a group's own anchor from the group it belongs to.
  • Ambiguous Wikipedia terms enrich instead of silently failing — for a genuinely ambiguous name ("Mercury", "Java", "Mars") the top search result behind the disambiguation page is that same disambiguation page, so the retry re-fetched the page it had just rejected and gave up. Disambiguation and index pages are now skipped explicitly and a bounded handful of real candidates tried, keeping the best-scoring article.
  • One nameless prototype no longer aborts a whole enrichment batch — the prototype scan threw on a missing name from outside any try/catch, and the failure was swallowed further up the chain, so the entire batch stopped silently.
  • The client stops hammering deployments that have no bridge — a host without a bridge answers unknown /api/* paths with the SPA fallback (200 + HTML), which the health check read as "alive", so it reconnected forever without ever tripping its attempt cap. Health now validates the payload rather than the status code, accepts both server shapes in the wild, and latches permanently-absent on an explicit 404 or a non-JSON body — which also stops the periodic upload of the whole store to a host with nothing there to receive it.
  • Security contact address in SECURITY.md corrected.

0.10.1

Choose a tag to compare

@github-actions github-actions released this 08 Aug 00:37

Added

  • Live model lists for every AI provider — the model dropdown in Settings and in the API key setup now fetches each provider's real catalog from its own models endpoint (Anthropic, OpenAI, Google, Cohere, OpenRouter, and any local OpenAI-compatible server) instead of showing a hand-maintained list that goes stale within weeks of a new release. Models are filtered to tool-capable chat models (the wizard is tool-driven, so a model without tool support is a broken selection), sorted newest-first, and shortlisted to the twenty most recent — with your currently-saved model always kept in the list even if it falls outside that. Results are cached for a day, keyed to the provider and a fingerprint of the key so switching accounts doesn't serve a stale list, and a Refresh models button re-fetches on demand. The static lists remain as an offline/no-key fallback, and the description under the picker says which you're looking at.
  • Saved keys are visible across providers — the provider dropdown now marks which providers you already have a key stored for, and the model fetch uses that provider's stored key rather than the active profile's (which belongs to a different provider and just 401s). Switching provider no longer looks like your other keys are gone.
  • Deterministic layout for grouped graphs — group placement used random jitter, which was invisible while every grouped graph went through the (already unreproducible) force solver. Now that grouped graphs can use pattern layouts, the jitter is derived from node IDs instead, so pressing the layout button twice produces the same drawing.
  • Test coverage for group membership and pattern layoutsgroupMembership.test.js (251 lines) covers the containment invariants below; patternLayouts.test.js (230 lines) covers arc-chain geometry.

Changed

  • Routing style now applies to grouped graphs — choosing Lombardi, Manhattan, or Clean routing on a graph with even one group used to throw the matching layout away and fall back to the force solver, drawing arcs over a force layout (the one arrangement Lombardi's geometry exists to avoid). The two now compose: the pattern pipeline lays out each group's interior, and group separation places the groups and enforces membership between them.
  • Long chains coil into a spiral instead of a circle — a sequence is laid as flat as it can be laid and curls only as far as it takes to stay inside a legible width, so short chains come out as gentle horizontal bows rather than circles nobody asked for. Past that width it coils into an Archimedean spiral rather than a ring: a ring has one turn to spend so its area grows with the square of the chain's length, while a spiral buys room by winding again — at 50 nodes the spiral is a quarter of the ring's area. A spiral's two ends also sit on different windings, so it reads as a sequence without needing a deliberately-held-open gap. The coil is rotated so the first connection lands dead level (not merely the first node at 12 o'clock — a chord sits half its own arc off the tangent, 17° at the rim of a twenty-node coil), and the sequence is read from the rim inward, opening where the curve is flattest.
    • Where a ring is still used, the opening scales off the ring's longest chord rather than a fixed angle, so short chains open into a broad arc and long ones close toward a near-complete circuit.
    • Connection label padding more than doubled (from 0.6em to 5.5em total along the edge). An edge whose label runs nearly wall-to-wall is legible alone and a smear in a busy diagram. This is affordable because of the coil: on a spiral the room is paid for linearly rather than by radius squared, so a twenty-node chain grows 11% and still comes out a fifth smaller than the ring did at the old padding.
  • Lombardi refinement is leashed to its seed — the rotation pass preserves edge length but not the drawing's shape, and on a sparse graph the corrections compound along a path: measured on a twenty-node coil it moved a node 1496px (three windings) to buy 0.85° of residual, and the seeded spiral came out crossing itself. A new maxDrift option pulls nodes back inside a radius of where the seed put them, applied every iteration rather than once at the end. Pattern layouts pass half the seed's own tightest clearance — whatever gap the seed was careful about is what gets protected. The default is unbounded, for seeds like the force solver's that have no structure to preserve.
  • Wizard graph specs tolerate the shapes small models actually sendlayers reaches the tools as a JSON string the model hand-escaped, and weaker models send a lone object instead of a one-element array, wrap the list under a repeat of the parameter name ({"layers": [...]}), wrap it in a fenced code block, or leave a trailing comma. Each of those used to coerce to an empty array and surface as "At least one layer is required" — an error reading "you forgot layers" when the model had in fact sent them. Bare strings in named lists (["Engine"]) are also accepted as {name: "Engine"}. When a spec really is unusable, the error now echoes what actually arrived so the model can repair it.
  • Wizard edits re-run layout — creating, resizing, or deleting a node; creating, retyping, or deleting an edge; every group operation; and merging graphs now all schedule a layout pass. Retyping an edge matters because the label is laid out along the edge and its measured width feeds node spacing — it changes geometry, not just text.

Fixed

  • Non-members no longer finish inside a group — containment is read straight off the drawing, so a node rendered inside a group's box is the layout asserting something false. Three separate holes are closed:
    • Foreign members were never ejected. Only ungrouped nodes were tested, so a member of group A was free to sit inside group B's box. Membership is now the whole test, and a node exits biased toward the centroid of its own groups — which is what makes it safe for nesting too: a member of parent A that drifted into nested child G leaves G through the side facing A, staying inside A rather than being flung out of both.
    • Ejection ping-ponged between adjacent boxes. Stepping out of whichever box a node is currently in doesn't converge when two boxes sit close together — the node leaves A into B, leaves B back into A, and which box it finishes in is decided by the parity of the loop bound. Exits are now proposed from every box and their union, filtered to those clear of everything, and the nearest is taken; the union's exits are always clear, so this terminates by construction.
    • The title bar wasn't protected. Group boxes were computed from the member bounding box plus padding, which leaves a node-group's title strip (hanging above the members) uncovered — a non-member parked just above finished underneath the title and read as a member. Every containment test now runs against the rect a renderer actually draws.
    • Containment is enforced as a finishing pass on both the pattern and force paths, rather than being left to a soft force running for twenty iterations.
  • Group rects the solver enforces against now match the ones on screen — group rects derive from gridSize (member padding scales with it), which wasn't being passed through to the solver, so exclusion was enforced against a rect that wasn't the drawn one.
  • Cross-group connection labels get room — the corridor between separated groups is now sized from the widest label on an edge that actually crosses between them, so a long relationship name isn't squeezed into the gap.
  • Node-group title bars take part in group separation — a group's drawn rect (title included) is used when pushing groups apart, instead of letting a title overhang its neighbour.
  • Sticky "Custom..." model selection — picking Custom in the model dropdown and not yet typing anything no longer snaps the select back, and the custom input no longer shows the literal string custom as its value.

0.10.0

Choose a tag to compare

@github-actions github-actions released this 07 Aug 08:24

Added

  • Nested node-groups — node-groups can now contain other node-groups, so a decomposed node can hold a decomposed node inside it and the whole composition stays visible and editable in one canvas.
    • Membership propagates through the nesting: adding an instance to an inner group adds it to every group that contains it, and removing it from an outer group cascades down, so a child group is always a strict subset of its parent.
    • Group layout keeps margin clearance around nested groups instead of letting an inner band collide with its parent's edge, and nesting depth now drives rendering order so inner groups always draw above the groups that contain them.
    • Connections are clipped precisely against the group shell, so an edge entering a nested group no longer paints over the band it passes through.
    • Nested groups survive decompose/combine, are copied intact when a group moves between graphs, and are preserved through save/load.
    • Nested empty node-group placeholders now remember where you dragged them instead of snapping back to their original position.
  • Node-groups can sync with their definition graph — a node-group's control panel gained two new actions: Update Definition pushes the group's current contents (members and the connections between them) into its linked definition graph, overwriting whatever was there before; Refresh From Definition does the reverse, replacing the group's contents with a fresh copy from the definition graph. Members that connect to something outside the group can't be represented in the definition graph and are dropped from the copy.
  • Empty node-groups for undefined prototypes — decomposing a node whose prototype has no definition graph yet (or an empty one) now opens an empty, draggable placeholder box sized to the original node, instead of failing. Dragging other nodes into it builds the definition out from scratch; once it has real members it behaves like any other node-group.
  • The wizard can build nested compositions — a new buildComposition tool lets the AI wizard lay out a graph as nested layers rather than a flat node-and-edge list, recursing into layer definitions with a depth limit and normalizing/validating layers, nodes, edges, and groups before anything is written. expandGraph and createPopulatedGraph accept layers directly, and layer names that collide with node names are disambiguated instead of silently overwriting each other.
  • Graph quality analysis — a quality pass now inspects a generated graph for structural problems (hub-and-spoke degeneration, missing or invalid layers, every layer left collapsed) and reports them back to the wizard so it can correct the graph instead of handing over something malformed.
  • Node size shorthand in wizard graph specs — node definitions accept a size marker (with validation and a warning for unrecognized tokens), so the wizard can set node sizes as it sketches instead of resizing afterward.
  • Trackpad zoom glide — trackpad pinch-zoom now carries momentum after you stop, with a Zoom Glide toggle and strength slider in Settings, matching the touch pinch-glide behavior.
  • Conversation compaction and history deduplication — long wizard conversations are compacted while preserving recent messages, and superseded read-only tool results are collapsed out of history, so a long session stops ballooning its own context. Request assembly was split out so the stable prefix caches cleanly and only volatile context is re-sent.
  • skipped task status — wizard plans can mark a step skipped rather than forcing it complete or leaving it open, and completion logic accounts for it.
  • Electron SVG rendering benchmark harness — a standalone Electron app for measuring SVG render and zoom performance outside the main application.

Changed

  • Connection labels render as placed glyphs instead of <textPath> — curved labels are now positioned glyph-by-glyph using measured per-glyph advances, which renders faster and places text accurately along an arc. Label geometry is measured only once fonts have actually loaded, and label frames are shared between React and the drag updater so a label can't disagree with itself mid-interaction.
  • Label placement prefers staying on the curve — placement now scores overlap area rather than simple collision, respects the visible geometry of a route (so a label doesn't dodge a segment that isn't drawn), and label curvature constants no longer vary with zoom, so a label keeps its shape as you zoom in and out.
  • Zoom and pan performance — canvas transform state moved off React's render path (isMoving is a ref, not state), glyph rasters are quantized to discrete zoom scales during motion so text isn't re-rasterized every frame, near-straight arcs use a cheaper curve threshold, and glyph rotation handling was reworked to avoid redundant work.
  • Layout is directionality-aware — layout now carries edge direction through to arrow orientation, and topology detection uses direction when classifying a cluster, so hierarchies are recognized as hierarchies instead of undirected meshes. Pattern layouts also resolve main-axis gaps between siblings and size label padding from the actual font size.
  • Wizard graph navigation is name-resolvableswitchToGraph and resolveGraphId resolve from graph names and node names, prefer recently opened graphs when ambiguous, and report clearly when navigation isn't needed, instead of failing on a stale ID.
  • Telemetry is bounded — wizard telemetry no longer grows without limit over a long session.

Fixed

  • Layer shells no longer duplicate nodes — applying a composition adopts an existing instance of the same name (excluding group anchors) and re-points it to the new prototype, preserving its edges, instead of creating a second copy. Re-applying the same composition is now idempotent.
  • Group deduplication — bulk updates merge duplicate groups, and decomposing a node no longer creates a duplicate group when linkedDefinitionIndex is undefined.
  • Wizard tool calls no longer show "Running…" forever — tool calls announced but never executed (early exit, token budget exhaustion, a broken stream) are now settled when the run ends. A watchdog also times out tool calls so a hanging synchronous tool can't block the agent loop, and oversized tool results are capped so one huge payload doesn't poison the following iterations.
  • findDuplicates no longer stalls the server — instance counts are indexed upfront and output is bounded, with truncation reported rather than silently applied.
  • Duplicate open-graph IDs — open graph IDs are deduplicated, fixing React key collisions and the rendering glitches they caused.
  • Streaming message duplication — streamed wizard messages and enriched nodes are no longer processed twice.
  • Graph previews match the canvas — preview nodes now use the same corner radius as real nodes, connection stroke width scales correctly in previews, and edge label font size derives from a shared base constant so preview layout matches what the canvas shows.
  • Group titles no longer overflow narrow groups — a node-group's background band now widens to fit its title when the group has few or small members, instead of letting a long name spill past the band.
  • Lombardi labels stay live through the whole drag — a connection label whose curvature crosses the straight/curved threshold mid-drag (a common two-hop side effect of a neighbor's tangent fan re-solving) no longer freezes, and now re-syncs correctly when zoom/pan settle mid-drag (the debounced drag-zoom-out ramp and edge auto-pan). Arc labels also track which side of the curve they're on, so a bow that flips sides mid-drag no longer sends the label jumping to the wrong side of the connection.
  • Combining an empty node-group reuses its anchor position — collapsing a member-less node-group back into a single node now places it where the group's anchor actually was, instead of snapping to the graph origin.
  • Orphaned group anchors are cleaned up — anchor instances left behind by a group that was lost outside the normal delete/combine paths (e.g. a stale save) are now swept up automatically instead of rendering invisibly forever.
  • Abstraction carousel no longer freezes mid-snap — a snap animation triggered under a heavy render (e.g. right after creating a new node and chain entry) could stall partway because the physics loop's control flow read stale state; physics transitions are now computed synchronously instead of waiting on React's next commit.
  • Deleting a node from the abstraction carousel keeps focus in the chain — removing a node no longer strands the carousel's scroll position on empty space or snaps it onto the wrong neighbor; focus now follows to the node that took its place.
  • Node/Connection Control Panel no longer double-opens over a group — selecting a node-group now clears any stale node/edge selection first, so the panel doesn't stomp its own state open and closed within the same render.
  • Node description text truncates cleanly — descriptions that overflow their fixed-height area now end with a manually-fit ellipsis instead of relying on CSS line-clamp (which didn't reliably render inside the SVG-hosted description box), and the underlying height calculation was corrected to match the actual rendered line height so the last line no longer clips.