@aryacode/dsh-aryacode-polish 0.8.1 → 0.8.2

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/CHANGELOG.md CHANGED
@@ -1,12 +1,23 @@
1
1
  # Changelog
2
2
 
3
+
4
+ ## [0.8.2] - 2026-10-28
5
+
6
+ ### Fixed
7
+
8
+ - The camera floated over the panel with metrics of its own, so it read as a
9
+ foreign control. It now mounts into the docking kit's chrome seat beside the
10
+ Shell's own presentation and collapse buttons, wearing that cluster's 28px box,
11
+ 15px glyph, hover state and tooltip, and it follows the strip through docked,
12
+ split and fullscreen layouts.
13
+
3
14
  ## [0.8.1] - 2026-10-28
4
15
 
5
16
  ### Changed
6
17
 
7
- - The camera moved out of the composer row and into the open right panel's own
8
- top-right corner, where the panel is what you are looking at. The composer
9
- dock entry stays, invisibly, to publish the session context the button needs.
18
+ - The camera moved out of the composer row and next to the right panel's own
19
+ controls. The composer dock entry stays, invisibly, to publish the session
20
+ context the button needs.
10
21
 
11
22
  ## [0.8.0] - 2026-10-28
12
23
 
package/client/face.js CHANGED
@@ -1572,7 +1572,6 @@ function shotName() {
1572
1572
  + '-' + pad(now.getHours()) + pad(now.getMinutes()) + pad(now.getSeconds()) + '.png'
1573
1573
  }
1574
1574
 
1575
- const SHOT_GLYPH_STYLE = { width: '14px', height: '14px', flex: 'none', display: 'block' }
1576
1575
 
1577
1576
  /**
1578
1577
  * Camera glyph, drawn here so the face depends on no icon package.
@@ -1581,51 +1580,94 @@ const SHOT_GLYPH_STYLE = { width: '14px', height: '14px', flex: 'none', display:
1581
1580
  function CameraGlyph() {
1582
1581
  return React.createElement('svg', {
1583
1582
  viewBox: '0 0 16 16',
1584
- width: 14,
1585
- height: 14,
1583
+ width: 15,
1584
+ height: 15,
1585
+ fill: 'none',
1586
+ stroke: 'currentColor',
1587
+ strokeWidth: 1,
1588
+ strokeLinecap: 'round',
1589
+ strokeLinejoin: 'round',
1590
+ 'aria-hidden': 'true',
1591
+ },
1592
+ React.createElement('path', { d: 'M2.6 5.4h1.9l1-1.5h5l1 1.5h1.9a1 1 0 0 1 1 1v5.7a1 1 0 0 1-1 1H2.6a1 1 0 0 1-1-1V6.4a1 1 0 0 1 1-1Z' }),
1593
+ React.createElement('circle', { cx: 8, cy: 9.3, r: 2.5 }))
1594
+ }
1595
+
1596
+ /** Confirmation tick, drawn at the strip's own glyph size. */
1597
+ function CheckGlyph() {
1598
+ return React.createElement('svg', {
1599
+ viewBox: '0 0 16 16',
1600
+ width: 15,
1601
+ height: 15,
1602
+ fill: 'none',
1603
+ stroke: 'currentColor',
1604
+ strokeWidth: 1.4,
1605
+ strokeLinecap: 'round',
1606
+ strokeLinejoin: 'round',
1607
+ 'aria-hidden': 'true',
1608
+ },
1609
+ React.createElement('path', { d: 'M3.4 8.6l3 3 6.2-6.6' }))
1610
+ }
1611
+
1612
+ /** Refusal mark, drawn at the strip's own glyph size. */
1613
+ function BangGlyph() {
1614
+ return React.createElement('svg', {
1615
+ viewBox: '0 0 16 16',
1616
+ width: 15,
1617
+ height: 15,
1586
1618
  fill: 'none',
1587
1619
  stroke: 'currentColor',
1588
- strokeWidth: 1.2,
1620
+ strokeWidth: 1.4,
1589
1621
  strokeLinecap: 'round',
1590
1622
  strokeLinejoin: 'round',
1591
1623
  'aria-hidden': 'true',
1592
- style: SHOT_GLYPH_STYLE,
1593
1624
  },
1594
- React.createElement('path', { d: 'M2 5.5h2.2l1-1.5h5.6l1 1.5H14v7H2z' }),
1595
- React.createElement('circle', { cx: 8, cy: 9, r: 2.2 }))
1625
+ React.createElement('path', { d: 'M8 3.6v5.2' }),
1626
+ React.createElement('path', { d: 'M8 12v.4' }))
1596
1627
  }
1597
1628
 
1598
1629
  /**
1599
- * Where the camera button is drawn: the panel's own top-right corner, just
1600
- * inside the frame and left of the Shell's expand/close controls.
1630
+ * The Shell has no header slot for the panel, so the camera rides the docking
1631
+ * kit's chrome seat: the element at the end of the top-right pane's tab strip
1632
+ * that already holds the presentation and collapse controls. Portalling into
1633
+ * that element, instead of floating over the panel at fixed coordinates, is what
1634
+ * makes the button share the cluster's gap, its vertical centring and whatever
1635
+ * geometry the strip has docked, fullscreened or split.
1601
1636
  */
1602
- const SHOT_ANCHOR_EDGE = 8
1603
- const SHOT_ANCHOR_BUTTON = 28
1604
- const SHOT_ANCHOR_CONTROLS = 96
1637
+ const SHOT_CHROME_SELECTOR = '[data-dockkit-strip-chrome]'
1605
1638
 
1606
1639
  /**
1607
- * Where the open right panel's header ends, in viewport coordinates.
1608
- * @returns the corner, or undefined when no panel is open.
1640
+ * Mirrors the Shell's own strip control: a 28px box with 6px padding around a
1641
+ * 15px glyph, secondary label colour, transparent until hovered.
1609
1642
  */
1610
- function shotAnchor() {
1611
- const panel = document.querySelector('[data-sidebar-right-session][data-sidebar-right-open]')
1612
- if (panel === null) return undefined
1613
- const rect = panel.getBoundingClientRect()
1614
- if (rect.width < 120 || rect.height < 120) return undefined
1615
- return {
1616
- top: rect.top + SHOT_ANCHOR_EDGE,
1617
- left: Math.max(rect.left + SHOT_ANCHOR_EDGE, rect.right - SHOT_ANCHOR_CONTROLS - SHOT_ANCHOR_BUTTON),
1618
- }
1643
+ const SHOT_BUTTON_STYLE = {
1644
+ display: 'inline-flex',
1645
+ flex: 'none',
1646
+ alignItems: 'center',
1647
+ justifyContent: 'center',
1648
+ width: '28px',
1649
+ height: '28px',
1650
+ padding: '6px',
1651
+ color: 'var(--dsw-alias-label-secondary, #61666b)',
1652
+ lineHeight: 1,
1653
+ background: 'transparent',
1654
+ border: 'none',
1655
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
1656
+ cursor: 'pointer',
1657
+ // Ahead of the Shell's own two controls: the last thing you would reach for,
1658
+ // and never between them and the panel edge.
1659
+ order: -1,
1619
1660
  }
1620
1661
 
1621
1662
  /**
1622
- * The composer context the overlay button attaches through.
1663
+ * The composer context the chrome button attaches through.
1623
1664
  *
1624
- * The camera button lives in a root overlay so it can sit in the panel header,
1625
- * but attaching an image needs the *session-scoped* composer props (`sessionId`
1626
- * and `inputActions`), which only a session slot receives. The composer dock is
1665
+ * The camera renders inside the panel strip, which is root chrome, but attaching
1666
+ * an image needs the *session-scoped* composer props (`sessionId` and
1667
+ * `inputActions`), which only a session slot receives. The composer dock is
1627
1668
  * therefore kept as an invisible publisher and this store carries its context up
1628
- * to the overlay; with no open Session the store is empty and the button hides.
1669
+ * to the chrome button; with no open Session the store is empty and the button
1670
+ * does not render.
1629
1671
  */
1630
1672
  const shotStore = (() => {
1631
1673
  const listeners = new Set()
@@ -1643,24 +1685,8 @@ const shotStore = (() => {
1643
1685
  }
1644
1686
  })()
1645
1687
 
1646
- const SHOT_BUTTON_STYLE = {
1647
- position: 'fixed',
1648
- display: 'inline-flex',
1649
- alignItems: 'center',
1650
- justifyContent: 'center',
1651
- width: SHOT_ANCHOR_BUTTON + 'px',
1652
- height: SHOT_ANCHOR_BUTTON + 'px',
1653
- padding: '0',
1654
- border: 'none',
1655
- borderRadius: 'var(--dsw-radius-sm, 6px)',
1656
- background: 'transparent',
1657
- color: 'var(--dsw-alias-label-tertiary, #61666b)',
1658
- cursor: 'pointer',
1659
- zIndex: 40,
1660
- }
1661
-
1662
1688
  /**
1663
- * Invisible composer-dock entry that publishes the session context the overlay
1689
+ * Invisible composer-dock entry that publishes the session context the chrome
1664
1690
  * button needs, and nothing else.
1665
1691
  * @param props - session slot props (`sessionId`, `inputActions`) plus the
1666
1692
  * late `conversation` lookup.
@@ -1679,29 +1705,35 @@ function PanelShotContext(props) {
1679
1705
  }
1680
1706
 
1681
1707
  /**
1682
- * Camera button pinned to the right panel's top-right corner: photographs the
1683
- * open tab, copies it, and attaches it to the message being written.
1684
- * @param props - slot props plus the late `conversation` lookup.
1685
- * @returns the button, or null while there is nothing to shoot or nowhere to
1686
- * attach.
1708
+ * Camera button in the right panel's tab strip: photographs the open tab, copies
1709
+ * it, and attaches it to the message being written.
1710
+ * @param props - overlay slot props plus the late `conversation` lookup.
1711
+ * @returns the portalled button, or null while there is no panel or no Session.
1687
1712
  */
1688
1713
  function PanelShotButton(props) {
1689
1714
  const on = useSwitch(props.configForms, 'panelScreenshot', true)
1690
1715
  const context = React.useSyncExternalStore(shotStore.subscribe, shotStore.get)
1691
- const [anchor, setAnchor] = React.useState(() => shotAnchor())
1716
+ const [host, setHost] = React.useState(undefined)
1692
1717
  const [phase, setPhase] = React.useState('idle')
1693
1718
  const [note, setNote] = React.useState('')
1694
1719
  React.useEffect(() => {
1695
- const measure = () => setAnchor(shotAnchor())
1696
- measure()
1697
- // The panel opens, closes, splits and resizes without any event this
1698
- // component would otherwise hear, so watch the attribute and the geometry.
1699
- const observer = new MutationObserver(measure)
1700
- observer.observe(document.body, { attributes: true, subtree: true, attributeFilter: ['data-sidebar-right-open', 'data-dockkit-pane-active'] })
1701
- window.addEventListener('resize', measure)
1720
+ // The strip is created, destroyed and re-created as panes open, split and
1721
+ // close, so follow the document rather than measuring it once. Frames are
1722
+ // coalesced because React commits fire many mutations in a row.
1723
+ let frame = 0
1724
+ const find = () => {
1725
+ frame = 0
1726
+ setHost(document.querySelector(SHOT_CHROME_SELECTOR) ?? undefined)
1727
+ }
1728
+ const schedule = () => {
1729
+ if (frame === 0) frame = window.requestAnimationFrame(find)
1730
+ }
1731
+ find()
1732
+ const observer = new MutationObserver(schedule)
1733
+ observer.observe(document.body, { childList: true, subtree: true })
1702
1734
  return () => {
1735
+ if (frame !== 0) window.cancelAnimationFrame(frame)
1703
1736
  observer.disconnect()
1704
- window.removeEventListener('resize', measure)
1705
1737
  }
1706
1738
  }, [])
1707
1739
  React.useEffect(() => {
@@ -1712,7 +1744,7 @@ function PanelShotButton(props) {
1712
1744
  }, 4000)
1713
1745
  return () => clearTimeout(timer)
1714
1746
  }, [note])
1715
- if (!on || context === undefined || anchor === undefined) return null
1747
+ if (!on || context === undefined || host === undefined) return null
1716
1748
  const take = async () => {
1717
1749
  if (phase === 'busy') return
1718
1750
  const node = panelTarget()
@@ -1748,63 +1780,46 @@ function PanelShotButton(props) {
1748
1780
  throw new Error('the composer refused the image')
1749
1781
  }
1750
1782
  setPhase('done')
1751
- setNote(copied ? 'right panel copied and attached' : 'right panel attached, clipboard refused')
1783
+ setNote(copied ? 'Right panel copied and attached' : 'Right panel attached, clipboard refused')
1752
1784
  } catch (error) {
1753
1785
  setPhase('failed')
1754
1786
  setNote(error instanceof Error ? error.message : String(error))
1755
1787
  console.warn('[aryacode-polish] panel screenshot failed:', error)
1756
1788
  }
1757
1789
  }
1758
- const label = phase === 'done' ? '✓' : phase === 'failed' ? '!' : '●'
1759
- return React.createElement(React.Fragment, null,
1760
- React.createElement('span', {
1761
- 'data-polish-shot-anchor': 'panel',
1762
- style: { position: 'fixed', top: anchor.top + 'px', left: anchor.left + 'px', width: '0', height: '0' },
1763
- }),
1764
- React.createElement('button', {
1765
- type: 'button',
1766
- 'data-polish-shot': phase,
1767
- title: note === '' ? 'Screenshot this panel into the chat (also copies it)' : note,
1768
- 'aria-label': 'Screenshot this panel',
1769
- disabled: phase === 'busy',
1770
- onClick: () => { void take() },
1771
- onMouseEnter: (event) => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
1772
- onMouseLeave: (event) => { event.currentTarget.style.background = 'transparent' },
1773
- style: {
1774
- ...SHOT_BUTTON_STYLE,
1775
- top: anchor.top + 'px',
1776
- left: anchor.left + 'px',
1777
- color: phase === 'failed'
1778
- ? 'var(--dsw-alias-label-error, #b42318)'
1779
- : 'var(--dsw-alias-label-tertiary, #61666b)',
1780
- opacity: phase === 'busy' ? 0.5 : 1,
1781
- cursor: phase === 'busy' ? 'progress' : 'pointer',
1782
- fontSize: '11px',
1783
- },
1790
+ const tip = note !== ''
1791
+ ? note
1792
+ : phase === 'busy' ? 'Capturing the right panel…' : 'Screenshot this panel into the chat (also copies it)'
1793
+ const glyph = phase === 'done'
1794
+ ? React.createElement(CheckGlyph, null)
1795
+ : phase === 'failed'
1796
+ ? React.createElement(BangGlyph, null)
1797
+ : React.createElement(CameraGlyph, null)
1798
+ const button = React.createElement('button', {
1799
+ type: 'button',
1800
+ 'data-polish-shot': phase,
1801
+ 'aria-label': 'Screenshot this panel',
1802
+ disabled: phase === 'busy',
1803
+ onClick: () => { void take() },
1804
+ onMouseEnter: (event) => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
1805
+ onMouseLeave: (event) => { event.currentTarget.style.background = 'transparent' },
1806
+ style: {
1807
+ ...SHOT_BUTTON_STYLE,
1808
+ color: phase === 'failed'
1809
+ ? 'var(--dsw-alias-label-error, #b42318)'
1810
+ : phase === 'done'
1811
+ ? 'var(--dsw-alias-label-primary, #111418)'
1812
+ : SHOT_BUTTON_STYLE.color,
1813
+ cursor: phase === 'busy' ? 'progress' : 'pointer',
1784
1814
  },
1785
- phase === 'done' || phase === 'failed'
1786
- ? React.createElement('span', { 'aria-hidden': 'true' }, label)
1787
- : React.createElement(CameraGlyph, null)),
1788
- note === ''
1789
- ? null
1790
- : React.createElement('span', {
1791
- 'data-polish-shot-note': phase,
1792
- style: {
1793
- position: 'fixed',
1794
- top: anchor.top + SHOT_ANCHOR_BUTTON + 6 + 'px',
1795
- left: Math.max(12, anchor.left - 140) + 'px',
1796
- maxWidth: '260px',
1797
- padding: '3px 8px',
1798
- borderRadius: 'var(--dsw-radius-sm, 6px)',
1799
- background: 'Canvas',
1800
- boxShadow: 'var(--dsw-elevation-prominent, 0 3px 8px rgba(0,0,0,0.12))',
1801
- color: phase === 'failed' ? 'var(--dsw-alias-label-error, #b42318)' : 'var(--dsw-alias-label-secondary, #61666b)',
1802
- fontSize: '12px',
1803
- lineHeight: '18px',
1804
- whiteSpace: 'nowrap',
1805
- zIndex: 41,
1806
- },
1807
- }, note))
1815
+ }, glyph)
1816
+ const Tooltip = primitives === undefined ? undefined : primitives.Tooltip
1817
+ return ReactDOM.createPortal(
1818
+ Tooltip === undefined
1819
+ ? button
1820
+ : React.createElement(Tooltip, { label: tip, side: 'bottom', delayMs: 500 }, button),
1821
+ host,
1822
+ )
1808
1823
  }
1809
1824
 
1810
1825
  /**
package/lib/client.js CHANGED
@@ -47,7 +47,7 @@ window.__ModuleLoader__.load({
47
47
  ]
48
48
  const ROW_ID = "polish"
49
49
  const ROW_KEY = "@aryacode/dsh-aryacode-polish" + '#' + ROW_ID
50
- const CLIENT_VERSION = "0.8.1"
50
+ const CLIENT_VERSION = "0.8.2"
51
51
 
52
52
  /* html2canvas 1.4.1 — MIT, vendored from vendor/html2canvas.min.js.
53
53
  * A UMD build, so it assigns itself to globalThis; face.js reads it lazily. */
@@ -1768,7 +1768,6 @@ window.__ModuleLoader__.load({
1768
1768
  + '-' + pad(now.getHours()) + pad(now.getMinutes()) + pad(now.getSeconds()) + '.png'
1769
1769
  }
1770
1770
 
1771
- const SHOT_GLYPH_STYLE = { width: '14px', height: '14px', flex: 'none', display: 'block' }
1772
1771
 
1773
1772
  /**
1774
1773
  * Camera glyph, drawn here so the face depends on no icon package.
@@ -1777,51 +1776,94 @@ window.__ModuleLoader__.load({
1777
1776
  function CameraGlyph() {
1778
1777
  return React.createElement('svg', {
1779
1778
  viewBox: '0 0 16 16',
1780
- width: 14,
1781
- height: 14,
1779
+ width: 15,
1780
+ height: 15,
1781
+ fill: 'none',
1782
+ stroke: 'currentColor',
1783
+ strokeWidth: 1,
1784
+ strokeLinecap: 'round',
1785
+ strokeLinejoin: 'round',
1786
+ 'aria-hidden': 'true',
1787
+ },
1788
+ React.createElement('path', { d: 'M2.6 5.4h1.9l1-1.5h5l1 1.5h1.9a1 1 0 0 1 1 1v5.7a1 1 0 0 1-1 1H2.6a1 1 0 0 1-1-1V6.4a1 1 0 0 1 1-1Z' }),
1789
+ React.createElement('circle', { cx: 8, cy: 9.3, r: 2.5 }))
1790
+ }
1791
+
1792
+ /** Confirmation tick, drawn at the strip's own glyph size. */
1793
+ function CheckGlyph() {
1794
+ return React.createElement('svg', {
1795
+ viewBox: '0 0 16 16',
1796
+ width: 15,
1797
+ height: 15,
1798
+ fill: 'none',
1799
+ stroke: 'currentColor',
1800
+ strokeWidth: 1.4,
1801
+ strokeLinecap: 'round',
1802
+ strokeLinejoin: 'round',
1803
+ 'aria-hidden': 'true',
1804
+ },
1805
+ React.createElement('path', { d: 'M3.4 8.6l3 3 6.2-6.6' }))
1806
+ }
1807
+
1808
+ /** Refusal mark, drawn at the strip's own glyph size. */
1809
+ function BangGlyph() {
1810
+ return React.createElement('svg', {
1811
+ viewBox: '0 0 16 16',
1812
+ width: 15,
1813
+ height: 15,
1782
1814
  fill: 'none',
1783
1815
  stroke: 'currentColor',
1784
- strokeWidth: 1.2,
1816
+ strokeWidth: 1.4,
1785
1817
  strokeLinecap: 'round',
1786
1818
  strokeLinejoin: 'round',
1787
1819
  'aria-hidden': 'true',
1788
- style: SHOT_GLYPH_STYLE,
1789
1820
  },
1790
- React.createElement('path', { d: 'M2 5.5h2.2l1-1.5h5.6l1 1.5H14v7H2z' }),
1791
- React.createElement('circle', { cx: 8, cy: 9, r: 2.2 }))
1821
+ React.createElement('path', { d: 'M8 3.6v5.2' }),
1822
+ React.createElement('path', { d: 'M8 12v.4' }))
1792
1823
  }
1793
1824
 
1794
1825
  /**
1795
- * Where the camera button is drawn: the panel's own top-right corner, just
1796
- * inside the frame and left of the Shell's expand/close controls.
1826
+ * The Shell has no header slot for the panel, so the camera rides the docking
1827
+ * kit's chrome seat: the element at the end of the top-right pane's tab strip
1828
+ * that already holds the presentation and collapse controls. Portalling into
1829
+ * that element, instead of floating over the panel at fixed coordinates, is what
1830
+ * makes the button share the cluster's gap, its vertical centring and whatever
1831
+ * geometry the strip has docked, fullscreened or split.
1797
1832
  */
1798
- const SHOT_ANCHOR_EDGE = 8
1799
- const SHOT_ANCHOR_BUTTON = 28
1800
- const SHOT_ANCHOR_CONTROLS = 96
1833
+ const SHOT_CHROME_SELECTOR = '[data-dockkit-strip-chrome]'
1801
1834
 
1802
1835
  /**
1803
- * Where the open right panel's header ends, in viewport coordinates.
1804
- * @returns the corner, or undefined when no panel is open.
1836
+ * Mirrors the Shell's own strip control: a 28px box with 6px padding around a
1837
+ * 15px glyph, secondary label colour, transparent until hovered.
1805
1838
  */
1806
- function shotAnchor() {
1807
- const panel = document.querySelector('[data-sidebar-right-session][data-sidebar-right-open]')
1808
- if (panel === null) return undefined
1809
- const rect = panel.getBoundingClientRect()
1810
- if (rect.width < 120 || rect.height < 120) return undefined
1811
- return {
1812
- top: rect.top + SHOT_ANCHOR_EDGE,
1813
- left: Math.max(rect.left + SHOT_ANCHOR_EDGE, rect.right - SHOT_ANCHOR_CONTROLS - SHOT_ANCHOR_BUTTON),
1814
- }
1839
+ const SHOT_BUTTON_STYLE = {
1840
+ display: 'inline-flex',
1841
+ flex: 'none',
1842
+ alignItems: 'center',
1843
+ justifyContent: 'center',
1844
+ width: '28px',
1845
+ height: '28px',
1846
+ padding: '6px',
1847
+ color: 'var(--dsw-alias-label-secondary, #61666b)',
1848
+ lineHeight: 1,
1849
+ background: 'transparent',
1850
+ border: 'none',
1851
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
1852
+ cursor: 'pointer',
1853
+ // Ahead of the Shell's own two controls: the last thing you would reach for,
1854
+ // and never between them and the panel edge.
1855
+ order: -1,
1815
1856
  }
1816
1857
 
1817
1858
  /**
1818
- * The composer context the overlay button attaches through.
1859
+ * The composer context the chrome button attaches through.
1819
1860
  *
1820
- * The camera button lives in a root overlay so it can sit in the panel header,
1821
- * but attaching an image needs the *session-scoped* composer props (`sessionId`
1822
- * and `inputActions`), which only a session slot receives. The composer dock is
1861
+ * The camera renders inside the panel strip, which is root chrome, but attaching
1862
+ * an image needs the *session-scoped* composer props (`sessionId` and
1863
+ * `inputActions`), which only a session slot receives. The composer dock is
1823
1864
  * therefore kept as an invisible publisher and this store carries its context up
1824
- * to the overlay; with no open Session the store is empty and the button hides.
1865
+ * to the chrome button; with no open Session the store is empty and the button
1866
+ * does not render.
1825
1867
  */
1826
1868
  const shotStore = (() => {
1827
1869
  const listeners = new Set()
@@ -1839,24 +1881,8 @@ window.__ModuleLoader__.load({
1839
1881
  }
1840
1882
  })()
1841
1883
 
1842
- const SHOT_BUTTON_STYLE = {
1843
- position: 'fixed',
1844
- display: 'inline-flex',
1845
- alignItems: 'center',
1846
- justifyContent: 'center',
1847
- width: SHOT_ANCHOR_BUTTON + 'px',
1848
- height: SHOT_ANCHOR_BUTTON + 'px',
1849
- padding: '0',
1850
- border: 'none',
1851
- borderRadius: 'var(--dsw-radius-sm, 6px)',
1852
- background: 'transparent',
1853
- color: 'var(--dsw-alias-label-tertiary, #61666b)',
1854
- cursor: 'pointer',
1855
- zIndex: 40,
1856
- }
1857
-
1858
1884
  /**
1859
- * Invisible composer-dock entry that publishes the session context the overlay
1885
+ * Invisible composer-dock entry that publishes the session context the chrome
1860
1886
  * button needs, and nothing else.
1861
1887
  * @param props - session slot props (`sessionId`, `inputActions`) plus the
1862
1888
  * late `conversation` lookup.
@@ -1875,29 +1901,35 @@ window.__ModuleLoader__.load({
1875
1901
  }
1876
1902
 
1877
1903
  /**
1878
- * Camera button pinned to the right panel's top-right corner: photographs the
1879
- * open tab, copies it, and attaches it to the message being written.
1880
- * @param props - slot props plus the late `conversation` lookup.
1881
- * @returns the button, or null while there is nothing to shoot or nowhere to
1882
- * attach.
1904
+ * Camera button in the right panel's tab strip: photographs the open tab, copies
1905
+ * it, and attaches it to the message being written.
1906
+ * @param props - overlay slot props plus the late `conversation` lookup.
1907
+ * @returns the portalled button, or null while there is no panel or no Session.
1883
1908
  */
1884
1909
  function PanelShotButton(props) {
1885
1910
  const on = useSwitch(props.configForms, 'panelScreenshot', true)
1886
1911
  const context = React.useSyncExternalStore(shotStore.subscribe, shotStore.get)
1887
- const [anchor, setAnchor] = React.useState(() => shotAnchor())
1912
+ const [host, setHost] = React.useState(undefined)
1888
1913
  const [phase, setPhase] = React.useState('idle')
1889
1914
  const [note, setNote] = React.useState('')
1890
1915
  React.useEffect(() => {
1891
- const measure = () => setAnchor(shotAnchor())
1892
- measure()
1893
- // The panel opens, closes, splits and resizes without any event this
1894
- // component would otherwise hear, so watch the attribute and the geometry.
1895
- const observer = new MutationObserver(measure)
1896
- observer.observe(document.body, { attributes: true, subtree: true, attributeFilter: ['data-sidebar-right-open', 'data-dockkit-pane-active'] })
1897
- window.addEventListener('resize', measure)
1916
+ // The strip is created, destroyed and re-created as panes open, split and
1917
+ // close, so follow the document rather than measuring it once. Frames are
1918
+ // coalesced because React commits fire many mutations in a row.
1919
+ let frame = 0
1920
+ const find = () => {
1921
+ frame = 0
1922
+ setHost(document.querySelector(SHOT_CHROME_SELECTOR) ?? undefined)
1923
+ }
1924
+ const schedule = () => {
1925
+ if (frame === 0) frame = window.requestAnimationFrame(find)
1926
+ }
1927
+ find()
1928
+ const observer = new MutationObserver(schedule)
1929
+ observer.observe(document.body, { childList: true, subtree: true })
1898
1930
  return () => {
1931
+ if (frame !== 0) window.cancelAnimationFrame(frame)
1899
1932
  observer.disconnect()
1900
- window.removeEventListener('resize', measure)
1901
1933
  }
1902
1934
  }, [])
1903
1935
  React.useEffect(() => {
@@ -1908,7 +1940,7 @@ window.__ModuleLoader__.load({
1908
1940
  }, 4000)
1909
1941
  return () => clearTimeout(timer)
1910
1942
  }, [note])
1911
- if (!on || context === undefined || anchor === undefined) return null
1943
+ if (!on || context === undefined || host === undefined) return null
1912
1944
  const take = async () => {
1913
1945
  if (phase === 'busy') return
1914
1946
  const node = panelTarget()
@@ -1944,63 +1976,46 @@ window.__ModuleLoader__.load({
1944
1976
  throw new Error('the composer refused the image')
1945
1977
  }
1946
1978
  setPhase('done')
1947
- setNote(copied ? 'right panel copied and attached' : 'right panel attached, clipboard refused')
1979
+ setNote(copied ? 'Right panel copied and attached' : 'Right panel attached, clipboard refused')
1948
1980
  } catch (error) {
1949
1981
  setPhase('failed')
1950
1982
  setNote(error instanceof Error ? error.message : String(error))
1951
1983
  console.warn('[aryacode-polish] panel screenshot failed:', error)
1952
1984
  }
1953
1985
  }
1954
- const label = phase === 'done' ? '✓' : phase === 'failed' ? '!' : '●'
1955
- return React.createElement(React.Fragment, null,
1956
- React.createElement('span', {
1957
- 'data-polish-shot-anchor': 'panel',
1958
- style: { position: 'fixed', top: anchor.top + 'px', left: anchor.left + 'px', width: '0', height: '0' },
1959
- }),
1960
- React.createElement('button', {
1961
- type: 'button',
1962
- 'data-polish-shot': phase,
1963
- title: note === '' ? 'Screenshot this panel into the chat (also copies it)' : note,
1964
- 'aria-label': 'Screenshot this panel',
1965
- disabled: phase === 'busy',
1966
- onClick: () => { void take() },
1967
- onMouseEnter: (event) => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
1968
- onMouseLeave: (event) => { event.currentTarget.style.background = 'transparent' },
1969
- style: {
1970
- ...SHOT_BUTTON_STYLE,
1971
- top: anchor.top + 'px',
1972
- left: anchor.left + 'px',
1973
- color: phase === 'failed'
1974
- ? 'var(--dsw-alias-label-error, #b42318)'
1975
- : 'var(--dsw-alias-label-tertiary, #61666b)',
1976
- opacity: phase === 'busy' ? 0.5 : 1,
1977
- cursor: phase === 'busy' ? 'progress' : 'pointer',
1978
- fontSize: '11px',
1979
- },
1986
+ const tip = note !== ''
1987
+ ? note
1988
+ : phase === 'busy' ? 'Capturing the right panel…' : 'Screenshot this panel into the chat (also copies it)'
1989
+ const glyph = phase === 'done'
1990
+ ? React.createElement(CheckGlyph, null)
1991
+ : phase === 'failed'
1992
+ ? React.createElement(BangGlyph, null)
1993
+ : React.createElement(CameraGlyph, null)
1994
+ const button = React.createElement('button', {
1995
+ type: 'button',
1996
+ 'data-polish-shot': phase,
1997
+ 'aria-label': 'Screenshot this panel',
1998
+ disabled: phase === 'busy',
1999
+ onClick: () => { void take() },
2000
+ onMouseEnter: (event) => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
2001
+ onMouseLeave: (event) => { event.currentTarget.style.background = 'transparent' },
2002
+ style: {
2003
+ ...SHOT_BUTTON_STYLE,
2004
+ color: phase === 'failed'
2005
+ ? 'var(--dsw-alias-label-error, #b42318)'
2006
+ : phase === 'done'
2007
+ ? 'var(--dsw-alias-label-primary, #111418)'
2008
+ : SHOT_BUTTON_STYLE.color,
2009
+ cursor: phase === 'busy' ? 'progress' : 'pointer',
1980
2010
  },
1981
- phase === 'done' || phase === 'failed'
1982
- ? React.createElement('span', { 'aria-hidden': 'true' }, label)
1983
- : React.createElement(CameraGlyph, null)),
1984
- note === ''
1985
- ? null
1986
- : React.createElement('span', {
1987
- 'data-polish-shot-note': phase,
1988
- style: {
1989
- position: 'fixed',
1990
- top: anchor.top + SHOT_ANCHOR_BUTTON + 6 + 'px',
1991
- left: Math.max(12, anchor.left - 140) + 'px',
1992
- maxWidth: '260px',
1993
- padding: '3px 8px',
1994
- borderRadius: 'var(--dsw-radius-sm, 6px)',
1995
- background: 'Canvas',
1996
- boxShadow: 'var(--dsw-elevation-prominent, 0 3px 8px rgba(0,0,0,0.12))',
1997
- color: phase === 'failed' ? 'var(--dsw-alias-label-error, #b42318)' : 'var(--dsw-alias-label-secondary, #61666b)',
1998
- fontSize: '12px',
1999
- lineHeight: '18px',
2000
- whiteSpace: 'nowrap',
2001
- zIndex: 41,
2002
- },
2003
- }, note))
2011
+ }, glyph)
2012
+ const Tooltip = primitives === undefined ? undefined : primitives.Tooltip
2013
+ return ReactDOM.createPortal(
2014
+ Tooltip === undefined
2015
+ ? button
2016
+ : React.createElement(Tooltip, { label: tip, side: 'bottom', delayMs: 500 }, button),
2017
+ host,
2018
+ )
2004
2019
  }
2005
2020
 
2006
2021
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aryacode/dsh-aryacode-polish",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "AryaCode Polish: one install for the small things that make AryaCode feel finished. One Plugins row, one switch per quality-of-life feature.",
5
5
  "keywords": [
6
6
  "dsh",