dsh-zen-remote 1.1.13 → 1.1.14

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 (77) hide show
  1. package/README.md +3 -2
  2. package/README.zh-CN.md +3 -2
  3. package/docs/interface.md +46 -3
  4. package/lib/client.js +3421 -103
  5. package/lib/index.js +13 -0
  6. package/lib/index.js.map +1 -1
  7. package/lib/share-export.js +347 -0
  8. package/lib/share-export.js.map +1 -0
  9. package/lib/types/client/MobileAttachButton.d.ts +8 -1
  10. package/lib/types/client/MobileAttachButton.d.ts.map +1 -1
  11. package/lib/types/client/MobileAttachChips.d.ts.map +1 -1
  12. package/lib/types/client/MobileSessionHeader.d.ts +7 -8
  13. package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
  14. package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
  15. package/lib/types/client/effects/keyboard-avoid.d.ts.map +1 -1
  16. package/lib/types/client/effects/keyboard-guard.d.ts +16 -11
  17. package/lib/types/client/effects/keyboard-guard.d.ts.map +1 -1
  18. package/lib/types/client/host-attach.d.ts +38 -0
  19. package/lib/types/client/host-attach.d.ts.map +1 -0
  20. package/lib/types/client/index.d.ts.map +1 -1
  21. package/lib/types/client/locales.d.ts +23 -0
  22. package/lib/types/client/locales.d.ts.map +1 -1
  23. package/lib/types/client/share/fetch-share.d.ts +77 -0
  24. package/lib/types/client/share/fetch-share.d.ts.map +1 -0
  25. package/lib/types/client/share/markdown.d.ts +97 -0
  26. package/lib/types/client/share/markdown.d.ts.map +1 -0
  27. package/lib/types/client/share/png-stitch.d.ts +96 -0
  28. package/lib/types/client/share/png-stitch.d.ts.map +1 -0
  29. package/lib/types/client/share/rasterize.d.ts +317 -0
  30. package/lib/types/client/share/rasterize.d.ts.map +1 -0
  31. package/lib/types/client/share/share-card.d.ts +93 -0
  32. package/lib/types/client/share/share-card.d.ts.map +1 -0
  33. package/lib/types/client/share/share-constants.d.ts +27 -0
  34. package/lib/types/client/share/share-constants.d.ts.map +1 -0
  35. package/lib/types/client/share/share-flow.d.ts +76 -0
  36. package/lib/types/client/share/share-flow.d.ts.map +1 -0
  37. package/lib/types/client/share/share-preview.d.ts +11 -0
  38. package/lib/types/client/share/share-preview.d.ts.map +1 -0
  39. package/lib/types/client/share/share-sheet.d.ts +17 -0
  40. package/lib/types/client/share/share-sheet.d.ts.map +1 -0
  41. package/lib/types/client/share/text-layout.d.ts +48 -0
  42. package/lib/types/client/share/text-layout.d.ts.map +1 -0
  43. package/lib/types/client/styles/header.css.d.ts +1 -1
  44. package/lib/types/client/styles/header.css.d.ts.map +1 -1
  45. package/lib/types/client/styles/layout.css.d.ts +1 -1
  46. package/lib/types/client/styles/layout.css.d.ts.map +1 -1
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/share-export.d.ts +66 -0
  49. package/lib/types/share-export.d.ts.map +1 -0
  50. package/package.json +7 -2
  51. package/scripts/check-host-shadow.mjs +41 -9
  52. package/scripts/check-keyboard-avoid.mjs +4 -2
  53. package/scripts/check-share-image.mjs +970 -0
  54. package/scripts/check-upload-endpoint.mjs +10 -3
  55. package/src/client/MobileAttachButton.tsx +13 -0
  56. package/src/client/MobileAttachChips.tsx +8 -1
  57. package/src/client/MobileSessionHeader.tsx +90 -30
  58. package/src/client/MobileSessionInfo.tsx +234 -1
  59. package/src/client/effects/keyboard-avoid.ts +14 -7
  60. package/src/client/effects/keyboard-guard.ts +28 -18
  61. package/src/client/host-attach.ts +54 -0
  62. package/src/client/index.tsx +16 -0
  63. package/src/client/locales.ts +46 -0
  64. package/src/client/share/fetch-share.ts +161 -0
  65. package/src/client/share/markdown.ts +609 -0
  66. package/src/client/share/png-stitch.ts +281 -0
  67. package/src/client/share/rasterize.ts +1078 -0
  68. package/src/client/share/share-card.tsx +637 -0
  69. package/src/client/share/share-constants.ts +30 -0
  70. package/src/client/share/share-flow.ts +166 -0
  71. package/src/client/share/share-preview.tsx +337 -0
  72. package/src/client/share/share-sheet.tsx +222 -0
  73. package/src/client/share/text-layout.ts +147 -0
  74. package/src/client/styles/header.css.ts +30 -0
  75. package/src/client/styles/layout.css.ts +5 -2
  76. package/src/index.ts +13 -0
  77. package/src/share-export.ts +431 -0
package/lib/client.js CHANGED
@@ -1534,84 +1534,2759 @@ function MobileHeaderActions({ sessionId, useSessions, t }) {
1534
1534
  const openInfoCard = () => { window.dispatchEvent(new CustomEvent(nav_store_ts_1.SESSION_INFO_EVENT)); };
1535
1535
  return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("button", { type: "button", "data-mobile-nav": "header-back", "aria-label": t('backToList'), title: t('backToList'), onClick: () => window.dispatchEvent(new CustomEvent(nav_store_ts_1.GO_HOME_EVENT)), children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconChevronLeftOutline14, { size: 20 }) }), tabs.length > 1 && active !== undefined && ((0, jsx_runtime_1.jsxs)("button", { type: "button", "data-mobile-nav": "header-viewrow", "aria-label": t('switchView'), onClick: () => tabs.find((tab) => !tab.active)?.el.click(), children: [(0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "header-viewrow-label", children: active.label }), (0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "header-viewrow-dots", "aria-hidden": "true", children: tabs.map((tab, index) => ((0, jsx_runtime_1.jsx)("i", { "data-active": tab.active ? '' : undefined }, index))) })] })), hasActivity && ((0, jsx_runtime_1.jsxs)("div", { "data-mobile-nav": "header-activity", children: [(0, jsx_runtime_1.jsx)(ActivityPill, { kind: "subagent", count: subagents.length, state: subagentState, label: t('infoSubagents', { count: subagents.length }), trigger: SUBAGENT_TRIGGER, onFallback: openInfoCard, Icon: IconSubagentOutline14 }), (0, jsx_runtime_1.jsx)(ActivityPill, { kind: "job", count: jobs.length, state: jobState, label: t('infoJobs', { count: jobs.length }), trigger: JOBS_TRIGGER, onFallback: openInfoCard, Icon: IconTaskOutline14 })] }))] }));
1536
1536
  }
1537
+ const BETTER_ROOT = '[data-dsh-better-sidebar]';
1538
+ const BETTER_TOGGLE = '[data-dsh-better-sidebar] button[class$="_toggleButton"]';
1539
+ const OFFICIAL_PANEL = '[data-sidebar-right-panel]';
1540
+ const OFFICIAL_EXPAND = '[data-sidebar-right-expand]';
1541
+ const OFFICIAL_COLLAPSE = '[data-sidebar-right-toggle]';
1542
+ /** Synchronous read of which sidebar backend this host offers. */
1543
+ function readSidebarTarget() {
1544
+ if (document.querySelector(BETTER_ROOT) !== null)
1545
+ return 'better';
1546
+ if (document.querySelector(OFFICIAL_PANEL) !== null)
1547
+ return 'official';
1548
+ return null;
1549
+ }
1550
+ /**
1551
+ * Toggle whichever sidebar `target` names, by clicking the official/plugin
1552
+ * control through its stable marker. The official pair is open-only in the
1553
+ * corner (`ExpandButton` unmounts once shown) and close-only inside the
1554
+ * panel (`data-sidebar-right-toggle` rides the panel's own strip), so the
1555
+ * open state is read off the panel's `data-sidebar-right-open` attribute and
1556
+ * the matching control is clicked. Synthetic `.click()` fires both React
1557
+ * handlers through `display: none` (the tablist precedent this plugin
1558
+ * already relies on for the view switch).
1559
+ */
1560
+ function toggleSidebarTarget(target) {
1561
+ if (target === 'better') {
1562
+ document.querySelector(BETTER_TOGGLE)?.click();
1563
+ return;
1564
+ }
1565
+ if (target === 'official') {
1566
+ const shown = document.querySelector(`${OFFICIAL_PANEL}[data-sidebar-right-open]`) !== null;
1567
+ const control = shown
1568
+ ? document.querySelector(OFFICIAL_COLLAPSE)
1569
+ : document.querySelector(OFFICIAL_EXPAND);
1570
+ control?.click();
1571
+ }
1572
+ }
1573
+ /** Live mirror of {@link readSidebarTarget} — same observer pattern as the
1574
+ * old workbench presence gate it replaces (plugins load after us; the right
1575
+ * panel mounts with the frame). */
1576
+ function useSidebarTarget() {
1577
+ const [target, setTarget] = (0, react_1.useState)(readSidebarTarget);
1578
+ (0, react_1.useEffect)(() => {
1579
+ const check = () => setTarget(readSidebarTarget());
1580
+ const observer = new MutationObserver(check);
1581
+ observer.observe(document.body, { childList: true, subtree: true });
1582
+ check();
1583
+ return () => observer.disconnect();
1584
+ }, []);
1585
+ return target;
1586
+ }
1587
+ /**
1588
+ * Session header, right lane: the session-info entry (S4 owns the actual
1589
+ * sheet — this fires a hook event for it to pick up) and the sidebar entry.
1590
+ * The sidebar button routes per {@link SidebarTarget}: better-sidebar's own
1591
+ * toggle (no public "open the panel" API — BetterSidebarService.openTab only
1592
+ * auto-expands for a content open, not a bare type-only open, so it clicks
1593
+ * the plugin's real toggle through its root marker `[data-dsh-better-
1594
+ * sidebar]` plus the `_toggleButton` class suffix, verified live 2026-08-17),
1595
+ * the official right sidebar's controls, or nothing when neither exists.
1596
+ */
1597
+ function MobileHeaderUtilities({ t }) {
1598
+ // Better-sidebar phone close button (S3.1 follow-up, 2026-08-17): the
1599
+ // panel's own top-right toggle cluster is hidden below 768px
1600
+ // (styles/compat.css.ts) because it duplicates the workbench button
1601
+ // below — but that cluster is also the panel's ONLY close control, so
1602
+ // hiding it blindly leaves an open panel with no way out. This button is
1603
+ // appended straight to document.body, mirroring the existing
1604
+ // preview-full-toggle pattern in MobileNavOverlay.tsx (raw DOM, not a
1605
+ // React portal — react-dom is not among this plugin's platform-module
1606
+ // imports, see AGENTS.md "client import purity"): never inside the
1607
+ // panel's own subtree (the third party's React re-renders would wipe
1608
+ // it) and never under any transformed/backdrop-filter ancestor (the S4
1609
+ // info-card WebKit lesson in AGENTS.md — position:fixed would re-anchor
1610
+ // to that ancestor instead of the viewport). It clicks the SAME hidden
1611
+ // official toggle the workbench button below uses. Visibility is pure
1612
+ // CSS (styles/compat.css.ts: `body:has([data-dsh-better-sidebar]
1613
+ // [class$="_panel"])` — the panel's class ends in "_panel" only while
1614
+ // open, "_panelHidden" is appended once closed), so this effect only
1615
+ // has to guarantee the node exists — no MutationObserver needed to
1616
+ // track open/closed state. Icon paths copied verbatim from
1617
+ // IconCloseOutline16 (primitives) for the same reason IconInfoOutline16
1618
+ // above is hand-built: this button lives outside the React tree, so it
1619
+ // cannot render a primitives component directly.
1620
+ (0, react_1.useEffect)(() => {
1621
+ const button = document.createElement('button');
1622
+ button.type = 'button';
1623
+ button.dataset.mobileNav = 'better-sidebar-close';
1624
+ button.setAttribute('aria-label', t('workbenchClose'));
1625
+ button.title = t('workbenchClose');
1626
+ // Bottom-center labeled pill (real-device follow-up, 2026-08-17): the
1627
+ // icon markup is a static trusted string (safe as innerHTML), but the
1628
+ // locale label is untrusted-shaped text — built as a real text node via
1629
+ // textContent, not string-concatenated into the same innerHTML, so a
1630
+ // translation can never be parsed as markup.
1631
+ button.innerHTML = '<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">'
1632
+ + '<path d="M14.1168 13.197L13.197 14.1167L1.8833 2.80303L2.80309 1.88324L14.1168 13.197Z" fill="currentColor"/>'
1633
+ + '<path d="M13.197 1.88326L14.1168 2.80305L2.80309 14.1168L1.8833 13.197L13.197 1.88326Z" fill="currentColor"/>'
1634
+ + '</svg>';
1635
+ const label = document.createElement('span');
1636
+ label.textContent = t('workbenchClose');
1637
+ button.appendChild(label);
1638
+ const onClick = () => {
1639
+ document.querySelector('[data-dsh-better-sidebar] button[class$="_toggleButton"]')?.click();
1640
+ };
1641
+ button.addEventListener('click', onClick);
1642
+ document.body.appendChild(button);
1643
+ return () => {
1644
+ button.removeEventListener('click', onClick);
1645
+ button.remove();
1646
+ };
1647
+ }, [t]);
1648
+ // Sidebar routing (2026-09-11): better-sidebar installed → its toggle (the
1649
+ // original design); else the official right sidebar (DSH 0.1.5+); else the
1650
+ // button stays hidden — it used to be a dead control without the plugin
1651
+ // (2026-08-17 user question), and the same must hold for a host with no
1652
+ // right sidebar either. The close pill above keeps its own better-sidebar
1653
+ // CSS :has() gate, unchanged.
1654
+ const sidebar = useSidebarTarget();
1655
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("button", { type: "button", "data-mobile-nav": "header-info", "aria-label": t('sessionInfo'), title: t('sessionInfo'), onClick: () => window.dispatchEvent(new CustomEvent(nav_store_ts_1.SESSION_INFO_EVENT)), children: (0, jsx_runtime_1.jsx)(IconInfoOutline16, { size: 20 }) }), sidebar !== null && (0, jsx_runtime_1.jsx)("button", { type: "button", "data-mobile-nav": "header-workbench", "data-sidebar-target": sidebar, "aria-label": t(sidebar === 'better' ? 'workbench' : 'sidebar'), title: t(sidebar === 'better' ? 'workbench' : 'sidebar'), onClick: () => { toggleSidebarTarget(sidebar); }, children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconPanelLeftOutline16, { size: 20 }) })] }));
1656
+ }
1657
+ };
1658
+ __modules["share/fetch-share.js"] = function (require, module, exports) {
1659
+ "use strict";
1660
+ /**
1661
+ * Share-export fetching (ticket 05, PLAN §5.3): the ONE typed fetch over
1662
+ * GET /_dsh/mobile-nav/share-export, shared by the real share flow
1663
+ * (MobileSessionInfo) and the debug preview (?mobile-nav-share-preview=1)
1664
+ * so query assembly and error handling never fork into two copies.
1665
+ *
1666
+ * Module-shape rules (same reason as rasterize.ts): the share-image check
1667
+ * script imports this file through Node type stripping, which rejects JSX
1668
+ * and runs no browser API — so every import of a JSX-bearing module is
1669
+ * `import type` (fully erased) and browser globals appear only inside
1670
+ * function bodies. The query builder and the error taxonomy below are pure
1671
+ * and pinned there.
1672
+ */
1673
+ Object.defineProperty(exports, "__esModule", { value: true });
1674
+ exports.ShareFetchError = exports.SHARE_TURNS_PRESETS = exports.SHARE_EXPORT_ROUTE = void 0;
1675
+ exports.buildShareExportQuery = buildShareExportQuery;
1676
+ exports.fetchShareExport = fetchShareExport;
1677
+ /** Route the host registers (mirror of src/share-export.ts — the client cannot import host code). */
1678
+ exports.SHARE_EXPORT_ROUTE = '/_dsh/mobile-nav/share-export';
1679
+ /** Preset turn counts the range sheet offers (PLAN §6: presets, no free input). */
1680
+ exports.SHARE_TURNS_PRESETS = [3, 5, 10];
1681
+ /** Localizable fetch failure; ticket 05 maps `code` to locales shareErr*. */
1682
+ class ShareFetchError extends Error {
1683
+ code;
1684
+ status;
1685
+ constructor(code, status, detail) {
1686
+ super(`share-fetch/${code}${status === undefined ? '' : ` (HTTP ${status})`}: ${detail}`);
1687
+ this.name = 'ShareFetchError';
1688
+ this.code = code;
1689
+ this.status = status;
1690
+ }
1691
+ }
1692
+ exports.ShareFetchError = ShareFetchError;
1693
+ /**
1694
+ * Pure: assemble the route query. The session id arrives in its branded
1695
+ * `session-<uuid>` form (useSessions row / session-scoped slot prop) and is
1696
+ * passed through encodeURIComponent — a bare uuid would 404 (PLAN §2 note).
1697
+ */
1698
+ function buildShareExportQuery(session, range) {
1699
+ const params = [`session=${encodeURIComponent(session)}`];
1700
+ params.push(range.kind === 'last' ? `range=last&turns=${range.turns}` : 'range=all');
1701
+ return `${exports.SHARE_EXPORT_ROUTE}?${params.join('&')}`;
1702
+ }
1703
+ function isShareBlockShape(block) {
1704
+ if (typeof block !== 'object' || block === null)
1705
+ return false;
1706
+ const kind = block.kind;
1707
+ if (kind === 'image')
1708
+ return true;
1709
+ return kind === 'text' && typeof block.text === 'string';
1710
+ }
1711
+ function isShareTurnShape(turn) {
1712
+ if (typeof turn !== 'object' || turn === null)
1713
+ return false;
1714
+ const { role, seq, blocks } = turn;
1715
+ return (role === 'user' || role === 'assistant')
1716
+ && typeof seq === 'number' && Number.isFinite(seq)
1717
+ && Array.isArray(blocks) && blocks.every(isShareBlockShape);
1718
+ }
1719
+ /**
1720
+ * Fetch one export and classify every failure.
1721
+ *
1722
+ * Error mapping (the reason a 404 is split in two): this plugin's route
1723
+ * answers 404 + `{error:{code:'session-not-found'}}` for an unknown session,
1724
+ * while a DSH old enough to lack the route answers the web server's own 404
1725
+ * — a different copy (“需要新版 DSH”) than “会话不存在”, decided by whether
1726
+ * our envelope is present.
1727
+ */
1728
+ async function fetchShareExport(session, range) {
1729
+ let res;
1730
+ try {
1731
+ res = await fetch(buildShareExportQuery(session, range), { cache: 'no-store' });
1732
+ }
1733
+ catch (err) {
1734
+ throw new ShareFetchError('network', undefined, err instanceof Error ? err.message : String(err));
1735
+ }
1736
+ if (!res.ok) {
1737
+ let envelope;
1738
+ try {
1739
+ envelope = await res.json();
1740
+ }
1741
+ catch {
1742
+ // Not JSON — an old DSH's plain 404 page or a gateway error page.
1743
+ envelope = undefined;
1744
+ }
1745
+ const code = envelope?.error?.code;
1746
+ const detail = typeof envelope?.error?.message === 'string' ? envelope.error.message : `HTTP ${res.status}`;
1747
+ if (res.status === 404 && code === 'session-not-found') {
1748
+ throw new ShareFetchError('session-not-found', res.status, detail);
1749
+ }
1750
+ if (res.status === 404) {
1751
+ throw new ShareFetchError('route-missing', res.status, detail);
1752
+ }
1753
+ if (res.status === 400) {
1754
+ throw new ShareFetchError('bad-request', res.status, detail);
1755
+ }
1756
+ throw new ShareFetchError('server', res.status, detail);
1757
+ }
1758
+ let body;
1759
+ try {
1760
+ body = await res.json();
1761
+ }
1762
+ catch (err) {
1763
+ throw new ShareFetchError('bad-body', res.status, err instanceof Error ? err.message : String(err));
1764
+ }
1765
+ if (body.ok !== true || !Array.isArray(body.turns) || !body.turns.every(isShareTurnShape)) {
1766
+ // Loud over lenient: silently dropping malformed rows would ship a
1767
+ // truncated transcript that LOOKS complete — the worst failure mode for
1768
+ // a share image.
1769
+ throw new ShareFetchError('bad-body', res.status, 'body is not a valid share-export payload');
1770
+ }
1771
+ return {
1772
+ turns: body.turns,
1773
+ createdAt: typeof body.createdAt === 'number' && Number.isFinite(body.createdAt) ? body.createdAt : undefined,
1774
+ };
1775
+ }
1776
+ };
1777
+ __modules["share/share-flow.js"] = function (require, module, exports) {
1778
+ "use strict";
1779
+ Object.defineProperty(exports, "__esModule", { value: true });
1780
+ exports.SHARE_NAME_MAX = exports.FALLBACK_SHARE_BASE_NAME = void 0;
1781
+ exports.sanitizeShareFileName = sanitizeShareFileName;
1782
+ exports.shareSliceFileName = shareSliceFileName;
1783
+ exports.shareImageFileName = shareImageFileName;
1784
+ exports.downloadBlobSequence = downloadBlobSequence;
1785
+ exports.deliverShareImages = deliverShareImages;
1786
+ exports.deliverShareImage = deliverShareImage;
1787
+ /** Basename used when the session title cleans down to nothing. */
1788
+ exports.FALLBACK_SHARE_BASE_NAME = 'share-card';
1789
+ /**
1790
+ * Max characters (code points, not UTF-16 units) kept from the title. The
1791
+ * `-NN.png` suffix adds 7; 50 + 7 stays far inside every filesystem's 255
1792
+ * byte-name limit even once UTF-8 doubles the CJK characters.
1793
+ */
1794
+ exports.SHARE_NAME_MAX = 50;
1795
+ /** XML 1.0-invalid control characters (same set rasterize.ts scrubs). */
1796
+ const CONTROL_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
1797
+ /**
1798
+ * Bidirectional format characters — LRM/RLM plus the LRE/RLE/PDF/LRO/RLO
1799
+ * family: zero-width, and in a filename they visually reorder the characters
1800
+ * around them (the classic `photo\u202Egpj.png` reads as a .jpg-extension
1801
+ * spoof), so they are stripped like controls rather than kept.
1802
+ */
1803
+ const BIDI_CONTROLS = /[\u200E\u200F\u202A-\u202E]/g;
1804
+ /** Path separators of every mainstream filesystem — removed, not replaced in place. */
1805
+ const PATH_SEPARATORS = /[\\/]/g;
1806
+ /** Any whitespace run — collapsed to a single space. */
1807
+ const WHITESPACE_RUN = /\s+/g;
1808
+ /**
1809
+ * Pure: session title → portable file basename. Path separators, control
1810
+ * characters and bidi format characters are removed (a separator in a
1811
+ * download name lets a hostile title pick the destination directory; a bidi
1812
+ * character visually reorders the name into an extension spoof), whitespace
1813
+ * runs fold to one space, the result is length-capped on code points (no
1814
+ * splitting surrogate pairs) and falls back to {@link FALLBACK_SHARE_BASE_NAME}
1815
+ * when nothing survives.
1816
+ */
1817
+ function sanitizeShareFileName(title) {
1818
+ const cleaned = title
1819
+ .replace(CONTROL_CHARS, '')
1820
+ .replace(BIDI_CONTROLS, '')
1821
+ .replace(PATH_SEPARATORS, ' ')
1822
+ .replace(WHITESPACE_RUN, ' ')
1823
+ .trim();
1824
+ if (cleaned === '')
1825
+ return exports.FALLBACK_SHARE_BASE_NAME;
1826
+ const chars = Array.from(cleaned);
1827
+ if (chars.length > exports.SHARE_NAME_MAX) {
1828
+ const capped = chars.slice(0, exports.SHARE_NAME_MAX).join('').trim();
1829
+ return capped === '' ? exports.FALLBACK_SHARE_BASE_NAME : capped;
1830
+ }
1831
+ return cleaned;
1832
+ }
1833
+ /**
1834
+ * File name of slice {@link index} (0-based): `<base>-NN.png`. Two digits
1835
+ * cover the rasterizer's 24-slice ceiling with room to spare and sort
1836
+ * lexicographically in every receiver's file list. Legacy multi-file fallback
1837
+ * only (ticket 07 stitches first when the browser supports it).
1838
+ */
1839
+ function shareSliceFileName(base, index) {
1840
+ return `${base}-${String(index + 1).padStart(2, '0')}.png`;
1841
+ }
1842
+ /**
1843
+ * File name of the stitched single long image (ticket 07): `<base>.png` — no
1844
+ * slice suffix, the deliverable is one file.
1845
+ */
1846
+ function shareImageFileName(base) {
1847
+ return `${base}.png`;
1848
+ }
1849
+ /** User dismissed the system share panel — not an error, nothing to retry. */
1850
+ function isAbortError(err) {
1851
+ return err instanceof Error && err.name === 'AbortError';
1852
+ }
1853
+ /**
1854
+ * The `<a download>` fallback, as one paced sequence (review 07+08 cleanup —
1855
+ * previously tripled across this module and the debug preview's export):
1856
+ * object URL → programmatic anchor click → delayed revoke per entry, with
1857
+ * consecutive clicks spaced 300ms apart so browsers do not coalesce or block
1858
+ * them. A single entry is simply one click with no pause.
1859
+ */
1860
+ async function downloadBlobSequence(entries) {
1861
+ for (const [index, entry] of entries.entries()) {
1862
+ const url = URL.createObjectURL(entry.blob);
1863
+ const anchor = document.createElement('a');
1864
+ anchor.href = url;
1865
+ anchor.download = entry.name;
1866
+ anchor.click();
1867
+ window.setTimeout(() => URL.revokeObjectURL(url), 10_000);
1868
+ if (index < entries.length - 1)
1869
+ await new Promise((resolve) => window.setTimeout(resolve, 300));
1870
+ }
1871
+ }
1872
+ /** The `<a download>` fallback: one paced click per file. */
1873
+ async function downloadFiles(files) {
1874
+ await downloadBlobSequence(files.map((file) => ({ blob: file, name: file.name })));
1875
+ }
1876
+ /**
1877
+ * Deliver rasterized slices (legacy multi-file fallback): system share panel
1878
+ * when the browser accepts files, downloads otherwise. A share() rejection
1879
+ * that is NOT the user's own cancel — classically NotAllowedError, because
1880
+ * seconds of rasterizing spent the click's transient activation — falls
1881
+ * through to downloads rather than discarding the finished PNGs.
1882
+ */
1883
+ async function deliverShareImages(slices, title) {
1884
+ const base = sanitizeShareFileName(title);
1885
+ const files = slices.map((slice, index) => new File([slice.blob], shareSliceFileName(base, index), { type: 'image/png' }));
1886
+ return deliverFiles(files);
1887
+ }
1888
+ /**
1889
+ * Deliver the stitched single long image (ticket 07): ONE `<base>.png` through
1890
+ * the system share panel, or a single download where the Web Share API is
1891
+ * missing/refusing — never a file sequence.
1892
+ */
1893
+ async function deliverShareImage(png, title) {
1894
+ const base = sanitizeShareFileName(title);
1895
+ return deliverFiles([new File([png], shareImageFileName(base), { type: 'image/png' })]);
1896
+ }
1897
+ /** Shared delivery tail: share panel, else one paced download per file. */
1898
+ async function deliverFiles(input) {
1899
+ const files = [...input]; // mutable shallow copy — the navigator.share shape wants File[]
1900
+ const nav = typeof navigator === 'undefined' ? undefined : navigator;
1901
+ if (typeof nav?.canShare === 'function' && typeof nav.share === 'function' && nav.canShare({ files })) {
1902
+ try {
1903
+ await nav.share({ files });
1904
+ return 'shared';
1905
+ }
1906
+ catch (err) {
1907
+ if (isAbortError(err))
1908
+ return 'cancelled';
1909
+ await downloadFiles(files);
1910
+ return 'downloaded';
1911
+ }
1912
+ }
1913
+ await downloadFiles(files);
1914
+ return 'downloaded';
1915
+ }
1916
+ };
1917
+ __modules["share/png-stitch.js"] = function (require, module, exports) {
1918
+ "use strict";
1919
+ /**
1920
+ * Streaming PNG long-image stitcher (ticket 07, PLAN §4.5).
1921
+ *
1922
+ * The rasterizer still paints slice-by-slice (each slice stays under the iOS
1923
+ * canvas budget), but the DELIVERABLE is one PNG: this module concatenates the
1924
+ * slices' scanlines into a single image — PNG signature + IHDR (whole-image
1925
+ * size, 8-bit RGBA) + IDAT (zlib stream, scanline filter 0) + IEND, CRC32
1926
+ * computed against a self-carried table, compression via the platform's
1927
+ * `CompressionStream('deflate')` whose output already IS the zlib-wrapped
1928
+ * deflate stream the PNG IDAT chunk requires.
1929
+ *
1930
+ * Streaming discipline (the whole point of a custom stitcher): slices are
1931
+ * decoded one at a time (createImageBitmap → offscreen canvas getImageData →
1932
+ * per-row writeRow → release) so the raw pixels of the WHOLE image are never
1933
+ * held; only the compressed output accumulates, as a Blob.
1934
+ *
1935
+ * Module contract (same one share-flow.ts honors): scripts/check-share-image.mjs
1936
+ * imports this file through Node type stripping, which EXECUTES module scope —
1937
+ * so everything here is pure math at module scope, and every browser global
1938
+ * (CompressionStream, createImageBitmap, document, Blob) is touched only
1939
+ * inside function/class bodies. The file is an import-free leaf.
1940
+ */
1941
+ Object.defineProperty(exports, "__esModule", { value: true });
1942
+ exports.PngStitchWriter = exports.MAX_IDAT_CHUNK_BYTES = exports.IEND_CHUNK = exports.PNG_SIGNATURE = void 0;
1943
+ exports.crc32 = crc32;
1944
+ exports.pngChunk = pngChunk;
1945
+ exports.ihdrData = ihdrData;
1946
+ exports.splitIdatBytes = splitIdatBytes;
1947
+ exports.supportsPngStitch = supportsPngStitch;
1948
+ exports.stitchSliceBlobs = stitchSliceBlobs;
1949
+ /* ============================================================================
1950
+ * PURE BYTES — CRC32 + chunk framing (runs in Node for the check script)
1951
+ * ========================================================================== */
1952
+ /** Standard CRC-32 table (reflected, polynomial 0xEDB88320). */
1953
+ const CRC_TABLE = (() => {
1954
+ const table = new Array(256);
1955
+ for (let n = 0; n < 256; n++) {
1956
+ let c = n;
1957
+ for (let k = 0; k < 8; k++)
1958
+ c = (c & 1) !== 0 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
1959
+ table[n] = c >>> 0;
1960
+ }
1961
+ return table;
1962
+ })();
1963
+ /**
1964
+ * CRC-32 (PNG variant: no init/xor toggling beyond the standard reflected
1965
+ * form — init 0xFFFFFFFF, final XOR 0xFFFFFFFF). Known answers pinned by the
1966
+ * check script: crc32('123456789') === 0xCBF43926, crc32('IEND') === 0xAE426082.
1967
+ */
1968
+ function crc32(bytes) {
1969
+ let c = 0xffffffff;
1970
+ for (let i = 0; i < bytes.length; i++) {
1971
+ c = (CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8)) >>> 0;
1972
+ }
1973
+ return (c ^ 0xffffffff) >>> 0;
1974
+ }
1975
+ /** The 8-byte PNG file signature. */
1976
+ exports.PNG_SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
1977
+ /** ASCII bytes of a chunk type string (types are always 4 ASCII letters). */
1978
+ function ascii(value) {
1979
+ const out = new Uint8Array(value.length);
1980
+ for (let i = 0; i < value.length; i++)
1981
+ out[i] = value.charCodeAt(i) & 0xff;
1982
+ return out;
1983
+ }
1984
+ function writeU32BE(target, offset, value) {
1985
+ target[offset] = (value >>> 24) & 0xff;
1986
+ target[offset + 1] = (value >>> 16) & 0xff;
1987
+ target[offset + 2] = (value >>> 8) & 0xff;
1988
+ target[offset + 3] = value & 0xff;
1989
+ }
1990
+ /**
1991
+ * One PNG chunk frame: 4-byte big-endian data length, the type, the data, and
1992
+ * the big-endian CRC32 over TYPE+DATA (never the length field).
1993
+ */
1994
+ function pngChunk(type, data) {
1995
+ const typeBytes = ascii(type);
1996
+ if (typeBytes.length !== 4)
1997
+ throw new Error(`png-stitch: chunk type must be 4 ASCII letters, got ${JSON.stringify(type)}`);
1998
+ const out = new Uint8Array(12 + data.length);
1999
+ writeU32BE(out, 0, data.length);
2000
+ out.set(typeBytes, 4);
2001
+ out.set(data, 8);
2002
+ const crcInput = new Uint8Array(4 + data.length);
2003
+ crcInput.set(typeBytes, 0);
2004
+ crcInput.set(data, 4);
2005
+ writeU32BE(out, 8 + data.length, crc32(crcInput));
2006
+ return out;
2007
+ }
2008
+ /** IHDR payload: width/height big-endian, 8 bits/channel, RGBA (color type 6), deflate, filter 0, no interlace. */
2009
+ function ihdrData(width, height) {
2010
+ const data = new Uint8Array(13);
2011
+ writeU32BE(data, 0, width);
2012
+ writeU32BE(data, 4, height);
2013
+ data[8] = 8; // bit depth
2014
+ data[9] = 6; // color type: truecolor + alpha
2015
+ data[10] = 0; // compression: deflate
2016
+ data[11] = 0; // filter: adaptive (per scanline byte)
2017
+ data[12] = 0; // interlace: none
2018
+ return data;
2019
+ }
2020
+ /** The IEND chunk is a constant: empty payload, CRC 0xAE426082. */
2021
+ exports.IEND_CHUNK = pngChunk('IEND', new Uint8Array(0));
2022
+ /**
2023
+ * IDAT payload split ceiling (1 MiB): a single IDAT may legally be any size,
2024
+ * but decoders appreciate modest chunks — and splitting keeps the multi-IDAT
2025
+ * path honest since most exports stay under it and emit exactly one.
2026
+ */
2027
+ exports.MAX_IDAT_CHUNK_BYTES = 1 << 20;
2028
+ /** Split compressed bytes into consecutive IDAT payloads (multi-chunk IDAT is legal and order-significant). */
2029
+ function splitIdatBytes(data, maxBytes) {
2030
+ if (!Number.isInteger(maxBytes) || maxBytes <= 0)
2031
+ throw new Error(`png-stitch: invalid IDAT chunk size ${maxBytes}`);
2032
+ if (data.length === 0)
2033
+ return [new Uint8Array(0)];
2034
+ const parts = [];
2035
+ for (let offset = 0; offset < data.length; offset += maxBytes) {
2036
+ parts.push(data.subarray(offset, Math.min(offset + maxBytes, data.length)));
2037
+ }
2038
+ return parts;
2039
+ }
2040
+ /* ============================================================================
2041
+ * STREAMING WRITER — rows in, Blob out (works in Node 22 too: the check
2042
+ * script produces real PNGs and verifies them with node:zlib)
2043
+ * ========================================================================== */
2044
+ /** Scanline filter type 0 (None) — the single filter byte every row carries. */
2045
+ const FILTER_NONE = new Uint8Array([0]);
2046
+ /**
2047
+ * Row-by-row PNG assembler for one already-known raster size.
2048
+ *
2049
+ * Backpressure: the compressed readable side is drained by a loop started in
2050
+ * the constructor — without a concurrent reader the transform stalls once its
2051
+ * buffers fill and every writeRow would deadlock. Row bytes are NOT copied:
2052
+ * writeRow's awaited write resolves only after the compressor has consumed the
2053
+ * chunk, and the rasterizer feeds disjoint subarrays of an ImageData buffer
2054
+ * that is never mutated after getImageData, so no staging buffer is needed.
2055
+ */
2056
+ class PngStitchWriter {
2057
+ width;
2058
+ height;
2059
+ rowsWritten = 0;
2060
+ closed = false;
2061
+ writer;
2062
+ drained;
2063
+ constructor(width, height) {
2064
+ if (!Number.isInteger(width) || width <= 0)
2065
+ throw new Error(`png-stitch: invalid width ${width}`);
2066
+ if (!Number.isInteger(height) || height <= 0)
2067
+ throw new Error(`png-stitch: invalid height ${height}`);
2068
+ this.width = width;
2069
+ this.height = height;
2070
+ const compressor = new CompressionStream('deflate');
2071
+ this.writer = compressor.writable.getWriter();
2072
+ const reader = compressor.readable.getReader();
2073
+ this.drained = (async () => {
2074
+ const chunks = [];
2075
+ for (;;) {
2076
+ const { done, value } = await reader.read();
2077
+ if (done === true)
2078
+ break;
2079
+ if (value !== undefined)
2080
+ chunks.push(value);
2081
+ }
2082
+ let total = 0;
2083
+ for (const chunk of chunks)
2084
+ total += chunk.length;
2085
+ const out = new Uint8Array(total);
2086
+ let offset = 0;
2087
+ for (const chunk of chunks) {
2088
+ out.set(chunk, offset);
2089
+ offset += chunk.length;
2090
+ }
2091
+ return out;
2092
+ })();
2093
+ // The drain loop starts here and may reject before finish() ever awaits
2094
+ // it (a writeRow error abandons the writer mid-stream) — mark it handled
2095
+ // from birth so the rejection surfaces only through finish(), never as an
2096
+ // unhandled promise rejection. The derived promise resolves; the original
2097
+ // still rejects for the awaiter.
2098
+ void this.drained.catch(() => { });
2099
+ }
2100
+ /** Feed one scanline (`width × 4` RGBA bytes). The bytes must stay untouched until the returned promise resolves. */
2101
+ async writeRow(rgba) {
2102
+ if (this.closed)
2103
+ throw new Error('png-stitch: writer already finished');
2104
+ if (this.rowsWritten >= this.height)
2105
+ throw new Error(`png-stitch: row ${this.rowsWritten} exceeds the declared height ${this.height}`);
2106
+ if (rgba.length !== this.width * 4) {
2107
+ throw new Error(`png-stitch: row is ${rgba.length} bytes, expected ${this.width * 4} (width ${this.width} × RGBA)`);
2108
+ }
2109
+ await this.writer.write(FILTER_NONE);
2110
+ await this.writer.write(rgba);
2111
+ this.rowsWritten += 1;
2112
+ }
2113
+ /** Close the stream and assemble the file. Rejects unless exactly `height` rows were fed. */
2114
+ async finish() {
2115
+ if (this.closed)
2116
+ throw new Error('png-stitch: writer already finished');
2117
+ if (this.rowsWritten !== this.height) {
2118
+ throw new Error(`png-stitch: declared ${this.height} rows but ${this.rowsWritten} were written — IHDR height must equal Σ slice heights`);
2119
+ }
2120
+ this.closed = true;
2121
+ await this.writer.close();
2122
+ const compressed = await this.drained;
2123
+ const parts = [exports.PNG_SIGNATURE, pngChunk('IHDR', ihdrData(this.width, this.height))];
2124
+ for (const payload of splitIdatBytes(compressed, exports.MAX_IDAT_CHUNK_BYTES)) {
2125
+ parts.push(pngChunk('IDAT', payload));
2126
+ }
2127
+ parts.push(exports.IEND_CHUNK);
2128
+ return new Blob(parts);
2129
+ }
2130
+ }
2131
+ exports.PngStitchWriter = PngStitchWriter;
2132
+ /* ============================================================================
2133
+ * BROWSER ORCHESTRATION — slice blobs in, single PNG Blob out
2134
+ * (never executed by the check script; Node has no createImageBitmap)
2135
+ * ========================================================================== */
2136
+ /**
2137
+ * Feature probe for the stitch path (ticket 07): CompressionStream gates the
2138
+ * whole mechanism (Safari gained it in 16.4, alongside createImageBitmap and
2139
+ * Blob — the trio is checked anyway because each is independently load-bearing).
2140
+ * Unavailable → the caller falls back to the legacy multi-file delivery.
2141
+ */
2142
+ function supportsPngStitch() {
2143
+ return typeof CompressionStream === 'function' && typeof createImageBitmap === 'function' && typeof Blob === 'function';
2144
+ }
2145
+ /**
2146
+ * Stitch painted slices into one PNG of the shared physical width.
2147
+ *
2148
+ * Per slice: createImageBitmap → draw onto a throwaway canvas → getImageData →
2149
+ * writeRow per scanline → zero the canvas and close the bitmap before the next
2150
+ * slice. getImageData un-premultiplies alpha, which is lossy for translucent
2151
+ * pixels — the share card paints on an opaque background (alpha 255), so the
2152
+ * round-trip is exact here. Every slice must decode at exactly the global
2153
+ * width and its declared height; a mismatch means a painting bug, and the
2154
+ * writer's row accounting would corrupt the file, so it fails loudly instead.
2155
+ */
2156
+ async function stitchSliceBlobs(parts, width) {
2157
+ if (parts.length === 0)
2158
+ throw new Error('png-stitch: no slices to stitch');
2159
+ if (!Number.isInteger(width) || width <= 0)
2160
+ throw new Error(`png-stitch: invalid stitch width ${width}`);
2161
+ const totalHeight = parts.reduce((sum, part) => sum + part.height, 0);
2162
+ const writer = new PngStitchWriter(width, totalHeight);
2163
+ for (const part of parts) {
2164
+ const bitmap = await createImageBitmap(part.blob);
2165
+ try {
2166
+ if (bitmap.width !== width) {
2167
+ throw new Error(`png-stitch: slice decoded ${bitmap.width}px wide, expected the global ${width}px — cannot concatenate scanlines`);
2168
+ }
2169
+ if (bitmap.height !== part.height) {
2170
+ throw new Error(`png-stitch: slice decoded ${bitmap.height}px tall, planned ${part.height}px`);
2171
+ }
2172
+ const canvas = document.createElement('canvas');
2173
+ canvas.width = width;
2174
+ canvas.height = part.height;
2175
+ try {
2176
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
2177
+ if (ctx === null)
2178
+ throw new Error('png-stitch: 2d context unavailable');
2179
+ ctx.drawImage(bitmap, 0, 0);
2180
+ const image = ctx.getImageData(0, 0, width, part.height);
2181
+ const bytesPerRow = width * 4;
2182
+ for (let y = 0; y < part.height; y++) {
2183
+ // Zero-copy row view over the ImageData buffer (its data is a
2184
+ // Uint8ClampedArray; a same-offset Uint8Array view shares the bytes).
2185
+ const row = new Uint8Array(image.data.buffer, image.data.byteOffset + y * bytesPerRow, bytesPerRow);
2186
+ await writer.writeRow(row);
2187
+ }
2188
+ }
2189
+ finally {
2190
+ canvas.width = 0;
2191
+ canvas.height = 0;
2192
+ }
2193
+ }
2194
+ finally {
2195
+ bitmap.close();
2196
+ }
2197
+ }
2198
+ return writer.finish();
2199
+ }
2200
+ };
2201
+ __modules["share/share-constants.js"] = function (require, module, exports) {
2202
+ "use strict";
2203
+ /**
2204
+ * Template constants the share card and its rasterizer MUST agree on
2205
+ * (tickets 03/04; the width-lifting formulas in rasterize.ts invert the
2206
+ * template's own constraints, so a diverging copy would mis-bucket wide
2207
+ * content). Formerly duplicated as literals in share-card.tsx and re-derived
2208
+ * in rasterize.ts with a regex lock in the check script; both sides now
2209
+ * import this single module, and scripts/check-share-image.mjs asserts its
2210
+ * values directly.
2211
+ *
2212
+ * Hard rule for this file: a pure leaf — no imports, no JSX, no DOM, no
2213
+ * module side effects. Node type stripping executes every value import, and
2214
+ * both the check script and the client bundler rely on this module being
2215
+ * loadable anywhere.
2216
+ */
2217
+ Object.defineProperty(exports, "__esModule", { value: true });
2218
+ exports.USER_BUBBLE_PAD_X = exports.USER_BUBBLE_MAX_RATIO = exports.WIDE_SLICE_WIDTH = exports.BASE_CARD_WIDTH = void 0;
2219
+ /** Base logical card width (share-card.tsx default render width). */
2220
+ exports.BASE_CARD_WIDTH = 390;
2221
+ /** Widened slice width for wide content (PLAN §5.2 “更大逻辑宽 ~780”). */
2222
+ exports.WIDE_SLICE_WIDTH = 780;
2223
+ /**
2224
+ * User-bubble width cap relative to its row (`maxWidth` in share-card.tsx,
2225
+ * e.g. 0.82 → '82%'). cardWidthForUserBubble inverts this percentage into a
2226
+ * division, so the two sides must be the same number.
2227
+ */
2228
+ exports.USER_BUBBLE_MAX_RATIO = 0.82;
2229
+ /** Horizontal padding inside the user bubble (its `padding` X component). */
2230
+ exports.USER_BUBBLE_PAD_X = 14;
2231
+ };
2232
+ __modules["share/rasterize.js"] = function (require, module, exports) {
2233
+ "use strict";
2234
+ /**
2235
+ * Share-card slicing + rasterization (ticket 04, PLAN §5.2).
2236
+ *
2237
+ * Turns a rendered ShareCard DOM tree (ticket 03: fully inline-styled, marked
2238
+ * with data-share-card / -header / -turn / -role / -footer) into a sequence of
2239
+ * PNG blobs: slice by measured turn heights under a per-slice canvas pixel
2240
+ * budget, then paint each slice through
2241
+ * `<svg><foreignObject>` → Image → canvas → PNG export. The SVG document
2242
+ * carries each slice's PHYSICAL target size (intrinsic = canvas), so the image
2243
+ * rasterizes sharp at the canvas's own resolution and the draw is a 1:1 copy.
2244
+ *
2245
+ * File layout, and why it matters:
2246
+ * - The top half is PURE planning math (no DOM, no React). It may import
2247
+ * ONLY the pure constants module of this directory (share-constants.ts):
2248
+ * scripts/check-share-image.mjs imports this file through Node type
2249
+ * stripping, which EXECUTES every value import — JSX or DOM code would
2250
+ * break the check, and any other module would widen the client bundle.
2251
+ * - The DOM half below only MEASURES and EXECUTES the pure plan's decisions:
2252
+ * slice grouping, width lifting, scale factors, pixel-ratio fallback and
2253
+ * truncation are all decided by the pure functions, and the SVG document the
2254
+ * painter draws is built by a pure serializer — so tests can pin all of it.
2255
+ *
2256
+ * Release policy per slice (PLAN §5.2): each slice is painted, blobbed and its
2257
+ * canvas dropped before the next slice is built — the export never holds more
2258
+ * than one live canvas. Ticket 07 adds the `unified` planning mode whose
2259
+ * single physical width lets png-stitch.ts concatenate the slice PNGs into
2260
+ * one long image.
2261
+ */
2262
+ Object.defineProperty(exports, "__esModule", { value: true });
2263
+ exports.ShareRasterizeError = exports.ULTRA_WIDTH_MARGIN = exports.PIXEL_RATIO_STEPS = exports.MAX_SLICES = exports.SLICE_PIXEL_BUDGET = void 0;
2264
+ exports.ratioChain = ratioChain;
2265
+ exports.slicePixelCount = slicePixelCount;
2266
+ exports.rasterGeometry = rasterGeometry;
2267
+ exports.buildSvgDocument = buildSvgDocument;
2268
+ exports.cardWidthForContent = cardWidthForContent;
2269
+ exports.cardWidthForUserBubble = cardWidthForUserBubble;
2270
+ exports.requiredCardWidth = requiredCardWidth;
2271
+ exports.bucketTurnWidth = bucketTurnWidth;
2272
+ exports.ultraSliceWidth = ultraSliceWidth;
2273
+ exports.planShareSlices = planShareSlices;
2274
+ exports.unifiedCardWidth = unifiedCardWidth;
2275
+ exports.planShareSlicesUnified = planShareSlicesUnified;
2276
+ exports.stitchPixelWidth = stitchPixelWidth;
2277
+ exports.rasterGeometryStitch = rasterGeometryStitch;
2278
+ exports.ensureProbe = ensureProbe;
2279
+ exports.rasterizeShareCard = rasterizeShareCard;
2280
+ const share_constants_ts_1 = require("./share/share-constants.js");
2281
+ /* ============================================================================
2282
+ * PURE PLANNING — no DOM, no React, imports only share-constants.ts
2283
+ * ========================================================================== */
2284
+ /** Per-slice physical pixel budget (iOS canvas hard cap 16.7M minus margin). */
2285
+ exports.SLICE_PIXEL_BUDGET = 12_000_000;
2286
+ /** Slice count ceiling; beyond it the head is dropped and the first slice marked. */
2287
+ exports.MAX_SLICES = 24;
2288
+ /** Pixel-ratio fallback ladder, walked top-down from min(devicePixelRatio, 2). */
2289
+ exports.PIXEL_RATIO_STEPS = [2, 1.5, 1];
2290
+ /** Safety px added around computed ultra widths (sub-pixel + rounding slack). */
2291
+ exports.ULTRA_WIDTH_MARGIN = 2;
2292
+ /** Localizable rasterize failure. `code` drives UI copy, `detail` is diagnostic. */
2293
+ class ShareRasterizeError extends Error {
2294
+ code;
2295
+ detail;
2296
+ constructor(code, detail) {
2297
+ super(`share-rasterize/${code}: ${detail}`);
2298
+ this.code = code;
2299
+ this.detail = detail;
2300
+ }
2301
+ }
2302
+ exports.ShareRasterizeError = ShareRasterizeError;
2303
+ /** Descending pixel-ratio candidates for an already-clamped cap; never empty. */
2304
+ function ratioChain(cap) {
2305
+ const chain = exports.PIXEL_RATIO_STEPS.filter((step) => step <= cap + 1e-9);
2306
+ return chain.length > 0 ? chain : [1];
2307
+ }
2308
+ /** Physical pixels a slice occupies once painted (rounding up, canvas-style). */
2309
+ function slicePixelCount(outWidth, outHeight, pixelRatio) {
2310
+ return Math.ceil(outWidth * pixelRatio) * Math.ceil(outHeight * pixelRatio);
2311
+ }
2312
+ /* ---- SVG raster document (pure; the painter only feeds it strings) --------- */
2313
+ const XHTML_NS = 'http://www.w3.org/1999/xhtml';
2314
+ const SVG_NS = 'http://www.w3.org/2000/svg';
2315
+ /** XML 1.0 forbids these; DOM text from transcripts can theoretically carry them. */
2316
+ const INVALID_XML_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/g;
2317
+ /**
2318
+ * Physical target size for one slice's raster. The svg/foreignObject
2319
+ * width/height attributes MUST be these numbers, never the logical ones: the
2320
+ * browser rasterizes an SVG-as-image at its INTRINSIC size, so logical
2321
+ * attributes make WebKit decode a small bitmap that drawImage then upscales
2322
+ * (blurry on 2x screens) — and an unbounded ultra renderWidth would ask the
2323
+ * decoder for an unbounded bitmap, sailing past the pixel budget before any
2324
+ * canvas is allocated. The logical tree instead rides an inner wrapper scaled
2325
+ * by `transformScale`, so the canvas draw is a 1:1 copy. Sizes are clamped
2326
+ * into `budgetPx` (floor per side when clamping, so the product provably fits
2327
+ * even where the unclamped per-side ceil would not have).
2328
+ */
2329
+ function rasterGeometry(renderWidth, logicalHeight, scale, pixelRatio, budgetPx) {
2330
+ let transformScale = scale * pixelRatio;
2331
+ let width = Math.max(1, Math.ceil(renderWidth * transformScale));
2332
+ let height = Math.max(1, Math.ceil(logicalHeight * transformScale));
2333
+ if (width * height > budgetPx) {
2334
+ transformScale *= Math.sqrt(budgetPx / (width * height));
2335
+ width = Math.max(1, Math.floor(renderWidth * transformScale));
2336
+ height = Math.max(1, Math.floor(logicalHeight * transformScale));
2337
+ }
2338
+ return { width, height, transformScale };
2339
+ }
2340
+ /**
2341
+ * Serialize one slice subtree into a standalone SVG document sized at its
2342
+ * PHYSICAL target: svg + foreignObject carry the physical px, the xhtml
2343
+ * wrapper carries the logical size and a scale() transform, so the image
2344
+ * rasterizes at exactly the resolution the canvas wants (no upscaled copy).
2345
+ * `bodyHtml` is the prepared slice clone's outerHTML — namespace attributes
2346
+ * must already be baked in by the caller (prepareXmlSerialization).
2347
+ */
2348
+ function buildSvgDocument(bodyHtml, spec) {
2349
+ const body = bodyHtml.replace(INVALID_XML_CHARS, '');
2350
+ const wrapper = `<div xmlns="${XHTML_NS}" style="width:${spec.renderWidth}px;height:${spec.logicalHeight}px;transform:scale(${spec.transformScale});transform-origin:0 0">${body}</div>`;
2351
+ return `<svg xmlns="${SVG_NS}" width="${spec.width}" height="${spec.height}"><foreignObject width="${spec.width}" height="${spec.height}">${wrapper}</foreignObject></svg>`;
2352
+ }
2353
+ /**
2354
+ * Card width that fits `contentWidth` of assistant-style rows: the row spans
2355
+ * the card's whole content box, so only the card padding stacks on top.
2356
+ */
2357
+ function cardWidthForContent(contentWidth, cardPaddingX) {
2358
+ return Math.ceil(contentWidth) + 2 * Math.ceil(cardPaddingX) + exports.ULTRA_WIDTH_MARGIN;
2359
+ }
2360
+ /**
2361
+ * Card width that fits `contentWidth` inside a user bubble: the bubble is
2362
+ * capped at {@link USER_BUBBLE_MAX_RATIO} of the row and adds its own padding,
2363
+ * so the percentage constraint inverts into a division.
2364
+ */
2365
+ function cardWidthForUserBubble(contentWidth, cardPaddingX) {
2366
+ const inner = Math.ceil(contentWidth) + 2 * share_constants_ts_1.USER_BUBBLE_PAD_X;
2367
+ return Math.ceil(inner / share_constants_ts_1.USER_BUBBLE_MAX_RATIO) + 2 * Math.ceil(cardPaddingX) + exports.ULTRA_WIDTH_MARGIN;
2368
+ }
2369
+ /** Card width a turn's measured content needs, by turn role. */
2370
+ function requiredCardWidth(role, contentWidth, cardPaddingX) {
2371
+ return role === 'user' ? cardWidthForUserBubble(contentWidth, cardPaddingX) : cardWidthForContent(contentWidth, cardPaddingX);
2372
+ }
2373
+ /** Bucket one turn: base width / widened (~780) / wider-than-wide (scale-to-fit). */
2374
+ function bucketTurnWidth(role, contentWidth, cardPaddingX, baseWidth, wideWidth) {
2375
+ const required = requiredCardWidth(role, contentWidth, cardPaddingX);
2376
+ if (required <= baseWidth)
2377
+ return 'base';
2378
+ if (required <= wideWidth)
2379
+ return 'wide';
2380
+ return 'ultra';
2381
+ }
2382
+ /**
2383
+ * Scale-to-fit geometry for content whose needed card width exceeds the wide
2384
+ * width: render the slice at full natural width, shrink geometrically on
2385
+ * output (pinch-zoom in the image viewer recovers readability — PLAN §5.2).
2386
+ */
2387
+ function ultraSliceWidth(role, contentWidth, cardPaddingX, wideWidth) {
2388
+ const renderWidth = requiredCardWidth(role, contentWidth, cardPaddingX);
2389
+ return { renderWidth, scale: Math.min(1, wideWidth / renderWidth) };
2390
+ }
2391
+ function sameRun(a, b) {
2392
+ return a.renderWidth === b.renderWidth && a.scale === b.scale;
2393
+ }
2394
+ function packingPixels(p, extras, pixelRatio) {
2395
+ const height = (extras.paddingY + p.children) * p.scale;
2396
+ return slicePixelCount(p.renderWidth * p.scale, height, pixelRatio);
2397
+ }
2398
+ /**
2399
+ * Greedy first-fit over runs of consecutive turns sharing the same geometry.
2400
+ * A turn is never split; a run never mixes widths (a 390px turn must not ride
2401
+ * a 780px slice — mid-sequence line-length jumps read as a glitch). The first
2402
+ * turn of a run always starts a slice even when it alone busts the budget
2403
+ * (un-splittable); the caller's ratio walk notices and degrades.
2404
+ */
2405
+ function greedyPacking(turns, extras, pixelRatio, budgetPx) {
2406
+ const out = [];
2407
+ let i = 0;
2408
+ while (i < turns.length) {
2409
+ let j = i + 1;
2410
+ while (j < turns.length && sameRun(turns[j], turns[i]))
2411
+ j += 1;
2412
+ let cur;
2413
+ let children = 0;
2414
+ for (let k = i; k < j; k++) {
2415
+ const turn = turns[k];
2416
+ const projected = children + (cur === undefined ? 0 : extras.gap) + turn.height;
2417
+ const candidate = cur === undefined
2418
+ ? { from: k, to: k, renderWidth: turn.renderWidth, scale: turn.scale, children: turn.height }
2419
+ : { ...cur, to: k, children: projected };
2420
+ if (cur !== undefined && packingPixels(candidate, extras, pixelRatio) > budgetPx) {
2421
+ out.push(cur);
2422
+ cur = { from: k, to: k, renderWidth: turn.renderWidth, scale: turn.scale, children: turn.height };
2423
+ children = turn.height;
2424
+ }
2425
+ else {
2426
+ cur = candidate;
2427
+ children = projected;
2428
+ }
2429
+ }
2430
+ if (cur !== undefined)
2431
+ out.push(cur);
2432
+ i = j;
2433
+ }
2434
+ return out;
2435
+ }
2436
+ /**
2437
+ * Ratio ladder + tail-keeping truncation shared by both planners. Order of
2438
+ * levers (PLAN §5.2):
2439
+ * 1. pick the highest pixel ratio whose greedy packing fits both the budget
2440
+ * on every slice and the {@link PlanOptions.maxSlices} count — the ratio is
2441
+ * GLOBAL, a property of the whole card's plan, never of one slice;
2442
+ * 2. at the lowest ratio, keep the LAST maxSlices slices and drop the head
2443
+ * (a share image is of the RECENT conversation — with range=last the user
2444
+ * explicitly named the newest turns, which head-keeping would discard).
2445
+ */
2446
+ function walkRatioAndTruncate(turns, extras, opts) {
2447
+ const lastRatio = opts.ratios.length > 0 ? opts.ratios[opts.ratios.length - 1] : 1;
2448
+ if (turns.length === 0) {
2449
+ return { packing: [], pixelRatio: lastRatio, truncated: false, droppedTurns: 0 };
2450
+ }
2451
+ let packing;
2452
+ let pixelRatio = lastRatio;
2453
+ for (const candidate of opts.ratios) {
2454
+ const p = greedyPacking(turns, extras, candidate, opts.budgetPx);
2455
+ if (p.length <= opts.maxSlices && p.every((s) => packingPixels(s, extras, candidate) <= opts.budgetPx)) {
2456
+ packing = p;
2457
+ pixelRatio = candidate;
2458
+ break;
2459
+ }
2460
+ }
2461
+ let truncated = false;
2462
+ let droppedTurns = 0;
2463
+ if (packing === undefined) {
2464
+ const p = greedyPacking(turns, extras, lastRatio, opts.budgetPx);
2465
+ // Keep the TAIL: the head is the only droppable direction (see doc above).
2466
+ const kept = p.slice(Math.max(0, p.length - opts.maxSlices));
2467
+ const firstKept = kept[0];
2468
+ droppedTurns = firstKept === undefined ? turns.length : firstKept.from;
2469
+ truncated = droppedTurns > 0;
2470
+ packing = kept;
2471
+ pixelRatio = lastRatio;
2472
+ }
2473
+ return { packing, pixelRatio, truncated, droppedTurns };
2474
+ }
2475
+ /** Reserve the header/note/footer chrome on the first/last packing rows (mutates). */
2476
+ function attachChrome(packing, extras, truncated) {
2477
+ const first = packing[0];
2478
+ first.children += extras.headerHeight + extras.gap;
2479
+ if (truncated)
2480
+ first.children += extras.noteHeight + extras.gap;
2481
+ const last = packing[packing.length - 1];
2482
+ last.children += extras.footerHeight + extras.gap;
2483
+ }
2484
+ /**
2485
+ * Plan the slice sequence (legacy multi-file geometry, ticket 04). Beyond the
2486
+ * shared walk, any slice still over budget (an un-splittable giant turn, or
2487
+ * the header/footer chrome nudging a full slice past the line) is shrunk
2488
+ * uniformly PER SLICE (the `lever`) — geometric scaling keeps completeness,
2489
+ * zoom restores size. Per-slice levers make physical widths diverge, which is
2490
+ * fine for separate files and exactly what the stitched planner removes.
2491
+ *
2492
+ * Deterministic pure function of its inputs.
2493
+ */
2494
+ function planShareSlices(turns, extras, opts) {
2495
+ const { packing, pixelRatio, truncated, droppedTurns } = walkRatioAndTruncate(turns, extras, opts);
2496
+ if (packing.length === 0) {
2497
+ return { pixelRatio, slices: [], truncated: false, droppedTurns: 0 };
2498
+ }
2499
+ attachChrome(packing, extras, truncated);
2500
+ const count = packing.length;
2501
+ const slices = packing.map((p, index) => {
2502
+ const height = extras.paddingY + p.children;
2503
+ // Budget lever: the sqrt bound ignores the canvas ceil rounding, so shrink
2504
+ // iteratively (sub-1% deficits converge in one or two steps).
2505
+ let scale = p.scale;
2506
+ if (slicePixelCount(p.renderWidth * scale, height * scale, pixelRatio) > opts.budgetPx) {
2507
+ const bound = Math.sqrt(opts.budgetPx / (p.renderWidth * height)) / pixelRatio;
2508
+ scale = Math.min(p.scale, bound);
2509
+ for (let attempt = 0; attempt < 24 && slicePixelCount(p.renderWidth * scale, height * scale, pixelRatio) > opts.budgetPx; attempt++) {
2510
+ scale *= 0.997;
2511
+ }
2512
+ }
2513
+ const lever = p.scale > 0 ? scale / p.scale : 1;
2514
+ return {
2515
+ from: p.from,
2516
+ to: p.to,
2517
+ renderWidth: p.renderWidth,
2518
+ scale,
2519
+ height,
2520
+ outWidth: p.renderWidth * scale,
2521
+ outHeight: height * scale,
2522
+ lever,
2523
+ first: index === 0,
2524
+ last: index === count - 1,
2525
+ truncated: truncated && index === 0,
2526
+ };
2527
+ });
2528
+ return { pixelRatio, slices, truncated, droppedTurns };
2529
+ }
2530
+ /* ---- stitch-mode unified planning (ticket 07, PLAN §4.5) -------------------- */
2531
+ /**
2532
+ * Global logical width of a stitched export: 390 unless ANY turn needs wide
2533
+ * content, then 780 for the WHOLE card — one long image must not mix widths
2534
+ * (scanlines concatenate only when every slice has the same pixel width; a
2535
+ * mid-image line-length jump would read as a glitch anyway).
2536
+ */
2537
+ function unifiedCardWidth(anyTurnNeedsWide) {
2538
+ return anyTurnNeedsWide ? share_constants_ts_1.WIDE_SLICE_WIDTH : share_constants_ts_1.BASE_CARD_WIDTH;
2539
+ }
2540
+ /**
2541
+ * Plan the slice sequence for a stitched single-PNG export. Same walk and
2542
+ * truncation semantics as {@link planShareSlices}, but the budget lever is
2543
+ * GLOBAL: one multiplier shared by every slice, so every slice's output width
2544
+ * is identical and the PNG stitcher can concatenate scanlines directly. The
2545
+ * inputs must already be normalized to the global width by the caller (the
2546
+ * DOM half measures the card once at {@link unifiedCardWidth}; turns still
2547
+ * wider than that keep their per-turn ultra scale-to-fit, which lands on the
2548
+ * same output width by construction).
2549
+ */
2550
+ function planShareSlicesUnified(turns, extras, opts) {
2551
+ const { packing, pixelRatio, truncated, droppedTurns } = walkRatioAndTruncate(turns, extras, opts);
2552
+ if (packing.length === 0) {
2553
+ return { pixelRatio, slices: [], truncated: false, droppedTurns: 0 };
2554
+ }
2555
+ attachChrome(packing, extras, truncated);
2556
+ // GLOBAL lever: when any un-splittable slice busts the budget at the chosen
2557
+ // ratio, the most-shrinking slice governs a single multiplier applied to all
2558
+ // of them — widths stay uniform (the stitch invariant) at the cost of
2559
+ // shrinking well-behaved slices too, which only happens when some single
2560
+ // turn is taller than the whole budget.
2561
+ let lever = 1;
2562
+ const pixelsOf = (p, multiplier) => {
2563
+ const height = (extras.paddingY + p.children) * p.scale * multiplier;
2564
+ return slicePixelCount(p.renderWidth * p.scale * multiplier, height, pixelRatio);
2565
+ };
2566
+ if (packing.some((p) => pixelsOf(p, 1) > opts.budgetPx)) {
2567
+ let bound = 1;
2568
+ for (const p of packing) {
2569
+ const logical = extras.paddingY + p.children;
2570
+ const sliceBound = Math.sqrt(opts.budgetPx / (p.renderWidth * logical)) / pixelRatio;
2571
+ bound = Math.min(bound, sliceBound / p.scale);
2572
+ }
2573
+ lever = Math.min(1, bound);
2574
+ for (let attempt = 0; attempt < 24 && packing.some((p) => pixelsOf(p, lever) > opts.budgetPx); attempt++) {
2575
+ lever *= 0.997;
2576
+ }
2577
+ }
2578
+ const count = packing.length;
2579
+ const slices = packing.map((p, index) => {
2580
+ const height = extras.paddingY + p.children;
2581
+ const scale = p.scale * lever;
2582
+ return {
2583
+ from: p.from,
2584
+ to: p.to,
2585
+ renderWidth: p.renderWidth,
2586
+ scale,
2587
+ height,
2588
+ outWidth: p.renderWidth * scale,
2589
+ outHeight: height * scale,
2590
+ lever,
2591
+ first: index === 0,
2592
+ last: index === count - 1,
2593
+ truncated: truncated && index === 0,
2594
+ };
2595
+ });
2596
+ return { pixelRatio, slices, truncated, droppedTurns };
2597
+ }
2598
+ /**
2599
+ * Authoritative physical width of a stitched plan — the IHDR width of the
2600
+ * single long image. Every slice's own `outWidth × pixelRatio` must already
2601
+ * agree; the max absorbs the ±ε float noise of ultra scales
2602
+ * (renderWidth × W/renderWidth), so one integer serves the whole card and the
2603
+ * painter FORCES it (rasterGeometryStitch) instead of re-deriving it per slice.
2604
+ */
2605
+ function stitchPixelWidth(plan) {
2606
+ if (plan.slices.length === 0)
2607
+ return 0;
2608
+ const widest = plan.slices.reduce((max, s) => Math.max(max, s.outWidth), 0);
2609
+ return Math.ceil(widest * plan.pixelRatio);
2610
+ }
2611
+ /**
2612
+ * Physical raster geometry of one slice in stitch mode. The width is pinned to
2613
+ * `globalWidth` so every slice's canvas — and the scanlines the stitcher reads
2614
+ * from it — is exactly as wide as the final image; a slice that freshly
2615
+ * measures past the per-slice budget would need a height CLIP (never a width
2616
+ * shrink: that would break concatenation, never aspect distortion), and the
2617
+ * painter rejects that case instead of clipping (see `clamped`). The
2618
+ * planner's global lever is the mechanism that keeps slices in budget; this
2619
+ * clamp is the loud tripwire behind it.
2620
+ */
2621
+ function rasterGeometryStitch(renderWidth, logicalHeight, globalWidth, budgetPx) {
2622
+ if (!Number.isInteger(globalWidth) || globalWidth <= 0)
2623
+ throw new Error(`share-rasterize: invalid stitch width ${globalWidth}`);
2624
+ const transformScale = globalWidth / renderWidth;
2625
+ let height = Math.max(1, Math.ceil(logicalHeight * transformScale));
2626
+ let clamped = false;
2627
+ if (globalWidth * height > budgetPx) {
2628
+ height = Math.max(1, Math.floor(budgetPx / globalWidth));
2629
+ clamped = true;
2630
+ }
2631
+ return { width: globalWidth, height, transformScale, clamped };
2632
+ }
2633
+ /* ============================================================================
2634
+ * DOM EXECUTION — measures only, executes the pure plan
2635
+ * ========================================================================== */
2636
+ /** Overflow below this many px is rounding noise, not wide content. */
2637
+ const OVERFLOW_EPSILON = 1;
2638
+ /** Default truncation copy: counts the dropped head turns. */
2639
+ const DEFAULT_TRUNCATED_NOTE = (droppedTurns) => `已省略前 ${droppedTurns} 轮`;
2640
+ /**
2641
+ * Offscreen measurement/render host. A shadow root so page stylesheets (which
2642
+ * the later SVG-image render cannot load) do not reach the clones — inside
2643
+ * the shadow tree only inherited properties flow through, and the card
2644
+ * template resets the inheritable ones (font/color/line-height) inline on its
2645
+ * root, making the two contexts agree. position:fixed/-9999px keeps it laid
2646
+ * out (measurement needs real layout) but invisible.
2647
+ */
2648
+ function createOffscreenHost() {
2649
+ const host = document.createElement('div');
2650
+ host.setAttribute('data-share-rasterize-host', '');
2651
+ host.style.position = 'fixed';
2652
+ host.style.left = '-9999px';
2653
+ host.style.top = '0';
2654
+ const root = host.attachShadow({ mode: 'open' });
2655
+ document.body.appendChild(host);
2656
+ return { root, dispose: () => { host.remove(); } };
2657
+ }
2658
+ function cloneCardAt(card, width) {
2659
+ const clone = card.cloneNode(true);
2660
+ clone.style.width = `${width}px`;
2661
+ return clone;
2662
+ }
2663
+ /**
2664
+ * Natural content width of a turn: the widest descendant box that actually
2665
+ * overflows its own container (`white-space: pre` code/table segments — their
2666
+ * extent is container-independent). Non-overflowing descendants report their
2667
+ * box width, which is a layout result, not a need, so they are skipped.
2668
+ */
2669
+ function contentExtent(turn) {
2670
+ let max = 0;
2671
+ for (const el of turn.querySelectorAll('*')) {
2672
+ if (el.scrollWidth > el.clientWidth + OVERFLOW_EPSILON && el.scrollWidth > max) {
2673
+ max = el.scrollWidth;
2674
+ }
2675
+ }
2676
+ return max;
2677
+ }
2678
+ /**
2679
+ * Clone the card at `width`, measure everything the planner needs, drop the
2680
+ * clone. All reads (offsetHeight/scrollWidth/getComputedStyle) force sync
2681
+ * layout, so the numbers are stable the moment the clone is attached — no
2682
+ * frame timing involved.
2683
+ */
2684
+ function measureCardAt(root, card, width) {
2685
+ const clone = cloneCardAt(card, width);
2686
+ root.appendChild(clone);
2687
+ const turns = [];
2688
+ for (const el of Array.from(clone.querySelectorAll('[data-share-turn]'))) {
2689
+ const raw = el.getAttribute('data-share-turn');
2690
+ if (raw === null)
2691
+ continue;
2692
+ const seq = Number(raw);
2693
+ if (!Number.isFinite(seq))
2694
+ continue;
2695
+ turns.push({
2696
+ seq,
2697
+ role: el.getAttribute('data-share-role') === 'user' ? 'user' : 'assistant',
2698
+ height: el.offsetHeight,
2699
+ content: contentExtent(el),
2700
+ });
2701
+ }
2702
+ const computed = getComputedStyle(clone);
2703
+ const header = clone.querySelector('[data-share-header]');
2704
+ const footer = clone.querySelector('[data-share-footer]');
2705
+ const measure = {
2706
+ turns,
2707
+ headerHeight: header?.offsetHeight ?? 0,
2708
+ footerHeight: footer?.offsetHeight ?? 0,
2709
+ gap: parsePx(computed.rowGap),
2710
+ paddingY: parsePx(computed.paddingTop) + parsePx(computed.paddingBottom),
2711
+ paddingX: parsePx(computed.paddingLeft),
2712
+ };
2713
+ clone.remove();
2714
+ return measure;
2715
+ }
2716
+ function parsePx(value) {
2717
+ const n = Number.parseFloat(value);
2718
+ return Number.isFinite(n) ? n : 0;
2719
+ }
2720
+ function measureElementHeight(root, el) {
2721
+ root.appendChild(el);
2722
+ const h = el.offsetHeight;
2723
+ el.remove();
2724
+ return h;
2725
+ }
2726
+ /**
2727
+ * Measure an ultra turn at its planned width, re-deciding up to three times if
2728
+ * the formula missed a constraint the DOM knows better (nested percentage
2729
+ * boxes): widening the card never shrinks `pre` content, so re-measuring
2730
+ * converges. When it still does not fit after the third pass the geometry is
2731
+ * exported anyway (completeness over sharpness) but warned about once — a
2732
+ * silent clip would look like data loss.
2733
+ */
2734
+ function measureUltraTurn(root, card, turn, cardPaddingX, targetWidth) {
2735
+ let geo = ultraSliceWidth(turn.role, turn.content, cardPaddingX, targetWidth);
2736
+ let height = turn.height;
2737
+ let scrollWidth = 0;
2738
+ let fits = false;
2739
+ for (let attempt = 0; attempt < 3 && !fits; attempt++) {
2740
+ const clone = cloneCardAt(card, geo.renderWidth);
2741
+ root.appendChild(clone);
2742
+ const el = clone.querySelector(`[data-share-turn="${turn.seq}"]`);
2743
+ const extent = el !== null ? contentExtent(el) : turn.content;
2744
+ height = el?.offsetHeight ?? height;
2745
+ scrollWidth = el?.scrollWidth ?? 0;
2746
+ fits = el === null || scrollWidth <= geo.renderWidth;
2747
+ clone.remove();
2748
+ if (!fits)
2749
+ geo = ultraSliceWidth(turn.role, extent, cardPaddingX, targetWidth);
2750
+ }
2751
+ if (!fits) {
2752
+ console.warn(`[dsh-zen-remote] share-image: ultra turn ${turn.seq} still wider than its slice after 3 re-measures (content ${scrollWidth}px > render width ${geo.renderWidth}px); exporting may clip its right edge`);
2753
+ }
2754
+ return { height, renderWidth: geo.renderWidth, scale: geo.scale };
2755
+ }
2756
+ /** Theme probes for the truncation note, read off the live card (inline styles). */
2757
+ function readNoteTheme(card) {
2758
+ let tertiary = '#81858c';
2759
+ let border = 'rgba(0, 0, 0, 0.1)';
2760
+ let fontFamily = '';
2761
+ try {
2762
+ fontFamily = getComputedStyle(card).fontFamily;
2763
+ const caption = card.querySelector('[data-share-footer] span:last-of-type');
2764
+ if (caption !== null)
2765
+ tertiary = getComputedStyle(caption).color;
2766
+ const rule = card.querySelector('[data-share-footer] > div:first-child');
2767
+ if (rule !== null)
2768
+ border = getComputedStyle(rule).backgroundColor;
2769
+ }
2770
+ catch {
2771
+ // getComputedStyle on a detached card — the defaults above carry the note.
2772
+ }
2773
+ return { tertiary, border, fontFamily };
2774
+ }
2775
+ /** The「已省略前 X 轮」marker bar — inline-styled like everything the card renders. */
2776
+ function buildTruncationNote(text, theme) {
2777
+ const el = document.createElement('div');
2778
+ el.setAttribute('data-share-truncated', '');
2779
+ el.style.display = 'flex';
2780
+ el.style.alignItems = 'center';
2781
+ el.style.gap = '6px';
2782
+ el.style.padding = '8px 12px';
2783
+ el.style.border = `1px dashed ${theme.border}`;
2784
+ el.style.borderRadius = '8px';
2785
+ el.style.fontSize = '12px';
2786
+ el.style.lineHeight = '1.5';
2787
+ el.style.color = theme.tertiary;
2788
+ if (theme.fontFamily !== '')
2789
+ el.style.fontFamily = theme.fontFamily;
2790
+ el.style.overflow = 'visible';
2791
+ el.textContent = text;
2792
+ return el;
2793
+ }
2794
+ /**
2795
+ * Clone the card and prune it down to exactly one slice's content: the
2796
+ * planned turns, the header only on the first slice, the footer only on the
2797
+ * last, plus the truncation note right under the header of a truncated first
2798
+ * slice. The root's own flex gap/padding keep the layout identical to the
2799
+ * planner's arithmetic (children + inter-child gaps + padding).
2800
+ */
2801
+ function buildSliceClone(card, root, slice, seqs, note) {
2802
+ const clone = cloneCardAt(card, slice.renderWidth);
2803
+ for (const el of Array.from(clone.querySelectorAll('[data-share-turn]'))) {
2804
+ const raw = el.getAttribute('data-share-turn');
2805
+ if (raw === null || !seqs.has(Number(raw)))
2806
+ el.remove();
2807
+ }
2808
+ const header = clone.querySelector('[data-share-header]');
2809
+ if (!slice.first)
2810
+ header?.remove();
2811
+ if (slice.truncated && note !== undefined) {
2812
+ const noteNode = note.cloneNode(true);
2813
+ if (header !== null && header.parentElement !== null)
2814
+ header.parentElement.insertBefore(noteNode, header.nextSibling);
2815
+ else
2816
+ clone.insertBefore(noteNode, clone.firstChild);
2817
+ }
2818
+ if (!slice.last)
2819
+ clone.querySelector('[data-share-footer]')?.remove();
2820
+ root.appendChild(clone);
2821
+ return clone;
2822
+ }
2823
+ /**
2824
+ * Make an HTML subtree survive XML parsing: the foreignObject child needs the
2825
+ * xhtml namespace (HTML serialization never emits it), and inline `<svg>`
2826
+ * glyphs (the image placeholder icon) need theirs — without it they parse as
2827
+ * xhtml-namespace elements named "svg" and silently render as nothing.
2828
+ */
2829
+ function prepareXmlSerialization(root) {
2830
+ root.setAttribute('xmlns', XHTML_NS);
2831
+ for (const el of Array.from(root.querySelectorAll('*'))) {
2832
+ if (el.namespaceURI === SVG_NS)
2833
+ el.setAttribute('xmlns', SVG_NS);
2834
+ }
2835
+ }
2836
+ /**
2837
+ * Describe a source URL for an error surface WITHOUT embedding its content:
2838
+ * the SVG data URL carries the whole card (session transcript), so only the
2839
+ * scheme prefix and total length are kept.
2840
+ */
2841
+ function describeImageUrl(url) {
2842
+ if (!url.startsWith('data:'))
2843
+ return url.slice(0, 120);
2844
+ const comma = url.indexOf(',');
2845
+ const prefix = comma === -1 ? url.slice(0, 48) : url.slice(0, comma);
2846
+ return `${prefix} (${url.length} chars, payload omitted)`;
2847
+ }
2848
+ function loadImage(url) {
2849
+ return new Promise((resolve, reject) => {
2850
+ const img = new Image();
2851
+ img.onload = () => { resolve(img); };
2852
+ img.onerror = () => { reject(new ShareRasterizeError('image-load', describeImageUrl(url))); };
2853
+ img.src = url;
2854
+ });
2855
+ }
2856
+ function canvasToPng(canvas) {
2857
+ return new Promise((resolve, reject) => {
2858
+ canvas.toBlob((blob) => {
2859
+ if (blob === null)
2860
+ reject(new ShareRasterizeError('canvas', 'toBlob returned null'));
2861
+ else
2862
+ resolve(blob);
2863
+ }, 'image/png');
2864
+ });
2865
+ }
2866
+ function readBackground(el) {
2867
+ const bg = getComputedStyle(el).backgroundColor;
2868
+ return bg === '' || bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent' ? '#ffffff' : bg;
2869
+ }
2870
+ /**
2871
+ * Paint one prepared slice clone: serialize → SVG data URL (encodeURIComponent
2872
+ * — transcript text is not ASCII) sized at the slice's PHYSICAL target → Image
2873
+ * whose intrinsic size already IS the canvas size → 1:1 canvas copy → PNG
2874
+ * blob. The canvas is released (zeroed) before returning; only the blob is
2875
+ * kept. Output height is taken from the freshly measured clone, not the
2876
+ * planner's estimate — reflow at slice width is authoritative — and any
2877
+ * resulting budget overshoot is clamped by rasterGeometry (legacy) or
2878
+ * REFUSED with ShareRasterizeError('slice-over-budget') by
2879
+ * rasterGeometryStitch (unified: width pinned to `globalWidth`, a floor'd
2880
+ * height would silently clip the bottom — review 07+08).
2881
+ */
2882
+ async function paintSlice(clone, slice, pixelRatio, globalWidth) {
2883
+ const logicalHeight = Math.ceil(clone.offsetHeight);
2884
+ let width;
2885
+ let height;
2886
+ let transformScale;
2887
+ if (globalWidth === undefined) {
2888
+ const geo = rasterGeometry(slice.renderWidth, logicalHeight, slice.scale, pixelRatio, exports.SLICE_PIXEL_BUDGET);
2889
+ width = geo.width;
2890
+ height = geo.height;
2891
+ transformScale = geo.transformScale;
2892
+ }
2893
+ else {
2894
+ const geo = rasterGeometryStitch(slice.renderWidth, logicalHeight, globalWidth, exports.SLICE_PIXEL_BUDGET);
2895
+ if (geo.clamped) {
2896
+ // Review 07+08 (plan B): floor'ing the height would silently cut the
2897
+ // bottom off the slice — data loss the user cannot see. The planner's
2898
+ // global lever should have kept every slice in budget; reaching here
2899
+ // means it did not, and the honest answer is a localized failure, not a
2900
+ // quietly truncated image.
2901
+ const needed = Math.ceil(logicalHeight * geo.transformScale);
2902
+ const detail = `slice ${slice.from}-${slice.to} measured ${needed}px tall at stitch width ${globalWidth}px, but the canvas budget caps it at ${geo.height}px — refusing a silent bottom clip`;
2903
+ console.warn(`[dsh-zen-remote] share-image: ${detail} (the global lever should have prevented this)`);
2904
+ throw new ShareRasterizeError('slice-over-budget', detail);
2905
+ }
2906
+ width = geo.width;
2907
+ height = geo.height;
2908
+ transformScale = geo.transformScale;
2909
+ }
2910
+ prepareXmlSerialization(clone);
2911
+ const svg = buildSvgDocument(clone.outerHTML, {
2912
+ renderWidth: slice.renderWidth,
2913
+ logicalHeight,
2914
+ width,
2915
+ height,
2916
+ transformScale,
2917
+ });
2918
+ const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
2919
+ const img = await loadImage(url);
2920
+ const canvas = document.createElement('canvas');
2921
+ canvas.width = width;
2922
+ canvas.height = height;
2923
+ const ctx = canvas.getContext('2d');
2924
+ if (ctx === null)
2925
+ throw new ShareRasterizeError('canvas', '2d context unavailable');
2926
+ ctx.fillStyle = readBackground(clone);
2927
+ ctx.fillRect(0, 0, width, height);
2928
+ ctx.imageSmoothingQuality = 'high';
2929
+ // 1:1 copy — the SVG's intrinsic size is the physical target, so nothing is
2930
+ // upscaled here (the old logical-intrinsic + drawImage(target) pair made
2931
+ // WebKit hand back a small bitmap this call had to enlarge: blurry on 2x).
2932
+ ctx.drawImage(img, 0, 0);
2933
+ const blob = await canvasToPng(canvas);
2934
+ canvas.width = 0;
2935
+ canvas.height = 0;
2936
+ return { blob, width, height };
2937
+ }
2938
+ let probeOutcome = 'unknown';
2939
+ const PROBE_SIZE = 40;
2940
+ const PROBE_CELLS = ['#e8322c', '#2c55e8', '#28a832', '#e8a028'];
2941
+ /**
2942
+ * 2×2 feature probe (PLAN §5.2): before the first export, rasterize a tiny
2943
+ * known card (four solid color cells through the exact foreignObject → Image
2944
+ * → canvas path) and read the pixels back. Old Safari renders foreignObject
2945
+ * images as nothing — exporting then would produce blank PNGs, so the probe
2946
+ * fails loudly once per page load instead. Failure is sticky.
2947
+ */
2948
+ async function ensureProbe() {
2949
+ if (probeOutcome === 'ok')
2950
+ return;
2951
+ if (probeOutcome === 'failed') {
2952
+ throw new ShareRasterizeError('probe-failed', 'the SVG foreignObject probe already failed on this page');
2953
+ }
2954
+ const cells = PROBE_CELLS.map((color) => `<div style="width:20px;height:20px;background:${color}"></div>`).join('');
2955
+ const svg = `<svg xmlns="${SVG_NS}" width="${PROBE_SIZE}" height="${PROBE_SIZE}"><foreignObject width="${PROBE_SIZE}" height="${PROBE_SIZE}"><div xmlns="${XHTML_NS}" style="width:${PROBE_SIZE}px;height:${PROBE_SIZE}px;margin:0;display:flex;flex-wrap:wrap">${cells}</div></foreignObject></svg>`;
2956
+ try {
2957
+ const img = await loadImage(`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`);
2958
+ const canvas = document.createElement('canvas');
2959
+ canvas.width = PROBE_SIZE;
2960
+ canvas.height = PROBE_SIZE;
2961
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
2962
+ if (ctx === null)
2963
+ throw new Error('2d context unavailable');
2964
+ ctx.drawImage(img, 0, 0);
2965
+ const data = ctx.getImageData(0, 0, PROBE_SIZE, PROBE_SIZE).data;
2966
+ const at = (x, y, channel) => data[(y * PROBE_SIZE + x) * 4 + channel] ?? 0;
2967
+ // Cell centers: red top-left, blue top-right (generous thresholds —
2968
+ // color management may shift values a little, blankness shifts them a lot).
2969
+ const red = at(10, 10, 0) >= 150 && at(10, 10, 1) < 150 && at(10, 10, 2) < 150;
2970
+ const blue = at(30, 10, 2) >= 150 && at(30, 10, 0) < 150 && at(30, 10, 1) < 150;
2971
+ canvas.width = 0;
2972
+ canvas.height = 0;
2973
+ if (!red || !blue)
2974
+ throw new Error('probe canvas came back blank (foreignObject not rendered)');
2975
+ probeOutcome = 'ok';
2976
+ }
2977
+ catch (err) {
2978
+ probeOutcome = 'failed';
2979
+ console.error('[dsh-zen-remote] share-image rasterize probe failed — this browser cannot paint SVG foreignObject content', err);
2980
+ throw new ShareRasterizeError('probe-failed', err instanceof Error ? err.message : String(err));
2981
+ }
2982
+ }
2983
+ /* ---- pipeline -------------------------------------------------------------- */
2984
+ /**
2985
+ * Rasterize a rendered share card into a PNG sequence.
2986
+ *
2987
+ * Pipeline: probe once → measure a clone at base width → bucket turns (pure)
2988
+ * → measure wide/ultra turns at their final widths → plan slices (pure) →
2989
+ * per slice: prune a clone to the slice, measure, serialize into SVG, paint,
2990
+ * release. The returned blobs are owned by the caller (object URLs etc.).
2991
+ *
2992
+ * `unified` (ticket 07) flips both planning decisions the stitch depends on:
2993
+ * one global logical width (re-measured once at 390 or 780 — a card that
2994
+ * lifts ANY turn to wide renders every turn wide) and one global budget
2995
+ * lever, so `stitchWidth` comes back as the single physical width every slice
2996
+ * shares and png-stitch.ts can concatenate the scanlines into one PNG.
2997
+ */
2998
+ async function rasterizeShareCard(card, options = {}) {
2999
+ await ensureProbe();
3000
+ const cap = Math.min(2, Math.max(1, options.pixelRatioCap ?? (typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1)));
3001
+ const host = createOffscreenHost();
3002
+ try {
3003
+ const base = measureCardAt(host.root, card, share_constants_ts_1.BASE_CARD_WIDTH);
3004
+ if (base.turns.length === 0) {
3005
+ throw new ShareRasterizeError('empty-card', 'no [data-share-turn] elements found under the card root');
3006
+ }
3007
+ const noteTheme = readNoteTheme(card);
3008
+ const noteFor = (droppedTurns) => buildTruncationNote((options.truncatedNote ?? DEFAULT_TRUNCATED_NOTE)(droppedTurns), noteTheme);
3009
+ const extrasOf = (note) => ({
3010
+ gap: base.gap,
3011
+ paddingY: base.paddingY,
3012
+ headerHeight: base.headerHeight,
3013
+ footerHeight: base.footerHeight,
3014
+ noteHeight: measureElementHeight(host.root, note),
3015
+ });
3016
+ const planOpts = { budgetPx: exports.SLICE_PIXEL_BUDGET, maxSlices: exports.MAX_SLICES, ratios: ratioChain(cap) };
3017
+ let note = noteFor(0);
3018
+ let plan;
3019
+ let stitchWidth;
3020
+ const inputs = [];
3021
+ if (options.unified === true) {
3022
+ // Global width first: any wide-needing turn widens the WHOLE card, then
3023
+ // everything is (re)measured at that one width. Turns still wider than
3024
+ // the global width keep the per-turn ultra scale-to-fit — which lands on
3025
+ // the same output width by construction (renderWidth × W/renderWidth).
3026
+ const needsWide = base.turns.some((turn) => bucketTurnWidth(turn.role, turn.content, base.paddingX, share_constants_ts_1.BASE_CARD_WIDTH, share_constants_ts_1.WIDE_SLICE_WIDTH) !== 'base');
3027
+ const cardWidth = unifiedCardWidth(needsWide);
3028
+ const measure = cardWidth === share_constants_ts_1.BASE_CARD_WIDTH ? base : measureCardAt(host.root, card, cardWidth);
3029
+ for (const turn of measure.turns) {
3030
+ if (requiredCardWidth(turn.role, turn.content, measure.paddingX) <= cardWidth) {
3031
+ inputs.push({ height: turn.height, renderWidth: cardWidth, scale: 1 });
3032
+ }
3033
+ else {
3034
+ const ultra = measureUltraTurn(host.root, card, turn, measure.paddingX, cardWidth);
3035
+ inputs.push({ height: ultra.height, renderWidth: ultra.renderWidth, scale: ultra.scale });
3036
+ }
3037
+ }
3038
+ plan = planShareSlicesUnified(inputs, extrasOf(note), planOpts);
3039
+ if (plan.truncated) {
3040
+ // Re-plan once with the real dropped count baked into the note (its text
3041
+ // length can change the bar's height); droppedTurns itself does not
3042
+ // depend on noteHeight, so this converges in one step.
3043
+ note = noteFor(plan.droppedTurns);
3044
+ plan = planShareSlicesUnified(inputs, extrasOf(note), planOpts);
3045
+ }
3046
+ stitchWidth = stitchPixelWidth(plan);
3047
+ }
3048
+ else {
3049
+ const buckets = base.turns.map((turn) => bucketTurnWidth(turn.role, turn.content, base.paddingX, share_constants_ts_1.BASE_CARD_WIDTH, share_constants_ts_1.WIDE_SLICE_WIDTH));
3050
+ const wideHeights = buckets.includes('wide')
3051
+ ? new Map(measureCardAt(host.root, card, share_constants_ts_1.WIDE_SLICE_WIDTH).turns.map((t) => [t.seq, t.height]))
3052
+ : undefined;
3053
+ for (let i = 0; i < base.turns.length; i++) {
3054
+ const turn = base.turns[i];
3055
+ const bucket = buckets[i];
3056
+ if (bucket === 'base') {
3057
+ inputs.push({ height: turn.height, renderWidth: share_constants_ts_1.BASE_CARD_WIDTH, scale: 1 });
3058
+ }
3059
+ else if (bucket === 'wide') {
3060
+ inputs.push({ height: wideHeights?.get(turn.seq) ?? turn.height, renderWidth: share_constants_ts_1.WIDE_SLICE_WIDTH, scale: 1 });
3061
+ }
3062
+ else {
3063
+ const ultra = measureUltraTurn(host.root, card, turn, base.paddingX, share_constants_ts_1.WIDE_SLICE_WIDTH);
3064
+ inputs.push({ height: ultra.height, renderWidth: ultra.renderWidth, scale: ultra.scale });
3065
+ }
3066
+ }
3067
+ plan = planShareSlices(inputs, extrasOf(note), planOpts);
3068
+ if (plan.truncated) {
3069
+ note = noteFor(plan.droppedTurns);
3070
+ plan = planShareSlices(inputs, extrasOf(note), planOpts);
3071
+ }
3072
+ }
3073
+ options.onProgress?.(0, plan.slices.length);
3074
+ const slices = [];
3075
+ for (let index = 0; index < plan.slices.length; index++) {
3076
+ const slice = plan.slices[index];
3077
+ const seqSet = new Set(base.turns.slice(slice.from, slice.to + 1).map((t) => t.seq));
3078
+ const clone = buildSliceClone(card, host.root, slice, seqSet, slice.truncated ? note : undefined);
3079
+ const painted = await paintSlice(clone, slice, plan.pixelRatio, stitchWidth);
3080
+ clone.remove();
3081
+ slices.push({
3082
+ index,
3083
+ blob: painted.blob,
3084
+ width: painted.width,
3085
+ height: painted.height,
3086
+ logicalWidth: slice.renderWidth,
3087
+ scale: slice.scale,
3088
+ fromSeq: base.turns[slice.from].seq,
3089
+ toSeq: base.turns[slice.to].seq,
3090
+ });
3091
+ options.onProgress?.(index + 1, plan.slices.length);
3092
+ }
3093
+ return { pixelRatio: plan.pixelRatio, truncated: plan.truncated, droppedTurns: plan.droppedTurns, slices, stitchWidth };
3094
+ }
3095
+ finally {
3096
+ host.dispose();
3097
+ }
3098
+ }
3099
+ };
3100
+ __modules["share/text-layout.js"] = function (require, module, exports) {
3101
+ "use strict";
3102
+ /**
3103
+ * Pure text → segment fold for share-card text blocks (ticket 03).
3104
+ *
3105
+ * Deliberately NOT a markdown parser: the transcripts the share card renders
3106
+ * are *mostly* markdown, and the readability wins that matter on a phone-size
3107
+ * image are structures recognizable from line shape alone — fenced/indented
3108
+ * code, list items, pipe tables. Everything else stays one pre-wrapped
3109
+ * paragraph, which degrades gracefully (raw text still reads), instead of
3110
+ * half-parsing markup into broken pseudo-HTML.
3111
+ *
3112
+ * No React and no DOM in this file on purpose: the share-image check script
3113
+ * (ticket 04) imports it through Node type stripping, which rejects JSX, so
3114
+ * the fold must live in a JSX-free module.
3115
+ */
3116
+ Object.defineProperty(exports, "__esModule", { value: true });
3117
+ exports.segmentShareText = segmentShareText;
3118
+ /** ``` / ~~~ fence opener; a language tag after it is allowed and dropped. */
3119
+ const FENCE_OPEN = /^ {0,3}(```+|~~~+)/;
3120
+ /** A closing fence is the same run of the same character, alone on a line. */
3121
+ const FENCE_CLOSE = {
3122
+ '`': /^ {0,3}`{3,}\s*$/,
3123
+ '~': /^ {0,3}~{3,}\s*$/,
3124
+ };
3125
+ /** 4+ leading spaces or a tab — the classic indented-code threshold. */
3126
+ const INDENTED = /^(?: {4,}|\t)/;
3127
+ /** `- item` / `* item` / `+ item` / `12. item` / `12) item`. */
3128
+ const LIST_ITEM = /^ {0,3}([-*+]|\d{1,3}[.)]) +(.*)$/;
3129
+ /** Pipe-table row; must be followed by another pipe row to OPEN a table block
3130
+ * (a single stray `|` line stays an ordinary paragraph). */
3131
+ const TABLE_ROW = /^\s*\|.*\|/;
3132
+ function isBlank(line) {
3133
+ return line.trim() === '';
3134
+ }
3135
+ /**
3136
+ * Fold one text block's raw string into display segments, top to bottom.
3137
+ *
3138
+ * Rules, in the order each line is tested:
3139
+ * - ```/~~~ fences switch to verbatim code until the closing fence (or the
3140
+ * end of the block — an unterminated fence must not swallow nothing);
3141
+ * - a 4-space/tab indent opens verbatim code; a blank line between two
3142
+ * indented lines stays inside the block (lazy continuation);
3143
+ * - two consecutive pipe rows open a table block, collected while rows last;
3144
+ * - a `-`/`*`/`+`/`N.`/`N)` line opens a list, collected while items last;
3145
+ * - blank lines end the current paragraph; anything else accumulates into the
3146
+ * current paragraph (rendered pre-wrap, so manual line breaks survive).
3147
+ * @param text - raw text-block content from the share-export route.
3148
+ * @returns the segments in reading order; never contains empty segments.
3149
+ */
3150
+ function segmentShareText(text) {
3151
+ const segments = [];
3152
+ const lines = text.replace(/\r\n?/g, '\n').split('\n');
3153
+ let pending = [];
3154
+ const flushPending = () => {
3155
+ if (pending.length > 0) {
3156
+ segments.push({ kind: 'para', text: pending.join('\n') });
3157
+ pending = [];
3158
+ }
3159
+ };
3160
+ let i = 0;
3161
+ while (i < lines.length) {
3162
+ const line = lines[i] ?? '';
3163
+ const fence = line.match(FENCE_OPEN);
3164
+ if (fence !== null) {
3165
+ flushPending();
3166
+ const close = FENCE_CLOSE[fence[1]?.[0] ?? '`'] ?? FENCE_CLOSE['`'];
3167
+ const body = [];
3168
+ i += 1;
3169
+ while (i < lines.length && !close.test(lines[i] ?? '')) {
3170
+ body.push(lines[i] ?? '');
3171
+ i += 1;
3172
+ }
3173
+ // Skip the closing fence; when the block never closed, this steps past
3174
+ // the end and the loop simply ends.
3175
+ i += 1;
3176
+ if (body.length > 0)
3177
+ segments.push({ kind: 'code', lines: body });
3178
+ continue;
3179
+ }
3180
+ if (INDENTED.test(line)) {
3181
+ flushPending();
3182
+ const body = [line];
3183
+ i += 1;
3184
+ while (i < lines.length) {
3185
+ const next = lines[i] ?? '';
3186
+ if (INDENTED.test(next)) {
3187
+ body.push(next);
3188
+ i += 1;
3189
+ continue;
3190
+ }
3191
+ if (isBlank(next) && INDENTED.test(lines[i + 1] ?? '')) {
3192
+ body.push('');
3193
+ i += 1;
3194
+ continue;
3195
+ }
3196
+ break;
3197
+ }
3198
+ segments.push({ kind: 'code', lines: body });
3199
+ continue;
3200
+ }
3201
+ if (TABLE_ROW.test(line) && TABLE_ROW.test(lines[i + 1] ?? '')) {
3202
+ flushPending();
3203
+ const body = [];
3204
+ while (i < lines.length && TABLE_ROW.test(lines[i] ?? '')) {
3205
+ body.push(lines[i] ?? '');
3206
+ i += 1;
3207
+ }
3208
+ segments.push({ kind: 'table', lines: body });
3209
+ continue;
3210
+ }
3211
+ if (isBlank(line)) {
3212
+ flushPending();
3213
+ i += 1;
3214
+ continue;
3215
+ }
3216
+ const item = line.match(LIST_ITEM);
3217
+ if (item !== null) {
3218
+ flushPending();
3219
+ const items = [];
3220
+ while (i < lines.length) {
3221
+ const m = (lines[i] ?? '').match(LIST_ITEM);
3222
+ if (m === null)
3223
+ break;
3224
+ items.push({ marker: m[1] ?? '-', text: m[2] ?? '' });
3225
+ i += 1;
3226
+ }
3227
+ segments.push({ kind: 'list', items });
3228
+ continue;
3229
+ }
3230
+ pending.push(line);
3231
+ i += 1;
3232
+ }
3233
+ flushPending();
3234
+ return segments;
3235
+ }
3236
+ };
3237
+ __modules["share/markdown.js"] = function (require, module, exports) {
3238
+ "use strict";
3239
+ /**
3240
+ * Pure lenient Markdown-subset parser for the share card's assistant text
3241
+ * (ticket 09, PLAN §5.1 v1.2).
3242
+ *
3243
+ * Scope = the constructs assistant transcripts actually use: ATX headings,
3244
+ * paragraphs, ```/~~~ fenced code (with the language tag), 4-space indented
3245
+ * code, blockquotes, ordered/unordered lists (two indent levels, 2-space
3246
+ * multiples), GFM pipe tables with an alignment row, thematic breaks, and
3247
+ * own-line image placeholders; inline bold/italic/strikethrough/code/link with
3248
+ * backslash escapes. Nothing else — no HTML passthrough, no reference links,
3249
+ * no setext headings, no syntax highlighting (PLAN §8 scope discipline).
3250
+ *
3251
+ * Leniency beats strictness ON PURPOSE (PLAN §5.1): a share image that shows
3252
+ * stray asterisks is worse than one that shows plain text. Every marker pair
3253
+ * must CLOSE before it counts — unclosed, empty or implausible markers fall
3254
+ * back to their literal characters, so arbitrary prose round-trips as itself.
3255
+ * The parser is also total: any input yields a tree (empty input yields []).
3256
+ * Totality is enforced, not assumed: recursion (nested quotes, emphasis inside
3257
+ * link text) carries a depth counter and a pathological 5000-deep "> > > …"
3258
+ * degrades to literal paragraphs instead of overflowing the stack; one inline
3259
+ * run longer than {@link INLINE_PLAIN_MAX} skips inline parsing entirely —
3260
+ * every close-scan is then bounded, so no amount of unclosed markers can
3261
+ * regress past linear time.
3262
+ *
3263
+ * Hard rule for this file: no imports, no JSX, no DOM, no module side effects.
3264
+ * scripts/check-share-image.mjs loads it through Node type stripping, which
3265
+ * EXECUTES every value import, and the client bundler inlines it next to
3266
+ * share-card without new externals.
3267
+ */
3268
+ Object.defineProperty(exports, "__esModule", { value: true });
3269
+ exports.parseMarkdown = parseMarkdown;
3270
+ /* ---- shared character helpers ------------------------------------------------ */
3271
+ /** Whitespace test for the opener/closer flanking rules; EOL counts as space. */
3272
+ function isWs(ch) {
3273
+ return ch === undefined || ch === ' ' || ch === '\t' || ch === '\n';
3274
+ }
3275
+ /** CommonMark's escapable set: one ASCII punctuation char after a backslash. */
3276
+ const ESCAPABLE = new Set('!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~'.split(''));
3277
+ /**
3278
+ * Nesting ceiling shared by the block and inline recursion (a quote inside a
3279
+ * quote, emphasis inside link text). Real transcripts never approach it; past
3280
+ * the ceiling the nested content degrades to literal text instead of growing
3281
+ * the native stack — a synthetic 5000-deep "> " chain used to throw.
3282
+ */
3283
+ const MAX_DEPTH = 64;
3284
+ /**
3285
+ * One inline run longer than this parses as plain text, no markers. Keeps the
3286
+ * close-marker scans bounded: unclosed "*a *a *a …" prose is quadratic in the
3287
+ * classic scan-per-opener shape, and a share-card paragraph this long (4k+
3288
+ * chars with no blank line) has no plausible emphasis anyway.
3289
+ */
3290
+ const INLINE_PLAIN_MAX = 4096;
3291
+ /* ---- inline parser ----------------------------------------------------------- */
3292
+ /**
3293
+ * Parse one inline run into spans. Scans left to right; backslash escapes and
3294
+ * code spans are handled first (their content is literal), then emphasis and
3295
+ * links. Anything that does not close cleanly is emitted as literal text.
3296
+ */
3297
+ function parseInline(raw, depth = 0) {
3298
+ // Depth or length guard (see the constants above): degrade to plain text.
3299
+ if (depth >= MAX_DEPTH || raw.length > INLINE_PLAIN_MAX) {
3300
+ return raw === '' ? [] : [{ kind: 'text', text: raw }];
3301
+ }
3302
+ const spans = [];
3303
+ let buf = '';
3304
+ const flush = () => {
3305
+ if (buf !== '') {
3306
+ spans.push({ kind: 'text', text: buf });
3307
+ buf = '';
3308
+ }
3309
+ };
3310
+ const n = raw.length;
3311
+ let i = 0;
3312
+ while (i < n) {
3313
+ const ch = raw[i];
3314
+ if (ch === '\\') {
3315
+ const next = raw[i + 1];
3316
+ if (next !== undefined && ESCAPABLE.has(next)) {
3317
+ buf += next;
3318
+ i += 2;
3319
+ }
3320
+ else {
3321
+ buf += '\\';
3322
+ i += 1;
3323
+ }
3324
+ continue;
3325
+ }
3326
+ if (ch === '`') {
3327
+ const run = tickRun(raw, i);
3328
+ const close = findTickRun(raw, i + run, run);
3329
+ if (close !== -1) {
3330
+ flush();
3331
+ spans.push({ kind: 'code', text: raw.slice(i + run, close) });
3332
+ i = close + run;
3333
+ }
3334
+ else {
3335
+ buf += '`';
3336
+ i += 1;
3337
+ }
3338
+ continue;
3339
+ }
3340
+ if (ch === '*') {
3341
+ const run = starRun(raw, i);
3342
+ // Try the longest marker first (*** = bold(italic)), then **, then *.
3343
+ const lengths = run >= 3 ? [3, 2, 1] : run === 2 ? [2, 1] : [1];
3344
+ let matched = false;
3345
+ for (const len of lengths) {
3346
+ if (matched)
3347
+ break;
3348
+ // Left-flanking: an opener must be immediately followed by a
3349
+ // non-space character (so "5 * 3" never italicizes).
3350
+ if (isWs(raw[i + len]))
3351
+ continue;
3352
+ const close = findStarClose(raw, i + len, len);
3353
+ if (close === -1)
3354
+ continue;
3355
+ const content = raw.slice(i + len, close);
3356
+ if (content.trim() === '')
3357
+ continue;
3358
+ flush();
3359
+ const inner = parseInline(content, depth + 1);
3360
+ if (len === 3)
3361
+ spans.push({ kind: 'bold', spans: [{ kind: 'italic', spans: inner }] });
3362
+ else if (len === 2)
3363
+ spans.push({ kind: 'bold', spans: inner });
3364
+ else
3365
+ spans.push({ kind: 'italic', spans: inner });
3366
+ i = close + len;
3367
+ matched = true;
3368
+ }
3369
+ if (!matched) {
3370
+ buf += '*';
3371
+ i += 1;
3372
+ }
3373
+ continue;
3374
+ }
3375
+ if (ch === '~' && raw[i + 1] === '~') {
3376
+ const close = findStrikeClose(raw, i + 2);
3377
+ if (close !== -1 && raw.slice(i + 2, close).trim() !== '') {
3378
+ flush();
3379
+ spans.push({ kind: 'strike', spans: parseInline(raw.slice(i + 2, close), depth + 1) });
3380
+ i = close + 2;
3381
+ continue;
3382
+ }
3383
+ // Unclosed ~~ (or a lone ~): literal tilde, one char at a time.
3384
+ buf += '~';
3385
+ i += 1;
3386
+ continue;
3387
+ }
3388
+ if (ch === '[') {
3389
+ const link = tryLink(raw, i, depth);
3390
+ if (link !== undefined) {
3391
+ flush();
3392
+ spans.push(link.span);
3393
+ i = link.end;
3394
+ continue;
3395
+ }
3396
+ buf += '[';
3397
+ i += 1;
3398
+ continue;
3399
+ }
3400
+ buf += ch;
3401
+ i += 1;
3402
+ }
3403
+ flush();
3404
+ return spans;
3405
+ }
3406
+ /** Length of the backtick run starting at `start`. */
3407
+ function tickRun(raw, start) {
3408
+ let n = 0;
3409
+ while (raw[start + n] === '`')
3410
+ n += 1;
3411
+ return n;
3412
+ }
3413
+ /** Length of the asterisk run starting at `start`. */
3414
+ function starRun(raw, start) {
3415
+ let n = 0;
3416
+ while (raw[start + n] === '*')
3417
+ n += 1;
3418
+ return n;
3419
+ }
3420
+ /**
3421
+ * Find the closing backtick run of EXACT length `len` at/after `from` (a code
3422
+ * span closes only on a matching run, per CommonMark). Escapes do not apply
3423
+ * inside code spans, so every tick is a tick.
3424
+ */
3425
+ function findTickRun(raw, from, len) {
3426
+ let j = from;
3427
+ while (j + len <= raw.length) {
3428
+ if (raw[j] !== '`') {
3429
+ j += 1;
3430
+ continue;
3431
+ }
3432
+ let k = 0;
3433
+ while (k < len && raw[j + k] === '`')
3434
+ k += 1;
3435
+ if (k === len && raw[j + len] !== '`')
3436
+ return j;
3437
+ while (raw[j] === '`')
3438
+ j += 1; // inside a longer run: skip it whole
3439
+ }
3440
+ return -1;
3441
+ }
3442
+ /**
3443
+ * Find a `*` closer of length `len` at/after `from`: the run must match EXACTLY
3444
+ * (a longer run cannot close a shorter marker — `*a **b** c*` must find its
3445
+ * single-star closer past the inner bold pair, not the first half of it), be
3446
+ * preceded by a non-space character (right-flanking), and escaped stars are
3447
+ * skipped. Mirrors findTickRun's exact-length guard.
3448
+ */
3449
+ function findStarClose(raw, from, len) {
3450
+ let j = from;
3451
+ while (j + len <= raw.length) {
3452
+ if (raw[j] === '\\') {
3453
+ j += 2;
3454
+ continue;
3455
+ }
3456
+ if (raw[j] === '*') {
3457
+ let k = 0;
3458
+ while (k < len && raw[j + k] === '*')
3459
+ k += 1;
3460
+ if (k === len && raw[j + len] !== '*' && raw[j - 1] !== undefined && !isWs(raw[j - 1]))
3461
+ return j;
3462
+ while (raw[j] === '*')
3463
+ j += 1;
3464
+ continue;
3465
+ }
3466
+ j += 1;
3467
+ }
3468
+ return -1;
3469
+ }
3470
+ /**
3471
+ * Find a `~~` closer at/after `from`, preceded by a non-space character. The
3472
+ * run must be exactly two tildes — a `~~~` fence-looking run is not a strike
3473
+ * closer (same exact-length rule as findStarClose).
3474
+ */
3475
+ function findStrikeClose(raw, from) {
3476
+ let j = from;
3477
+ while (j + 2 <= raw.length) {
3478
+ if (raw[j] === '\\') {
3479
+ j += 2;
3480
+ continue;
3481
+ }
3482
+ if (raw[j] === '~') {
3483
+ if (raw[j + 1] === '~' && raw[j + 2] !== '~' && raw[j - 1] !== undefined && !isWs(raw[j - 1]))
3484
+ return j;
3485
+ // Not a closer (start of a longer run, or wrong flanking): skip the
3486
+ // WHOLE run — advancing one char would let positions 2–3 of a `~~~`
3487
+ // triple pose as an exact `~~` closer.
3488
+ while (raw[j] === '~')
3489
+ j += 1;
3490
+ continue;
3491
+ }
3492
+ j += 1;
3493
+ }
3494
+ return -1;
3495
+ }
3496
+ /**
3497
+ * Try to read `[text](href)` starting at the `[` at `start`. The destination
3498
+ * must be a bare URL (no whitespace, no parentheses) — that requirement is
3499
+ * what keeps prose like "[see (note)](aside)" from turning into links.
3500
+ * Empty link text displays the URL itself.
3501
+ */
3502
+ function tryLink(raw, start, depth) {
3503
+ let j = start + 1;
3504
+ while (j < raw.length && raw[j] !== ']') {
3505
+ j += raw[j] === '\\' ? 2 : 1;
3506
+ }
3507
+ if (j >= raw.length || raw[j + 1] !== '(')
3508
+ return undefined;
3509
+ let k = j + 2;
3510
+ while (k < raw.length && raw[k] !== ')') {
3511
+ k += raw[k] === '\\' ? 2 : 1;
3512
+ }
3513
+ if (k >= raw.length)
3514
+ return undefined;
3515
+ const href = raw.slice(j + 2, k);
3516
+ if (/[\s()]/.test(href))
3517
+ return undefined;
3518
+ const text = raw.slice(start + 1, j);
3519
+ return {
3520
+ span: { kind: 'link', spans: parseInline(text === '' ? href : text, depth + 1), href },
3521
+ end: k + 1,
3522
+ };
3523
+ }
3524
+ /* ---- block parser ------------------------------------------------------------ */
3525
+ /** ```/~~~ fence opener; the info string after it becomes the language tag. */
3526
+ const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})[ \t]*(.*)$/;
3527
+ /** 4+ leading spaces or a tab — the indented-code threshold (as text-layout). */
3528
+ const INDENTED = /^(?: {4,}|\t)/;
3529
+ /** 3+ of one char (- * _) with optional spaces between — a thematic break. */
3530
+ const HR = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
3531
+ /** ATX heading: 1–6 hashes, then space(s) + text (or nothing). */
3532
+ const HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*))?$/;
3533
+ /** Own-line image: `![alt](src)` with a bare-URL src (alt may be empty). */
3534
+ const IMAGE_LINE = /^ {0,3}!\[([^[\]]*)\]\(([^()[\]\s]*)\)[ \t]*$/;
3535
+ /** Blockquote prefix; the single space after `>` is consumed with it. */
3536
+ const QUOTE_PREFIX = /^ {0,3}>[ \t]?/;
3537
+ /**
3538
+ * List item: up to 8 leading spaces (the block loop routes ≥4-space lines at
3539
+ * document level to indented code, so inside a list collection the wider
3540
+ * indents simply land on the nested level), then a bullet or `N.`/`N)` marker
3541
+ * and space-separated content. The space is REQUIRED after the marker, which
3542
+ * is what keeps "3.14" a paragraph.
3543
+ */
3544
+ const LIST_ITEM = /^( {0,8})([-*+]|\d{1,9}[.)])(?:[ \t]+(.*))?$/;
3545
+ /** One delimiter-row cell: ---, :--, --:, :-: (at least one hyphen). */
3546
+ const DELIM_CELL = /^:?-+:?$/;
3547
+ /** Split a pipe row into trimmed cells; edge pipes are dropped when empty. */
3548
+ function splitCells(line) {
3549
+ let cells = line.split('|');
3550
+ if (cells.length > 1) {
3551
+ if (cells[0].trim() === '')
3552
+ cells = cells.slice(1);
3553
+ if (cells.length > 1 && cells[cells.length - 1].trim() === '')
3554
+ cells = cells.slice(0, -1);
3555
+ }
3556
+ return cells.map((cell) => cell.trim());
3557
+ }
3558
+ /** Delimiter-row test: has a pipe, and every cell is ---/:--/--:/:-:. */
3559
+ function isDelimiterRow(line) {
3560
+ if (!line.includes('|'))
3561
+ return false;
3562
+ const cells = splitCells(line);
3563
+ return cells.length > 0 && cells.every((cell) => cell !== '' && DELIM_CELL.test(cell));
3564
+ }
3565
+ /** Column alignment from one delimiter cell. */
3566
+ function alignOf(cell) {
3567
+ const left = cell.startsWith(':');
3568
+ const right = cell.endsWith(':');
3569
+ if (left && right)
3570
+ return 'center';
3571
+ if (right)
3572
+ return 'right';
3573
+ if (left)
3574
+ return 'left';
3575
+ return null;
3576
+ }
3577
+ /** Parse a list-item line; undefined when the line is not an item. */
3578
+ function matchListItem(line) {
3579
+ const m = line.match(LIST_ITEM);
3580
+ if (m === null)
3581
+ return undefined;
3582
+ const marker = m[2];
3583
+ const ordered = /^\d/.test(marker);
3584
+ return {
3585
+ indent: m[1].length,
3586
+ ordered,
3587
+ number: ordered ? Number.parseInt(marker, 10) : undefined,
3588
+ text: m[3] ?? '',
3589
+ };
3590
+ }
3591
+ /** Count leading spaces (a tab counts as the 4 that make indented code). */
3592
+ function leadingSpaces(line) {
3593
+ if (line.startsWith('\t'))
3594
+ return 4;
3595
+ let n = 0;
3596
+ while (line[n] === ' ')
3597
+ n += 1;
3598
+ return n;
3599
+ }
3600
+ /** Parse a run of lines into blocks (also the recursion entry for quotes). */
3601
+ function parseBlocks(lines, depth = 0) {
3602
+ const blocks = [];
3603
+ let para = [];
3604
+ const flushPara = () => {
3605
+ if (para.length > 0) {
3606
+ blocks.push({ kind: 'para', spans: parseInline(para.join('\n'), depth) });
3607
+ para = [];
3608
+ }
3609
+ };
3610
+ let i = 0;
3611
+ while (i < lines.length) {
3612
+ const line = lines[i];
3613
+ if (line.trim() === '') {
3614
+ flushPara();
3615
+ i += 1;
3616
+ continue;
3617
+ }
3618
+ const fence = line.match(FENCE_OPEN);
3619
+ if (fence !== null) {
3620
+ flushPara();
3621
+ const ch = fence[1][0];
3622
+ const close = ch === '`' ? /^ {0,3}`{3,}[ \t]*$/ : /^ {0,3}~{3,}[ \t]*$/;
3623
+ const body = [];
3624
+ i += 1;
3625
+ while (i < lines.length && !close.test(lines[i])) {
3626
+ body.push(lines[i]);
3627
+ i += 1;
3628
+ }
3629
+ // Past the closing fence; an unterminated fence just ends the block.
3630
+ i += 1;
3631
+ const info = fence[2]?.trim() ?? '';
3632
+ blocks.push({ kind: 'code', lang: info === '' ? undefined : info, lines: body });
3633
+ continue;
3634
+ }
3635
+ // Indented code cannot interrupt a paragraph (CommonMark) — with one open
3636
+ // the line is an ordinary continuation below.
3637
+ if (para.length === 0 && INDENTED.test(line)) {
3638
+ const strip = (s) => s.replace(/^(?: {4}|\t)/, '');
3639
+ const body = [strip(line)];
3640
+ i += 1;
3641
+ while (i < lines.length) {
3642
+ const next = lines[i];
3643
+ if (INDENTED.test(next)) {
3644
+ body.push(strip(next));
3645
+ i += 1;
3646
+ continue;
3647
+ }
3648
+ // A blank line between indented lines stays inside the block (lazy).
3649
+ if (next.trim() === '' && INDENTED.test(lines[i + 1] ?? '')) {
3650
+ body.push('');
3651
+ i += 1;
3652
+ continue;
3653
+ }
3654
+ break;
3655
+ }
3656
+ blocks.push({ kind: 'code', lang: undefined, lines: body });
3657
+ continue;
3658
+ }
3659
+ if (HR.test(line)) {
3660
+ flushPara();
3661
+ blocks.push({ kind: 'hr' });
3662
+ i += 1;
3663
+ continue;
3664
+ }
3665
+ const heading = line.match(HEADING);
3666
+ if (heading !== null) {
3667
+ flushPara();
3668
+ blocks.push({ kind: 'heading', level: heading[1].length, spans: parseInline((heading[2] ?? '').trim(), depth) });
3669
+ i += 1;
3670
+ continue;
3671
+ }
3672
+ const image = line.match(IMAGE_LINE);
3673
+ if (image !== null) {
3674
+ flushPara();
3675
+ blocks.push({ kind: 'image', alt: image[1] ?? '', src: image[2] ?? '' });
3676
+ i += 1;
3677
+ continue;
3678
+ }
3679
+ if (line.includes('|') && isDelimiterRow(lines[i + 1] ?? '')) {
3680
+ flushPara();
3681
+ const headerCells = splitCells(line);
3682
+ const alignCells = splitCells(lines[i + 1]);
3683
+ const align = headerCells.map((_, c) => (c < alignCells.length ? alignOf(alignCells[c]) : null));
3684
+ i += 2;
3685
+ const rows = [];
3686
+ while (i < lines.length) {
3687
+ const row = lines[i];
3688
+ if (row.trim() === '' || !row.includes('|'))
3689
+ break;
3690
+ rows.push(splitCells(row));
3691
+ i += 1;
3692
+ }
3693
+ blocks.push({
3694
+ kind: 'table',
3695
+ align,
3696
+ header: headerCells.map((cell) => parseInline(cell, depth)),
3697
+ // Rows ride the header's column count: short rows pad, extras drop.
3698
+ rows: rows.map((cells) => headerCells.map((_, c) => parseInline(cells[c] ?? '', depth))),
3699
+ });
3700
+ continue;
3701
+ }
3702
+ if (/^ {0,3}>/.test(line)) {
3703
+ flushPara();
3704
+ const inner = [];
3705
+ while (i < lines.length && /^ {0,3}>/.test(lines[i])) {
3706
+ inner.push(lines[i].replace(QUOTE_PREFIX, ''));
3707
+ i += 1;
3708
+ }
3709
+ // Depth guard (MAX_DEPTH): past the ceiling the quoted lines degrade to
3710
+ // a plain paragraph instead of recursing — a synthetic "> > > …" chain
3711
+ // deep enough used to overflow the native stack.
3712
+ blocks.push(depth + 1 >= MAX_DEPTH
3713
+ ? { kind: 'para', spans: [{ kind: 'text', text: inner.join('\n') }] }
3714
+ : { kind: 'quote', children: parseBlocks(inner, depth + 1) });
3715
+ continue;
3716
+ }
3717
+ {
3718
+ const item = matchListItem(line);
3719
+ // Only 0–3-space indents OPEN a list at block level; deeper indents are
3720
+ // indented code (para closed) or paragraph continuation (para open).
3721
+ // Inside collectList the wider indents land on the nested level.
3722
+ if (item !== undefined && item.indent <= 3) {
3723
+ flushPara();
3724
+ const collected = collectList(lines, i, depth);
3725
+ blocks.push(collected.block);
3726
+ i = collected.next;
3727
+ continue;
3728
+ }
3729
+ }
3730
+ para.push(line);
3731
+ i += 1;
3732
+ }
3733
+ flushPara();
3734
+ return blocks;
3735
+ }
1537
3736
  /**
1538
- * Session header, right lane: the session-info entry (S4 owns the actual
1539
- * sheet — this fires a hook event for it to pick up) and the workbench
1540
- * entry, which triggers dsh-better-sidebar's own toggle. There is no public
1541
- * API for "open the panel" (BetterSidebarService.openTab only auto-expands
1542
- * for a content open, not a bare type-only open), so this clicks the
1543
- * plugin's real toggle button through a stable, non-hashed anchor: its root
1544
- * mount marker `[data-dsh-better-sidebar]` plus the `_toggleButton` class
1545
- * suffix (verified live: 2026-08-17). Safe no-op when the plugin, or any
1546
- * other workbench-style plugin sharing that convention, is not installed.
3737
+ * Collect one list block starting at `start` (a list-item line).
3738
+ *
3739
+ * Rules: marker lines with ≥2 leading spaces nest under the last top-level
3740
+ * item (capped at the parser's two levels); a non-marker line indented ≥2
3741
+ * spaces lazily continues the deepest open item; a blank line keeps the list
3742
+ * open only when the next non-blank line is a same-kind item (loose lists);
3743
+ * anything else ends the block.
1547
3744
  */
1548
- function MobileHeaderUtilities({ t }) {
1549
- // Better-sidebar phone close button (S3.1 follow-up, 2026-08-17): the
1550
- // panel's own top-right toggle cluster is hidden below 768px
1551
- // (styles/compat.css.ts) because it duplicates the workbench button
1552
- // below — but that cluster is also the panel's ONLY close control, so
1553
- // hiding it blindly leaves an open panel with no way out. This button is
1554
- // appended straight to document.body, mirroring the existing
1555
- // preview-full-toggle pattern in MobileNavOverlay.tsx (raw DOM, not a
1556
- // React portal — react-dom is not among this plugin's platform-module
1557
- // imports, see AGENTS.md "client import purity"): never inside the
1558
- // panel's own subtree (the third party's React re-renders would wipe
1559
- // it) and never under any transformed/backdrop-filter ancestor (the S4
1560
- // info-card WebKit lesson in AGENTS.md — position:fixed would re-anchor
1561
- // to that ancestor instead of the viewport). It clicks the SAME hidden
1562
- // official toggle the workbench button below uses. Visibility is pure
1563
- // CSS (styles/compat.css.ts: `body:has([data-dsh-better-sidebar]
1564
- // [class$="_panel"])` — the panel's class ends in "_panel" only while
1565
- // open, "_panelHidden" is appended once closed), so this effect only
1566
- // has to guarantee the node exists — no MutationObserver needed to
1567
- // track open/closed state. Icon paths copied verbatim from
1568
- // IconCloseOutline16 (primitives) for the same reason IconInfoOutline16
1569
- // above is hand-built: this button lives outside the React tree, so it
1570
- // cannot render a primitives component directly.
1571
- (0, react_1.useEffect)(() => {
1572
- const button = document.createElement('button');
1573
- button.type = 'button';
1574
- button.dataset.mobileNav = 'better-sidebar-close';
1575
- button.setAttribute('aria-label', t('workbenchClose'));
1576
- button.title = t('workbenchClose');
1577
- // Bottom-center labeled pill (real-device follow-up, 2026-08-17): the
1578
- // icon markup is a static trusted string (safe as innerHTML), but the
1579
- // locale label is untrusted-shaped text — built as a real text node via
1580
- // textContent, not string-concatenated into the same innerHTML, so a
1581
- // translation can never be parsed as markup.
1582
- button.innerHTML = '<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">'
1583
- + '<path d="M14.1168 13.197L13.197 14.1167L1.8833 2.80303L2.80309 1.88324L14.1168 13.197Z" fill="currentColor"/>'
1584
- + '<path d="M13.197 1.88326L14.1168 2.80305L2.80309 14.1168L1.8833 13.197L13.197 1.88326Z" fill="currentColor"/>'
1585
- + '</svg>';
1586
- const label = document.createElement('span');
1587
- label.textContent = t('workbenchClose');
1588
- button.appendChild(label);
1589
- const onClick = () => {
1590
- document.querySelector('[data-dsh-better-sidebar] button[class$="_toggleButton"]')?.click();
1591
- };
1592
- button.addEventListener('click', onClick);
1593
- document.body.appendChild(button);
1594
- return () => {
1595
- button.removeEventListener('click', onClick);
1596
- button.remove();
1597
- };
1598
- }, [t]);
1599
- // Presence gate (2026-08-17 user question): without dsh-better-sidebar
1600
- // installed the workbench button was a dead control — rendered, clickable,
1601
- // did nothing. Same live-probe pattern as the home chips: observe until the
1602
- // plugin's root marker exists, hide the button while it doesn't. The close
1603
- // pill never had this problem (its CSS :has() gate needs the panel node).
1604
- const [workbenchPresent, setWorkbenchPresent] = (0, react_1.useState)(() => document.querySelector('[data-dsh-better-sidebar]') !== null);
1605
- (0, react_1.useEffect)(() => {
1606
- const check = () => setWorkbenchPresent(document.querySelector('[data-dsh-better-sidebar]') !== null);
1607
- const observer = new MutationObserver(check);
1608
- observer.observe(document.body, { childList: true, subtree: true });
1609
- check();
1610
- return () => observer.disconnect();
1611
- }, []);
1612
- return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("button", { type: "button", "data-mobile-nav": "header-info", "aria-label": t('sessionInfo'), title: t('sessionInfo'), onClick: () => window.dispatchEvent(new CustomEvent(nav_store_ts_1.SESSION_INFO_EVENT)), children: (0, jsx_runtime_1.jsx)(IconInfoOutline16, { size: 20 }) }), workbenchPresent && (0, jsx_runtime_1.jsx)("button", { type: "button", "data-mobile-nav": "header-workbench", "aria-label": t('workbench'), title: t('workbench'), onClick: () => {
1613
- document.querySelector('[data-dsh-better-sidebar] button[class$="_toggleButton"]')?.click();
1614
- }, children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconPanelLeftOutline16, { size: 20 }) })] }));
3745
+ function collectList(lines, start, depth) {
3746
+ const ordered = matchListItem(lines[start]).ordered;
3747
+ const items = [];
3748
+ const deepest = () => {
3749
+ const top = items[items.length - 1];
3750
+ if (top === undefined)
3751
+ return undefined;
3752
+ return top.children[top.children.length - 1] ?? top;
3753
+ };
3754
+ let i = start;
3755
+ while (i < lines.length) {
3756
+ const line = lines[i];
3757
+ if (line.trim() === '') {
3758
+ let j = i + 1;
3759
+ while (j < lines.length && lines[j].trim() === '')
3760
+ j += 1;
3761
+ const nextItem = j < lines.length ? matchListItem(lines[j]) : undefined;
3762
+ if (nextItem !== undefined && nextItem.ordered === ordered) {
3763
+ i = j;
3764
+ continue;
3765
+ }
3766
+ break;
3767
+ }
3768
+ const m = matchListItem(line);
3769
+ if (m !== undefined) {
3770
+ if (m.ordered !== ordered)
3771
+ break;
3772
+ const raw = { text: [m.text], number: m.number, children: [] };
3773
+ if (m.indent >= 2 && items.length > 0)
3774
+ items[items.length - 1].children.push(raw);
3775
+ else
3776
+ items.push(raw);
3777
+ i += 1;
3778
+ continue;
3779
+ }
3780
+ if (leadingSpaces(line) >= 2) {
3781
+ const target = deepest();
3782
+ if (target !== undefined) {
3783
+ target.text.push(line.trim());
3784
+ i += 1;
3785
+ continue;
3786
+ }
3787
+ }
3788
+ break;
3789
+ }
3790
+ const finish = (raw) => ({
3791
+ spans: parseInline(raw.text.join('\n'), depth),
3792
+ number: raw.number,
3793
+ children: raw.children.map(finish),
3794
+ });
3795
+ return { block: { kind: 'list', ordered, items: items.map(finish) }, next: i };
3796
+ }
3797
+ /**
3798
+ * Parse a raw text block into the share card's Markdown tree.
3799
+ * @param text - raw text-block content from the share-export route.
3800
+ * @returns blocks in reading order; `[]` for blank input.
3801
+ */
3802
+ function parseMarkdown(text) {
3803
+ return parseBlocks(text.replace(/\r\n?/g, '\n').split('\n'));
3804
+ }
3805
+ };
3806
+ __modules["share/share-card.js"] = function (require, module, exports) {
3807
+ "use strict";
3808
+ Object.defineProperty(exports, "__esModule", { value: true });
3809
+ exports.DEFAULT_SHARE_THEME = void 0;
3810
+ exports.readShareTheme = readShareTheme;
3811
+ exports.ShareCard = ShareCard;
3812
+ const jsx_runtime_1 = require("react/jsx-runtime");
3813
+ const text_layout_ts_1 = require("./share/text-layout.js");
3814
+ const markdown_ts_1 = require("./share/markdown.js");
3815
+ const share_constants_ts_1 = require("./share/share-constants.js");
3816
+ /** Light-palette fallback (resolved values from the host theme package). */
3817
+ exports.DEFAULT_SHARE_THEME = {
3818
+ text: '#0f1115',
3819
+ textSecondary: '#61666b',
3820
+ textTertiary: '#81858c',
3821
+ cardBg: '#ffffff',
3822
+ codeBg: '#f9fafb',
3823
+ bubbleBg: '#0f1115',
3824
+ bubbleText: '#ffffff',
3825
+ accent: '#4176e6',
3826
+ border: 'rgba(0, 0, 0, 0.1)',
3827
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif',
3828
+ monoFamily: '"SF Mono", "JetBrains Mono", "Fira Code", Consolas, Menlo, Courier, monospace',
3829
+ };
3830
+ /**
3831
+ * Read the host theme once (per render call). A raw value that still contains
3832
+ * `var(` means the engine returned it un-substituted — treated as missing so
3833
+ * the default palette wins over a broken declaration.
3834
+ * @returns concrete theme values for this render.
3835
+ */
3836
+ function readShareTheme() {
3837
+ const fallback = exports.DEFAULT_SHARE_THEME;
3838
+ if (typeof document === 'undefined')
3839
+ return fallback;
3840
+ // body first (see file header), then documentElement as a generic fallback.
3841
+ const sources = [getComputedStyle(document.body), getComputedStyle(document.documentElement)];
3842
+ const read = (name) => {
3843
+ for (const computed of sources) {
3844
+ const raw = computed.getPropertyValue(name).trim();
3845
+ if (raw !== '' && !raw.includes('var('))
3846
+ return raw;
3847
+ }
3848
+ return undefined;
3849
+ };
3850
+ return {
3851
+ text: read('--dsw-alias-label-primary') ?? fallback.text,
3852
+ textSecondary: read('--dsw-alias-label-secondary') ?? fallback.textSecondary,
3853
+ textTertiary: read('--dsw-alias-label-tertiary') ?? fallback.textTertiary,
3854
+ cardBg: read('--dsw-alias-bg-base') ?? fallback.cardBg,
3855
+ codeBg: read('--dsw-alias-markdown-code-block') ?? fallback.codeBg,
3856
+ bubbleBg: read('--dsw-alias-brand-primary') ?? fallback.bubbleBg,
3857
+ bubbleText: read('--dsw-alias-label-primary-foreground') ?? fallback.bubbleText,
3858
+ accent: read('--dsw-alias-state-business-primary') ?? fallback.accent,
3859
+ border: read('--dsw-alias-border-l2') ?? fallback.border,
3860
+ fontFamily: read('--dsw-font-family') ?? fallback.fontFamily,
3861
+ monoFamily: read('--ds-font-family-code') ?? fallback.monoFamily,
3862
+ };
3863
+ }
3864
+ /**
3865
+ * The bubble-width cap as the CSS percentage string the template renders —
3866
+ * `Math.round` guards the float multiply (0.82 × 100), and the value comes
3867
+ * from share-constants.ts so the rasterizer's width formulas invert exactly
3868
+ * this number (see USER_BUBBLE_MAX_RATIO).
3869
+ */
3870
+ const USER_BUBBLE_MAX_WIDTH = `${Math.round(share_constants_ts_1.USER_BUBBLE_MAX_RATIO * 100)}%`;
3871
+ /** Locale-following short date+time; the share image is dated content. */
3872
+ function formatStamp(ms) {
3873
+ const date = new Date(ms);
3874
+ try {
3875
+ return new Intl.DateTimeFormat(undefined, {
3876
+ year: 'numeric',
3877
+ month: 'short',
3878
+ day: 'numeric',
3879
+ hour: '2-digit',
3880
+ minute: '2-digit',
3881
+ }).format(date);
3882
+ }
3883
+ catch {
3884
+ return date.toISOString().slice(0, 16).replace('T', ' ');
3885
+ }
3886
+ }
3887
+ /**
3888
+ * The share card. A pure template: no hooks, no effects, no context — the
3889
+ * same JSX must render identically inside the page (preview) and inside a
3890
+ * cloned `<foreignObject>` (rasterize).
3891
+ */
3892
+ function ShareCard({ title, subtitle, createdAt, turns, width, copy }) {
3893
+ const theme = readShareTheme();
3894
+ const logicalWidth = width ?? share_constants_ts_1.BASE_CARD_WIDTH;
3895
+ // "N 轮" counts user anchors (one exchange = user prompt + its answers),
3896
+ // matching how the host route counts turns for range=last.
3897
+ const turnCount = turns.filter((turn) => turn.role === 'user').length;
3898
+ const stamp = createdAt !== undefined && Number.isFinite(createdAt) ? formatStamp(createdAt) : undefined;
3899
+ const metaParts = [
3900
+ ...(subtitle !== undefined && subtitle !== '' ? [subtitle] : []),
3901
+ ...(stamp !== undefined ? [stamp] : []),
3902
+ copy.turnsLabel(turnCount),
3903
+ ];
3904
+ return ((0, jsx_runtime_1.jsxs)("div", { "data-share-card": "", style: {
3905
+ width: `${logicalWidth}px`,
3906
+ boxSizing: 'border-box',
3907
+ display: 'flex',
3908
+ flexDirection: 'column',
3909
+ gap: '14px',
3910
+ padding: '20px 18px 16px',
3911
+ background: theme.cardBg,
3912
+ color: theme.text,
3913
+ fontFamily: theme.fontFamily,
3914
+ fontSize: '15px',
3915
+ lineHeight: 1.65,
3916
+ // Pin the INHERITABLE typography so the page render (the preview and
3917
+ // the rasterizer's measuring clones live in a shadow root, which still
3918
+ // inherits these from the host cascade) and the SVG-image render (a
3919
+ // fresh context at engine defaults) lay out identically — any unpinned
3920
+ // property is a channel for host-style drift to change text wrapping
3921
+ // between measurement and raster, invalidating the rasterizer's
3922
+ // numbers. Every value below is the CSS initial value.
3923
+ letterSpacing: 'normal',
3924
+ wordBreak: 'normal',
3925
+ overflowWrap: 'normal',
3926
+ hyphens: 'manual',
3927
+ textAlign: 'start',
3928
+ textTransform: 'none',
3929
+ tabSize: 8,
3930
+ fontVariantLigatures: 'normal',
3931
+ // Never hide horizontal content: wide code/tables must stay visible
3932
+ // (and measurable by ticket 04) — see the file header.
3933
+ overflow: 'visible',
3934
+ }, children: [(0, jsx_runtime_1.jsxs)("div", { "data-share-header": "", style: { display: 'flex', flexDirection: 'column', gap: '6px' }, children: [(0, jsx_runtime_1.jsx)("div", { style: { fontSize: '17px', fontWeight: 600, lineHeight: 1.4 }, children: title }), (0, jsx_runtime_1.jsx)("div", { style: { fontSize: '12px', lineHeight: 1.5, color: theme.textSecondary }, children: metaParts.join(' · ') }), (0, jsx_runtime_1.jsx)("div", { style: { height: '1px', marginTop: '6px', background: theme.border } })] }), turns.map((turn) => turn.role === 'user'
3935
+ ? ((0, jsx_runtime_1.jsx)("div", { "data-share-turn": turn.seq, "data-share-role": turn.role, style: { display: 'flex', justifyContent: 'flex-end' }, children: (0, jsx_runtime_1.jsx)("div", { style: {
3936
+ maxWidth: USER_BUBBLE_MAX_WIDTH,
3937
+ display: 'flex',
3938
+ flexDirection: 'column',
3939
+ gap: '8px',
3940
+ padding: `9px ${share_constants_ts_1.USER_BUBBLE_PAD_X}px`,
3941
+ background: theme.bubbleBg,
3942
+ color: theme.bubbleText,
3943
+ borderRadius: '16px',
3944
+ // Sharp corner on the trailing edge = the chat-bubble tail.
3945
+ borderBottomRightRadius: '4px',
3946
+ overflow: 'visible',
3947
+ }, children: (0, jsx_runtime_1.jsx)(ShareBlocks, { blocks: turn.blocks, role: turn.role, theme: theme, copy: copy }) }) }, turn.seq))
3948
+ : ((0, jsx_runtime_1.jsx)("div", { "data-share-turn": turn.seq, "data-share-role": turn.role, style: { display: 'flex', flexDirection: 'column', gap: '8px', overflow: 'visible' }, children: (0, jsx_runtime_1.jsx)(ShareBlocks, { blocks: turn.blocks, role: turn.role, theme: theme, copy: copy }) }, turn.seq))), (0, jsx_runtime_1.jsxs)("div", { "data-share-footer": "", style: { display: 'flex', flexDirection: 'column', gap: '8px' }, children: [(0, jsx_runtime_1.jsx)("div", { style: { height: '1px', background: theme.border } }), (0, jsx_runtime_1.jsxs)("div", { style: { display: 'flex', alignItems: 'center', gap: '6px', fontSize: '11px', lineHeight: 1.4, color: theme.textTertiary }, children: [(0, jsx_runtime_1.jsx)("span", { style: { width: '3px', height: '12px', borderRadius: '2px', background: theme.accent } }), (0, jsx_runtime_1.jsxs)("span", { children: [copy.generatedBy, stamp !== undefined ? ` · ${stamp}` : ''] })] })] })] }));
3949
+ }
3950
+ /** Render one row's blocks in order (text flows, images become badges). */
3951
+ function ShareBlocks({ blocks, role, theme, copy }) {
3952
+ return ((0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: blocks.map((block, index) => block.kind === 'text'
3953
+ ? (role === 'assistant'
3954
+ // Assistant prose is Markdown (ticket 09); user messages stay
3955
+ // plain text — the Chat view renders them the same way.
3956
+ ? (0, jsx_runtime_1.jsx)(ShareMarkdown, { text: block.text, theme: theme, copy: copy }, index)
3957
+ : (0, jsx_runtime_1.jsx)(ShareText, { text: block.text, theme: theme }, index))
3958
+ : (0, jsx_runtime_1.jsx)(ImageBadge, { theme: theme, label: copy.image }, index)) }));
3959
+ }
3960
+ /** User-message typesetting: the plain-text fold of ticket 03, unchanged. */
3961
+ function ShareText({ text, theme }) {
3962
+ const segments = (0, text_layout_ts_1.segmentShareText)(text);
3963
+ if (segments.length === 0)
3964
+ return null;
3965
+ return ((0, jsx_runtime_1.jsx)("div", { style: { display: 'flex', flexDirection: 'column', gap: '8px', overflow: 'visible' }, children: segments.map((segment, index) => ((0, jsx_runtime_1.jsx)(ShareSegment, { segment: segment, theme: theme }, index))) }));
3966
+ }
3967
+ function ShareSegment({ segment, theme }) {
3968
+ switch (segment.kind) {
3969
+ case 'code':
3970
+ case 'table':
3971
+ // Monospace + `pre` (NOT pre-wrap): the line's natural width wins, the
3972
+ // block may visually overflow the card edge, and ticket 04 measures
3973
+ // that width to widen the slice — wrapping here would destroy the only
3974
+ // signal the width probe has.
3975
+ return ((0, jsx_runtime_1.jsx)("div", { style: {
3976
+ fontFamily: theme.monoFamily,
3977
+ fontSize: '12.5px',
3978
+ lineHeight: 1.6,
3979
+ whiteSpace: 'pre',
3980
+ background: theme.codeBg,
3981
+ borderRadius: '8px',
3982
+ padding: '10px 12px',
3983
+ overflow: 'visible',
3984
+ }, children: segment.lines.join('\n') }));
3985
+ case 'list':
3986
+ return ((0, jsx_runtime_1.jsx)("div", { style: { display: 'flex', flexDirection: 'column', gap: '4px' }, children: segment.items.map((item, index) => ((0, jsx_runtime_1.jsxs)("div", { style: { display: 'flex', gap: '8px' }, children: [(0, jsx_runtime_1.jsx)("span", { style: { flexShrink: 0, color: theme.textTertiary }, children: item.marker }), (0, jsx_runtime_1.jsx)("span", { style: { flex: 1, minWidth: 0, whiteSpace: 'pre-wrap', overflow: 'visible' }, children: item.text })] }, index))) }));
3987
+ case 'para':
3988
+ default:
3989
+ return ((0, jsx_runtime_1.jsx)("div", { style: { whiteSpace: 'pre-wrap', overflow: 'visible' }, children: segment.text }));
3990
+ }
3991
+ }
3992
+ /* ---- markdown rendering (ticket 09, PLAN §5.1 v1.2) -------------------------
3993
+ * Assistant text renders through the pure parser in markdown.ts. Same red
3994
+ * lines as the rest of the card: inline styles only (an SVG-as-image render
3995
+ * loads no stylesheets), and NEVER hide horizontal content — code lines and
3996
+ * table cells keep their natural width so the rasterizer's width probe can
3997
+ * lift the slice (inline emphasis wraps instead, which changes nothing). */
3998
+ /** Assistant text block → parsed blocks → self-styled Markdown rendering. */
3999
+ function ShareMarkdown({ text, theme, copy }) {
4000
+ const blocks = (0, markdown_ts_1.parseMarkdown)(text);
4001
+ if (blocks.length === 0)
4002
+ return null;
4003
+ return ((0, jsx_runtime_1.jsx)("div", { style: { display: 'flex', flexDirection: 'column', gap: '8px', overflow: 'visible' }, children: blocks.map((block, index) => ((0, jsx_runtime_1.jsx)(MdBlockView, { block: block, theme: theme, copy: copy }, index))) }));
4004
+ }
4005
+ /** Heading ladder over the card's 15px body; h6 demotes to the secondary tone. */
4006
+ function mdHeadingStyle(level, theme) {
4007
+ switch (level) {
4008
+ case 1: return { fontSize: '20px', fontWeight: 700, lineHeight: 1.4 };
4009
+ case 2: return { fontSize: '18px', fontWeight: 700, lineHeight: 1.4 };
4010
+ case 3: return { fontSize: '16.5px', fontWeight: 600, lineHeight: 1.45 };
4011
+ case 4: return { fontSize: '15.5px', fontWeight: 600, lineHeight: 1.5 };
4012
+ case 5: return { fontSize: '15px', fontWeight: 600, lineHeight: 1.5 };
4013
+ default: return { fontSize: '15px', fontWeight: 600, lineHeight: 1.5, color: theme.textSecondary };
4014
+ }
4015
+ }
4016
+ /** One markdown block. Every branch keeps `overflow: 'visible'` (file header). */
4017
+ function MdBlockView({ block, theme, copy }) {
4018
+ switch (block.kind) {
4019
+ case 'heading':
4020
+ return ((0, jsx_runtime_1.jsx)("div", { style: { ...mdHeadingStyle(block.level, theme), overflow: 'visible' }, children: (0, jsx_runtime_1.jsx)(MdSpans, { spans: block.spans, theme: theme }) }));
4021
+ case 'para':
4022
+ return ((0, jsx_runtime_1.jsx)("div", { style: { whiteSpace: 'pre-wrap', overflow: 'visible' }, children: (0, jsx_runtime_1.jsx)(MdSpans, { spans: block.spans, theme: theme }) }));
4023
+ case 'code':
4024
+ return ((0, jsx_runtime_1.jsxs)("div", { style: {
4025
+ background: theme.codeBg,
4026
+ borderRadius: '8px',
4027
+ padding: '10px 12px',
4028
+ display: 'flex',
4029
+ flexDirection: 'column',
4030
+ gap: '6px',
4031
+ overflow: 'visible',
4032
+ }, children: [block.lang !== undefined ? ((0, jsx_runtime_1.jsx)("div", { style: { fontSize: '11px', lineHeight: 1.4, color: theme.textTertiary }, children: block.lang })) : null, (0, jsx_runtime_1.jsx)("div", { style: {
4033
+ fontFamily: theme.monoFamily,
4034
+ fontSize: '12.5px',
4035
+ lineHeight: 1.6,
4036
+ whiteSpace: 'pre',
4037
+ overflow: 'visible',
4038
+ }, children: block.lines.join('\n') })] }));
4039
+ case 'quote':
4040
+ return ((0, jsx_runtime_1.jsx)("div", { style: {
4041
+ borderLeft: `3px solid ${theme.border}`,
4042
+ paddingLeft: '12px',
4043
+ paddingRight: '2px',
4044
+ color: theme.textSecondary,
4045
+ display: 'flex',
4046
+ flexDirection: 'column',
4047
+ gap: '6px',
4048
+ overflow: 'visible',
4049
+ }, children: block.children.map((child, index) => ((0, jsx_runtime_1.jsx)(MdBlockView, { block: child, theme: theme, copy: copy }, index))) }));
4050
+ case 'list':
4051
+ return (0, jsx_runtime_1.jsx)(MdListView, { items: block.items, depth: 0, theme: theme });
4052
+ case 'table':
4053
+ return (0, jsx_runtime_1.jsx)(MdTableView, { block: block, theme: theme });
4054
+ case 'hr':
4055
+ return (0, jsx_runtime_1.jsx)("div", { style: { height: '1px', background: theme.border, overflow: 'visible' } });
4056
+ case 'image':
4057
+ // Markdown image: same placeholder row an image block renders (the
4058
+ // picture lives on the far end of a URL the share image cannot fetch).
4059
+ return (0, jsx_runtime_1.jsx)(ImageBadge, { theme: theme, label: copy.image });
4060
+ }
4061
+ }
4062
+ /** List rows; one nesting indent under the parent item's content column. */
4063
+ function MdListView({ items, depth, theme }) {
4064
+ return ((0, jsx_runtime_1.jsx)("div", { style: { display: 'flex', flexDirection: 'column', gap: '4px', ...(depth > 0 ? { marginLeft: '18px' } : null) }, children: items.map((item, index) => {
4065
+ const ordered = item.number !== undefined;
4066
+ return ((0, jsx_runtime_1.jsxs)("div", { style: { display: 'flex', gap: '8px' }, children: [(0, jsx_runtime_1.jsx)("span", { style: {
4067
+ flexShrink: 0,
4068
+ color: theme.textTertiary,
4069
+ // A fixed marker column keeps 1.–9. dots right-aligned.
4070
+ minWidth: ordered ? '20px' : undefined,
4071
+ textAlign: ordered ? 'right' : undefined,
4072
+ }, children: ordered ? `${item.number}.` : '•' }), (0, jsx_runtime_1.jsxs)("div", { style: { flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: '4px' }, children: [(0, jsx_runtime_1.jsx)("div", { style: { whiteSpace: 'pre-wrap', overflow: 'visible' }, children: (0, jsx_runtime_1.jsx)(MdSpans, { spans: item.spans, theme: theme }) }), item.children.length > 0 ? (0, jsx_runtime_1.jsx)(MdListView, { items: item.children, depth: depth + 1, theme: theme }) : null] })] }, index));
4073
+ }) }));
4074
+ }
4075
+ /** Column alignment → CSS text-align ('start' keeps the card's direction). */
4076
+ function mdAlignText(align) {
4077
+ if (align === 'center')
4078
+ return 'center';
4079
+ if (align === 'right')
4080
+ return 'right';
4081
+ return 'start';
4082
+ }
4083
+ /** GFM table as a natural-width grid: nowrap cells, hairline borders. */
4084
+ function MdTableView({ block, theme }) {
4085
+ const cellBase = { padding: '6px 10px', border: `1px solid ${theme.border}` };
4086
+ return ((0, jsx_runtime_1.jsxs)("table", { style: { borderCollapse: 'collapse', fontSize: '13px', lineHeight: 1.5, overflow: 'visible' }, children: [(0, jsx_runtime_1.jsx)("thead", { children: (0, jsx_runtime_1.jsx)("tr", { children: block.header.map((cell, index) => ((0, jsx_runtime_1.jsx)("th", { style: {
4087
+ ...cellBase,
4088
+ background: theme.codeBg,
4089
+ fontWeight: 600,
4090
+ textAlign: mdAlignText(block.align[index] ?? null),
4091
+ whiteSpace: 'nowrap',
4092
+ overflow: 'visible',
4093
+ }, children: (0, jsx_runtime_1.jsx)(MdSpans, { spans: cell, theme: theme }) }, index))) }) }), (0, jsx_runtime_1.jsx)("tbody", { children: block.rows.map((row, rowIndex) => ((0, jsx_runtime_1.jsx)("tr", { children: row.map((cell, colIndex) => ((0, jsx_runtime_1.jsx)("td", { style: {
4094
+ ...cellBase,
4095
+ textAlign: mdAlignText(block.align[colIndex] ?? null),
4096
+ // Natural width, same rationale as code lines above.
4097
+ whiteSpace: 'nowrap',
4098
+ overflow: 'visible',
4099
+ }, children: (0, jsx_runtime_1.jsx)(MdSpans, { spans: cell, theme: theme }) }, colIndex))) }, rowIndex))) })] }));
4100
+ }
4101
+ /** Render an inline span sequence (raw strings for plain text). */
4102
+ function MdSpans({ spans, theme }) {
4103
+ if (spans.length === 0)
4104
+ return null;
4105
+ return ((0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: spans.map((span, index) => ((0, jsx_runtime_1.jsx)(MdSpanView, { span: span, theme: theme }, index))) }));
4106
+ }
4107
+ /**
4108
+ * One inline span. Plain <span>s with explicit styles, never semantic tags:
4109
+ * the SVG-image render applies its own UA cascade, and only explicit values
4110
+ * lay out identically to the measured page render.
4111
+ */
4112
+ function MdSpanView({ span, theme }) {
4113
+ switch (span.kind) {
4114
+ case 'text':
4115
+ return span.text;
4116
+ case 'bold':
4117
+ return (0, jsx_runtime_1.jsx)("span", { style: { fontWeight: 700 }, children: (0, jsx_runtime_1.jsx)(MdSpans, { spans: span.spans, theme: theme }) });
4118
+ case 'italic':
4119
+ return (0, jsx_runtime_1.jsx)("span", { style: { fontStyle: 'italic' }, children: (0, jsx_runtime_1.jsx)(MdSpans, { spans: span.spans, theme: theme }) });
4120
+ case 'strike':
4121
+ return (0, jsx_runtime_1.jsx)("span", { style: { textDecoration: 'line-through' }, children: (0, jsx_runtime_1.jsx)(MdSpans, { spans: span.spans, theme: theme }) });
4122
+ case 'code':
4123
+ return ((0, jsx_runtime_1.jsx)("span", { style: {
4124
+ fontFamily: theme.monoFamily,
4125
+ fontSize: '0.92em',
4126
+ background: theme.codeBg,
4127
+ borderRadius: '4px',
4128
+ padding: '1px 5px',
4129
+ }, children: span.text }));
4130
+ case 'link':
4131
+ // Colored text only: the share image is not clickable, and the URL is
4132
+ // deliberately NOT repeated next to the label.
4133
+ return (0, jsx_runtime_1.jsx)("span", { style: { color: theme.accent }, children: (0, jsx_runtime_1.jsx)(MdSpans, { spans: span.spans, theme: theme }) });
4134
+ }
4135
+ }
4136
+ /** Image attachments stay placeholders in v1 (files live on the host disk). */
4137
+ function ImageBadge({ theme, label }) {
4138
+ return ((0, jsx_runtime_1.jsxs)("div", { style: {
4139
+ display: 'flex',
4140
+ alignItems: 'center',
4141
+ gap: '6px',
4142
+ padding: '8px 12px',
4143
+ border: `1px dashed ${theme.border}`,
4144
+ borderRadius: '8px',
4145
+ fontSize: '12px',
4146
+ color: theme.textTertiary,
4147
+ overflow: 'visible',
4148
+ }, children: [(0, jsx_runtime_1.jsxs)("svg", { viewBox: "0 0 16 16", width: "14", height: "14", "aria-hidden": "true", style: { flexShrink: 0 }, children: [(0, jsx_runtime_1.jsx)("rect", { x: "1.5", y: "2.5", width: "13", height: "11", rx: "2", fill: "none", stroke: "currentColor", strokeWidth: "1.2" }), (0, jsx_runtime_1.jsx)("circle", { cx: "5.5", cy: "6.5", r: "1.3", fill: "currentColor" }), (0, jsx_runtime_1.jsx)("path", { d: "M2.5 12 L6.5 8 L9 10.5 L11.5 8 L13.5 10", fill: "none", stroke: "currentColor", strokeWidth: "1.2", strokeLinejoin: "round", strokeLinecap: "round" })] }), (0, jsx_runtime_1.jsx)("span", { children: label })] }));
4149
+ }
4150
+ };
4151
+ __modules["share/share-sheet.js"] = function (require, module, exports) {
4152
+ "use strict";
4153
+ Object.defineProperty(exports, "__esModule", { value: true });
4154
+ exports.ShareRangeSheet = ShareRangeSheet;
4155
+ const jsx_runtime_1 = require("react/jsx-runtime");
4156
+ /**
4157
+ * Share-range bottom sheet (ticket 05, PLAN §6): the small picker that opens
4158
+ * from the session-info sheet's 分享图 action — whole conversation or one of
4159
+ * the last-N-turns presets (3/5/10, no free input).
4160
+ *
4161
+ * Mounts INSIDE the info layer's DOM as a DIRECT child of
4162
+ * `[data-mobile-nav="info-layer"]` — a sibling of the info card, never a
4163
+ * child of the card itself (a card-internal mount clipped this sheet's mask
4164
+ * to the card's box, anchored its bottom to the card's scrollable area, and
4165
+ * let taps outside the card reach the info mask underneath — closing both
4166
+ * layers at once and stranding the share layer's history entry; review
4167
+ * 2026-09-10). That placement buys three things for free: the ≥768px
4168
+ * `display:none` of info.css.ts keeps the desktop pixel-identical no-op
4169
+ * (this feature adds no page CSS, PLAN §9.4), the sheet paints in the
4170
+ * header's z:70 promotion while the info sheet is open, and the info layer
4171
+ * (itself a fixed stacking context, inset:0) is the containing block — so
4172
+ * this sheet's own absolute inset:0 overlay is a fullscreen mask and its
4173
+ * bottom anchor is the viewport safe area, exactly like the info card's.
4174
+ * The back stack is the caller's business: the caller pushes one SHARE_LAYER
4175
+ * entry on top of the info layer's, so a back gesture closes exactly this
4176
+ * sheet — one layer per press, same discipline as history-nav.ts demands.
4177
+ *
4178
+ * All-inline styles like everything else in the share feature; theme values
4179
+ * read the host CSS variables with the same fallbacks info.css.ts declares,
4180
+ * and the entry animations reuse base.css.ts's existing keyframes (referenced
4181
+ * from inline `animation`, which adds no stylesheet rules). Reduced-motion is
4182
+ * honored by checking the media query at mount — inline styles cannot carry
4183
+ * a media query themselves.
4184
+ */
4185
+ const react_1 = require("react");
4186
+ const dsh_client_ui_primitives_1 = require("@deepseek-ai/dsh-client-ui-primitives");
4187
+ const fetch_share_ts_1 = require("./share/fetch-share.js");
4188
+ /** One-time page-load probe: skip the entry animations when the user asks for less motion. */
4189
+ function prefersReducedMotion() {
4190
+ return typeof window !== 'undefined' && typeof window.matchMedia === 'function'
4191
+ && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
4192
+ }
4193
+ const MASK_STYLE = {
4194
+ position: 'absolute',
4195
+ inset: 0,
4196
+ border: 'none',
4197
+ background: 'var(--dsw-alias-bg-mask-3, rgba(0, 0, 0, .45))',
4198
+ };
4199
+ const SHEET_STYLE = {
4200
+ position: 'absolute',
4201
+ left: '8px',
4202
+ right: '8px',
4203
+ bottom: 'calc(var(--mnav-sab, 0px) + 8px)',
4204
+ boxSizing: 'border-box',
4205
+ padding: '12px',
4206
+ borderRadius: '16px',
4207
+ background: 'var(--dsw-alias-bg-layer-2, #ffffff)',
4208
+ boxShadow: '0 8px 32px rgba(0, 0, 0, .28)',
4209
+ };
4210
+ const HEAD_STYLE = {
4211
+ display: 'flex',
4212
+ alignItems: 'center',
4213
+ justifyContent: 'space-between',
4214
+ gap: 8,
4215
+ marginBottom: 10,
4216
+ };
4217
+ const TITLE_STYLE = {
4218
+ fontSize: 15,
4219
+ fontWeight: 600,
4220
+ color: 'var(--dsw-alias-label-primary, inherit)',
4221
+ };
4222
+ const CLOSE_STYLE = {
4223
+ display: 'inline-flex',
4224
+ alignItems: 'center',
4225
+ justifyContent: 'center',
4226
+ width: 34,
4227
+ height: 34,
4228
+ flex: 'none',
4229
+ padding: 0,
4230
+ border: 'none',
4231
+ borderRadius: '50%',
4232
+ background: 'transparent',
4233
+ color: 'var(--dsw-alias-label-secondary, inherit)',
4234
+ cursor: 'pointer',
4235
+ touchAction: 'manipulation',
4236
+ WebkitTapHighlightColor: 'transparent',
4237
+ };
4238
+ const OPTION_BASE = {
4239
+ display: 'flex',
4240
+ alignItems: 'center',
4241
+ gap: 8,
4242
+ width: '100%',
4243
+ minHeight: 48,
4244
+ padding: '0 14px',
4245
+ border: 'none',
4246
+ borderRadius: 12,
4247
+ background: 'transparent',
4248
+ color: 'var(--dsw-alias-label-primary, inherit)',
4249
+ fontFamily: 'inherit',
4250
+ fontSize: 14,
4251
+ textAlign: 'start',
4252
+ cursor: 'pointer',
4253
+ touchAction: 'manipulation',
4254
+ WebkitTapHighlightColor: 'transparent',
4255
+ };
4256
+ const CONFIRM_STYLE = {
4257
+ width: '100%',
4258
+ minHeight: 48,
4259
+ marginTop: 10,
4260
+ padding: '0 14px',
4261
+ border: 'none',
4262
+ borderRadius: 12,
4263
+ background: 'var(--dsw-alias-brand-primary, #0f1115)',
4264
+ color: 'var(--dsw-alias-label-primary-foreground, #ffffff)',
4265
+ fontFamily: 'inherit',
4266
+ fontSize: 14,
4267
+ fontWeight: 600,
4268
+ cursor: 'pointer',
4269
+ touchAction: 'manipulation',
4270
+ WebkitTapHighlightColor: 'transparent',
4271
+ };
4272
+ /** The share-range sheet. Rendered only while open (the caller owns the layer stack). */
4273
+ function ShareRangeSheet({ busy, onConfirm, onClose, t }) {
4274
+ const [preset, setPreset] = (0, react_1.useState)('all');
4275
+ const [reduced] = (0, react_1.useState)(prefersReducedMotion);
4276
+ const enter = reduced ? undefined : 'dsh-mobile-nav-sheet-up .22s var(--ds-ease-out, ease-in-out)';
4277
+ const fade = reduced ? undefined : 'dsh-mobile-nav-fade .18s var(--ds-ease-out, ease-in-out)';
4278
+ const options = [
4279
+ { key: 'all', label: t('sharePickerAll'), value: 'all' },
4280
+ ...fetch_share_ts_1.SHARE_TURNS_PRESETS.map((count) => ({ key: `last-${count}`, label: t('sharePickerLast', { count }), value: count })),
4281
+ ];
4282
+ return ((0, jsx_runtime_1.jsxs)("div", { "data-mobile-nav": "share-range-layer", style: { position: 'absolute', inset: 0, zIndex: 2 }, children: [(0, jsx_runtime_1.jsx)("div", { "data-mobile-nav": "share-range-mask", role: "button", tabIndex: -1, "aria-label": t('infoClose'), onClick: onClose, style: fade === undefined ? MASK_STYLE : { ...MASK_STYLE, animation: fade } }), (0, jsx_runtime_1.jsxs)("div", { "data-mobile-nav": "share-range-sheet", role: "dialog", "aria-modal": "true", "aria-label": t('sharePickerTitle'), style: enter === undefined ? SHEET_STYLE : { ...SHEET_STYLE, animation: enter }, children: [(0, jsx_runtime_1.jsxs)("div", { style: HEAD_STYLE, children: [(0, jsx_runtime_1.jsx)("span", { style: TITLE_STYLE, children: t('sharePickerTitle') }), (0, jsx_runtime_1.jsx)("button", { type: "button", "aria-label": t('infoClose'), onClick: onClose, style: CLOSE_STYLE, children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconCloseOutline16, { size: 16 }) })] }), (0, jsx_runtime_1.jsx)("div", { role: "radiogroup", "aria-label": t('sharePickerTitle'), style: { display: 'flex', flexDirection: 'column', gap: 2 }, children: options.map((option) => {
4283
+ const selected = preset === option.value;
4284
+ return ((0, jsx_runtime_1.jsxs)("button", { type: "button", role: "radio", "aria-checked": selected, disabled: busy, "data-selected": selected ? '' : undefined, onClick: () => { setPreset(option.value); }, style: {
4285
+ ...OPTION_BASE,
4286
+ background: selected ? 'var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06))' : 'transparent',
4287
+ fontWeight: selected ? 600 : 400,
4288
+ }, children: [(0, jsx_runtime_1.jsx)("span", { children: option.label }), selected && ((0, jsx_runtime_1.jsx)("span", { style: { marginLeft: 'auto', display: 'inline-flex', color: 'var(--dsw-alias-label-primary, inherit)' }, children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconCheckOutline16, { size: 16 }) }))] }, option.key));
4289
+ }) }), (0, jsx_runtime_1.jsx)("button", { type: "button", disabled: busy, onClick: () => { onConfirm(preset); }, style: { ...CONFIRM_STYLE, opacity: busy ? 0.6 : 1 }, children: busy ? t('sharePickerBusy') : t('sharePickerConfirm') })] })] }));
1615
4290
  }
1616
4291
  };
1617
4292
  __modules["MobileSessionInfo.js"] = function (require, module, exports) {
@@ -1625,8 +4300,20 @@ const store_ts_1 = require("./compat/store.js");
1625
4300
  const types_ts_1 = require("./compat/types.js");
1626
4301
  const nav_store_ts_1 = require("./nav-store.js");
1627
4302
  const history_nav_ts_1 = require("./history-nav.js");
4303
+ const fetch_share_ts_1 = require("./share/fetch-share.js");
4304
+ const share_flow_ts_1 = require("./share/share-flow.js");
4305
+ const png_stitch_ts_1 = require("./share/png-stitch.js");
4306
+ const rasterize_ts_1 = require("./share/rasterize.js");
4307
+ const share_card_tsx_1 = require("./share/share-card.js");
4308
+ const share_sheet_tsx_1 = require("./share/share-sheet.js");
1628
4309
  /** Layer id for the info sheet, so Android back closes it before leaving the session. */
1629
4310
  const INFO_LAYER = 'session-info';
4311
+ /**
4312
+ * Layer id for the share-range sheet. Pushed ON TOP of the info layer's own
4313
+ * entry, so one back gesture closes exactly the picker — never both layers
4314
+ * at once (the one-direction rule in history-nav.ts).
4315
+ */
4316
+ const SHARE_LAYER = 'session-info-share';
1630
4317
  const MobileSessionHeader_tsx_1 = require("./MobileSessionHeader.js");
1631
4318
  /* ---- StatsLine-identical formatting -------------------------------------
1632
4319
  * Ported (not imported — the source functions are module-private to
@@ -1692,9 +4379,18 @@ function MobileSessionInfo({ sessionId, useSessions, useProjection, forkSession,
1692
4379
  const [open, setOpen] = (0, react_1.useState)(false);
1693
4380
  const [busy, setBusy] = (0, react_1.useState)(false);
1694
4381
  const [error, setError] = (0, react_1.useState)(null);
4382
+ // Degraded-outcome notice (review 07+08): the multi-file delivery SUCCEEDED,
4383
+ // so "操作失败:…" would lie — this row says what the user actually got,
4384
+ // without the error template. Rendered in its own row (below), never through
4385
+ // setError.
4386
+ const [notice, setNotice] = (0, react_1.useState)(null);
4387
+ const [shareOpen, setShareOpen] = (0, react_1.useState)(false);
4388
+ const [shareData, setShareData] = (0, react_1.useState)(null);
4389
+ const shareCardHostRef = (0, react_1.useRef)(null);
1695
4390
  (0, react_1.useEffect)(() => {
1696
4391
  const onOpen = () => {
1697
4392
  setError(null);
4393
+ setNotice(null);
1698
4394
  setOpen(true);
1699
4395
  // Stack a history entry on top of the session's: Android back now
1700
4396
  // closes this sheet first and only then leaves the session.
@@ -1709,6 +4405,109 @@ function MobileSessionInfo({ sessionId, useSessions, useProjection, forkSession,
1709
4405
  const jobCount = useSessions((s) => s.jobsBySession[sessionId]?.length ?? 0);
1710
4406
  const stats = useProjection('sessionStats');
1711
4407
  const usage = useProjection('tokenUsage');
4408
+ /* ---- share-image flow (ticket 05) --------------------------------------
4409
+ * The range sheet renders INSIDE this info layer's DOM — a DIRECT child of
4410
+ * the layer, a sibling of the info card, never inside the card itself (a
4411
+ * card-internal mount clipped the mask to the card's box, anchored the
4412
+ * sheet's bottom to the card's scrollable area, and let outside taps fall
4413
+ * through to the info mask, closing both layers at once and stranding
4414
+ * SHARE_LAYER as a dead history entry; review 2026-09-10): info.css.ts's
4415
+ * ≥768px display:none covers it (this feature adds no page CSS), and the
4416
+ * info layer's own z:70 header promotion carries it. Its history entry
4417
+ * sits above INFO_LAYER's, so back closes the picker alone. These helpers
4418
+ * live before the `if (!open) return null` below because the pipeline
4419
+ * effect may still be settling when the sheet unmounts. */
4420
+ // Rewind rather than flip the flag — same one-direction rule as `close`.
4421
+ const closeShare = () => {
4422
+ if ((0, history_nav_ts_1.hasLayer)(SHARE_LAYER))
4423
+ (0, history_nav_ts_1.popLayer)(SHARE_LAYER);
4424
+ else
4425
+ setShareOpen(false);
4426
+ };
4427
+ // Errors land on the info sheet's existing error row (ticket 05); the
4428
+ // share sheet closes first so the row is not hidden behind its mask.
4429
+ const shareErrorMessage = (err) => {
4430
+ if (err instanceof fetch_share_ts_1.ShareFetchError) {
4431
+ switch (err.code) {
4432
+ case 'route-missing': return t('shareErrRouteMissing');
4433
+ case 'session-not-found': return t('shareErrSessionNotFound');
4434
+ case 'bad-request': return t('shareErrBadRequest');
4435
+ case 'network': return t('shareErrNetwork');
4436
+ case 'server': return t('shareErrServer');
4437
+ case 'bad-body': return t('shareErrBody');
4438
+ }
4439
+ }
4440
+ if (err instanceof rasterize_ts_1.ShareRasterizeError) {
4441
+ if (err.code === 'probe-failed')
4442
+ return t('shareErrProbe');
4443
+ if (err.code === 'slice-over-budget')
4444
+ return t('shareErrSliceBudget');
4445
+ return t('shareErrRender');
4446
+ }
4447
+ return err instanceof Error ? err.message : String(err);
4448
+ };
4449
+ const shareFailed = (err) => {
4450
+ setBusy(false);
4451
+ setShareData(null);
4452
+ closeShare();
4453
+ setError(shareErrorMessage(err));
4454
+ };
4455
+ // Pipeline stage two. Stage one (onShareConfirm below) fetches and commits
4456
+ // the data, which renders the hidden ShareCard; this effect then runs
4457
+ // AFTER that commit, when the card element exists. The state → render →
4458
+ // effect gap is the React-idiomatic bridge over "DOM ready" without
4459
+ // react-dom (not in the client externals whitelist). Ticket 07: when the
4460
+ // browser can stitch (CompressionStream), the slices are planned with the
4461
+ // unified global width/ratio and concatenated into ONE PNG before delivery;
4462
+ // a runtime stitch failure falls back to the legacy per-slice multi-file
4463
+ // delivery, and either degraded form lands on the notice row below (never
4464
+ // the error row — the delivery itself succeeded, review 07+08).
4465
+ (0, react_1.useEffect)(() => {
4466
+ if (shareData === null)
4467
+ return;
4468
+ const card = shareCardHostRef.current?.querySelector('[data-share-card]') ?? null;
4469
+ if (card === null) {
4470
+ shareFailed(new Error(t('shareErrRender')));
4471
+ return;
4472
+ }
4473
+ const stitch = (0, png_stitch_ts_1.supportsPngStitch)();
4474
+ (0, rasterize_ts_1.rasterizeShareCard)(card, {
4475
+ pixelRatioCap: Math.min(window.devicePixelRatio || 1, 2),
4476
+ truncatedNote: (droppedTurns) => t('shareTruncatedNote', { count: droppedTurns }),
4477
+ unified: stitch,
4478
+ })
4479
+ .then(async (result) => {
4480
+ const title = row?.displayTitle ?? '';
4481
+ if (stitch && result.stitchWidth !== undefined) {
4482
+ try {
4483
+ const png = await (0, png_stitch_ts_1.stitchSliceBlobs)(result.slices, result.stitchWidth);
4484
+ await (0, share_flow_ts_1.deliverShareImage)(png, title);
4485
+ return null;
4486
+ }
4487
+ catch (err) {
4488
+ // Runtime stitch failure (a slice decoded at the wrong size, a
4489
+ // compressor error, …) — the rasterized slices are finished PNGs
4490
+ // either way, so deliver them multi-file instead of failing the
4491
+ // whole export. The original error stays on the console for
4492
+ // diagnosis; the notice row tells the user what they got (no
4493
+ // "操作失败" prefix — the delivery succeeded, review 07+08).
4494
+ console.error('[dsh-zen-remote] share-image: stitch failed — delivering the painted slices as multiple images instead', err);
4495
+ await (0, share_flow_ts_1.deliverShareImages)(result.slices, title);
4496
+ return t('shareStitchFailed');
4497
+ }
4498
+ }
4499
+ await (0, share_flow_ts_1.deliverShareImages)(result.slices, title);
4500
+ return t('shareStitchUnsupported');
4501
+ })
4502
+ .then((degraded) => {
4503
+ setBusy(false);
4504
+ setShareData(null);
4505
+ closeShare();
4506
+ setNotice(degraded);
4507
+ })
4508
+ .catch(shareFailed);
4509
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- shareData is the trigger; helpers only touch setters and module functions.
4510
+ }, [shareData]);
1712
4511
  if (!open)
1713
4512
  return null;
1714
4513
  // Rewind rather than flip the flag — see the one-direction rule in
@@ -1767,10 +4566,50 @@ function MobileSessionInfo({ sessionId, useSessions, useProjection, forkSession,
1767
4566
  const onExport = () => {
1768
4567
  void downloadSessionLog(sessionId);
1769
4568
  };
4569
+ const onShare = () => {
4570
+ setError(null);
4571
+ setNotice(null);
4572
+ setShareData(null);
4573
+ setShareOpen(true);
4574
+ (0, history_nav_ts_1.pushLayer)({ id: SHARE_LAYER, close: () => setShareOpen(false) });
4575
+ };
4576
+ // Pipeline stage one: fetch the selected range. Stage two (rasterize +
4577
+ // deliver) lives in the shareData effect above — the hidden card must be
4578
+ // committed to the DOM before the rasterizer can clone it.
4579
+ const onShareConfirm = (choice) => {
4580
+ const range = choice === 'all' ? { kind: 'all' } : { kind: 'last', turns: choice };
4581
+ setBusy(true);
4582
+ setError(null);
4583
+ // Session id straight off the useSessions row (its branded
4584
+ // session-<uuid> form — a bare uuid 404s, PLAN §2), slot prop as backup.
4585
+ (0, fetch_share_ts_1.fetchShareExport)(row?.id ?? sessionId, range)
4586
+ .then((data) => {
4587
+ if (data.turns.length === 0)
4588
+ throw new Error(t('shareErrEmpty'));
4589
+ setShareData(data);
4590
+ })
4591
+ .catch(shareFailed);
4592
+ };
1770
4593
  const cacheHit = usage === undefined ? null : cacheHitPercent(usage);
1771
4594
  const tokensEmpty = usage === undefined || (billedInputTokens(usage) === 0 && usage.outputTokens === 0);
1772
4595
  const cwd = row?.cwd === undefined || row.cwd === '' ? undefined : (0, store_ts_1.workspaceTitleOf)(row.cwd);
1773
4596
  const preset = (0, types_ts_1.agentPresetOf)(row);
4597
+ // Known-empty sessions (stats observed, zero turns) disable the share
4598
+ // button outright; unknown-stats sessions stay enabled and let the fetch's
4599
+ // empty-turns guard speak. Known 口径 gap, accepted for v1: the projection
4600
+ // counts a turn only once one of its steps has CLOSED (the fold increments
4601
+ // on step/end — dsh-session-stats projection.js), so a session whose only
4602
+ // content is a still-streaming turn, or user messages with no assistant
4603
+ // step yet, also reads turns === 0 and is over-disabled here. Wrong
4604
+ // direction but fail-safe: the button is merely greyed (no misleading
4605
+ // error), and every session that passes through still meets the fetch-side
4606
+ // empty-turns guard (shareErrEmpty below), which is exact.
4607
+ const shareEmpty = stats !== undefined && stats.turns === 0;
4608
+ const shareCardCopy = {
4609
+ turnsLabel: (count) => t('shareCardTurns', { count }),
4610
+ generatedBy: t('shareCardGeneratedBy'),
4611
+ image: t('shareCardImage'),
4612
+ };
1774
4613
  const cells = [
1775
4614
  { label: t('infoStatTurns'), value: stats === undefined ? NA : String(stats.turns), sub: undefined },
1776
4615
  { label: t('infoStatSteps'), value: stats === undefined ? NA : String(stats.steps), sub: undefined },
@@ -1808,7 +4647,15 @@ function MobileSessionInfo({ sessionId, useSessions, useProjection, forkSession,
1808
4647
  if (!tab.active)
1809
4648
  tab.el.click();
1810
4649
  close();
1811
- }, children: tab.label }, tab.label))) })), (0, jsx_runtime_1.jsx)("button", { type: "button", "data-mobile-nav": "info-close", "aria-label": t('infoClose'), onClick: close, children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconCloseOutline16, { size: 16 }) })] }), (0, jsx_runtime_1.jsxs)("div", { "data-mobile-nav": "info-badges", children: [preset !== undefined && (0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-badge", children: preset }), subagentCount > 0 && ((0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-badge", children: t('infoSubagents', { count: subagentCount }) })), jobCount > 0 && ((0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-badge", children: t('infoJobs', { count: jobCount }) })), (0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-badge-cwd", children: cwd ?? t('infoCwdFallback') })] }), (0, jsx_runtime_1.jsx)("div", { "data-mobile-nav": "info-stats", children: cells.map((cell) => ((0, jsx_runtime_1.jsxs)("div", { "data-mobile-nav": "info-stat", children: [(0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-stat-value", children: cell.value }), (0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-stat-label", children: cell.label }), cell.sub !== undefined && (0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-stat-sub", children: cell.sub })] }, cell.label))) }), error !== null && (0, jsx_runtime_1.jsx)("div", { "data-mobile-nav": "info-error", children: t('infoActionError', { message: error }) }), (0, jsx_runtime_1.jsxs)("div", { "data-mobile-nav": "info-actions", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", "data-mobile-nav": "info-action", disabled: busy, onClick: onExport, children: [(0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconDownloadOutline16, { size: 16 }), (0, jsx_runtime_1.jsx)("span", { children: t('infoExport') })] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", "data-mobile-nav": "info-action", disabled: busy, onClick: onRename, children: [(0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconEditOutline16, { size: 16 }), (0, jsx_runtime_1.jsx)("span", { children: t('infoRename') })] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", "data-mobile-nav": "info-action", disabled: busy, onClick: onFork, children: [(0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconBranchOutline16, { size: 16 }), (0, jsx_runtime_1.jsx)("span", { children: t('infoFork') })] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", "data-mobile-nav": "info-action", "data-mobile-nav-danger": "", disabled: busy, onClick: onArchive, children: [(0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconArchiveOutline20, { size: 20 }), (0, jsx_runtime_1.jsx)("span", { children: t('infoArchive') })] })] })] })] }));
4650
+ }, children: tab.label }, tab.label))) })), (0, jsx_runtime_1.jsx)("button", { type: "button", "data-mobile-nav": "info-close", "aria-label": t('infoClose'), onClick: close, children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconCloseOutline16, { size: 16 }) })] }), (0, jsx_runtime_1.jsxs)("div", { "data-mobile-nav": "info-badges", children: [preset !== undefined && (0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-badge", children: preset }), subagentCount > 0 && ((0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-badge", children: t('infoSubagents', { count: subagentCount }) })), jobCount > 0 && ((0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-badge", children: t('infoJobs', { count: jobCount }) })), (0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-badge-cwd", children: cwd ?? t('infoCwdFallback') })] }), (0, jsx_runtime_1.jsx)("div", { "data-mobile-nav": "info-stats", children: cells.map((cell) => ((0, jsx_runtime_1.jsxs)("div", { "data-mobile-nav": "info-stat", children: [(0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-stat-value", children: cell.value }), (0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-stat-label", children: cell.label }), cell.sub !== undefined && (0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-stat-sub", children: cell.sub })] }, cell.label))) }), error !== null && (0, jsx_runtime_1.jsx)("div", { "data-mobile-nav": "info-error", children: t('infoActionError', { message: error }) }), notice !== null && ((0, jsx_runtime_1.jsx)("div", { "data-mobile-nav": "info-notice", role: "status", style: {
4651
+ marginBottom: 8,
4652
+ padding: '8px 10px',
4653
+ borderRadius: 10,
4654
+ background: 'var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06))',
4655
+ color: 'var(--dsw-alias-label-secondary, rgba(0, 0, 0, .6))',
4656
+ fontSize: 12,
4657
+ lineHeight: 1.4,
4658
+ }, children: notice })), (0, jsx_runtime_1.jsxs)("div", { "data-mobile-nav": "info-actions", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", "data-mobile-nav": "info-action", disabled: busy, onClick: onExport, children: [(0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconDownloadOutline16, { size: 16 }), (0, jsx_runtime_1.jsx)("span", { children: t('infoExport') })] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", "data-mobile-nav": "info-action", disabled: busy, onClick: onRename, children: [(0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconEditOutline16, { size: 16 }), (0, jsx_runtime_1.jsx)("span", { children: t('infoRename') })] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", "data-mobile-nav": "info-action", disabled: busy, onClick: onFork, children: [(0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconBranchOutline16, { size: 16 }), (0, jsx_runtime_1.jsx)("span", { children: t('infoFork') })] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", "data-mobile-nav": "info-action", "data-mobile-nav-danger": "", disabled: busy, onClick: onArchive, children: [(0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconArchiveOutline20, { size: 20 }), (0, jsx_runtime_1.jsx)("span", { children: t('infoArchive') })] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", "data-mobile-nav": "info-action", disabled: busy || shareEmpty, onClick: onShare, children: [(0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconShareOutline16, { size: 16 }), (0, jsx_runtime_1.jsx)("span", { children: t('shareAction') })] })] }), shareData !== null && ((0, jsx_runtime_1.jsx)("div", { ref: shareCardHostRef, "aria-hidden": "true", style: { position: 'fixed', left: '-9999px', top: 0, pointerEvents: 'none' }, children: (0, jsx_runtime_1.jsx)(share_card_tsx_1.ShareCard, { title: row?.displayTitle ?? t('sessionInfo'), ...(preset !== undefined ? { subtitle: preset } : {}), ...(shareData.createdAt !== undefined ? { createdAt: shareData.createdAt } : {}), turns: shareData.turns, copy: shareCardCopy }) }))] }), shareOpen && ((0, jsx_runtime_1.jsx)(share_sheet_tsx_1.ShareRangeSheet, { busy: busy, onConfirm: onShareConfirm, onClose: closeShare, t: t }))] }));
1812
4659
  }
1813
4660
  };
1814
4661
  __modules["attach-upload.js"] = function (require, module, exports) {
@@ -1957,6 +4804,63 @@ function releaseThumbnailsExcept(keep) {
1957
4804
  }
1958
4805
  }
1959
4806
  };
4807
+ __modules["host-attach.js"] = function (require, module, exports) {
4808
+ "use strict";
4809
+ Object.defineProperty(exports, "__esModule", { value: true });
4810
+ exports.HOST_ATTACH_INPUT = void 0;
4811
+ exports.hostAttachPresent = hostAttachPresent;
4812
+ exports.useOwnAttachUi = useOwnAttachUi;
4813
+ const react_1 = require("react");
4814
+ /**
4815
+ * The host composer's own hidden file picker, when the running DSH ships one.
4816
+ *
4817
+ * DSH 0.1.5 grew a native attachment flow (dsh-attachment / dsh-client-file-
4818
+ * upload): the official tool row carries a second `_add` paperclip whose click
4819
+ * drives an `<input type="file" hidden>` that is a DIRECT sibling of the
4820
+ * buttons inside `_tools`. That input is the one structural, locale-free
4821
+ * proof that the host implements composer attachment upload — the button
4822
+ * itself can only be told apart from the "+" command menu by its localized
4823
+ * aria-label, and the capability prop (`addFiles`) lives on the composer
4824
+ * bar's own inject face, which never reaches `conversation.input.left`
4825
+ * registrants. Scoped to `_tools` children so a third-party plugin parking
4826
+ * its own file input somewhere else in the composer cannot be misread as
4827
+ * the official feature; `:not([data-mobile-nav])` keeps OUR picker
4828
+ * (`attach-picker`) out of the match.
4829
+ */
4830
+ exports.HOST_ATTACH_INPUT = '[data-slot="conversation.composer.bar"] [class$="_tools"] > input[type="file"]:not([data-mobile-nav])';
4831
+ /** Synchronous read: does this host's composer ship its own attach picker? */
4832
+ function hostAttachPresent() {
4833
+ return document.querySelector(exports.HOST_ATTACH_INPUT) !== null;
4834
+ }
4835
+ /**
4836
+ * True while OUR attachment UI should mount (the host has none of its own).
4837
+ *
4838
+ * The rule this encodes (2026-09-11): where the host implements attachment
4839
+ * upload, the phone composer shows exactly ONE attach control — the host's —
4840
+ * and this plugin's S7 button and chips row stand down; on a host without
4841
+ * one (DSH ≤ 0.1.2), both load as they always have.
4842
+ *
4843
+ * `useLayoutEffect`, not `useEffect`: on the composer's very first mount our
4844
+ * slot child renders in the same React pass as the host's tool row, so the
4845
+ * state initializer below reads the DOM BEFORE either exists — the layout
4846
+ * effect re-checks after the commit and before the browser paints, so the
4847
+ * demotion to hidden (or, on an old host, the decision to stay) never
4848
+ * reaches the screen as a flash. The MutationObserver covers the lifetime
4849
+ * after that: the picker's existence only really changes with the page, but
4850
+ * a dynamic host plugin or a reload must not strand a stale verdict.
4851
+ */
4852
+ function useOwnAttachUi() {
4853
+ const [own, setOwn] = (0, react_1.useState)(() => !hostAttachPresent());
4854
+ (0, react_1.useLayoutEffect)(() => {
4855
+ const check = () => setOwn(!hostAttachPresent());
4856
+ const observer = new MutationObserver(check);
4857
+ observer.observe(document.body, { childList: true, subtree: true });
4858
+ check();
4859
+ return () => observer.disconnect();
4860
+ }, []);
4861
+ return own;
4862
+ }
4863
+ };
1960
4864
  __modules["MobileAttachButton.js"] = function (require, module, exports) {
1961
4865
  "use strict";
1962
4866
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -1965,6 +4869,7 @@ const jsx_runtime_1 = require("react/jsx-runtime");
1965
4869
  const react_1 = require("react");
1966
4870
  const dsh_client_ui_primitives_1 = require("@deepseek-ai/dsh-client-ui-primitives");
1967
4871
  const attach_upload_ts_1 = require("./attach-upload.js");
4872
+ const host_attach_ts_1 = require("./host-attach.js");
1968
4873
  /** Post one file to the host route and return the workspace-relative path it landed on. */
1969
4874
  async function uploadFile(sessionId, file) {
1970
4875
  const response = await fetch((0, attach_upload_ts_1.uploadUrl)(sessionId, file.name), {
@@ -1990,6 +4895,13 @@ async function uploadFile(sessionId, file) {
1990
4895
  * the CLIENT's picker — no `accept` attribute on purpose, so iOS offers the
1991
4896
  * full 相册 / 拍照 / 选取文件 sheet rather than one of them.
1992
4897
  *
4898
+ * DSH 0.1.5 gave the official tool row its own paperclip (the host attachment
4899
+ * flow), which made TWO identical controls on a phone. This button now stands
4900
+ * down entirely while the host ships its own picker (`host-attach.ts` probes
4901
+ * for it) — the official flow uploads into `~/.dsh/attachments` and renders
4902
+ * its own rail, so the composer keeps exactly one attach affordance. On a
4903
+ * host without one (DSH ≤ 0.1.2) everything below loads exactly as before.
4904
+ *
1993
4905
  * Every picked file takes the SAME path: upload to the node half, then append
1994
4906
  * `@.dsh-uploads/name` to the draft through `inputActions.setDraft` (the
1995
4907
  * official write path — no DOM value poking). S7.1 removed the split that used
@@ -2005,12 +4917,17 @@ function MobileAttachButton({ t, sessionId, useInput, inputActions }) {
2005
4917
  const [busy, setBusy] = (0, react_1.useState)(false);
2006
4918
  const [error, setError] = (0, react_1.useState)(null);
2007
4919
  const draft = useInput((state) => state.draft);
4920
+ const ownUi = (0, host_attach_ts_1.useOwnAttachUi)();
2008
4921
  // Uploads are awaited, so the `draft` captured at pick time goes stale — the
2009
4922
  // user can keep typing while a file is in flight. The ref is refreshed on
2010
4923
  // every render (useInput re-renders us on each draft change), so the loop
2011
4924
  // below can rebase onto whatever the composer holds right now.
2012
4925
  const liveDraft = (0, react_1.useRef)(draft);
2013
4926
  liveDraft.current = draft;
4927
+ // The host's own attach button owns this seat now — render nothing (the
4928
+ // hidden picker input goes with it). All hooks stay above this line.
4929
+ if (!ownUi)
4930
+ return null;
2014
4931
  const pick = async (event) => {
2015
4932
  const files = Array.from(event.target.files ?? []);
2016
4933
  // Reset first: picking the same file twice in a row fires no change event
@@ -2056,6 +4973,7 @@ const jsx_runtime_1 = require("react/jsx-runtime");
2056
4973
  const react_1 = require("react");
2057
4974
  const dsh_client_ui_primitives_1 = require("@deepseek-ai/dsh-client-ui-primitives");
2058
4975
  const attach_upload_ts_1 = require("./attach-upload.js");
4976
+ const host_attach_ts_1 = require("./host-attach.js");
2059
4977
  /**
2060
4978
  * Attachment preview row (S7.1), in `conversation.input.dock` — the official
2061
4979
  * full-width row above the composer card, which composer.css.ts already lays
@@ -2084,10 +5002,17 @@ const attach_upload_ts_1 = require("./attach-upload.js");
2084
5002
  */
2085
5003
  function MobileAttachChips({ t, useInput, inputActions }) {
2086
5004
  const draft = useInput((state) => state.draft);
2087
- const attachments = (0, attach_upload_ts_1.mentionsIn)(draft);
5005
+ const ownUi = (0, host_attach_ts_1.useOwnAttachUi)();
2088
5006
  (0, react_1.useEffect)(() => {
2089
5007
  (0, attach_upload_ts_1.releaseThumbnailsExcept)((0, attach_upload_ts_1.mentionsIn)(draft));
2090
5008
  }, [draft]);
5009
+ // Same gate as MobileAttachButton (host-attach.ts): where the host ships
5010
+ // its own attachment flow, our chips row stands down too — the official
5011
+ // rail owns the preview seat. The effect above still runs, so object URLs
5012
+ // from OUR flow get revoked even after the switch.
5013
+ if (!ownUi)
5014
+ return null;
5015
+ const attachments = (0, attach_upload_ts_1.mentionsIn)(draft);
2091
5016
  if (attachments.length === 0)
2092
5017
  return null;
2093
5018
  return ((0, jsx_runtime_1.jsx)("div", { "data-mobile-nav": "attach-chips", children: attachments.map((relPath) => {
@@ -2707,8 +5632,11 @@ exports.LAYOUT_CSS = `/* ---------- mobile-only layout ---------- */
2707
5632
  first-child's last-child, so without this exclusion it silently matched
2708
5633
  this generic selector too — measured 358px (100vw-32px) instead of the
2709
5634
  374px its own left:8px/right:8px margins specify. Same category of bug
2710
- as the ZuhsRW directory-picker collision below; same fix shape. */
2711
- [aria-modal="true"]:not(:has(> :first-child > :last-child > button)):not([data-mobile-nav="info-sheet"]) {
5635
+ as the ZuhsRW directory-picker collision below; same fix shape.
5636
+ :not([data-mobile-nav="share-range-sheet"]) (share-image, 2026-09-10):
5637
+ the share range picker rides the same role="dialog" aria-modal bottom
5638
+ -sheet shape and the same margins, so it hit the identical clamp. */
5639
+ [aria-modal="true"]:not(:has(> :first-child > :last-child > button)):not([data-mobile-nav="info-sheet"]):not([data-mobile-nav="share-range-sheet"]) {
2712
5640
  max-width: calc(100vw - 32px) !important;
2713
5641
  }
2714
5642
  /* Nav bar: hide the "Settings" caption (redundant on a full-width sheet)
@@ -4493,6 +7421,11 @@ __modules["styles/header.css.js"] = function (require, module, exports) {
4493
7421
  // nav.crumbs breadcrumb chain, last segment = the title
4494
7422
  // div.headerActions [data-slot="conversation.session.header.actions"]
4495
7423
  // div.headerUtilities [data-slot="conversation.session.header.utilities"]
7424
+ // div.headerCorner [data-conversation-header-corner]
7425
+ // [data-slot="conversation.session.header.corner"]
7426
+ // (DSH 0.1.5: the right sidebar's ExpandButton —
7427
+ // hidden below, our workbench button routes to
7428
+ // its controls instead)
4496
7429
  // div.tabs[role="tablist"] Chat/Trajectory, only when there is >1 view
4497
7430
  Object.defineProperty(exports, "__esModule", { value: true });
4498
7431
  exports.HEADER_CSS = void 0;
@@ -4761,6 +7694,31 @@ exports.HEADER_CSS = `/* ---------- session header five-piece reflow (< 768px) -
4761
7694
  [data-phase] header [data-slot="conversation.session.header.utilities"] > * {
4762
7695
  display: none !important;
4763
7696
  }
7697
+ /* DSH 0.1.5's third titleRow child: the right sidebar's ExpandButton
7698
+ (dsh-client-ui-sidebar-right, slot conversation.session.header.corner
7699
+ inside div.headerCorner). Left alone it auto-placed into an implicit
7700
+ 4th grid column at the screen's top-right corner — a second panel-left
7701
+ icon next to our Workbench button, reading as "the hidden official
7702
+ sidebar toggle is back" (reported 2026-09-11). The button itself is the
7703
+ one our workbench control forwards to when better-sidebar is absent
7704
+ (MobileSessionHeader's toggleSidebarTarget), and a display:none button
7705
+ still receives synthetic .click() (the tablist precedent), so hiding it
7706
+ costs nothing.
7707
+ Anchors, in the specificity order they NEED (measured live, 2026-09-11):
7708
+ the corner div must be beaten with (0,3,1) — the utilities un-hide rule
7709
+ above spells header > :first-child > :last-child at exactly that
7710
+ weight, and with 0.1.5's third titleRow child the corner IS that
7711
+ :last-child now, so its flex !important outranks any lighter hide (a
7712
+ single-attribute hide loses at (0,2,1) — both spellings were tried
7713
+ live). Chaining BOTH of the corner's own anchors on one selector — the
7714
+ host's data-conversation-header-corner contract attribute AND the
7715
+ class suffix — reaches the tie, and the later source order here wins
7716
+ it. The slot wrapper rule below needs no help: nothing !important
7717
+ fights it, so its (0,2,1) settles the button. */
7718
+ [data-phase] header [data-conversation-header-corner][class$="_headerCorner"],
7719
+ [data-phase] header [data-slot="conversation.session.header.corner"] {
7720
+ display: none !important;
7721
+ }
4764
7722
  /* Native background-task / subagent entries (rewritten for DSH 0.1.1,
4765
7723
  2026-08-21). Three upstream changes killed the old "park the root as a
4766
7724
  zero-width anchor, hide the trigger, forward the tap" design at once:
@@ -6190,6 +9148,7 @@ exports.zh = {
6190
9148
  'sessionInfo': '会话信息',
6191
9149
  'workbench': '工作台',
6192
9150
  'workbenchClose': '关闭工作台',
9151
+ 'sidebar': '侧边栏',
6193
9152
  'switchView': '切换视图',
6194
9153
  'attach': '添加附件',
6195
9154
  'attachBusy': '上传中…',
@@ -6217,6 +9176,28 @@ exports.zh = {
6217
9176
  'infoActionError': '操作失败:{message}',
6218
9177
  'turnFold': '过程 · {count} 步',
6219
9178
  'turnFoldRunning': '进行中 · {count} 步',
9179
+ 'shareCardTurns': '{count} 轮对话',
9180
+ 'shareCardGeneratedBy': '由 DeepSeek Harness 生成',
9181
+ 'shareCardImage': '图片附件',
9182
+ 'shareAction': '分享图',
9183
+ 'sharePickerTitle': '分享范围',
9184
+ 'sharePickerAll': '全部对话',
9185
+ 'sharePickerLast': '最近 {count} 轮',
9186
+ 'sharePickerConfirm': '生成分享图',
9187
+ 'sharePickerBusy': '生成中…',
9188
+ 'shareTruncatedNote': '内容过长,已省略前 {count} 轮',
9189
+ 'shareErrRouteMissing': '当前 DSH 版本未提供分享图接口,请更新 DSH 后重试',
9190
+ 'shareErrSessionNotFound': '会话不存在或已被删除',
9191
+ 'shareErrBadRequest': '分享请求无效,请重试',
9192
+ 'shareErrNetwork': '网络连接失败,请检查网络后重试',
9193
+ 'shareErrServer': '服务暂时不可用,请稍后重试',
9194
+ 'shareErrBody': '分享数据异常,请重试',
9195
+ 'shareErrProbe': '当前浏览器无法生成分享图',
9196
+ 'shareErrRender': '生成分享图失败,请重试',
9197
+ 'shareErrSliceBudget': '分享图单段内容超出画布上限,请缩小分享范围后重试',
9198
+ 'shareErrEmpty': '会话还没有可分享的内容',
9199
+ 'shareStitchUnsupported': '当前浏览器暂不支持合成单张长图,已导出为多张图片',
9200
+ 'shareStitchFailed': '合成单张长图失败,已改为多张图片导出',
6220
9201
  'settings': '设置',
6221
9202
  'chipTaskboard': '任务看板',
6222
9203
  'chipSsh': 'SSH',
@@ -6243,6 +9224,7 @@ exports.en = {
6243
9224
  'sessionInfo': 'Session info',
6244
9225
  'workbench': 'Workbench',
6245
9226
  'workbenchClose': 'Close workbench',
9227
+ 'sidebar': 'Sidebar',
6246
9228
  'switchView': 'Switch view',
6247
9229
  'attach': 'Add attachment',
6248
9230
  'attachBusy': 'Uploading…',
@@ -6270,6 +9252,28 @@ exports.en = {
6270
9252
  'infoActionError': 'Action failed: {message}',
6271
9253
  'turnFold': 'Process · {count} steps',
6272
9254
  'turnFoldRunning': 'Working · {count} steps',
9255
+ 'shareCardTurns': '{count} turns',
9256
+ 'shareCardGeneratedBy': 'Made with DeepSeek Harness',
9257
+ 'shareCardImage': 'Image attachment',
9258
+ 'shareAction': 'Share image',
9259
+ 'sharePickerTitle': 'Share range',
9260
+ 'sharePickerAll': 'Whole conversation',
9261
+ 'sharePickerLast': 'Last {count} turns',
9262
+ 'sharePickerConfirm': 'Generate',
9263
+ 'sharePickerBusy': 'Generating…',
9264
+ 'shareTruncatedNote': 'Long conversation — first {count} turns omitted',
9265
+ 'shareErrRouteMissing': 'This DSH version has no share-image endpoint — please update DSH',
9266
+ 'shareErrSessionNotFound': 'Session not found or deleted',
9267
+ 'shareErrBadRequest': 'Invalid share request — please try again',
9268
+ 'shareErrNetwork': 'Network error — check the connection and try again',
9269
+ 'shareErrServer': 'Service temporarily unavailable — try again later',
9270
+ 'shareErrBody': 'Unexpected share data — please try again',
9271
+ 'shareErrProbe': 'This browser cannot render share images',
9272
+ 'shareErrRender': 'Failed to generate the share image — please try again',
9273
+ 'shareErrSliceBudget': 'One slice of the share image exceeded the canvas limit — try a smaller share range',
9274
+ 'shareErrEmpty': 'Nothing to share in this session yet',
9275
+ 'shareStitchUnsupported': 'This browser cannot stitch one long image — exported as multiple images instead',
9276
+ 'shareStitchFailed': 'Could not stitch one long image — exported as multiple images instead',
6273
9277
  'settings': 'Settings',
6274
9278
  'chipTaskboard': 'Task board',
6275
9279
  'chipSsh': 'SSH',
@@ -8845,24 +11849,29 @@ const INTENT_WINDOW_MS = 1000;
8845
11849
  /**
8846
11850
  * S9 — keep the phone keyboard down until the user asks for it.
8847
11851
  *
8848
- * dsh-client-ui-conversation focuses the composer textarea on every
8849
- * sessionId change (lib/client.js:3423 — el.focus({preventScroll:true}) in a
8850
- * [locked, sessionId] effect). Sensible on desktop; on a phone it pops the
8851
- * software keyboard over half the screen every time a session opens.
11852
+ * dsh-client-ui-conversation focuses the composer's editing host on every
11853
+ * sessionId change (0.1.2: `el.focus({preventScroll:true})` on the textarea;
11854
+ * 0.1.5: the same on the Lexical contenteditable). Sensible on desktop; on a
11855
+ * phone it pops the software keyboard over half the screen every time a
11856
+ * session opens.
8852
11857
  *
8853
- * Rule: focus on the composer textarea survives only when the user asked for
8854
- * it — a tap on the textarea itself or typing on a hardware keyboard.
8855
- * Anything else (session-open autofocus, push-deep-link opens, the refocus
8856
- * side effects of the other composer buttons — slash-command toggle, attach,
8857
- * send) is blurred.
11858
+ * Rule: focus on the composer field survives only when the user asked for
11859
+ * it — a tap on the field itself or typing on a hardware keyboard. Anything
11860
+ * else (session-open autofocus, push-deep-link opens, the refocus side
11861
+ * effects of the other composer buttons — the official attach paperclip's
11862
+ * `keepFocus` mousedown, the slash-command toggle, send) is blurred. That
11863
+ * attach case is not hypothetical: 0.1.5's official paperclip refocuses the
11864
+ * editor on MOUSEDOWN, and while this guard was blind to the contenteditable
11865
+ * (2026-09-11 report) every tap on it popped the keyboard and shoved the
11866
+ * composer up the screen.
8858
11867
  *
8859
11868
  * Two triggers, because one is not enough:
8860
11869
  * - focusin catches the autofocus the moment it happens;
8861
11870
  * - a body MutationObserver re-runs the check after transcript swaps
8862
- * (opening a session re-renders the flow but may reuse the same textarea,
11871
+ * (opening a session re-renders the flow but may reuse the same field,
8863
11872
  * and a focus that landed before this plugin loaded never fired focusin
8864
11873
  * for us at all).
8865
- * Once focus is user-granted it stays granted until the textarea blurs, so
11874
+ * Once focus is user-granted it stays granted until the field blurs, so
8866
11875
  * the observer never yanks a keyboard the user opened (e.g. while the agent
8867
11876
  * streams and the user pauses typing).
8868
11877
  */
@@ -8873,7 +11882,12 @@ function installKeyboardGuard(ctx) {
8873
11882
  let granted = false;
8874
11883
  let observer = null;
8875
11884
  let frame = 0;
8876
- const composerTextarea = (node) => node instanceof HTMLElement && node.tagName === 'TEXTAREA' && node.closest(COMPOSER) !== null
11885
+ /** The composer's editing host: the `data-composer-input` contenteditable
11886
+ * DSH 0.1.5 binds Lexical to, or the textarea older hosts rendered (the
11887
+ * attribute also sat on that textarea — both spellings match both hosts,
11888
+ * belt and braces). */
11889
+ const composerField = (node) => node instanceof HTMLElement && node.closest(COMPOSER) !== null
11890
+ && (node.hasAttribute('data-composer-input') || node.tagName === 'TEXTAREA')
8877
11891
  ? node
8878
11892
  : null;
8879
11893
  const onPointerDown = (event) => {
@@ -8881,18 +11895,18 @@ function installKeyboardGuard(ctx) {
8881
11895
  // composer control (slash-command toggle, attach, model menu, send)
8882
11896
  // must not: several of them refocus the input as a side effect, which
8883
11897
  // popped the keyboard on every command-button tap.
8884
- if (composerTextarea(event.target) !== null) {
11898
+ if (composerField(event.target) !== null) {
8885
11899
  lastIntent = Date.now();
8886
11900
  granted = true;
8887
11901
  }
8888
11902
  };
8889
11903
  const onKeyDown = () => {
8890
11904
  lastIntent = Date.now();
8891
- if (composerTextarea(document.activeElement) !== null)
11905
+ if (composerField(document.activeElement) !== null)
8892
11906
  granted = true;
8893
11907
  };
8894
11908
  const sweep = () => {
8895
- const el = composerTextarea(document.activeElement);
11909
+ const el = composerField(document.activeElement);
8896
11910
  if (el === null)
8897
11911
  return;
8898
11912
  if (granted || Date.now() - lastIntent < INTENT_WINDOW_MS) {
@@ -8902,12 +11916,12 @@ function installKeyboardGuard(ctx) {
8902
11916
  el.blur();
8903
11917
  };
8904
11918
  const onFocusIn = (event) => {
8905
- if (composerTextarea(event.target) === null)
11919
+ if (composerField(event.target) === null)
8906
11920
  return;
8907
11921
  sweep();
8908
11922
  };
8909
11923
  const onFocusOut = (event) => {
8910
- if (composerTextarea(event.target) === null)
11924
+ if (composerField(event.target) === null)
8911
11925
  return;
8912
11926
  granted = false;
8913
11927
  };
@@ -9146,9 +12160,16 @@ function installKeyboardAvoid(ctx) {
9146
12160
  tuning = loaded.keyboard;
9147
12161
  pad = safetyPad(navigator.userAgent, tuning.safetyPadPx);
9148
12162
  });
9149
- const composerTextarea = (node) => node instanceof HTMLElement && node.tagName === 'TEXTAREA' && node.closest(COMPOSER) !== null;
12163
+ /** The composer's editing host: the `data-composer-input` contenteditable
12164
+ * DSH 0.1.5 binds Lexical to, or the textarea of older hosts (the
12165
+ * attribute sat on that textarea too). Same recognition as S9's guard —
12166
+ * while this checked TEXTAREA only, the whole focus machinery below was
12167
+ * blind to the 0.1.5 contenteditable and the dumb-keyboard estimate
12168
+ * never ran. */
12169
+ const composerField = (node) => node instanceof HTMLElement && node.closest(COMPOSER) !== null
12170
+ && (node.hasAttribute('data-composer-input') || node.tagName === 'TEXTAREA');
9150
12171
  const sync = () => {
9151
- const focused = composerTextarea(document.activeElement);
12172
+ const focused = composerField(document.activeElement);
9152
12173
  if (!focused)
9153
12174
  baseline = viewport.height;
9154
12175
  const geometric = keyboardLift({
@@ -9194,7 +12215,7 @@ function installKeyboardAvoid(ctx) {
9194
12215
  sync();
9195
12216
  }
9196
12217
  const el = document.activeElement;
9197
- if (composerTextarea(el) && el instanceof HTMLElement)
12218
+ if (composerField(el) && el instanceof HTMLElement)
9198
12219
  el.blur();
9199
12220
  };
9200
12221
  /** True when a touch outside the composer means "the reader moved on". */
@@ -9217,7 +12238,7 @@ function installKeyboardAvoid(ctx) {
9217
12238
  retract();
9218
12239
  };
9219
12240
  const onFocusIn = (event) => {
9220
- if (!composerTextarea(event.target))
12241
+ if (!composerField(event.target))
9221
12242
  return;
9222
12243
  // Focus not born from a touch (hardware keyboard, programmatic) raises
9223
12244
  // no on-screen keyboard — never estimate for it.
@@ -9257,7 +12278,7 @@ function installKeyboardAvoid(ctx) {
9257
12278
  // Keyboard is up (touch focus on a phone) yet the viewport never
9258
12279
  // reacted: the browser cannot see it. Estimate until blur, and
9259
12280
  // remember the verdict so the next focus skips the wait.
9260
- if (!composerTextarea(document.activeElement))
12281
+ if (!composerField(document.activeElement))
9261
12282
  return;
9262
12283
  localStorage.setItem(DUMB_KEY, '1');
9263
12284
  estimate = estimatedLift(window.innerHeight, tuning);
@@ -9266,7 +12287,7 @@ function installKeyboardAvoid(ctx) {
9266
12287
  probeTimer = window.setTimeout(step, PROBE_INTERVAL_MS);
9267
12288
  };
9268
12289
  const onFocusOut = (event) => {
9269
- if (!composerTextarea(event.target))
12290
+ if (!composerField(event.target))
9270
12291
  return;
9271
12292
  stopProbe();
9272
12293
  if (estimate === 0)
@@ -9317,6 +12338,288 @@ function installKeyboardAvoid(ctx) {
9317
12338
  }, 'dsh-mobile-nav: composer keyboard avoid');
9318
12339
  }
9319
12340
  };
12341
+ __modules["share/share-preview.js"] = function (require, module, exports) {
12342
+ "use strict";
12343
+ Object.defineProperty(exports, "__esModule", { value: true });
12344
+ exports.SharePreview = SharePreview;
12345
+ const jsx_runtime_1 = require("react/jsx-runtime");
12346
+ /**
12347
+ * Debug preview for the share-card template (ticket 03) + PNG export entry
12348
+ * (ticket 04, single long image since ticket 07): ?mobile-nav-share-preview=1
12349
+ * renders the card at the top of the page so the layout can be eyeballed at
12350
+ * any viewport, and the caption bar's 导出 PNG button drives the full
12351
+ * slice→rasterize→stitch pipeline, downloading ONE stitched PNG — the
12352
+ * acceptance path before ticket 05 wires the real UI.
12353
+ *
12354
+ * Debug-surface conventions follow debug.ts: the entry only mounts with the
12355
+ * URL param (the slot entry is not even registered without it — zero impact
12356
+ * otherwise, the same exemption ?mobile-nav-inset= enjoys), and its chrome
12357
+ * copy is hardcoded Chinese rather than locale keys, because it never ships
12358
+ * to end users. Only the card's own baked-in strings go through `t`.
12359
+ *
12360
+ * Data comes through fetch-share.ts (ticket 05) — the same typed fetch,
12361
+ * query builder and error taxonomy the real share flow uses; only the error
12362
+ * SURFACE differs (this panel folds any failure back to the fixture and
12363
+ * prints the code, the real UI maps codes to locale copy).
12364
+ */
12365
+ const react_1 = require("react");
12366
+ const types_ts_1 = require("./compat/types.js");
12367
+ const fetch_share_ts_1 = require("./share/fetch-share.js");
12368
+ const fetch_share_ts_2 = require("./share/fetch-share.js");
12369
+ const png_stitch_ts_1 = require("./share/png-stitch.js");
12370
+ const rasterize_ts_1 = require("./share/rasterize.js");
12371
+ const share_card_tsx_1 = require("./share/share-card.js");
12372
+ const share_flow_ts_1 = require("./share/share-flow.js");
12373
+ /* ---- fixture ---------------------------------------------------------------
12374
+ * Exercises every block kind the template knows, and since ticket 09 every
12375
+ * Markdown construct the assistant path renders: headings, inline emphasis /
12376
+ * code / links / escapes, fenced code with a language tag and a deliberately
12377
+ * over-long line (wide-content acceptance), an aligned pipe table, block
12378
+ * quote, ordered + nested unordered lists, thematic break, indented code, a
12379
+ * Markdown image line, and image placeholders. Fixed timestamp so the header
12380
+ * is deterministic between reloads. */
12381
+ const FIXTURE_CREATED_AT = Date.UTC(2026, 8, 10, 4, 0); // 2026-09-10 12:00 +08:00
12382
+ const FIXTURE_TITLE = '示例会话 · 网关错误率报表';
12383
+ const FIXTURE_TURNS = [
12384
+ {
12385
+ role: 'user',
12386
+ seq: 1,
12387
+ blocks: [{ kind: 'text', text: '帮我把这份原始日志清洗成表格,算出每个服务的错误率,再给一个可以复用的脚本。' }],
12388
+ },
12389
+ {
12390
+ role: 'assistant',
12391
+ seq: 2,
12392
+ blocks: [
12393
+ {
12394
+ kind: 'text',
12395
+ text: [
12396
+ '清洗结果如下(错误率 = 错误 / 请求),口径见 [统计说明](#stats):',
12397
+ '',
12398
+ '## 服务错误率',
12399
+ '',
12400
+ '| 服务 | 请求 | 错误 | 错误率 | 备注 |',
12401
+ '| :--- | ---: | ---: | :---: | --- |',
12402
+ '| lan-gate | 12040 | 36 | 0.30% | 配对墙拦截计入 |',
12403
+ '| api-gateway | 98213 | 121 | 0.12% | 5xx 全量 |',
12404
+ '| web-ui-static | 330912 | 2 | 0.00% | CDN 回源 |',
12405
+ '',
12406
+ '复用脚本(长行不裁剪,超出卡片宽是预期行为,切片宽度自适应交给栅格化):',
12407
+ '',
12408
+ '```python',
12409
+ 'def error_rate(total_requests: int, errors: int) -> float:',
12410
+ ' return errors / total_requests if total_requests else 0.0',
12411
+ 'print(f"{error_rate(98213, 121):.2%}") # -> 0.12%',
12412
+ '```',
12413
+ '',
12414
+ '### 排版要点',
12415
+ '',
12416
+ '行内支持 **粗体**、*斜体*、***粗斜体***、~~删除线~~、`行内代码`、[链接着色](https://example.com/docs) 与转义 \\*字面星号\\*。',
12417
+ '',
12418
+ '无序清单(两级缩进):',
12419
+ '',
12420
+ '- 只取近 24 小时的聚合数据',
12421
+ ' - 请求量不足 1000 的服务不参与排名',
12422
+ ' - 按错误率降序输出',
12423
+ '- 长行项目照常参与宽度测量',
12424
+ '',
12425
+ '有序步骤:',
12426
+ '',
12427
+ '1. 拉取聚合指标',
12428
+ '2. 计算错误率',
12429
+ ' 1. 过滤零流量服务',
12430
+ ' 2. 保留两位小数',
12431
+ '3. 输出报表',
12432
+ '',
12433
+ '> 引用块:以上口径与 [监控面板](https://example.com/monitor) 一致,',
12434
+ '> 以 5 分钟粒度聚合,空窗期按 0 处理。',
12435
+ '',
12436
+ '---',
12437
+ '',
12438
+ '另外附一个历史口径的配置片段(缩进代码):',
12439
+ '',
12440
+ ' window_seconds = 86400',
12441
+ ' min_requests = 1000',
12442
+ '',
12443
+ '架构示意图:',
12444
+ '',
12445
+ '![部署架构图](https://example.com/arch.png)',
12446
+ ].join('\n'),
12447
+ },
12448
+ ],
12449
+ },
12450
+ {
12451
+ role: 'user',
12452
+ seq: 3,
12453
+ blocks: [
12454
+ { kind: 'text', text: '很好。再把现在的部署架构画一张图给我。' },
12455
+ { kind: 'image' },
12456
+ ],
12457
+ },
12458
+ {
12459
+ role: 'assistant',
12460
+ seq: 4,
12461
+ blocks: [
12462
+ { kind: 'text', text: '架构图放在这里了(分享图 v1 中图片附件以占位呈现):' },
12463
+ { kind: 'image' },
12464
+ { kind: 'text', text: '需要我把表格导出成 CSV 的话说一声。' },
12465
+ ],
12466
+ },
12467
+ ];
12468
+ const FIXTURE_STATE = {
12469
+ source: 'fixture',
12470
+ turns: FIXTURE_TURNS,
12471
+ createdAt: FIXTURE_CREATED_AT,
12472
+ error: null,
12473
+ };
12474
+ /**
12475
+ * Share-card preview panel. Fixed at the top of the overlay layer, scrollable
12476
+ * (long transcripts must be inspectable), all-inline-styled like the card —
12477
+ * this feature adds no page CSS by design (PLAN §9.4).
12478
+ */
12479
+ function SharePreview({ useSessions, t }) {
12480
+ const [gone, setGone] = (0, react_1.useState)(false);
12481
+ const [state, setState] = (0, react_1.useState)(FIXTURE_STATE);
12482
+ const [exportBusy, setExportBusy] = (0, react_1.useState)(null);
12483
+ const [exportNote, setExportNote] = (0, react_1.useState)(null);
12484
+ const cardHostRef = (0, react_1.useRef)(null);
12485
+ const currentId = useSessions((s) => s.current);
12486
+ const row = useSessions((s) => (s.current === undefined ? undefined : s.byId[s.current]));
12487
+ // Try the real route whenever a session is open; any failure (route not
12488
+ // mounted on this host, network, bad body) silently reverts to the fixture
12489
+ // — the preview must always render something. The fetch itself is the
12490
+ // production one (fetch-share.ts), so this exercises the exact query and
12491
+ // validation the real share flow sends.
12492
+ (0, react_1.useEffect)(() => {
12493
+ if (currentId === undefined) {
12494
+ setState(FIXTURE_STATE);
12495
+ return;
12496
+ }
12497
+ let cancelled = false;
12498
+ (0, fetch_share_ts_1.fetchShareExport)(currentId, { kind: 'all' })
12499
+ .then((data) => {
12500
+ if (cancelled)
12501
+ return;
12502
+ setState({ source: 'live', turns: data.turns, createdAt: data.createdAt, error: null });
12503
+ })
12504
+ .catch((err) => {
12505
+ if (cancelled)
12506
+ return;
12507
+ // Debug affordance: the typed code (+HTTP status) says WHICH path
12508
+ // failed; the real UI maps these codes to locale copy instead.
12509
+ const detail = err instanceof fetch_share_ts_2.ShareFetchError
12510
+ ? `${err.code}${err.status === undefined ? '' : ` (HTTP ${err.status})`}`
12511
+ : err instanceof Error ? err.message : String(err);
12512
+ setState({ ...FIXTURE_STATE, error: detail });
12513
+ });
12514
+ return () => {
12515
+ cancelled = true;
12516
+ };
12517
+ }, [currentId]);
12518
+ if (gone)
12519
+ return null;
12520
+ // Full pipeline on the live card element: probe → measure → plan (unified
12521
+ // when the browser can stitch) → paint → stitch, then a single long-image
12522
+ // download (ticket 07 — the caption notes the final dimensions); browsers
12523
+ // without CompressionStream keep the per-slice downloads.
12524
+ const runExport = async () => {
12525
+ const card = cardHostRef.current?.querySelector('[data-share-card]') ?? null;
12526
+ if (card === null) {
12527
+ setExportNote('导出失败:未找到分享卡元素');
12528
+ return;
12529
+ }
12530
+ setExportBusy('0/?');
12531
+ setExportNote(null);
12532
+ try {
12533
+ const stitch = (0, png_stitch_ts_1.supportsPngStitch)();
12534
+ const result = await (0, rasterize_ts_1.rasterizeShareCard)(card, {
12535
+ pixelRatioCap: Math.min(window.devicePixelRatio || 1, 2),
12536
+ truncatedNote: (droppedTurns) => t('shareTruncatedNote', { count: droppedTurns }),
12537
+ onProgress: (done, total) => setExportBusy(`${done}/${total}`),
12538
+ unified: stitch,
12539
+ });
12540
+ const tail = result.truncated ? `,已省略前 ${result.droppedTurns} 轮` : '';
12541
+ if (stitch && result.stitchWidth !== undefined) {
12542
+ const png = await (0, png_stitch_ts_1.stitchSliceBlobs)(result.slices, result.stitchWidth);
12543
+ const height = result.slices.reduce((sum, slice) => sum + slice.height, 0);
12544
+ await (0, share_flow_ts_1.downloadBlobSequence)([{ blob: png, name: 'share-card.png' }]);
12545
+ setExportNote(`已导出长图 ${result.stitchWidth}×${height}(${result.slices.length} 片拼接,像素比 ${result.pixelRatio}${tail})`);
12546
+ }
12547
+ else {
12548
+ // The same paced anchor-download sequence the real delivery falls
12549
+ // back to (review 07+08 cleanup — this was a third inline copy).
12550
+ await (0, share_flow_ts_1.downloadBlobSequence)(result.slices.map((slice, index) => ({ blob: slice.blob, name: (0, share_flow_ts_1.shareSliceFileName)('share-card', index) })));
12551
+ setExportNote(`已导出 ${result.slices.length} 张分片(浏览器不支持拼接,像素比 ${result.pixelRatio}${tail})`);
12552
+ }
12553
+ console.info('[share-preview] rasterized', result);
12554
+ }
12555
+ catch (err) {
12556
+ console.error('[share-preview] PNG export failed', err);
12557
+ setExportNote(`导出失败:${err instanceof Error ? err.message : String(err)}`);
12558
+ }
12559
+ finally {
12560
+ setExportBusy(null);
12561
+ }
12562
+ };
12563
+ const copy = {
12564
+ turnsLabel: (count) => t('shareCardTurns', { count }),
12565
+ generatedBy: t('shareCardGeneratedBy'),
12566
+ image: t('shareCardImage'),
12567
+ };
12568
+ const preset = (0, types_ts_1.agentPresetOf)(row);
12569
+ return ((0, jsx_runtime_1.jsxs)("div", { style: PANEL_STYLE, children: [(0, jsx_runtime_1.jsxs)("div", { style: CAPTION_STYLE, children: [(0, jsx_runtime_1.jsxs)("span", { children: ["\u5206\u4EAB\u5361\u9884\u89C8\uFF08?mobile-nav-share-preview=1\uFF09 \u00B7 ", state.source === 'live' ? '会话数据' : 'fixture 数据', state.error !== null ? ` · 拉取失败:${state.error}` : ''] }), (0, jsx_runtime_1.jsx)("button", { type: "button", style: EXPORT_STYLE, disabled: exportBusy !== null, onClick: () => { void runExport(); }, children: exportBusy !== null ? `导出中 ${exportBusy}` : '导出 PNG' }), exportNote !== null
12570
+ ? (0, jsx_runtime_1.jsx)("span", { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, children: exportNote })
12571
+ : null, (0, jsx_runtime_1.jsx)("button", { type: "button", style: CLOSE_STYLE, onClick: () => setGone(true), children: "\u2715" })] }), (0, jsx_runtime_1.jsx)("div", { ref: cardHostRef, style: { padding: '16px', display: 'flex', justifyContent: 'center', minHeight: '120px' }, children: (0, jsx_runtime_1.jsx)(share_card_tsx_1.ShareCard, { title: state.source === 'live' ? (row?.displayTitle ?? FIXTURE_TITLE) : FIXTURE_TITLE, ...(preset !== undefined ? { subtitle: preset } : {}), ...(state.createdAt !== undefined ? { createdAt: state.createdAt } : {}), turns: state.turns, copy: copy }) })] }));
12572
+ }
12573
+ /* Debug chrome styles (inline like everything else in this feature). */
12574
+ const PANEL_STYLE = {
12575
+ position: 'absolute',
12576
+ top: 0,
12577
+ left: 0,
12578
+ right: 0,
12579
+ maxHeight: '100%',
12580
+ overflow: 'auto',
12581
+ zIndex: 30,
12582
+ // The shell overlay layer is pointer-events:none with pointer-events:auto
12583
+ // restored on children; explicit keeps this panel usable regardless.
12584
+ pointerEvents: 'auto',
12585
+ background: 'rgba(20, 22, 26, 0.08)',
12586
+ };
12587
+ const CAPTION_STYLE = {
12588
+ position: 'sticky',
12589
+ top: 0,
12590
+ zIndex: 1,
12591
+ display: 'flex',
12592
+ alignItems: 'center',
12593
+ gap: '8px',
12594
+ padding: '6px 12px',
12595
+ background: 'rgba(15, 17, 21, 0.82)',
12596
+ color: '#fff',
12597
+ font: '12px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace',
12598
+ };
12599
+ const CLOSE_STYLE = {
12600
+ marginLeft: '8px',
12601
+ border: 'none',
12602
+ borderRadius: '6px',
12603
+ background: 'rgba(255, 255, 255, 0.14)',
12604
+ color: '#fff',
12605
+ font: 'inherit',
12606
+ lineHeight: 1,
12607
+ padding: '4px 8px',
12608
+ cursor: 'pointer',
12609
+ };
12610
+ const EXPORT_STYLE = {
12611
+ marginLeft: 'auto',
12612
+ border: 'none',
12613
+ borderRadius: '6px',
12614
+ background: 'rgba(255, 255, 255, 0.22)',
12615
+ color: '#fff',
12616
+ font: 'inherit',
12617
+ lineHeight: 1,
12618
+ padding: '4px 8px',
12619
+ cursor: 'pointer',
12620
+ whiteSpace: 'nowrap',
12621
+ };
12622
+ };
9320
12623
  __modules["index.js"] = function (require, module, exports) {
9321
12624
  "use strict";
9322
12625
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -9345,6 +12648,7 @@ const native_trigger_overlay_ts_1 = require("./effects/native-trigger-overlay.js
9345
12648
  const welcome_notice_ts_1 = require("./effects/welcome-notice.js");
9346
12649
  const keyboard_guard_ts_1 = require("./effects/keyboard-guard.js");
9347
12650
  const keyboard_avoid_ts_1 = require("./effects/keyboard-avoid.js");
12651
+ const share_preview_tsx_1 = require("./share/share-preview.js");
9348
12652
  const locales_ts_1 = require("./locales.js");
9349
12653
  /** Required services (cordis fiber inject — the loader passes all module exports as an object plugin). */
9350
12654
  exports.inject = ['slots', 'layout', 'locale', 'sessionLogDownload', 'sessions', 'workspaces'];
@@ -9562,6 +12866,20 @@ function apply(ctx) {
9562
12866
  toggleSidebar: () => ctx.layout.toggleSidebar(),
9563
12867
  }),
9564
12868
  }, MobileDrawerFooter_tsx_1.MobileDrawerFooter));
12869
+ // Share-card debug preview (ticket 03): renders the share-card template at
12870
+ // the top of the page so its layout is inspectable before the rasterizer
12871
+ // (ticket 04) exists. Registered ONLY with the URL param, so the no-param
12872
+ // path — including desktop — stays bit-for-bit untouched (same debug-param
12873
+ // exemption as ?mobile-nav-inset=, see debug.ts).
12874
+ if (new URLSearchParams(location.search).has('mobile-nav-share-preview')) {
12875
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
12876
+ name: 'shell.overlay',
12877
+ id: 'share-preview',
12878
+ // Above the phone home screen (order 20) within the same overlay layer.
12879
+ order: 30,
12880
+ locale: locales_ts_1.NS,
12881
+ }, share_preview_tsx_1.SharePreview));
12882
+ }
9565
12883
  }
9566
12884
  };
9567
12885
  var __cache = {};