dsh-ptc-plus 0.4.8 → 0.5.0

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.
Files changed (139) hide show
  1. package/README.en.md +61 -123
  2. package/README.md +61 -123
  3. package/assets/dsh-ptc-plus-banner-en.webp +0 -0
  4. package/assets/dsh-ptc-plus-banner-zh.webp +0 -0
  5. package/assets/ptc-plus-repl-workspace-en.png +0 -0
  6. package/assets/ptc-plus-repl-workspace-zh.png +0 -0
  7. package/assets/ptc-plus-settings-en.png +0 -0
  8. package/assets/ptc-plus-settings-zh.png +0 -0
  9. package/client.cjs +42336 -0
  10. package/cordis.patch.yml +13 -2
  11. package/docs/README.md +7 -2
  12. package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +31 -179
  13. package/docs/adr/0008-define-agent-operating-contract.md +5 -1
  14. package/docs/adr/0009-bound-expensive-acceptance-default.md +2 -2
  15. package/docs/adr/0016-archive-resolved-review-findings.md +1 -1
  16. package/docs/adr/0017-track-the-latest-dsh-public-surface.md +2 -2
  17. package/docs/adr/0019-plugin-settings-and-kill-switch.md +14 -3
  18. package/docs/adr/0020-optional-cordis-tools-in-ptc-mode.md +2 -2
  19. package/docs/adr/0022-stage-npm-releases-with-oidc.md +4 -2
  20. package/docs/adr/0023-global-user-bindings.md +3 -1
  21. package/docs/adr/0026-persist-the-delivery-verdict-against-the-verified-candidate.md +2 -0
  22. package/docs/adr/0028-attach-to-the-host-ptc-execution-seam.md +101 -35
  23. package/docs/adr/0030-serve-both-typert-codec-generations-from-one-codec.md +3 -3
  24. package/docs/adr/0031-close-semantic-obligations-by-owner.md +1 -1
  25. package/docs/adr/0032-own-session-log-facts-in-a-host-only-projection.md +27 -0
  26. package/docs/adr/0033-gate-client-visual-usability-in-a-browser.md +23 -0
  27. package/docs/adr/0034-keep-program-values-separate-from-output-projections.md +29 -0
  28. package/docs/adr/0035-run-coverage-only-before-a-new-release.md +27 -0
  29. package/docs/adr/0036-separate-focused-development-from-delivery-verification.md +29 -0
  30. package/docs/adr/0037-keep-tool-hover-previews-passive.md +52 -0
  31. package/docs/adr/0038-freeze-session-capability-plans.md +71 -0
  32. package/docs/adr/0039-verify-documentation-without-runtime-suites.md +29 -0
  33. package/docs/adr/0040-separate-capability-configurations-from-session-observations.md +39 -0
  34. package/docs/adr/0041-record-cell-execution-directories.md +65 -0
  35. package/docs/adr/0042-persist-provider-availability-and-handback.md +41 -0
  36. package/docs/adr/0043-compose-capability-and-context-policies.md +37 -0
  37. package/docs/adr/README.md +6 -0
  38. package/docs/architecture.md +25 -27
  39. package/docs/binding-review-placement-design.md +29 -21
  40. package/docs/capability-planning-components.md +93 -0
  41. package/docs/capability-projection.md +7 -0
  42. package/docs/client-ui.md +88 -29
  43. package/docs/client-visual-contract.md +65 -0
  44. package/docs/context-management.md +22 -0
  45. package/docs/context-optimization-plan.md +353 -0
  46. package/docs/context-public-interface-coverage.md +25 -0
  47. package/docs/durability-design.md +31 -14
  48. package/docs/evaluation.md +41 -6
  49. package/docs/evaluations/command-goat-deepseek-v4.1-flash-20261006.md +126 -0
  50. package/docs/evaluations/command-goat-deepseek-v4.1-flash.md +281 -0
  51. package/docs/installation.md +50 -8
  52. package/docs/program-data-plane.md +5 -3
  53. package/docs/publishing.md +5 -5
  54. package/docs/release-coverage.md +73 -0
  55. package/docs/release-notes.md +1 -1
  56. package/docs/review-procedure.md +56 -0
  57. package/docs/runtime-reference.md +48 -16
  58. package/docs/semantic-validation.md +14 -2
  59. package/docs/stateful-binding-updates-proposal.md +2 -2
  60. package/docs/user-bindings.en.md +2 -2
  61. package/docs/user-bindings.md +3 -3
  62. package/docs/value-wire.md +11 -1
  63. package/docs/verification-optimization-work.md +52 -15
  64. package/docs/verification.md +30 -86
  65. package/index.js +147 -23
  66. package/internal/binding-toggle-receipt.js +24 -0
  67. package/internal/capability-command-receipt.js +29 -0
  68. package/internal/capability-environment.js +1247 -0
  69. package/internal/capability-plan.js +285 -0
  70. package/internal/capability-policy.js +96 -0
  71. package/internal/capability-statistics-store.js +180 -0
  72. package/internal/capability-statistics.js +210 -0
  73. package/internal/compiler-platform.js +5 -3
  74. package/internal/compiler-service.js +2 -9
  75. package/internal/config-spec.js +79 -9
  76. package/internal/cordis-tools-owner.js +22 -19
  77. package/internal/direct-surface-owner.js +29 -29
  78. package/internal/dynamic-environment-runtime.js +9 -9
  79. package/internal/edit-transport-owner.js +183 -139
  80. package/internal/execution-provider-owner.js +160 -0
  81. package/internal/execution-seam-compat.js +76 -120
  82. package/internal/isolated-worker.js +12 -8
  83. package/internal/kernel-worker.js +104 -32
  84. package/internal/native-root-dynamic.js +12 -13
  85. package/internal/program-bindings.js +190 -0
  86. package/internal/recovery-tips.js +53 -23
  87. package/internal/rejected-cell-editor.js +8 -8
  88. package/internal/repl-observation-interest.js +11 -3
  89. package/internal/rpc-contract.js +1 -0
  90. package/internal/runtime-bridge-owner.js +351 -82
  91. package/internal/runtime-config.js +58 -0
  92. package/internal/runtime-contexts.js +85 -16
  93. package/internal/runtime-intrinsics.js +1 -0
  94. package/internal/runtime-messages.js +41 -0
  95. package/internal/sdk-projection.js +10 -82
  96. package/internal/session-cell-executor.js +42 -19
  97. package/internal/session-events.js +74 -4
  98. package/internal/session-journal-recovery.js +904 -184
  99. package/internal/session-journal-schema.js +6 -3
  100. package/internal/session-journal.js +31 -2
  101. package/internal/session-log-view.js +189 -43
  102. package/internal/session-runtime.js +175 -23
  103. package/internal/settings-compat.js +23 -6
  104. package/internal/stateful-module-compiler.js +3 -2
  105. package/internal/stateful-module-runtime.js +7 -3
  106. package/internal/stateful-root-runtime.js +9 -4
  107. package/internal/user-binding-authoring.js +2 -7
  108. package/internal/user-bindings-owner.js +47 -7
  109. package/internal/validated-parse-repair.js +1 -1
  110. package/internal/value-wire.js +68 -21
  111. package/internal/worker-cwd-virtualization.js +20 -18
  112. package/internal/worker-realm-surfaces.js +3 -3
  113. package/locale/en.json +6 -0
  114. package/locale/zh.json +6 -0
  115. package/package.json +55 -38
  116. package/src/client-authoring-view.js +286 -168
  117. package/src/client-binding-review.js +4 -1
  118. package/src/client-binding-toggle.js +18 -0
  119. package/src/client-capabilities-view.js +1341 -0
  120. package/src/client-capability-sort.js +29 -0
  121. package/src/client-code-editor.js +6 -0
  122. package/src/client-copy.js +881 -8
  123. package/src/client-feature-gates.js +15 -5
  124. package/src/client-host-compat.js +23 -2
  125. package/src/client-indicator-view.js +4 -6
  126. package/src/client-primitives.js +280 -0
  127. package/src/client-repl-view.js +23 -16
  128. package/src/client-settings-view.js +412 -69
  129. package/src/client-styles.js +193 -41
  130. package/src/client-tool-hover.js +185 -0
  131. package/src/client-tool-view.js +45 -11
  132. package/src/client-transport.js +44 -9
  133. package/src/client-workbench.js +9 -10
  134. package/src/client.js +148 -68
  135. package/assets/ptc-plus-bindings-en.png +0 -0
  136. package/assets/ptc-plus-bindings-zh.png +0 -0
  137. package/assets/ptc-plus-repair-en.png +0 -0
  138. package/assets/ptc-plus-repair-zh.png +0 -0
  139. package/client.js +0 -31857
@@ -1,8 +1,10 @@
1
1
  import { bindingDraftProjection, bindingReviewStatus } from './client-binding-review.js'
2
2
  import { bindingModelPreferences } from '../internal/user-binding-model-context.js'
3
+ import { capabilityCommandText } from './client-capabilities-view.js'
3
4
  import { featureEnabled } from './client-feature-gates.js'
4
5
  import {
5
6
  MENU_CHILDREN_PROBE_SENTINEL,
7
+ isHostComponent,
6
8
  isHostIconComponent,
7
9
  readMenuChildrenProbe,
8
10
  } from './client-host-compat.js'
@@ -22,9 +24,9 @@ const HOVER_DWELL_MS = 150
22
24
  */
23
25
  export function createAuthoringView(React, deps) {
24
26
  const {
25
- ActionButton, IconButton, Menu, Toast, Tooltip, CodeBlock, BindingsDialog, PTCPlusSettingsDialog,
27
+ ActionButton, IconButton, Menu, MenuFallback, Toast, Tooltip, CodeBlock, Modal, createPortal, BindingsDialog, PTCPlusSettingsDialog,
26
28
  useWorkbenchController, useBindingReview, catalogOwner, callUserBindings, subscribeReset,
27
- settingsCardSeat, updateSetting, menuChildren,
29
+ settingsCardSeat, updateSetting, updateCapabilityConfiguration, menuChildren, SessionCapabilityControls,
28
30
  icons: {
29
31
  sparkle: IconSparkle16, chevron: IconChevronDownOutline14,
30
32
  close: IconCloseOutline16, check: IconCheckOutline14,
@@ -32,6 +34,24 @@ export function createAuthoringView(React, deps) {
32
34
  } = deps
33
35
  const h = React.createElement
34
36
 
37
+ function EnlargeIcon() {
38
+ return h('svg', { width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true },
39
+ h('path', { d: 'M6 2H2v4m8-4h4v4M2 10v4h4m8-4v4h-4', stroke: 'currentColor', strokeWidth: 1.5,
40
+ strokeLinecap: 'round', strokeLinejoin: 'round' }))
41
+ }
42
+
43
+ function RestoreIcon() {
44
+ return h('svg', { width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true },
45
+ h('path', { d: 'M2 6h4V2m4 0v4h4M6 14v-4H2m12 0h-4v4', stroke: 'currentColor', strokeWidth: 1.5,
46
+ strokeLinecap: 'round', strokeLinejoin: 'round' }))
47
+ }
48
+
49
+ function ReviewCloseIcon() {
50
+ return h('svg', { width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true },
51
+ h('path', { d: 'm4 4 8 8m0-8-8 8', stroke: 'currentColor', strokeWidth: 1.5,
52
+ strokeLinecap: 'round' }))
53
+ }
54
+
35
55
  /**
36
56
  * Where the settings card lives on the installed generation.
37
57
  *
@@ -45,10 +65,11 @@ export function createAuthoringView(React, deps) {
45
65
  }[settingsCardSeat?.()] ?? 'settings.menuEntry')
46
66
 
47
67
  function BindingAuthorButton({
48
- sessionId, t, useInput, inputActions, usePtcSettings, useBindingCommand,
68
+ sessionId, t, useInput, inputActions, usePtcSettings, useBindingCommand, useCapabilityCommand,
49
69
  }) {
50
70
  const input = useInput?.(snapshot => snapshot)
51
71
  const available = useBindingCommand(snapshot => snapshot)
72
+ const capabilityAvailable = useCapabilityCommand?.(snapshot => snapshot) === true
52
73
  const settings = usePtcSettings(snapshot => snapshot)
53
74
  const [review, view] = useBindingReview(sessionId)
54
75
  const anchorRef = React.useRef(null)
@@ -56,20 +77,21 @@ export function createAuthoringView(React, deps) {
56
77
  anchorRef.current = element
57
78
  review.access = element
58
79
  }, [review])
59
- const firstItemRef = React.useRef(null)
60
- const manageItemRef = React.useRef(null)
80
+ const menuBodyRef = React.useRef(null)
61
81
  const reloadItemRef = React.useRef(null)
62
- const settingsItemRef = React.useRef(null)
82
+ const disabledGroupRef = React.useRef(null)
83
+ const [disabledOpen, setDisabledOpen] = React.useState(false)
63
84
  const focused = React.useRef(false)
64
85
  const dialogReturnFocus = React.useRef(null)
65
86
  const hoverTimer = React.useRef(undefined)
66
87
  const [menu, setMenu] = React.useState(null)
67
88
  const [managing, setManaging] = React.useState(false)
89
+ const [capabilitiesOpen, setCapabilitiesOpen] = React.useState(false)
68
90
  const [settingsOpen, setSettingsOpen] = React.useState(false)
69
91
  // The composer entry's four actions live in the host Menu's `children`
70
92
  // region. Preceding Clients accept that prop and mount nothing, so the entry
71
- // probes the installed primitive once and, when it renders no such region,
72
- // keeps the same actions reachable through the Menu's pinned rows.
93
+ // probes the installed primitive once and uses its own Menu fallback when
94
+ // that public region is absent.
73
95
  const probeRef = React.useRef(null)
74
96
  const [probing, setProbing] = React.useState(() => menuChildren.supported() === undefined)
75
97
  const menuChildrenRegion = menuChildren.supported() !== false
@@ -86,16 +108,25 @@ export function createAuthoringView(React, deps) {
86
108
  const catalogFocus = React.useRef(null)
87
109
  const toastSequence = React.useRef(0)
88
110
  const [toast, setToast] = React.useState(null)
89
- const hasDraft = view.mounted && view.candidate !== null && view.action === null
111
+ const hasDraft = featureEnabled(settings, 'bindings') && view.mounted && view.candidate !== null && view.action === null
90
112
  const quickAccess = featureEnabled(settings, 'authorButton')
91
- const canAuthor = quickAccess && available && typeof useInput === 'function' && typeof inputActions?.setDraft === 'function'
92
- const menuOpen = (hasDraft || quickAccess) && view.reachable && !managing && !settingsOpen
113
+ const pluginEnabled = featureEnabled(settings, 'plugin') && (hasDraft || featureEnabled(settings, 'ptcMenu'))
114
+ const canPrefill = typeof useInput === 'function' && typeof inputActions?.setDraft === 'function'
115
+ const canAuthor = quickAccess && available && canPrefill
116
+ const canRefresh = pluginEnabled && settings.status === 'ready' && settings.writable === true
117
+ && capabilityAvailable && canPrefill
118
+ const menuOpen = pluginEnabled && view.reachable && !managing && !capabilitiesOpen && !settingsOpen
93
119
  && menu !== null && menu.key === view.candidateKey
94
120
  // The dialog owns the management session; losing the input surface only
95
121
  // suspends reads, so a draft survives a temporary Host takeover.
96
122
  const workbench = useWorkbenchController({
97
123
  enabled: quickAccess && managing, active: view.reachable, callUserBindings,
98
124
  })
125
+ React.useEffect(() => {
126
+ if (!quickAccess) {
127
+ setManaging(false)
128
+ }
129
+ }, [quickAccess])
99
130
  const refreshCatalog = React.useCallback(async (reload = false) => {
100
131
  if (reload) captureCatalogFocus()
101
132
  await catalogSource.read({ reload })
@@ -105,6 +136,7 @@ export function createAuthoringView(React, deps) {
105
136
  catalogSource.reset()
106
137
  setMenu(null)
107
138
  setManaging(false)
139
+ setCapabilitiesOpen(false)
108
140
  setSettingsOpen(false)
109
141
  }
110
142
  const unsubscribe = subscribeReset(reset)
@@ -116,9 +148,14 @@ export function createAuthoringView(React, deps) {
116
148
  const toggleBinding = async entry => {
117
149
  if (!catalogReady || !quickAccess) return
118
150
  captureCatalogFocus()
119
- await catalogSource.write(() => callUserBindings(entry.enabled ? 'disable' : 'enable', {
151
+ const next = await catalogSource.write(() => callUserBindings(entry.enabled ? 'disable' : 'enable', {
120
152
  id: entry.id, expectedRevision: catalog.revision,
121
153
  }))
154
+ const saved = next?.entries.find(item => item.id === entry.id)
155
+ if (saved) {
156
+ toastSequence.current++
157
+ setToast({ sequence: toastSequence.current, key: saved.enabled ? 'bindings.toggleEnabled' : 'bindings.toggleDisabled', params: { name: saved.name } })
158
+ }
122
159
  }
123
160
  const cancelHoverOpen = React.useCallback(() => {
124
161
  if (hoverTimer.current === undefined) return
@@ -128,7 +165,10 @@ export function createAuthoringView(React, deps) {
128
165
  React.useEffect(() => cancelHoverOpen, [cancelHoverOpen])
129
166
  const showMenu = mode => {
130
167
  cancelHoverOpen()
131
- if (!menuOpen && quickAccess) void refreshCatalog()
168
+ if (!menuOpen) {
169
+ setDisabledOpen(false)
170
+ if (quickAccess) void refreshCatalog()
171
+ }
132
172
  setMenu({ key: view.candidateKey, mode, step: 'root' })
133
173
  }
134
174
  // Touch has no hover: its tap reaches the same menu through the click path.
@@ -140,11 +180,24 @@ export function createAuthoringView(React, deps) {
140
180
  showMenu('hover')
141
181
  }, HOVER_DWELL_MS)
142
182
  }
143
- const menuElement = () => manageItemRef.current?.closest('[role=menu]')
144
- ?? firstItemRef.current?.closest('[role=menu]')
183
+ const menuElement = () => menuBodyRef.current
145
184
  const captureCatalogFocus = () => {
146
185
  catalogFocus.current = menuElement()?.contains(document.activeElement) ? document.activeElement : null
147
186
  }
187
+ const focusMenuItem = next => {
188
+ next?.focus({ preventScroll: true })
189
+ const scroll = menuElement()?.querySelector('.ptcPlusBindingMenuScroll')
190
+ if (!scroll?.contains(next)) return
191
+ const viewport = scroll.getBoundingClientRect()
192
+ const item = next.getBoundingClientRect()
193
+ // Reveal only inside the owned catalog. Browser focus scrolling can also
194
+ // scroll the outer menu or transcript and move the fixed actions.
195
+ const offset = item.height > scroll.clientHeight
196
+ ? item.top + item.height / 2 - (viewport.top + viewport.height / 2)
197
+ : item.top < viewport.top ? item.top - viewport.top
198
+ : item.bottom > viewport.bottom ? item.bottom - viewport.bottom : 0
199
+ scroll.scrollTop += offset
200
+ }
148
201
  // The list must sit next to the entry that opened it. The published Menu reads
149
202
  // this rect on open and on every scroll/resize, and re-runs placement whenever
150
203
  // the prop identity changes, so measuring the trigger each render also keeps an
@@ -190,19 +243,22 @@ export function createAuthoringView(React, deps) {
190
243
  if (!menuOpen) { catalogFocus.current = null; return }
191
244
  if (catalogStatus === 'writing' || catalogStatus === 'loading') return
192
245
  const target = catalogFocus.current
193
- catalogFocus.current = null
194
- if (!target || document.activeElement !== document.body) return
246
+ if (!target || (document.activeElement !== document.body && document.activeElement !== target)) return
195
247
  const root = menuElement()
248
+ const bindingId = target.dataset.bindingId
249
+ const currentRow = bindingId === undefined ? null
250
+ : [...(root?.querySelectorAll('[data-binding-id]') ?? [])].find(row => row.dataset.bindingId === bindingId)
196
251
  const next = root?.contains(target) && !target.disabled ? target
197
- : reloadItemRef.current?.closest('button') ?? root?.querySelector('button:not(:disabled)')
198
- next?.focus({ preventScroll: true })
252
+ : currentRow ?? (bindingId !== undefined && catalog?.entries.some(entry => entry.id === bindingId && !entry.enabled)
253
+ ? disabledGroupRef.current : null) ?? reloadItemRef.current ?? root?.querySelector('button:not(:disabled)')
254
+ focusMenuItem(next)
199
255
  }, [catalogStatus, menuOpen])
200
256
  React.useLayoutEffect(() => {
201
257
  if (!menuOpen || menu.mode === 'hover') return
202
258
  // The public Menu commits its measured position before keyboard focus enters it.
203
259
  let cancelled = false
204
260
  queueMicrotask(() => {
205
- if (!cancelled && anchorRef.current?.getClientRects().length) menuElement()?.querySelector('button:not(:disabled)')?.focus({ preventScroll: true })
261
+ if (!cancelled && anchorRef.current?.getClientRects().length) focusMenuItem(menuElement()?.querySelector('button:not(:disabled)'))
206
262
  })
207
263
  return () => { cancelled = true }
208
264
  }, [menuOpen, menu?.mode, menu?.step])
@@ -217,18 +273,17 @@ export function createAuthoringView(React, deps) {
217
273
  setProbing(false)
218
274
  }, [probing, menuChildren])
219
275
  React.useEffect(() => {
220
- if (toast === null || typeof Toast === 'function') return undefined
276
+ if (toast === null || isHostComponent(Toast)) return undefined
221
277
  const timer = setTimeout(() => setToast(null), 2_500)
222
278
  return () => clearTimeout(timer)
223
279
  }, [toast])
224
280
  const label = hasDraft ? t('bindings.reviewMenuLabel', { count: 1 })
225
- + (view.message ? ` · ${t('bindings.reviewAttention')}` : '') : t('bindings.open')
226
- const hint = t(hasDraft ? 'bindings.draftHint' : 'bindings.openHint')
227
- // Every authoring action goes through the same busy guard and input owner, so a
228
- // new or revision request can never overwrite an unsent user draft.
229
- const prefillAuthoring = value => {
281
+ + (view.message ? ` · ${t('bindings.reviewAttention')}` : '') : t(quickAccess ? 'bindings.open' : 'settings.toolsMenu')
282
+ const hint = t(hasDraft ? 'bindings.draftHint' : quickAccess ? 'bindings.openHint' : 'settings.toolsMenu')
283
+ // Command shortcuts share the composer guard; submission remains the user's action.
284
+ const prefillCommand = (value, allowed) => {
230
285
  hideMenu()
231
- if (!canAuthor) return
286
+ if (!allowed) return
232
287
  if (typeof input?.draft === 'string' && input.draft.trim() !== '') {
233
288
  toastSequence.current += 1
234
289
  setToast({ sequence: toastSequence.current, text: t('bindings.composerBusy') })
@@ -236,12 +291,12 @@ export function createAuthoringView(React, deps) {
236
291
  }
237
292
  inputActions.setDraft(value)
238
293
  }
239
- const openAuthoring = () => prefillAuthoring('/binding new ')
240
- const openAuthoringEdit = entry => prefillAuthoring(`/binding edit ${entry.id} `)
294
+ const openAuthoring = () => prefillCommand('/binding new ', canAuthor)
295
+ const openAuthoringEdit = entry => prefillCommand(`/binding edit ${entry.id} `, canAuthor)
241
296
  const starButton = h('button', {
242
297
  type: 'button', className: 'ptcPlusAuthorButton', 'aria-label': label,
243
298
  // Older UI-kit lines ship no Tooltip primitive; the native title carries the hint there.
244
- title: typeof Tooltip === 'function' || menuOpen || managing || settingsOpen ? undefined : hint,
299
+ title: isHostComponent(Tooltip) || menuOpen || managing || settingsOpen ? undefined : hint,
245
300
  'aria-haspopup': 'menu', 'aria-expanded': menuOpen,
246
301
  onPointerEnter: hoverMenu,
247
302
  onPointerLeave: cancelHoverOpen,
@@ -261,15 +316,30 @@ export function createAuthoringView(React, deps) {
261
316
  // and drops a duplicate, while disabling rows would dim every row and add a
262
317
  // pending row for the duration of the request: both read as the popover
263
318
  // flickering when the user toggles an entry.
319
+ const catalogRow = entry => ({ id: `global:${entry.id}`, bindingId: entry.id,
320
+ disabled: catalog === null,
321
+ label: h('span', { className: 'ptcPlusBindingQuickRow', 'data-enabled': entry.enabled },
322
+ h('span', { className: 'ptcPlusBindingQuickName' }, h('strong', { title: entry.name }, entry.name),
323
+ h('span', { className: 'ptcPlusBindingQuickState' }, t(entry.enabled ? 'bindings.enabled' : 'bindings.disabledEntry'))),
324
+ h('span', { className: 'ptcPlusBindingQuickPurpose', title: entry.purpose }, entry.purpose)),
325
+ })
326
+ const enabledEntries = (catalog?.entries ?? []).filter(entry => entry.enabled)
327
+ const disabledEntries = (catalog?.entries ?? []).filter(entry => !entry.enabled)
264
328
  const catalogItems = !quickAccess ? [] : [
265
- { id: 'global-heading', type: 'label', text: t('bindings.quickHeading') },
266
- ...(catalog?.entries ?? []).map(entry => ({ id: `global:${entry.id}`,
267
- disabled: catalog === null,
268
- label: h('span', { className: 'ptcPlusBindingQuickRow', 'data-enabled': entry.enabled },
269
- h('span', { className: 'ptcPlusBindingQuickName' }, h('strong', { title: entry.name }, entry.name),
270
- h('span', { className: 'ptcPlusBindingQuickState' }, t(entry.enabled ? 'bindings.enabled' : 'bindings.disabledEntry'))),
271
- h('span', { className: 'ptcPlusBindingQuickPurpose', title: entry.purpose }, entry.purpose)),
272
- })),
329
+ ...(catalog === null || catalog.entries.length === 0 ? [] : [
330
+ { id: 'enabled-heading', type: 'label', text: t('bindings.quickEnabled', { count: enabledEntries.length }) },
331
+ ...enabledEntries.map(catalogRow),
332
+ ...(enabledEntries.length === 0 ? [{ id: 'no-enabled', type: 'label', text: t('bindings.quickNoEnabled') }] : []),
333
+ ...(disabledEntries.length === 0 ? [] : [
334
+ { id: 'disabled-group', label: h(React.Fragment, null,
335
+ h('svg', { className: 'ptcPlusMenuGroupChevron', 'aria-hidden': true,
336
+ width: 14, height: 14, viewBox: '0 0 14 14', fill: 'none', 'data-expanded': disabledOpen },
337
+ h('path', { d: 'm5 3 4 4-4 4', stroke: 'currentColor', strokeWidth: 1.5,
338
+ strokeLinecap: 'round', strokeLinejoin: 'round' })),
339
+ h('span', null, t('bindings.quickDisabled', { count: disabledEntries.length }))) },
340
+ ...(disabledOpen ? disabledEntries.map(catalogRow) : []),
341
+ ]),
342
+ ]),
273
343
  ...(catalog !== null && catalog.entries.length === 0
274
344
  ? [{ id: 'empty', type: 'label', text: t('memory.globalEmpty') }] : []),
275
345
  ...(catalog === null && catalogStatus !== 'error'
@@ -281,20 +351,11 @@ export function createAuthoringView(React, deps) {
281
351
  // submenu here would lose the bounded, scrollable catalog this step needs.
282
352
  const editItems = [
283
353
  { id: 'edit-heading', type: 'label', text: t('bindings.quickEditHeading') },
284
- ...(catalog?.entries ?? []).map(entry => ({
285
- id: `edit:${entry.id}`,
286
- disabled: catalog === null,
287
- label: h('span', { className: 'ptcPlusBindingQuickRow' },
288
- h('span', { className: 'ptcPlusBindingQuickName' }, h('strong', { title: entry.name }, entry.name),
289
- h('span', { className: 'ptcPlusBindingQuickState' }, t(entry.enabled ? 'bindings.enabled' : 'bindings.disabledEntry'))),
290
- h('span', { className: 'ptcPlusBindingQuickPurpose', title: entry.purpose }, entry.purpose)),
291
- })),
292
- // The back row also anchors menu lookup, so the step still owns its list
293
- // when the catalog is momentarily empty.
294
- { id: 'edit-back', label: h('span', { className: 'ptcPlusBindingMenuAction', ref: firstItemRef }, t('bindings.back')) },
354
+ ...(catalog?.entries ?? []).map(entry => ({ ...catalogRow(entry), id: `edit:${entry.id}` })),
295
355
  ]
296
356
  const selectMenuItem = id => {
297
357
  if (!anchorRef.current?.getClientRects().length) return
358
+ if (id === 'disabled-group') { setDisabledOpen(open => !open); return }
298
359
  if (id === 'edit') {
299
360
  setMenu(current => current === null ? current : { ...current, step: 'edit' })
300
361
  return
@@ -319,6 +380,8 @@ export function createAuthoringView(React, deps) {
319
380
  setSettingsOpen(true)
320
381
  return
321
382
  }
383
+ if (id === 'capabilities') { hideMenu(); setCapabilitiesOpen(true); return }
384
+ if (id === 'refresh') { prefillCommand('/capabilities refresh', canRefresh); return }
322
385
  hideMenu()
323
386
  if (id === 'new') openAuthoring()
324
387
  else if (id === 'manage') {
@@ -329,14 +392,29 @@ export function createAuthoringView(React, deps) {
329
392
  }
330
393
  // One copy of each action label serves the grid the plugin renders itself,
331
394
  // in whichever carrier the installed Menu publishes for it.
332
- const actionCopy = (id, copy, ref) => h('span', {
333
- ref, className: 'ptcPlusBindingMenuAction',
395
+ const actionPaths = {
396
+ new: 'M8 3v10M3 8h10',
397
+ edit: 'm10 3 3 3M3 13l3-.7 7-7a1.4 1.4 0 0 0-3-3l-7 7L3 13Z',
398
+ refresh: 'M13 6a5 5 0 0 0-9-2L2 6m0-3v3h3M3 10a5 5 0 0 0 9 2l2-2m0 3v-3h-3',
399
+ manage: 'M2 4h5l1.5 2H14v7H2V4Z',
400
+ capabilities: 'M3 4h10M3 8h10M3 12h10M6 2v4M10 6v4M6 10v4',
401
+ settings: 'M6 2h4l.5 2 1.5 1.5 2 .5v4l-2 .5-1.5 1.5-.5 2H6l-.5-2L4 10.5 2 10V6l2-.5L5.5 4 6 2ZM10 8a2 2 0 1 0-4 0 2 2 0 0 0 4 0Z',
402
+ 'edit-back': 'm7 3-5 5 5 5M2 8h12',
403
+ }
404
+ const actionCopy = (id, copy) => h(React.Fragment, null,
405
+ h('svg', { className: 'ptcPlusMenuActionIcon', width: 16, height: 16, viewBox: '0 0 16 16',
406
+ fill: 'none', 'aria-hidden': true }, h('path', { d: actionPaths[id], stroke: 'currentColor',
407
+ strokeWidth: 1.4, strokeLinecap: 'round', strokeLinejoin: 'round' })), h('span', {
408
+ className: 'ptcPlusBindingMenuAction',
334
409
  title: id === 'settings' ? settingsPath(t) : undefined,
335
- }, t(copy))
336
- const actionButton = (id, copy, ref) => h('button', {
410
+ }, t(copy)))
411
+ const actionButton = (id, copy) => h('button', {
337
412
  type: 'button', role: 'menuitem', className: 'ptcPlusMenuButton',
413
+ disabled: id === 'refresh' && !canRefresh,
414
+ title: id === 'refresh' && !capabilityAvailable ? t('capabilities.refreshUnavailable')
415
+ : id === 'settings' ? settingsPath(t) : t(copy),
338
416
  onClick: () => selectMenuItem(id),
339
- }, actionCopy(id, copy, ref))
417
+ }, actionCopy(id, copy))
340
418
  const actionSections = [
341
419
  canAuthor ? h('div', { key: 'authoring', className: 'ptcPlusMenuAuthoring', role: 'group', 'aria-label': t('bindings.authorGroup') },
342
420
  h('div', { className: 'ptcPlusMenuGroupLabel' }, t('bindings.authorGroup')),
@@ -344,49 +422,61 @@ export function createAuthoringView(React, deps) {
344
422
  actionButton('new', 'bindings.authorNewDraft'),
345
423
  (catalog?.entries?.length ?? 0) > 0 ? actionButton('edit', 'bindings.authorEdit') : null)) : null,
346
424
  h('div', { key: 'utilities', className: 'ptcPlusMenuUtilities' },
347
- quickAccess ? actionButton('manage', 'bindings.manage', manageItemRef) : null,
348
- actionButton('settings', 'settings.menuEntry', settingsItemRef)),
425
+ actionButton('refresh', 'capabilities.applySession'),
426
+ quickAccess ? actionButton('manage', 'bindings.manage') : null,
427
+ SessionCapabilityControls ? actionButton('capabilities', 'capabilities.quick.title') : null,
428
+ actionButton('settings', 'settings.menuEntry')),
429
+ ]
430
+ const menuEntries = menu?.step === 'edit' ? editItems : [
431
+ ...(hasDraft ? [{ id: 'draft-heading', type: 'label', text: t('bindings.quickDrafts') }, {
432
+ id: view.candidateKey,
433
+ label: h('span', { className: 'ptcPlusDraftMenuItem' },
434
+ h('strong', null, view.candidate.entry.name), h('span', null, t(bindingReviewStatus(view)))),
435
+ }, ...(quickAccess ? [{ id: 'draft-separator', type: 'separator' }] : [])] : []),
436
+ ...catalogItems,
437
+ ...(quickAccess && catalogError !== null ? [{ id: 'reload', label: t('bindings.reload') }] : []),
349
438
  ]
350
- const actionsInRegion = menu?.step === 'edit'
351
- ? null : h('div', { className: 'ptcPlusMenuActions' }, actionSections)
352
- // The plugin's own action area, never a row list. A Client that mounts no
353
- // `children` region still renders the pinned entry area, which carries the
354
- // same grouped grid; the published `text` type names a string while the
355
- // implementation renders the value, so this stays the plugin's own markup
356
- // rather than a set of host-styled rows. Neither carrier enters the draft
357
- // menu's second step, which owns its own rows.
358
- const actionsPinned = menuChildrenRegion || menu?.step === 'edit' ? [] : [{
359
- id: 'ptc-plus-actions',
360
- type: 'label',
361
- text: h('div', { className: 'ptcPlusMenuActions ptcPlusMenuActionsPinned' }, actionSections),
362
- }]
439
+ const menuContent = h('div', { className: 'ptcPlusBindingMenuContent', ref: menuBodyRef,
440
+ // A reopened menu can acquire foreground focus while an earlier write is
441
+ // pending. Recovery follows the live menu, not the initiating operation.
442
+ onFocusCapture: event => { catalogFocus.current = event.target },
443
+ onBlurCapture: event => {
444
+ if (!event.currentTarget.contains(event.relatedTarget)) catalogFocus.current = null
445
+ },
446
+ },
447
+ h('div', { className: 'ptcPlusBindingMenuScroll' }, ...menuEntries.map(entry => entry.type === 'separator'
448
+ ? h('div', { key: entry.id, role: 'separator', className: 'ptcPlusOwnedMenuSeparator' })
449
+ : entry.type === 'label'
450
+ ? h('div', { key: entry.id, className: 'ptcPlusMenuGroupLabel' }, entry.text)
451
+ : h('button', {
452
+ key: entry.id, type: 'button', role: 'menuitem',
453
+ className: entry.id === 'disabled-group' ? 'ptcPlusOwnedMenuRow ptcPlusMenuDisclosure' : 'ptcPlusOwnedMenuRow',
454
+ disabled: entry.disabled, 'data-binding-id': entry.bindingId,
455
+ ref: entry.id === 'reload' ? reloadItemRef : entry.id === 'disabled-group' ? disabledGroupRef : undefined,
456
+ 'aria-expanded': entry.id === 'disabled-group' ? disabledOpen : undefined,
457
+ onClick: () => selectMenuItem(entry.id),
458
+ }, entry.label))),
459
+ h('div', { className: 'ptcPlusMenuActions' }, menu?.step === 'edit'
460
+ ? h('div', { className: 'ptcPlusMenuUtilities' }, actionButton('edit-back', 'bindings.back')) : actionSections))
363
461
  return h('span', {
364
462
  className: 'ptcPlusComposerBindingAnchor', tabIndex: -1,
365
463
  onFocusCapture: () => { focused.current = true }, onBlurCapture: () => { focused.current = false },
366
464
  'data-text': isHostIconComponent(IconSparkle16) ? undefined : true,
367
465
  ref: attachAnchor,
368
466
  },
369
- !hasDraft && !quickAccess ? null : h(Menu, {
467
+ !pluginEnabled ? null : h(menuChildrenRegion ? Menu : MenuFallback, {
370
468
  className: 'ptcPlusAuthorButtonShell', open: menuOpen,
371
- anchor: typeof Tooltip === 'function'
469
+ anchor: isHostComponent(Tooltip)
372
470
  ? h(Tooltip, { label: hint, delayMs: 700, disabled: menuOpen || managing || settingsOpen }, starButton) : starButton,
373
471
  portal: true, side: 'top', dense: true,
472
+ listClassName: 'ptcPlusBindingMenu',
374
473
  // A hovered menu leaves with the pointer once it leaves the trigger and the
375
474
  // list for the published grace, which re-entering either cancels; click and
376
475
  // keyboard menus stay until dismissed.
377
476
  closeOnPointerLeave: menu !== null && menu.mode === 'hover',
378
477
  getAnchorRect: menuAnchorRect,
379
478
  onClose: hideMenu,
380
- items: menu?.step === 'edit' ? editItems : [...(hasDraft ? [{ id: 'draft-heading', type: 'label', text: t('bindings.quickDrafts') }, { id: view.candidateKey,
381
- label: h('span', { className: 'ptcPlusDraftMenuItem', ref: firstItemRef },
382
- h('strong', null, view.candidate.entry.name),
383
- h('span', null, t(bindingReviewStatus(view)))) },
384
- ...(quickAccess ? [{ id: 'draft-separator', type: 'separator' }] : [])] : []), ...catalogItems,
385
- ...(quickAccess && catalogError !== null
386
- ? [{ id: 'reload', label: h('span', { ref: reloadItemRef }, t('bindings.reload')) }] : []),
387
- ],
388
- children: actionsInRegion,
389
- ...(actionsPinned.length === 0 ? {} : { footer: actionsPinned }),
479
+ items: [], children: menuContent,
390
480
  onSelect: selectMenuItem,
391
481
  }),
392
482
  // The probe mounts the host Menu's own `children` region inside a hidden
@@ -404,25 +494,33 @@ export function createAuthoringView(React, deps) {
404
494
  controller: workbench, t, onClose: () => setManaging(false), returnFocusRef: dialogReturnFocus,
405
495
  getReturnFocus: () => anchorRef.current?.querySelector('.ptcPlusAuthorButton'),
406
496
  }) : null,
497
+ SessionCapabilityControls && capabilitiesOpen && view.reachable
498
+ ? h(SessionCapabilityControls, { sessionId, open: true, t,
499
+ enabled: pluginEnabled && settings.status === 'ready' && settings.writable === true,
500
+ onClose: () => {
501
+ setCapabilitiesOpen(false)
502
+ queueMicrotask(() => anchorRef.current?.querySelector('.ptcPlusAuthorButton')
503
+ ?.focus({ preventScroll: true }))
504
+ } }) : null,
407
505
  settingsOpen && view.reachable
408
506
  ? h(PTCPlusSettingsDialog, {
409
- t, usePtcSettings, updateSetting, callUserBindings,
507
+ t, usePtcSettings, updateSetting, updateCapabilityConfiguration, callUserBindings,
410
508
  onClose: () => {
411
509
  setSettingsOpen(false)
412
510
  queueMicrotask(() => anchorRef.current?.querySelector('.ptcPlusAuthorButton')
413
511
  ?.focus({ preventScroll: true }))
414
512
  },
415
513
  }) : null,
416
- toast === null ? null : typeof Toast === 'function'
514
+ toast === null ? null : isHostComponent(Toast)
417
515
  ? h(Toast, {
418
516
  key: toast.sequence,
419
- text: toast.text,
517
+ text: toast.key ? t(toast.key, toast.params) : capabilityCommandText(t, toast.text),
420
518
  anchor: anchorRef.current,
421
519
  onDone: () => setToast(null),
422
520
  })
423
521
  : h('span', {
424
522
  key: toast.sequence, className: 'ptcPlusComposerNotice', role: 'status',
425
- }, toast.text))
523
+ }, toast.key ? t(toast.key, toast.params) : capabilityCommandText(t, toast.text)))
426
524
  }
427
525
 
428
526
  function BindingCandidateContent({ candidate, t, showName = true }) {
@@ -432,7 +530,7 @@ export function createAuthoringView(React, deps) {
432
530
  showName ? h('strong', null, candidate.entry.name) : null,
433
531
  h('span', { className: 'ptcPlusBindingMeta' }, `${candidate.entry.scope} - ${candidate.entry.symbols.join(', ')}`),
434
532
  candidate.entry.purpose ? h('p', { className: 'ptcPlusMessage' }, candidate.entry.purpose) : null,
435
- typeof CodeBlock === 'function'
533
+ isHostComponent(CodeBlock)
436
534
  ? h(CodeBlock, { code: candidate.entry.source, lang: 'typescript', className: 'ptcPlusBindingCommandCode',
437
535
  copyLabel: t('tool.copy'), copiedLabel: t('tool.copied') })
438
536
  : h('pre', { className: 'ptcPlusBindingCommandSource' }, candidate.entry.source),
@@ -450,22 +548,20 @@ export function createAuthoringView(React, deps) {
450
548
  preferences.instructions || t('bindings.noInstructions'))))))
451
549
  }
452
550
 
453
- // Native editable semantics are enough to restore focus; no Host class, store or editor internals are used.
454
551
  function focusComposer(anchor) {
455
552
  if (!anchor || anchor.getClientRects().length === 0) return
456
- for (let parent = anchor.parentElement; parent; parent = parent.parentElement) {
457
- const editable = [...parent.querySelectorAll('textarea, [contenteditable="true"]')]
458
- .find(element => element.getClientRects().length > 0 && !element.disabled)
459
- if (editable) { editable.focus({ preventScroll: true }); return }
460
- }
461
553
  anchor.focus({ preventScroll: true })
462
554
  }
463
555
 
464
- function openBindingReview(review, candidate = review.getSnapshot().candidate) {
465
- if (!review.display('expanded', candidate)) return
556
+ function openBindingReview(review, candidate = review.getSnapshot().candidate,
557
+ visibility = 'enlarged') {
558
+ if (!review.display(visibility, candidate)) return
466
559
  requestAnimationFrame(() => {
467
- if (review.getSnapshot().candidate === candidate && review.panel?.getClientRects().length) {
468
- review.panel.focus({ preventScroll: true })
560
+ const current = review.getSnapshot()
561
+ if (review.isCurrentCandidate(candidate) && current.reachable && current.visibility === 'expanded'
562
+ && review.panel?.getClientRects().length) {
563
+ if (review.previewAvailable === false) focusBindingReviewAccess(review)
564
+ else review.panel.focus({ preventScroll: true })
469
565
  }
470
566
  })
471
567
  }
@@ -477,60 +573,6 @@ export function createAuthoringView(React, deps) {
477
573
  else focusComposer(anchor)
478
574
  }
479
575
 
480
- function fitBindingReview(panel) {
481
- const body = panel?.querySelector('.ptcPlusBindingDockBody')
482
- const anchor = panel?.parentElement
483
- if (!anchor || typeof ResizeObserver !== 'function') return undefined
484
- const ancestors = []
485
- let seat = panel
486
- let viewport
487
- for (let parent = panel.parentElement; parent; parent = parent.parentElement) {
488
- ancestors.push(parent)
489
- if (/auto|scroll|hidden|clip/.test(getComputedStyle(parent).overflowY)) {
490
- viewport = parent
491
- break
492
- }
493
- seat = parent
494
- }
495
- if (!viewport) return undefined
496
- let frame
497
- const update = () => {
498
- if (!panel.getClientRects().length) return
499
- const top = Math.max(viewport.getBoundingClientRect().top + viewport.clientTop,
500
- window.visualViewport?.offsetTop ?? 0)
501
- // Float above the complete composer stack without changing its height or covering other docks.
502
- const offset = anchor.getBoundingClientRect().bottom - seat.getBoundingClientRect().top + 8
503
- panel.style.setProperty('--ptc-plus-review-offset', `${Math.max(8, Math.ceil(offset))}px`)
504
- const room = Math.max(0, seat.getBoundingClientRect().top - top - 16)
505
- panel.style.setProperty('--ptc-plus-review-height', `${Math.floor(room)}px`)
506
- if (body) {
507
- const chrome = panel.scrollHeight - body.offsetHeight + 2
508
- const available = Math.max(0, room - chrome)
509
- // In short viewports one scroller keeps source and actions reachable together.
510
- panel.dataset.scroll = available < 80 ? 'panel' : 'body'
511
- body.style.setProperty('--ptc-plus-review-space', `${Math.floor(available)}px`)
512
- }
513
- }
514
- const schedule = () => {
515
- cancelAnimationFrame(frame)
516
- frame = requestAnimationFrame(update)
517
- }
518
- const observer = new ResizeObserver(schedule)
519
- for (const element of [panel, ...panel.children, ...ancestors]) observer.observe(element)
520
- window.visualViewport?.addEventListener('resize', schedule)
521
- window.visualViewport?.addEventListener('scroll', schedule)
522
- update()
523
- return () => {
524
- observer.disconnect()
525
- cancelAnimationFrame(frame)
526
- window.visualViewport?.removeEventListener('resize', schedule)
527
- window.visualViewport?.removeEventListener('scroll', schedule)
528
- panel.style.removeProperty('--ptc-plus-review-offset')
529
- panel.style.removeProperty('--ptc-plus-review-height')
530
- delete panel.dataset.scroll
531
- body?.style.removeProperty('--ptc-plus-review-space')
532
- }
533
- }
534
576
 
535
577
  function BindingReviewDock({ sessionId, useProjection, t }) {
536
578
  const raw = useProjection('ptcPlusBindingDraft')
@@ -549,10 +591,75 @@ export function createAuthoringView(React, deps) {
549
591
  React.useLayoutEffect(() => { review.sync(projection) }, [review, projection])
550
592
  const title = React.useId()
551
593
  const content = React.useId()
552
- const expanded = view.visibility === 'expanded'
594
+ const anchor = React.useRef(null)
595
+ const layer = React.useRef(null)
596
+ const [compact, setCompact] = React.useState(false)
597
+ const enlarged = view.visibility === 'enlarged'
598
+ const expanded = enlarged || view.visibility === 'expanded' && !compact
553
599
  const candidateKey = view.candidateKey
554
- React.useLayoutEffect(() => fitBindingReview(review.panel),
555
- [review, candidateKey, expanded, view.visibility, view.message])
600
+ React.useLayoutEffect(() => {
601
+ if (enlarged || !view.reachable || view.visibility === 'hidden' || view.candidate === null) return undefined
602
+ let frame
603
+ let previous
604
+ // A dock can move when input or sibling cards grow without a scroll/resize.
605
+ // Track only our own anchor while visible; no Host node owns this placement.
606
+ const place = () => {
607
+ const rect = anchor.current?.getBoundingClientRect()
608
+ const surface = layer.current
609
+ if (rect && surface) {
610
+ const viewport = window.visualViewport
611
+ const leftEdge = (viewport?.offsetLeft ?? 0) + 8
612
+ const topEdge = (viewport?.offsetTop ?? 0) + 8
613
+ const rightEdge = leftEdge + (viewport?.width ?? window.innerWidth) - 16
614
+ const bottomEdge = topEdge + (viewport?.height ?? window.innerHeight) - 16
615
+ const width = Math.min(rect.width, rightEdge - leftEdge)
616
+ const left = Math.max(leftEdge, Math.min(rect.left, rightEdge - width))
617
+ if (surface.style.width !== `${width}px`) surface.style.width = `${width}px`
618
+ const panel = surface.querySelector('.ptcPlusBindingDock')
619
+ const head = panel?.querySelector('.ptcPlusBindingDockHead')
620
+ const actions = panel?.querySelector('.ptcPlusBindingDockActions')
621
+ const body = panel?.querySelector('.ptcPlusBindingDockBody')
622
+ const panelStyle = panel && getComputedStyle(panel)
623
+ const border = (parseFloat(panelStyle?.borderTopWidth) || 0) + (parseFloat(panelStyle?.borderBottomWidth) || 0)
624
+ const chrome = (head?.getBoundingClientRect().height ?? 0) + border
625
+ const bottom = Math.max(topEdge, Math.min(Math.max(topEdge + chrome, rect.top - 8), rect.top, bottomEdge))
626
+ const height = bottom - topEdge
627
+ const geometry = [width, left, bottom, height, chrome].join(',')
628
+ if (geometry !== previous) {
629
+ previous = geometry
630
+ surface.style.left = `${left}px`
631
+ surface.style.bottom = `${window.innerHeight - bottom}px`
632
+ surface.style.setProperty('--ptc-plus-review-space', `${height}px`)
633
+ }
634
+ if (rect.width > 0) {
635
+ const available = surface.getBoundingClientRect().height >= chrome && bottom <= rect.top
636
+ review.previewAvailable = available
637
+ if (surface.dataset.available !== String(available)) {
638
+ surface.dataset.available = String(available)
639
+ if (available) {
640
+ surface.removeAttribute('inert')
641
+ surface.removeAttribute('aria-hidden')
642
+ } else {
643
+ surface.setAttribute('inert', '')
644
+ surface.setAttribute('aria-hidden', 'true')
645
+ }
646
+ }
647
+ }
648
+ // CSS owns the viewport cap. Full chrome stays measurable in compact
649
+ // mode so hiding it cannot make the layout classification oscillate.
650
+ if (head && actions && body && rect.width > 0) {
651
+ const minimumBody = parseFloat(getComputedStyle(body).minBlockSize)
652
+ const required = chrome + actions.getBoundingClientRect().height + minimumBody
653
+ setCompact(surface.getBoundingClientRect().height < required)
654
+ }
655
+ }
656
+ frame = requestAnimationFrame(place)
657
+ }
658
+ place()
659
+ return () => {
660
+ cancelAnimationFrame(frame)
661
+ }
662
+ }, [candidateKey, enlarged, view.reachable, view.visibility, view.candidate === null])
556
663
  const close = () => {
557
664
  review.display('hidden')
558
665
  requestAnimationFrame(() => focusBindingReviewAccess(review))
@@ -562,44 +669,55 @@ export function createAuthoringView(React, deps) {
562
669
  if (review.panel?.contains(document.activeElement)) review.panel.focus({ preventScroll: true })
563
670
  void review.act(operation, activate)
564
671
  }
672
+ const restorePreview = () => openBindingReview(review, view.candidate, 'expanded')
565
673
  if (view.candidate === null || view.visibility === 'hidden') return null
566
- return h('div', { className: 'ptcPlusBindingDockAnchor' },
567
- h('section', { key: candidateKey, className: 'ptcPlusBindingDock', 'aria-labelledby': title, tabIndex: -1,
674
+ const panel = h('section', { key: candidateKey, className: 'ptcPlusBindingDock', 'aria-labelledby': title, tabIndex: -1,
568
675
  ref: element => { review.panel = element },
569
676
  onFocusCapture: () => { focused.current = true },
570
677
  onBlurCapture: event => {
571
678
  // Blink blurs the removed panel before layout effects; completion still owns that lost focus.
572
679
  if (event.relatedTarget || review.getSnapshot().action === null) focused.current = false
573
680
  },
574
- 'aria-busy': view.busy },
681
+ 'aria-busy': view.busy, 'data-compact': !enlarged && compact },
575
682
  h('div', { className: 'ptcPlusBindingDockHead' },
576
- h('button', { type: 'button', className: 'ptcPlusBindingDockToggle',
577
- 'aria-label': t(expanded ? 'bindings.reviewCollapse' : 'bindings.reviewExpand'),
578
- 'aria-expanded': expanded, 'aria-controls': expanded ? content : undefined,
579
- onClick: () => review.display(expanded ? 'collapsed' : 'expanded') },
683
+ h(enlarged ? 'div' : 'button', { type: enlarged ? undefined : 'button', className: 'ptcPlusBindingDockToggle',
684
+ 'aria-label': enlarged ? undefined : t(expanded ? 'bindings.reviewCollapse' : 'bindings.reviewExpand'),
685
+ 'aria-expanded': enlarged ? undefined : expanded, 'aria-controls': expanded && !enlarged ? content : undefined,
686
+ onClick: enlarged ? undefined : () => review.display(compact ? 'enlarged' : expanded ? 'collapsed' : 'expanded') },
580
687
  h('span', { className: 'ptcPlusBindingDockSymbol', 'aria-hidden': true }, '</>'),
581
688
  h('span', { className: 'ptcPlusBindingDockHeading' },
582
689
  h('strong', { id: title, title: view.candidate.entry.name }, view.candidate.entry.name),
583
690
  h('span', { role: 'status', title: t(bindingReviewStatus(view)) },
584
691
  `${t('bindings.reviewTitle')} · ${t(bindingReviewStatus(view))}`)),
585
- h('span', { className: 'ptcPlusBindingDockChevron', 'aria-hidden': true },
692
+ enlarged ? null : h('span', { className: 'ptcPlusBindingDockChevron', 'aria-hidden': true },
586
693
  h(IconChevronDownOutline14, { size: 16 }))),
587
- h(IconButton, { icon: IconCloseOutline16, label: t('bindings.reviewClose'), onClick: close })),
588
- !expanded ? null : h(React.Fragment, null,
694
+ h('div', { className: 'ptcPlusBindingDockControls' },
695
+ h(IconButton, { icon: enlarged ? RestoreIcon : EnlargeIcon,
696
+ label: t(enlarged ? 'bindings.reviewRestore' : 'bindings.reviewEnlarge'),
697
+ onClick: enlarged ? restorePreview : () => review.display('enlarged', view.candidate) }),
698
+ h(IconButton, { icon: isHostIconComponent(IconCloseOutline16) ? IconCloseOutline16 : ReviewCloseIcon,
699
+ label: t('bindings.reviewClose'), onClick: close }))),
700
+ !enlarged && view.visibility !== 'expanded' ? null : h(React.Fragment, null,
589
701
  h('div', { className: 'ptcPlusBindingDockBody', id: content, tabIndex: 0,
702
+ inert: !expanded ? '' : undefined, 'aria-hidden': !expanded ? true : undefined,
590
703
  role: 'region', 'aria-label': t('bindings.source') },
704
+ view.message === null ? null : h('p', { className: 'ptcPlusMessage ptcPlusDanger', role: 'status' }, t(view.message)),
705
+ view.message === null ? null : h(ActionButton, { disabled: view.busy,
706
+ onClick: () => review.reset() }, t('bindings.reviewRetry')),
591
707
  h(BindingCandidateContent, { candidate: view.candidate, t, showName: false })),
592
- view.message === null ? null : h('p', { className: 'ptcPlusMessage ptcPlusDanger', role: 'status' }, t(view.message)),
593
- h('div', { className: 'ptcPlusBindingDockActions' },
708
+ h('div', { className: 'ptcPlusBindingDockActions',
709
+ inert: !expanded ? '' : undefined, 'aria-hidden': !expanded ? true : undefined },
594
710
  h(ActionButton, { className: 'ptcPlusBindingDockDiscard', 'data-kind': 'ghost',
595
711
  disabled: !view.writable || view.busy,
596
712
  onClick: () => act('discard-draft') }, t('bindings.draftDiscard')),
597
713
  h(ActionButton, { disabled: !view.writable || view.busy,
598
714
  onClick: () => act('save-draft', false) }, t('bindings.draftSave')),
599
715
  h(ActionButton, { 'data-kind': 'primary', disabled: !view.writable || view.busy,
600
- onClick: () => act('save-draft', true) }, t('bindings.draftSaveEnable')),
601
- view.message === null ? null : h(ActionButton, { disabled: view.busy,
602
- onClick: () => review.reset() }, t('bindings.reviewRetry'))))))
716
+ onClick: () => act('save-draft', true) }, t('bindings.draftSaveEnable')))))
717
+ return h('div', { className: 'ptcPlusBindingDockAnchor', ref: anchor }, enlarged
718
+ ? h(Modal, { open: view.reachable, title: `${view.candidate.entry.name} · ${t('bindings.reviewTitle')}`,
719
+ headless: true, className: 'ptcPlusBindingReviewModal', onClose: restorePreview }, panel)
720
+ : !view.reachable ? null : createPortal(h('div', { className: 'ptcPlusBindingPreviewLayer', ref: layer }, panel), document.body))
603
721
  }
604
722
 
605
723
  function BindingCommandCard({ node, sessionId, t, useProjection }) {