@jini-ai/agent-plugins 0.6.17 → 0.6.19

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 (335) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/NOTICE +14 -0
  3. package/README.md +35 -14
  4. package/charts-echarts/plugin.json +39 -0
  5. package/charts-echarts/skills/charts-echarts/SKILL.md +110 -0
  6. package/charts-recharts/plugin.json +32 -0
  7. package/charts-recharts/skills/charts-recharts/SKILL.md +108 -0
  8. package/cloudflare/NOTES.md +16 -0
  9. package/cloudflare/form-destinations/cloudflare-d1.mjs +9 -0
  10. package/cloudflare/lib/api.mjs +96 -0
  11. package/cloudflare/lib/assets.mjs +65 -0
  12. package/cloudflare/lib/dns.mjs +32 -0
  13. package/cloudflare/lib/domain-inventory.mjs +61 -0
  14. package/cloudflare/lib/domain-status.mjs +48 -0
  15. package/cloudflare/lib/domains.mjs +95 -0
  16. package/cloudflare/lib/forms.mjs +254 -0
  17. package/cloudflare/lib/pages-upload.mjs +51 -0
  18. package/cloudflare/lib/target.mjs +165 -0
  19. package/cloudflare/lib/workers-upload.mjs +48 -0
  20. package/cloudflare/lib/zones.mjs +25 -0
  21. package/cloudflare/mcp.json +4 -0
  22. package/cloudflare/plugin.json +96 -0
  23. package/cloudflare/skills/cloudflare/SKILL.md +57 -0
  24. package/cloudflare/skills/cloudflare/references/domains.md +31 -0
  25. package/cloudflare/skills/cloudflare/references/pages.md +18 -0
  26. package/cloudflare/skills/cloudflare/references/permissions.md +29 -0
  27. package/cloudflare/skills/cloudflare/references/troubleshooting.md +20 -0
  28. package/cloudflare/skills/cloudflare/references/workers.md +42 -0
  29. package/cloudflare/targets/cloudflare-pages.mjs +12 -0
  30. package/cloudflare/targets/cloudflare-workers.mjs +17 -0
  31. package/cloudflare/tovu-consent.json +6 -0
  32. package/cloudflare/tovu-deploy-targets.json +630 -0
  33. package/cloudflare/tovu-form-destinations.json +12 -0
  34. package/cloudflare/worker/forms-receiver.mjs +249 -0
  35. package/composio/mcp.json +10 -0
  36. package/composio/plugin.json +68 -0
  37. package/composio/skills/composio/SKILL.md +85 -0
  38. package/dist/core/identity-namespace.d.ts +12 -0
  39. package/dist/core/identity-namespace.js +20 -0
  40. package/dist/folder-zip-node.js +29 -7
  41. package/dist/lifecycle/bundled-digests.js +4 -3
  42. package/dist/lifecycle/install.d.ts +7 -0
  43. package/dist/lifecycle/install.js +45 -3
  44. package/dist/lifecycle/manifest.d.ts +2 -2
  45. package/dist/lifecycle/manifest.js +5 -3
  46. package/dist/lifecycle/node.js +2 -0
  47. package/dist/lifecycle/persistent-state.js +1 -1
  48. package/dist/lifecycle/ports.d.ts +8 -0
  49. package/dist/lifecycle/seed-bundled.js +4 -1
  50. package/dist/manifest.d.ts +1 -1
  51. package/dist/manifest.js +3 -2
  52. package/fly/deploy-configs/fly.mjs +115 -0
  53. package/fly/deploy-ops/fly.mjs +102 -0
  54. package/fly/mcp.json +4 -0
  55. package/fly/plugin.json +81 -0
  56. package/fly/scripts/render-deploy-workflow.mjs +150 -0
  57. package/fly/skills/fly/SKILL.md +43 -0
  58. package/fly/skills/fly/references/fly-deploy.template.yml +117 -0
  59. package/fly/skills/fly/references/fly.template.toml +81 -0
  60. package/fly/tovu-credential-schemes.json +11 -0
  61. package/fly/tovu-deploy-configs.json +21 -0
  62. package/fly/tovu-deploy-ops.json +14 -0
  63. package/github/deploy-ops/github-actions.mjs +164 -0
  64. package/github/deployments/github.mjs +415 -0
  65. package/github/mcp.json +4 -0
  66. package/github/plugin.json +105 -0
  67. package/github/skills/github/SKILL.md +249 -0
  68. package/github/skills/github/references/actions.md +224 -0
  69. package/github/skills/github/references/auth-and-tokens.md +140 -0
  70. package/github/skills/github/references/repo-files.md +135 -0
  71. package/github/skills/github/references/site-backup.md +159 -0
  72. package/github/skills/github/references/source-control.md +123 -0
  73. package/github/source-control/backup-push.mjs +242 -0
  74. package/github/source-control/commit-site.mjs +459 -0
  75. package/github/source-control/github.mjs +93 -0
  76. package/github/source-control/write-files.mjs +468 -0
  77. package/github/tovu-deploy-ops.json +13 -0
  78. package/github/tovu-source-control.json +117 -0
  79. package/github-pages/NOTES.md +19 -0
  80. package/github-pages/mcp.json +4 -0
  81. package/github-pages/plugin.json +48 -0
  82. package/github-pages/skills/github-pages/SKILL.md +44 -0
  83. package/github-pages/targets/github-pages.mjs +543 -0
  84. package/github-pages/tovu-deploy-targets.json +327 -0
  85. package/google-login/exports/google-g.svg +1 -0
  86. package/google-login/exports/sign-in-button.css +68 -0
  87. package/google-login/exports/sign-in-button.html +4 -0
  88. package/google-login/mcp.json +4 -0
  89. package/google-login/plugin.json +59 -0
  90. package/google-login/skills/google-login/SKILL.md +96 -0
  91. package/google-login/tovu-login-providers.json +21 -0
  92. package/google-workspace/README.md +132 -0
  93. package/google-workspace/form-destinations/README.md +87 -0
  94. package/google-workspace/form-destinations/dedupe.mjs +43 -0
  95. package/google-workspace/form-destinations/delivery-result.mjs +69 -0
  96. package/google-workspace/form-destinations/mapping.mjs +152 -0
  97. package/google-workspace/form-destinations/sheets.mjs +202 -0
  98. package/google-workspace/lib/auth.mjs +81 -0
  99. package/google-workspace/lib/drive-files.mjs +54 -0
  100. package/google-workspace/lib/form-destination-settings.mjs +72 -0
  101. package/google-workspace/lib/http.mjs +168 -0
  102. package/google-workspace/lib/picker-flow.mjs +171 -0
  103. package/google-workspace/lib/picker-selection.mjs +107 -0
  104. package/google-workspace/lib/product-state.mjs +93 -0
  105. package/google-workspace/lib/request-policy.mjs +163 -0
  106. package/google-workspace/lib/sheets-a1.mjs +79 -0
  107. package/google-workspace/lib/sheets-admin-csv.mjs +82 -0
  108. package/google-workspace/lib/sheets-admin-request-kinds.mjs +183 -0
  109. package/google-workspace/lib/sheets-client.mjs +286 -0
  110. package/google-workspace/lib/surface-actions.mjs +107 -0
  111. package/google-workspace/mcp.json +4 -0
  112. package/google-workspace/plugin.json +77 -0
  113. package/google-workspace/products.json +12 -0
  114. package/google-workspace/skills/google-workspace/SKILL.md +82 -0
  115. package/google-workspace/skills/google-workspace/references/connection.md +102 -0
  116. package/google-workspace/skills/google-workspace/references/form-delivery.md +50 -0
  117. package/google-workspace/skills/google-workspace/references/sheets.md +149 -0
  118. package/google-workspace/skills/google-workspace/references/troubleshooting.md +31 -0
  119. package/google-workspace/tools/account-info.mjs +18 -0
  120. package/google-workspace/tools/sheets/README.md +54 -0
  121. package/google-workspace/tools/sheets/copy-tab.mjs +23 -0
  122. package/google-workspace/tools/sheets/copy.mjs +23 -0
  123. package/google-workspace/tools/sheets/create.mjs +35 -0
  124. package/google-workspace/tools/sheets/find-replace.mjs +38 -0
  125. package/google-workspace/tools/sheets/find.mjs +52 -0
  126. package/google-workspace/tools/sheets/get-by-data-filter.mjs +23 -0
  127. package/google-workspace/tools/sheets/get.mjs +22 -0
  128. package/google-workspace/tools/sheets/values-append.mjs +27 -0
  129. package/google-workspace/tools/sheets/values-batch-clear.mjs +20 -0
  130. package/google-workspace/tools/sheets/values-batch-get.mjs +20 -0
  131. package/google-workspace/tools/sheets/values-batch-update.mjs +29 -0
  132. package/google-workspace/tools/sheets/values-by-data-filter.mjs +62 -0
  133. package/google-workspace/tools/sheets/values-clear.mjs +20 -0
  134. package/google-workspace/tools/sheets/values-get.mjs +21 -0
  135. package/google-workspace/tools/sheets/values-update.mjs +26 -0
  136. package/google-workspace/tools/sheets-admin/README.md +45 -0
  137. package/google-workspace/tools/sheets-admin/basic-filter.mjs +32 -0
  138. package/google-workspace/tools/sheets-admin/batch-update.mjs +25 -0
  139. package/google-workspace/tools/sheets-admin/developer-metadata.mjs +28 -0
  140. package/google-workspace/tools/sheets-admin/export-csv.mjs +67 -0
  141. package/google-workspace/tools/sheets-admin/filter-views.mjs +38 -0
  142. package/google-workspace/tools/sheets-admin/format.mjs +70 -0
  143. package/google-workspace/tools/sheets-admin/named-ranges.mjs +42 -0
  144. package/google-workspace/tools/sheets-admin/open-existing.mjs +34 -0
  145. package/google-workspace/tools/sheets-admin/protected-ranges-get.mjs +18 -0
  146. package/google-workspace/tools/sheets-admin/sort.mjs +18 -0
  147. package/google-workspace/tools/sheets-admin/tab-modify.mjs +36 -0
  148. package/google-workspace/tovu-admin-surfaces.json +63 -0
  149. package/google-workspace/tovu-agent-tools.json +1178 -0
  150. package/google-workspace/tovu-connections.json +35 -0
  151. package/google-workspace/tovu-form-destinations.json +10 -0
  152. package/google-workspace/ui/README.md +73 -0
  153. package/google-workspace/ui/connection-panel.handler.mjs +167 -0
  154. package/google-workspace/ui/connection-panel.html +17 -0
  155. package/google-workspace/ui/connection-panel.mjs +189 -0
  156. package/google-workspace/ui/connection-view.mjs +120 -0
  157. package/google-workspace/ui/destination-panel.handler.mjs +106 -0
  158. package/google-workspace/ui/destination-panel.html +17 -0
  159. package/google-workspace/ui/destination-panel.mjs +150 -0
  160. package/google-workspace/ui/dom.mjs +140 -0
  161. package/google-workspace/ui/panel.css +170 -0
  162. package/google-workspace/ui/picker-flow.mjs +87 -0
  163. package/google-workspace/ui/picker-view.mjs +44 -0
  164. package/google-workspace/ui/picker.handler.mjs +56 -0
  165. package/google-workspace/ui/picker.html +17 -0
  166. package/google-workspace/ui/picker.mjs +88 -0
  167. package/google-workspace/ui/surface-client.mjs +227 -0
  168. package/higgsfield-media/mcp.json +9 -0
  169. package/higgsfield-media/plugin.json +109 -0
  170. package/higgsfield-media/skills/higgsfield-media/SKILL.md +325 -0
  171. package/higgsfield-media/skills/higgsfield-media/references/failure-modes.md +116 -0
  172. package/higgsfield-media/skills/higgsfield-media/references/models-and-plan-gates.md +142 -0
  173. package/jev/mcp.json +4 -0
  174. package/jev/plugin.json +44 -0
  175. package/jev/skills/jev/SKILL.md +308 -0
  176. package/jev/skills/jev/references/cookbooks.md +197 -0
  177. package/jev/skills/jev/references/jaggedness.md +135 -0
  178. package/jev/skills/jev/references/javascript-sdk.md +146 -0
  179. package/jev/skills/jev/references/patterns.md +123 -0
  180. package/jev/skills/jev/references/primitives-reference.md +187 -0
  181. package/langfuse/plugin.json +22 -0
  182. package/meilisearch/mcp.json +4 -0
  183. package/meilisearch/plugin.json +54 -0
  184. package/meilisearch/providers/meilisearch.mjs +651 -0
  185. package/meilisearch/skills/meilisearch/SKILL.md +43 -0
  186. package/meilisearch/tovu-search-providers.json +10 -0
  187. package/netlify/NOTES.md +13 -0
  188. package/netlify/mcp.json +4 -0
  189. package/netlify/plugin.json +47 -0
  190. package/netlify/skills/netlify/SKILL.md +39 -0
  191. package/netlify/targets/netlify.mjs +384 -0
  192. package/netlify/tovu-consent.json +6 -0
  193. package/netlify/tovu-deploy-targets.json +163 -0
  194. package/opentelemetry/mcp.json +4 -0
  195. package/opentelemetry/plugin.json +52 -0
  196. package/opentelemetry/skills/opentelemetry/SKILL.md +39 -0
  197. package/package.json +161 -6
  198. package/posthog/mcp.json +4 -0
  199. package/posthog/plugin.json +46 -0
  200. package/posthog/skills/posthog/SKILL.md +36 -0
  201. package/railway/deploy-configs/railway.mjs +135 -0
  202. package/railway/deploy-ops/railway.mjs +167 -0
  203. package/railway/mcp.json +4 -0
  204. package/railway/plugin.json +45 -0
  205. package/railway/skills/railway/SKILL.md +66 -0
  206. package/railway/tovu-deploy-configs.json +16 -0
  207. package/railway/tovu-deploy-ops.json +11 -0
  208. package/render/deploy-configs/render.mjs +141 -0
  209. package/render/deploy-ops/render.mjs +152 -0
  210. package/render/mcp.json +4 -0
  211. package/render/plugin.json +47 -0
  212. package/render/skills/render/SKILL.md +63 -0
  213. package/render/tovu-deploy-configs.json +16 -0
  214. package/render/tovu-deploy-ops.json +11 -0
  215. package/resend/NOTES.md +52 -0
  216. package/resend/adapters/resend.mjs +161 -0
  217. package/resend/lib/domains.mjs +137 -0
  218. package/resend/lib/errors.mjs +25 -0
  219. package/resend/lib/feedback.mjs +118 -0
  220. package/resend/mcp.json +13 -0
  221. package/resend/plugin.json +192 -0
  222. package/resend/skills/resend/SKILL.md +107 -0
  223. package/resend/tovu-mail-adapters.json +22 -0
  224. package/s3-compatible/NOTES.md +11 -0
  225. package/s3-compatible/mcp.json +4 -0
  226. package/s3-compatible/object-stores/s3-compatible.mjs +287 -0
  227. package/s3-compatible/plugin.json +56 -0
  228. package/s3-compatible/skills/s3-compatible/SKILL.md +39 -0
  229. package/s3-compatible/targets/s3-compatible.mjs +1054 -0
  230. package/s3-compatible/tovu-deploy-targets.json +432 -0
  231. package/s3-compatible/tovu-object-stores.json +29 -0
  232. package/sentry/mcp.json +4 -0
  233. package/sentry/plugin.json +49 -0
  234. package/sentry/skills/sentry/SKILL.md +34 -0
  235. package/shopify/mcp.json +4 -0
  236. package/shopify/plugin.json +22 -0
  237. package/shopify/skills/shopify/SKILL.md +103 -0
  238. package/shopify/skills/shopify/references/catalog-sync.md +74 -0
  239. package/shopify/skills/shopify/references/domains.md +26 -0
  240. package/shopify/skills/shopify/references/live-storefront.md +34 -0
  241. package/shopify/skills/shopify/references/storefront-snippets.md +21 -0
  242. package/site-compliance/mcp.json +4 -0
  243. package/site-compliance/plugin.json +34 -0
  244. package/site-compliance/skills/site-compliance/SKILL.md +220 -0
  245. package/site-compliance/skills/site-compliance/references/ccpa-cpra.md +115 -0
  246. package/site-compliance/skills/site-compliance/references/gdpr-eu-uk.md +143 -0
  247. package/site-compliance/skills/site-compliance/references/wcag-2-2.md +129 -0
  248. package/site-import/mcp.json +4 -0
  249. package/site-import/plugin.json +41 -0
  250. package/site-import/skills/site-import/SKILL.md +232 -0
  251. package/site-import/skills/site-import/references/content-mapping.md +166 -0
  252. package/site-import/skills/site-import/references/discovery-and-classification.md +117 -0
  253. package/site-import/skills/site-import/references/theme-extraction.md +34 -0
  254. package/slide-design/com.anthropic.claude-code/hooks/hooks.json +4 -0
  255. package/slide-design/mcp.json +4 -0
  256. package/slide-design/plugin.json +26 -0
  257. package/slide-design/skills/slide-design/SKILL.md +103 -0
  258. package/slide-design/skills/slide-design/references/charts-and-exhibits.md +100 -0
  259. package/slide-design/skills/slide-design/references/html-deck.md +175 -0
  260. package/slide-design/skills/slide-design/references/layout-and-style.md +131 -0
  261. package/slide-design/skills/slide-design/references/review.md +93 -0
  262. package/slide-design/skills/slide-design/references/storyline.md +118 -0
  263. package/stripe/plugin.json +22 -0
  264. package/supabase/mcp.json +26 -0
  265. package/supabase/plugin.json +78 -0
  266. package/supabase/skills/supabase/SKILL.md +169 -0
  267. package/supabase/skills/supabase/references/failure-modes.md +101 -0
  268. package/ui-ux-design/CHANGELOG.md +28 -0
  269. package/ui-ux-design/LICENSE +201 -0
  270. package/ui-ux-design/NOTICE +14 -0
  271. package/ui-ux-design/README.md +62 -0
  272. package/ui-ux-design/com.anthropic.claude-code/hooks/hooks.json +4 -0
  273. package/ui-ux-design/mcp.json +4 -0
  274. package/ui-ux-design/plugin.json +26 -0
  275. package/ui-ux-design/skills/frontend-accessibility/SKILL.md +60 -0
  276. package/ui-ux-design/skills/gstack-design/SKILL.md +53 -0
  277. package/ui-ux-design/skills/gstack-design/references/consultation.md +30 -0
  278. package/ui-ux-design/skills/gstack-design/references/html.md +29 -0
  279. package/ui-ux-design/skills/gstack-design/references/review.md +28 -0
  280. package/ui-ux-design/skills/gstack-design/references/shotgun.md +33 -0
  281. package/ui-ux-design/skills/gstack-design/references/upstream-notes.md +23 -0
  282. package/ui-ux-design/skills/interface-design/ORIGINAL.md +16 -0
  283. package/ui-ux-design/skills/interface-design/SKILL.md +41 -0
  284. package/ui-ux-design/skills/interface-design/references/commands/audit.md +40 -0
  285. package/ui-ux-design/skills/interface-design/references/commands/critique.md +21 -0
  286. package/ui-ux-design/skills/interface-design/references/commands/extract.md +34 -0
  287. package/ui-ux-design/skills/interface-design/references/commands/init.md +31 -0
  288. package/ui-ux-design/skills/interface-design/references/commands/status.md +22 -0
  289. package/ui-ux-design/skills/interface-design/references/examples/system-precision.md +29 -0
  290. package/ui-ux-design/skills/interface-design/references/examples/system-warmth.md +29 -0
  291. package/ui-ux-design/skills/interface-design/references/system-template.md +57 -0
  292. package/ui-ux-design/skills/shadcn-ui/README.md +244 -0
  293. package/ui-ux-design/skills/shadcn-ui/SKILL.md +333 -0
  294. package/ui-ux-design/skills/shadcn-ui/examples/auth-layout.tsx +177 -0
  295. package/ui-ux-design/skills/shadcn-ui/examples/data-table.tsx +313 -0
  296. package/ui-ux-design/skills/shadcn-ui/examples/form-pattern.tsx +177 -0
  297. package/ui-ux-design/skills/shadcn-ui/resources/component-catalog.md +481 -0
  298. package/ui-ux-design/skills/shadcn-ui/resources/customization-guide.md +516 -0
  299. package/ui-ux-design/skills/shadcn-ui/resources/migration-guide.md +463 -0
  300. package/ui-ux-design/skills/shadcn-ui/resources/setup-guide.md +412 -0
  301. package/ui-ux-design/skills/shadcn-ui/scripts/verify-setup.sh +134 -0
  302. package/ui-ux-design/skills/ui-ux-design/SKILL.md +171 -0
  303. package/ui-ux-design/skills/ui-ux-design/references/brand-and-voice.md +78 -0
  304. package/ui-ux-design/skills/ui-ux-design/references/chrome-mobile-test-matrix.md +137 -0
  305. package/ui-ux-design/skills/ui-ux-design/references/components-and-states.md +112 -0
  306. package/ui-ux-design/skills/ui-ux-design/references/delight-and-motion.md +54 -0
  307. package/ui-ux-design/skills/ui-ux-design/references/foundations.md +145 -0
  308. package/ui-ux-design/skills/ui-ux-design/references/inclusive-ai-imagery.md +57 -0
  309. package/ui-ux-design/skills/ui-ux-design/references/kole-jain-uiux-concepts.md +104 -0
  310. package/ui-ux-design/skills/ui-ux-design/references/kuinetic-reference.md +184 -0
  311. package/ui-ux-design/skills/ui-ux-design/references/openai-frontend-skill.md +139 -0
  312. package/ui-ux-design/skills/ui-ux-design/references/premium-ui.md +55 -0
  313. package/ui-ux-design/skills/ui-ux-design/references/research-and-validation.md +88 -0
  314. package/ui-ux-design/skills/ui-ux-design/references/sam-crawford-premium-websites.md +103 -0
  315. package/ui-ux-design/skills/ui-ux-design/references/self-made-web-designer-core-skills.md +119 -0
  316. package/ui-ux-design/skills/ui-ux-design/references/ui-animation.md +173 -0
  317. package/ui-ux-design/skills/ui-ux-design/references/ui-loop.md +234 -0
  318. package/ui-ux-design/skills/ui-ux-design/references/visual-storytelling.md +54 -0
  319. package/ui-ux-design/skills/vercel-web-design-guidelines/SKILL.md +39 -0
  320. package/ui-ux-design/skills/web-compliance/SKILL.md +134 -0
  321. package/ui-ux-design/skills/web-compliance/references/audit-evidence.md +59 -0
  322. package/vercel/NOTES.md +23 -0
  323. package/vercel/mcp.json +4 -0
  324. package/vercel/object-stores/vercel-blob.mjs +147 -0
  325. package/vercel/plugin.json +47 -0
  326. package/vercel/skills/vercel/SKILL.md +48 -0
  327. package/vercel/targets/vercel.mjs +586 -0
  328. package/vercel/tovu-consent.json +6 -0
  329. package/vercel/tovu-deploy-targets.json +219 -0
  330. package/vercel/tovu-object-stores.json +10 -0
  331. package/wordpress/NOTES.md +33 -0
  332. package/wordpress/plugin.json +30 -0
  333. package/wordpress/skills/wordpress/SKILL.md +43 -0
  334. package/wordpress/skills/wordpress/references/free-site-redirect.md +46 -0
  335. package/wordpress/skills/wordpress/references/subdomain.md +66 -0
@@ -0,0 +1,106 @@
1
+ /**
2
+ * @file Server-side handlers of the form-destination panel (`ui/destination-panel.html`), loaded by
3
+ * the host as this surface's `handlerModule` (never served to the frame).
4
+ *
5
+ * Exists so a person can say "submissions of this form go to this tab of this spreadsheet": the
6
+ * spreadsheet comes from the Picker flow (the same verified actions as the Picker panel), the tab is
7
+ * chosen by its numeric sheet id (renaming a tab never moves deliveries), and every save re-checks
8
+ * the spreadsheet and tab through Google before anything is stored, so a pasted id grants nothing.
9
+ * Saving asks the person to confirm that removing the destination later leaves the rows already
10
+ * written in the spreadsheet (local delete never purges external rows). A saved destination is the
11
+ * form's delivery binding itself, written through the host's `deliveryBindings` capability.
12
+ */
13
+ import { metadataOf, readPickedSpreadsheets } from '../lib/picker-flow.mjs';
14
+ import { verifySpreadsheetAccess } from '../lib/picker-selection.mjs';
15
+ import { assertSpreadsheetId, getSpreadsheet } from '../lib/sheets-client.mjs';
16
+ import { CONNECTION_ID, sheetsUnavailableReason, surfaceAccount, surfaceRefusal, surfaceSheetsClient, withSurfaceFailures } from '../lib/surface-actions.mjs';
17
+ import { FORM_SLUG_PATTERN, MAX_FORM_DESTINATIONS, readFormDestinations, saveFormDestination } from '../lib/form-destination-settings.mjs';
18
+ import { createPickerHandlers } from './picker.handler.mjs';
19
+
20
+ const TAB_FIELDS = 'sheets.properties(sheetId,title,sheetType)';
21
+ const MAX_TABS = 200;
22
+
23
+ /** The verified spreadsheet and its grid tabs (`{ sheetId, title }`, at most 200). @complexity O(t): three requests. */
24
+ async function spreadsheetTabs(capabilities, spreadsheetId) {
25
+ const fileId = assertSpreadsheetId({ id: spreadsheetId }, { code: 'invalid-selection' });
26
+ const client = surfaceSheetsClient({ capabilities });
27
+ const spreadsheet = await verifySpreadsheetAccess({ client, connectionRef: CONNECTION_ID, fileId });
28
+ const body = await getSpreadsheet({ client, connectionRef: CONNECTION_ID, spreadsheetId: fileId, fields: TAB_FIELDS });
29
+ const grids = (Array.isArray(body?.sheets) ? body.sheets : []).map((sheet) => sheet?.properties).filter((tab) => Number.isInteger(tab?.sheetId) && (tab.sheetType ?? 'GRID') === 'GRID');
30
+ return { spreadsheet, tabs: grids.slice(0, MAX_TABS).map((tab) => ({ sheetId: tab.sheetId, title: String(tab.title ?? '') })) };
31
+ }
32
+
33
+ /** The current account id when Sheets is usable, else null (the list still shows, unchecked). @complexity O(1). */
34
+ async function currentAccountId(capabilities) {
35
+ return (await sheetsUnavailableReason({ capabilities })) === null ? (await surfaceAccount({ capabilities })).accountId : null;
36
+ }
37
+
38
+ /** The panel's list: saved destinations, picked spreadsheets and, when supported, site forms.
39
+ * Absence preserves older hosts' typed fallback; denied access must fail, never enable typing.
40
+ * @complexity O(d + p + f), with forms bounded by the host capability session. */
41
+ async function listView({ capabilities }, _options = {}) {
42
+ const accountId = await currentAccountId(capabilities);
43
+ const saved = await readFormDestinations({ deliveryBindings: capabilities.deliveryBindings });
44
+ const destinations = Object.entries(saved).map(([formSlug, destination]) => ({
45
+ formSlug, ...metadataOf(destination), sheetId: destination.sheetId, tabTitle: destination.tabTitle,
46
+ ...(accountId === null ? {} : { sameAccount: destination.accountId === accountId }),
47
+ }));
48
+ return {
49
+ destinations, picked: (await readPickedSpreadsheets({ state: capabilities.state })).map(metadataOf),
50
+ ...(capabilities.forms === undefined ? {} : { forms: await capabilities.forms.list({}) }),
51
+ };
52
+ }
53
+
54
+ /** Refuses a save the person has not fully confirmed or that names no valid form. @complexity O(1). */
55
+ function assertSaveInput(input, fail) {
56
+ if (!FORM_SLUG_PATTERN.test(input.formSlug)) throw surfaceRefusal({ fail, code: 'invalid-destination' });
57
+ if (input.confirmExternalRows !== true) throw surfaceRefusal({ fail, code: 'confirm-external-rows' });
58
+ }
59
+
60
+ /** Verifies and stores one form's destination as its binding. @complexity O(t + d): four requests, one binding write. */
61
+ async function saveDestination({ capabilities, input, fail }) {
62
+ assertSaveInput(input, fail);
63
+ const account = await surfaceAccount({ capabilities });
64
+ const { spreadsheet, tabs } = await spreadsheetTabs(capabilities, input.spreadsheetId);
65
+ const tab = tabs.find((candidate) => candidate.sheetId === input.sheetId);
66
+ if (tab === undefined) throw surfaceRefusal({ fail, code: 'tab-not-found' });
67
+ const destinations = await readFormDestinations({ deliveryBindings: capabilities.deliveryBindings });
68
+ const existing = Object.hasOwn(destinations, input.formSlug) ? destinations[input.formSlug] : undefined;
69
+ if (existing === undefined && Object.keys(destinations).length >= MAX_FORM_DESTINATIONS) throw surfaceRefusal({ fail, code: 'too-many-destinations' });
70
+ const destination = { spreadsheetId: spreadsheet.spreadsheetId, title: spreadsheet.title, sheetId: tab.sheetId, tabTitle: tab.title, accountId: account.accountId };
71
+ await saveFormDestination({ deliveryBindings: capabilities.deliveryBindings, formSlug: input.formSlug, destination }, existing === undefined ? {} : { bindingId: existing.bindingId });
72
+ return listView({ capabilities });
73
+ }
74
+
75
+ /**
76
+ * Builds the panel's handler module: the Picker panel's bootstrap and selection actions, plus the
77
+ * destination actions.
78
+ * @param {object} [_required]
79
+ * @param {{ now?: () => number, randomId?: () => string }} [optional] Passed to the Picker flow (tests).
80
+ * @returns {{ bootstrap: Function, actions: Record<string, Function> }}
81
+ * @complexity O(1) to build.
82
+ */
83
+ export function createDestinationPanelHandlers(_required = {}, optional = {}) {
84
+ const picker = createPickerHandlers({}, optional);
85
+ const act = (fail, run) => withSurfaceFailures({ fail, run });
86
+ return {
87
+ bootstrap: picker.bootstrap,
88
+ actions: {
89
+ ...picker.actions,
90
+ 'list-destinations': ({ capabilities, fail }) => act(fail, () => listView({ capabilities })),
91
+ 'list-tabs': ({ capabilities, input, fail }) => act(fail, async () => {
92
+ const { spreadsheet, tabs } = await spreadsheetTabs(capabilities, input.spreadsheetId);
93
+ return { spreadsheet: metadataOf(spreadsheet), tabs };
94
+ }),
95
+ 'save-destination': (call) => act(call.fail, () => saveDestination(call)),
96
+ /** Removes one form's binding; rows already in the spreadsheet stay where they are. */
97
+ 'remove-destination': ({ capabilities, input, fail }) => act(fail, async () => {
98
+ const destinations = await readFormDestinations({ deliveryBindings: capabilities.deliveryBindings });
99
+ if (Object.hasOwn(destinations, input.formSlug)) await capabilities.deliveryBindings.remove({ bindingId: destinations[input.formSlug].bindingId });
100
+ return listView({ capabilities });
101
+ }),
102
+ },
103
+ };
104
+ }
105
+
106
+ export default createDestinationPanelHandlers();
@@ -0,0 +1,17 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Form destinations</title>
7
+ <!-- Static and secret-free: served without credentials. Start data arrives in the host's init message. -->
8
+ <link rel="stylesheet" href="panel.css">
9
+ <script type="module" src="destination-panel.mjs"></script>
10
+ </head>
11
+ <body>
12
+ <main>
13
+ <h1 class="sr-only">Form destinations</h1>
14
+ <div id="app" aria-live="polite"><p class="muted">Loading…</p></div>
15
+ </main>
16
+ </body>
17
+ </html>
@@ -0,0 +1,150 @@
1
+ /**
2
+ * @file The form-destination panel in the browser: lists each form's saved spreadsheet and tab, and
3
+ * lets a person choose a spreadsheet (a picked one, or the Picker flow), a tab, and save. The server
4
+ * re-verifies the spreadsheet and tab on every save; this file only collects the choice.
5
+ *
6
+ * A saved destination is the form's delivery binding (the server writes it), so there is nothing to
7
+ * copy anywhere. Saving needs the "rows stay in the spreadsheet" confirmation, because removing a destination here
8
+ * (or deleting a form or submission on the site) never deletes rows Google already holds.
9
+ */
10
+ import { connectToHost, failureText, h, notice, show, tip } from './dom.mjs';
11
+ import { chooseSpreadsheet } from './picker-flow.mjs';
12
+ import { CANCELLED_TEXT, EXTERNAL_ROWS_NOTE, pickerIntro } from './picker-view.mjs';
13
+
14
+ let client;
15
+ let app;
16
+ let draft;
17
+ let view;
18
+ let status;
19
+ /** 'error' after a failed step, else 'info' (the status line's colour). */
20
+ let statusTone;
21
+ /** Re-runs the failed step when the host said a retry can help ("Try again"), else undefined. */
22
+ let statusRetry;
23
+ let bootstrap;
24
+
25
+ const call = (action, payload = {}) => client.call({ action, payload });
26
+
27
+ /** Runs one server step; its failure becomes the status line. */
28
+ async function step(work) {
29
+ statusTone = 'info';
30
+ statusRetry = undefined;
31
+ try {
32
+ await work();
33
+ } catch (error) {
34
+ status = failureText(error);
35
+ statusTone = 'error';
36
+ statusRetry = error?.retryable === true ? () => step(work) : undefined;
37
+ }
38
+ render();
39
+ }
40
+
41
+ /** A spreadsheet became the draft's: load its tabs (the server verifies access first). */
42
+ async function useSpreadsheet(spreadsheetId) {
43
+ const { spreadsheet, tabs } = await call('list-tabs', { spreadsheetId });
44
+ Object.assign(draft, { spreadsheet, tabs, sheetId: tabs[0]?.sheetId ?? null });
45
+ }
46
+
47
+ function savedList() {
48
+ if (view.destinations.length === 0) return h('p', { class: 'muted empty' }, ['No form sends to Google Sheets yet.']);
49
+ return h('ul', { class: 'rows' }, view.destinations.map((entry) => h('li', { class: 'card destination' }, [
50
+ h('p', {}, [h('strong', {}, [entry.formSlug]), ` → ${entry.title} / ${entry.tabTitle}`]),
51
+ entry.sameAccount === false ? h('p', { class: 'notice warn' }, ['Chosen with a different Google account than the one connected now. Choose its spreadsheet again.']) : null,
52
+ h('div', { class: 'actions' }, [h('button', { type: 'button', class: 'small', onClick: () => step(async () => { view = await call('remove-destination', { formSlug: entry.formSlug }); status = `Removed ${entry.formSlug}. ${EXTERNAL_ROWS_NOTE}`; statusTone = 'ok'; }) }, ['Remove'])]),
53
+ ])));
54
+ }
55
+
56
+ function spreadsheetChooser() {
57
+ const options = view.picked.map((sheet) => h('option', { value: sheet.spreadsheetId, ...(draft.spreadsheet?.spreadsheetId === sheet.spreadsheetId ? { selected: true } : {}) }, [sheet.title]));
58
+ return h('div', { class: 'field-stack' }, [
59
+ view.picked.length > 0 ? h('label', {}, ['Spreadsheet', h('select', { onChange: (event) => step(() => useSpreadsheet(event.target.value)) }, [h('option', { value: '' }, ['Choose…']), ...options])]) : null,
60
+ // Same rule as the Picker panel: no start data (or an unavailable mode) means nothing to open.
61
+ bootstrap !== undefined && bootstrap.mode !== 'unavailable' ? h('button', { type: 'button', onClick: () => step(async () => {
62
+ const chosen = await chooseSpreadsheet({ client, bootstrap, onStatus: (text) => { status = text; render(); } });
63
+ if (chosen === null) { status = CANCELLED_TEXT; return; }
64
+ status = '';
65
+ await useSpreadsheet(chosen.spreadsheetId);
66
+ view = await call('list-destinations');
67
+ }) }, ['Choose another spreadsheet']) : null,
68
+ ]);
69
+ }
70
+
71
+ function tabChooser() {
72
+ if (draft.spreadsheet === null) return null;
73
+ return h('label', {}, ['Tab', h('select', { onChange: (event) => { draft.sheetId = Number(event.target.value); } },
74
+ draft.tabs.map((tab) => h('option', { value: String(tab.sheetId), ...(tab.sheetId === draft.sheetId ? { selected: true } : {}) }, [tab.title])))]);
75
+ }
76
+
77
+ /** Site choices use their stable slug as the value and their title as plain text. An empty list
78
+ * still means discovery is supported; typing is only for hosts that lack the capability.
79
+ * @complexity O(f) for the host's at most 200 choices. */
80
+ function formChooser() {
81
+ if (view.forms === undefined) return h('label', {}, ['Form short name', h('input', { type: 'text', value: draft.formSlug, maxlength: '64', autocomplete: 'off', onInput: (event) => { draft.formSlug = event.target.value.trim(); } })]);
82
+ return h('label', {}, ['Form', h('select', { name: 'formSlug', ...(view.forms.length === 0 ? { disabled: true } : {}), onChange: (event) => { draft.formSlug = event.target.value; } }, [
83
+ h('option', { value: '' }, [view.forms.length === 0 ? 'No forms available' : 'Choose a form…']),
84
+ ...view.forms.map((form) => h('option', { value: form.slug, ...(draft.formSlug === form.slug ? { selected: true } : {}) }, [form.title])),
85
+ ])]);
86
+ }
87
+
88
+ /** What the person still has to fill in before a save can work, or null. @complexity O(f), at most 200 form choices. */
89
+ function missingForSave() {
90
+ if (view.forms !== undefined && !view.forms.some((form) => form.slug === draft.formSlug)) return 'Choose a form.';
91
+ if (draft.formSlug === '') return "Enter the form's short name.";
92
+ if (draft.spreadsheet === null) return 'Choose a spreadsheet.';
93
+ if (!draft.confirmed) return 'Tick the box to confirm rows stay in the spreadsheet.';
94
+ return null;
95
+ }
96
+
97
+ function editor() {
98
+ const ready = bootstrap !== undefined && bootstrap.mode !== 'unavailable';
99
+ return h('section', { class: 'card', 'aria-labelledby': 'add-heading' }, [
100
+ h('div', { class: 'head' }, [
101
+ h('h2', { id: 'add-heading' }, ['Send a form to a spreadsheet']),
102
+ ready ? tip('About choosing a spreadsheet', pickerIntro({ bootstrap }), { bodyClass: 'intro' }) : null,
103
+ ]),
104
+ formChooser(),
105
+ spreadsheetChooser(),
106
+ tabChooser(),
107
+ h('label', { class: 'check confirm-rows' }, [h('input', { type: 'checkbox', ...(draft.confirmed ? { checked: true } : {}), onChange: (event) => { draft.confirmed = event.target.checked; } }), EXTERNAL_ROWS_NOTE]),
108
+ h('div', { class: 'actions' }, [h('button', { type: 'button', class: 'primary', onClick: () => step(async () => {
109
+ // Checked here first so the person reads what is missing, not the host's schema refusal.
110
+ const missing = missingForSave();
111
+ if (missing !== null) { status = missing; statusTone = 'error'; return; }
112
+ view = await call('save-destination', { formSlug: draft.formSlug, spreadsheetId: draft.spreadsheet?.spreadsheetId ?? '', sheetId: draft.sheetId ?? 0, confirmExternalRows: draft.confirmed });
113
+ // A save only succeeds with a chosen spreadsheet: the declared input refuses an empty
114
+ // spreadsheetId (minLength 1), and that id is non-empty only once `draft.spreadsheet` is set.
115
+ status = `Saved: ${draft.formSlug} sends to ${draft.spreadsheet.title}.`;
116
+ statusTone = 'ok';
117
+ }) }, ['Save destination'])]),
118
+ ]);
119
+ }
120
+
121
+ function render() {
122
+ // Not ready (no start data, Sheets off, not connected): the reason is the visible notice.
123
+ const ready = bootstrap !== undefined && bootstrap.mode !== 'unavailable';
124
+ show(app,
125
+ ready ? null : h('p', { class: 'notice warn intro' }, [pickerIntro({ bootstrap })]),
126
+ status ? notice({ text: status, tone: statusTone, retry: statusRetry }) : null,
127
+ ...(view === undefined ? [] : [
128
+ h('section', { class: 'card', 'aria-labelledby': 'saved-heading' }, [h('h2', { id: 'saved-heading' }, ['Saved destinations']), savedList()]),
129
+ editor(),
130
+ ]));
131
+ }
132
+
133
+ /**
134
+ * Starts the panel in a frame: connects to the host and loads the saved destinations once the host's
135
+ * init arrives. Exported so a test can start a fresh panel (fresh draft) in its own jsdom window; the
136
+ * last line starts the real one.
137
+ * @param {{ window: Window, document: Document }} required
138
+ */
139
+ export function initDestinationPanel(required) {
140
+ client = connectToHost({ window: required.window });
141
+ app = required.document.getElementById('app');
142
+ draft = { formSlug: '', spreadsheet: null, tabs: [], sheetId: null, confirmed: false };
143
+ view = undefined;
144
+ status = '';
145
+ statusTone = 'info';
146
+ bootstrap = undefined;
147
+ client.ready.then(() => { bootstrap = client.bootstrap(); return step(async () => { view = await call('list-destinations'); }); });
148
+ }
149
+
150
+ initDestinationPanel({ window, document });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * @file The few browser helpers the three panels share: connect to the host through the copied
3
+ * admin-surface client, build elements, and show status text.
4
+ *
5
+ * Exists so every panel builds its DOM the same safe way: text always goes through `textContent`
6
+ * (never `innerHTML`), because titles, labels and messages come from Google or the host and must
7
+ * never be parsed as markup inside the panel.
8
+ */
9
+ import { createAdminSurfaceClient } from './surface-client.mjs';
10
+
11
+ /**
12
+ * The client for this frame: listens to the parent's messages only (the client checks the source),
13
+ * and applies the admin's design tokens to this frame's root so `panel.css` matches the admin.
14
+ * @param {{ window: Window }} required - the frame's window (a test passes its own jsdom window).
15
+ */
16
+ export function connectToHost(required) {
17
+ const { window: frame } = required;
18
+ closeTipsOnOutsideClick(frame.document);
19
+ return createAdminSurfaceClient({
20
+ parent: frame.parent,
21
+ listen: (handler) => { frame.addEventListener('message', handler); return () => frame.removeEventListener('message', handler); },
22
+ }, { themeRoot: frame.document.documentElement });
23
+ }
24
+
25
+ /**
26
+ * An element with attributes, event handlers (`on<Event>` functions) and children (strings become text).
27
+ * @param {string} tag
28
+ * @param {Record<string, string | boolean | Function | undefined>} [props]
29
+ * @param {Array<Node | string | null | undefined>} [children]
30
+ * @returns {HTMLElement}
31
+ */
32
+ export function h(tag, props = {}, children = []) {
33
+ const element = document.createElement(tag);
34
+ for (const [name, value] of Object.entries(props)) {
35
+ if (typeof value === 'function') element.addEventListener(name.slice(2).toLowerCase(), value);
36
+ else if (value === true) element.setAttribute(name, '');
37
+ else if (typeof value === 'string') element.setAttribute(name, value);
38
+ else {
39
+ // False and absent props do not create attributes.
40
+ }
41
+ }
42
+ element.append(...children.filter((child) => child !== null && child !== undefined));
43
+ return element;
44
+ }
45
+
46
+ let nextTip = 0;
47
+
48
+ /**
49
+ * An "i" toggletip: the explanation shows on hover, keyboard focus or tap, instead of a paragraph
50
+ * on screen. The trigger is a focusable span (not a `<button>`), so the panels' button lists stay
51
+ * their actions only; its text stays in the DOM and is the trigger's accessible description.
52
+ * @param {string} label - what the explanation is about ("About Products").
53
+ * @param {string} text
54
+ * @param {{ bodyClass?: string }} [optional] - an extra class on the explanation (e.g. `intro`).
55
+ */
56
+ export function tip(label, text, optional = {}) {
57
+ nextTip += 1;
58
+ const id = `tip-${nextTip}`;
59
+ const toggle = (event) => {
60
+ if (event.type === 'keydown' && event.key !== 'Enter' && event.key !== ' ' && event.key !== 'Escape') return;
61
+ event.preventDefault();
62
+ const wrapper = event.currentTarget.parentElement;
63
+ const open = event.key === 'Escape' ? false : !wrapper.classList.contains('open');
64
+ wrapper.classList.toggle('open', open);
65
+ event.currentTarget.setAttribute('aria-expanded', String(open));
66
+ };
67
+ return h('span', { class: 'tip' }, [
68
+ h('span', { class: 'tip-icon', role: 'button', tabindex: '0', 'aria-label': label, 'aria-describedby': id, 'aria-expanded': 'false', onClick: toggle, onKeydown: toggle }, ['i']),
69
+ h('span', { class: optional.bodyClass ? `tip-body ${optional.bodyClass}` : 'tip-body', role: 'tooltip', id }, [text]),
70
+ ]);
71
+ }
72
+
73
+ /** Taps elsewhere close an open toggletip (once per document). */
74
+ function closeTipsOnOutsideClick(doc) {
75
+ if (doc.tipsWired) return;
76
+ doc.tipsWired = true;
77
+ doc.addEventListener('click', (event) => {
78
+ for (const open of doc.querySelectorAll('.tip.open')) {
79
+ if (!open.contains(event.target)) { open.classList.remove('open'); open.firstChild.setAttribute('aria-expanded', 'false'); }
80
+ }
81
+ });
82
+ }
83
+
84
+ /**
85
+ * A compact status line. `tone`: 'error' (default, plain `notice`), 'ok', 'info' or 'warn'. With
86
+ * `retry`, a "Try again" button sits inside the same box (outside the `.notice` text).
87
+ * @param {{ text: string, tone?: string, retry?: () => void }} required
88
+ */
89
+ export function notice(required) {
90
+ const { text, tone = 'error', retry } = required;
91
+ const line = h('p', { class: tone === 'error' ? 'notice' : `notice ${tone}`, role: 'status' }, [text]);
92
+ if (!retry) return line;
93
+ return h('div', { class: `notice-group ${tone}` }, [line, h('button', { type: 'button', class: 'small', onClick: retry }, ['Try again'])]);
94
+ }
95
+
96
+ const FOCUSABLE = 'button:not([disabled]), input:not([disabled]), select:not([disabled]), a[href], [tabindex="0"]';
97
+
98
+ /** What identifies a focused control across a re-render: its id, else its tag and visible name. */
99
+ function focusKey(element, root) {
100
+ if (!element || element === root || !root.contains(element)) return null;
101
+ return { id: element.id, tag: element.tagName, name: element.getAttribute('aria-label') ?? element.textContent.trim() };
102
+ }
103
+
104
+ function matchesFocusKey({ control, key }, _options = {}) {
105
+ if (key.id && control.id === key.id) return true;
106
+ return control.tagName === key.tag && (control.getAttribute('aria-label') ?? control.textContent.trim()) === key.name;
107
+ }
108
+
109
+ /**
110
+ * Replaces a region's content, keeping keyboard focus where the person was: on the same control
111
+ * when it is still there, else on the first control of the new content (never lost to `<body>`).
112
+ */
113
+ export function show(root, ...nodes) {
114
+ const key = focusKey(root.ownerDocument.activeElement, root) ?? root.pendingFocus ?? null;
115
+ root.pendingFocus = null;
116
+ root.replaceChildren(...nodes.filter(Boolean));
117
+ if (key === null) return;
118
+ const controls = [...root.querySelectorAll(FOCUSABLE)];
119
+ const same = controls.find(control => matchesFocusKey({ control, key }));
120
+ (same ?? controls[0])?.focus();
121
+ }
122
+
123
+ /** The sentence to show for a failed call: the host's or plugin's fixed message. */
124
+ export function failureText(error) {
125
+ // The host's schema check names JSON paths ("$.spreadsheetId has the wrong length"): developer text.
126
+ if (error?.code === 'invalid-payload') return 'Some details are missing or not valid. Check them and try again.';
127
+ return typeof error?.message === 'string' && error.message ? error.message : 'Something went wrong. Try again.';
128
+ }
129
+
130
+ /** Runs `work` with every button in `root` disabled, so one click sends one request. */
131
+ export async function busy(root, work) {
132
+ // Disabling the focused button drops focus to <body>; `show` restores it from here.
133
+ root.pendingFocus = focusKey(root.ownerDocument.activeElement, root);
134
+ const buttons = [...root.querySelectorAll('button')];
135
+ for (const control of buttons) control.disabled = true;
136
+ try { return await work(); } finally { for (const control of buttons) control.disabled = false; }
137
+ }
138
+
139
+ /** Resolves after `ms` (the desktop Picker and sign-in polls). */
140
+ export const wait = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
@@ -0,0 +1,170 @@
1
+ /* Shared styles of the Google Workspace admin panels. The CSP allows only this file ('self'), no inline style.
2
+ Colours, type, radius and spacing come from the admin: the host sends its design tokens and the surface
3
+ client sets them as `--jini-*` custom properties on <html> (plus `data-jini-scheme`), so these panels
4
+ match the admin and follow its light/dark switch. The `--fb-*` values are only for a host that sends none. */
5
+ :root {
6
+ color-scheme: light dark;
7
+ --fb-bg: #ffffff; --fb-raised: #f5f6f8; --fb-text: #1f2430; --fb-muted: #626a78; --fb-line: #e4e7eb; --fb-line-strong: #cfd4db;
8
+ --fb-accent: #2f6fde; --fb-accent-ink: #ffffff; --fb-danger: #b42318; --fb-ok: #18763b; --fb-warn: #845800;
9
+ }
10
+ @media (prefers-color-scheme: dark) {
11
+ :root:not([data-jini-scheme="light"]) {
12
+ --fb-bg: #17191e; --fb-raised: #1f2228; --fb-text: #eceef2; --fb-muted: #9aa1ad; --fb-line: #30343c; --fb-line-strong: #434852;
13
+ --fb-accent: #7aa7f5; --fb-accent-ink: #111318; --fb-danger: #f2877d; --fb-ok: #79d69b; --fb-warn: #ebc36b;
14
+ }
15
+ }
16
+ :root {
17
+ --bg: var(--jini-bg, var(--fb-bg));
18
+ --raised: var(--jini-surface, var(--fb-raised));
19
+ --text: var(--jini-text, var(--fb-text));
20
+ --muted: var(--jini-text-muted, var(--fb-muted));
21
+ --line: var(--jini-border, var(--fb-line));
22
+ --line-strong: var(--jini-border-strong, var(--fb-line-strong));
23
+ --accent: var(--jini-primary, var(--fb-accent));
24
+ --accent-ink: var(--jini-primary-ink, var(--fb-accent-ink));
25
+ --danger: var(--jini-danger, var(--fb-danger));
26
+ --ok: var(--jini-success, var(--fb-ok));
27
+ --warn: var(--jini-warning, var(--fb-warn));
28
+ --danger-bg: var(--jini-danger-bg, color-mix(in oklab, var(--danger) 9%, var(--bg)));
29
+ --ok-bg: var(--jini-success-bg, color-mix(in oklab, var(--ok) 10%, var(--bg)));
30
+ --warn-bg: var(--jini-warning-bg, color-mix(in oklab, var(--warn) 11%, var(--bg)));
31
+ --info-bg: color-mix(in oklab, var(--accent) 8%, var(--bg));
32
+ --radius-sm: var(--jini-radius-sm, 7px);
33
+ --radius-md: var(--jini-radius-md, 11px);
34
+ --space-1: var(--jini-space-1, .25rem); --space-2: var(--jini-space-2, .5rem); --space-3: var(--jini-space-3, .75rem); --space-4: var(--jini-space-4, 1rem);
35
+ --fs-xs: var(--jini-font-size-xs, .75rem); --fs-sm: var(--jini-font-size-sm, .8125rem);
36
+ --fs-base: var(--jini-font-size-base, .875rem); --fs-md: var(--jini-font-size-md, .95rem);
37
+ --focus: 0 0 0 3px color-mix(in oklab, var(--accent) 24%, transparent);
38
+ --measure: 40rem;
39
+ }
40
+ * { box-sizing: border-box; }
41
+ /* The type is set on body, not :root, so `rem` stays the browser's 16px like the admin's. */
42
+ body {
43
+ margin: 0; padding: var(--space-1) var(--space-1) var(--space-4); color: var(--text); background: var(--bg);
44
+ font: var(--fs-base)/1.5 var(--jini-font-ui, system-ui, -apple-system, "Segoe UI", sans-serif); -webkit-font-smoothing: antialiased;
45
+ }
46
+ main { max-width: var(--measure); display: grid; gap: var(--space-3); }
47
+ #app { display: grid; gap: var(--space-3); }
48
+ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
49
+ h2 { font-size: var(--fs-md); font-weight: 600; line-height: 1.3; margin: 0; }
50
+ p { margin: 0; }
51
+ .muted { color: var(--muted); font-size: var(--fs-sm); }
52
+
53
+ /* A section: one bordered panel per job, header row first. */
54
+ .card { border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--bg); padding: var(--space-3) var(--space-4); display: grid; gap: var(--space-3); }
55
+ .head { display: flex; align-items: center; gap: var(--space-2); position: relative; min-width: 0; }
56
+ .head h2 { overflow-wrap: anywhere; }
57
+
58
+ /* Connection status dot beside the heading; the heading's words carry the meaning. */
59
+ .dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--line-strong); }
60
+ .dot.ok { background: var(--ok); box-shadow: 0 0 0 3px var(--ok-bg); }
61
+ .dot.warn { background: var(--warn); box-shadow: 0 0 0 3px var(--warn-bg); }
62
+ .dot.info { background: var(--accent); box-shadow: 0 0 0 3px var(--info-bg); }
63
+
64
+ /* Buttons: the admin's sizes and weights. */
65
+ .actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
66
+ button {
67
+ font: inherit; font-size: .85rem; font-weight: 600; line-height: 1.2; padding: .5rem .95rem; min-height: 2.125rem;
68
+ border-radius: var(--radius-sm); border: 1px solid var(--line-strong); background: var(--bg); color: var(--text); cursor: pointer;
69
+ }
70
+ button:hover:not(:disabled) { background: var(--raised); }
71
+ button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
72
+ button.primary:hover:not(:disabled) { background: color-mix(in oklab, var(--accent) 88%, var(--text)); }
73
+ button.danger { background: var(--danger); border-color: var(--danger); color: var(--bg); }
74
+ button.danger:hover:not(:disabled) { background: color-mix(in oklab, var(--danger) 88%, var(--text)); }
75
+ button.danger-quiet { margin-left: auto; border-color: transparent; background: transparent; color: var(--danger); }
76
+ button.danger-quiet:hover:not(:disabled) { background: var(--danger-bg); }
77
+ button.small { padding: .3rem .7rem; min-height: 1.75rem; font-size: var(--fs-sm); }
78
+ button:disabled { opacity: .55; cursor: default; }
79
+ button:focus-visible, select:focus-visible, input:focus-visible, .tip-icon:focus-visible {
80
+ outline: 2px solid var(--accent); outline-offset: 2px; box-shadow: none;
81
+ }
82
+
83
+ /* Product rows: name + state, then explanation, grant, switch, all on one line. */
84
+ ul.rows { list-style: none; margin: 0; padding: 0; display: grid; }
85
+ ul.rows > li { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-2) 0; border-top: 1px solid var(--line); position: relative; }
86
+ ul.rows > li:first-child { border-top: 0; padding-top: 0; }
87
+ ul.rows > li:last-child { padding-bottom: 0; }
88
+ .product-name { display: grid; min-width: 0; }
89
+ .product-name strong { font-weight: 600; }
90
+ .state { color: var(--muted); font-size: var(--fs-sm); }
91
+ .state.warn { color: var(--warn); }
92
+ .product-name { margin-right: auto; }
93
+ .name-line { display: inline-flex; align-items: center; gap: var(--space-2); }
94
+
95
+ /* The product switch: the admin's 38×22 pill. `aria-pressed` is the state. */
96
+ button.switch { position: relative; flex: none; width: 38px; height: 22px; min-height: 0; padding: 0; border-radius: 999px; border: 1px solid var(--line-strong); background: var(--line-strong); }
97
+ button.switch::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .25); transition: transform .15s ease; }
98
+ button.switch[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); }
99
+ button.switch[aria-pressed="true"]::after { transform: translateX(16px); }
100
+ button.switch:hover:not(:disabled) { background: color-mix(in oklab, var(--line-strong) 80%, var(--text)); }
101
+ button.switch[aria-pressed="true"]:hover:not(:disabled) { background: color-mix(in oklab, var(--accent) 88%, var(--text)); }
102
+
103
+ /* "i" toggletip: hover, focus or tap shows the explanation under its row. */
104
+ .tip { display: inline-flex; flex: none; }
105
+ .tip-icon {
106
+ display: inline-grid; place-items: center; width: 1.05rem; height: 1.05rem; border-radius: 50%;
107
+ border: 1px solid var(--line-strong); color: var(--muted); font: 600 .7rem/1 Georgia, "Times New Roman", serif; font-style: italic;
108
+ cursor: help; user-select: none;
109
+ }
110
+ .tip-icon:hover, .tip.open .tip-icon { color: var(--text); border-color: var(--muted); }
111
+ .tip-body {
112
+ display: none; position: absolute; z-index: 10; left: 0; top: calc(100% + 6px); width: max-content; max-width: min(24rem, calc(100vw - 2rem));
113
+ padding: var(--space-2) var(--space-3); border-radius: var(--radius-sm); font-size: var(--fs-sm); line-height: 1.45; font-weight: 400;
114
+ background: var(--jini-overlay-tooltip-bg, var(--bg)); color: var(--jini-overlay-tooltip-text, var(--text));
115
+ border: 1px solid var(--jini-overlay-tooltip-border, var(--line)); box-shadow: 0 6px 20px rgb(0 0 0 / .14);
116
+ }
117
+ .tip:hover .tip-body, .tip-icon:focus-visible + .tip-body, .tip.open .tip-body { display: block; }
118
+
119
+ /* Compact inline notices; tone by class. Plain `notice` is a failure. */
120
+ .notice, .notice-group {
121
+ justify-self: start; max-width: 100%; font-size: var(--fs-sm); line-height: 1.45;
122
+ padding: var(--space-2) var(--space-3); border-radius: var(--radius-sm);
123
+ border: 1px solid color-mix(in oklab, var(--danger) 28%, var(--bg)); background: var(--danger-bg); color: var(--text);
124
+ }
125
+ .notice.ok, .notice-group.ok { border-color: color-mix(in oklab, var(--ok) 30%, var(--bg)); background: var(--ok-bg); }
126
+ .notice.warn, .notice-group.warn { border-color: color-mix(in oklab, var(--warn) 30%, var(--bg)); background: var(--warn-bg); }
127
+ .notice.info, .notice-group.info { border-color: color-mix(in oklab, var(--accent) 22%, var(--bg)); background: var(--info-bg); }
128
+ .notice-group { display: flex; align-items: center; gap: var(--space-3); }
129
+ .notice-group > .notice { padding: 0; border: 0; background: none; flex: 1; }
130
+ .notice-group > button { flex: none; }
131
+
132
+ /* Disconnect confirmation: inline, danger-tinted, inside the account card. */
133
+ .confirm { border: 1px solid color-mix(in oklab, var(--danger) 28%, var(--bg)); background: var(--danger-bg); border-radius: var(--radius-sm); padding: var(--space-3); display: grid; gap: var(--space-2); font-size: var(--fs-sm); }
134
+ .account-link { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); font-size: var(--fs-sm); color: var(--muted); }
135
+
136
+ /* Form controls: labels above, widths sized to content. */
137
+ label { display: grid; gap: var(--space-1); font-size: var(--fs-sm); font-weight: 600; justify-self: start; }
138
+ input, select {
139
+ font: inherit; font-size: var(--fs-base); font-weight: 400; padding: .45rem .6rem; min-height: 2.125rem;
140
+ border-radius: var(--radius-sm); border: 1px solid var(--line-strong); background: var(--jini-control-bg, var(--bg)); color: var(--text);
141
+ }
142
+ /* min() rather than a fixed width + max-width: a fixed rem width is the field's min-content size, which
143
+ widened the whole grid past a 375px frame. */
144
+ input[type="text"] { width: min(20rem, 100%); }
145
+ select { width: min(28rem, 100%); }
146
+ label { max-width: 100%; min-width: 0; }
147
+ /* A field's label spans the row so the percentage above has a width to resolve against. */
148
+ label:has(> input[type="text"], > select) { justify-self: stretch; }
149
+ .field-stack { display: grid; gap: var(--space-2); justify-items: start; }
150
+ .head > .actions.end { margin-left: auto; }
151
+ .check { display: flex; gap: var(--space-2); align-items: flex-start; font-weight: 400; color: var(--muted); max-width: 32rem; }
152
+ .check input { min-height: 0; margin: .2rem 0 0; accent-color: var(--accent); }
153
+ fieldset.list { border: 0; margin: 0; padding: 0; display: grid; gap: var(--space-2); }
154
+ fieldset.list legend { padding: 0; margin-bottom: var(--space-1); font-size: var(--fs-sm); font-weight: 600; }
155
+ .reassign .check { color: var(--text); }
156
+
157
+ /* Chosen spreadsheets and saved destinations. */
158
+ li.sheet { flex-wrap: wrap; column-gap: var(--space-3); }
159
+ li.card.destination { border: 0; border-top: 1px solid var(--line); border-radius: 0; padding: var(--space-2) 0; display: grid; grid-template-columns: 1fr auto; gap: var(--space-2); align-items: center; }
160
+ li.card.destination:first-child { border-top: 0; padding-top: 0; }
161
+ li.card.destination > .notice { grid-column: 1 / -1; grid-row: 2; }
162
+ li.card.destination > .actions { grid-column: 2; grid-row: 1; }
163
+ .empty { padding: var(--space-1) 0; }
164
+ code, .url { font-family: var(--jini-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: var(--fs-xs); color: var(--muted); overflow-wrap: anywhere; user-select: all; }
165
+
166
+ @media (max-width: 480px) {
167
+ .card { padding: var(--space-3); }
168
+ button.danger-quiet { margin-left: 0; }
169
+ }
170
+ @media (prefers-reduced-motion: reduce) { * { transition: none !important; } }