@nelsonlongxiang/dsh-open-a2a-net 0.6.25 → 0.6.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -57,8 +57,15 @@ export function createPlanningMenus(d: MenusDeps): PlanningMenus {
57
57
 
58
58
  function startCreateFromSelection(): void {
59
59
  if (dialog !== null) return
60
- // Peer cards are not joinable sessions: they never enter 建队 ids.
61
- const ids = selectedByY().filter(id => model.getNode(id)?.remote !== true)
60
+ // gh#20 W-B: a mixed local+remote selection composes a cross-host team —
61
+ // peers ride their zone/id8 handle at the server boundary. A peer
62
+ // without a handle yet (no remote team rows) cannot join; drop it and
63
+ // let the ≥2 floor judge the rest.
64
+ const ids = selectedByY().filter(id => {
65
+ const node = model.getNode(id)
66
+ if (node === undefined) return false
67
+ return node.remote !== true || (node.remoteHandle !== undefined && node.remoteHandle !== '')
68
+ })
62
69
  if (ids.length < 2) { d.notice('info', '框选至少 2 个会话节点'); return }
63
70
  openNameDialog(ids)
64
71
  }
@@ -313,9 +320,14 @@ export function createPlanningMenus(d: MenusDeps): PlanningMenus {
313
320
  /** Context menu from a real contextmenu event or the keyboard equivalent. */
314
321
  function contextMenuAt(x: number, y: number, target: Element | null): void {
315
322
  if (dialog !== null) return
316
- // Peer endpoints are read-only entries: no roster menu for them.
323
+ // gh#20 W-B: a peer WITH a remote handle gets the roster menu (加入团队
324
+ // composes a cross-host team through the handle boundary); a peer
325
+ // WITHOUT one stays a read-only entry (nothing the server would accept).
317
326
  const probeNode = target?.closest<HTMLElement>('.p-node') ?? null
318
- if (probeNode !== null && model.getNode(probeNode.dataset.id ?? '')?.remote === true) return
327
+ if (probeNode !== null) {
328
+ const probe = model.getNode(probeNode.dataset.id ?? '')
329
+ if (probe?.remote === true && (probe.remoteHandle === undefined || probe.remoteHandle === '')) return
330
+ }
319
331
  const nodeEl = probeNode
320
332
  const headEl = target?.closest<HTMLElement>('.p-frame-head') ?? null
321
333
  if (nodeEl !== null) {
@@ -24,6 +24,9 @@ export interface PlanningInput {
24
24
  inFlight?: ReadonlyArray<InFlightRow>
25
25
  /** Remote team directory rows (PR D v1 ruling): grouped under peer cards. */
26
26
  remoteTeams?: ReadonlyArray<RemoteTeamRow>
27
+ /** gh#41 fleet planning: each tracked peer's published canvas org —
28
+ * rendered as REMOTE team frames (read + cross-host regulation). */
29
+ peerOrg?: ReadonlyArray<{ url: string; org: ReadonlyArray<{ name: string; team: string; members: readonly string[] }> }>
27
30
  }
28
31
 
29
32
  /** Screen-width class a viewport is remembered under (window width, not board width — collapsing the rail must not switch buckets). */
@@ -383,22 +383,41 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
383
383
  el.classList.toggle('selected', model.isSelected(n.id))
384
384
  el.setAttribute('aria-selected', String(model.isSelected(n.id)))
385
385
  const host = n.id.slice('peer-'.length)
386
- const nmText = n.remote === true ? host : displayName(n)
386
+ // gh#38: member cards show their display NAME as the title (the whole
387
+ // point is picking a person); peer HOST cards keep the host spelling.
388
+ const nmText = n.id.startsWith('member-')
389
+ ? (n.name !== undefined && n.name !== '' ? n.name : n.team)
390
+ : n.remote === true ? host : displayName(n)
387
391
  const nmEl = el.querySelector<HTMLElement>('.nm-text')!
388
392
  if (nmEl.textContent !== nmText) nmEl.textContent = nmText
389
393
  const subEl = el.querySelector<HTMLElement>('.sub')!
390
394
  const subBase = n.team + (n.name !== undefined && n.name !== '' ? ' · ' + n.name : '')
391
395
  // When the title fell back to the team label the sub line would repeat
392
396
  // it verbatim; collapse to the cross-team suffix alone in that case.
393
- const subText = n.remote === true
394
- ? remoteSubText(host)
395
- : (subBase === nmText ? '' : subBase)
396
- + (n.memberships.length > 1 ? ` · 跨队×${n.memberships.length}` : '')
397
- + (n.teams !== undefined && n.teams.length > 0 ? ` · 网络队×${n.teams.length}` : '')
397
+ const subText = n.id.startsWith('member-')
398
+ ? n.team
399
+ : n.remote === true
400
+ ? remoteSubText(host)
401
+ : (subBase === nmText ? '' : subBase)
402
+ + (n.memberships.length > 1 ? ` · 跨队×${n.memberships.length}` : '')
403
+ + (n.teams !== undefined && n.teams.length > 0 ? ` · 网络队×${n.teams.length}` : '')
398
404
  if (subEl.textContent !== subText) subEl.textContent = subText
399
405
  const prioEl = el.querySelector<HTMLElement>('.prio')!
400
- const prioText = n.remote === true ? 'peer' : (n.memberships[0] !== undefined ? `P${n.memberships[0]!.index}` : '')
401
- if (prioEl.textContent !== prioText) prioEl.textContent = prioText
406
+ if (n.remote === true) {
407
+ // gh#20 W-A: the peer badge — link class colors the chip (linked
408
+ // green / stranger gray), the verified tick/cross rides after it.
409
+ // An absent link field (older host) renders the neutral 'peer'.
410
+ const info = peerInfoByHost.get(host)
411
+ const cls = info?.link === 'linked' ? 'lnk-linked' : info?.link === 'stranger' ? 'lnk-stranger' : ''
412
+ if (prioEl.className !== `prio mono ${cls}`.trim()) prioEl.className = `prio mono ${cls}`.trim()
413
+ const mark = info === undefined ? '' : info.verified === true ? '✓' : '✗'
414
+ const badge = (info?.link ?? 'peer') + mark
415
+ if (prioEl.textContent !== badge) prioEl.textContent = badge
416
+ } else {
417
+ if (prioEl.className !== 'prio mono') prioEl.className = 'prio mono'
418
+ const prioText = n.memberships[0] !== undefined ? `P${n.memberships[0]!.index}` : ''
419
+ if (prioEl.textContent !== prioText) prioEl.textContent = prioText
420
+ }
402
421
  }
403
422
  for (const [id, el] of [...nodeEls]) {
404
423
  if (!seen.has(id)) { el.remove(); nodeEls.delete(id) }
@@ -433,6 +452,8 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
433
452
  let lastPeers: ReadonlyArray<PeerRow> = []
434
453
  let lastRemoteTeams: ReadonlyMap<string, RemoteTeamRow[]> = new Map()
435
454
  const peerScoreByText = new Map<string, number | undefined>()
455
+ /** gh#20 W-A: per-host link/verified info for the peer badges. */
456
+ const peerInfoByHost = new Map<string, { link?: 'linked' | 'stranger'; verified?: boolean }>()
436
457
  let lastInFlight: ReadonlyArray<InFlightRow> = []
437
458
 
438
459
  function renderFederation(): void {
@@ -524,8 +545,41 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
524
545
  if (n <= 0) pendingTeams.delete(team)
525
546
  else pendingTeams.set(team, n)
526
547
  }
548
+ // gh#41: remote-frame registry (frame name → peer URL), refreshed by
549
+ // reconcile and read by emitAction's serverAction to fork remote drops.
550
+ let remoteFramesRef = new Map<string, string>()
527
551
 
528
552
  function emitAction(a: CanvasAction): void {
553
+ // gh#20 W-B: the SERVER sees zone/id8 remote handles where the model
554
+ // keeps its peer-<host> node id — the canvas member contract is
555
+ // session-id-or-remote-ref. Translation happens here, one boundary;
556
+ // the model action keeps board-local ids so rendering and undo stay
557
+ // node-keyed. A peer without a known handle (no remote team rows yet)
558
+ // is filtered out — the server would refuse the raw peer- id.
559
+ function serverAction(act: CanvasAction): CanvasAction {
560
+ const mapIds = (ids: readonly string[]): string[] => {
561
+ const out: string[] = []
562
+ for (const id of ids) {
563
+ // Both remote card families translate to their zone/id8 handle
564
+ // (gh#38: member- cards join teams like peer- cards do).
565
+ if (!id.startsWith('peer-') && !id.startsWith('member-')) { out.push(id); continue }
566
+ const handle = model.getNode(id)?.remoteHandle
567
+ if (handle !== undefined && handle !== '') out.push(handle)
568
+ }
569
+ return out
570
+ }
571
+ if (act.type === 'add-member' || act.type === 'remove-member' || act.type === 'create-team') {
572
+ const ids = mapIds(act.ids)
573
+ // gh#41: a drop into a REMOTE frame carries the peer URL — main.ts
574
+ // forks it to the fleet relay instead of the local canvas control.
575
+ const fleetPeer = act.type !== 'create-team' ? remoteFramesRef.get(act.team) : undefined
576
+ return ids.length > 0 || fleetPeer !== undefined
577
+ ? { ...act, ids, ...(fleetPeer !== undefined ? { fleetPeer } : {}) }
578
+ : act
579
+ }
580
+ return act
581
+ }
582
+
529
583
  // Optimistic seats: a write may reference a session the teamed gate
530
584
  // keeps OFF the model (no canvas card yet). The write actions read and
531
585
  // write memberships through the node table, so materialize those ids
@@ -534,7 +588,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
534
588
  if (a.type === 'add-member' || a.type === 'remove-member' || a.type === 'reorder' || a.type === 'create-team') {
535
589
  const ids = a.type === 'reorder' ? a.ops.map(op => op.id) : [...a.ids]
536
590
  for (const id of ids) {
537
- if (id === '' || id.startsWith('peer-')) continue
591
+ if (id === '' || id.startsWith('peer-') || id.startsWith('member-')) continue
538
592
  if (model.getNode(id) !== undefined) continue
539
593
  const s = lastSessions.find(row => row.id === id)
540
594
  if (s === undefined) continue
@@ -551,7 +605,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
551
605
  const team = actionTeam(a)
552
606
  pendingAdd(team)
553
607
  render()
554
- void deps.onCanvasAction(a).then(
608
+ void deps.onCanvasAction(serverAction(a)).then(
555
609
  ok => {
556
610
  pendingRelease(team)
557
611
  if (!ok) undo() // host refused: roll this action's optimistic delta back
@@ -627,10 +681,18 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
627
681
  const p = localXY(ev)
628
682
  const w = screenToWorld(vp, p.x, p.y)
629
683
  const f = innermostFrameAt(new Map(model.allFrames()), w)
630
- // 契约不变量 1:成员必须 joined session——对等卡永远不进名单写面。
631
- const localIds = gesture.ids.filter(id => model.getNode(id)?.remote !== true)
684
+ // gh#20 W-B: peer cards join canvas teams too — a mixed local+remote
685
+ // drop composes a cross-host team; the server boundary translates
686
+ // peer ids to zone/id8 handles (emitAction's serverAction). Reorder
687
+ // stays local-only (roster priority is a local-session fact).
688
+ const memberIds = gesture.ids.filter(id => {
689
+ const node = model.getNode(id)
690
+ if (node === undefined) return false
691
+ if (node.remote !== true) return true
692
+ return node.remoteHandle !== undefined && node.remoteHandle !== ''
693
+ })
632
694
  if (f !== undefined) {
633
- const fresh = localIds.filter(id => !(model.getNode(id)?.memberships.some(m => m.team === f)))
695
+ const fresh = memberIds.filter(id => !(model.getNode(id)?.memberships.some(m => m.team === f)))
634
696
  if (fresh.length > 0) {
635
697
  emitAction({ type: 'add-member', team: f, ids: fresh }) // 入队:不自动离原队(多对多)
636
698
  return
@@ -772,7 +834,12 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
772
834
  // Drop-target highlight: the innermost frame under the pointer, but
773
835
  // only when the drop would change something (some id not yet member).
774
836
  const f = innermostFrameAt(new Map(model.allFrames()), w)
775
- const joinables = gesture.ids.filter(id => model.getNode(id)?.remote !== true)
837
+ // W-B: peers with a remote handle are droppable members too.
838
+ const joinables = gesture.ids.filter(id => {
839
+ const node = model.getNode(id)
840
+ if (node === undefined) return false
841
+ return node.remote !== true || (node.remoteHandle !== undefined && node.remoteHandle !== '')
842
+ })
776
843
  const relevant = f !== undefined && joinables.length > 0
777
844
  for (const [name, el] of frameEls) el.classList.toggle('drop-target', relevant && name === f)
778
845
  render()
@@ -1043,6 +1110,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
1043
1110
  let lastTeams: ReadonlyArray<PlanningTeam> = []
1044
1111
 
1045
1112
  function reconcile(input: PlanningInput): void {
1113
+ // gh#41: remote-frame registry shared with serverAction (drop fork).
1046
1114
  lastTeams = input.teams
1047
1115
  lastPeers = input.peers ?? []
1048
1116
  lastInFlight = input.inFlight ?? []
@@ -1127,7 +1195,8 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
1127
1195
  }
1128
1196
  }
1129
1197
  for (const id of model.nodeIds()) {
1130
- if (!seen.has(id) && !id.startsWith('peer-')) model.removeNode(id)
1198
+ // peer- and member- cards are reconciled by their own blocks below.
1199
+ if (!seen.has(id) && !id.startsWith('peer-') && !id.startsWith('member-')) model.removeNode(id)
1131
1200
  }
1132
1201
 
1133
1202
  // Peer nodes (v1 ruling): same model pipeline as sessions, `remote` flag
@@ -1139,6 +1208,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
1139
1208
  peerScoreByText.clear()
1140
1209
  for (const p of input.peers ?? []) {
1141
1210
  peerScoreByText.set(peerHostOf(p.url), p.score)
1211
+ peerInfoByHost.set(peerHostOf(p.url), { link: p.link, verified: p.verified })
1142
1212
  }
1143
1213
  if (input.peers !== undefined) {
1144
1214
  const localBounds = model.contentBounds()
@@ -1153,9 +1223,15 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
1153
1223
  : saved !== undefined
1154
1224
  ? { x: saved.x, y: saved.y }
1155
1225
  : { x: placements[i]?.x ?? 200, y: placements[i]?.y ?? 0 }
1226
+ // gh#20 W-B: the peer's server-side member form — its first remote
1227
+ // team row's zone/id8 handle (canvas remote refs are exact handles
1228
+ // per the D1 ruling). Absent until the first remote team row lands.
1229
+ const host = peerHostOf(p.url)
1230
+ const handle = lastRemoteTeams.get(host)?.[0]?.team
1156
1231
  model.upsertNode({
1157
1232
  id, x: base.x, y: base.y,
1158
1233
  label: p.url, team: '', name: p.url, peerUrl: p.url,
1234
+ remoteHandle: handle,
1159
1235
  live: true, remote: true, memberships: [],
1160
1236
  })
1161
1237
  model.upsertScore(id, p.score)
@@ -1164,6 +1240,118 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
1164
1240
  for (const id of model.nodeIds()) {
1165
1241
  if (id.startsWith('peer-') && !peerSeen.has(id)) model.removeNode(id)
1166
1242
  }
1243
+ // gh#38 (owner ruling 「统一到画布里面拖拽」): every remote session-team
1244
+ // HANDLE renders as its own draggable card, stacked beside its host's
1245
+ // peer card — pulling a specific remote member into a local team is a
1246
+ // plain drag (no picker, no hand-typed handle). Host cards stay for
1247
+ // federation context; the member cards carry the actual join gesture.
1248
+ const memberSeen = new Set<string>()
1249
+ for (const [host, rows] of lastRemoteTeams) {
1250
+ const hostNode = model.allNodes().find(n => n.remote === true && peerHostOf(n.peerUrl ?? '') === host && !n.id.startsWith('member-'))
1251
+ const baseX = (hostNode?.x ?? 400) + 190
1252
+ let y = hostNode?.y ?? 0
1253
+ for (const row of rows) {
1254
+ if (!row.team.includes('/')) continue
1255
+ const id = `member-${row.team.replace(/[^a-z0-9-]/gi, '-')}`
1256
+ if (memberSeen.has(id)) continue
1257
+ memberSeen.add(id)
1258
+ const saved = layoutDoc?.nodes[id]
1259
+ const existing = model.getNode(id)
1260
+ const pos = existing !== undefined ? { x: existing.x, y: existing.y } : saved !== undefined ? { x: saved.x, y: saved.y } : { x: baseX, y }
1261
+ y += 92
1262
+ model.upsertNode({
1263
+ id, x: pos.x, y: pos.y,
1264
+ label: row.team, team: row.team, name: row.name !== undefined && row.name !== '' ? row.name : row.team,
1265
+ remoteHandle: row.team,
1266
+ live: true, remote: true, memberships: [],
1267
+ })
1268
+ }
1269
+ }
1270
+ for (const id of model.nodeIds()) {
1271
+ if (id.startsWith('member-') && !memberSeen.has(id)) model.removeNode(id)
1272
+ }
1273
+ // gh#42 (owner report: dragged a remote peer into base, no edge): team
1274
+ // edges derive from NODE memberships, but remote cards carried hardcoded
1275
+ // empty memberships — the server truth (base contains dsh/d659a8f9)
1276
+ // never re-attached after a poll or reload. Re-attach here: every local
1277
+ // team's remote member handles resolve onto their cards (member- card
1278
+ // preferred, the peer HOST card as fallback), in-flight teams excluded.
1279
+ const handleToNode = new Map<string, string>()
1280
+ for (const n of model.allNodes()) {
1281
+ if (n.remote !== true || n.remoteHandle === undefined || n.remoteHandle === '') continue
1282
+ if (!handleToNode.has(n.remoteHandle) || n.id.startsWith('member-')) handleToNode.set(n.remoteHandle, n.id)
1283
+ }
1284
+ for (const team of input.teams) {
1285
+ if (pendingTeams.has(team.name)) continue
1286
+ const resolved: string[] = []
1287
+ for (const m of team.members) {
1288
+ const id = handleToNode.get(m.id) ?? m.id
1289
+ if (model.getNode(id) !== undefined) resolved.push(id)
1290
+ }
1291
+ const current = [...model.teamMemberIds(team.name)].sort().join('|')
1292
+ const next = [...resolved].sort().join('|')
1293
+ if (current !== next) model.setTeamMembers(team.name, resolved)
1294
+ }
1295
+ // gh#41 fleet planning: the peers' published canvas org renders as
1296
+ // REMOTE team frames — the owner reads the other side's grouping from
1297
+ // ANY node and regulates it in place. Members attach as board
1298
+ // memberships; drops into a remote frame fork to the host relay (see
1299
+ // serverAction: the action carries fleetPeer, and main.ts routes it to
1300
+ // /__dsh_a2a/fleet-canvas-op instead of the local canvas control).
1301
+ const remoteFrames = new Map<string, string>()
1302
+ const prevRemoteFrames = remoteFramesRef
1303
+ remoteFramesRef = remoteFrames
1304
+ if (input.peerOrg !== undefined) {
1305
+ let slot = 0
1306
+ const localNames = new Set(input.teams.map(t => t.name))
1307
+ for (const row of input.peerOrg) {
1308
+ // The peer's zone comes from its directory rows ('dsh/xxxx' →
1309
+ // 'dsh'); the org's members are stored verbatim — LOCAL members as
1310
+ // session ids, remote members as zone/id8 handles. Both must
1311
+ // resolve onto that host's session cards: id-form members become
1312
+ // `member-<zone>-<id8>` (id8 = strip 'session-' prefix, first 8
1313
+ // chars — the repo's id8 derivation), handle members sanitize
1314
+ // directly. The old sanitize-only pass misresolved every local
1315
+ // member (owner: 数据对不上).
1316
+ const peerZone = input.remoteTeams?.find(r => r.via === row.url)?.team.split('/')[0] ?? 'dsh'
1317
+ const id8of = (id: string): string => id.replace(/^session-/, '').slice(0, 8)
1318
+ // The server now canonicalizes org members to handles (gh#41 wire
1319
+ // form); the id-form branch stays as the defensive path for peers
1320
+ // on an older build that still publish raw session ids.
1321
+ const memberBoardId = (m: string): string =>
1322
+ m.includes('/') ? `member-${m.replace(/[^a-z0-9-]/gi, '-')}` : `member-${peerZone}-${id8of(m)}`
1323
+ for (const team of row.org) {
1324
+ if (team.name === '' || localNames.has(team.name)) continue
1325
+ const memberIds = team.members.map(memberBoardId).filter(id => model.getNode(id) !== undefined)
1326
+ const saved = layoutDoc?.frames?.[team.name]
1327
+ // gh#44 (owner: frame position/size never sticks): the LOCAL-team
1328
+ // path keeps an existing frame's live rect ("live rect wins" — a
1329
+ // poll must not snap a frame the user just dragged, the save loop
1330
+ // persists it a beat later). Remote frames re-set unconditionally
1331
+ // every poll, so every drag snapped back before the save landed.
1332
+ // Same contract here: only place the frame when it is absent.
1333
+ if (model.getFrame(team.name) === undefined) {
1334
+ model.setFrame(team.name, saved !== undefined ? { x: saved.x, y: saved.y, w: saved.w, h: saved.h } : { x: 200 + (slot % 4) * 280, y: 940 + Math.floor(slot / 4) * 160, w: 250, h: 130 })
1335
+ }
1336
+ model.setTeamMembers(team.name, memberIds)
1337
+ remoteFrames.set(team.name, row.url)
1338
+ slot += 1
1339
+ }
1340
+ }
1341
+ }
1342
+ for (const [name, url] of prevRemoteFrames) {
1343
+ if (!remoteFrames.has(name)) {
1344
+ model.removeFrame(name)
1345
+ void url
1346
+ }
1347
+ }
1348
+ // gh#20 W-B: the server echoes cross-host members as zone/id8 handles —
1349
+ // map each handle back onto the peer node that carries it, so the
1350
+ // reconcile keeps remote memberships instead of dropping them.
1351
+ const handleToPeer = new Map<string, string>()
1352
+ for (const n of model.allNodes()) {
1353
+ if (n.remote === true && n.remoteHandle !== undefined && n.remoteHandle !== '') handleToPeer.set(n.remoteHandle, n.id)
1354
+ }
1167
1355
  const homes = homeTeams(input.teams)
1168
1356
  for (const team of input.teams) {
1169
1357
  if (model.getFrame(team.name) !== undefined) continue // live rect wins
@@ -1177,17 +1365,19 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
1177
1365
  const rects: LayoutRect[] = []
1178
1366
  const all: LayoutRect[] = []
1179
1367
  for (const m of team.members) {
1180
- const n = model.getNode(m.id)
1368
+ const n = model.getNode(handleToPeer.get(m.id) ?? m.id)
1181
1369
  if (n === undefined) continue
1182
1370
  all.push(nodeRect(n))
1183
- if (homes.get(m.id) === team.name) rects.push(nodeRect(n))
1371
+ if (homes.get(n.id) === team.name) rects.push(nodeRect(n))
1184
1372
  }
1185
1373
  const wrap = rects.length > 0 ? rects : all
1186
1374
  if (wrap.length > 0) model.setFrame(team.name, deriveInitialFrame(wrap))
1187
1375
  }
1188
1376
  for (const [name] of model.allFrames()) {
1189
1377
  if (pendingTeams.has(name)) continue // an in-flight create/remove owns this name
1190
- if (!input.teams.some(t => t.name === name)) model.removeFrame(name)
1378
+ // gh#41: remote org frames are reconciled by the peerOrg block above —
1379
+ // the local-team cleanup must not remove them.
1380
+ if (!input.teams.some(t => t.name === name) && !remoteFrames.has(name)) model.removeFrame(name)
1191
1381
  }
1192
1382
  // An open node panel follows the poll: facts (joins, leaves, team
1193
1383
  // declarations, peer arrivals) land within one cadence.
@@ -176,6 +176,10 @@
176
176
  #dsh-plan .p-node.remote{border-left-color:var(--federal); padding-right:46px}
177
177
  #dsh-plan .p-node.remote .dot{background:var(--federal)}
178
178
  #dsh-plan .p-node.remote .prio{color:var(--ink)}
179
+ /* gh#20 W-A link badge: linked green / stranger gray (fleet reserved);
180
+ the ✓/✗ verified mark rides inside the same chip. */
181
+ #dsh-plan .p-node .prio.lnk-linked{color:var(--live, #2e9e5b); border-color:var(--live, #2e9e5b)}
182
+ #dsh-plan .p-node .prio.lnk-stranger{color:var(--ink-muted); border-color:var(--card-line)}
179
183
  #dsh-plan .e-activity{stroke:var(--accent); stroke-width:2; stroke-dasharray:8 6; stroke-opacity:.9}
180
184
  #dsh-plan .e-federal{stroke:var(--federal); stroke-width:1.5; stroke-dasharray:6 5; stroke-opacity:.5}
181
185
  #dsh-plan .edge-label{font-size:10.5px; fill:var(--ink-muted)}