Issue: #411
Goal: Support linear chains of 3+ glyphs: [ax|py|prompt]
Phase 1 Complete: Storage + DAG migration (PRs #436, #443)
composition_edges table, proto types)Composition directly, no derived fieldsPhase 2 Complete: Port-aware meldability + multi-directional melding
Phase 3 Complete: Composition extension, consolidation, CSS Grid layout
Edge-based DAG structure (DAG-native, no derived fields)
Initial Phase 1 implemented glyphIds: string[] for linear chains. However, QNTX requires multi-directional melding:
Flat arrays cannot represent DAG structures. Phase 1b migrates to edge-based composition.
Final structure (DAG-native):
interface CompositionEdge {
from: string; // source glyph ID
to: string; // target glyph ID
direction: 'right' | 'top' | 'bottom';
position: number; // ordering for multiple edges same direction
}
interface CompositionState {
id: string;
edges: CompositionEdge[];
x: number;
y: number;
}
Key principle: No glyph_ids field. Traverse edges to find glyphs (DAG-native thinking).
Rationale:
glyph_ids field - traverse edges to find glyphs'right' and 'bottom' edges (multi-directional)'top' direction (reserved)glyph_ids) to prevent accidental reuseDecision: Clean breaking change (no backward compatibility). No melded compositions exist in production yet.
CompositionState type in web/ts/state/ui.ts
initiatorId: string + targetId: string to glyphIds: string[]'ax-py-prompt'isGlyphInComposition, findCompositionByGlyph){ glyphElements, glyphIds }✅ Create migration 020_multi_glyph_compositions.sql
composition_glyphs(composition_id, glyph_id, position)canvas_compositions without initiator_id/target_id (breaking change)✅ Update glyph/storage/canvas_store.go
GetComposition() returns GlyphIDs []string (queries junction table)UpsertComposition() accepts glyph ID array (transaction-based with junction table)ListCompositions() fixed nested query issue (two-pass approach)✅ Update glyph/handlers/canvas.go
glyph_ids: string[]glyph/storage/canvas_store_test.go
GlyphIDs array formatForeignKeyConstraints test for new junction table behaviorweb/ts/state/compositions.test.ts for glyphIds arrayweb/ts/components/glyph/meld-composition.test.ts for new unmeld return formatweb/ts/state/compositions.test.ts:
isGlyphInComposition works with 3-glyph chainsfindCompositionByGlyph finds 3-glyph chainsweb/ts/components/glyph/meld-composition.test.ts:
Goal: Define composition DAG structure in protobuf as single source of truth (ADR-006).
Approach: Follow ADR-007 pattern (TypeScript interfaces only), ADR-006 pattern (Go manual conversion at boundaries).
glyph/proto/canvas.proto:
syntax = "proto3";
package glyph;
option go_package = "github.com/teranos/QNTX/glyph/proto";
// CompositionEdge represents a directed edge in the composition DAG
// Supports multi-directional melding: horizontal (right), vertical (top/bottom)
message CompositionEdge {
string from = 1; // source glyph ID
string to = 2; // target glyph ID
string direction = 3; // 'right', 'top', 'bottom'
int32 position = 4; // ordering for multiple edges in same direction
}
// Composition represents a DAG of melded glyphs
// Edges define the graph structure - no derived fields
message Composition {
string id = 1;
repeated CompositionEdge edges = 2;
reserved 3; // formerly glyph_ids (removed for DAG-native approach)
double x = 4; // anchor X position in pixels
double y = 5; // anchor Y position in pixels
}
✅ Update proto.nix:
canvas.proto to Go generation (generate-proto-go):
${pkgs.protobuf}/bin/protoc \
--plugin=${pkgs.protoc-gen-go}/bin/protoc-gen-go \
--go_out=. --go_opt=paths=source_relative \
glyph/proto/canvas.proto
canvas.proto to TypeScript generation (generate-proto-typescript):
${pkgs.protobuf}/bin/protoc \
--plugin=protoc-gen-ts_proto=web/node_modules/.bin/protoc-gen-ts_proto \
--ts_proto_opt=esModuleInterop=true \
--ts_proto_opt=outputEncodeMethods=false \
--ts_proto_opt=outputJsonMethods=false \
--ts_proto_opt=outputClientImpl=false \
--ts_proto_opt=outputServices=false \
--ts_proto_opt=onlyTypes=true \
--ts_proto_opt=snakeToCamel=false \
--ts_proto_out=web/ts/generated/proto \
glyph/proto/canvas.proto
✅ Run make proto to generate code:
glyph/proto/canvas.pb.goweb/ts/generated/proto/glyph/proto/canvas.ts✅ Commit proto definition and generated code
Goal: Migrate database and Go storage layer from composition_glyphs junction table to edge-based DAG structure.
Strategy: Breaking change (drop existing compositions, like Phase 1)
021_dag_composition_edges.sql
composition_edges table with foreign keys to compositions and glyphscomposition_glyphs table (breaking change)canvas_compositions without type columnglyph/storage/canvas_store.go
import pb "github.com/teranos/QNTX/glyph/proto"compositionEdge struct for database operationsCanvasComposition struct with Edges []*pb.CompositionEdgetoProtoEdge() and fromProtoEdge()UpsertComposition() to write edges in transactionGetComposition() to load edges via JOINListCompositions() to load edges (two-pass approach)DeleteComposition() cascade handled by foreign key constraints✅ Update glyph/storage/canvas_store_test.go
GlyphIDs with Edges in test dataType fieldmake test - all Go tests passing (356 pass, 24 skip, 0 fail)✅ Update glyph/handlers/canvas_test.go
Goal: Update API handlers and TypeScript state management for edge-based compositions.
Completion: All tests passing (728 total: 352 Go + 376 TypeScript)
glyph/handlers/canvas.go requires no changes (generic JSON passthrough)CanvasComposition structglyph_ids field from proto - edges ARE the compositionreserved 3 to prevent field reuseComposition message contains only: id, edges, x, y✅ Update web/ts/state/ui.ts
import type { CompositionEdge, Composition } from '../generated/proto/glyph/proto/canvas'Composition directly: export type CompositionState = Compositiontype field✅ Update web/ts/state/compositions.ts (DAG-native)
buildEdgesFromChain(glyphIds: string[], direction): CompositionEdge[] (for tests/migrations)extractGlyphIds(edges: CompositionEdge[]): string[] (for logging)getCompositionType() and getMultiGlyphCompositionType()isGlyphInComposition() - traverses edges (DAG-native)findCompositionByGlyph() - traverses edges (DAG-native)✅ Update web/ts/api/canvas.ts
upsertComposition() to send edges onlyglyph_ids and type fields from API calls✅ Update web/ts/components/glyph/meld-system.ts (DAG-native)
performMeld() (not array-then-convert){ from: initiator, to: target, direction: 'right', position: 0 }glyphIds from unmeldComposition() return value✅ Update web/ts/state/compositions.test.ts
glyphIds with edges in test data (DAG-native)type field referencesgetCompositionType() tests (function removed)edges: [{ from: 'ax1', to: 'prompt1', direction: 'right', position: 0 }]bun test - all TypeScript tests passing (376 tests)✅ Update web/ts/components/glyph/meld-composition.test.ts
glyphIds expectation from unmeldComposition() return valueGoal: Update meld system and UI to work with edge-based compositions.
Completion: All tests passing (728 total: 352 Go + 376 TypeScript)
✅ Update web/ts/components/glyph/meld-system.ts
performMeld() already creates edges directly (done in Phase 1bb)unmeldComposition() already doesn't return glyphIds (done in Phase 1bb)reconstructMeld() signature simplified:
compositionType parameter (no longer needed)✅ Update web/ts/components/glyph/canvas-glyph.ts
extractGlyphIds from compositions moduleextractGlyphIds(comp.edges) to find glyph IDs from edgesedges instead of glyphIdscomp.type from reconstructMeld call✅ Run full test suite: make test
✅ Manual browser test: Create 2-glyph composition
Goal: Spatial ports on glyphs defining valid directional connections, multi-directional proximity detection and layout.
meldability.ts: Restructured from flat class → class[] to class → PortRule[]
EdgeDirection = 'right' | 'bottom' | 'top'PortRule = { direction, targets[] }areClassesCompatible() returns EdgeDirection | nullgetLeafGlyphIds() / getRootGlyphIds() for DAG sink/source detectiongetMeldOptions() for append (leaf) and prepend (root) with incomingRole: 'from' | 'to'getGlyphClass() regex-based, decoupled from registrymeld-system.ts: checkDirectionalProximity() handles right/bottom/top with alignmentfindMeldTarget() returns { target, distance, direction }performMeld() accepts direction, switches flex layout (row vs column)reconstructMeld() accepts edges, determines layout from edge directionspy-glyph.ts: createAndDisplayResultGlyph calls performMeld('bottom')glyph-interaction.ts: passes direction to performMeldcanvas-glyph.ts: passes edges to reconstructMeldcompositions.ts: removed isCompositionMeldable placeholdermeldability.test.ts (new): port-aware registry, DAG helpers, getMeldOptionsmeld-composition.test.ts: directional melding, direction-aware reconstructionmeld-detect.test.ts: bidirectional detection, composition target findingGoal: Meld a standalone glyph into an existing composition (edges-only: append to leaf / prepend to root). Also fix auto-meld when py is already inside a composition.
Composition ID strategy: Regenerate ID on extend (melded-{from}-{to} with the new edge's endpoints).
meld-system.ts into focused modules:
meld-detect.ts — proximity detection, target finding (findMeldTarget, canInitiateMeld, canReceiveMeld)meld-feedback.ts — visual proximity cues (applyMeldFeedback, clearMeldFeedback)meld-composition.ts — composition CRUD (performMeld, extendComposition, reconstructMeld, unmeldComposition)meld-system.ts — barrel re-export (zero import churn for callers)meld-detect.test.ts and meld-composition.test.tsfindMeldTarget() recognizes glyphs inside compositions as meld targets
.closest('.melded-composition') for sub-container awarenessextendComposition() in meld-composition.ts
meld-sub-container (nested flex) to preserve spatial layoutglyph-interaction.ts: detects composition targets via .closest(), calls extendComposition.melded-composition (flex-direction: row)
├── ax-glyph
└── .meld-sub-container (flex-direction: column)
├── py-glyph
└── result-glyph
reconstructMeld() rebuilds sub-containers from stored mixed-direction edges on page reload.closest('.melded-composition') instead of parentElement?.classList.contains()py-glyph.ts: uses .closest('.melded-composition') to find parent composition
extendComposition with direction 'bottom' and role 'to'meld-detect.test.ts: reverse meld detection, composition target detectionmeld-composition.test.ts: meld/unmeld, directional layout, reconstruction, extension (same-axis + cross-axis + repeat execution), storage verificationPR: #446
PR: #451 (continues #446)
Goal: Replace flex+sub-container layout with CSS Grid. Three parallel code paths (extendComposition, reconstructMeld, unmeldComposition) that independently managed sub-containers collapse into one shared applyGridLayout() function.
computeGridPositions() in meldability.ts — BFS from roots, handles multi-child fan-out per directionapplyGridLayout() in meld-composition.ts — single source of truth for all composition layoutperformMeld, extendComposition, reconstructMeld simplified (net code reduction)unmeldComposition clears grid styles on restore.melded-composition uses display: grid instead of display: flexgrid-row/grid-columncomputeGridPositions (base cases + edge cases: fan-out, multi-root, top direction)Deferred backend tests:
glyph/handlers/canvas_test.go: 3-edge composition POST/GET roundtrip, edge ordering preservedManual verification:
[py|py] → [py|py|prompt]Open issues:
py → py chaining:
MELDABILITY['canvas-py-glyph'] includes { direction: 'right', targets: ['canvas-py-glyph'] }Edge-based composition IDs:
melded-{from}-{to})Cross-axis layout (superseded in Phase 3c):
meld-sub-container divs with flex layout. Three parallel code paths independently managed sub-containers — fragile and blocked composition-to-composition melding.grid-row/grid-column derived from the edge DAG by computeGridPositions(). Single applyGridLayout() function used by all composition operations.Meld system module split:
meld-system.ts into meld-detect.ts, meld-feedback.ts, meld-composition.ts with barrel re-exportActual approach (Phase 1): Clean breaking change
Since no melded compositions exist in production yet, we opted for a simpler breaking change:
canvas_compositions table (drops old schema)Rationale: