@poveste/app 0.5.3 → 0.6.1

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 (90) hide show
  1. package/dist/app/style/font.css +78 -0
  2. package/dist/app/style/main.pcss +4 -1
  3. package/dist/app/style/sandbox.css +3 -0
  4. package/dist/bundle-main-dev.js +1 -0
  5. package/dist/bundle-main.js +3 -0
  6. package/dist/bundled/app.css +28 -20
  7. package/dist/bundled/components/app/AppActions.vue_vue_type_script_setup_true_lang.js +2 -2
  8. package/dist/bundled/components/base/BaseSelect.vue_vue_type_script_setup_true_lang.js +1 -1
  9. package/dist/bundled/components/layout/LayoutModal.vue_vue_type_script_setup_true_lang.js +2 -2
  10. package/dist/bundled/components/layout/LayoutPane.vue_vue_type_script_setup_true_lang.js +4 -4
  11. package/dist/bundled/components/panel/StatePresets.vue_vue_type_script_setup_true_lang.js +5 -5
  12. package/dist/bundled/components/panel/StoryControls.vue_vue_type_script_setup_true_lang.js +1 -1
  13. package/dist/bundled/components/panel/StoryDocs.vue_vue_type_script_setup_true_lang.js +1 -1
  14. package/dist/bundled/components/panel/StoryEvent.vue_vue_type_script_setup_true_lang.js +1 -1
  15. package/dist/bundled/components/panel/StorySidePanel.vue_vue_type_script_setup_true_lang.js +1 -1
  16. package/dist/bundled/components/panel/StorySourceCode.js +1 -1
  17. package/dist/bundled/components/panel/StorySourceCode.vue_vue_type_script_setup_true_lang.js +2 -2
  18. package/dist/bundled/components/search/SearchItem.js +1 -1
  19. package/dist/bundled/components/search/SearchItem.vue_vue_type_script_setup_true_lang.js +2 -2
  20. package/dist/bundled/components/search/SearchModal.vue_vue_type_script_setup_true_lang.js +1 -1
  21. package/dist/bundled/components/search/SearchPane.vue_vue_type_script_setup_true_lang.js +1 -0
  22. package/dist/bundled/components/story/StoryResponsivePreview.js +1 -1
  23. package/dist/bundled/components/story/StoryResponsivePreview.vue_vue_type_script_setup_true_lang.js +69 -64
  24. package/dist/bundled/components/story/StoryVariantGrid.js +3 -1
  25. package/dist/bundled/components/story/StoryVariantGrid.vue_vue_type_script_setup_true_lang.js +82 -35
  26. package/dist/bundled/components/story/StoryVariantGridItem.js +1 -1
  27. package/dist/bundled/components/story/StoryVariantGridItem.vue_vue_type_script_setup_true_lang.js +20 -12
  28. package/dist/bundled/components/story/StoryVariantListItem.js +1 -1
  29. package/dist/bundled/components/story/StoryVariantListItem.vue_vue_type_script_setup_true_lang.js +2 -2
  30. package/dist/bundled/components/story/StoryVariantSinglePreviewNative.vue_vue_type_script_setup_true_lang.js +1 -1
  31. package/dist/bundled/components/story/StoryVariantSinglePreviewRemote.vue_vue_type_script_setup_true_lang.js +97 -32
  32. package/dist/bundled/components/story/StoryVariantSingleView.vue_vue_type_script_setup_true_lang.js +1 -1
  33. package/dist/bundled/components/toolbar/ToolbarBackground.js +1 -1
  34. package/dist/bundled/components/toolbar/ToolbarBackground.vue_vue_type_script_setup_true_lang.js +7 -7
  35. package/dist/bundled/components/tree/StoryGroup.vue_vue_type_script_setup_true_lang.js +1 -1
  36. package/dist/bundled/components/tree/StoryListFolder.js +1 -1
  37. package/dist/bundled/components/tree/StoryListFolder.vue_vue_type_script_setup_true_lang.js +2 -2
  38. package/dist/bundled/components/tree/StoryListItem.js +1 -1
  39. package/dist/bundled/components/tree/StoryListItem.vue_vue_type_script_setup_true_lang.js +3 -3
  40. package/dist/bundled/index.js +3 -0
  41. package/dist/bundled/sandbox.js +53 -24
  42. package/dist/bundled/util/const.js +2 -1
  43. package/dist/bundled/util/grid-cell-height.js +18 -0
  44. package/dist/bundled/util/grid-slots.js +64 -0
  45. package/dist/bundled/util/icons.generated.js +104 -0
  46. package/dist/bundled/util/state-bridge.js +75 -0
  47. package/dist/bundled/util/state.js +19 -1
  48. package/dist/style.css +7 -2
  49. package/package.json +12 -5
  50. package/src/__tests__/font.spec.ts +30 -0
  51. package/src/__tests__/grid-slots.spec.ts +84 -0
  52. package/src/__tests__/icons.spec.ts +26 -0
  53. package/src/__tests__/state-bridge.spec.ts +155 -0
  54. package/src/__tests__/state-clone.spec.ts +66 -0
  55. package/src/app/components/app/AppActions.vue +2 -2
  56. package/src/app/components/layout/LayoutModal.vue +2 -2
  57. package/src/app/components/layout/LayoutPane.vue +4 -4
  58. package/src/app/components/panel/StatePresets.vue +5 -5
  59. package/src/app/components/panel/StoryControls.vue +1 -1
  60. package/src/app/components/panel/StoryDocs.vue +1 -1
  61. package/src/app/components/panel/StoryEvent.vue +1 -1
  62. package/src/app/components/panel/StorySidePanel.vue +1 -1
  63. package/src/app/components/panel/StorySourceCode.vue +2 -2
  64. package/src/app/components/search/SearchItem.vue +1 -1
  65. package/src/app/components/search/SearchModal.vue +1 -1
  66. package/src/app/components/search/SearchPane.vue +8 -0
  67. package/src/app/components/story/StoryResponsivePreview.vue +85 -50
  68. package/src/app/components/story/StoryVariantGrid.vue +126 -49
  69. package/src/app/components/story/StoryVariantGridItem.vue +35 -10
  70. package/src/app/components/story/StoryVariantListItem.vue +1 -1
  71. package/src/app/components/story/StoryVariantSinglePreviewNative.vue +1 -1
  72. package/src/app/components/story/StoryVariantSinglePreviewRemote.vue +165 -35
  73. package/src/app/components/story/StoryVariantSingleView.vue +1 -1
  74. package/src/app/components/toolbar/ToolbarBackground.vue +4 -4
  75. package/src/app/components/tree/StoryGroup.vue +1 -1
  76. package/src/app/components/tree/StoryListFolder.vue +1 -1
  77. package/src/app/components/tree/StoryListItem.vue +1 -1
  78. package/src/app/index.ts +10 -0
  79. package/src/app/sandbox.ts +95 -31
  80. package/src/app/style/font.css +78 -0
  81. package/src/app/style/main.pcss +4 -1
  82. package/src/app/style/sandbox.css +3 -0
  83. package/src/app/util/const.ts +1 -0
  84. package/src/app/util/grid-cell-height.ts +16 -0
  85. package/src/app/util/grid-slots.ts +72 -0
  86. package/src/app/util/icons.generated.ts +9 -0
  87. package/src/app/util/state-bridge.ts +95 -0
  88. package/src/app/util/state.ts +19 -0
  89. package/src/bundle-main-dev.js +1 -0
  90. package/src/bundle-main.js +3 -0
@@ -1,23 +1,27 @@
1
1
  <script lang="ts" setup>
2
2
  import type { HstEvent } from '../../stores/events'
3
3
  import type { Story, Variant } from '../../types'
4
- import { applyState } from '@poveste/shared'
5
4
  import { useEventListener } from '@vueuse/core'
6
5
  import { computed, onBeforeUnmount, ref, toRaw, watch } from 'vue'
7
6
  import { useEventsStore } from '../../stores/events'
8
7
  import { usePreviewSettingsStore } from '../../stores/preview-settings'
9
8
  import { useStoryStore } from '../../stores/story'
10
- import { EVENT_SEND, PREVIEW_SETTINGS_REQUEST, PREVIEW_SETTINGS_SYNC, SANDBOX_HEIGHT, SANDBOX_READY, STATE_SYNC } from '../../util/const'
9
+ import { EVENT_SEND, PREVIEW_SETTINGS_REQUEST, PREVIEW_SETTINGS_SYNC, SANDBOX_HEIGHT, SANDBOX_READY, SANDBOX_RETARGET, STATE_SYNC } from '../../util/const'
10
+ import { firstReportedHeight } from '../../util/grid-cell-height'
11
11
  import { trackWindow } from '../../util/keyboard'
12
12
  import { getSandboxUrl } from '../../util/sandbox'
13
13
  import { toRawDeep } from '../../util/state'
14
+ import { createStateBridge } from '../../util/state-bridge'
14
15
  import StoryResponsivePreview from './StoryResponsivePreview.vue'
15
16
 
16
- const props = defineProps<{
17
+ const props = withDefaults(defineProps<{
17
18
  story: Story
18
19
  variant: Variant
19
20
  autoHeight?: boolean
20
- }>()
21
+ responsive?: boolean
22
+ }>(), {
23
+ responsive: true,
24
+ })
21
25
 
22
26
  const settings = usePreviewSettingsStore().currentSettings
23
27
 
@@ -25,67 +29,105 @@ const settings = usePreviewSettingsStore().currentSettings
25
29
 
26
30
  const iframe = ref<HTMLIFrameElement>()
27
31
 
28
- function syncState() {
29
- if (iframe.value?.contentWindow && props.variant.previewReady) {
30
- iframe.value.contentWindow.postMessage({
31
- type: STATE_SYNC,
32
- state: toRawDeep(props.variant.state, true),
33
- })
34
- }
32
+ // The host end of the bridge. It sends what this side changed rather than the
33
+ // whole state, so a write here and a write in the story cannot overwrite each
34
+ // other — see `createStateBridge` for what that used to cost.
35
+ function postState(changes: Record<string, any>) {
36
+ iframe.value?.contentWindow?.postMessage({
37
+ type: STATE_SYNC,
38
+ state: changes,
39
+ })
35
40
  }
36
41
 
37
- // The host end of the bridge, and the same flag as `plugin-vue`'s state sync
38
- // with the same #95 caveat: `synced` may only be set when the write below will
39
- // actually provoke the watcher. `applyState` reports that now. Setting it
40
- // unconditionally means an apply that changes nothing leaves it set, and the
41
- // next real edit is skipped instead — a control that does nothing.
42
- let synced = false
42
+ let bridge = createStateBridge(postState)
43
43
 
44
- watch(() => props.variant.state, () => {
45
- if (synced) {
46
- synced = false
47
- return
48
- }
44
+ function syncState() {
45
+ // Nothing can be sent yet, so nothing may be recorded as agreed either — the
46
+ // baseline would then hold changes the sandbox has never seen, and send it
47
+ // only the ones that came after. Returning early leaves the first message
48
+ // that does go out carrying the whole state, which is what it carried before.
49
+ if (!iframe.value?.contentWindow || !props.variant.previewReady) return
50
+
51
+ bridge.send(toRawDeep(props.variant.state, true))
52
+ }
53
+
54
+ watch(() => props.variant.state, (state, previous) => {
55
+ // Another variant's state, not a change to this one: this cell was handed a
56
+ // new occupant (#240). The handover below syncs once the realm reports it in;
57
+ // sending now would put the new variant's state into the old occupant.
58
+ if (state !== previous) return
49
59
  syncState()
50
60
  }, {
51
61
  deep: true,
52
- immediate: true,
53
62
  })
54
63
 
55
64
  const storyStore = useStoryStore()
56
65
 
57
66
  storyStore.setPreviewReady(props.variant, false)
58
67
 
68
+ // Floor for a reported height, and what a cell shows before its sandbox reports.
69
+ const MIN_HEIGHT = 40
70
+
71
+ // A cell may grow to its story but not past this, because a grid row is as tall
72
+ // as its tallest item — one long variant would otherwise stretch every sibling
73
+ // in its row to match.
74
+ const MAX_AUTO_HEIGHT = 400
75
+
59
76
  const reportedHeight = ref<number | null>(null)
60
77
 
78
+ const placeholderHeight = computed(() => (
79
+ firstReportedHeight.value?.storyId === props.story.id ? firstReportedHeight.value.height : MIN_HEIGHT
80
+ ))
81
+
82
+ // Whether a message is about the variant this cell shows now. A realm that has
83
+ // just been retargeted can still speak for its old occupant — a height from an
84
+ // observer, a state change from a timer — until it processes the handover, and
85
+ // those must not land on the new one. Unnamed messages come from sandboxes that
86
+ // predate retargeting; take those.
87
+ function fromCurrentOccupant(data: { storyId?: string, variantId?: string }) {
88
+ return !data.variantId || (data.variantId === props.variant.id && data.storyId === props.story.id)
89
+ }
90
+
61
91
  useEventListener(window, 'message', (event) => {
62
92
  // With many grid iframes mounted, every sandbox postMessage hits every
63
93
  // parent listener. Skip events that didn't originate from this iframe.
64
94
  if (event.source !== iframe.value?.contentWindow) return
65
95
  switch (event.data?.type) {
66
96
  case STATE_SYNC:
97
+ if (!fromCurrentOccupant(event.data)) break
67
98
  updateVariantState(event.data.state)
68
99
  break
69
100
  case EVENT_SEND:
70
101
  logEvent(event.data.event)
71
102
  break
72
103
  case SANDBOX_READY:
104
+ if (!fromCurrentOccupant(event.data)) break
73
105
  setPreviewReady()
74
106
  break
75
107
  case PREVIEW_SETTINGS_REQUEST:
76
108
  syncSettings()
77
109
  break
78
110
  case SANDBOX_HEIGHT:
79
- if (typeof event.data.h === 'number') {
80
- const next = Math.max(40, event.data.h)
111
+ if (!fromCurrentOccupant(event.data)) break
112
+ // The sandbox reports on its first frames, before the story mounts, and
113
+ // those carry the empty body — 0, or a few px of margin. Clamping one to
114
+ // the floor would count as a real measurement and lock the cell out of
115
+ // the placeholder below, which is what left rows stuck at 40px. Anything
116
+ // under the floor renders as the floor anyway, so nothing is lost by
117
+ // waiting for a bigger number.
118
+ if (typeof event.data.h === 'number' && event.data.h >= MIN_HEIGHT) {
119
+ const next = event.data.h
81
120
  if (next !== reportedHeight.value) reportedHeight.value = next
121
+ if (props.autoHeight && firstReportedHeight.value?.storyId !== props.story.id) {
122
+ firstReportedHeight.value = { storyId: props.story.id, height: next }
123
+ }
82
124
  }
83
125
  break
84
126
  }
85
127
  })
86
128
 
87
129
  function updateVariantState(state: any) {
88
- synced = applyState(props.variant.state, state)
130
+ bridge.receive(props.variant.state, state)
89
131
  }
90
132
 
91
133
  function logEvent(event: HstEvent) {
@@ -93,28 +135,97 @@ function logEvent(event: HstEvent) {
93
135
  eventsStore.addEvent(event)
94
136
  }
95
137
 
138
+ // Realm reuse state (#240) — see the block below `sandboxUrl` for the design.
139
+ const REUSE_LIMIT = 30
140
+ const RETARGET_TIMEOUT = 8_000
141
+ const retargeting = ref(false)
142
+ let retargetTimer: ReturnType<typeof setTimeout> | undefined
143
+
96
144
  function setPreviewReady() {
145
+ retargeting.value = false
146
+ clearTimeout(retargetTimer)
97
147
  storyStore.setPreviewReady(props.variant, true)
148
+ // A fresh mount starts from the story's own state; what this side holds for
149
+ // the variant — earlier control edits, or the state it had the last time this
150
+ // realm showed it — is the truth the story should render.
151
+ syncState()
98
152
  }
99
153
 
100
154
  const sandboxUrl = computed(() => {
101
155
  return getSandboxUrl(props.story, props.variant)
102
156
  })
103
157
 
158
+ /*
159
+ * Realm reuse (#240). A sandbox document is a full realm boot — bundle
160
+ * evaluation, story mount — and same-origin iframes boot serially on one main
161
+ * thread, so a grid that scrolls pays that for every cell that enters the
162
+ * window. Instead of changing `src`, a warm realm is told which variant to
163
+ * show next and remounts it in place: the order of the realm boot cost becomes
164
+ * the number of cells on screen, not the number scrolled past.
165
+ *
166
+ * What survives between occupants is the realm's JS state — globals a story
167
+ * patched, timers it leaked. Style isolation is untouched. Two valves for
168
+ * stories that cannot share: `layout.isolate` opts a story back into a cold
169
+ * document per render — on the way in and on the way out, since what it
170
+ * leaves behind is the point — and a realm is cold-reloaded after REUSE_LIMIT
171
+ * retargets so what does accumulate stays bounded. A retarget that never
172
+ * reports ready falls back to a reload.
173
+ */
174
+ // What the iframe actually loads. Only a cold (re)load assigns it.
175
+ const iframeSrc = ref(sandboxUrl.value)
104
176
  const isIframeLoaded = ref(false)
177
+ let retargets = 0
178
+ // The story whose document the realm holds — booted or retargeted into it.
179
+ let servedStory = props.story
105
180
 
106
181
  let stopTrackKeyboard: (() => void) | undefined
107
182
  let unmounted = false
108
183
 
109
- watch(sandboxUrl, () => {
184
+ function reload(url: string) {
185
+ retargets = 0
186
+ retargeting.value = false
187
+ clearTimeout(retargetTimer)
110
188
  isIframeLoaded.value = false
111
- storyStore.setPreviewReady(props.variant, false)
189
+ reportedHeight.value = null
190
+ servedStory = props.story
112
191
  stopTrackKeyboard?.()
113
192
  stopTrackKeyboard = undefined
193
+ // The document the realm booted with is the one wanted again — after a
194
+ // retarget that timed out, or one past the reuse limit. The binding would
195
+ // not change, so nothing would load; set the attribute by hand.
196
+ if (iframeSrc.value === url && iframe.value) iframe.value.src = url
197
+ iframeSrc.value = url
198
+ }
199
+
200
+ watch(sandboxUrl, (url) => {
201
+ // A new occupant has agreed to nothing, so neither has this end. Kept as a
202
+ // new bridge rather than a reset so there is one way to be in that state.
203
+ bridge = createStateBridge(postState)
204
+ storyStore.setPreviewReady(props.variant, false)
205
+
206
+ const reuse = props.story.layout?.isolate !== true && servedStory.layout?.isolate !== true
207
+ const target = iframe.value?.contentWindow
208
+ if (reuse && isIframeLoaded.value && target && retargets < REUSE_LIMIT) {
209
+ // Within a story the last height is the best guess until the new occupant
210
+ // reports; snapping to the placeholder in between made rows jump twice.
211
+ if (servedStory.id !== props.story.id) reportedHeight.value = null
212
+ servedStory = props.story
213
+ retargets++
214
+ retargeting.value = true
215
+ target.postMessage({ type: SANDBOX_RETARGET, storyId: props.story.id, variantId: props.variant.id }, window.location.origin)
216
+ clearTimeout(retargetTimer)
217
+ retargetTimer = setTimeout(() => {
218
+ if (retargeting.value && !unmounted) reload(url)
219
+ }, RETARGET_TIMEOUT)
220
+ return
221
+ }
222
+
223
+ reload(url)
114
224
  })
115
225
 
116
226
  onBeforeUnmount(() => {
117
227
  unmounted = true
228
+ clearTimeout(retargetTimer)
118
229
  stopTrackKeyboard?.()
119
230
  })
120
231
 
@@ -136,12 +247,33 @@ watch(() => settings, () => {
136
247
  immediate: true,
137
248
  })
138
249
 
250
+ // `auto-height` — the grid — sizes a cell to the story it renders, and that
251
+ // wins over the responsive height. The responsive size is for examining one
252
+ // component at a viewport; applied to every cell it just makes the grid
253
+ // unreadable. A stored `responsiveHeight` of 600 gives 729px cells holding one
254
+ // small button, which is the shape #198 was filed about.
255
+ function previewStyle(isResponsiveEnabled: boolean, finalWidth: number | null, finalHeight: number | null) {
256
+ const style: Record<string, string> = {}
257
+
258
+ if (isResponsiveEnabled) {
259
+ if (finalWidth) style.width = `${finalWidth}px`
260
+ if (finalHeight) style.height = `${finalHeight}px`
261
+ }
262
+
263
+ if (props.autoHeight) {
264
+ style.height = `${Math.min(reportedHeight.value ?? placeholderHeight.value, MAX_AUTO_HEIGHT)}px`
265
+ }
266
+
267
+ return style
268
+ }
269
+
139
270
  // Iframe load
140
271
 
141
272
  function onIframeLoad() {
142
273
  if (unmounted) {
143
274
  return
144
275
  }
276
+ retargets = 0
145
277
  isIframeLoaded.value = true
146
278
  syncState()
147
279
  syncSettings()
@@ -157,20 +289,18 @@ function onIframeLoad() {
157
289
  v-slot="{ isResponsiveEnabled, finalWidth, finalHeight, resizing }"
158
290
  class="poveste-story-variant-single-preview-remote"
159
291
  :variant="variant"
292
+ :responsive="responsive"
160
293
  >
161
294
  <iframe
162
295
  ref="iframe"
163
- :src="sandboxUrl"
296
+ :src="iframeSrc"
164
297
  class="w-full h-full relative"
165
298
  :class="{
166
- 'invisible': !isIframeLoaded,
299
+ 'invisible': !isIframeLoaded || retargeting,
167
300
  'pointer-events-none': resizing,
168
301
  }"
169
- :style="isResponsiveEnabled ? {
170
- width: finalWidth ? `${finalWidth}px` : null,
171
- height: finalHeight ? `${finalHeight}px` : null,
172
- } : (props.autoHeight && reportedHeight ? { height: `${reportedHeight}px` } : undefined)"
173
- data-test-id="preview-iframe"
302
+ :style="previewStyle(isResponsiveEnabled, finalWidth, finalHeight)"
303
+ data-testid="preview-iframe"
174
304
  @load="onIframeLoad()"
175
305
  />
176
306
  </StoryResponsivePreview>
@@ -12,7 +12,7 @@ defineProps<{
12
12
  <template>
13
13
  <div
14
14
  class="poveste-story-variant-single-view h-full flex flex-col"
15
- data-test-id="story-variant-single-view"
15
+ data-testid="story-variant-single-view"
16
16
  >
17
17
  <StoryVariantSinglePreviewNative
18
18
  v-if="story.layout?.iframe === false"
@@ -26,7 +26,7 @@ const showDropdown = showColorScheme || !!povesteConfig.backgroundPresets.length
26
26
  placement="bottom-end"
27
27
  :skidding="6"
28
28
  class="poveste-toolbar-background flex-none"
29
- data-test-id="toolbar-background"
29
+ data-testid="toolbar-background"
30
30
  >
31
31
  <!-- The tooltip would sit on top of the first row of the dropdown, so drop
32
32
  it while the dropdown is open. -->
@@ -49,17 +49,17 @@ const showDropdown = showColorScheme || !!povesteConfig.backgroundPresets.length
49
49
  <template #popper="{ hide }">
50
50
  <div
51
51
  class="flex flex-col items-stretch"
52
- data-test-id="background-popper"
52
+ data-testid="background-popper"
53
53
  >
54
54
  <div
55
55
  v-if="showColorScheme"
56
56
  class="flex items-stretch gap-1 px-2 pt-2"
57
- data-test-id="sandbox-color-scheme"
57
+ data-testid="sandbox-color-scheme"
58
58
  >
59
59
  <button
60
60
  v-for="option in colorSchemeOptions"
61
61
  :key="option.value"
62
- :data-test-id="`sandbox-color-scheme-${option.value}`"
62
+ :data-testid="`sandbox-color-scheme-${option.value}`"
63
63
  class="flex-1 px-3 py-1.5 rounded flex items-center justify-center gap-1.5 cursor-pointer text-sm"
64
64
  :class="[
65
65
  settings.colorScheme === option.value
@@ -26,7 +26,7 @@ function toggleOpen() {
26
26
 
27
27
  <template>
28
28
  <div
29
- data-test-id="story-group"
29
+ data-testid="story-group"
30
30
  class="poveste-story-group my-2 first:mt-0 last:mb-0 group"
31
31
  >
32
32
  <template v-if="group.title">
@@ -31,7 +31,7 @@ const folderPadding = computed(() => {
31
31
 
32
32
  <template>
33
33
  <div
34
- data-test-id="story-list-folder"
34
+ data-testid="story-list-folder"
35
35
  class="poveste-story-list-folder"
36
36
  >
37
37
  <div
@@ -26,7 +26,7 @@ useScrollOnActive(isActive, el)
26
26
  <template>
27
27
  <div
28
28
  ref="el"
29
- data-test-id="story-list-item"
29
+ data-testid="story-list-item"
30
30
  class="poveste-story-list-item"
31
31
  >
32
32
  <BaseListItemLink
package/src/app/index.ts CHANGED
@@ -1,12 +1,22 @@
1
+ import { addCollection } from '@iconify/vue'
1
2
  import FloatingVue from 'floating-vue'
2
3
  import { createPinia } from 'pinia'
3
4
  import { createApp } from 'vue'
4
5
  import App from './App.vue'
5
6
  import { setupPluginApi } from './plugin.js'
6
7
  import { router } from './router'
8
+ import { iconCollections } from './util/icons.generated.js'
7
9
  import 'virtual:$poveste-theme'
8
10
 
9
11
  export async function mountMainApp() {
12
+ // The chrome's icon data ships with the app. Without this every Icon fetched
13
+ // its data from api.iconify.design at runtime — a handful of CDN requests per
14
+ // boot, and no toolbar offline (#219). Story icons a user sets still resolve
15
+ // through the runtime lookup.
16
+ for (const collection of iconCollections) {
17
+ addCollection(collection)
18
+ }
19
+
10
20
  const app = createApp(App)
11
21
  app.use(createPinia())
12
22
  app.use(FloatingVue, {
@@ -1,22 +1,42 @@
1
1
  import type { StoryFile } from './types'
2
- import { applyState } from '@poveste/shared'
3
2
  import { usePreferredDark } from '@vueuse/core'
4
3
  import { createPinia } from 'pinia'
5
- import { files } from 'virtual:$poveste-stories'
4
+ import { files, onUpdate } from 'virtual:$poveste-stories'
6
5
  import { computed, createApp, h, onMounted, ref, watch } from 'vue'
7
6
  import { parseQuery } from 'vue-router'
8
7
  import GenericMountStory from './components/story/GenericMountStory.vue'
9
8
  import GenericRenderStory from './components/story/GenericRenderStory.vue'
10
- import { setupPluginApi } from './plugin.js'
11
9
  import { previewDarkClasses, resolvePreviewDark } from './util/color-scheme.js'
12
- import { PREVIEW_SETTINGS_REQUEST, PREVIEW_SETTINGS_SYNC, SANDBOX_HEIGHT, SANDBOX_READY, STATE_SYNC } from './util/const.js'
10
+ import { PREVIEW_SETTINGS_REQUEST, PREVIEW_SETTINGS_SYNC, SANDBOX_HEIGHT, SANDBOX_READY, SANDBOX_RETARGET, STATE_SYNC } from './util/const.js'
13
11
  import { isDark } from './util/dark.js'
14
12
  import { mapFile } from './util/mapping'
15
13
  import { applyPreviewSettings, loadStoredPreviewSettings, receivedSettings } from './util/preview-settings.js'
14
+ import { createStateBridge } from './util/state-bridge.js'
16
15
  import { toRawDeep } from './util/state.js'
17
16
 
18
17
  const query = parseQuery(window.location.search)
19
- const file = ref<StoryFile>(mapFile(files.find(f => f.id === query.storyId)))
18
+
19
+ // What this realm serves. Set from the URL on boot, and again by a
20
+ // SANDBOX_RETARGET from the host: a warm realm is handed the next story or
21
+ // variant instead of being torn down for a new document (#240).
22
+ const storyId = ref(typeof query.storyId === 'string' ? query.storyId : null)
23
+ const variantId = ref(typeof query.variantId === 'string' ? query.variantId : null)
24
+
25
+ // `files` is `[]` until the dev server's first collect lands. The app's iframe
26
+ // never sees that — it mounts after — but a sandbox opened on its own does, and
27
+ // the list only arrives through `onUpdate`.
28
+ const file = ref<StoryFile | null>(null)
29
+ function resolveFile(list: StoryFile[] = files) {
30
+ const found = list.find(f => f.id === storyId.value)
31
+ // Same story, different variant: keep the mapped file so the story component
32
+ // is not remounted for a variant switch.
33
+ if (found && file.value?.story.id === found.id) return
34
+ file.value = found ? mapFile(found) : null
35
+ }
36
+ resolveFile()
37
+ onUpdate((newFiles: StoryFile[]) => {
38
+ if (!file.value) resolveFile(newFiles)
39
+ })
20
40
 
21
41
  // Sandboxes opened on their own (the "open in a new tab" toolbar button) never
22
42
  // receive a PREVIEW_SETTINGS_SYNC, and inside the app the first one only lands
@@ -72,41 +92,64 @@ watch(previewDark, (value) => {
72
92
  immediate: true,
73
93
  })
74
94
 
95
+ // Height reporting state, declared here because the retarget handler inside the
96
+ // app resets it.
97
+ let pendingFrame: number | null = null
98
+ let lastReportedHeight = -1
99
+
75
100
  const app = createApp({
76
101
  name: 'SandboxApp',
77
102
 
78
103
  setup() {
79
- const story = computed(() => file.value.story)
80
- const variant = computed(() => story.value?.variants.find(v => v.id === query.variantId))
81
-
82
- // `synced` is the sandbox end of the same flag the host holds, with the same
83
- // #95 caveat: only set it when the apply will actually provoke the watcher
84
- // below, which is what `applyState`'s return value reports. Set it
85
- // unconditionally and an apply that changes nothing leaves it stuck, so the
86
- // next edit the story makes never reaches the host.
87
- let synced = false
104
+ const story = computed(() => file.value?.story)
105
+ const variant = computed(() => story.value?.variants.find(v => v.id === variantId.value))
106
+
107
+ // The sandbox end of the bridge. It sends what the story changed rather than
108
+ // the whole state, so a write here and a control edit on the host cannot
109
+ // overwrite each other — see `createStateBridge` for what that used to cost.
110
+ // Named, like SANDBOX_READY and SANDBOX_HEIGHT: after a retarget the host
111
+ // is waiting on the new occupant, and anything the old one still says
112
+ // must be told apart from that.
113
+ const postState = (changes: Record<string, any>) => {
114
+ window.parent?.postMessage({
115
+ type: STATE_SYNC,
116
+ state: changes,
117
+ storyId: storyId.value,
118
+ variantId: variantId.value,
119
+ })
120
+ }
121
+ let bridge = createStateBridge(postState)
88
122
  let mounted = false
89
123
 
90
124
  window.addEventListener('message', (event) => {
91
125
  // console.log('[sandbox] received message', event.data)
92
126
  if (event.data?.type === STATE_SYNC) {
93
- if (!mounted) return
94
- synced = applyState(variant.value.state, event.data.state)
127
+ if (!mounted || !variant.value) return
128
+ bridge.receive(variant.value.state, event.data.state)
95
129
  }
96
130
  else if (event.data?.type === PREVIEW_SETTINGS_SYNC) {
97
131
  applyPreviewSettings(event.data.settings)
98
132
  }
133
+ else if (event.data?.type === SANDBOX_RETARGET) {
134
+ // A new occupant: nothing has been agreed with the host about it, and
135
+ // the last height belonged to the old one. The keyed render below
136
+ // remounts and reports both afresh.
137
+ bridge = createStateBridge(postState)
138
+ lastReportedHeight = -1
139
+ variantId.value = typeof event.data.variantId === 'string' ? event.data.variantId : null
140
+ storyId.value = typeof event.data.storyId === 'string' ? event.data.storyId : null
141
+ resolveFile()
142
+ }
99
143
  })
100
144
 
101
- watch(() => variant.value.state, (value) => {
102
- if (synced && mounted) {
103
- synced = false
104
- return
105
- }
106
- window.parent?.postMessage({
107
- type: STATE_SYNC,
108
- state: toRawDeep(value, true),
109
- })
145
+ watch(() => variant.value?.state, (value, previous) => {
146
+ // A retarget swapped the occupant, not a story writing its state. The
147
+ // new one has agreed to nothing yet, and a send here would carry its
148
+ // whole state — defaults, or a copy left from the last time this realm
149
+ // served it — over what the host holds, and the host's does come down
150
+ // once the new mount reports ready.
151
+ if (!value || value !== previous) return
152
+ bridge.send(toRawDeep(value, true))
110
153
  }, {
111
154
  deep: true,
112
155
  })
@@ -122,18 +165,36 @@ const app = createApp({
122
165
  },
123
166
 
124
167
  render() {
168
+ if (!file.value) {
169
+ return null
170
+ }
171
+
125
172
  return [
126
173
  h('div', { class: '__poveste-sandbox-hidden' }, [
127
174
  h(GenericMountStory, {
128
175
  key: file.value.story.id,
129
176
  story: file.value.story,
177
+ // This realm serves exactly one variant at a time; a plugin that
178
+ // mounts variants can skip all the others (#197). Falls through
179
+ // GenericMountStory as an attr, so plugins that don't know the prop
180
+ // ignore it.
181
+ targetVariantId: variantId.value,
130
182
  }),
131
183
  ]),
132
184
  this.story && this.variant
133
- ? h(GenericRenderStory, { story: this.story, variant: this.variant, onReady: () => {
185
+ // Keyed so a retarget is a clean unmount and mount, not a prop update
186
+ // into a host that is halfway through rendering the previous occupant.
187
+ ? h(GenericRenderStory, { key: `${this.story.id}/${this.variant.id}`, story: this.story, variant: this.variant, onReady: () => {
188
+ // Named, so a host that has since retargeted this realm can tell a
189
+ // late ready for the old occupant from the one it is waiting for.
134
190
  window.parent?.postMessage({
135
191
  type: SANDBOX_READY,
192
+ storyId: this.story.id,
193
+ variantId: this.variant.id,
136
194
  })
195
+ // A retarget to a same-height story would otherwise never report:
196
+ // the observer only fires on a change.
197
+ scheduleReport()
137
198
  } })
138
199
  : null,
139
200
  ]
@@ -148,8 +209,6 @@ app.mount('#app')
148
209
  // source-level rules that add overflow/min-height to the render root.
149
210
  document.body.classList.add('__poveste-render-story', '__poveste-render-custom-controls')
150
211
 
151
- let pendingFrame: number | null = null
152
- let lastReportedHeight = -1
153
212
  function reportHeight() {
154
213
  pendingFrame = null
155
214
  const renderRoot = document.querySelector('.__poveste-render-story')
@@ -160,7 +219,7 @@ function reportHeight() {
160
219
  lastReportedHeight = h
161
220
  // Same-origin: parent and iframe are both served by the poveste dev /
162
221
  // build server, so scope the target rather than broadcasting.
163
- window.parent?.postMessage({ type: SANDBOX_HEIGHT, h }, window.location.origin)
222
+ window.parent?.postMessage({ type: SANDBOX_HEIGHT, h, storyId: storyId.value, variantId: variantId.value }, window.location.origin)
164
223
  }
165
224
  function scheduleReport() {
166
225
  if (pendingFrame !== null) return
@@ -170,6 +229,11 @@ const ro = new ResizeObserver(scheduleReport)
170
229
  ro.observe(document.body)
171
230
  requestAnimationFrame(scheduleReport)
172
231
 
173
- if (import.meta.hot) {
174
- /* #__PURE__ */ setupPluginApi()
232
+ // Dev only, and loaded lazily on purpose: a static import of `plugin.js`
233
+ // carried the app router — and through its lazy routes every app view and
234
+ // their CSS — into the sandbox graph, so each sandbox realm parsed ~100 KB of
235
+ // chrome styles it can never match (#219). `import.meta.env.DEV` is a build-time
236
+ // literal, so the whole branch, dynamic import included, drops out of a build.
237
+ if (import.meta.env.DEV && import.meta.hot) {
238
+ import('./plugin.js').then(({ setupPluginApi }) => setupPluginApi())
175
239
  }