@autumnsgrove/groveengine 0.9.97 → 0.9.98

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 (259) hide show
  1. package/dist/auth/session.js +2 -1
  2. package/dist/components/admin/FloatingToolbar.svelte +43 -1
  3. package/dist/components/admin/GutterManager.svelte +2 -2
  4. package/dist/components/admin/MarkdownEditor.svelte +76 -9
  5. package/dist/components/admin/MarkdownEditor.svelte.d.ts +2 -0
  6. package/dist/components/admin/VoiceInput.svelte +7 -0
  7. package/dist/components/custom/GutterItem.svelte +2 -2
  8. package/dist/components/custom/InternalsPostViewer.svelte +2 -2
  9. package/dist/components/custom/MobileTOC.svelte +54 -2
  10. package/dist/components/custom/TableOfContents.svelte +1 -1
  11. package/dist/components/quota/UpgradePrompt.svelte +1 -1
  12. package/dist/config/tiers.d.ts +3 -1
  13. package/dist/config/tiers.js +16 -6
  14. package/dist/curios/timeline/Timeline.svelte +14 -1
  15. package/dist/curios/timeline/secrets.server.d.ts +78 -0
  16. package/dist/curios/timeline/secrets.server.js +180 -0
  17. package/dist/data/grove-term-manifest.json +552 -0
  18. package/dist/email/components/GroveButton.d.ts +10 -0
  19. package/dist/email/components/GroveButton.tsx +77 -0
  20. package/dist/email/components/GroveDivider.d.ts +7 -0
  21. package/dist/email/components/GroveDivider.tsx +80 -0
  22. package/dist/email/components/GroveEmail.d.ts +12 -0
  23. package/dist/email/components/GroveEmail.tsx +148 -0
  24. package/dist/email/components/GroveHighlight.d.ts +9 -0
  25. package/dist/email/components/GroveHighlight.tsx +71 -0
  26. package/dist/email/components/GrovePatchNote.d.ts +12 -0
  27. package/dist/email/components/GrovePatchNote.tsx +114 -0
  28. package/dist/email/components/GroveText.d.ts +24 -0
  29. package/dist/email/components/GroveText.tsx +131 -0
  30. package/dist/email/components/index.d.ts +37 -0
  31. package/dist/email/components/index.js +43 -0
  32. package/dist/email/components/styles.d.ts +47 -0
  33. package/dist/email/components/styles.js +51 -0
  34. package/dist/email/index.d.ts +31 -0
  35. package/dist/email/index.js +35 -0
  36. package/dist/email/lifecycle/GentleNudge.d.ts +7 -0
  37. package/dist/email/lifecycle/GentleNudge.tsx +52 -0
  38. package/dist/email/lifecycle/RenewalThankYou.d.ts +7 -0
  39. package/dist/email/lifecycle/RenewalThankYou.tsx +52 -0
  40. package/dist/email/lifecycle/index.d.ts +8 -0
  41. package/dist/email/lifecycle/index.js +8 -0
  42. package/dist/email/porch/PorchReplyEmail.d.ts +22 -0
  43. package/dist/email/porch/PorchReplyEmail.tsx +150 -0
  44. package/dist/email/porch/index.d.ts +7 -0
  45. package/dist/email/porch/index.js +6 -0
  46. package/dist/email/render.d.ts +41 -0
  47. package/dist/email/render.js +74 -0
  48. package/dist/email/schedule.d.ts +78 -0
  49. package/dist/email/schedule.js +198 -0
  50. package/dist/email/seasonal/SeasonalGreeting.d.ts +9 -0
  51. package/dist/email/seasonal/SeasonalGreeting.tsx +81 -0
  52. package/dist/email/seasonal/index.d.ts +7 -0
  53. package/dist/email/seasonal/index.js +7 -0
  54. package/dist/email/sequences/Day14Email.d.ts +7 -0
  55. package/dist/email/sequences/Day14Email.tsx +49 -0
  56. package/dist/email/sequences/Day1Email.d.ts +7 -0
  57. package/dist/email/sequences/Day1Email.tsx +57 -0
  58. package/dist/email/sequences/Day30Email.d.ts +7 -0
  59. package/dist/email/sequences/Day30Email.tsx +45 -0
  60. package/dist/email/sequences/Day7Email.d.ts +7 -0
  61. package/dist/email/sequences/Day7Email.tsx +85 -0
  62. package/dist/email/sequences/WelcomeEmail.d.ts +7 -0
  63. package/dist/email/sequences/WelcomeEmail.tsx +93 -0
  64. package/dist/email/sequences/index.d.ts +25 -0
  65. package/dist/email/sequences/index.js +30 -0
  66. package/dist/email/types.d.ts +82 -0
  67. package/dist/email/types.js +39 -0
  68. package/dist/email/updates/AnnouncementEmail.d.ts +17 -0
  69. package/dist/email/updates/AnnouncementEmail.tsx +61 -0
  70. package/dist/email/updates/PatchNotesEmail.d.ts +16 -0
  71. package/dist/email/updates/PatchNotesEmail.tsx +80 -0
  72. package/dist/email/updates/index.d.ts +8 -0
  73. package/dist/email/updates/index.js +8 -0
  74. package/dist/email/urls.d.ts +60 -0
  75. package/dist/email/urls.js +83 -0
  76. package/dist/feature-flags/grafts.d.ts +81 -0
  77. package/dist/feature-flags/grafts.js +106 -0
  78. package/dist/feature-flags/index.d.ts +2 -0
  79. package/dist/feature-flags/index.js +4 -0
  80. package/dist/feature-flags/tenant-grafts.d.ts +82 -0
  81. package/dist/feature-flags/tenant-grafts.js +207 -0
  82. package/dist/grafts/greenhouse/CultivateFlagRow.svelte +12 -11
  83. package/dist/grafts/greenhouse/CultivateFlagTable.svelte +13 -12
  84. package/dist/grafts/greenhouse/GraftControlPanel.svelte +374 -0
  85. package/dist/grafts/greenhouse/GraftControlPanel.svelte.d.ts +21 -0
  86. package/dist/grafts/greenhouse/GraftToggleRow.svelte +189 -0
  87. package/dist/grafts/greenhouse/GraftToggleRow.svelte.d.ts +18 -0
  88. package/dist/grafts/greenhouse/GreenhouseAdminPanel.svelte +491 -0
  89. package/dist/grafts/greenhouse/GreenhouseAdminPanel.svelte.d.ts +71 -0
  90. package/dist/grafts/greenhouse/GreenhouseEnrollDialog.svelte +5 -10
  91. package/dist/grafts/greenhouse/GreenhouseEnrollTable.svelte +24 -20
  92. package/dist/grafts/greenhouse/GreenhouseToggle.svelte +10 -0
  93. package/dist/grafts/greenhouse/index.d.ts +4 -1
  94. package/dist/grafts/greenhouse/index.js +5 -0
  95. package/dist/grafts/greenhouse/types.d.ts +84 -0
  96. package/dist/grafts/login/EmailButton.svelte +260 -0
  97. package/dist/grafts/login/EmailButton.svelte.d.ts +14 -0
  98. package/dist/grafts/login/LoginGraft.svelte +11 -1
  99. package/dist/grafts/login/PasskeyButton.svelte +1 -1
  100. package/dist/grafts/login/config.d.ts +3 -1
  101. package/dist/grafts/login/config.js +4 -2
  102. package/dist/grafts/login/index.d.ts +1 -0
  103. package/dist/grafts/login/index.js +1 -0
  104. package/dist/grafts/login/passkey-authenticate.js +1 -1
  105. package/dist/grafts/login/server/callback.js +1 -1
  106. package/dist/grafts/login/types.d.ts +3 -3
  107. package/dist/grafts/pricing/PricingFineprint.svelte +1 -1
  108. package/dist/grafts/pricing/PricingToggle.svelte +2 -2
  109. package/dist/payments/types.d.ts +23 -22
  110. package/dist/server/billing.d.ts +17 -0
  111. package/dist/server/billing.js +40 -0
  112. package/dist/server/rate-limits/config.js +4 -0
  113. package/dist/server/services/index.d.ts +1 -1
  114. package/dist/server/services/index.js +2 -0
  115. package/dist/server/services/storage.d.ts +32 -0
  116. package/dist/server/services/storage.js +125 -0
  117. package/dist/server/services/trace-email.js +1 -1
  118. package/dist/styles/tokens.css +145 -35
  119. package/dist/ui/components/charts/ActivityOverview.svelte +8 -6
  120. package/dist/ui/components/chrome/AdminHeader.svelte +3 -3
  121. package/dist/ui/components/chrome/Header.svelte +220 -18
  122. package/dist/ui/components/chrome/Header.svelte.d.ts +27 -1
  123. package/dist/ui/components/chrome/MobileMenu.svelte +116 -4
  124. package/dist/ui/components/chrome/MobileMenu.svelte.d.ts +11 -1
  125. package/dist/ui/components/chrome/defaults.js +7 -5
  126. package/dist/ui/components/chrome/index.d.ts +2 -0
  127. package/dist/ui/components/chrome/index.js +2 -0
  128. package/dist/ui/components/chrome/tenant-nav.d.ts +46 -0
  129. package/dist/ui/components/chrome/tenant-nav.js +70 -0
  130. package/dist/ui/components/chrome/types.d.ts +9 -0
  131. package/dist/ui/components/gallery/ImageGallery.svelte +3 -1
  132. package/dist/ui/components/gallery/ZoomableImage.svelte +2 -0
  133. package/dist/ui/components/nature/LogoArchive.svelte +18 -0
  134. package/dist/ui/components/nature/botanical/Acorn.svelte +1 -1
  135. package/dist/ui/components/nature/botanical/Berry.svelte +1 -1
  136. package/dist/ui/components/nature/botanical/DandelionPuff.svelte +8 -1
  137. package/dist/ui/components/nature/botanical/FallingLeavesLayer.svelte +22 -1
  138. package/dist/ui/components/nature/botanical/FallingPetalsLayer.svelte +21 -1
  139. package/dist/ui/components/nature/botanical/Leaf.svelte +1 -1
  140. package/dist/ui/components/nature/botanical/LeafFalling.svelte +1 -0
  141. package/dist/ui/components/nature/botanical/PetalFalling.svelte +12 -0
  142. package/dist/ui/components/nature/botanical/PineCone.svelte +1 -1
  143. package/dist/ui/components/nature/botanical/Vine.svelte +8 -1
  144. package/dist/ui/components/nature/creatures/Bee.svelte +19 -4
  145. package/dist/ui/components/nature/creatures/Bird.svelte +18 -7
  146. package/dist/ui/components/nature/creatures/BirdFlying.svelte +8 -0
  147. package/dist/ui/components/nature/creatures/Bluebird.svelte +13 -3
  148. package/dist/ui/components/nature/creatures/Butterfly.svelte +13 -1
  149. package/dist/ui/components/nature/creatures/Cardinal.svelte +13 -3
  150. package/dist/ui/components/nature/creatures/Chickadee.svelte +16 -5
  151. package/dist/ui/components/nature/creatures/Deer.svelte +12 -2
  152. package/dist/ui/components/nature/creatures/Firefly.svelte +38 -6
  153. package/dist/ui/components/nature/creatures/Owl.svelte +13 -3
  154. package/dist/ui/components/nature/creatures/Rabbit.svelte +13 -2
  155. package/dist/ui/components/nature/creatures/Robin.svelte +16 -5
  156. package/dist/ui/components/nature/creatures/Squirrel.svelte +12 -2
  157. package/dist/ui/components/nature/ground/Bush.svelte +7 -1
  158. package/dist/ui/components/nature/ground/Crocus.svelte +7 -1
  159. package/dist/ui/components/nature/ground/Daffodil.svelte +7 -1
  160. package/dist/ui/components/nature/ground/Fern.svelte +7 -1
  161. package/dist/ui/components/nature/ground/FlowerWild.svelte +7 -1
  162. package/dist/ui/components/nature/ground/GrassTuft.svelte +7 -1
  163. package/dist/ui/components/nature/ground/Log.svelte +1 -1
  164. package/dist/ui/components/nature/ground/Mushroom.svelte +1 -1
  165. package/dist/ui/components/nature/ground/MushroomCluster.svelte +1 -1
  166. package/dist/ui/components/nature/ground/Rock.svelte +1 -1
  167. package/dist/ui/components/nature/ground/Stump.svelte +1 -1
  168. package/dist/ui/components/nature/ground/Tulip.svelte +13 -4
  169. package/dist/ui/components/nature/palette.d.ts +92 -0
  170. package/dist/ui/components/nature/palette.js +92 -0
  171. package/dist/ui/components/nature/sky/Cloud.svelte +40 -23
  172. package/dist/ui/components/nature/sky/Cloud.svelte.d.ts +1 -0
  173. package/dist/ui/components/nature/sky/CloudWispy.svelte +7 -0
  174. package/dist/ui/components/nature/sky/Moon.svelte +41 -6
  175. package/dist/ui/components/nature/sky/Moon.svelte.d.ts +1 -0
  176. package/dist/ui/components/nature/sky/Rainbow.svelte +7 -1
  177. package/dist/ui/components/nature/sky/Star.svelte +7 -0
  178. package/dist/ui/components/nature/sky/StarCluster.svelte +7 -1
  179. package/dist/ui/components/nature/sky/StarShooting.svelte +8 -0
  180. package/dist/ui/components/nature/sky/Sun.svelte +8 -1
  181. package/dist/ui/components/nature/structural/Birdhouse.svelte +1 -1
  182. package/dist/ui/components/nature/structural/Bridge.svelte +7 -4
  183. package/dist/ui/components/nature/structural/FencePost.svelte +1 -1
  184. package/dist/ui/components/nature/structural/GardenGate.svelte +1 -1
  185. package/dist/ui/components/nature/structural/Lantern.svelte +9 -1
  186. package/dist/ui/components/nature/structural/Lattice.svelte +1 -1
  187. package/dist/ui/components/nature/structural/LatticeWithVine.svelte +1 -1
  188. package/dist/ui/components/nature/structural/StonePath.svelte +1 -1
  189. package/dist/ui/components/nature/trees/TreeAspen.svelte +7 -1
  190. package/dist/ui/components/nature/trees/TreeBirch.svelte +11 -3
  191. package/dist/ui/components/nature/trees/TreeCherry.svelte +7 -0
  192. package/dist/ui/components/nature/trees/TreePine.svelte +7 -0
  193. package/dist/ui/components/nature/water/LilyPad.svelte +17 -4
  194. package/dist/ui/components/nature/water/Pond.svelte +22 -12
  195. package/dist/ui/components/nature/water/Reeds.svelte +7 -1
  196. package/dist/ui/components/nature/water/Stream.svelte +28 -14
  197. package/dist/ui/components/nature/weather/SnowfallLayer.svelte +21 -1
  198. package/dist/ui/components/nature/weather/Snowflake.svelte +1 -0
  199. package/dist/ui/components/nature/weather/SnowflakeFalling.svelte +7 -0
  200. package/dist/ui/components/primitives/badge/badge.svelte +8 -1
  201. package/dist/ui/components/primitives/badge/badge.svelte.d.ts +1 -0
  202. package/dist/ui/components/primitives/dialog/dialog-content.svelte +6 -1
  203. package/dist/ui/components/primitives/dialog/dialog-overlay.svelte +3 -1
  204. package/dist/ui/components/terrarium/PlacedAsset.svelte +16 -1
  205. package/dist/ui/components/terrarium/utils/export.d.ts +1 -1
  206. package/dist/ui/components/terrarium/utils/export.js +34 -17
  207. package/dist/ui/components/ui/GlassCarousel.svelte +2 -0
  208. package/dist/ui/components/ui/GlassConfirmDialog.svelte +76 -167
  209. package/dist/ui/components/ui/GlassConfirmDialog.svelte.d.ts +3 -5
  210. package/dist/ui/components/ui/Waystone.svelte +40 -98
  211. package/dist/ui/components/ui/Waystone.svelte.d.ts +22 -0
  212. package/dist/ui/components/ui/groveterm/GroveTerm.svelte +301 -0
  213. package/dist/ui/components/ui/groveterm/GroveTerm.svelte.d.ts +17 -0
  214. package/dist/ui/components/ui/groveterm/GroveTermPopup.svelte +272 -0
  215. package/dist/ui/components/ui/groveterm/GroveTermPopup.svelte.d.ts +32 -0
  216. package/dist/ui/components/ui/groveterm/index.d.ts +15 -0
  217. package/dist/ui/components/ui/groveterm/index.js +15 -0
  218. package/dist/ui/components/ui/groveterm/types.d.ts +64 -0
  219. package/dist/ui/components/ui/groveterm/types.js +42 -0
  220. package/dist/ui/components/ui/index.d.ts +5 -0
  221. package/dist/ui/components/ui/index.js +6 -0
  222. package/dist/ui/components/ui/waystone/Waystone.svelte +266 -0
  223. package/dist/ui/components/ui/waystone/Waystone.svelte.d.ts +18 -0
  224. package/dist/ui/components/ui/waystone/WaystonePopup.svelte +244 -0
  225. package/dist/ui/components/ui/waystone/WaystonePopup.svelte.d.ts +34 -0
  226. package/dist/ui/components/ui/waystone/index.d.ts +10 -0
  227. package/dist/ui/components/ui/waystone/index.js +10 -0
  228. package/dist/ui/components/ui/waystone/types.d.ts +42 -0
  229. package/dist/ui/components/ui/waystone/types.js +17 -0
  230. package/dist/ui/stores/index.d.ts +1 -0
  231. package/dist/ui/stores/index.js +1 -0
  232. package/dist/ui/stores/sidebar.svelte.d.ts +19 -0
  233. package/dist/ui/stores/sidebar.svelte.js +37 -0
  234. package/dist/ui/tailwind.preset.js +54 -56
  235. package/dist/ui/utils/cn.d.ts +1 -13
  236. package/dist/ui/utils/cn.js +4 -19
  237. package/dist/ui/vineyard/FeatureCard.svelte +32 -5
  238. package/dist/ui/vineyard/FeatureCard.svelte.d.ts +2 -2
  239. package/dist/utils/grove-url.d.ts +52 -0
  240. package/dist/utils/grove-url.js +83 -0
  241. package/dist/utils/imageProcessor.js +53 -4
  242. package/dist/utils/index.d.ts +2 -0
  243. package/dist/utils/index.js +3 -0
  244. package/dist/utils/rehype-groveterm.d.ts +82 -0
  245. package/dist/utils/rehype-groveterm.js +266 -0
  246. package/dist/utils/upload-validation.d.ts +80 -0
  247. package/dist/utils/upload-validation.js +186 -23
  248. package/dist/utils/user.d.ts +22 -0
  249. package/dist/utils/user.js +33 -0
  250. package/dist/zephyr/README.md +459 -0
  251. package/dist/zephyr/client.d.ts +64 -0
  252. package/dist/zephyr/client.js +156 -0
  253. package/dist/zephyr/index.d.ts +7 -0
  254. package/dist/zephyr/index.js +6 -0
  255. package/dist/zephyr/types.d.ts +38 -0
  256. package/dist/zephyr/types.js +6 -0
  257. package/package.json +57 -3
  258. package/dist/ui/styles/grove.css +0 -922
  259. package/dist/ui/styles/tokens.css +0 -429
@@ -5,6 +5,7 @@
5
5
  * Session management is now handled by Heartwood SessionDO.
6
6
  * This file only contains tenant verification functions.
7
7
  */
8
+ import { emailsMatch } from "../utils/user.js";
8
9
  /**
9
10
  * Verify that a user owns/has access to a tenant
10
11
  */
@@ -21,7 +22,7 @@ export async function verifyTenantOwnership(db, tenantId, userEmail) {
21
22
  return false;
22
23
  }
23
24
  // Check if user email matches tenant owner email
24
- return tenant.email.toLowerCase() === userEmail.toLowerCase();
25
+ return emailsMatch(tenant.email, userEmail);
25
26
  }
26
27
  catch (error) {
27
28
  console.error("Error verifying tenant ownership:", error);
@@ -219,8 +219,50 @@
219
219
  }
220
220
  }
221
221
 
222
- // Set up selection monitoring and keyboard shortcuts
222
+ // Track if the textarea is focused to avoid global listener interference
223
+ let isTextareaFocused = $state(false);
224
+
225
+ // Handle focus/blur on textarea to manage global listeners lifecycle
226
+ function handleTextareaFocus() {
227
+ isTextareaFocused = true;
228
+ }
229
+
230
+ /** @param {FocusEvent} e */
231
+ function handleTextareaBlur(e) {
232
+ // If focus moved to toolbar, keep it open
233
+ if (e.relatedTarget && toolbarRef?.contains(/** @type {Node} */ (e.relatedTarget))) {
234
+ return;
235
+ }
236
+ isTextareaFocused = false;
237
+ isVisible = false;
238
+ }
239
+
240
+ // Set up focus tracking on the textarea
223
241
  $effect(() => {
242
+ if (!textareaRef) return;
243
+
244
+ textareaRef.addEventListener("focus", handleTextareaFocus);
245
+ textareaRef.addEventListener("blur", handleTextareaBlur);
246
+
247
+ // Check if already focused
248
+ if (document.activeElement === textareaRef) {
249
+ isTextareaFocused = true;
250
+ }
251
+
252
+ return () => {
253
+ textareaRef?.removeEventListener("focus", handleTextareaFocus);
254
+ textareaRef?.removeEventListener("blur", handleTextareaBlur);
255
+ };
256
+ });
257
+
258
+ // Only add global listeners when textarea is focused
259
+ // This prevents interference with other form elements in the admin panel
260
+ $effect(() => {
261
+ if (!isTextareaFocused) {
262
+ isVisible = false;
263
+ return;
264
+ }
265
+
224
266
  // Use selectionchange for more reliable selection tracking (catches programmatic changes)
225
267
  document.addEventListener("selectionchange", handleSelectionChange);
226
268
  document.addEventListener("mousedown", handleClickOutside);
@@ -567,7 +567,7 @@
567
567
  variant="outline"
568
568
  onclick={() => openImagePicker((url) => (itemUrl = url))}
569
569
  >
570
- Browse CDN
570
+ Browse Images
571
571
  </Button>
572
572
  </div>
573
573
  </div>
@@ -636,7 +636,7 @@
636
636
  </Dialog>
637
637
 
638
638
  <!-- Image Picker Modal -->
639
- <Dialog bind:open={showImagePicker} title="Select Image from CDN">
639
+ <Dialog bind:open={showImagePicker} title="Select Image">
640
640
  {#snippet children()}
641
641
  <div class="picker-controls">
642
642
  <Input
@@ -41,6 +41,12 @@
41
41
  * @property {Array<{url: string, alt?: string, caption?: string}>} [images]
42
42
  */
43
43
 
44
+ /**
45
+ * @typedef {Object.<string, boolean>} GraftsRecord
46
+ * Graft flags for this tenant - component reads what it needs.
47
+ * Known flags: fireside_mode (AI-assisted prompts), scribe_mode (voice-to-text)
48
+ */
49
+
44
50
  // Props
45
51
  let {
46
52
  content = $bindable(""),
@@ -54,8 +60,14 @@
54
60
  previewTags = /** @type {string[]} */ ([]),
55
61
  gutterItems = /** @type {GutterItemProp[]} */ ([]),
56
62
  firesideAssisted = $bindable(false),
63
+ /** All grafts for this tenant - component reads what it needs */
64
+ grafts = /** @type {GraftsRecord} */ ({}),
57
65
  } = $props();
58
66
 
67
+ // Derived graft flags - add new ones here as they're created
68
+ const firesideEnabled = $derived(grafts?.fireside_mode ?? false);
69
+ const scribeEnabled = $derived(grafts?.scribe_mode ?? false);
70
+
59
71
  // Core refs and state
60
72
  /** @type {HTMLTextAreaElement | null} */
61
73
  let textareaRef = $state(null);
@@ -125,12 +137,43 @@
125
137
 
126
138
  // Note: Slash commands and command palette removed for simplified Medium-style UX
127
139
 
140
+ // Debounced preview HTML - avoid expensive markdown rendering on every keystroke
141
+ // Cache the last rendered HTML to prevent jank during typing
142
+ let debouncedContent = $state(content);
143
+ // NOT $state - these are cleanup handles, not reactive state
144
+ // Using $state here causes infinite loops (effect writes to state it reads)
145
+ /** @type {ReturnType<typeof setTimeout> | null} */
146
+ let debounceTimer = null;
147
+ let isMounted = true;
148
+
149
+ // Update debounced content after 150ms of no typing
150
+ $effect(() => {
151
+ // Clear any existing timer
152
+ if (debounceTimer) clearTimeout(debounceTimer);
153
+
154
+ // Capture current content for the closure
155
+ const currentContent = content;
156
+
157
+ debounceTimer = setTimeout(() => {
158
+ // Only update if component is still mounted (prevents race condition)
159
+ if (isMounted) {
160
+ debouncedContent = currentContent;
161
+ }
162
+ }, 150);
163
+
164
+ return () => {
165
+ if (debounceTimer) clearTimeout(debounceTimer);
166
+ isMounted = false; // Mark as unmounted on cleanup
167
+ };
168
+ });
169
+
128
170
  // Computed values
129
171
  let wordCount = $derived(content.trim() ? content.trim().split(/\s+/).length : 0);
130
172
  let charCount = $derived(content.length);
131
173
  let lineCount = $derived(content.split("\n").length);
132
- let previewHtml = $derived(content ? sanitizeMarkdown(editorMd.render(content)) : "");
133
- let previewHeaders = $derived(content ? extractHeaders(content) : []);
174
+ // Use debounced content for expensive operations (markdown rendering)
175
+ let previewHtml = $derived(debouncedContent ? sanitizeMarkdown(editorMd.render(debouncedContent)) : "");
176
+ let previewHeaders = $derived(debouncedContent ? extractHeaders(debouncedContent) : []);
134
177
 
135
178
  let readingTime = $derived.by(() => {
136
179
  const minutes = Math.ceil(wordCount / 200);
@@ -254,8 +297,8 @@
254
297
  cycleEditorMode();
255
298
  }
256
299
 
257
- // Cmd/Ctrl + Shift + F for Fireside mode
258
- if (e.key === "f" && (e.metaKey || e.ctrlKey) && e.shiftKey) {
300
+ // Cmd/Ctrl + Shift + F for Fireside mode (only if graft enabled)
301
+ if (e.key === "f" && (e.metaKey || e.ctrlKey) && e.shiftKey && firesideEnabled) {
259
302
  e.preventDefault();
260
303
  toggleFiresideMode();
261
304
  }
@@ -541,6 +584,30 @@
541
584
  }
542
585
  });
543
586
 
587
+ // Full preview modal focus management
588
+ /** @type {HTMLElement | null} */
589
+ let previouslyFocusedBeforePreview = null;
590
+ /** @type {HTMLDivElement | null} */
591
+ let fullPreviewModalRef = $state(null);
592
+
593
+ $effect(() => {
594
+ if (showFullPreview) {
595
+ // Store the currently focused element to restore on close
596
+ const activeEl = document.activeElement;
597
+ if (activeEl instanceof HTMLElement) {
598
+ previouslyFocusedBeforePreview = activeEl;
599
+ }
600
+ // Focus the modal for keyboard accessibility
601
+ setTimeout(() => {
602
+ fullPreviewModalRef?.focus();
603
+ }, 50);
604
+ } else if (previouslyFocusedBeforePreview) {
605
+ // Restore focus when modal closes
606
+ previouslyFocusedBeforePreview.focus();
607
+ previouslyFocusedBeforePreview = null;
608
+ }
609
+ });
610
+
544
611
  // Drag and drop handlers
545
612
  /** @param {DragEvent} e */
546
613
  function handleDragEnter(e) {
@@ -724,7 +791,7 @@
724
791
  {#if !isFiresideMode}
725
792
  <div class="toolbar">
726
793
  <div class="toolbar-left">
727
- {#if !content.trim()}
794
+ {#if firesideEnabled && !content.trim()}
728
795
  <button
729
796
  type="button"
730
797
  class="fireside-btn"
@@ -737,8 +804,8 @@
737
804
  </button>
738
805
  <span class="toolbar-divider">|</span>
739
806
  {/if}
740
- <!-- Voice Input (Scribe) -->
741
- {#if editorMode !== "preview"}
807
+ <!-- Voice Input (Scribe) - gated by scribe_mode graft -->
808
+ {#if scribeEnabled && editorMode !== "preview"}
742
809
  <div class="voice-wrapper" title="Voice Input (⌘⇧U) - Hold to record, release to transcribe">
743
810
  <VoiceInput
744
811
  mode={voiceMode}
@@ -850,7 +917,7 @@
850
917
  onkeydown={handleKeydown}
851
918
  onscroll={handleScroll}
852
919
  onpaste={handlePaste}
853
- placeholder="Start writing your post... (Drag & drop or paste images)"
920
+ placeholder="Start writing your bloom... (Drag & drop or paste images)"
854
921
  spellcheck="true"
855
922
  disabled={readonly}
856
923
  class="editor-textarea"
@@ -929,7 +996,7 @@
929
996
 
930
997
  <!-- Full Preview Modal -->
931
998
  {#if showFullPreview}
932
- <div class="full-preview-modal" role="dialog" aria-modal="true" aria-label="Full article preview" tabindex="-1" onkeydown={(e) => e.key === 'Escape' && (showFullPreview = false)}>
999
+ <div bind:this={fullPreviewModalRef} class="full-preview-modal" role="dialog" aria-modal="true" aria-label="Full article preview" tabindex="-1" onkeydown={(e) => e.key === 'Escape' && (showFullPreview = false)}>
933
1000
  <button type="button" class="full-preview-backdrop" onclick={() => (showFullPreview = false)} aria-label="Close preview"></button>
934
1001
  <div class="full-preview-container" class:has-vines={gutterItems.length > 0}>
935
1002
  <header class="full-preview-header">
@@ -23,6 +23,7 @@ declare const MarkdownEditor: import("svelte").Component<{
23
23
  previewTags?: any;
24
24
  gutterItems?: any;
25
25
  firesideAssisted?: boolean;
26
+ grafts?: any;
26
27
  }, {
27
28
  getAvailableAnchors: () => string[];
28
29
  insertAnchor: (name: string) => void;
@@ -44,4 +45,5 @@ type $$ComponentProps = {
44
45
  previewTags?: any;
45
46
  gutterItems?: any;
46
47
  firesideAssisted?: boolean;
48
+ grafts?: any;
47
49
  };
@@ -16,6 +16,7 @@
16
16
  type ScribeRecorderState,
17
17
  } from "../../scribe/recorder.js";
18
18
  import type { GutterItem, ScribeMode } from "../../lumen/types.js";
19
+ import { getCSRFToken } from "../../utils/api.js";
19
20
 
20
21
  // ============================================================================
21
22
  // Props & Events
@@ -163,10 +164,16 @@
163
164
  formData.append("audio", audioBlob);
164
165
  formData.append("mode", mode);
165
166
 
167
+ // Get CSRF token for state-changing request
168
+ const csrfToken = getCSRFToken();
169
+
166
170
  const response = await fetch("/api/lumen/transcribe", {
167
171
  method: "POST",
168
172
  body: formData,
169
173
  credentials: "include",
174
+ headers: csrfToken
175
+ ? { "X-CSRF-Token": csrfToken, "csrf-token": csrfToken }
176
+ : {},
170
177
  });
171
178
 
172
179
  if (!response.ok) {
@@ -53,7 +53,7 @@
53
53
  {@const imageSrc = item.src || item.url || item.file}
54
54
  <figure class="gutter-photo">
55
55
  <button class="image-button" onclick={() => openLightbox(imageSrc, item.caption || 'Gutter image', item.caption || '')}>
56
- <img src={imageSrc} alt={item.caption || 'Gutter image'} />
56
+ <img src={imageSrc} alt={item.caption || 'Gutter image'} loading="lazy" decoding="async" />
57
57
  </button>
58
58
  {#if item.caption}
59
59
  <figcaption>{item.caption}</figcaption>
@@ -76,7 +76,7 @@
76
76
  {/if}
77
77
  {:else if item.type === 'emoji'}
78
78
  <div class="gutter-emoji">
79
- <img src={item.src} alt={item.alt || 'Emoji'} title={item.alt || ''} />
79
+ <img src={item.src} alt={item.alt || 'Emoji'} title={item.alt || ''} loading="lazy" decoding="async" />
80
80
  </div>
81
81
  {/if}
82
82
  </div>
@@ -1,6 +1,6 @@
1
1
  <script>
2
2
  /**
3
- * Simple component to display a featured blog post
3
+ * Simple component to display a featured garden post
4
4
  * @prop {{ title: string; description?: string; slug: string; date?: string }} post - Post data
5
5
  * @prop {string} [caption] - Optional caption text
6
6
  */
@@ -17,7 +17,7 @@
17
17
  {#if caption}
18
18
  <span class="caption">{caption}</span>
19
19
  {/if}
20
- <a href="/blog/{post.slug}" class="post-link">
20
+ <a href="/garden/{post.slug}" class="post-link">
21
21
  <h3 class="title">{post.title}</h3>
22
22
  {#if post.description}
23
23
  <p class="description">{post.description}</p>
@@ -19,6 +19,7 @@
19
19
  let menuRef = $state<HTMLDivElement>();
20
20
  let buttonRef = $state<HTMLButtonElement>();
21
21
  let activeId = $state('');
22
+ let previouslyFocusedElement: HTMLElement | null = null;
22
23
 
23
24
  function toggleMenu() {
24
25
  isOpen = !isOpen;
@@ -28,6 +29,29 @@
28
29
  isOpen = false;
29
30
  }
30
31
 
32
+ // Focus trap: Tab key cycles within menu
33
+ function handleFocusTrap(event: KeyboardEvent) {
34
+ if (event.key === 'Tab' && isOpen && menuRef) {
35
+ const focusableElements = menuRef.querySelectorAll<HTMLElement>(
36
+ 'button, a, [tabindex]:not([tabindex="-1"])'
37
+ );
38
+ if (focusableElements.length === 0) return;
39
+
40
+ const firstElement = focusableElements[0];
41
+ const lastElement = focusableElements[focusableElements.length - 1];
42
+
43
+ if (event.shiftKey && document.activeElement === firstElement) {
44
+ // Shift+Tab on first element: wrap to last
45
+ event.preventDefault();
46
+ lastElement?.focus();
47
+ } else if (!event.shiftKey && document.activeElement === lastElement) {
48
+ // Tab on last element: wrap to first
49
+ event.preventDefault();
50
+ firstElement?.focus();
51
+ }
52
+ }
53
+ }
54
+
31
55
  function scrollToHeader(id: string) {
32
56
  const element = document.getElementById(id);
33
57
  if (element) {
@@ -55,11 +79,14 @@
55
79
  }
56
80
  }
57
81
 
58
- // Handle escape key
82
+ // Handle escape key and focus trap
59
83
  function handleKeydown(event: KeyboardEvent) {
60
84
  if (event.key === 'Escape' && isOpen) {
61
85
  closeMenu();
86
+ // Restore focus to button when closing
87
+ buttonRef?.focus();
62
88
  }
89
+ handleFocusTrap(event);
63
90
  }
64
91
 
65
92
  // Set up intersection observer to track active section
@@ -90,6 +117,25 @@
90
117
  return () => observer.disconnect();
91
118
  }
92
119
 
120
+ // Handle focus management when menu opens/closes
121
+ $effect(() => {
122
+ if (isOpen) {
123
+ // Store the previously focused element to restore later
124
+ previouslyFocusedElement = document.activeElement as HTMLElement;
125
+ // Focus the first TOC item when menu opens
126
+ requestAnimationFrame(() => {
127
+ const firstLink = menuRef?.querySelector<HTMLButtonElement>('.toc-link');
128
+ firstLink?.focus();
129
+ });
130
+ } else {
131
+ if (previouslyFocusedElement && previouslyFocusedElement !== buttonRef) {
132
+ // Restore focus when menu closes (unless already on button)
133
+ previouslyFocusedElement.focus();
134
+ }
135
+ previouslyFocusedElement = null;
136
+ }
137
+ });
138
+
93
139
  $effect(() => {
94
140
  const cleanup = setupScrollTracking();
95
141
 
@@ -124,7 +170,13 @@
124
170
 
125
171
  <!-- Floating Menu -->
126
172
  {#if isOpen}
127
- <div class="toc-menu" bind:this={menuRef}>
173
+ <div
174
+ class="toc-menu"
175
+ bind:this={menuRef}
176
+ role="dialog"
177
+ aria-modal="true"
178
+ aria-label="Table of contents"
179
+ >
128
180
  <h3 class="toc-title">{title}</h3>
129
181
  <ul class="toc-list">
130
182
  {#each headers as header (header.id)}
@@ -73,7 +73,7 @@
73
73
  </script>
74
74
 
75
75
  {#if headers.length > 0}
76
- <nav class="toc">
76
+ <nav class="toc" aria-label="Table of contents">
77
77
  <h3 class="toc-title">{title}</h3>
78
78
  <ul class="toc-list">
79
79
  {#each headers as header (header.id)}
@@ -261,7 +261,7 @@
261
261
 
262
262
  <div class="flex gap-3">
263
263
  <a
264
- href="/admin/posts"
264
+ href="/arbor/posts"
265
265
  class="flex-1 px-4 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 text-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800"
266
266
  >
267
267
  Manage Posts
@@ -18,9 +18,11 @@ export type TierIcon = "user" | "sprout" | "tree-deciduous" | "trees" | "crown";
18
18
  export interface TierLimits {
19
19
  posts: number;
20
20
  storage: number;
21
+ storageDisplay: string;
21
22
  themes: number;
22
23
  navPages: number;
23
24
  commentsPerWeek: number;
25
+ aiWordsPerMonth: number;
24
26
  }
25
27
  export interface TierFeatures {
26
28
  blog: boolean;
@@ -121,7 +123,7 @@ export declare function tierHasFeature(tier: TierKey, feature: keyof TierFeature
121
123
  /**
122
124
  * Get a specific limit for a tier.
123
125
  */
124
- export declare function getTierLimit(tier: TierKey, limit: keyof TierLimits): number;
126
+ export declare function getTierLimit(tier: TierKey, limit: keyof TierLimits): number | string;
125
127
  /**
126
128
  * Get rate limits for a tier.
127
129
  */
@@ -22,9 +22,11 @@ export const TIERS = {
22
22
  limits: {
23
23
  posts: 0,
24
24
  storage: 0,
25
+ storageDisplay: "0 MB",
25
26
  themes: 0,
26
27
  navPages: 0,
27
28
  commentsPerWeek: 20,
29
+ aiWordsPerMonth: 0,
28
30
  },
29
31
  features: {
30
32
  blog: false,
@@ -55,14 +57,14 @@ export const TIERS = {
55
57
  display: {
56
58
  name: "Free",
57
59
  tagline: "Just visiting",
58
- description: "Hang out in Meadow, follow blogs, react and comment.",
60
+ description: "Hang out in Meadow, follow gardens, react and comment.",
59
61
  icon: "user",
60
62
  bestFor: "Readers",
61
63
  featureStrings: [
62
64
  "Meadow access",
63
65
  "20 comments/week",
64
- "Follow blogs",
65
- "React to posts",
66
+ "Follow gardens",
67
+ "React to blooms",
66
68
  ],
67
69
  },
68
70
  support: { level: "help_center", displayString: "Help Center" },
@@ -74,9 +76,11 @@ export const TIERS = {
74
76
  limits: {
75
77
  posts: 50,
76
78
  storage: 1 * 1024 * 1024 * 1024, // 1 GB
79
+ storageDisplay: "1 GB",
77
80
  themes: 3,
78
81
  navPages: 0,
79
82
  commentsPerWeek: Infinity,
83
+ aiWordsPerMonth: 750, // ~25/day * 30 days
80
84
  },
81
85
  features: {
82
86
  blog: true,
@@ -111,7 +115,7 @@ export const TIERS = {
111
115
  icon: "sprout",
112
116
  bestFor: "Curious",
113
117
  featureStrings: [
114
- "50 posts",
118
+ "50 blooms",
115
119
  "1 GB storage",
116
120
  "3 curated themes",
117
121
  "Meadow access",
@@ -128,9 +132,11 @@ export const TIERS = {
128
132
  limits: {
129
133
  posts: 250,
130
134
  storage: 5 * 1024 * 1024 * 1024, // 5 GB
135
+ storageDisplay: "5 GB",
131
136
  themes: 10,
132
137
  navPages: 3,
133
138
  commentsPerWeek: Infinity,
139
+ aiWordsPerMonth: 3000, // ~100/day * 30 days
134
140
  },
135
141
  features: {
136
142
  blog: true,
@@ -165,7 +171,7 @@ export const TIERS = {
165
171
  icon: "tree-deciduous",
166
172
  bestFor: "Hobbyists",
167
173
  featureStrings: [
168
- "250 posts",
174
+ "250 blooms",
169
175
  "5 GB storage",
170
176
  "10 themes",
171
177
  "3 nav pages",
@@ -183,9 +189,11 @@ export const TIERS = {
183
189
  limits: {
184
190
  posts: Infinity,
185
191
  storage: 20 * 1024 * 1024 * 1024, // 20 GB
192
+ storageDisplay: "20 GB",
186
193
  themes: Infinity,
187
194
  navPages: 5,
188
195
  commentsPerWeek: Infinity,
196
+ aiWordsPerMonth: 15000, // ~500/day * 30 days
189
197
  },
190
198
  features: {
191
199
  blog: true,
@@ -220,7 +228,7 @@ export const TIERS = {
220
228
  icon: "trees",
221
229
  bestFor: "Serious Bloggers",
222
230
  featureStrings: [
223
- "Unlimited posts",
231
+ "Unlimited blooms",
224
232
  "20 GB storage",
225
233
  "Theme customizer",
226
234
  "5 nav pages",
@@ -238,9 +246,11 @@ export const TIERS = {
238
246
  limits: {
239
247
  posts: Infinity,
240
248
  storage: 100 * 1024 * 1024 * 1024, // 100 GB
249
+ storageDisplay: "100 GB",
241
250
  themes: Infinity,
242
251
  navPages: 8,
243
252
  commentsPerWeek: Infinity,
253
+ aiWordsPerMonth: 75000, // ~2500/day * 30 days
244
254
  },
245
255
  features: {
246
256
  blog: true,
@@ -79,6 +79,19 @@
79
79
  let loadingMore = $state(false);
80
80
  let expandedCards = $state(new Set<string>());
81
81
 
82
+ // Cache rendered markdown by summary ID (prevents re-rendering on every reactive update)
83
+ const renderedHtmlCache = new Map<string, string>();
84
+
85
+ function getRenderedHtml(summary: Summary): string {
86
+ const cacheKey = summary.id;
87
+ if (renderedHtmlCache.has(cacheKey)) {
88
+ return renderedHtmlCache.get(cacheKey)!;
89
+ }
90
+ const gutterItems = summary.gutter_content ?? [];
91
+ const html = renderMarkdownWithGutter(summary.detailed_timeline ?? '', gutterItems);
92
+ renderedHtmlCache.set(cacheKey, html);
93
+ return html;
94
+ }
82
95
 
83
96
  // Fun rest day messages
84
97
  const REST_DAY_MESSAGES = [
@@ -320,7 +333,7 @@
320
333
  {#if summary.detailed_timeline && isExpanded}
321
334
  <div class="detailed-section">
322
335
  <div class="detailed-timeline markdown-content">
323
- {@html renderMarkdownWithGutter(summary.detailed_timeline, gutterItems)}
336
+ {@html getRenderedHtml(summary)}
324
337
  </div>
325
338
  </div>
326
339
  {/if}
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Timeline Curio Secrets Helper
3
+ *
4
+ * Provides token retrieval with graceful migration from legacy encryption
5
+ * (TOKEN_ENCRYPTION_KEY) to envelope encryption (SecretsManager).
6
+ *
7
+ * Migration strategy:
8
+ * 1. Try SecretsManager first (new system, per-tenant isolation)
9
+ * 2. Fall back to legacy column + TOKEN_ENCRYPTION_KEY
10
+ * 3. Auto-migrate legacy tokens to SecretsManager on successful read
11
+ */
12
+ import { type SecretsManager } from "../../server/secrets";
13
+ /** Secret key names for Timeline tokens in SecretsManager */
14
+ export declare const TIMELINE_SECRET_KEYS: {
15
+ readonly GITHUB_TOKEN: "timeline_github_token";
16
+ readonly OPENROUTER_KEY: "timeline_openrouter_key";
17
+ };
18
+ export type TimelineSecretKey = (typeof TIMELINE_SECRET_KEYS)[keyof typeof TIMELINE_SECRET_KEYS];
19
+ /**
20
+ * Environment bindings needed for token operations
21
+ */
22
+ interface TokenEnv {
23
+ DB: D1Database;
24
+ GROVE_KEK?: {
25
+ get(): Promise<string>;
26
+ };
27
+ TOKEN_ENCRYPTION_KEY?: string;
28
+ }
29
+ /**
30
+ * Result of a token retrieval operation
31
+ */
32
+ export interface TokenResult {
33
+ token: string | null;
34
+ source: "secrets_manager" | "legacy" | "none";
35
+ migrated: boolean;
36
+ }
37
+ /**
38
+ * Get a Timeline token with graceful migration from legacy encryption.
39
+ *
40
+ * Priority:
41
+ * 1. SecretsManager (envelope encryption) - preferred, per-tenant isolation
42
+ * 2. Legacy column + TOKEN_ENCRYPTION_KEY - fallback with auto-migrate
43
+ *
44
+ * @param env - Platform environment with DB and encryption bindings
45
+ * @param tenantId - The tenant ID
46
+ * @param keyName - Which secret to retrieve
47
+ * @param legacyColumnValue - Value from the legacy encrypted column (may be null)
48
+ * @returns TokenResult with the decrypted token and metadata
49
+ */
50
+ export declare function getTimelineToken(env: TokenEnv, tenantId: string, keyName: TimelineSecretKey, legacyColumnValue: string | null): Promise<TokenResult>;
51
+ /**
52
+ * Save a Timeline token using SecretsManager (preferred) or legacy encryption.
53
+ *
54
+ * @param env - Platform environment with DB and encryption bindings
55
+ * @param tenantId - The tenant ID
56
+ * @param keyName - Which secret to store
57
+ * @param plainToken - The plaintext token value
58
+ * @returns Object indicating which system was used
59
+ */
60
+ export declare function setTimelineToken(env: TokenEnv, tenantId: string, keyName: TimelineSecretKey, plainToken: string): Promise<{
61
+ system: "secrets_manager" | "legacy";
62
+ legacyValue: string | null;
63
+ }>;
64
+ /**
65
+ * Delete a Timeline token from SecretsManager.
66
+ * Legacy column should be cleared separately via SQL.
67
+ */
68
+ export declare function deleteTimelineToken(env: TokenEnv, tenantId: string, keyName: TimelineSecretKey): Promise<boolean>;
69
+ /**
70
+ * Check if a Timeline token exists in either system.
71
+ */
72
+ export declare function hasTimelineToken(env: TokenEnv, tenantId: string, keyName: TimelineSecretKey, legacyColumnValue: string | null): Promise<boolean>;
73
+ /**
74
+ * Create a SecretsManager instance, handling missing GROVE_KEK gracefully.
75
+ * Returns null if GROVE_KEK is not configured.
76
+ */
77
+ export declare function maybeCreateSecretsManager(env: TokenEnv): Promise<SecretsManager | null>;
78
+ export {};