@nelsonlongxiang/dsh-open-a2a-net 0.6.28 → 0.6.30
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.
- package/README.md +87 -14
- package/README_zh.md +83 -7
- package/assets/nexusDist/assets/index-C_x3iAth.js +9 -0
- package/assets/nexusDist/assets/index-Dz8N80tI.css +1 -0
- package/assets/nexusDist/index.html +2 -2
- package/cli/a2a.mjs +0 -0
- package/cordis.patch.yml +21 -21
- package/lib/canvas-store.js +21 -1
- package/lib/client.js +64 -64
- package/lib/client.js.map +1 -1
- package/lib/faces/admin.js +755 -0
- package/lib/faces/canvas-notify.js +138 -0
- package/lib/faces/wire.js +669 -0
- package/lib/flood-guard.js +106 -0
- package/lib/index.js +200 -1392
- package/lib/recursion-guard.js +78 -0
- package/lib/runtime.js +1 -0
- package/lib/skill/SKILL.md +35 -9
- package/lib/skill/references/cli.md +24 -7
- package/lib/skill/references/collaboration.md +355 -0
- package/lib/skill/references/discovery.md +5 -2
- package/lib/skill/references/pitfalls.md +2 -1
- package/lib/skill/references/routing.md +18 -8
- package/lib/skill/references/setup.md +152 -0
- package/lib/task-ledger.js +40 -4
- package/lib/types/canvas-store.d.ts +10 -1
- package/lib/types/faces/admin.d.ts +133 -0
- package/lib/types/faces/canvas-notify.d.ts +74 -0
- package/lib/types/faces/wire.d.ts +171 -0
- package/lib/types/flood-guard.d.ts +29 -0
- package/lib/types/index.d.ts +11 -3
- package/lib/types/recursion-guard.d.ts +56 -0
- package/lib/types/runtime.d.ts +35 -0
- package/lib/types/task-ledger.d.ts +15 -1
- package/nexus-stage/package-lock.json +2262 -2262
- package/nexus-stage/src/canvas-ops.ts +175 -175
- package/nexus-stage/src/canvas-wire.ts +156 -156
- package/nexus-stage/src/edges.ts +72 -72
- package/nexus-stage/src/federation-overlay.ts +103 -103
- package/nexus-stage/src/federation.ts +240 -240
- package/nexus-stage/src/layout-doc.ts +143 -143
- package/nexus-stage/src/layout-wire.ts +158 -158
- package/nexus-stage/src/main.ts +5 -1
- package/nexus-stage/src/planning-card-text.ts +73 -0
- package/nexus-stage/src/planning-edges.ts +26 -0
- package/nexus-stage/src/planning-fit.ts +45 -0
- package/nexus-stage/src/planning-legend.ts +38 -0
- package/nexus-stage/src/planning-mirror.ts +31 -0
- package/nexus-stage/src/planning-overlap.ts +159 -0
- package/nexus-stage/src/planning-relayout.ts +121 -0
- package/nexus-stage/src/planning-search.ts +168 -0
- package/nexus-stage/src/planning-types.ts +1 -0
- package/nexus-stage/src/planning-undo.ts +68 -0
- package/nexus-stage/src/planning-view.ts +120 -121
- package/nexus-stage/src/planning.css +41 -8
- package/nexus-stage/src/seat.ts +35 -35
- package/nexus-stage/src/stage-name.ts +13 -13
- package/nexus-stage/src/tokens.ts +31 -31
- package/nexus-stage/src/viewport.ts +24 -1
- package/nexus-stage/src/wb-chat.ts +20 -6
- package/nexus-stage/src/wb-ledger.ts +20 -7
- package/nexus-stage/src/wb-names.ts +43 -0
- package/nexus-stage/src/workbench.css +8 -2
- package/nexus-stage/src/workbench.ts +55 -17
- package/nexus-stage/src/world.ts +13 -21
- package/package.json +216 -214
- package/assets/nexusDist/assets/index-1niOBYxp.js +0 -5
- package/assets/nexusDist/assets/index-uQf7NwjF.css +0 -1
- package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 撤销 for the board layout (frontend seat, 2026-10-29): with no status-bar
|
|
3
|
+
* hint for automatic moves (owner: 载入时避让挪了卡片,不需要在状态条提示),
|
|
4
|
+
* a wrong move must be one keystroke away from undone. Covers card drags,
|
|
5
|
+
* frame drags and resizes, keyboard nudges, 整理成员, 一键重排 and the
|
|
6
|
+
* load-time settle — positions and frame rects only. Team membership
|
|
7
|
+
* (入队 / 离队, host writes) is not undone here.
|
|
8
|
+
*
|
|
9
|
+
* capture() before an edit, commit(before) after it: only an edit that
|
|
10
|
+
* actually moved something already on the board is recorded, so a click,
|
|
11
|
+
* a pan or a marquee leaves the stack alone, and a card that arrived by
|
|
12
|
+
* poll mid-gesture is not counted as a move. revert(before) is for a
|
|
13
|
+
* gesture the OS cancelled (pointercancel): layout back, nothing recorded.
|
|
14
|
+
* @module nexus-stage/planning-undo
|
|
15
|
+
*/
|
|
16
|
+
import type { FrameGeom, WorldModel } from './world'
|
|
17
|
+
|
|
18
|
+
export interface LayoutSnap {
|
|
19
|
+
readonly nodes: ReadonlyMap<string, { readonly x: number; readonly y: number }>
|
|
20
|
+
readonly frames: ReadonlyMap<string, FrameGeom>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Undo depth (older steps fall off). */
|
|
24
|
+
export const UNDO_LIMIT = 30
|
|
25
|
+
|
|
26
|
+
export function createLayoutUndo(model: WorldModel) {
|
|
27
|
+
const stack: LayoutSnap[] = []
|
|
28
|
+
const capture = (): LayoutSnap => ({
|
|
29
|
+
nodes: new Map(model.allNodes().map(n => [n.id, { x: n.x, y: n.y }])),
|
|
30
|
+
frames: new Map(model.allFrames().map(([name, f]) => [name, { ...f }])),
|
|
31
|
+
})
|
|
32
|
+
/** True when something present both before and now has moved or changed size. */
|
|
33
|
+
const moved = (before: LayoutSnap): boolean =>
|
|
34
|
+
model.allNodes().some(n => { const b = before.nodes.get(n.id); return b !== undefined && (b.x !== n.x || b.y !== n.y) }) ||
|
|
35
|
+
model.allFrames().some(([name, f]) => { const b = before.frames.get(name); return b !== undefined && (b.x !== f.x || b.y !== f.y || b.w !== f.w || b.h !== f.h) })
|
|
36
|
+
const restore = (snap: LayoutSnap): void => {
|
|
37
|
+
for (const n of model.allNodes()) {
|
|
38
|
+
const b = snap.nodes.get(n.id)
|
|
39
|
+
if (b !== undefined && (b.x !== n.x || b.y !== n.y)) model.dragNodes([n.id], b.x - n.x, b.y - n.y)
|
|
40
|
+
}
|
|
41
|
+
for (const [name, f] of model.allFrames()) {
|
|
42
|
+
const b = snap.frames.get(name)
|
|
43
|
+
if (b !== undefined && (b.x !== f.x || b.y !== f.y || b.w !== f.w || b.h !== f.h)) model.setFrame(name, { ...b })
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
capture,
|
|
48
|
+
commit(before: LayoutSnap): void {
|
|
49
|
+
if (!moved(before)) return
|
|
50
|
+
stack.push(before)
|
|
51
|
+
if (stack.length > UNDO_LIMIT) stack.shift()
|
|
52
|
+
},
|
|
53
|
+
/** Restore the last recorded layout; false when there is nothing to undo. */
|
|
54
|
+
undo(): boolean {
|
|
55
|
+
const snap = stack.pop()
|
|
56
|
+
if (snap === undefined) return false
|
|
57
|
+
restore(snap)
|
|
58
|
+
return true
|
|
59
|
+
},
|
|
60
|
+
/** Put back `before` without recording a step (an abandoned gesture); true when something moved. */
|
|
61
|
+
revert(before: LayoutSnap): boolean {
|
|
62
|
+
if (!moved(before)) return false
|
|
63
|
+
restore(before)
|
|
64
|
+
return true
|
|
65
|
+
},
|
|
66
|
+
get depth(): number { return stack.length },
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
|
|
28
28
|
import type { LampState } from './layout-wire'
|
|
29
29
|
import { buildLayoutDoc, clampDoc, type LayoutDoc, type LayoutRect } from './layout-doc'
|
|
30
|
-
import { FRAME_HEAD_LIFT
|
|
30
|
+
import { FRAME_HEAD_LIFT } from './edges'
|
|
31
|
+
import { drawMemberEdges } from './planning-edges'
|
|
31
32
|
import { createFederationOverlay } from './federation-overlay'
|
|
32
33
|
import { displayName } from './stage-name'
|
|
33
34
|
import {
|
|
@@ -36,11 +37,19 @@ import {
|
|
|
36
37
|
type RemoteTeamRow,
|
|
37
38
|
} from './federation'
|
|
38
39
|
import { clampViewport, fitView, panBy, screenToWorld, worldTransformCss, zoomAt, type LayoutViewport } from './viewport'
|
|
40
|
+
import { boardBounds, fitBelowHud, hudBottomOf } from './planning-fit'
|
|
39
41
|
import { WorldModel, nodeRect, deriveInitialFrame, homeTeams, planSeatClear, planTeamClusters, NODE_W, NODE_H } from './world'
|
|
40
42
|
import { applyAction, actionTeam, innermostFrameAt, reorderOps, yOrderedMembers, type CanvasAction } from './canvas-ops'
|
|
41
43
|
import { FRAME_HUES } from './tokens'
|
|
42
44
|
import { createPlanningMenus } from './planning-menus'
|
|
45
|
+
import { createNodeFlyer, mountBoardSearch } from './planning-search'
|
|
46
|
+
import { applyBoardRelayout } from './planning-relayout'
|
|
47
|
+
import { mirrorRoute } from './planning-mirror'
|
|
48
|
+
import { settleBoard, tidyMembers } from './planning-overlap'
|
|
49
|
+
import { createLayoutUndo, type LayoutSnap } from './planning-undo'
|
|
43
50
|
import { createNodeList } from './planning-nodelist'
|
|
51
|
+
import { cardText } from './planning-card-text'
|
|
52
|
+
import { createLegend } from './planning-legend'
|
|
44
53
|
import { viewportBucket, type PlanningDeps, type PlanningInput, type PlanningSession, type PlanningTeam, type PlanningView, type SeamKey, type SeamPointer, type ViewportBucket } from './planning-types'
|
|
45
54
|
import './planning.css'
|
|
46
55
|
|
|
@@ -78,6 +87,9 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
78
87
|
let renderedRevision = -1
|
|
79
88
|
/** Until the first reconcile with nodes (or an adopted viewport), frame the content once. */
|
|
80
89
|
let firstFitPending = true
|
|
90
|
+
let settledOnce = false
|
|
91
|
+
const layoutUndo = createLayoutUndo(model) // 撤销 (planning-undo)
|
|
92
|
+
let undoBefore: LayoutSnap | undefined
|
|
81
93
|
let viewportAdopted = false
|
|
82
94
|
const bucket = (): ViewportBucket => viewportBucket(globalThis.innerWidth || viewW)
|
|
83
95
|
const markDirty = (): void => {
|
|
@@ -151,11 +163,15 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
151
163
|
b.addEventListener('click', onClick, signal)
|
|
152
164
|
return b
|
|
153
165
|
}
|
|
166
|
+
// Review 2026-10-27: the toolbar reads as three groups — view | arrange |
|
|
167
|
+
// network — split by separators; every action stays one click (owner orders).
|
|
168
|
+
const sep = (): HTMLElement => { const s = document.createElement('span'); s.className = 'p-sep'; s.setAttribute('role', 'separator'); s.setAttribute('aria-orientation', 'vertical'); return s }
|
|
154
169
|
toolbar.append(
|
|
155
170
|
mkButton('适配', '适配视图(快捷键 0)', () => fitToContent()),
|
|
156
171
|
mkButton('-', '缩小', () => zoomCenter(1 / 1.2)),
|
|
157
172
|
zoomLabel,
|
|
158
173
|
mkButton('+', '放大', () => zoomCenter(1.2)),
|
|
174
|
+
sep(),
|
|
159
175
|
mkButton('建队', '组成团队(G):先框选至少 2 个节点', () => menus.startCreateFromSelection()),
|
|
160
176
|
// gh#48 (owner order, review-seat spec): one-click tidy — inside every
|
|
161
177
|
// team frame, the members that belong to EXACTLY that one team re-seat
|
|
@@ -165,6 +181,14 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
165
181
|
// ride the same rule; the move persists through the ordinary layout
|
|
166
182
|
// save loop exactly like a hand drag.
|
|
167
183
|
mkButton('整理成员', '每队框内仅属一队的成员自动排齐(多队成员不动)', () => tidyTeamMembers()),
|
|
184
|
+
// Supervisor order v2 (upgrades gh#48): one-click WHOLE-BOARD relayout —
|
|
185
|
+
// the two buttons split the job (整理成员 = cards inside one frame;
|
|
186
|
+
// 一键重排 = every local frame AND its cards). Frame size derives from
|
|
187
|
+
// content (width and height both); frames tile in a row flow
|
|
188
|
+
// (constructively disjoint); multi-team members never enter the size
|
|
189
|
+
// math and never move; remote org frames and frameless nodes stay put.
|
|
190
|
+
mkButton('一键重排', '全板队框+成员重排:框尺寸随内容自适应、框位行流式平铺(远端队框与多归属成员不动)', () => relayoutBoard()),
|
|
191
|
+
mkButton('撤销', '撤销上一次布局改动:拖动、缩放、整理、重排、载入时避让(Ctrl+Z;入队离队不撤销)', () => undoLayout()),
|
|
168
192
|
)
|
|
169
193
|
// Unjoined sessions ride a toolbar chip + dropdown: the panel-slim
|
|
170
194
|
// migration made the planning view the join surface — without this, an
|
|
@@ -172,7 +196,8 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
172
196
|
const netBtn = mkButton('未入网', '未入网会话(点击选择入网)', () => menus.toggleNetMenu())
|
|
173
197
|
netBtn.className = 'p-netbtn'
|
|
174
198
|
netBtn.setAttribute('aria-haspopup', 'menu')
|
|
175
|
-
toolbar.
|
|
199
|
+
toolbar.append(sep(), netBtn)
|
|
200
|
+
const search = mountBoardSearch(toolbar, model, displayName, id => flyToNode(id))
|
|
176
201
|
// Node list (network-membership-display): the canvas shows teamed nodes
|
|
177
202
|
// only; every other session — teamless joined, unjoined, and each peer's
|
|
178
203
|
// published nodes — is managed from this list, switched by host.
|
|
@@ -218,20 +243,8 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
218
243
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); flyToFederation() }
|
|
219
244
|
}, signal)
|
|
220
245
|
|
|
221
|
-
|
|
222
|
-
legend
|
|
223
|
-
const legendRow = (dashed: boolean, text: string, color?: string): void => {
|
|
224
|
-
const row = document.createElement('span')
|
|
225
|
-
const bar = document.createElement('i')
|
|
226
|
-
if (dashed) bar.className = 'dashed'
|
|
227
|
-
if (color !== undefined) bar.style.borderTopColor = color
|
|
228
|
-
row.append(bar, document.createTextNode(text))
|
|
229
|
-
legend.appendChild(row)
|
|
230
|
-
}
|
|
231
|
-
legendRow(false, '成员边(星形,框→成员)')
|
|
232
|
-
legendRow(true, '跨队多属(虚线)')
|
|
233
|
-
legendRow(false, '活动边(inFlight 路由,瞬时)', 'var(--accent)')
|
|
234
|
-
legendRow(true, '联邦线(→peer)', 'var(--federal)')
|
|
246
|
+
// Edge key: a closed-by-default fold (planning-legend), not a fixed panel.
|
|
247
|
+
const legend = createLegend()
|
|
235
248
|
|
|
236
249
|
const noticeStack = document.createElement('div')
|
|
237
250
|
noticeStack.className = 'p-notice-stack'
|
|
@@ -269,31 +282,26 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
269
282
|
* — the same persistence path a hand drag takes.
|
|
270
283
|
*/
|
|
271
284
|
function tidyTeamMembers(): void {
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
if (i > 0 && col === 0) y += NODE_H + GAP_Y
|
|
293
|
-
const x = startX + col * (NODE_W + GAP_X)
|
|
294
|
-
model.dragNodes([n.id], x - n.x, y - n.y)
|
|
295
|
-
})
|
|
296
|
-
}
|
|
285
|
+
const before = layoutUndo.capture()
|
|
286
|
+
tidyMembers(model) // gh#48 grid (planning-overlap); then nothing may overlap
|
|
287
|
+
settleBoard(model, new Set(remoteFramesRef.keys()))
|
|
288
|
+
layoutUndo.commit(before)
|
|
289
|
+
markDirty()
|
|
290
|
+
render()
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Supervisor order v2: whole-board deterministic relayout (the planner
|
|
295
|
+
* and the model adapter live in planning-relayout.ts): LOCAL frames +
|
|
296
|
+
* sole members only — remote org frames keep gh#44 live-rect-wins,
|
|
297
|
+
* frameless nodes and multi-team members never move; the same
|
|
298
|
+
* markDirty()/render() persistence path a hand drag takes.
|
|
299
|
+
*/
|
|
300
|
+
function relayoutBoard(): void {
|
|
301
|
+
const before = layoutUndo.capture()
|
|
302
|
+
if (!applyBoardRelayout(model, new Set(remoteFramesRef.keys()))) return
|
|
303
|
+
settleBoard(model, new Set(remoteFramesRef.keys())) // vs remote frames + stray cards
|
|
304
|
+
layoutUndo.commit(before)
|
|
297
305
|
markDirty()
|
|
298
306
|
render()
|
|
299
307
|
}
|
|
@@ -304,7 +312,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
304
312
|
renderedRevision = model.revision
|
|
305
313
|
renderFrames()
|
|
306
314
|
renderNodes()
|
|
307
|
-
|
|
315
|
+
drawMemberEdges(svg, model) // planning-edges
|
|
308
316
|
}
|
|
309
317
|
renderFederation()
|
|
310
318
|
}
|
|
@@ -373,24 +381,21 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
373
381
|
}
|
|
374
382
|
const cntText = `${members} 成员` + (cross > 0 ? ` +${cross} 跨队` : '')
|
|
375
383
|
if (cnt.textContent !== cntText) cnt.textContent = cntText
|
|
376
|
-
const
|
|
384
|
+
const mirror = mirrorRoute(remoteFramesRef.get(name), pendingTeams.has(name)) // PR#96 single owner + mirror
|
|
385
|
+
const routeText0 = mirror?.text ?? routeText(name)
|
|
377
386
|
if (route.textContent !== routeText0) route.textContent = routeText0
|
|
387
|
+
route.title = mirror?.title ?? ''
|
|
388
|
+
el.classList.toggle('is-mirror', mirror !== undefined)
|
|
389
|
+
el.classList.toggle('is-syncing', mirror?.syncing === true)
|
|
390
|
+
const label = `团队框 ${name}${mirror !== undefined ? `(${mirror.text})` : ''}(拖动整组,回车菜单)`
|
|
391
|
+
const headEl = el.querySelector('.p-frame-head')!
|
|
392
|
+
if (headEl.getAttribute('aria-label') !== label) headEl.setAttribute('aria-label', label) // a rewrite re-announces
|
|
378
393
|
}
|
|
379
394
|
for (const [name, el] of [...frameEls]) {
|
|
380
395
|
if (!seen.has(name)) { el.remove(); frameEls.delete(name) }
|
|
381
396
|
}
|
|
382
397
|
}
|
|
383
398
|
|
|
384
|
-
/** Remote card sub line: score + remote team count/names behind this peer. */
|
|
385
|
-
function remoteSubText(host: string): string {
|
|
386
|
-
const rows = lastRemoteTeams.get(host) ?? []
|
|
387
|
-
const names = rows.map(r => r.name !== undefined && r.name !== '' ? r.name : r.team)
|
|
388
|
-
const head = names.slice(0, 2).join('、')
|
|
389
|
-
const more = names.length > 2 ? ` +${names.length - 2}` : ''
|
|
390
|
-
const score = peerScoreByText.get(host)
|
|
391
|
-
return (score !== undefined ? `score ${score} · ` : '') + `${rows.length} 远端团队` + (head !== '' ? `:${head}${more}` : '')
|
|
392
|
-
}
|
|
393
|
-
|
|
394
399
|
/** The mono route line `<host>/canvas/<name>` when the payload carries it. */
|
|
395
400
|
function routeText(name: string): string {
|
|
396
401
|
const t = lastTeams.find(x => x.name === name)
|
|
@@ -431,25 +436,13 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
431
436
|
el.classList.toggle('selected', model.isSelected(n.id))
|
|
432
437
|
el.setAttribute('aria-selected', String(model.isSelected(n.id)))
|
|
433
438
|
const host = n.id.slice('peer-'.length)
|
|
434
|
-
//
|
|
435
|
-
|
|
436
|
-
const nmText = n.id.startsWith('member-')
|
|
437
|
-
? (n.name !== undefined && n.name !== '' ? n.name : n.team)
|
|
438
|
-
: n.remote === true ? host : displayName(n)
|
|
439
|
+
// Title / second line / tooltip rules: planning-card-text.
|
|
440
|
+
const txt = cardText(n, n.remote === true ? lastRemoteTeams.get(host) ?? [] : [], peerScoreByText.get(host))
|
|
439
441
|
const nmEl = el.querySelector<HTMLElement>('.nm-text')!
|
|
440
|
-
if (nmEl.textContent !==
|
|
442
|
+
if (nmEl.textContent !== txt.title) nmEl.textContent = txt.title
|
|
441
443
|
const subEl = el.querySelector<HTMLElement>('.sub')!
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
// it verbatim; collapse to the cross-team suffix alone in that case.
|
|
445
|
-
const subText = n.id.startsWith('member-')
|
|
446
|
-
? n.team
|
|
447
|
-
: n.remote === true
|
|
448
|
-
? remoteSubText(host)
|
|
449
|
-
: (subBase === nmText ? '' : subBase)
|
|
450
|
-
+ (n.memberships.length > 1 ? ` · 跨队×${n.memberships.length}` : '')
|
|
451
|
-
+ (n.teams !== undefined && n.teams.length > 0 ? ` · 网络队×${n.teams.length}` : '')
|
|
452
|
-
if (subEl.textContent !== subText) subEl.textContent = subText
|
|
444
|
+
if (subEl.textContent !== txt.sub) subEl.textContent = txt.sub
|
|
445
|
+
if (el.title !== txt.tip) el.title = txt.tip
|
|
453
446
|
const prioEl = el.querySelector<HTMLElement>('.prio')!
|
|
454
447
|
if (n.remote === true) {
|
|
455
448
|
// gh#20 W-A: the peer badge — link class colors the chip (linked
|
|
@@ -470,30 +463,14 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
470
463
|
for (const [id, el] of [...nodeEls]) {
|
|
471
464
|
if (!seen.has(id)) { el.remove(); nodeEls.delete(id) }
|
|
472
465
|
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
// ── edges: stateless, rebuilt wholesale ──
|
|
476
|
-
// Derived from the MODEL (not lastTeams): optimistic membership changes
|
|
477
|
-
// must repaint their edges immediately, before the host confirms.
|
|
478
|
-
function renderEdges(): void {
|
|
479
|
-
while (svg.firstChild !== null) svg.firstChild.remove()
|
|
480
|
-
const teams = model.allFrames().map(([name]) => ({ name, members: model.teamMemberIds(name).map(id => ({ id })) }))
|
|
481
|
-
for (const e of starEdges(teams, new Map(model.allFrames()), model.positions())) {
|
|
482
|
-
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line')
|
|
483
|
-
line.setAttribute('class', 'e-member' + (e.dashed ? ' dashed' : ''))
|
|
484
|
-
line.setAttribute('x1', String(e.x1))
|
|
485
|
-
line.setAttribute('y1', String(e.y1))
|
|
486
|
-
line.setAttribute('x2', String(e.x2))
|
|
487
|
-
line.setAttribute('y2', String(e.y2))
|
|
488
|
-
svg.appendChild(line)
|
|
489
|
-
}
|
|
466
|
+
search.refresh()
|
|
490
467
|
}
|
|
491
468
|
|
|
492
469
|
// ── federation overlay (PR D): peer cards' activity/federal edges ──
|
|
493
470
|
// Runs on EVERY render, outside the model-revision gate: the poll's
|
|
494
471
|
// inFlight ages must tick even when the model is unchanged, and the
|
|
495
472
|
// badge column tracks the content bounds each reconcile. Its SVG
|
|
496
|
-
// children are tracked separately because
|
|
473
|
+
// children are tracked separately because drawMemberEdges clears the svg
|
|
497
474
|
// wholesale - this appends after it (render() orders the two).
|
|
498
475
|
// gh#49: the DOM drawing lives in federation-overlay.ts (module-size
|
|
499
476
|
// ruling); this view keeps only the seams (facts + fly).
|
|
@@ -515,20 +492,19 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
515
492
|
markDirty()
|
|
516
493
|
}
|
|
517
494
|
|
|
495
|
+
/** W1 pick: frame one node at ~100% and pulse its card. */
|
|
496
|
+
const flyToNode = createNodeFlyer({ model, viewSize: measure, setVp: next => { vp = next }, applyViewport, markDirty, cardEl: id => nodeEls.get(id) })
|
|
518
497
|
// ── status counts ──
|
|
519
498
|
function renderStatus(input: PlanningInput): void {
|
|
520
499
|
const live = input.sessions.filter(s => s.live !== false).length
|
|
521
|
-
const
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
bCold.className = 'warn'
|
|
530
|
-
bCold.textContent = `● ${cold} cold`
|
|
531
|
-
statusCold.appendChild(bCold)
|
|
500
|
+
const dot = (host: HTMLElement, cls: string, text: string): void => {
|
|
501
|
+
const b = document.createElement('b')
|
|
502
|
+
b.className = cls
|
|
503
|
+
b.textContent = text
|
|
504
|
+
host.replaceChildren(b)
|
|
505
|
+
}
|
|
506
|
+
dot(statusLive, 'ok', `● ${live} live`)
|
|
507
|
+
dot(statusCold, 'warn', `● ${input.sessions.length - live} cold`)
|
|
532
508
|
statusAct.textContent = `${input.inFlight?.length ?? 0} inFlight`
|
|
533
509
|
statusMut.className = 'mut'
|
|
534
510
|
statusMut.textContent = `${input.teams.length} 队 · ${input.peerCount} peer`
|
|
@@ -539,8 +515,10 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
539
515
|
// up until BOTH settle (a Set deleted per action let a mid-queue poll wipe
|
|
540
516
|
// the second action's optimistic membership).
|
|
541
517
|
const pendingTeams = new Map<string, number>()
|
|
542
|
-
|
|
518
|
+
// Each change re-renders frames: the mirror 「同步中…」 marker reads pendingTeams, not the model revision.
|
|
519
|
+
const pendingAdd = (team: string): void => { pendingTeams.set(team, (pendingTeams.get(team) ?? 0) + 1); renderedRevision = -1 }
|
|
543
520
|
const pendingRelease = (team: string): void => {
|
|
521
|
+
renderedRevision = -1
|
|
544
522
|
const n = (pendingTeams.get(team) ?? 0) - 1
|
|
545
523
|
if (n <= 0) pendingTeams.delete(team)
|
|
546
524
|
else pendingTeams.set(team, n)
|
|
@@ -595,7 +573,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
595
573
|
const saved = layoutDoc?.nodes[id]
|
|
596
574
|
const seat = saved !== undefined ? { x: saved.x, y: saved.y } : seatFor2(id)
|
|
597
575
|
model.upsertNode({
|
|
598
|
-
id, x: seat.x, y: seat.y, label: s.label, team: s.team, name: s.name,
|
|
576
|
+
id, x: seat.x, y: seat.y, label: s.label, team: s.team, name: s.name, description: s.description,
|
|
599
577
|
live: s.live !== false, memberships: [],
|
|
600
578
|
...(s.teams !== undefined && s.teams.length > 0 ? { teams: s.teams } : {}),
|
|
601
579
|
})
|
|
@@ -725,6 +703,8 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
725
703
|
ev.preventDefault()
|
|
726
704
|
return
|
|
727
705
|
}
|
|
706
|
+
if (target !== null && target.closest('button, input, textarea, select, .p-toolbar, .p-search, .p-lamp, .p-status, .p-legend') !== null) return // HUD chrome owns its presses (no marquee / preventDefault: that stole the search box's focus — and, first, its native right-click menu)
|
|
707
|
+
undoBefore = layoutUndo.capture() // committed at pointerUp only if this press moved something
|
|
728
708
|
const p = localXY(ev)
|
|
729
709
|
// 右键按下:按住拖动 = 平移(Figma 惯例),原地抬起 = 上下文菜单。
|
|
730
710
|
// Pan actually starts on the first move beyond RIGHT_DRAG_THRESHOLD px;
|
|
@@ -735,7 +715,6 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
735
715
|
try { root.setPointerCapture(ev.pointerId ?? 0) } catch { /* jsdom */ }
|
|
736
716
|
return
|
|
737
717
|
}
|
|
738
|
-
if (target !== null && (target.closest('button') !== null || target.closest('.p-lamp') !== null || target.closest('.p-status') !== null)) return
|
|
739
718
|
const nodeEl = target !== null ? target.closest<HTMLElement>('.p-node') : null
|
|
740
719
|
const headEl = target !== null ? target.closest<HTMLElement>('.p-frame-head') : null
|
|
741
720
|
if ((nodeEl !== null || headEl !== null) && ev.button === 1) {
|
|
@@ -930,10 +909,9 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
930
909
|
for (const el of frameEls.values()) el.classList.remove('drop-target')
|
|
931
910
|
gesture.el?.classList.remove('dragging')
|
|
932
911
|
render()
|
|
933
|
-
} else if (gesture.kind === 'frame') {
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
if (gesture.moved) markDirty() // the resized rect persists with the layout
|
|
912
|
+
} else if (gesture.kind === 'frame' || gesture.kind === 'frame-resize') {
|
|
913
|
+
// The frame stays where it was put; neighbours and stray cards yield (planning-overlap).
|
|
914
|
+
if (gesture.moved) { settleBoard(model, new Set(remoteFramesRef.keys()), gesture.name); markDirty(); render() }
|
|
937
915
|
} else if (gesture.kind === 'pan') {
|
|
938
916
|
if (gesture.moved) markDirty() // the viewport persists with the layout
|
|
939
917
|
} else if (gesture.kind === 'marquee') {
|
|
@@ -945,6 +923,21 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
945
923
|
render()
|
|
946
924
|
}
|
|
947
925
|
gesture = { kind: 'none' }
|
|
926
|
+
if (undoBefore !== undefined) { layoutUndo.commit(undoBefore); undoBefore = undefined }
|
|
927
|
+
try { root.releasePointerCapture(ev.pointerId ?? 0) } catch { /* not captured */ }
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** The OS took the pointer (touch scroll, system gesture): abandon the press — no drop, no menu, layout back. */
|
|
931
|
+
function pointerCancel(ev: SeamPointer): void {
|
|
932
|
+
for (const el of frameEls.values()) el.classList.remove('drop-target')
|
|
933
|
+
if (gesture.kind === 'node') gesture.el?.classList.remove('dragging')
|
|
934
|
+
const panned = (gesture.kind === 'pan' && gesture.moved) || rightPress?.moved === true // right-drag pans live in rightPress
|
|
935
|
+
marquee.style.display = 'none'
|
|
936
|
+
rightPress = null
|
|
937
|
+
gesture = { kind: 'none' }
|
|
938
|
+
if (undoBefore !== undefined && layoutUndo.revert(undoBefore)) render()
|
|
939
|
+
if (panned) markDirty() // the pan stays (it is the view, not the layout) and persists like a finished one
|
|
940
|
+
undoBefore = undefined
|
|
948
941
|
try { root.releasePointerCapture(ev.pointerId ?? 0) } catch { /* not captured */ }
|
|
949
942
|
}
|
|
950
943
|
|
|
@@ -975,15 +968,21 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
975
968
|
markDirty()
|
|
976
969
|
}
|
|
977
970
|
|
|
971
|
+
function undoLayout(): void {
|
|
972
|
+
if (layoutUndo.undo()) { markDirty(); render() } else notice('info', '没有可撤销的布局改动')
|
|
973
|
+
}
|
|
974
|
+
|
|
978
975
|
function fitToContent(): void {
|
|
979
976
|
syncViewSize()
|
|
980
|
-
vp =
|
|
977
|
+
vp = fitBelowHud(boardBounds(model.contentBounds(), model.allNodes()), viewW, viewH, hudBottomOf(toolbar, status))
|
|
981
978
|
applyViewport()
|
|
982
979
|
markDirty()
|
|
983
980
|
}
|
|
984
981
|
|
|
985
982
|
function key(ev: SeamKey): void {
|
|
986
|
-
if (menus.isDialogOpen()) return // modal
|
|
983
|
+
if (menus.isDialogOpen() || (ev.target as Element | null)?.closest?.('.p-search') !== null) return // modal / search box own their keys
|
|
984
|
+
if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === 'k') { ev.preventDefault(); search.focus(); return } // W1 Ctrl+K
|
|
985
|
+
if ((ev.ctrlKey || ev.metaKey) && !ev.shiftKey && ev.key.toLowerCase() === 'z') { ev.preventDefault(); undoLayout(); return }
|
|
987
986
|
if (ev.ctrlKey || ev.metaKey) return // browser/OS chords stay browser/OS
|
|
988
987
|
if (menus.isMenuOpen()) {
|
|
989
988
|
// The menu owns most keys (its own handler navigates); Escape still
|
|
@@ -1037,7 +1036,9 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
1037
1036
|
ev.preventDefault()
|
|
1038
1037
|
const ids = model.selectedIds()
|
|
1039
1038
|
if (ids.length > 0) {
|
|
1039
|
+
const before = layoutUndo.capture()
|
|
1040
1040
|
model.nudge(ids, d[0]!, d[1]!)
|
|
1041
|
+
layoutUndo.commit(before)
|
|
1041
1042
|
render()
|
|
1042
1043
|
markDirty()
|
|
1043
1044
|
}
|
|
@@ -1093,12 +1094,13 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
1093
1094
|
root.addEventListener('pointerdown', (ev) => pointerDown(ev as unknown as SeamPointer), signal)
|
|
1094
1095
|
root.addEventListener('pointermove', (ev) => pointerMove(ev as unknown as SeamPointer), signal)
|
|
1095
1096
|
root.addEventListener('pointerup', (ev) => pointerUp(ev as unknown as SeamPointer), signal)
|
|
1097
|
+
root.addEventListener('pointercancel', (ev) => pointerCancel(ev as unknown as SeamPointer), signal)
|
|
1096
1098
|
root.addEventListener('wheel', (ev) => wheel(ev as unknown as SeamPointer), { ...signal, passive: false })
|
|
1097
1099
|
root.addEventListener('keydown', (ev) => key(ev as unknown as SeamKey), signal)
|
|
1098
1100
|
root.addEventListener('keyup', (ev) => keyUp(ev as unknown as SeamKey), signal)
|
|
1099
1101
|
root.addEventListener('contextmenu', (ev) => {
|
|
1100
1102
|
const e = ev as unknown as SeamPointer
|
|
1101
|
-
e.
|
|
1103
|
+
if ((e.target as Element | null)?.closest?.('input, textarea, .p-search') != null) return // text fields keep the native menu (paste)
|
|
1102
1104
|
// Right-drag pans (suppressNextMenu); a clean right-click opens the
|
|
1103
1105
|
// menu programmatically at pointerup. Either way the native menu dies.
|
|
1104
1106
|
e.preventDefault()
|
|
@@ -1174,7 +1176,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
1174
1176
|
if (model.getNode(s.id) !== undefined) {
|
|
1175
1177
|
model.upsertNode({
|
|
1176
1178
|
id: s.id, x: model.getNode(s.id)!.x, y: model.getNode(s.id)!.y,
|
|
1177
|
-
label: s.label, team: s.team, name: s.name, live: s.live !== false,
|
|
1179
|
+
label: s.label, team: s.team, name: s.name, description: s.description, live: s.live !== false,
|
|
1178
1180
|
memberships: effective,
|
|
1179
1181
|
...(s.teams !== undefined && s.teams.length > 0 ? { teams: s.teams } : {}),
|
|
1180
1182
|
})
|
|
@@ -1188,7 +1190,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
1188
1190
|
y = seat.y
|
|
1189
1191
|
}
|
|
1190
1192
|
model.upsertNode({
|
|
1191
|
-
id: s.id, x, y, label: s.label, team: s.team, name: s.name, live: s.live !== false,
|
|
1193
|
+
id: s.id, x, y, label: s.label, team: s.team, name: s.name, description: s.description, live: s.live !== false,
|
|
1192
1194
|
memberships: effective,
|
|
1193
1195
|
...(s.teams !== undefined && s.teams.length > 0 ? { teams: s.teams } : {}),
|
|
1194
1196
|
})
|
|
@@ -1389,21 +1391,17 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
1389
1391
|
// opened empty. Fit once, the first time there is something to frame.
|
|
1390
1392
|
// Not marked dirty — nothing the user arranged changed; a saved layout
|
|
1391
1393
|
// (adopted at any point) keeps its own viewport and cancels this.
|
|
1394
|
+
// Once per load, a board that arrives overlapped is shown settled (not
|
|
1395
|
+
// saved: no write without a user action; the next edit persists it).
|
|
1396
|
+
if (!settledOnce && gesture.kind === 'none' && model.nodeIds().length > 0) { settledOnce = true; const b = layoutUndo.capture(); settleBoard(model, new Set(remoteFramesRef.keys())); layoutUndo.commit(b) }
|
|
1392
1397
|
if (firstFitPending && gesture.kind === 'none' && model.nodeIds().length > 0) {
|
|
1393
1398
|
syncViewSize()
|
|
1394
1399
|
if (viewW > 0 && viewH > 0) {
|
|
1395
1400
|
firstFitPending = false
|
|
1396
1401
|
// Peer cards hang outside the local content bounds — frame them too,
|
|
1397
|
-
// or the federation column opens clipped at the board's edge.
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
if (n.remote !== true) continue
|
|
1401
|
-
const r = nodeRect(n)
|
|
1402
|
-
bounds = bounds === null
|
|
1403
|
-
? { minX: r.x, minY: r.y, maxX: r.x + r.w, maxY: r.y + r.h }
|
|
1404
|
-
: { minX: Math.min(bounds.minX, r.x), minY: Math.min(bounds.minY, r.y), maxX: Math.max(bounds.maxX, r.x + r.w), maxY: Math.max(bounds.maxY, r.y + r.h) }
|
|
1405
|
-
}
|
|
1406
|
-
vp = fitView(bounds, viewW, viewH, 1) // frame, never magnify
|
|
1402
|
+
// or the federation column opens clipped at the board's edge. Same
|
|
1403
|
+
// framing as 适配 (planning-fit): ≤100%, below the HUD.
|
|
1404
|
+
vp = fitBelowHud(boardBounds(model.contentBounds(), model.allNodes()), viewW, viewH, hudBottomOf(toolbar, status))
|
|
1407
1405
|
}
|
|
1408
1406
|
}
|
|
1409
1407
|
render()
|
|
@@ -1491,6 +1489,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
1491
1489
|
pointerDown,
|
|
1492
1490
|
pointerMove,
|
|
1493
1491
|
pointerUp,
|
|
1492
|
+
pointerCancel,
|
|
1494
1493
|
wheel,
|
|
1495
1494
|
key,
|
|
1496
1495
|
keyUp,
|
|
@@ -82,6 +82,8 @@
|
|
|
82
82
|
#dsh-plan .p-frame-head:hover{border-color:var(--card-line-hover)}
|
|
83
83
|
#dsh-plan .p-frame-head .cnt{color:var(--ink-muted); font-weight:400}
|
|
84
84
|
#dsh-plan .p-frame-head .route{font-size:10.5px; color:var(--ink-muted)}
|
|
85
|
+
#dsh-plan .p-frame.is-mirror{border-style:dashed}
|
|
86
|
+
#dsh-plan .p-frame.is-syncing .p-frame-head .route{color:var(--accent)}
|
|
85
87
|
|
|
86
88
|
/* ── node card (fixed 56px border-box height = world.NODE_H) ── */
|
|
87
89
|
#dsh-plan .p-node{position:absolute; width:172px; height:56px; padding:8px 26px 8px 14px;
|
|
@@ -97,12 +99,31 @@
|
|
|
97
99
|
#dsh-plan .p-node .nm{font-weight:600; display:flex; align-items:center; gap:7px;
|
|
98
100
|
overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
|
|
99
101
|
/* The name span clips with an ellipsis (min-width:0 lets it shrink inside
|
|
100
|
-
the flex row) — without it a long peer address was hard-cut mid-digit.
|
|
101
|
-
|
|
102
|
+
the flex row) — without it a long peer address was hard-cut mid-digit.
|
|
103
|
+
属主令 W2: TWO-LINE clamp for the name (long Chinese team titles read
|
|
104
|
+
fully); the card's 172x56 geometry is untouched — the clamp absorbs the
|
|
105
|
+
overflow. The handle line (.sub) stays single-line ellipsis below. */
|
|
106
|
+
#dsh-plan .p-node .nm-text{min-width:0; overflow:hidden; white-space:normal;
|
|
107
|
+
display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; line-height:1.25}
|
|
108
|
+
/* A peer address has no break point, so the two-line clamp clipped it to one
|
|
109
|
+
line under the badge; let it wrap anywhere onto the second line. */
|
|
110
|
+
#dsh-plan .p-node.remote .nm-text{overflow-wrap:anywhere}
|
|
102
111
|
#dsh-plan .p-node .dot{width:8px; height:8px; border-radius:50%; background:var(--edge-live); flex:none}
|
|
103
112
|
#dsh-plan .p-node.cold .dot{background:transparent; border:1.5px solid var(--edge-cold)}
|
|
104
113
|
#dsh-plan .p-node .sub{font-size:11px; color:var(--ink-muted); margin-top:2px;
|
|
105
114
|
overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
|
|
115
|
+
/* W1: a picked search hit pulses its card for 1.2s (class removed on timer). */
|
|
116
|
+
#dsh-plan .p-node.pulse{box-shadow:0 0 0 2px var(--accent), 0 0 14px 4px var(--uupm-wash-15); border-color:var(--accent)}
|
|
117
|
+
/* W1: toolbar search box + hit list. */
|
|
118
|
+
#dsh-plan .p-search{position:relative}
|
|
119
|
+
#dsh-plan .p-search-input{width:170px; min-height:28px; padding:2px 8px; font-size:12px;
|
|
120
|
+
background:transparent; color:var(--ink); border:1px solid var(--card-line); border-radius:6px}
|
|
121
|
+
#dsh-plan .p-search-input:focus-visible{outline:2px solid var(--accent); outline-offset:1px}
|
|
122
|
+
#dsh-plan .p-search-list{position:absolute; top:100%; left:0; margin-top:4px; display:none;
|
|
123
|
+
min-width:240px; max-height:220px; overflow:auto; z-index:9;
|
|
124
|
+
background:var(--toolbar-bg); border:1px solid var(--card-line); border-radius:6px}
|
|
125
|
+
#dsh-plan .p-search-hit{padding:5px 10px; font-size:12px; color:var(--ink); cursor:pointer; white-space:nowrap; overflow:hidden; text-overflow:ellipsis}
|
|
126
|
+
#dsh-plan .p-search-hit:hover, #dsh-plan .p-search-hit.active{background:var(--uupm-wash-15)}
|
|
106
127
|
#dsh-plan .p-node .prio{position:absolute; top:6px; right:8px; font-size:10px; color:var(--ink-muted);
|
|
107
128
|
border:1px solid var(--card-line); border-radius:4px; padding:0 5px}
|
|
108
129
|
|
|
@@ -126,6 +147,8 @@
|
|
|
126
147
|
#dsh-plan .p-toolbar button:active{background:var(--uupm-wash-15)}
|
|
127
148
|
#dsh-plan .p-toolbar button:focus-visible{outline:2px solid var(--accent); outline-offset:1px}
|
|
128
149
|
#dsh-plan .p-zoom{min-width:44px; text-align:center; font-size:11px; color:var(--ink-muted)}
|
|
150
|
+
/* Toolbar groups (view | arrange | network): a hairline between them. */
|
|
151
|
+
#dsh-plan .p-sep{align-self:stretch; width:1px; margin:2px 4px; background:var(--card-line)}
|
|
129
152
|
#dsh-plan .p-lamp{display:flex; align-items:center; gap:6px; font-size:11px; color:var(--ink-muted); padding:0 6px}
|
|
130
153
|
#dsh-plan .p-lamp i{width:7px; height:7px; border-radius:50%; background:var(--ink-muted); transition:background 140ms ease}
|
|
131
154
|
#dsh-plan .p-lamp.saved i{background:var(--edge-live)}
|
|
@@ -140,8 +163,11 @@
|
|
|
140
163
|
#dsh-plan .ok{color:var(--edge-live)} #dsh-plan .warn{color:var(--uupm-warn)} #dsh-plan .mut{color:var(--ink-muted)}
|
|
141
164
|
|
|
142
165
|
#dsh-plan .p-legend{position:absolute; bottom:16px; left:16px; z-index:3; font-size:11px; color:var(--ink-muted);
|
|
143
|
-
background:var(--toolbar-bg); border:1px solid var(--card-line); border-radius:8px; padding:
|
|
144
|
-
|
|
166
|
+
background:var(--toolbar-bg); border:1px solid var(--card-line); border-radius:8px; padding:4px 10px}
|
|
167
|
+
#dsh-plan .p-legend summary{cursor:pointer; padding:2px 0; user-select:none}
|
|
168
|
+
#dsh-plan .p-legend summary:focus-visible{outline:2px solid var(--accent); outline-offset:2px}
|
|
169
|
+
#dsh-plan .p-legend[open]{padding-bottom:8px}
|
|
170
|
+
#dsh-plan .p-legend-rows{display:grid; gap:4px; margin-top:4px}
|
|
145
171
|
#dsh-plan .p-legend i{display:inline-block; width:18px; border-top:2px solid var(--uupm-edge-member);
|
|
146
172
|
vertical-align:middle; margin-right:6px}
|
|
147
173
|
#dsh-plan .p-legend i.dashed{border-top-style:dashed}
|
|
@@ -149,12 +175,19 @@
|
|
|
149
175
|
/* ── narrow board (<680px): the HUD stacks instead of colliding ──
|
|
150
176
|
The toolbar, status chip and legend are absolutely placed for a wide
|
|
151
177
|
board; on a phone they overlapped each other and the toolbar's CJK
|
|
152
|
-
labels wrapped one glyph per line.
|
|
153
|
-
the
|
|
178
|
+
labels wrapped one glyph per line. The one-row sideways scroll that
|
|
179
|
+
followed hid the search box and half the actions off-screen (review
|
|
180
|
+
2026-10-27), so the toolbar and status chip now flow at the top of the
|
|
181
|
+
board instead: the toolbar wraps, search first on its own row, and the
|
|
182
|
+
status chip sits under whatever height that takes. The legend (reference,
|
|
183
|
+
not state) hides. */
|
|
154
184
|
@media (max-width:680px){
|
|
155
|
-
#dsh-plan .p-toolbar{
|
|
185
|
+
#dsh-plan .p-toolbar{position:relative; top:auto; left:auto; margin:8px 8px 0; flex-wrap:wrap; row-gap:6px}
|
|
156
186
|
#dsh-plan .p-toolbar > *{flex:none}
|
|
157
|
-
#dsh-plan .p-
|
|
187
|
+
#dsh-plan .p-toolbar .p-search{order:-1; flex:1 1 100%}
|
|
188
|
+
#dsh-plan .p-toolbar .p-sep{display:none}
|
|
189
|
+
#dsh-plan .p-toolbar .p-search-input{width:100%; box-sizing:border-box}
|
|
190
|
+
#dsh-plan .p-status{position:relative; top:auto; right:auto; left:auto; width:fit-content; margin:6px 8px 0; padding:6px 10px; gap:10px; flex-wrap:wrap}
|
|
158
191
|
#dsh-plan .p-legend{display:none}
|
|
159
192
|
}
|
|
160
193
|
@media (pointer:coarse){
|