Fix diagram layout and clicks; tell agents to yield to image messages

This commit is contained in:
Ian Gulliver
2026-10-23 10:58:01 -07:00
parent eba85a6a1a
commit 4e20fe8a12
5 changed files with 168 additions and 49 deletions
+26 -6
View File
@@ -387,19 +387,27 @@ type midTurn struct {
taskInputs []store.TaskInput taskInputs []store.TaskInput
leadInputs []store.LeadInput leadInputs []store.LeadInput
responses []string responses []string
hinted int64
} }
func (m *Manager) deliverMidTurn(ctx context.Context, l *live) midTurn { func (m *Manager) deliverMidTurn(ctx context.Context, l *live) midTurn {
if l.owner != "" { if l.owner != "" {
inputs := m.takeLeadQueuedMidTurn(ctx, l) inputs, held := m.takeLeadQueuedMidTurn(ctx, l)
if len(inputs) == 0 { parts := []string{}
return midTurn{} if len(inputs) > 0 {
}
msgs := []queued{} msgs := []queued{}
for _, in := range inputs { for _, in := range inputs {
msgs = append(msgs, queued{actor: in.Actor, text: in.Text, sendID: in.SendID, client: in.Client}) msgs = append(msgs, queued{actor: in.Actor, text: in.Text, sendID: in.SendID, client: in.Client})
} }
return midTurn{text: m.recordQueued(ctx, l, msgs), leadInputs: inputs} parts = append(parts, m.recordQueued(ctx, l, msgs))
}
mt := midTurn{leadInputs: inputs}
if hint := m.imagesHint(l, held); hint != "" {
parts = append(parts, hint)
mt.hinted = held.id
}
mt.text = strings.Join(parts, "\n\n")
return mt
} }
unlock := m.lockDelivery(l.id) unlock := m.lockDelivery(l.id)
@@ -419,7 +427,8 @@ func (m *Manager) deliverMidTurn(ctx context.Context, l *live) midTurn {
mt := midTurn{} mt := midTurn{}
parts := []string{} parts := []string{}
mt.taskInputs = m.takeQueuedMidTurn(ctx, l) inputs, held := m.takeQueuedMidTurn(ctx, l)
mt.taskInputs = inputs
if len(mt.taskInputs) > 0 { if len(mt.taskInputs) > 0 {
msgs := []queued{} msgs := []queued{}
for _, in := range mt.taskInputs { for _, in := range mt.taskInputs {
@@ -427,6 +436,10 @@ func (m *Manager) deliverMidTurn(ctx context.Context, l *live) midTurn {
} }
parts = append(parts, m.recordQueued(ctx, l, msgs)) parts = append(parts, m.recordQueued(ctx, l, msgs))
} }
if hint := m.imagesHint(l, held); hint != "" {
parts = append(parts, hint)
mt.hinted = held.id
}
responses, ids := m.responsesMidTurn(ctx, l) responses, ids := m.responsesMidTurn(ctx, l)
if responses != "" { if responses != "" {
parts = append(parts, responses) parts = append(parts, responses)
@@ -472,6 +485,13 @@ func (m *Manager) restoreMidTurn(ctx context.Context, l *live, mt midTurn) {
slog.Error("mark responses undelivered", "session", l.id, "err", err, "responses", mt.responses) slog.Error("mark responses undelivered", "session", l.id, "err", err, "responses", mt.responses)
} }
} }
if mt.hinted != 0 {
m.mu.Lock()
if l.hintedImages == mt.hinted {
l.hintedImages = 0
}
m.mu.Unlock()
}
slog.Info("mid-turn delivery restored", "session", l.id, "inputs", len(mt.taskInputs)+len(mt.leadInputs), "responses", mt.responses) slog.Info("mid-turn delivery restored", "session", l.id, "inputs", len(mt.taskInputs)+len(mt.leadInputs), "responses", mt.responses)
} }
+2
View File
@@ -122,6 +122,8 @@ type live struct {
lastText string lastText string
subagents map[string]bool subagents map[string]bool
hintedImages int64
contextModel string contextModel string
contextUsed int contextUsed int
contextWindow int contextWindow int
+34 -12
View File
@@ -176,12 +176,17 @@ type queued struct {
client string client string
} }
func (m *Manager) takeQueuedMidTurn(ctx context.Context, l *live) []store.TaskInput { type heldImages struct {
id int64
actor string
}
func (m *Manager) takeQueuedMidTurn(ctx context.Context, l *live) ([]store.TaskInput, heldImages) {
m.mu.Lock() m.mu.Lock()
compacting := l.compacting compacting := l.compacting
m.mu.Unlock() m.mu.Unlock()
if compacting { if compacting {
return nil return nil, heldImages{}
} }
unlock := m.lockDelivery(queueLockKey(l.taskID)) unlock := m.lockDelivery(queueLockKey(l.taskID))
@@ -190,34 +195,36 @@ func (m *Manager) takeQueuedMidTurn(ctx context.Context, l *live) []store.TaskIn
inputs, err := m.store.TaskInputs(ctx, l.taskID, l.member) inputs, err := m.store.TaskInputs(ctx, l.taskID, l.member)
if err != nil { if err != nil {
slog.Error("list queued messages", "session", l.id, "err", err) slog.Error("list queued messages", "session", l.id, "err", err)
return nil return nil, heldImages{}
} }
out := []store.TaskInput{} out := []store.TaskInput{}
ids := []int64{} ids := []int64{}
held := heldImages{}
for _, in := range inputs { for _, in := range inputs {
if len(in.Images) > 0 { if len(in.Images) > 0 {
held = heldImages{in.ID, in.Actor}
break break
} }
out = append(out, in) out = append(out, in)
ids = append(ids, in.ID) ids = append(ids, in.ID)
} }
if len(ids) == 0 { if len(ids) == 0 {
return nil return nil, held
} }
err = m.store.DeleteTaskInputs(ctx, ids) err = m.store.DeleteTaskInputs(ctx, ids)
if err != nil { if err != nil {
slog.Error("delete queued messages", "session", l.id, "err", err) slog.Error("delete queued messages", "session", l.id, "err", err)
return nil return nil, held
} }
return out return out, held
} }
func (m *Manager) takeLeadQueuedMidTurn(ctx context.Context, l *live) []store.LeadInput { func (m *Manager) takeLeadQueuedMidTurn(ctx context.Context, l *live) ([]store.LeadInput, heldImages) {
m.mu.Lock() m.mu.Lock()
compacting := l.compacting compacting := l.compacting
m.mu.Unlock() m.mu.Unlock()
if compacting { if compacting {
return nil return nil, heldImages{}
} }
m.leadMu.Lock() m.leadMu.Lock()
@@ -226,29 +233,44 @@ func (m *Manager) takeLeadQueuedMidTurn(ctx context.Context, l *live) []store.Le
inputs, err := m.store.LeadInputs(ctx, l.owner) inputs, err := m.store.LeadInputs(ctx, l.owner)
if err != nil { if err != nil {
slog.Error("list held lead messages", "session", l.id, "err", err) slog.Error("list held lead messages", "session", l.id, "err", err)
return nil return nil, heldImages{}
} }
out := []store.LeadInput{} out := []store.LeadInput{}
ids := []int64{} ids := []int64{}
held := heldImages{}
for _, in := range inputs { for _, in := range inputs {
if in.Kind != store.LeadMessage { if in.Kind != store.LeadMessage {
continue continue
} }
if len(in.Images) > 0 { if len(in.Images) > 0 {
held = heldImages{in.ID, in.Actor}
break break
} }
out = append(out, in) out = append(out, in)
ids = append(ids, in.ID) ids = append(ids, in.ID)
} }
if len(ids) == 0 { if len(ids) == 0 {
return nil return nil, held
} }
err = m.store.DeleteLeadInputs(ctx, ids) err = m.store.DeleteLeadInputs(ctx, ids)
if err != nil { if err != nil {
slog.Error("delete held lead messages", "session", l.id, "err", err) slog.Error("delete held lead messages", "session", l.id, "err", err)
return nil return nil, held
} }
return out return out, held
}
func (m *Manager) imagesHint(l *live, held heldImages) string {
if held.id == 0 {
return ""
}
m.mu.Lock()
defer m.mu.Unlock()
if l.hintedImages == held.id {
return ""
}
l.hintedImages = held.id
return fmt.Sprintf("%s sent you a message with images, which byre can only hand you as a new turn. Finish the step you're on, end this turn with a short summary, and don't start more work: they're waiting for you to read it.", held.actor)
} }
func (m *Manager) recordQueued(ctx context.Context, l *live, msgs []queued) string { func (m *Manager) recordQueued(ctx context.Context, l *live, msgs []queued) string {
+94 -21
View File
@@ -380,6 +380,7 @@ struct BoxLayout {
struct BoxFocus { struct BoxFocus {
let boxes: Set<String> let boxes: Set<String>
let edges: Set<Int> let edges: Set<Int>
let outlined: Set<String>
static func box(_ id: String, _ layout: BoxLayout) -> BoxFocus { static func box(_ id: String, _ layout: BoxLayout) -> BoxFocus {
var boxes: Set<String> = [id] var boxes: Set<String> = [id]
@@ -389,12 +390,12 @@ struct BoxFocus {
boxes.insert(r.edge.from) boxes.insert(r.edge.from)
boxes.insert(r.edge.to) boxes.insert(r.edge.to)
} }
return BoxFocus(boxes: boxes, edges: edges) return BoxFocus(boxes: boxes, edges: edges, outlined: [id])
} }
static func preset(_ p: BoxPreset, _ layout: BoxLayout) -> BoxFocus { static func preset(_ p: BoxPreset, _ layout: BoxLayout) -> BoxFocus {
let edges = layout.edges.indices.filter { p.ids.contains(layout.edges[$0].edge.from) && p.ids.contains(layout.edges[$0].edge.to) } let edges = layout.edges.indices.filter { p.ids.contains(layout.edges[$0].edge.from) && p.ids.contains(layout.edges[$0].edge.to) }
return BoxFocus(boxes: p.ids, edges: Set(edges)) return BoxFocus(boxes: p.ids, edges: Set(edges), outlined: p.ids)
} }
func dims(_ b: PlacedBox, _ layout: BoxLayout) -> Bool { func dims(_ b: PlacedBox, _ layout: BoxLayout) -> Bool {
@@ -412,6 +413,7 @@ private let headGap: CGFloat = 24
private let childGap: CGFloat = 16 private let childGap: CGFloat = 16
private let boxGap: CGFloat = 24 private let boxGap: CGFloat = 24
private let rowGap: CGFloat = 52 private let rowGap: CGFloat = 52
private let laneGap: CGFloat = 24
private let columnGap: CGFloat = 80 private let columnGap: CGFloat = 80
private let margin: CGFloat = 20 private let margin: CGFloat = 20
private let labelRoom: CGFloat = 28 private let labelRoom: CGFloat = 28
@@ -675,11 +677,26 @@ private func placeRows(_ rows: [BoxRow], _ edges: [BoxEdge]) -> (boxes: [PlacedB
let here = Set(rows.flatMap { $0.main + $0.aside }.flatMap(ids)) let here = Set(rows.flatMap { $0.main + $0.aside }.flatMap(ids))
let crossing = edges.filter { here.contains($0.from) && here.contains($0.to) && asideIDs.contains($0.from) != asideIDs.contains($0.to) }.compactMap(\.label) let crossing = edges.filter { here.contains($0.from) && here.contains($0.to) && asideIDs.contains($0.from) != asideIDs.contains($0.to) }.compactMap(\.label)
let asideX = margin + mainWidth + crossing.map { ceil(textSize($0, edgeLabelFont).width) + labelRoom }.reduce(columnGap, max) let asideX = margin + mainWidth + crossing.map { ceil(textSize($0, edgeLabelFont).width) + labelRoom }.reduce(columnGap, max)
var rowOf: [String: Int] = [:]
for (i, r) in rows.enumerated() {
for id in r.main.flatMap(ids) {
rowOf[id] = i
}
}
func gap(below i: Int) -> CGFloat {
let n = edges.filter { e in
guard let a = rowOf[e.from], let b = rowOf[e.to] else {
return false
}
return min(a, b) <= i && max(a, b) > i
}.count
return max(rowGap, CGFloat(n + 1) * laneGap)
}
var boxes: [PlacedBox] = [] var boxes: [PlacedBox] = []
var y = margin var y = margin
var asideBottom = margin - rowGap var asideBottom = margin - rowGap
var bottom = margin var bottom = margin
for r in rows { for (i, r) in rows.enumerated() {
let height = r.main.map { sizer.naturalSize($0).height }.max() ?? 0 let height = r.main.map { sizer.naturalSize($0).height }.max() ?? 0
sizer.lay(r.main, from: margin, width: mainWidth, gaps: sizer.gaps(r.main, boxGap)) { i, x0, x1 in sizer.lay(r.main, from: margin, width: mainWidth, gaps: sizer.gaps(r.main, boxGap)) { i, x0, x1 in
sizer.place(r.main[i], CGRect(x: x0, y: y, width: x1 - x0, height: height), 0, nil, &boxes) sizer.place(r.main[i], CGRect(x: x0, y: y, width: x1 - x0, height: height), 0, nil, &boxes)
@@ -695,7 +712,7 @@ private func placeRows(_ rows: [BoxRow], _ edges: [BoxEdge]) -> (boxes: [PlacedB
asideBottom = ay - rowGap asideBottom = ay - rowGap
} }
bottom = max(bottom, y + height, asideBottom) bottom = max(bottom, y + height, asideBottom)
y += height + (r.main.isEmpty ? 0 : rowGap) y += height + (r.main.isEmpty ? 0 : gap(below: i))
} }
let width = (asideWidth > 0 ? asideX + asideWidth : margin + mainWidth) + margin let width = (asideWidth > 0 ? asideX + asideWidth : margin + mainWidth) + margin
return (boxes, CGSize(width: width, height: bottom + margin)) return (boxes, CGSize(width: width, height: bottom + margin))
@@ -731,8 +748,13 @@ private func routeLevel(_ level: String, _ boxes: [PlacedBox], _ size: CGSize, _
} }
routed.append(RoutedEdge(edge: e, points: pts, labelRect: labelRect)) routed.append(RoutedEdge(edge: e, points: pts, labelRect: labelRect))
} }
let fit = CGSize(width: ceil(labels.map(\.maxX).reduce(size.width - margin, max) + margin), height: ceil(labels.map(\.maxY).reduce(size.height - margin, max) + margin)) let dx = max(0, ceil(margin - labels.map(\.minX).reduce(margin, min)))
return BoxLayout(level: level, boxes: boxes, index: index, edges: routed, presets: presets, size: fit) let dy = max(0, ceil(margin - labels.map(\.minY).reduce(margin, min)))
let fit = CGSize(width: ceil(labels.map(\.maxX).reduce(size.width - margin, max) + margin) + dx, height: ceil(labels.map(\.maxY).reduce(size.height - margin, max) + margin) + dy)
let shifted = routed.map { r in
RoutedEdge(edge: r.edge, points: r.points.map { CGPoint(x: $0.x + dx, y: $0.y + dy) }, labelRect: r.labelRect?.offsetBy(dx: dx, dy: dy))
}
return BoxLayout(level: level, boxes: boxes.map { $0.moved(dx, dy) }, index: index, edges: shifted, presets: presets, size: fit)
} }
private struct Anchor { private struct Anchor {
@@ -868,17 +890,24 @@ private func labelSpot(_ size: CGSize, _ pts: [CGPoint], headers: [CGRect], outl
return area(box, c) return area(box, c)
} }
func overlap(_ c: CGRect) -> CGFloat { func overlap(_ c: CGRect) -> CGFloat {
let inside = c.intersection(bounds) var covered = headers.reduce(0) { $0 + area($1, c) }
let outside = c.width * c.height - (inside.isNull ? 0 : inside.width * inside.height)
var covered = headers.reduce(outside) { $0 + area($1, c) }
covered = outlines.reduce(covered) { $0 + straddles($1, c) } covered = outlines.reduce(covered) { $0 + straddles($1, c) }
covered = labels.reduce(covered) { $0 + area($1, c, 12) } covered = labels.reduce(covered) { $0 + area($1, c, 12) }
return covered * 100 + lines.reduce(0) { $0 + area($1, c) } return covered * 100 + lines.reduce(0) { $0 + area($1, c) }
} }
func outside(_ c: CGRect) -> CGFloat {
let inside = c.intersection(bounds)
return c.width * c.height - (inside.isNull ? 0 : inside.width * inside.height)
}
return candidates.min { a, b in return candidates.min { a, b in
let oa = overlap(a.rect) let oa = overlap(a.rect)
let ob = overlap(b.rect) let ob = overlap(b.rect)
return oa != ob ? oa < ob : a.order < b.order if oa != ob {
return oa < ob
}
let xa = outside(a.rect)
let xb = outside(b.rect)
return xa != xb ? xa < xb : a.order < b.order
}!.rect }!.rect
} }
@@ -967,6 +996,15 @@ private struct Router {
mark(CGRect(x: r.maxX - cell, y: r.minY, width: cell * 3, height: r.height), &level) mark(CGRect(x: r.maxX - cell, y: r.minY, width: cell * 3, height: r.height), &level)
} }
} }
let toAncestors = Set(lineage(e.to))
if let outer = lineage(e.from).first(where: toAncestors.contains) {
let c = cells(boxes[index[outer]!].rect)
for y in 0..<rows {
for x in 0..<cols where x <= c.x0 || x >= c.x1 || y <= c.y0 || y >= c.y1 {
grid[y * cols + x] = true
}
}
}
return (grid, upright, level) return (grid, upright, level)
} }
@@ -1314,7 +1352,7 @@ struct BoxCanvas: View {
ctx.opacity = 0.3 ctx.opacity = 0.3
} }
let path = Path(roundedRect: b.rect, cornerRadius: corner(b)) let path = Path(roundedRect: b.rect, cornerRadius: corner(b))
let lit = focus?.boxes.contains(b.node.id) == true let lit = focus?.outlined.contains(b.node.id) == true
let stroke = lit ? Palette.accent : b.kind == .ghost ? Palette.muted : Palette.border let stroke = lit ? Palette.accent : b.kind == .ghost ? Palette.muted : Palette.border
if b.kind == .box { if b.kind == .box {
ctx.fill(path, with: .color(fill(b))) ctx.fill(path, with: .color(fill(b)))
@@ -1515,25 +1553,23 @@ struct DiagramViewer: View {
} }
} }
private struct TappableCanvas: View { private struct SizedCanvas: View {
let layout: BoxLayout let layout: BoxLayout
let focus: BoxFocus? let focus: BoxFocus?
let tap: (String?) -> Void
let open: (String) -> Void
var body: some View { var body: some View {
BoxCanvas(layout: layout, focus: focus) BoxCanvas(layout: layout, focus: focus)
.frame(width: layout.size.width, height: layout.size.height) .frame(width: layout.size.width, height: layout.size.height)
.contentShape(Rectangle()) .diagramMenu(layout)
.onTapGesture(coordinateSpace: .local) { p in }
}
private func click(_ layout: BoxLayout, _ p: CGPoint, tap: (String?) -> Void, open: (String) -> Void) {
if let id = layout.opener(at: p) { if let id = layout.opener(at: p) {
open(id) open(id)
} else { } else {
tap(layout.hit(p)) tap(layout.hit(p))
} }
}
.diagramMenu(layout)
}
} }
extension View { extension View {
@@ -1584,6 +1620,26 @@ private struct ZoomingCanvas: NSViewRepresentable {
let tap: (String?) -> Void let tap: (String?) -> Void
let open: (String) -> Void let open: (String) -> Void
@MainActor
final class Coordinator: NSObject {
var canvas: ZoomingCanvas
init(_ canvas: ZoomingCanvas) {
self.canvas = canvas
}
@objc func clicked(_ g: NSClickGestureRecognizer) {
guard let view = g.view else {
return
}
click(canvas.layout, g.location(in: view), tap: canvas.tap, open: canvas.open)
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeNSView(context: Context) -> FittingScrollView { func makeNSView(context: Context) -> FittingScrollView {
let scroll = FittingScrollView() let scroll = FittingScrollView()
scroll.contentView = CenteringClipView() scroll.contentView = CenteringClipView()
@@ -1594,17 +1650,34 @@ private struct ZoomingCanvas: NSViewRepresentable {
scroll.allowsMagnification = true scroll.allowsMagnification = true
scroll.minMagnification = 0.25 scroll.minMagnification = 0.25
scroll.maxMagnification = 6 scroll.maxMagnification = 6
let host = NSHostingView(rootView: TappableCanvas(layout: layout, focus: focus, tap: tap, open: open)) let host = NSHostingView(rootView: SizedCanvas(layout: layout, focus: focus))
host.frame = CGRect(origin: .zero, size: layout.size) host.frame = CGRect(origin: .zero, size: layout.size)
host.addGestureRecognizer(NSClickGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.clicked)))
scroll.documentView = host scroll.documentView = host
return scroll return scroll
} }
func updateNSView(_ scroll: FittingScrollView, context: Context) { func updateNSView(_ scroll: FittingScrollView, context: Context) {
(scroll.documentView as? NSHostingView<TappableCanvas>)?.rootView = TappableCanvas(layout: layout, focus: focus, tap: tap, open: open) context.coordinator.canvas = self
(scroll.documentView as? NSHostingView<SizedCanvas>)?.rootView = SizedCanvas(layout: layout, focus: focus)
} }
} }
#else #else
private struct TappableCanvas: View {
let layout: BoxLayout
let focus: BoxFocus?
let tap: (String?) -> Void
let open: (String) -> Void
var body: some View {
SizedCanvas(layout: layout, focus: focus)
.contentShape(Rectangle())
.onTapGesture(coordinateSpace: .local) { p in
click(layout, p, tap: tap, open: open)
}
}
}
private final class FittingScrollView: UIScrollView, UIScrollViewDelegate { private final class FittingScrollView: UIScrollView, UIScrollViewDelegate {
let host: UIHostingController<TappableCanvas> let host: UIHostingController<TappableCanvas>
private var fitted = false private var fitted = false
+7 -5
View File
@@ -149,7 +149,8 @@ func diagramHTML(_ diagram: DiagramLayout) -> String {
html,body{margin:0;height:100%;background:var(--bg);color:var(--text);font-family:Atkinson,-apple-system,sans-serif} html,body{margin:0;height:100%;background:var(--bg);color:var(--text);font-family:Atkinson,-apple-system,sans-serif}
body{display:flex;flex-direction:column} body{display:flex;flex-direction:column}
nav{display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:12px 12px 0;font:700 13px Atkinson,-apple-system,sans-serif} nav{display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:12px 12px 0;font:700 13px Atkinson,-apple-system,sans-serif}
nav:empty{display:none} #trail:empty,#bar[hidden]{display:none}
#bar{min-height:27px}
nav .sep{color:var(--muted)} nav .sep{color:var(--muted)}
nav .grow{flex:1} nav .grow{flex:1}
nav a{color:var(--accent);cursor:pointer} nav a{color:var(--accent);cursor:pointer}
@@ -197,16 +198,17 @@ func diagramHTML(_ diagram: DiagramLayout) -> String {
t.append(l===level?el("span","",D.levels[l].title):el("a","",D.levels[l].title,()=>go(l,ls[i+1])))})} t.append(l===level?el("span","",D.levels[l].title):el("a","",D.levels[l].title,()=>go(l,ls[i+1])))})}
render();fit()} render();fit()}
function render(){ function render(){
let boxes=null,edges=new Set(); let boxes=null,edges=new Set(),outlined=new Set();
if(picked!=null){boxes=new Set([picked]);L.edges.forEach((e,i)=>{if(e.from==picked||e.to==picked){edges.add(i);boxes.add(e.from);boxes.add(e.to)}})} if(picked!=null){boxes=new Set([picked]);outlined=new Set([picked]);L.edges.forEach((e,i)=>{if(e.from==picked||e.to==picked){edges.add(i);boxes.add(e.from);boxes.add(e.to)}})}
else if(preset!=null){const ids=L.presets[preset].ids;boxes=new Set(ids);L.edges.forEach((e,i)=>{if(boxes.has(e.from)&&boxes.has(e.to))edges.add(i)})} else if(preset!=null){const ids=L.presets[preset].ids;boxes=new Set(ids);outlined=boxes;L.edges.forEach((e,i)=>{if(boxes.has(e.from)&&boxes.has(e.to))edges.add(i)})}
const g=shown(); const g=shown();
g.querySelectorAll(".box").forEach(g=>{const id=g.dataset.id; g.querySelectorAll(".box").forEach(g=>{const id=g.dataset.id;
g.classList.toggle("lit",!!boxes&&boxes.has(id)); g.classList.toggle("lit",outlined.has(id));
const dim=!!boxes&&!boxes.has(id)&&!anc(id).some(a=>boxes.has(a))&&![...boxes].some(b=>anc(b).includes(id)); const dim=!!boxes&&!boxes.has(id)&&!anc(id).some(a=>boxes.has(a))&&![...boxes].some(b=>anc(b).includes(id));
g.classList.toggle("dim",dim)}); g.classList.toggle("dim",dim)});
g.querySelectorAll(".edge").forEach(g=>{const i=+g.dataset.i;g.classList.toggle("lit",edges.has(i));g.classList.toggle("faint",!!boxes&&!edges.has(i))}); g.querySelectorAll(".edge").forEach(g=>{const i=+g.dataset.i;g.classList.toggle("lit",edges.has(i));g.classList.toggle("faint",!!boxes&&!edges.has(i))});
const bar=document.getElementById("bar");bar.replaceChildren(); const bar=document.getElementById("bar");bar.replaceChildren();
bar.hidden=!L.presets.length&&!L.boxes.some(b=>b.kind==="box"&&b.opens);
L.presets.forEach((p,i)=>{const b=el("button",i===preset?"on":"",p.name,()=>{picked=null;preset=preset===i?null:i;render()});bar.append(b)}); L.presets.forEach((p,i)=>{const b=el("button",i===preset?"on":"",p.name,()=>{picked=null;preset=preset===i?null:i;render()});bar.append(b)});
const opening=picked!=null&&L.boxes.find(b=>b.id===picked&&b.kind==="box"&&b.opens); const opening=picked!=null&&L.boxes.find(b=>b.id===picked&&b.kind==="box"&&b.opens);
if(opening){bar.append(el("span","grow",""));bar.append(el("button","on","Open "+D.levels[picked].title+" ›",()=>go(picked,null)))} if(opening){bar.append(el("span","grow",""));bar.append(el("button","on","Open "+D.levels[picked].title+" ›",()=>go(picked,null)))}