dsh-ptc-plus 0.4.8 → 0.4.10

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 (62) hide show
  1. package/README.en.md +11 -3
  2. package/README.md +11 -3
  3. package/client.js +1151 -529
  4. package/cordis.patch.yml +12 -2
  5. package/docs/README.md +1 -0
  6. package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +19 -0
  7. package/docs/adr/0019-plugin-settings-and-kill-switch.md +13 -2
  8. package/docs/adr/0020-optional-cordis-tools-in-ptc-mode.md +1 -1
  9. package/docs/adr/0023-global-user-bindings.md +1 -1
  10. package/docs/adr/0028-attach-to-the-host-ptc-execution-seam.md +82 -18
  11. package/docs/adr/0030-serve-both-typert-codec-generations-from-one-codec.md +3 -3
  12. package/docs/adr/0032-own-session-log-facts-in-a-host-only-projection.md +27 -0
  13. package/docs/adr/0033-gate-client-visual-usability-in-a-browser.md +23 -0
  14. package/docs/architecture.md +3 -3
  15. package/docs/binding-review-placement-design.md +29 -21
  16. package/docs/client-ui.md +20 -8
  17. package/docs/client-visual-contract.md +48 -0
  18. package/docs/durability-design.md +12 -0
  19. package/docs/installation.md +27 -6
  20. package/docs/runtime-reference.md +1 -1
  21. package/docs/user-bindings.en.md +2 -2
  22. package/docs/user-bindings.md +3 -3
  23. package/docs/verification-optimization-work.md +26 -0
  24. package/docs/verification.md +2 -0
  25. package/index.js +28 -7
  26. package/internal/compiler-platform.js +5 -3
  27. package/internal/compiler-service.js +2 -9
  28. package/internal/cordis-tools-owner.js +22 -19
  29. package/internal/direct-surface-owner.js +2 -0
  30. package/internal/edit-transport-owner.js +8 -5
  31. package/internal/execution-provider-owner.js +157 -0
  32. package/internal/execution-seam-compat.js +69 -113
  33. package/internal/isolated-worker.js +12 -8
  34. package/internal/recovery-tips.js +50 -21
  35. package/internal/runtime-bridge-owner.js +88 -57
  36. package/internal/runtime-contexts.js +13 -13
  37. package/internal/session-events.js +40 -4
  38. package/internal/session-journal-recovery.js +678 -181
  39. package/internal/session-log-view.js +189 -43
  40. package/internal/session-runtime.js +58 -10
  41. package/internal/settings-compat.js +12 -2
  42. package/internal/user-bindings-owner.js +6 -5
  43. package/locale/en.json +6 -0
  44. package/locale/zh.json +6 -0
  45. package/package.json +13 -14
  46. package/src/client-authoring-view.js +222 -141
  47. package/src/client-binding-review.js +2 -0
  48. package/src/client-code-editor.js +6 -0
  49. package/src/client-copy.js +10 -0
  50. package/src/client-feature-gates.js +12 -5
  51. package/src/client-host-compat.js +23 -2
  52. package/src/client-primitives.js +249 -0
  53. package/src/client-repl-view.js +3 -3
  54. package/src/client-settings-view.js +2 -29
  55. package/src/client-styles.js +92 -33
  56. package/src/client-tool-view.js +8 -3
  57. package/src/client-workbench.js +3 -3
  58. package/src/client.js +72 -46
  59. package/assets/ptc-plus-bindings-en.png +0 -0
  60. package/assets/ptc-plus-bindings-zh.png +0 -0
  61. package/assets/ptc-plus-repair-en.png +0 -0
  62. package/assets/ptc-plus-repair-zh.png +0 -0
@@ -3,6 +3,7 @@ import { bindingModelPreferences } from '../internal/user-binding-model-context.
3
3
  import { featureEnabled } from './client-feature-gates.js'
4
4
  import {
5
5
  MENU_CHILDREN_PROBE_SENTINEL,
6
+ isHostComponent,
6
7
  isHostIconComponent,
7
8
  readMenuChildrenProbe,
8
9
  } from './client-host-compat.js'
@@ -22,7 +23,7 @@ const HOVER_DWELL_MS = 150
22
23
  */
23
24
  export function createAuthoringView(React, deps) {
24
25
  const {
25
- ActionButton, IconButton, Menu, Toast, Tooltip, CodeBlock, BindingsDialog, PTCPlusSettingsDialog,
26
+ ActionButton, IconButton, Menu, MenuFallback, Toast, Tooltip, CodeBlock, Modal, createPortal, BindingsDialog, PTCPlusSettingsDialog,
26
27
  useWorkbenchController, useBindingReview, catalogOwner, callUserBindings, subscribeReset,
27
28
  settingsCardSeat, updateSetting, menuChildren,
28
29
  icons: {
@@ -32,6 +33,24 @@ export function createAuthoringView(React, deps) {
32
33
  } = deps
33
34
  const h = React.createElement
34
35
 
36
+ function EnlargeIcon() {
37
+ return h('svg', { width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true },
38
+ h('path', { d: 'M6 2H2v4m8-4h4v4M2 10v4h4m8-4v4h-4', stroke: 'currentColor', strokeWidth: 1.5,
39
+ strokeLinecap: 'round', strokeLinejoin: 'round' }))
40
+ }
41
+
42
+ function RestoreIcon() {
43
+ return h('svg', { width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true },
44
+ h('path', { d: 'M2 6h4V2m4 0v4h4M6 14v-4H2m12 0h-4v4', stroke: 'currentColor', strokeWidth: 1.5,
45
+ strokeLinecap: 'round', strokeLinejoin: 'round' }))
46
+ }
47
+
48
+ function ReviewCloseIcon() {
49
+ return h('svg', { width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true },
50
+ h('path', { d: 'm4 4 8 8m0-8-8 8', stroke: 'currentColor', strokeWidth: 1.5,
51
+ strokeLinecap: 'round' }))
52
+ }
53
+
35
54
  /**
36
55
  * Where the settings card lives on the installed generation.
37
56
  *
@@ -56,10 +75,10 @@ export function createAuthoringView(React, deps) {
56
75
  anchorRef.current = element
57
76
  review.access = element
58
77
  }, [review])
59
- const firstItemRef = React.useRef(null)
60
- const manageItemRef = React.useRef(null)
78
+ const menuBodyRef = React.useRef(null)
61
79
  const reloadItemRef = React.useRef(null)
62
- const settingsItemRef = React.useRef(null)
80
+ const disabledGroupRef = React.useRef(null)
81
+ const [disabledOpen, setDisabledOpen] = React.useState(false)
63
82
  const focused = React.useRef(false)
64
83
  const dialogReturnFocus = React.useRef(null)
65
84
  const hoverTimer = React.useRef(undefined)
@@ -68,8 +87,8 @@ export function createAuthoringView(React, deps) {
68
87
  const [settingsOpen, setSettingsOpen] = React.useState(false)
69
88
  // The composer entry's four actions live in the host Menu's `children`
70
89
  // 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.
90
+ // probes the installed primitive once and uses its own Menu fallback when
91
+ // that public region is absent.
73
92
  const probeRef = React.useRef(null)
74
93
  const [probing, setProbing] = React.useState(() => menuChildren.supported() === undefined)
75
94
  const menuChildrenRegion = menuChildren.supported() !== false
@@ -128,7 +147,10 @@ export function createAuthoringView(React, deps) {
128
147
  React.useEffect(() => cancelHoverOpen, [cancelHoverOpen])
129
148
  const showMenu = mode => {
130
149
  cancelHoverOpen()
131
- if (!menuOpen && quickAccess) void refreshCatalog()
150
+ if (!menuOpen) {
151
+ setDisabledOpen(false)
152
+ if (quickAccess) void refreshCatalog()
153
+ }
132
154
  setMenu({ key: view.candidateKey, mode, step: 'root' })
133
155
  }
134
156
  // Touch has no hover: its tap reaches the same menu through the click path.
@@ -140,11 +162,24 @@ export function createAuthoringView(React, deps) {
140
162
  showMenu('hover')
141
163
  }, HOVER_DWELL_MS)
142
164
  }
143
- const menuElement = () => manageItemRef.current?.closest('[role=menu]')
144
- ?? firstItemRef.current?.closest('[role=menu]')
165
+ const menuElement = () => menuBodyRef.current
145
166
  const captureCatalogFocus = () => {
146
167
  catalogFocus.current = menuElement()?.contains(document.activeElement) ? document.activeElement : null
147
168
  }
169
+ const focusMenuItem = next => {
170
+ next?.focus({ preventScroll: true })
171
+ const scroll = menuElement()?.querySelector('.ptcPlusBindingMenuScroll')
172
+ if (!scroll?.contains(next)) return
173
+ const viewport = scroll.getBoundingClientRect()
174
+ const item = next.getBoundingClientRect()
175
+ // Reveal only inside the owned catalog. Browser focus scrolling can also
176
+ // scroll the outer menu or transcript and move the fixed actions.
177
+ const offset = item.height > scroll.clientHeight
178
+ ? item.top + item.height / 2 - (viewport.top + viewport.height / 2)
179
+ : item.top < viewport.top ? item.top - viewport.top
180
+ : item.bottom > viewport.bottom ? item.bottom - viewport.bottom : 0
181
+ scroll.scrollTop += offset
182
+ }
148
183
  // The list must sit next to the entry that opened it. The published Menu reads
149
184
  // this rect on open and on every scroll/resize, and re-runs placement whenever
150
185
  // the prop identity changes, so measuring the trigger each render also keeps an
@@ -190,19 +225,22 @@ export function createAuthoringView(React, deps) {
190
225
  if (!menuOpen) { catalogFocus.current = null; return }
191
226
  if (catalogStatus === 'writing' || catalogStatus === 'loading') return
192
227
  const target = catalogFocus.current
193
- catalogFocus.current = null
194
- if (!target || document.activeElement !== document.body) return
228
+ if (!target || (document.activeElement !== document.body && document.activeElement !== target)) return
195
229
  const root = menuElement()
230
+ const bindingId = target.dataset.bindingId
231
+ const currentRow = bindingId === undefined ? null
232
+ : [...(root?.querySelectorAll('[data-binding-id]') ?? [])].find(row => row.dataset.bindingId === bindingId)
196
233
  const next = root?.contains(target) && !target.disabled ? target
197
- : reloadItemRef.current?.closest('button') ?? root?.querySelector('button:not(:disabled)')
198
- next?.focus({ preventScroll: true })
234
+ : currentRow ?? (bindingId !== undefined && catalog?.entries.some(entry => entry.id === bindingId && !entry.enabled)
235
+ ? disabledGroupRef.current : null) ?? reloadItemRef.current ?? root?.querySelector('button:not(:disabled)')
236
+ focusMenuItem(next)
199
237
  }, [catalogStatus, menuOpen])
200
238
  React.useLayoutEffect(() => {
201
239
  if (!menuOpen || menu.mode === 'hover') return
202
240
  // The public Menu commits its measured position before keyboard focus enters it.
203
241
  let cancelled = false
204
242
  queueMicrotask(() => {
205
- if (!cancelled && anchorRef.current?.getClientRects().length) menuElement()?.querySelector('button:not(:disabled)')?.focus({ preventScroll: true })
243
+ if (!cancelled && anchorRef.current?.getClientRects().length) focusMenuItem(menuElement()?.querySelector('button:not(:disabled)'))
206
244
  })
207
245
  return () => { cancelled = true }
208
246
  }, [menuOpen, menu?.mode, menu?.step])
@@ -217,7 +255,7 @@ export function createAuthoringView(React, deps) {
217
255
  setProbing(false)
218
256
  }, [probing, menuChildren])
219
257
  React.useEffect(() => {
220
- if (toast === null || typeof Toast === 'function') return undefined
258
+ if (toast === null || isHostComponent(Toast)) return undefined
221
259
  const timer = setTimeout(() => setToast(null), 2_500)
222
260
  return () => clearTimeout(timer)
223
261
  }, [toast])
@@ -241,7 +279,7 @@ export function createAuthoringView(React, deps) {
241
279
  const starButton = h('button', {
242
280
  type: 'button', className: 'ptcPlusAuthorButton', 'aria-label': label,
243
281
  // 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,
282
+ title: isHostComponent(Tooltip) || menuOpen || managing || settingsOpen ? undefined : hint,
245
283
  'aria-haspopup': 'menu', 'aria-expanded': menuOpen,
246
284
  onPointerEnter: hoverMenu,
247
285
  onPointerLeave: cancelHoverOpen,
@@ -261,15 +299,31 @@ export function createAuthoringView(React, deps) {
261
299
  // and drops a duplicate, while disabling rows would dim every row and add a
262
300
  // pending row for the duration of the request: both read as the popover
263
301
  // flickering when the user toggles an entry.
302
+ const catalogRow = entry => ({ id: `global:${entry.id}`, bindingId: entry.id,
303
+ disabled: catalog === null,
304
+ label: h('span', { className: 'ptcPlusBindingQuickRow', 'data-enabled': entry.enabled },
305
+ h('span', { className: 'ptcPlusBindingQuickName' }, h('strong', { title: entry.name }, entry.name),
306
+ h('span', { className: 'ptcPlusBindingQuickState' }, t(entry.enabled ? 'bindings.enabled' : 'bindings.disabledEntry'))),
307
+ h('span', { className: 'ptcPlusBindingQuickPurpose', title: entry.purpose }, entry.purpose)),
308
+ })
309
+ const enabledEntries = (catalog?.entries ?? []).filter(entry => entry.enabled)
310
+ const disabledEntries = (catalog?.entries ?? []).filter(entry => !entry.enabled)
264
311
  const catalogItems = !quickAccess ? [] : [
265
312
  { 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
- })),
313
+ ...(catalog === null || catalog.entries.length === 0 ? [] : [
314
+ { id: 'enabled-heading', type: 'label', text: t('bindings.quickEnabled', { count: enabledEntries.length }) },
315
+ ...enabledEntries.map(catalogRow),
316
+ ...(enabledEntries.length === 0 ? [{ id: 'no-enabled', type: 'label', text: t('bindings.quickNoEnabled') }] : []),
317
+ ...(disabledEntries.length === 0 ? [] : [
318
+ { id: 'disabled-group', label: h(React.Fragment, null,
319
+ h('svg', { className: 'ptcPlusMenuGroupChevron', 'aria-hidden': true,
320
+ width: 14, height: 14, viewBox: '0 0 14 14', fill: 'none', 'data-expanded': disabledOpen },
321
+ h('path', { d: 'm5 3 4 4-4 4', stroke: 'currentColor', strokeWidth: 1.5,
322
+ strokeLinecap: 'round', strokeLinejoin: 'round' })),
323
+ h('span', null, t('bindings.quickDisabled', { count: disabledEntries.length }))) },
324
+ ...(disabledOpen ? disabledEntries.map(catalogRow) : []),
325
+ ]),
326
+ ]),
273
327
  ...(catalog !== null && catalog.entries.length === 0
274
328
  ? [{ id: 'empty', type: 'label', text: t('memory.globalEmpty') }] : []),
275
329
  ...(catalog === null && catalogStatus !== 'error'
@@ -289,12 +343,10 @@ export function createAuthoringView(React, deps) {
289
343
  h('span', { className: 'ptcPlusBindingQuickState' }, t(entry.enabled ? 'bindings.enabled' : 'bindings.disabledEntry'))),
290
344
  h('span', { className: 'ptcPlusBindingQuickPurpose', title: entry.purpose }, entry.purpose)),
291
345
  })),
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')) },
295
346
  ]
296
347
  const selectMenuItem = id => {
297
348
  if (!anchorRef.current?.getClientRects().length) return
349
+ if (id === 'disabled-group') { setDisabledOpen(open => !open); return }
298
350
  if (id === 'edit') {
299
351
  setMenu(current => current === null ? current : { ...current, step: 'edit' })
300
352
  return
@@ -329,14 +381,14 @@ export function createAuthoringView(React, deps) {
329
381
  }
330
382
  // One copy of each action label serves the grid the plugin renders itself,
331
383
  // in whichever carrier the installed Menu publishes for it.
332
- const actionCopy = (id, copy, ref) => h('span', {
333
- ref, className: 'ptcPlusBindingMenuAction',
384
+ const actionCopy = (id, copy) => h('span', {
385
+ className: 'ptcPlusBindingMenuAction',
334
386
  title: id === 'settings' ? settingsPath(t) : undefined,
335
387
  }, t(copy))
336
- const actionButton = (id, copy, ref) => h('button', {
388
+ const actionButton = (id, copy) => h('button', {
337
389
  type: 'button', role: 'menuitem', className: 'ptcPlusMenuButton',
338
390
  onClick: () => selectMenuItem(id),
339
- }, actionCopy(id, copy, ref))
391
+ }, actionCopy(id, copy))
340
392
  const actionSections = [
341
393
  canAuthor ? h('div', { key: 'authoring', className: 'ptcPlusMenuAuthoring', role: 'group', 'aria-label': t('bindings.authorGroup') },
342
394
  h('div', { className: 'ptcPlusMenuGroupLabel' }, t('bindings.authorGroup')),
@@ -344,49 +396,58 @@ export function createAuthoringView(React, deps) {
344
396
  actionButton('new', 'bindings.authorNewDraft'),
345
397
  (catalog?.entries?.length ?? 0) > 0 ? actionButton('edit', 'bindings.authorEdit') : null)) : null,
346
398
  h('div', { key: 'utilities', className: 'ptcPlusMenuUtilities' },
347
- quickAccess ? actionButton('manage', 'bindings.manage', manageItemRef) : null,
348
- actionButton('settings', 'settings.menuEntry', settingsItemRef)),
399
+ quickAccess ? actionButton('manage', 'bindings.manage') : null,
400
+ actionButton('settings', 'settings.menuEntry')),
401
+ ]
402
+ const menuEntries = menu?.step === 'edit' ? editItems : [
403
+ ...(hasDraft ? [{ id: 'draft-heading', type: 'label', text: t('bindings.quickDrafts') }, {
404
+ id: view.candidateKey,
405
+ label: h('span', { className: 'ptcPlusDraftMenuItem' },
406
+ h('strong', null, view.candidate.entry.name), h('span', null, t(bindingReviewStatus(view)))),
407
+ }, ...(quickAccess ? [{ id: 'draft-separator', type: 'separator' }] : [])] : []),
408
+ ...catalogItems,
409
+ ...(quickAccess && catalogError !== null ? [{ id: 'reload', label: t('bindings.reload') }] : []),
349
410
  ]
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
- }]
411
+ const menuContent = h('div', { className: 'ptcPlusBindingMenuContent', ref: menuBodyRef,
412
+ // A reopened menu can acquire foreground focus while an earlier write is
413
+ // pending. Recovery follows the live menu, not the initiating operation.
414
+ onFocusCapture: event => { catalogFocus.current = event.target },
415
+ onBlurCapture: event => {
416
+ if (!event.currentTarget.contains(event.relatedTarget)) catalogFocus.current = null
417
+ },
418
+ },
419
+ h('div', { className: 'ptcPlusBindingMenuScroll' }, ...menuEntries.map(entry => entry.type === 'separator'
420
+ ? h('div', { key: entry.id, role: 'separator', className: 'ptcPlusOwnedMenuSeparator' })
421
+ : entry.type === 'label'
422
+ ? h('div', { key: entry.id, className: 'ptcPlusMenuGroupLabel' }, entry.text)
423
+ : h('button', {
424
+ key: entry.id, type: 'button', role: 'menuitem', className: 'ptcPlusOwnedMenuRow',
425
+ disabled: entry.disabled, 'data-binding-id': entry.bindingId,
426
+ ref: entry.id === 'reload' ? reloadItemRef : entry.id === 'disabled-group' ? disabledGroupRef : undefined,
427
+ 'aria-expanded': entry.id === 'disabled-group' ? disabledOpen : undefined,
428
+ onClick: () => selectMenuItem(entry.id),
429
+ }, entry.label))),
430
+ h('div', { className: 'ptcPlusMenuActions' }, menu?.step === 'edit'
431
+ ? h('div', { className: 'ptcPlusMenuUtilities' }, actionButton('edit-back', 'bindings.back')) : actionSections))
363
432
  return h('span', {
364
433
  className: 'ptcPlusComposerBindingAnchor', tabIndex: -1,
365
434
  onFocusCapture: () => { focused.current = true }, onBlurCapture: () => { focused.current = false },
366
435
  'data-text': isHostIconComponent(IconSparkle16) ? undefined : true,
367
436
  ref: attachAnchor,
368
437
  },
369
- !hasDraft && !quickAccess ? null : h(Menu, {
438
+ !hasDraft && !quickAccess ? null : h(menuChildrenRegion ? Menu : MenuFallback, {
370
439
  className: 'ptcPlusAuthorButtonShell', open: menuOpen,
371
- anchor: typeof Tooltip === 'function'
440
+ anchor: isHostComponent(Tooltip)
372
441
  ? h(Tooltip, { label: hint, delayMs: 700, disabled: menuOpen || managing || settingsOpen }, starButton) : starButton,
373
442
  portal: true, side: 'top', dense: true,
443
+ listClassName: 'ptcPlusBindingMenu',
374
444
  // A hovered menu leaves with the pointer once it leaves the trigger and the
375
445
  // list for the published grace, which re-entering either cancels; click and
376
446
  // keyboard menus stay until dismissed.
377
447
  closeOnPointerLeave: menu !== null && menu.mode === 'hover',
378
448
  getAnchorRect: menuAnchorRect,
379
449
  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 }),
450
+ items: [], children: menuContent,
390
451
  onSelect: selectMenuItem,
391
452
  }),
392
453
  // The probe mounts the host Menu's own `children` region inside a hidden
@@ -413,7 +474,7 @@ export function createAuthoringView(React, deps) {
413
474
  ?.focus({ preventScroll: true }))
414
475
  },
415
476
  }) : null,
416
- toast === null ? null : typeof Toast === 'function'
477
+ toast === null ? null : isHostComponent(Toast)
417
478
  ? h(Toast, {
418
479
  key: toast.sequence,
419
480
  text: toast.text,
@@ -432,7 +493,7 @@ export function createAuthoringView(React, deps) {
432
493
  showName ? h('strong', null, candidate.entry.name) : null,
433
494
  h('span', { className: 'ptcPlusBindingMeta' }, `${candidate.entry.scope} - ${candidate.entry.symbols.join(', ')}`),
434
495
  candidate.entry.purpose ? h('p', { className: 'ptcPlusMessage' }, candidate.entry.purpose) : null,
435
- typeof CodeBlock === 'function'
496
+ isHostComponent(CodeBlock)
436
497
  ? h(CodeBlock, { code: candidate.entry.source, lang: 'typescript', className: 'ptcPlusBindingCommandCode',
437
498
  copyLabel: t('tool.copy'), copiedLabel: t('tool.copied') })
438
499
  : h('pre', { className: 'ptcPlusBindingCommandSource' }, candidate.entry.source),
@@ -450,22 +511,20 @@ export function createAuthoringView(React, deps) {
450
511
  preferences.instructions || t('bindings.noInstructions'))))))
451
512
  }
452
513
 
453
- // Native editable semantics are enough to restore focus; no Host class, store or editor internals are used.
454
514
  function focusComposer(anchor) {
455
515
  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
516
  anchor.focus({ preventScroll: true })
462
517
  }
463
518
 
464
- function openBindingReview(review, candidate = review.getSnapshot().candidate) {
465
- if (!review.display('expanded', candidate)) return
519
+ function openBindingReview(review, candidate = review.getSnapshot().candidate,
520
+ visibility = 'enlarged') {
521
+ if (!review.display(visibility, candidate)) return
466
522
  requestAnimationFrame(() => {
467
- if (review.getSnapshot().candidate === candidate && review.panel?.getClientRects().length) {
468
- review.panel.focus({ preventScroll: true })
523
+ const current = review.getSnapshot()
524
+ if (review.isCurrentCandidate(candidate) && current.reachable && current.visibility === 'expanded'
525
+ && review.panel?.getClientRects().length) {
526
+ if (review.previewAvailable === false) focusBindingReviewAccess(review)
527
+ else review.panel.focus({ preventScroll: true })
469
528
  }
470
529
  })
471
530
  }
@@ -477,60 +536,6 @@ export function createAuthoringView(React, deps) {
477
536
  else focusComposer(anchor)
478
537
  }
479
538
 
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
539
 
535
540
  function BindingReviewDock({ sessionId, useProjection, t }) {
536
541
  const raw = useProjection('ptcPlusBindingDraft')
@@ -549,10 +554,75 @@ export function createAuthoringView(React, deps) {
549
554
  React.useLayoutEffect(() => { review.sync(projection) }, [review, projection])
550
555
  const title = React.useId()
551
556
  const content = React.useId()
552
- const expanded = view.visibility === 'expanded'
557
+ const anchor = React.useRef(null)
558
+ const layer = React.useRef(null)
559
+ const [compact, setCompact] = React.useState(false)
560
+ const enlarged = view.visibility === 'enlarged'
561
+ const expanded = enlarged || view.visibility === 'expanded' && !compact
553
562
  const candidateKey = view.candidateKey
554
- React.useLayoutEffect(() => fitBindingReview(review.panel),
555
- [review, candidateKey, expanded, view.visibility, view.message])
563
+ React.useLayoutEffect(() => {
564
+ if (enlarged || !view.reachable || view.visibility === 'hidden' || view.candidate === null) return undefined
565
+ let frame
566
+ let previous
567
+ // A dock can move when input or sibling cards grow without a scroll/resize.
568
+ // Track only our own anchor while visible; no Host node owns this placement.
569
+ const place = () => {
570
+ const rect = anchor.current?.getBoundingClientRect()
571
+ const surface = layer.current
572
+ if (rect && surface) {
573
+ const viewport = window.visualViewport
574
+ const leftEdge = (viewport?.offsetLeft ?? 0) + 8
575
+ const topEdge = (viewport?.offsetTop ?? 0) + 8
576
+ const rightEdge = leftEdge + (viewport?.width ?? window.innerWidth) - 16
577
+ const bottomEdge = topEdge + (viewport?.height ?? window.innerHeight) - 16
578
+ const width = Math.min(rect.width, rightEdge - leftEdge)
579
+ const left = Math.max(leftEdge, Math.min(rect.left, rightEdge - width))
580
+ if (surface.style.width !== `${width}px`) surface.style.width = `${width}px`
581
+ const panel = surface.querySelector('.ptcPlusBindingDock')
582
+ const head = panel?.querySelector('.ptcPlusBindingDockHead')
583
+ const actions = panel?.querySelector('.ptcPlusBindingDockActions')
584
+ const body = panel?.querySelector('.ptcPlusBindingDockBody')
585
+ const panelStyle = panel && getComputedStyle(panel)
586
+ const border = (parseFloat(panelStyle?.borderTopWidth) || 0) + (parseFloat(panelStyle?.borderBottomWidth) || 0)
587
+ const chrome = (head?.getBoundingClientRect().height ?? 0) + border
588
+ const bottom = Math.max(topEdge, Math.min(Math.max(topEdge + chrome, rect.top - 8), rect.top, bottomEdge))
589
+ const height = bottom - topEdge
590
+ const geometry = [width, left, bottom, height, chrome].join(',')
591
+ if (geometry !== previous) {
592
+ previous = geometry
593
+ surface.style.left = `${left}px`
594
+ surface.style.bottom = `${window.innerHeight - bottom}px`
595
+ surface.style.setProperty('--ptc-plus-review-space', `${height}px`)
596
+ }
597
+ if (rect.width > 0) {
598
+ const available = surface.getBoundingClientRect().height >= chrome && bottom <= rect.top
599
+ review.previewAvailable = available
600
+ if (surface.dataset.available !== String(available)) {
601
+ surface.dataset.available = String(available)
602
+ if (available) {
603
+ surface.removeAttribute('inert')
604
+ surface.removeAttribute('aria-hidden')
605
+ } else {
606
+ surface.setAttribute('inert', '')
607
+ surface.setAttribute('aria-hidden', 'true')
608
+ }
609
+ }
610
+ }
611
+ // CSS owns the viewport cap. Full chrome stays measurable in compact
612
+ // mode so hiding it cannot make the layout classification oscillate.
613
+ if (head && actions && body && rect.width > 0) {
614
+ const minimumBody = parseFloat(getComputedStyle(body).minBlockSize)
615
+ const required = chrome + actions.getBoundingClientRect().height + minimumBody
616
+ setCompact(surface.getBoundingClientRect().height < required)
617
+ }
618
+ }
619
+ frame = requestAnimationFrame(place)
620
+ }
621
+ place()
622
+ return () => {
623
+ cancelAnimationFrame(frame)
624
+ }
625
+ }, [candidateKey, enlarged, view.reachable, view.visibility, view.candidate === null])
556
626
  const close = () => {
557
627
  review.display('hidden')
558
628
  requestAnimationFrame(() => focusBindingReviewAccess(review))
@@ -562,44 +632,55 @@ export function createAuthoringView(React, deps) {
562
632
  if (review.panel?.contains(document.activeElement)) review.panel.focus({ preventScroll: true })
563
633
  void review.act(operation, activate)
564
634
  }
635
+ const restorePreview = () => openBindingReview(review, view.candidate, 'expanded')
565
636
  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,
637
+ const panel = h('section', { key: candidateKey, className: 'ptcPlusBindingDock', 'aria-labelledby': title, tabIndex: -1,
568
638
  ref: element => { review.panel = element },
569
639
  onFocusCapture: () => { focused.current = true },
570
640
  onBlurCapture: event => {
571
641
  // Blink blurs the removed panel before layout effects; completion still owns that lost focus.
572
642
  if (event.relatedTarget || review.getSnapshot().action === null) focused.current = false
573
643
  },
574
- 'aria-busy': view.busy },
644
+ 'aria-busy': view.busy, 'data-compact': !enlarged && compact },
575
645
  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') },
646
+ h(enlarged ? 'div' : 'button', { type: enlarged ? undefined : 'button', className: 'ptcPlusBindingDockToggle',
647
+ 'aria-label': enlarged ? undefined : t(expanded ? 'bindings.reviewCollapse' : 'bindings.reviewExpand'),
648
+ 'aria-expanded': enlarged ? undefined : expanded, 'aria-controls': expanded && !enlarged ? content : undefined,
649
+ onClick: enlarged ? undefined : () => review.display(compact ? 'enlarged' : expanded ? 'collapsed' : 'expanded') },
580
650
  h('span', { className: 'ptcPlusBindingDockSymbol', 'aria-hidden': true }, '</>'),
581
651
  h('span', { className: 'ptcPlusBindingDockHeading' },
582
652
  h('strong', { id: title, title: view.candidate.entry.name }, view.candidate.entry.name),
583
653
  h('span', { role: 'status', title: t(bindingReviewStatus(view)) },
584
654
  `${t('bindings.reviewTitle')} · ${t(bindingReviewStatus(view))}`)),
585
- h('span', { className: 'ptcPlusBindingDockChevron', 'aria-hidden': true },
655
+ enlarged ? null : h('span', { className: 'ptcPlusBindingDockChevron', 'aria-hidden': true },
586
656
  h(IconChevronDownOutline14, { size: 16 }))),
587
- h(IconButton, { icon: IconCloseOutline16, label: t('bindings.reviewClose'), onClick: close })),
588
- !expanded ? null : h(React.Fragment, null,
657
+ h('div', { className: 'ptcPlusBindingDockControls' },
658
+ h(IconButton, { icon: enlarged ? RestoreIcon : EnlargeIcon,
659
+ label: t(enlarged ? 'bindings.reviewRestore' : 'bindings.reviewEnlarge'),
660
+ onClick: enlarged ? restorePreview : () => review.display('enlarged', view.candidate) }),
661
+ h(IconButton, { icon: isHostIconComponent(IconCloseOutline16) ? IconCloseOutline16 : ReviewCloseIcon,
662
+ label: t('bindings.reviewClose'), onClick: close }))),
663
+ !enlarged && view.visibility !== 'expanded' ? null : h(React.Fragment, null,
589
664
  h('div', { className: 'ptcPlusBindingDockBody', id: content, tabIndex: 0,
665
+ inert: !expanded ? '' : undefined, 'aria-hidden': !expanded ? true : undefined,
590
666
  role: 'region', 'aria-label': t('bindings.source') },
667
+ view.message === null ? null : h('p', { className: 'ptcPlusMessage ptcPlusDanger', role: 'status' }, t(view.message)),
668
+ view.message === null ? null : h(ActionButton, { disabled: view.busy,
669
+ onClick: () => review.reset() }, t('bindings.reviewRetry')),
591
670
  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' },
671
+ h('div', { className: 'ptcPlusBindingDockActions',
672
+ inert: !expanded ? '' : undefined, 'aria-hidden': !expanded ? true : undefined },
594
673
  h(ActionButton, { className: 'ptcPlusBindingDockDiscard', 'data-kind': 'ghost',
595
674
  disabled: !view.writable || view.busy,
596
675
  onClick: () => act('discard-draft') }, t('bindings.draftDiscard')),
597
676
  h(ActionButton, { disabled: !view.writable || view.busy,
598
677
  onClick: () => act('save-draft', false) }, t('bindings.draftSave')),
599
678
  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'))))))
679
+ onClick: () => act('save-draft', true) }, t('bindings.draftSaveEnable')))))
680
+ return h('div', { className: 'ptcPlusBindingDockAnchor', ref: anchor }, enlarged
681
+ ? h(Modal, { open: view.reachable, title: `${view.candidate.entry.name} · ${t('bindings.reviewTitle')}`,
682
+ headless: true, className: 'ptcPlusBindingReviewModal', onClose: restorePreview }, panel)
683
+ : !view.reachable ? null : createPortal(h('div', { className: 'ptcPlusBindingPreviewLayer', ref: layer }, panel), document.body))
603
684
  }
604
685
 
605
686
  function BindingCommandCard({ node, sessionId, t, useProjection }) {
@@ -191,6 +191,8 @@ export function createBindingReviews(call) {
191
191
  }
192
192
  const review = {
193
193
  getSnapshot: () => snapshot,
194
+ isCurrentCandidate: candidate => !disposed && attached && snapshot.action === null
195
+ && sameCandidate(snapshot.candidate, candidate),
194
196
  subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener) },
195
197
  sync,
196
198
  attach() {
@@ -4,6 +4,7 @@ import { EditorView, keymap } from '@codemirror/view'
4
4
  import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
5
5
  import { javascript } from '@codemirror/lang-javascript'
6
6
  import { tags } from '@lezer/highlight'
7
+ import { adoptUnownedStyles, captureUnownedStyles } from './client-styles.js'
7
8
 
8
9
  const editorTheme = EditorView.theme({
9
10
  '&': { backgroundColor: 'var(--dsw-alias-bg-layer-3)', color: 'var(--dsw-alias-label-primary)', fontSize: '12px' },
@@ -40,6 +41,10 @@ export function createTypeScriptEditor(React) {
40
41
  const accessExtensions = () => [EditorState.readOnly.of(disabled), EditorView.editable.of(!disabled)]
41
42
 
42
43
  React.useLayoutEffect(() => {
44
+ // style-mod mounts one global sheet per document and cannot tag it, so
45
+ // the plugin that mounts the editor claims the sheet this mount added
46
+ // before any other client plugin can take it over.
47
+ const unowned = captureUnownedStyles()
43
48
  const editor = new EditorView({
44
49
  parent: parent.current,
45
50
  state: EditorState.create({ doc: value, extensions: [
@@ -63,6 +68,7 @@ export function createTypeScriptEditor(React) {
63
68
  }),
64
69
  ] }),
65
70
  })
71
+ adoptUnownedStyles(unowned)
66
72
  view.current = editor
67
73
  return () => { view.current = null; editor.destroy() }
68
74
  }, [documentId])