dsh-zen-remote 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/AGENTS.md +51 -0
  2. package/LICENSE +38 -0
  3. package/README.md +237 -0
  4. package/assets/home.png +0 -0
  5. package/assets/info.png +0 -0
  6. package/assets/pairing.png +0 -0
  7. package/assets/session.png +0 -0
  8. package/assets/sheet.png +0 -0
  9. package/cordis.patch.yml +17 -0
  10. package/cordis.patch.yml.example +16 -0
  11. package/docs/interface.md +200 -0
  12. package/docs/remote-access.en.md +335 -0
  13. package/docs/remote-access.md +422 -0
  14. package/dsh-push.mjs +213 -0
  15. package/lan-gate.mjs +78 -0
  16. package/lib/client.js +6615 -0
  17. package/lib/index.js +310 -0
  18. package/lib/index.js.map +1 -0
  19. package/lib/lan-gate-server.cjs +626 -0
  20. package/lib/types/client/MobileAttachButton.d.ts +25 -0
  21. package/lib/types/client/MobileAttachButton.d.ts.map +1 -0
  22. package/lib/types/client/MobileAttachChips.d.ts +32 -0
  23. package/lib/types/client/MobileAttachChips.d.ts.map +1 -0
  24. package/lib/types/client/MobileDrawerFooter.d.ts +21 -0
  25. package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -0
  26. package/lib/types/client/MobileHome.d.ts +24 -0
  27. package/lib/types/client/MobileHome.d.ts.map +1 -0
  28. package/lib/types/client/MobileHomeChips.d.ts +76 -0
  29. package/lib/types/client/MobileHomeChips.d.ts.map +1 -0
  30. package/lib/types/client/MobileNavOverlay.d.ts +15 -0
  31. package/lib/types/client/MobileNavOverlay.d.ts.map +1 -0
  32. package/lib/types/client/MobileNavToggle.d.ts +18 -0
  33. package/lib/types/client/MobileNavToggle.d.ts.map +1 -0
  34. package/lib/types/client/MobileSessionHeader.d.ts +58 -0
  35. package/lib/types/client/MobileSessionHeader.d.ts.map +1 -0
  36. package/lib/types/client/MobileSessionInfo.d.ts +41 -0
  37. package/lib/types/client/MobileSessionInfo.d.ts.map +1 -0
  38. package/lib/types/client/attach-upload.d.ts +78 -0
  39. package/lib/types/client/attach-upload.d.ts.map +1 -0
  40. package/lib/types/client/chips-store.d.ts +13 -0
  41. package/lib/types/client/chips-store.d.ts.map +1 -0
  42. package/lib/types/client/debug.d.ts +9 -0
  43. package/lib/types/client/debug.d.ts.map +1 -0
  44. package/lib/types/client/effects/aionui-compat.d.ts +4 -0
  45. package/lib/types/client/effects/aionui-compat.d.ts.map +1 -0
  46. package/lib/types/client/effects/gestures.d.ts +7 -0
  47. package/lib/types/client/effects/gestures.d.ts.map +1 -0
  48. package/lib/types/client/effects/header-status.d.ts +15 -0
  49. package/lib/types/client/effects/header-status.d.ts.map +1 -0
  50. package/lib/types/client/effects/phone-chrome.d.ts +106 -0
  51. package/lib/types/client/effects/phone-chrome.d.ts.map +1 -0
  52. package/lib/types/client/effects/turn-fold.d.ts +25 -0
  53. package/lib/types/client/effects/turn-fold.d.ts.map +1 -0
  54. package/lib/types/client/index.d.ts +18 -0
  55. package/lib/types/client/index.d.ts.map +1 -0
  56. package/lib/types/client/locales.d.ts +58 -0
  57. package/lib/types/client/locales.d.ts.map +1 -0
  58. package/lib/types/client/nav-store.d.ts +73 -0
  59. package/lib/types/client/nav-store.d.ts.map +1 -0
  60. package/lib/types/client/session-dot.d.ts +12 -0
  61. package/lib/types/client/session-dot.d.ts.map +1 -0
  62. package/lib/types/client/styles/base.css.d.ts +2 -0
  63. package/lib/types/client/styles/base.css.d.ts.map +1 -0
  64. package/lib/types/client/styles/chips.css.d.ts +2 -0
  65. package/lib/types/client/styles/chips.css.d.ts.map +1 -0
  66. package/lib/types/client/styles/compat.css.d.ts +2 -0
  67. package/lib/types/client/styles/compat.css.d.ts.map +1 -0
  68. package/lib/types/client/styles/composer.css.d.ts +2 -0
  69. package/lib/types/client/styles/composer.css.d.ts.map +1 -0
  70. package/lib/types/client/styles/header.css.d.ts +2 -0
  71. package/lib/types/client/styles/header.css.d.ts.map +1 -0
  72. package/lib/types/client/styles/home.css.d.ts +2 -0
  73. package/lib/types/client/styles/home.css.d.ts.map +1 -0
  74. package/lib/types/client/styles/index.d.ts +15 -0
  75. package/lib/types/client/styles/index.d.ts.map +1 -0
  76. package/lib/types/client/styles/info.css.d.ts +2 -0
  77. package/lib/types/client/styles/info.css.d.ts.map +1 -0
  78. package/lib/types/client/styles/layout.css.d.ts +2 -0
  79. package/lib/types/client/styles/layout.css.d.ts.map +1 -0
  80. package/lib/types/client/styles/misc.css.d.ts +2 -0
  81. package/lib/types/client/styles/misc.css.d.ts.map +1 -0
  82. package/lib/types/client/styles/turn-fold.css.d.ts +2 -0
  83. package/lib/types/client/styles/turn-fold.css.d.ts.map +1 -0
  84. package/lib/types/index.d.ts +83 -0
  85. package/lib/types/index.d.ts.map +1 -0
  86. package/llms.txt +20 -0
  87. package/package.json +128 -0
  88. package/pwa/app.css +95 -0
  89. package/pwa/icons/icon-192.png +0 -0
  90. package/pwa/icons/icon-512.png +0 -0
  91. package/pwa/icons/icon-maskable-512.png +0 -0
  92. package/pwa/icons/icon-maskable.svg +1 -0
  93. package/pwa/icons/icon.svg +1 -0
  94. package/pwa/inject.js +190 -0
  95. package/pwa/manifest.json +29 -0
  96. package/pwa/offline.html +38 -0
  97. package/pwa/sw.js +176 -0
  98. package/pwa/touch-gestures.js +94 -0
  99. package/scripts/build-client.mjs +101 -0
  100. package/scripts/cdp-probe.mjs +64 -0
  101. package/scripts/check-attach-upload.mjs +99 -0
  102. package/scripts/check-sunk-viewport.mjs +33 -0
  103. package/scripts/check-upload-endpoint.mjs +168 -0
  104. package/src/client/MobileAttachButton.tsx +125 -0
  105. package/src/client/MobileAttachChips.tsx +85 -0
  106. package/src/client/MobileDrawerFooter.tsx +56 -0
  107. package/src/client/MobileHome.tsx +348 -0
  108. package/src/client/MobileHomeChips.tsx +426 -0
  109. package/src/client/MobileNavOverlay.tsx +279 -0
  110. package/src/client/MobileNavToggle.tsx +52 -0
  111. package/src/client/MobileSessionHeader.tsx +242 -0
  112. package/src/client/MobileSessionInfo.tsx +303 -0
  113. package/src/client/attach-upload.ts +140 -0
  114. package/src/client/chips-store.ts +41 -0
  115. package/src/client/debug.ts +253 -0
  116. package/src/client/effects/aionui-compat.ts +183 -0
  117. package/src/client/effects/gestures.ts +265 -0
  118. package/src/client/effects/header-status.ts +30 -0
  119. package/src/client/effects/phone-chrome.ts +265 -0
  120. package/src/client/effects/turn-fold.ts +258 -0
  121. package/src/client/index.tsx +227 -0
  122. package/src/client/locales.ts +109 -0
  123. package/src/client/nav-store.ts +78 -0
  124. package/src/client/session-dot.ts +17 -0
  125. package/src/client/styles/base.css.ts +167 -0
  126. package/src/client/styles/chips.css.ts +224 -0
  127. package/src/client/styles/compat.css.ts +681 -0
  128. package/src/client/styles/composer.css.ts +504 -0
  129. package/src/client/styles/header.css.ts +368 -0
  130. package/src/client/styles/home.css.ts +477 -0
  131. package/src/client/styles/index.ts +25 -0
  132. package/src/client/styles/info.css.ts +238 -0
  133. package/src/client/styles/layout.css.ts +501 -0
  134. package/src/client/styles/misc.css.ts +128 -0
  135. package/src/client/styles/turn-fold.css.ts +106 -0
  136. package/src/index.ts +343 -0
@@ -0,0 +1,2 @@
1
+ export declare const LAYOUT_CSS = "/* ---------- mobile-only layout ---------- */\n\n@media (max-width: 1023px) {\n /* --- Phone chrome ---\n The system status bar stays visible (no fullscreen). Two adjustments\n make it behave:\n - touch-action: manipulation kills double-tap-to-zoom (and the 300ms\n tap delay) while keeping pan and pinch zoom; the client also\n suppresses legacy-iOS gesturestart as a fallback.\n - With the client's viewport-fit=cover, env(safe-area-inset-top) is the\n status bar / notch height; the rules below push the app content below\n it so the status bar never covers anything. Off notched phones (or in\n a normal browser tab where the layout viewport already sits below the\n status bar) the inset is 0 and nothing shifts. */\n html,\n body {\n touch-action: manipulation !important;\n }\n\n /* AppFrame: the drawer takes the sidebar column out of grid flow, so the\n remaining in-flow items (center, details) land in tracks 1..2: give the\n center every pixel and keep the details track at zero. The top padding\n clears the status bar / notch for every in-flow surface (session header,\n messages, composer); the absolutely-positioned drawer is unaffected (its\n containing block is the frame's padding box, i.e. still the frame top). */\n /* box-sizing: border-box is load-bearing (S2.1, 2026-08-17). The official\n frame carries height: 100% with the default content-box, so the\n safe-area padding ADDED 54px to its height instead of taking 54px out\n of the content area: the frame grew past the viewport, the document\n became scrollable by exactly the inset, and the very first scroll put\n the header right back under the notch \u2014 measured with\n ?mobile-nav-inset=54 (frame 898px tall inside an 844px root, html at\n y=-54). Invisible on every desktop browser because the inset is 0\n there, which is how it shipped. */\n [data-mobile-nav=\"frame\"] {\n position: relative !important;\n box-sizing: border-box !important;\n grid-template-columns: minmax(0, 1fr) 0 0 !important;\n padding-top: var(--mnav-sat) !important;\n }\n\n /* The sidebar column (first grid child) becomes a left drawer. The drawer\n hugs the sidebar content exactly (the wide sidebar carries an inline\n width, ~280px): a fixed 92vw box would leave a white strip where the\n container background shows beside the content.\n Closed state: translateX(-110%) \u2014 more than -100% of the max-content\n width \u2014 guarantees the whole drawer (and its shadow, had it one) leaves\n the viewport. A mere -100% leaves a sliver on screen; -105% (as used\n before) left 14px of the drawer plus a long 32px-blur shadow gradient\n visible along the left edge of the main UI. No box-shadow at all: the\n dimmed backdrop already separates drawer from content. */\n [data-mobile-nav=\"frame\"] > :first-child {\n position: absolute !important;\n inset: 0 auto 0 0 !important;\n width: max-content !important;\n max-width: 92vw !important;\n z-index: 40 !important;\n transform: translateX(-110%);\n transition: transform .28s var(--ds-ease-in-out, ease-in-out);\n background: var(--dsw-alias-bg-base, #ffffff);\n /* Keep the drawer's own content below the status bar / notch: the drawer\n spans the full frame height (its absolute containing block is the\n frame's padding box, so the frame's own safe-area padding does NOT\n reach it). The drawer background paints the status-bar strip, which\n the client's theme-color meta matches, so the strip reads seamless. */\n padding-top: var(--mnav-sat) !important;\n /* Kill the official sidebarCol right border: with the backdrop the edge\n reads cleanly, and the settings dialog (width:100% of this box) stays\n pixel-flush with the drawer. */\n border-right: none !important;\n }\n\n /* Expanded state (frame without data-sidebar-collapsed) slides the drawer in.\n The open state must be transform:none \u2014 NOT translateX(0): an identity\n transform still makes the drawer the containing block for fixed-position\n descendants (the settings dialog's .VOzbGW_overlay is portaled into the\n sidebar DOM). With the identity transform the wide settings sheet\n (100vw-16) overflows the 280px drawer, the dialog's focus scrolls the\n overflow:hidden drawer to scrollLeft=102, and every static child (plus the\n fixed overlay) shifts 102px off-screen. With transform:none the overlay is\n viewport-anchored: it dims the full screen and the sheet sits at left:8. */\n [data-mobile-nav=\"frame\"]:not([data-sidebar-collapsed]) > :first-child {\n transform: none !important;\n }\n\n /* Drag handles are useless on touch and would float over the drawer. */\n [data-side=\"sidebar\"],\n [data-side=\"details\"] {\n display: none !important;\n }\n\n /* --- Conversation text on mobile ---\n The official message flow keeps desktop's 32px side gutters and 16px\n type. On a phone: shrink the type a notch and widen the lines by\n trimming the gutters (the sidebar drawer list keeps its size). The\n flow's scroll container is the only _scroll element holding markdown\n <p> paragraphs \u2014 the composer's own scroll (textarea) is excluded\n via :has(p). */\n /* The official main scroll body reserves scrollbar-gutter for desktop\n scrollbars (8px), which shoves every column off-center on a phone.\n Classic desktop scrollbars (Edge/Chrome) also occupy ~8-17px in a\n phone-sized viewport, shifting the column further. Mobile scrolling\n is touch/wheel, so remove the scrollbar entirely on phones: the\n column is then exactly centered in every browser. */\n [data-phase] [class$=\"_scrollBody\"] {\n scrollbar-gutter: auto !important;\n scrollbar-width: none !important;\n }\n [data-phase] [class$=\"_scrollBody\"]::-webkit-scrollbar {\n display: none !important;\n width: 0 !important;\n height: 0 !important;\n }\n /* Message action rows (copy / run-time badges) can overflow the right\n edge on narrow screens \u2014 keep them inside the message width. */\n [data-phase] [class$=\"_actions\"] {\n overflow: hidden !important;\n }\n [data-phase] [class$=\"_actions\"] [class$=\"_timeEnd\"] {\n flex: 0 1 auto !important;\n min-width: 0 !important;\n overflow: hidden !important;\n text-overflow: ellipsis !important;\n white-space: nowrap !important;\n }\n\n [data-phase] [class$=\"_scroll\"]:has(p) {\n padding-left: 20px !important;\n padding-right: 20px !important;\n font-size: 15px !important;\n }\n /* The official markdown styles set an explicit 16px on paragraphs and\n list items, so the container's inherited 15px is not enough. User\n messages render their text in a div whose class carries _text_\n (16px too) \u2014 cover it as well. */\n [data-phase] [class$=\"_scroll\"]:has(p) p,\n [data-phase] [class$=\"_scroll\"]:has(p) li,\n [data-phase] [class$=\"_scroll\"]:has(p) [class*=\"_text_\"] {\n font-size: 15px !important;\n }\n\n /* Markdown tables: the official table uses width:max-content, so on a phone\n it hugs the content and leaves dead space beside/inside the table. Force\n the table to fill the message column and let the table wrapper handle\n overflow if a cell is genuinely too wide. */\n [data-phase] table {\n width: 100% !important;\n max-width: 100% !important;\n }\n [data-phase] th,\n [data-phase] td {\n max-width: none !important;\n min-width: 0 !important;\n }\n\n /* User bubbles: the official stack is capped at min(525px, 82%), which on a\n phone leaves a large blank strip on the left and pushes the bubble high.\n On mobile let the user message fill the same full width as assistant\n messages (the bubble background then spans the whole message column). */\n [data-phase] [class$=\"_userStack\"],\n [data-phase] [class$=\"_userStack\"] [class$=\"_bubble\"] {\n box-sizing: border-box !important;\n width: fit-content !important;\n max-width: 100% !important;\n }\n\n /* --- Composer bottom row on mobile ---\n The official row gives the model pill (trailing) flex:0 0 auto, which\n squeezes the agent-permission pill (modes) down to 15px: the pill's\n chevron then overflows on top of the model name. Let the permission\n pill keep its natural width and let the model pill shrink instead.\n Anchored by the composer card (:has(textarea)): row = the _row class\n containing a _trailing group, tools = its first child, permission pill\n = its 2nd child, model pill = the _trailing group.\n NOTE: do NOT anchor these by the card's :last-child \u2014 a client effect\n moves the git-graph branch chip INTO the card, which becomes the card's\n new last child and silently disables every :last-child rule (the model\n pill then falls back to the official fixed layout and long model IDs\n overflow instead of ellipsizing). Structural anchors only. */\n /* The official row pads every group with 12px gaps, which on a phone eats\n width the model pill needs for the full model ID. Tighten the row to 8px\n gaps (the modes pill keeps its natural width) so the model name gets\n every spare pixel. */\n [data-phase] [class*=\"_card\"]:has(textarea) [class$=\"_row\"]:has([class$=\"_trailing\"]) {\n gap: 8px !important;\n }\n [data-phase] [class*=\"_card\"]:has(textarea) [class$=\"_row\"]:has([class$=\"_trailing\"]) > :first-child {\n gap: 8px !important;\n }\n [data-phase] [class*=\"_card\"]:has(textarea) [class$=\"_row\"]:has([class$=\"_trailing\"]) > :first-child > :nth-child(2) {\n flex: 0 0 auto !important;\n gap: 8px !important;\n }\n [data-phase] [class*=\"_card\"]:has(textarea) [class$=\"_trailing\"] {\n flex: 1 1 auto !important;\n gap: 8px !important;\n min-width: 0 !important;\n }\n /* Let the model selector take the free space in the trailing group so the\n model name is not squeezed/truncated; the context meter stays fixed. */\n [data-phase] [class*=\"_card\"]:has(textarea) [class$=\"_root\"]:has(> [class$=\"_trigger\"][aria-haspopup=\"menu\"]) {\n flex: 1 1 auto !important;\n min-width: 0 !important;\n }\n [data-phase] [class*=\"_card\"]:has(textarea) [class$=\"_root\"]:has(> [class$=\"_trigger\"][aria-haspopup=\"menu\"]) > [class$=\"_trigger\"] {\n width: 100% !important;\n max-width: 100% !important;\n }\n /* The model label must absorb the trigger's free width: officially it is a\n fixed-content flex item, so the spare width of a grown pill sits unused\n between the chevron and the pill edge. Let the label grow and shrink:\n the full model ID shows whenever the row can fit it, and the ellipsis\n lands exactly at the available width otherwise. */\n [data-phase] [class*=\"_card\"]:has(textarea) [class$=\"_root\"]:has(> [class$=\"_trigger\"][aria-haspopup=\"menu\"]) > [class$=\"_trigger\"] > [class$=\"_triggerLabel\"] {\n flex: 1 1 auto !important;\n min-width: 0 !important;\n }\n [data-phase] [class*=\"_card\"]:has(textarea) [class$=\"_root\"]:has(> [class$=\"_trigger\"]:not([aria-haspopup=\"menu\"])) {\n flex: 0 0 auto !important;\n }\n\n /* Model switcher menu: the official dropdown is right-aligned to the\n trigger (right:0). The model pill sits near the center of the composer,\n so on a phone the 240px menu overflows the left edge and looks off-center.\n Center the menu on the trigger instead. */\n [data-phase] [class*=\"_card\"]:has(textarea) [class$=\"_root\"]:has(> [class$=\"_trigger\"]) > [class$=\"_menu\"] {\n left: 50% !important;\n right: auto !important;\n transform: translateX(-50%) !important;\n }\n\n /* --- Session header on mobile ---\n Layout goal: [toggle] [session title] [mode badge] in a row, with the\n Session log capsule removed from the header (relocated to the drawer\n footer). Stable structural hooks only:\n [data-phase] header the session header element\n header > :first-child titleRow (titleCluster + utilities)\n header > :first-child > :last-child headerUtilities (Session log seat) */\n [data-phase] header {\n padding-right: 12px !important;\n }\n /* Give the title row a lane clear of the absolutely-placed toggle, then\n balance the header: with header padding-right 12px, a 20px left\n padding puts the title's geometric center exactly on the viewport\n center (measured 195/195 at 390px). */\n [data-phase] header > :first-child {\n padding-left: 20px !important;\n }\n /* The directory toggle sits at the far left of the header (the header\n is position:relative; the data-slot wrappers are display:contents). */\n [data-mobile-nav=\"toggle\"] {\n position: absolute !important;\n left: 8px !important;\n top: 12px !important;\n z-index: 2 !important;\n }\n /* The Files action stays in-flow inside headerActions. On mobile headerActions\n does not expand to fill the row: it is pushed to the right edge and keeps\n its content size, so [mode] [subagent/jobs] [files] sit close together\n with the normal 8px gaps \u2014 compact, no artificial blank columns. Explicit\n flex order keeps Files rightmost regardless of DOM order. */\n [data-mobile-nav=\"files\"] {\n position: static !important;\n left: auto !important;\n right: auto !important;\n top: auto !important;\n z-index: auto !important;\n }\n [data-phase] header [class$=\"_headerActions\"] {\n flex: 0 1 auto !important;\n min-width: 0 !important;\n margin-left: auto !important;\n justify-content: flex-end !important;\n }\n /* The session title/crumb must yield space on mobile so the mode label and\n subagent cluster are not squeezed to a single letter. Cap it at ~24vw and\n let it truncate with ellipsis. */\n [data-phase] header [class$=\"_crumbs\"] {\n flex: 0 1 auto !important;\n min-width: 0 !important;\n max-width: 24vw !important;\n }\n /* Mode label (AgentPresetLabel): the official pill uses inline-flex, whose\n direct text node is only clipped \u2014 no ellipsis. Convert the pill to a\n block container, keep its icon absolutely positioned on the left, and let\n it size naturally up to 42vw so the mode name stays readable while the\n subagent/Files cluster still fits on a phone. */\n [data-phase] header [class$=\"_label\"]:has(> svg) {\n order: 1 !important;\n flex: 0 1 auto !important;\n min-width: 0 !important;\n max-width: 42vw !important;\n display: block !important;\n position: relative !important;\n box-sizing: border-box !important;\n padding-left: 18px !important;\n padding-right: 2px !important;\n overflow: hidden !important;\n text-overflow: ellipsis !important;\n white-space: nowrap !important;\n }\n [data-phase] header [class$=\"_label\"]:has(> svg) > svg {\n position: absolute !important;\n left: 0 !important;\n top: 50% !important;\n transform: translateY(-50%) !important;\n }\n /* Subagent/jobs helpers: keep their triggers on one line and never let the\n row grow artificial empty space. */\n [data-phase] header [class$=\"_root\"]:has(> button[class$=\"_trigger\"]) {\n order: 2 !important;\n flex: 0 0 auto !important;\n min-width: max-content !important;\n max-width: none !important;\n white-space: nowrap !important;\n /* On mobile the popover is anchored to the header (viewport-width\n containing block) instead of the trigger, so a small left inset keeps it\n fully on screen and never clips the tree's status dots. */\n position: static !important;\n }\n [data-phase] header [class$=\"_root\"]:has(> button[class$=\"_trigger\"]) > button,\n [data-phase] header [class$=\"_root\"]:has(> button[class$=\"_trigger\"]) > button * {\n white-space: nowrap !important;\n }\n [data-phase] header [data-mobile-nav=\"files\"] {\n order: 3 !important;\n flex: 0 0 auto !important;\n }\n /* Session log download: gone from the header row on mobile (the utilities\n seat holds only the session-log-export capsule). */\n [data-phase] header > :first-child > :last-child {\n display: none !important;\n }\n\n /* --- Header popovers on mobile (dsh-client-ui-jobs / dsh-client-ui-subagent) ---\n The official entries sit in the session header actions. Their popovers\n are anchored to the trigger's LEFT edge (left: 0), so from a right-edge\n trigger the 336px panel spills past the viewport. Re-anchor them to the\n trigger's right edge instead. The popover classes are hashed CSS-module\n names, so target them by the stable _menu suffix inside the header. */\n /* Header popovers on mobile: anchor to the left edge with a small inset and\n clamp the width, instead of right-aligning to a trigger near the right\n edge (which pushed the panel left off-screen and clipped the status dots).\n Also cap the height so the panel does not run into the composer/task bar. */\n [data-phase] header [class$=\"_menu\"] {\n left: 8px !important;\n right: auto !important;\n width: min(336px, calc(100vw - 16px)) !important;\n max-width: none !important;\n max-height: min(420px, calc(100dvh - 120px)) !important;\n }\n /* --- Settings dialog on mobile ---\n Desktop: 800px two-column flex (188px nav + content). Mobile: a\n near-full-width sheet \u2014 nav tabs wrap into rows on top, option rows\n stay horizontal (title+description left, control right). Structural\n selectors are scoped to the unique aria-modal dialog; every\n settings-specific rule is gated with\n :has(> :first-child > :last-child > button) \u2014 the settings nav tab\n list holds <button> tabs, so the transient export dialog (the same\n primitives Modal, header(title+close)+description+body) keeps its\n official centered card layout. Requires :has() support\n (Chromium 105+, 2022).\n\n The directory picker (dsh-client-ui-directory-picker-browse) must be\n excluded too: its footer bar holds <button> children AND its breadcrumb\n trail (role=\"navigation\") \u2014 which the role gate relies on to exclude\n it \u2014 is REPLACED by the path input in edit mode (pencil button), so\n without the ZuhsRW exclusion clicking the pencil would suddenly match\n this sheet rule: the dialog jumps to the top of the screen, the header\n (with the path input) is hidden by the > :first-child > :first-child\n display:none rule below, and the user can no longer type a path\n (issue #12, 2026-08-16). The picker family keeps the official layout\n on mobile in every mode. */\n [aria-modal=\"true\"]:has(> :first-child > :last-child > button):not(:has([role=\"navigation\"])):not(:has([class*=\"ZuhsRW\"])) {\n position: absolute !important;\n left: 8px !important;\n /* Fixed top (no translateY): a transform on the panel combined with the\n panel overflowing the max-content drawer shifts the fixed overlay's\n coordinate frame, dragging the whole sidebar content off-screen. The\n safe-area inset keeps the sheet below the status bar / notch. */\n top: calc(var(--mnav-sat) + 12px) !important;\n width: calc(100vw - 16px) !important;\n max-width: calc(100vw - 16px) !important;\n /* Height follows the content (no dead space under a short page); it\n caps at 100dvh-24 (less the safe-area top) and the options area\n scrolls only then. */\n height: auto !important;\n max-height: min(800px, calc(100vh - 24px - var(--mnav-sat))) !important;\n max-height: min(800px, calc(100dvh - 24px - var(--mnav-sat))) !important;\n flex-direction: column !important;\n border-radius: 14px !important;\n animation: dsh-mobile-nav-sheet-in .22s var(--ds-ease-out, ease-in-out);\n }\n /* The settings sheet's dimmed mask fades in with the panel (the mask is\n the first child of the overlay that directly contains the sheet). */\n :has(> [aria-modal=\"true\"]:has(> :first-child > :last-child > button):not(:has([role=\"navigation\"])):not(:has([class*=\"ZuhsRW\"]))) > :first-child {\n animation: dsh-mobile-nav-fade .18s var(--ds-ease-out, ease-in-out);\n }\n @media (prefers-reduced-motion: reduce) {\n [aria-modal=\"true\"]:has(> :first-child > :last-child > button):not(:has([role=\"navigation\"])):not(:has([class*=\"ZuhsRW\"])),\n :has(> [aria-modal=\"true\"]:has(> :first-child > :last-child > button):not(:has([role=\"navigation\"])):not(:has([class*=\"ZuhsRW\"]))) > :first-child {\n animation: none !important;\n }\n }\n /* The export dialog (not the settings sheet) must never overflow the\n viewport: the official centered card can be wider than 390px.\n :not([data-mobile-nav=\"info-sheet\"]) (S4, 2026-08-17): the session-info\n sheet also carries role=\"dialog\" aria-modal=\"true\" (correct a11y\n semantics for a bottom sheet with a scrim) and has no button as its\n first-child's last-child, so without this exclusion it silently matched\n this generic selector too \u2014 measured 358px (100vw-32px) instead of the\n 374px its own left:8px/right:8px margins specify. Same category of bug\n as the ZuhsRW directory-picker collision below; same fix shape. */\n [aria-modal=\"true\"]:not(:has(> :first-child > :last-child > button)):not([data-mobile-nav=\"info-sheet\"]) {\n max-width: calc(100vw - 32px) !important;\n }\n /* Nav bar: hide the \"Settings\" caption (redundant on a full-width sheet)\n and wrap the tab list so every tab is visible \u2014 a horizontal scroll cut\n the last tab (\"Plugins\") off with no affordance to scroll. */\n [aria-modal=\"true\"]:has(> :first-child > :last-child > button):not(:has([role=\"navigation\"])):not(:has([class*=\"ZuhsRW\"])) > :first-child {\n width: 100% !important;\n flex-direction: row !important;\n align-items: center !important;\n gap: 6px !important;\n padding: 10px 12px 8px !important;\n }\n [aria-modal=\"true\"]:has(> :first-child > :last-child > button):not(:has([role=\"navigation\"])):not(:has([class*=\"ZuhsRW\"])) > :first-child > :first-child {\n display: none !important;\n }\n /* The tab list scrolls in the space left by the toolbar: the toolbar\n (config file + close) is reparented INTO this nav row by a client\n effect (MobileNavOverlay), so the tab list must be anchored by its\n class, NOT by :last-child (the reparented toolbar becomes the nav's\n new last child). */\n [aria-modal=\"true\"]:has(> :first-child > :last-child > button):not(:has([role=\"navigation\"])):not(:has([class*=\"ZuhsRW\"])) > :first-child [class$=\"_navList\"] {\n flex: 1 1 auto !important;\n min-width: 0 !important;\n flex-direction: row !important;\n flex-wrap: wrap !important;\n gap: 6px !important;\n overflow: visible !important;\n }\n /* Content toolbar (Open configuration file + close): grouped flush to\n the right edge, and reparented INTO the nav row on mobile so it shares\n one line with the tabs (user feedback 2026-08-16 \u2014 the toolbar's own\n row left a full-width dead gap under the tabs). Anchored by class: the\n header leaves the content subtree, so :first-child/:last-child anchors\n would now hit the options area. Children carry official auto-margins\n that would defeat flex-end, so neutralize them. The close button gets\n a round tappable base so it reads as its own control, not part of the\n outline button. */\n [aria-modal=\"true\"]:has(> :first-child > :last-child > button):not(:has([role=\"navigation\"])):not(:has([class*=\"ZuhsRW\"])) [class$=\"_header\"] {\n flex: 0 0 auto !important;\n justify-content: flex-end !important;\n align-items: center !important;\n gap: 8px !important;\n padding: 0 0 0 4px !important;\n min-height: 40px !important;\n }\n [aria-modal=\"true\"]:has(> :first-child > :last-child > button):not(:has([role=\"navigation\"])):not(:has([class*=\"ZuhsRW\"])) [class$=\"_header\"] > * {\n margin-left: 0 !important;\n margin-right: 0 !important;\n }\n [aria-modal=\"true\"]:has(> :first-child > :last-child > button):not(:has([role=\"navigation\"])):not(:has([class*=\"ZuhsRW\"])) [class$=\"_header\"] > :last-child {\n width: 32px !important;\n height: 32px !important;\n border-radius: 50% !important;\n display: inline-flex !important;\n align-items: center !important;\n justify-content: center !important;\n background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06)) !important;\n }\n /* Appearance mode cards: the official cube row renders three tall\n vertical cards (~268px) that eat half the sheet. Turn them into a\n compact horizontal trio (icon + label inline, equal widths).\n Relies on the official cube-row class name of this version. */\n [aria-modal=\"true\"] [class$=\"_cubeRow\"] {\n gap: 6px !important;\n }\n [aria-modal=\"true\"] [class$=\"_cubeRow\"] > * {\n flex: 1 1 0 !important;\n flex-direction: row !important;\n align-items: center !important;\n justify-content: center !important;\n gap: 6px !important;\n padding: 10px 8px !important;\n min-height: 0 !important;\n }\n /* Content: the options scroll area gets bottom breathing room so the last\n row never sits flush against the sheet's rounded corner. */\n [aria-modal=\"true\"]:has(> :first-child > :last-child > button):not(:has([role=\"navigation\"])):not(:has([class*=\"ZuhsRW\"])) > :last-child {\n flex: 1 1 auto !important;\n min-height: 0 !important;\n }\n [aria-modal=\"true\"]:has(> :first-child > :last-child > button):not(:has([role=\"navigation\"])):not(:has([class*=\"ZuhsRW\"])) > :last-child > :last-child {\n padding: 0 12px 24px !important;\n }\n";
2
+ //# sourceMappingURL=layout.css.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"layout.css.d.ts","sourceRoot":"","sources":["../../../../src/client/styles/layout.css.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,UAAU,m6yBAiftB,CAAA"}
@@ -0,0 +1,2 @@
1
+ export declare const MISC_CSS = " /* ---------- hero composer on mobile ----------\n The official hero card carries a 2-line textarea plus a tall tool row,\n which reads oversized on a phone. Tighten the empty-state rhythm: keep\n the official centered hero, shrink the textarea line box, slim the card\n padding and the tool row, and close the gap under the headline. */\n\n [data-phase=\"hero\"] [class$=\"_card\"]:has(textarea) {\n padding-top: 6px !important;\n gap: 8px !important;\n }\n /* The official composer autosizes the textarea and writes an inline\n height (2 lines on the hero empty state) on the textarea's scroll/grow\n wrappers. :placeholder-shown lets us collapse the EMPTY state to one\n line with !important; as soon as the user types, the pseudo-class no\n longer matches and the autosizer's inline height takes over again \u2014 so\n multi-line growth keeps working. */\n [data-phase=\"hero\"] textarea:placeholder-shown {\n height: 28px !important;\n }\n [data-phase=\"hero\"] [class$=\"_card\"]:has(textarea:placeholder-shown) > [class$=\"_scroll\"],\n [data-phase=\"hero\"] [class$=\"_card\"]:has(textarea:placeholder-shown) [class$=\"_grow\"] {\n height: 28px !important;\n }\n [data-phase=\"hero\"] [class$=\"_card\"]:has(textarea) > [class$=\"_row\"] {\n padding-top: 2px !important;\n }\n [data-phase=\"hero\"] [class$=\"_headline\"] {\n line-height: 1.15 !important;\n margin-bottom: 0 !important;\n }\n [data-phase=\"hero\"] [class$=\"_stack\"] {\n gap: 0 !important;\n }\n\n /* ---------- composer dock: swap git branch chip with the todo card ----------\n The git-graph branch chip (conversation.input.dock, order 100) floats\n alone at the bottom-left above the input card, with a dead zone to its\n right; the full-width todo card (order 0) sits above it. Swap them so\n the chip reads as the stack's top row and the todo card fills the row\n above the composer. The dock container itself is display:contents\n (inline style) \u2014 its children are direct flex items of the composer\n stack, so order on the children is what reorders them. Only the chip\n needs an order change: -1 puts it before the todo card (order 0) and\n before the input card (order 0, later in DOM). The todo card must KEEP\n its order 0 \u2014 raising it past the input card's order 0 would drop it\n below the composer entirely (2026-08-16 regression, fixed). The queue\n strip (order 20) keeps hugging the input card. Desktop untouched (this\n block lives inside the max-width: 1023px media query). */\n [data-slot=\"conversation.input.dock\"] [data-gitgraph-chip-anchor] {\n order: -1 !important;\n }\n /* Mobile tap target + feedback for the branch chip (git-graph, 24px\n desktop spec). Two real-world problems: \u2460 the chip is tiny and sits\n right above the expandable todo card \u2014 mis-taps land on the todo card;\n \u2461 opening the popover waits for the host's /git/branches round-trip\n (~700ms on device) with zero feedback, so users tap again and toggle\n the popover closed. Enlarge the target, kill double-tap zoom delay,\n and give an instant pressed state so a tap reads as registered. */\n [data-slot=\"conversation.input.dock\"] [data-gitgraph-chip-anchor] [data-gitgraph-chip] {\n touch-action: manipulation !important;\n min-height: 34px !important;\n padding: 0 12px !important;\n font-size: 13px !important;\n }\n [data-slot=\"conversation.input.dock\"] [data-gitgraph-chip-anchor] [data-gitgraph-chip]:active {\n transform: scale(.96) !important;\n transition: transform .12s !important;\n }\n}\n\n/* ---------- tablet / wide mobile: keep sheets from becoming full-width ----------\n Below 768px the near-full-width sheets are the right call for a phone.\n On wider but still sub-desktop viewports (foldables, tablet portrait,\n desktop-mode tall windows) the same full-bleed sheet leaves content\n clustered at the left edge with a large dead zone on the right. Cap and\n center the modal sheets and the aionui bottom sheets instead. */\n@media (min-width: 768px) and (max-width: 1023px) {\n /* All modal dialogs: centered, never edge-to-edge. The settings sheet has\n a higher-specificity full-width rule above, so repeat its selector here\n to win; the generic export/other-modal rule is covered by the second\n selector. */\n [aria-modal=\"true\"]:has(> :first-child > :last-child > button):not(:has([role=\"navigation\"])):not(:has([class*=\"ZuhsRW\"])),\n [aria-modal=\"true\"]:not(:has(> :first-child > :last-child > button)) {\n left: 0 !important;\n right: 0 !important;\n margin-left: auto !important;\n margin-right: auto !important;\n width: min(calc(100vw - 32px), 720px) !important;\n max-width: min(calc(100vw - 32px), 720px) !important;\n }\n\n /* The dsh-web-ui explorer / preview bottom sheets: same treatment \u2014 keep\n the mobile bottom-sheet behavior, but stop them spanning the full width. */\n [data-aionui-explorer-col],\n [data-aionui-preview-col] {\n left: 0 !important;\n right: 0 !important;\n width: min(calc(100vw - 32px), 720px) !important;\n margin-left: auto !important;\n margin-right: auto !important;\n }\n\n /* Settings sections (e.g. Agent presets) often carry a desktop max-width\n (720px) that leaves a dead strip on the right once the sheet is capped to\n the same width; let them fill the sheet body instead. */\n [aria-modal=\"true\"] [class$=\"_section\"] {\n width: 100% !important;\n max-width: none !important;\n }\n}\n\n/* ---------- desktop: the mobile controls must never appear ---------- */\n\n@media (min-width: 1024px) {\n [data-mobile-nav=\"toggle\"],\n [data-mobile-nav=\"files\"],\n [data-mobile-nav=\"fab\"],\n [data-mobile-nav=\"backdrop\"],\n [data-mobile-nav=\"session-log\"],\n [data-mobile-nav=\"explorer\"],\n [data-mobile-nav=\"drawer-actions\"] {\n display: none !important;\n }\n}\n";
2
+ //# sourceMappingURL=misc.css.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"misc.css.d.ts","sourceRoot":"","sources":["../../../../src/client/styles/misc.css.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,QAAQ,05LA4HpB,CAAA"}
@@ -0,0 +1,2 @@
1
+ export declare const TURN_FOLD_CSS = "/* ---------- turn process fold (< 768px) ---------- */\n\n/* The summary row never paints outside the phone breakpoint, even if a\n media-query change raced the effect's own cleanup. */\n[data-mobile-nav=\"turn-fold\"] {\n display: none;\n}\n\n@media (max-width: 767px) {\n /* Folded process: tool-call / context / command rows and the Think\n disclosures inside an assistant step. !important because the official\n flow item and ReasoningRow both set their own display. */\n [data-mnav-fold]:not([data-mnav-fold-open]) {\n display: none !important;\n }\n\n /* Compact chip, sized like the composer's own pills rather than a button:\n it is a reading affordance in the middle of the message flow, so it has\n to stay quieter than the content around it. The flow column is a flex\n column with gap: 16px (ChatView.module.css) \u2014 align-self keeps the chip\n from stretching, and the negative block margin trims that generous gap\n back to something a 26px row can live in. */\n [data-chat-flow] > [data-mobile-nav=\"turn-fold\"] {\n display: inline-flex;\n align-self: flex-start;\n align-items: center;\n gap: 6px;\n margin: -4px 0;\n padding: 0 10px;\n height: 26px;\n border: none;\n border-radius: 13px;\n background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06));\n color: var(--dsw-alias-label-secondary, rgba(0, 0, 0, .55));\n font: inherit;\n font-size: 12px;\n line-height: 26px;\n white-space: nowrap;\n cursor: pointer;\n -webkit-tap-highlight-color: transparent;\n }\n [data-chat-flow] > [data-mobile-nav=\"turn-fold\"]:active {\n background: var(--dsw-alias-interactive-bg-pressed, rgba(0, 0, 0, .1));\n }\n\n /* Leading dot: idle turns get a quiet mark, a running turn gets the same\n business-primary colour the session header's own status dot uses\n (styles/header.css.ts) plus a breathing pulse. */\n [data-chat-flow] > [data-mobile-nav=\"turn-fold\"]::before {\n content: '';\n flex: none;\n width: 6px;\n height: 6px;\n border-radius: 50%;\n background: currentColor;\n opacity: .45;\n }\n [data-chat-flow] > [data-mobile-nav=\"turn-fold\"][data-running]::before {\n background: var(--dsw-alias-state-business-primary, #4f6ef7);\n opacity: 1;\n animation: dsh-mobile-nav-breathe 1.4s var(--ds-ease-in-out, ease-in-out) infinite;\n }\n\n /* Chevron drawn from two borders \u2014 no icon injection, no mask image, and\n it rotates to point up once the turn is open. */\n [data-chat-flow] > [data-mobile-nav=\"turn-fold\"]::after {\n content: '';\n flex: none;\n width: 5px;\n height: 5px;\n margin-top: -3px;\n border-right: 1.5px solid currentColor;\n border-bottom: 1.5px solid currentColor;\n transform: rotate(45deg);\n transition: transform .16s var(--ds-ease-out, ease-in-out);\n }\n [data-chat-flow] > [data-mobile-nav=\"turn-fold\"][data-open]::after {\n margin-top: 3px;\n transform: rotate(-135deg);\n }\n\n @media (prefers-reduced-motion: reduce) {\n [data-chat-flow] > [data-mobile-nav=\"turn-fold\"][data-running]::before {\n animation: none;\n }\n [data-chat-flow] > [data-mobile-nav=\"turn-fold\"]::after {\n transition: none;\n }\n }\n}\n\n@keyframes dsh-mobile-nav-breathe {\n 0%, 100% { opacity: 1; transform: scale(1); }\n 50% { opacity: .4; transform: scale(.72); }\n}\n";
2
+ //# sourceMappingURL=turn-fold.css.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"turn-fold.css.d.ts","sourceRoot":"","sources":["../../../../src/client/styles/turn-fold.css.ts"],"names":[],"mappings":"AAUA,eAAO,MAAM,aAAa,02GA+FzB,CAAA"}
@@ -0,0 +1,83 @@
1
+ /**
2
+ * dsh-mobile-nav, node half.
3
+ *
4
+ * Was an empty apply (pure client UI plugin) until S7. It now owns ONE host
5
+ * route: the phone composer's attachment upload. The official file picker
6
+ * opens on the machine running DSH, which is useless from a phone, and the
7
+ * public client API has no upload verb at all — the only public browser->host
8
+ * byte channel is `session.prompt([{type:'image',…}])`, which is images only
9
+ * and lands as a sent message rather than a file on disk. So a non-image
10
+ * attachment needs a route of its own, and that route belongs here rather
11
+ * than in the gateway half of this plugin: the gateway authenticates and forwards
12
+ * verbatim, it does not know what a session or a workspace is.
13
+ *
14
+ * The browser half still ships via exports["./client"], discovered through
15
+ * the package.json dsh.client declaration.
16
+ */
17
+ import type { IncomingMessage, ServerResponse } from 'node:http';
18
+ import type { Context } from '@deepseek-ai/cordis';
19
+ /** Exact route the phone composer POSTs one file body to. */
20
+ export declare const UPLOAD_ROUTE = "/_dsh/mobile-nav/upload";
21
+ /** Workspace-relative directory uploads land in (also the `@` prefix the composer inserts). */
22
+ export declare const UPLOAD_DIR = ".dsh-uploads";
23
+ /** Body cap when the plugin row sets no `maxUploadBytes`. */
24
+ export declare const DEFAULT_MAX_UPLOAD_BYTES: number;
25
+ /** Host half config; the only knob is the body cap. */
26
+ export interface MobileNavConfig {
27
+ /** Max upload body in bytes; larger bodies get 413. Default {@link DEFAULT_MAX_UPLOAD_BYTES}. */
28
+ maxUploadBytes?: number;
29
+ }
30
+ /**
31
+ * Accept a state-changing request only from this DSH Web application's origin.
32
+ *
33
+ * The gateway half rewrites `Origin`/`Host` to the upstream origin
34
+ * before forwarding (lan-gate-server.cjs `cleanHeaders`), so a phone request
35
+ * that already cleared the pairing wall presents here as same-origin; a
36
+ * request with neither header falls back to the Fetch metadata.
37
+ * @param req - the inbound request.
38
+ * @returns true when the request may mutate the workspace.
39
+ */
40
+ export declare function sameOriginPost(req: IncomingMessage): boolean;
41
+ /**
42
+ * Reject a resolved path that is not rooted below the expected directory.
43
+ * @param root - the directory the target must stay inside.
44
+ * @param target - the resolved candidate path.
45
+ * @throws when the target escapes the root.
46
+ */
47
+ export declare function ensurePathInside(root: string, target: string): void;
48
+ /**
49
+ * Convert an untrusted browser label into one portable leaf filename.
50
+ *
51
+ * Everything that could steer the write out of the upload directory is gone
52
+ * after this: only the basename survives (so `../../etc/passwd` becomes
53
+ * `passwd`), separators and control characters become `_`, leading dots are
54
+ * dropped, and the Windows reserved device names are prefixed. Whitespace
55
+ * folds to `_` rather than being kept: the client appends the result to the
56
+ * composer draft as an `@path` mention, and a mention with a space in it is
57
+ * broken for the agent reading it, not just for the chip parser. Length is
58
+ * capped in BYTES because the label arrives as UTF-8.
59
+ * @param raw - browser-supplied filename.
60
+ * @returns a single safe leaf name, never empty.
61
+ */
62
+ export declare function safeUploadName(raw: string): string;
63
+ /**
64
+ * Handle one `POST {@link UPLOAD_ROUTE}?session=<id>&name=<file>` request.
65
+ *
66
+ * Exported so an integration check can drive it with a plain node:http server
67
+ * and a fake sessions service instead of booting a harness.
68
+ * @param ctx - host context carrying the sessions service and logger.
69
+ * @param maxBytes - body cap.
70
+ * @param req - inbound request; its body is the raw file.
71
+ * @param res - the response this call owns end to end.
72
+ */
73
+ export declare function handleUpload(ctx: Context, maxBytes: number, req: IncomingMessage, res: ServerResponse): Promise<void>;
74
+ /**
75
+ * Host half: mount the upload route wherever a webServer and live sessions
76
+ * exist. Both are injected INSIDE apply rather than declared as a top-level
77
+ * `inject`, so the plugin row still loads (and the browser half still ships)
78
+ * in a composition without them — Electron carries no webServer.
79
+ * @param ctx - host plugin context.
80
+ * @param config - optional body cap override.
81
+ */
82
+ export declare function apply(ctx: Context, config?: MobileNavConfig): void;
83
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAIH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAEhE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAIlD,6DAA6D;AAC7D,eAAO,MAAM,YAAY,4BAA4B,CAAA;AAErD,+FAA+F;AAC/F,eAAO,MAAM,UAAU,iBAAiB,CAAA;AAExC,6DAA6D;AAC7D,eAAO,MAAM,wBAAwB,QAAmB,CAAA;AAQxD,uDAAuD;AACvD,MAAM,WAAW,eAAe;IAC9B,iGAAiG;IACjG,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB;AAwCD;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,eAAe,GAAG,OAAO,CAa5D;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAKnE;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAgBlD;AA6HD;;;;;;;;;GASG;AACH,wBAAsB,YAAY,CAChC,GAAG,EAAE,OAAO,EACZ,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,eAAe,EACpB,GAAG,EAAE,cAAc,GAClB,OAAO,CAAC,IAAI,CAAC,CA+Bf;AAED;;;;;;;GAOG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,GAAE,eAAoB,GAAG,IAAI,CAStE"}
package/llms.txt ADDED
@@ -0,0 +1,20 @@
1
+ # dsh-zen-remote
2
+
3
+ > Complete mobile plugin for DeepSeek Harness (DSH), shipped as ONE npm package: app-shell mobile UI (two-level page stack, session-list home, reworked composer, gestures, phone-local attachment upload) + pairing-code/device-token gateway for public-internet access behind your own reverse proxy + installable PWA + offline service worker + real Web Push agent-done notifications.
4
+
5
+ ## Key facts
6
+ - One npm package `dsh-zen-remote`, MIT. Install: `dsh plugin add dsh-zen-remote`, then restart `dsh web`.
7
+ - Declares `dsh.bundle.patch` → `cordis.patch.yml` with three insert rows, all self-contained (no manual profile patching needed):
8
+ `dsh-zen-remote` (host upload route + browser client via `dsh.client`), `dsh-zen-remote-gateway` (`lan-gate.mjs`), `dsh-zen-remote-push` (`dsh-push.mjs`).
9
+ - Interface half: browser client bundle (`lib/client.js`, registered as `__ModuleLoader__.load({id:"dsh-zen-remote"})`); <768px app shell, 768–1023px drawer, ≥1024px strict no-op. Host route `POST /_dsh/mobile-nav/upload` writes phone-picked files into the session workspace's `.dsh-uploads/`.
10
+ - Gateway half: standalone child-process Node gateway (`lib/lan-gate-server.cjs`, Node stdlib + one runtime dep `web-push`), listens on `127.0.0.1:3088`, sits behind a user-run TLS-terminating reverse proxy (nginx/Caddy/Lucky). Device identity = pairing token (cookie `lg_device`), not source IP. Reverse-proxies paired devices to DSH on `127.0.0.1:3080`; DSH webserver untouched. HTML injection adds the PWA manifest link, service-worker bootstrap, `viewport-fit=cover`, shell CSS, randomUUID polyfill.
11
+ - Push half: listens on `agent/turn-stopping` (configurable) and registers the model-callable `push_notify` tool; both deliver through the gateway's local `/pwa/push/send` (VAPID + aes128gcm).
12
+ - Env: `LAN_GATE_PORT` (3088), `LAN_GATE_HOST` (127.0.0.1), `LAN_GATE_TARGET_PORT` (3080), `LAN_GATE_RATE_LIMIT` (120), `LAN_GATE_TRUSTED_PROXIES`, `LAN_GATE_VAPID_SUBJECT`, `DSH_PUSH_EVENTS`, `DSH_PUSH_DEBOUNCE_MS`, `DSH_PUSH_SUMMARY`, `DSH_PUSH_TOOL`. Same keys (lowerCamelCase) in `~/.dsh/lan-gate.config.json`.
13
+ - Admin (local-direct-connection only, 403 otherwise): `http://127.0.0.1:3088/lan-gate/admin` — generate pairing codes, rename/set-kind/revoke devices, trigger a push.
14
+ - Security: 8-char one-time pairing code (10 min TTL), 5-wrong-code lockout (15 min), revocable per-device tokens, 120 req/min per resolved client IP, admin surface rejects any request carrying `X-Forwarded-*`.
15
+
16
+ ## Docs
17
+ - README.md (中文,安装与配置为主), AGENTS.md (repo guide), LICENSE.
18
+ - docs/remote-access.md(.en.md) — gateway detail: reverse proxy, pairing, env, admin API, push, security boundary.
19
+ - docs/interface.md — interface detail: breakpoints, debug badge, safe-area system, compatible plugins.
20
+ - .ai/ — AI Project OS state, memory, decisions, tasks.
package/package.json ADDED
@@ -0,0 +1,128 @@
1
+ {
2
+ "name": "dsh-zen-remote",
3
+ "version": "1.0.0",
4
+ "description": "把 DeepSeek Harness 变成手机 App:移动端界面重排(两级页面栈、会话列表主屏、composer 重排、手势、附件上传)+ 配对码认证网关 + PWA 安装 + 真 Web Push。Complete mobile plugin for DeepSeek Harness (DSH): app-shell mobile UI, pairing-code gateway for public-internet access behind your own reverse proxy, installable PWA, offline service worker, and real Web Push agent-done notifications.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "lib/index.js",
8
+ "types": "lib/types/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./lib/types/index.d.ts",
12
+ "default": "./lib/index.js"
13
+ },
14
+ "./client": {
15
+ "types": "./lib/types/client/index.d.ts",
16
+ "default": "./lib/client.js"
17
+ },
18
+ "./lan-gate.mjs": "./lan-gate.mjs",
19
+ "./dsh-push.mjs": "./dsh-push.mjs",
20
+ "./cordis.patch.yml": "./cordis.patch.yml",
21
+ "./package.json": "./package.json"
22
+ },
23
+ "files": [
24
+ "lan-gate.mjs",
25
+ "dsh-push.mjs",
26
+ "lib",
27
+ "pwa",
28
+ "src",
29
+ "scripts",
30
+ "assets",
31
+ "docs",
32
+ "cordis.patch.yml",
33
+ "cordis.patch.yml.example",
34
+ "llms.txt",
35
+ "AGENTS.md",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "scripts": {
40
+ "build": "tsc -p tsconfig.json && tsc -p tsconfig.client.json && node scripts/build-client.mjs",
41
+ "verify": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.client.json --noEmit",
42
+ "test": "node --test \"test/*.test.cjs\" && node scripts/check-sunk-viewport.mjs && node scripts/check-attach-upload.mjs && node scripts/check-upload-endpoint.mjs",
43
+ "prepack": "npm run build"
44
+ },
45
+ "dsh": {
46
+ "bundle": {
47
+ "patch": "./cordis.patch.yml"
48
+ },
49
+ "client": {
50
+ "platform": "web",
51
+ "inject": [
52
+ "@deepseek-ai/dsh-client-runtime"
53
+ ]
54
+ }
55
+ },
56
+ "keywords": [
57
+ "dsh",
58
+ "dsh-plugin",
59
+ "dsh-plugin-add",
60
+ "deepseek-harness",
61
+ "deepseek-harness-plugin",
62
+ "mobile",
63
+ "mobile-ui",
64
+ "phone",
65
+ "pwa",
66
+ "progressive-web-app",
67
+ "remote-access",
68
+ "pairing",
69
+ "public",
70
+ "reverse-proxy",
71
+ "gateway",
72
+ "service-worker",
73
+ "web-push",
74
+ "offline",
75
+ "ai-agent"
76
+ ],
77
+ "repository": {
78
+ "type": "git",
79
+ "url": "git+https://github.com/KyoMio/dsh-zen-remote.git"
80
+ },
81
+ "engines": {
82
+ "node": ">=18"
83
+ },
84
+ "packageManager": "pnpm@11.7.0",
85
+ "dependencies": {
86
+ "web-push": "^3.6.7"
87
+ },
88
+ "peerDependencies": {
89
+ "@deepseek-ai/cordis": "^4.0.1",
90
+ "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
91
+ "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
92
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
93
+ "@deepseek-ai/dsh-client-ui-layout": "^0.1.0-rc.6",
94
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
95
+ "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.0-rc.6",
96
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
97
+ "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
98
+ "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
99
+ "@deepseek-ai/dsh-session-log-export": "^0.1.0-rc.6",
100
+ "react": "^18.2.0",
101
+ "@deepseek-ai/dsh-tools": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0"
102
+ },
103
+ "peerDependenciesMeta": {
104
+ "@deepseek-ai/dsh-host-webserver": {
105
+ "optional": true
106
+ },
107
+ "@deepseek-ai/dsh-session": {
108
+ "optional": true
109
+ },
110
+ "@deepseek-ai/dsh-tools": {
111
+ "optional": true
112
+ }
113
+ },
114
+ "devDependencies": {
115
+ "@deepseek-ai/dsh-api-remotes": "0.1.0-rc.6",
116
+ "@deepseek-ai/dsh-client-connection": "0.1.0-rc.6",
117
+ "@deepseek-ai/dsh-host-apiproxy": "0.1.0-rc.6",
118
+ "@deepseek-ai/dsh-host-webserver": "0.1.0-rc.6",
119
+ "@deepseek-ai/dsh-session": "0.1.0-rc.6",
120
+ "@deepseek-ai/dsh-session-stats": "0.1.0-rc.6",
121
+ "@deepseek-ai/dsh-token-meter": "0.1.0-rc.6",
122
+ "@types/node": "^26.2.0",
123
+ "@types/react": "~18.3.1",
124
+ "@types/react-dom": "~18.3.0",
125
+ "typescript": "^6.0.3",
126
+ "@deepseek-ai/dsh-tools": "0.1.0-rc.6"
127
+ }
128
+ }
package/pwa/app.css ADDED
@@ -0,0 +1,95 @@
1
+ /* dsh-mobile-pwa · mobile shell CSS
2
+ * Loaded on every device that isn't explicitly marked "desktop"
3
+ * (`html:not([data-lan-device="desktop"])`). A real phone paired through the
4
+ * gateway defaults to kind "auto" — which never gets a `data-lan-device`
5
+ * attribute at all (see lib/lan-gate-server.cjs) — so gating on the literal
6
+ * `[data-lan-device="phone"]` value (only set when an admin manually flips a
7
+ * device to "phone") meant these patches never actually applied on a real
8
+ * phone. `:not([data-lan-device="desktop"])` matches both "no attribute" and
9
+ * an explicit "phone", so auto devices get them too. Desktop is still never
10
+ * affected.
11
+ *
12
+ * Division of labor (2026-08-17): mobile UI layout — font-size scale,
13
+ * fullscreen dialogs, composer chrome, popover positioning, touch-target
14
+ * sizing, double-tap-zoom suppression — now belongs entirely to the
15
+ * @dsh-external/dsh-mobile-nav plugin. This file keeps only rules the PWA
16
+ * shell itself needs: browser-behavior workarounds (iOS input zoom, text
17
+ * auto-resize) and the CSS side of this plugin's own touch gestures
18
+ * (pinch-to-resize) implemented in touch-gestures.js.
19
+ * None of the rules below duplicate dsh-mobile-nav, and the page does not
20
+ * depend on dsh-mobile-nav being installed for them to make sense.
21
+ *
22
+ * Trimmed from 163 to this: removed the font-size-variable compression
23
+ * block, fullscreen modal rules, composer select-pill compaction (incl. the
24
+ * hashed `.Sh0Q9G_triggerLabel` selector), popover positioning, touch-target
25
+ * min-height/touch-action rules, and the whole non-phone
26
+ * `@media (max-width: 820px)` fallback block — dsh-mobile-nav's own media
27
+ * queries are not gated on data-lan-device, so they already cover that
28
+ * fallback scenario.
29
+ */
30
+
31
+ /* ----- Browser-behavior baseline: stop iOS/Android auto text resize, kill
32
+ the tap-highlight flash, respect the safe-area inset. These apply to the
33
+ whole page (not just dsh-mobile-nav's own elements), so they stay here
34
+ rather than move to dsh-mobile-nav. ----- */
35
+ html:not([data-lan-device="desktop"]) {
36
+ -webkit-text-size-adjust: 100%;
37
+ text-size-adjust: 100%;
38
+ -webkit-tap-highlight-color: transparent;
39
+ scroll-padding-bottom: max(env(safe-area-inset-bottom), 0px);
40
+ }
41
+
42
+ /* Suppress the browser's own overscroll "rubber band"/pull-to-refresh at the
43
+ * document edge. This plugin's touch-gestures.js used to implement its own
44
+ * pull-to-refresh gesture here too, but that was removed (users found it
45
+ * trigger-happy and disorienting: any accidental overscroll reloaded the
46
+ * page and dropped them back to the conversation list mid-chat). This rule
47
+ * stands on its own now — it just stops the browser's native gesture from
48
+ * firing an unwanted reload inside the standalone PWA. */
49
+ html:not([data-lan-device="desktop"]) body {
50
+ overscroll-behavior-y: none;
51
+ }
52
+
53
+ /* iOS Safari zooms the whole page when a focused input's font-size is below
54
+ * 16px. dsh-mobile-nav does not add this workaround, so it stays here. */
55
+ html:not([data-lan-device="desktop"]) [data-slot="conversation"] input,
56
+ html:not([data-lan-device="desktop"]) [data-slot="conversation"] textarea {
57
+ font-size: 16px;
58
+ }
59
+
60
+ /* Apply the pinch-to-resize font scale. touch-gestures.js sets
61
+ * --dsh-pwa-font-scale via root.style.setProperty() on pinch; this is this
62
+ * plugin's own gesture feature, not a dsh-mobile-nav concern. */
63
+ html:not([data-lan-device="desktop"]) {
64
+ --dsh-pwa-font-scale: 1;
65
+ }
66
+ html:not([data-lan-device="desktop"]) [data-slot="conversation"] [data-phase] {
67
+ font-size: calc(1em * var(--dsh-pwa-font-scale));
68
+ }
69
+
70
+ /* Home-indicator clearance for the composer USED to live here, as
71
+ * [data-slot="conversation.composer"], [data-slot="conversation.input.bar"]
72
+ * { padding-bottom: max(env(safe-area-inset-bottom), 8px) }
73
+ * It is gone (S4.1, 2026-08-17) and should not come back. Two reasons:
74
+ *
75
+ * 1. It never did anything. Both of those slot elements are `display:
76
+ * contents` in the current DSH shell — they generate no box, so padding on
77
+ * them is discarded. Measured 2026-08-17: forcing the declaration to a
78
+ * literal 34px moved the composer capsule by exactly 0px.
79
+ * 2. Composer chrome is dsh-mobile-nav's job (see the file header). The real
80
+ * clearance now lives in that plugin's styles/composer.css.ts, on an
81
+ * element that actually lays out, and reads its --mnav-sab variable so it
82
+ * can be regression-tested off-device with ?mobile-nav-inset=54,34.
83
+ * env() here was untestable on any desktop browser, which is how an inert
84
+ * rule survived this long.
85
+ */
86
+
87
+ /* Scroll long code lines horizontally instead of letting them widen the
88
+ * page. dsh-mobile-nav has no equivalent rule; dropping this would let a
89
+ * long code block push the whole phone layout into horizontal scroll. */
90
+ html:not([data-lan-device="desktop"]) pre,
91
+ html:not([data-lan-device="desktop"]) code {
92
+ white-space: pre;
93
+ overflow-x: auto;
94
+ -webkit-overflow-scrolling: touch;
95
+ }
Binary file
Binary file
Binary file
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" fill="#4D6BFE"/><g transform="translate(102.4,102.4) scale(6.144)"><path d="M48.8354 10.0479C48.3232 9.79199 48.1025 10.2798 47.8032 10.5278C47.7007 10.6079 47.6143 10.7119 47.5273 10.8076C46.7793 11.624 45.9048 12.1597 44.7622 12.0957C43.0923 12 41.666 12.5356 40.4058 13.8398C40.1377 12.2319 39.2476 11.272 37.8926 10.6558C37.1836 10.3359 36.4668 10.0156 35.9702 9.31982C35.6235 8.82373 35.5293 8.27197 35.356 7.72754C35.2456 7.3999 35.1353 7.06396 34.7651 7.00781C34.3633 6.94385 34.2056 7.2876 34.0479 7.57568C33.418 8.75195 33.1733 10.0479 33.1973 11.3599C33.2524 14.312 34.4736 16.6641 36.8999 18.3359C37.1758 18.5278 37.2466 18.7197 37.1597 19C36.9946 19.5757 36.7974 20.1357 36.624 20.7119C36.5137 21.0801 36.3486 21.1597 35.9624 21C34.6309 20.4321 33.481 19.5918 32.4644 18.5757C30.7393 16.8721 29.1792 14.9917 27.2334 13.52C26.7764 13.1758 26.3193 12.856 25.8467 12.5518C23.8618 10.584 26.1069 8.96777 26.627 8.77588C27.1704 8.57568 26.8159 7.8877 25.0591 7.896C23.3022 7.90381 21.6953 8.50391 19.647 9.30371C19.3477 9.42383 19.0322 9.51172 18.7095 9.58398C16.8501 9.22363 14.9199 9.14355 12.9033 9.37598C9.10596 9.80762 6.07275 11.6396 3.84326 14.7681C1.16455 18.5278 0.53418 22.7998 1.30664 27.2559C2.11768 31.9521 4.46582 35.8398 8.07373 38.8799C11.8159 42.0322 16.1255 43.5762 21.041 43.2803C24.0269 43.104 27.3516 42.6963 31.1016 39.4561C32.0469 39.936 33.0396 40.1279 34.686 40.272C35.9546 40.3921 37.1758 40.208 38.1211 40.0078C39.6021 39.688 39.4995 38.2881 38.9639 38.0322C34.623 35.9678 35.5762 36.8081 34.71 36.1279C36.9155 33.4639 40.2402 30.6958 41.54 21.728C41.6426 21.0161 41.5557 20.5679 41.54 19.9917C41.5322 19.6396 41.6108 19.5039 42.0049 19.4639C43.0923 19.3359 44.1479 19.0317 45.1167 18.4878C47.9292 16.9199 49.064 14.3438 49.3315 11.2559C49.3711 10.7837 49.3237 10.2959 48.8354 10.0479ZM24.3262 37.8398C20.1196 34.4639 18.0791 33.3521 17.2358 33.3999C16.4482 33.4482 16.5898 34.3682 16.7632 34.9678C16.9443 35.5601 17.1812 35.9683 17.5117 36.4878C17.7402 36.832 17.8979 37.3442 17.2832 37.728C15.9282 38.584 13.5728 37.4399 13.4624 37.3838C10.7207 35.7358 8.42822 33.5601 6.81348 30.584C5.25342 27.7197 4.34766 24.6479 4.19775 21.3677C4.1582 20.5757 4.38672 20.2959 5.15869 20.1519C6.17529 19.96 7.22314 19.9199 8.23926 20.0718C12.5327 20.7119 16.1885 22.6719 19.2529 25.7759C21.002 27.5439 22.3252 29.6558 23.6885 31.7202C25.1377 33.9121 26.6978 36 28.6831 37.7119C29.3843 38.312 29.9434 38.7681 30.479 39.104C28.8643 39.2881 26.1699 39.3281 24.3262 37.8398ZM26.3433 24.6001C26.3433 24.248 26.6191 23.9678 26.9658 23.9678C27.0444 23.9678 27.1152 23.9839 27.1782 24.0078C27.2651 24.04 27.3438 24.0879 27.4067 24.1602C27.5171 24.272 27.5801 24.4321 27.5801 24.6001C27.5801 24.9521 27.3042 25.2319 26.9575 25.2319C26.6108 25.2319 26.3433 24.9521 26.3433 24.6001ZM32.6064 27.8799C32.2046 28.0479 31.8027 28.1919 31.4165 28.208C30.8179 28.2397 30.1641 27.9922 29.8096 27.688C29.2583 27.2158 28.8643 26.9521 28.6987 26.1279C28.6279 25.7759 28.6675 25.2319 28.7305 24.9199C28.8721 24.248 28.7144 23.8159 28.2495 23.4238C27.8716 23.104 27.3911 23.0161 26.8633 23.0161C26.666 23.0161 26.4849 22.9277 26.3511 22.856C26.1304 22.7441 25.9492 22.4639 26.1226 22.1201C26.1777 22.0078 26.4458 21.7358 26.5088 21.688C27.2256 21.272 28.0527 21.4077 28.8169 21.7197C29.5259 22.0161 30.0615 22.5601 30.834 23.3281C31.6216 24.2559 31.7632 24.5117 32.2124 25.208C32.5669 25.752 32.8901 26.312 33.1104 26.9521C33.2446 27.3521 33.0713 27.6802 32.6064 27.8799Z" fill="#ffffff"/></g></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" rx="115" fill="#4D6BFE"/><g transform="translate(76.8,76.8) scale(7.168)"><path d="M48.8354 10.0479C48.3232 9.79199 48.1025 10.2798 47.8032 10.5278C47.7007 10.6079 47.6143 10.7119 47.5273 10.8076C46.7793 11.624 45.9048 12.1597 44.7622 12.0957C43.0923 12 41.666 12.5356 40.4058 13.8398C40.1377 12.2319 39.2476 11.272 37.8926 10.6558C37.1836 10.3359 36.4668 10.0156 35.9702 9.31982C35.6235 8.82373 35.5293 8.27197 35.356 7.72754C35.2456 7.3999 35.1353 7.06396 34.7651 7.00781C34.3633 6.94385 34.2056 7.2876 34.0479 7.57568C33.418 8.75195 33.1733 10.0479 33.1973 11.3599C33.2524 14.312 34.4736 16.6641 36.8999 18.3359C37.1758 18.5278 37.2466 18.7197 37.1597 19C36.9946 19.5757 36.7974 20.1357 36.624 20.7119C36.5137 21.0801 36.3486 21.1597 35.9624 21C34.6309 20.4321 33.481 19.5918 32.4644 18.5757C30.7393 16.8721 29.1792 14.9917 27.2334 13.52C26.7764 13.1758 26.3193 12.856 25.8467 12.5518C23.8618 10.584 26.1069 8.96777 26.627 8.77588C27.1704 8.57568 26.8159 7.8877 25.0591 7.896C23.3022 7.90381 21.6953 8.50391 19.647 9.30371C19.3477 9.42383 19.0322 9.51172 18.7095 9.58398C16.8501 9.22363 14.9199 9.14355 12.9033 9.37598C9.10596 9.80762 6.07275 11.6396 3.84326 14.7681C1.16455 18.5278 0.53418 22.7998 1.30664 27.2559C2.11768 31.9521 4.46582 35.8398 8.07373 38.8799C11.8159 42.0322 16.1255 43.5762 21.041 43.2803C24.0269 43.104 27.3516 42.6963 31.1016 39.4561C32.0469 39.936 33.0396 40.1279 34.686 40.272C35.9546 40.3921 37.1758 40.208 38.1211 40.0078C39.6021 39.688 39.4995 38.2881 38.9639 38.0322C34.623 35.9678 35.5762 36.8081 34.71 36.1279C36.9155 33.4639 40.2402 30.6958 41.54 21.728C41.6426 21.0161 41.5557 20.5679 41.54 19.9917C41.5322 19.6396 41.6108 19.5039 42.0049 19.4639C43.0923 19.3359 44.1479 19.0317 45.1167 18.4878C47.9292 16.9199 49.064 14.3438 49.3315 11.2559C49.3711 10.7837 49.3237 10.2959 48.8354 10.0479ZM24.3262 37.8398C20.1196 34.4639 18.0791 33.3521 17.2358 33.3999C16.4482 33.4482 16.5898 34.3682 16.7632 34.9678C16.9443 35.5601 17.1812 35.9683 17.5117 36.4878C17.7402 36.832 17.8979 37.3442 17.2832 37.728C15.9282 38.584 13.5728 37.4399 13.4624 37.3838C10.7207 35.7358 8.42822 33.5601 6.81348 30.584C5.25342 27.7197 4.34766 24.6479 4.19775 21.3677C4.1582 20.5757 4.38672 20.2959 5.15869 20.1519C6.17529 19.96 7.22314 19.9199 8.23926 20.0718C12.5327 20.7119 16.1885 22.6719 19.2529 25.7759C21.002 27.5439 22.3252 29.6558 23.6885 31.7202C25.1377 33.9121 26.6978 36 28.6831 37.7119C29.3843 38.312 29.9434 38.7681 30.479 39.104C28.8643 39.2881 26.1699 39.3281 24.3262 37.8398ZM26.3433 24.6001C26.3433 24.248 26.6191 23.9678 26.9658 23.9678C27.0444 23.9678 27.1152 23.9839 27.1782 24.0078C27.2651 24.04 27.3438 24.0879 27.4067 24.1602C27.5171 24.272 27.5801 24.4321 27.5801 24.6001C27.5801 24.9521 27.3042 25.2319 26.9575 25.2319C26.6108 25.2319 26.3433 24.9521 26.3433 24.6001ZM32.6064 27.8799C32.2046 28.0479 31.8027 28.1919 31.4165 28.208C30.8179 28.2397 30.1641 27.9922 29.8096 27.688C29.2583 27.2158 28.8643 26.9521 28.6987 26.1279C28.6279 25.7759 28.6675 25.2319 28.7305 24.9199C28.8721 24.248 28.7144 23.8159 28.2495 23.4238C27.8716 23.104 27.3911 23.0161 26.8633 23.0161C26.666 23.0161 26.4849 22.9277 26.3511 22.856C26.1304 22.7441 25.9492 22.4639 26.1226 22.1201C26.1777 22.0078 26.4458 21.7358 26.5088 21.688C27.2256 21.272 28.0527 21.4077 28.8169 21.7197C29.5259 22.0161 30.0615 22.5601 30.834 23.3281C31.6216 24.2559 31.7632 24.5117 32.2124 25.208C32.5669 25.752 32.8901 26.312 33.1104 26.9521C33.2446 27.3521 33.0713 27.6802 32.6064 27.8799Z" fill="#ffffff"/></g></svg>