@poveste/app 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) 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 +22 -14
  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 +3 -6
  31. package/dist/bundled/components/story/StoryVariantSinglePreviewRemote.vue_vue_type_script_setup_true_lang.js +101 -33
  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 +57 -29
  42. package/dist/bundled/util/color-scheme.js +11 -1
  43. package/dist/bundled/util/const.js +3 -1
  44. package/dist/bundled/util/grid-cell-height.js +18 -0
  45. package/dist/bundled/util/grid-slots.js +64 -0
  46. package/dist/bundled/util/icons.generated.js +104 -0
  47. package/dist/bundled/util/state-bridge.js +75 -0
  48. package/dist/bundled/util/state.js +19 -1
  49. package/dist/style.css +7 -2
  50. package/package.json +12 -5
  51. package/src/__tests__/color-scheme.spec.ts +55 -0
  52. package/src/__tests__/font.spec.ts +30 -0
  53. package/src/__tests__/grid-slots.spec.ts +84 -0
  54. package/src/__tests__/icons.spec.ts +26 -0
  55. package/src/__tests__/state-bridge.spec.ts +155 -0
  56. package/src/__tests__/state-clone.spec.ts +66 -0
  57. package/src/app/components/app/AppActions.vue +2 -2
  58. package/src/app/components/layout/LayoutModal.vue +2 -2
  59. package/src/app/components/layout/LayoutPane.vue +4 -4
  60. package/src/app/components/panel/StatePresets.vue +5 -5
  61. package/src/app/components/panel/StoryControls.vue +1 -1
  62. package/src/app/components/panel/StoryDocs.vue +1 -1
  63. package/src/app/components/panel/StoryEvent.vue +1 -1
  64. package/src/app/components/panel/StorySidePanel.vue +1 -1
  65. package/src/app/components/panel/StorySourceCode.vue +2 -2
  66. package/src/app/components/search/SearchItem.vue +1 -1
  67. package/src/app/components/search/SearchModal.vue +1 -1
  68. package/src/app/components/search/SearchPane.vue +8 -0
  69. package/src/app/components/story/StoryResponsivePreview.vue +85 -50
  70. package/src/app/components/story/StoryVariantGrid.vue +126 -49
  71. package/src/app/components/story/StoryVariantGridItem.vue +37 -14
  72. package/src/app/components/story/StoryVariantListItem.vue +1 -1
  73. package/src/app/components/story/StoryVariantSinglePreviewNative.vue +3 -6
  74. package/src/app/components/story/StoryVariantSinglePreviewRemote.vue +169 -36
  75. package/src/app/components/story/StoryVariantSingleView.vue +1 -1
  76. package/src/app/components/toolbar/ToolbarBackground.vue +4 -4
  77. package/src/app/components/tree/StoryGroup.vue +1 -1
  78. package/src/app/components/tree/StoryListFolder.vue +1 -1
  79. package/src/app/components/tree/StoryListItem.vue +1 -1
  80. package/src/app/index.ts +10 -0
  81. package/src/app/sandbox.ts +108 -40
  82. package/src/app/style/font.css +78 -0
  83. package/src/app/style/main.pcss +4 -1
  84. package/src/app/style/sandbox.css +3 -0
  85. package/src/app/util/color-scheme.ts +13 -0
  86. package/src/app/util/const.ts +2 -0
  87. package/src/app/util/grid-cell-height.ts +16 -0
  88. package/src/app/util/grid-slots.ts +72 -0
  89. package/src/app/util/icons.generated.ts +9 -0
  90. package/src/app/util/state-bridge.ts +95 -0
  91. package/src/app/util/state.ts +19 -0
  92. package/src/bundle-main-dev.js +1 -0
  93. 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_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,64 +29,105 @@ const settings = usePreviewSettingsStore().currentSettings
25
29
 
26
30
  const iframe = ref<HTMLIFrameElement>()
27
31
 
28
- function syncState() {
29
- if (iframe.value && 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
107
+ case PREVIEW_SETTINGS_REQUEST:
108
+ syncSettings()
109
+ break
75
110
  case SANDBOX_HEIGHT:
76
- if (typeof event.data.h === 'number') {
77
- 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
78
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
+ }
79
124
  }
80
125
  break
81
126
  }
82
127
  })
83
128
 
84
129
  function updateVariantState(state: any) {
85
- synced = applyState(props.variant.state, state)
130
+ bridge.receive(props.variant.state, state)
86
131
  }
87
132
 
88
133
  function logEvent(event: HstEvent) {
@@ -90,35 +135,104 @@ function logEvent(event: HstEvent) {
90
135
  eventsStore.addEvent(event)
91
136
  }
92
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
+
93
144
  function setPreviewReady() {
145
+ retargeting.value = false
146
+ clearTimeout(retargetTimer)
94
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()
95
152
  }
96
153
 
97
154
  const sandboxUrl = computed(() => {
98
155
  return getSandboxUrl(props.story, props.variant)
99
156
  })
100
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)
101
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
102
180
 
103
181
  let stopTrackKeyboard: (() => void) | undefined
104
182
  let unmounted = false
105
183
 
106
- watch(sandboxUrl, () => {
184
+ function reload(url: string) {
185
+ retargets = 0
186
+ retargeting.value = false
187
+ clearTimeout(retargetTimer)
107
188
  isIframeLoaded.value = false
108
- storyStore.setPreviewReady(props.variant, false)
189
+ reportedHeight.value = null
190
+ servedStory = props.story
109
191
  stopTrackKeyboard?.()
110
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)
111
224
  })
112
225
 
113
226
  onBeforeUnmount(() => {
114
227
  unmounted = true
228
+ clearTimeout(retargetTimer)
115
229
  stopTrackKeyboard?.()
116
230
  })
117
231
 
118
232
  // Settings
119
233
 
120
234
  function syncSettings() {
121
- if (iframe.value) {
235
+ if (iframe.value?.contentWindow) {
122
236
  iframe.value.contentWindow.postMessage({
123
237
  type: PREVIEW_SETTINGS_SYNC,
124
238
  settings: toRaw(settings),
@@ -133,12 +247,33 @@ watch(() => settings, () => {
133
247
  immediate: true,
134
248
  })
135
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
+
136
270
  // Iframe load
137
271
 
138
272
  function onIframeLoad() {
139
273
  if (unmounted) {
140
274
  return
141
275
  }
276
+ retargets = 0
142
277
  isIframeLoaded.value = true
143
278
  syncState()
144
279
  syncSettings()
@@ -154,20 +289,18 @@ function onIframeLoad() {
154
289
  v-slot="{ isResponsiveEnabled, finalWidth, finalHeight, resizing }"
155
290
  class="poveste-story-variant-single-preview-remote"
156
291
  :variant="variant"
292
+ :responsive="responsive"
157
293
  >
158
294
  <iframe
159
295
  ref="iframe"
160
- :src="sandboxUrl"
296
+ :src="iframeSrc"
161
297
  class="w-full h-full relative"
162
298
  :class="{
163
- 'invisible': !isIframeLoaded,
299
+ 'invisible': !isIframeLoaded || retargeting,
164
300
  'pointer-events-none': resizing,
165
301
  }"
166
- :style="isResponsiveEnabled ? {
167
- width: finalWidth ? `${finalWidth}px` : null,
168
- height: finalHeight ? `${finalHeight}px` : null,
169
- } : (props.autoHeight && reportedHeight ? { height: `${reportedHeight}px` } : undefined)"
170
- data-test-id="preview-iframe"
302
+ :style="previewStyle(isResponsiveEnabled, finalWidth, finalHeight)"
303
+ data-testid="preview-iframe"
171
304
  @load="onIframeLoad()"
172
305
  />
173
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,23 +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
- import { resolvePreviewDark } from './util/color-scheme.js'
12
- import { povesteConfig } from './util/config.js'
13
- import { PREVIEW_SETTINGS_SYNC, SANDBOX_HEIGHT, SANDBOX_READY, STATE_SYNC } from './util/const.js'
9
+ import { previewDarkClasses, resolvePreviewDark } from './util/color-scheme.js'
10
+ import { PREVIEW_SETTINGS_REQUEST, PREVIEW_SETTINGS_SYNC, SANDBOX_HEIGHT, SANDBOX_READY, SANDBOX_RETARGET, STATE_SYNC } from './util/const.js'
14
11
  import { isDark } from './util/dark.js'
15
12
  import { mapFile } from './util/mapping'
16
13
  import { applyPreviewSettings, loadStoredPreviewSettings, receivedSettings } from './util/preview-settings.js'
14
+ import { createStateBridge } from './util/state-bridge.js'
17
15
  import { toRawDeep } from './util/state.js'
18
16
 
19
17
  const query = parseQuery(window.location.search)
20
- 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
+ })
21
40
 
22
41
  // Sandboxes opened on their own (the "open in a new tab" toolbar button) never
23
42
  // receive a PREVIEW_SETTINGS_SYNC, and inside the app the first one only lands
@@ -28,15 +47,20 @@ if (storedSettings) {
28
47
  applyPreviewSettings(storedSettings)
29
48
  }
30
49
 
50
+ // The app pushes settings when it sees the iframe load, which is one document
51
+ // swap away from being wrong: a push aimed at a document that is going away is
52
+ // dropped with no error and nothing re-sends it, leaving the sandbox on the
53
+ // stored value or on none at all (#75). Ask instead — a request can only come
54
+ // from a document that is running and listening.
55
+ if (window.parent && window.parent !== window) {
56
+ window.parent.postMessage({ type: PREVIEW_SETTINGS_REQUEST }, window.location.origin)
57
+ }
58
+
31
59
  // The story preview has its own color scheme, independent from the app chrome.
32
60
  // The fallback covers settings stored before the option existed.
33
61
  const prefersDark = usePreferredDark()
34
62
  const previewDark = computed(() => resolvePreviewDark(receivedSettings.colorScheme, prefersDark.value, isDark.value))
35
63
 
36
- // `sandboxDarkClass` is poveste's own deprecated config option, superseded by
37
- // `theme.darkClass`. Still honoured on purpose: dropping it would silently stop
38
- // applying the class for anyone who set it. Removing the option is a breaking
39
- // config change and belongs to a major, not here.
40
64
  /*
41
65
  * The class also has to land *inside* the story's `@scope` root, not only on
42
66
  * `<html>` (#101).
@@ -60,49 +84,72 @@ watch(previewDark, (value) => {
60
84
  for (const el of darkClassTargets) {
61
85
  if (!el) continue
62
86
 
63
- // eslint-disable-next-line ts/no-deprecated
64
- el.classList.toggle(povesteConfig.sandboxDarkClass, value)
65
- el.classList.toggle(povesteConfig.theme.darkClass, value)
87
+ for (const className of previewDarkClasses()) {
88
+ el.classList.toggle(className, value)
89
+ }
66
90
  }
67
91
  }, {
68
92
  immediate: true,
69
93
  })
70
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
+
71
100
  const app = createApp({
72
101
  name: 'SandboxApp',
73
102
 
74
103
  setup() {
75
- const story = computed(() => file.value.story)
76
- const variant = computed(() => story.value?.variants.find(v => v.id === query.variantId))
77
-
78
- // `synced` is the sandbox end of the same flag the host holds, with the same
79
- // #95 caveat: only set it when the apply will actually provoke the watcher
80
- // below, which is what `applyState`'s return value reports. Set it
81
- // unconditionally and an apply that changes nothing leaves it stuck, so the
82
- // next edit the story makes never reaches the host.
83
- 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)
84
122
  let mounted = false
85
123
 
86
124
  window.addEventListener('message', (event) => {
87
125
  // console.log('[sandbox] received message', event.data)
88
126
  if (event.data?.type === STATE_SYNC) {
89
- if (!mounted) return
90
- synced = applyState(variant.value.state, event.data.state)
127
+ if (!mounted || !variant.value) return
128
+ bridge.receive(variant.value.state, event.data.state)
91
129
  }
92
130
  else if (event.data?.type === PREVIEW_SETTINGS_SYNC) {
93
131
  applyPreviewSettings(event.data.settings)
94
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
+ }
95
143
  })
96
144
 
97
- watch(() => variant.value.state, (value) => {
98
- if (synced && mounted) {
99
- synced = false
100
- return
101
- }
102
- window.parent?.postMessage({
103
- type: STATE_SYNC,
104
- state: toRawDeep(value, true),
105
- })
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))
106
153
  }, {
107
154
  deep: true,
108
155
  })
@@ -118,18 +165,36 @@ const app = createApp({
118
165
  },
119
166
 
120
167
  render() {
168
+ if (!file.value) {
169
+ return null
170
+ }
171
+
121
172
  return [
122
173
  h('div', { class: '__poveste-sandbox-hidden' }, [
123
174
  h(GenericMountStory, {
124
175
  key: file.value.story.id,
125
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,
126
182
  }),
127
183
  ]),
128
184
  this.story && this.variant
129
- ? 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.
130
190
  window.parent?.postMessage({
131
191
  type: SANDBOX_READY,
192
+ storyId: this.story.id,
193
+ variantId: this.variant.id,
132
194
  })
195
+ // A retarget to a same-height story would otherwise never report:
196
+ // the observer only fires on a change.
197
+ scheduleReport()
133
198
  } })
134
199
  : null,
135
200
  ]
@@ -144,8 +209,6 @@ app.mount('#app')
144
209
  // source-level rules that add overflow/min-height to the render root.
145
210
  document.body.classList.add('__poveste-render-story', '__poveste-render-custom-controls')
146
211
 
147
- let pendingFrame: number | null = null
148
- let lastReportedHeight = -1
149
212
  function reportHeight() {
150
213
  pendingFrame = null
151
214
  const renderRoot = document.querySelector('.__poveste-render-story')
@@ -156,7 +219,7 @@ function reportHeight() {
156
219
  lastReportedHeight = h
157
220
  // Same-origin: parent and iframe are both served by the poveste dev /
158
221
  // build server, so scope the target rather than broadcasting.
159
- 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)
160
223
  }
161
224
  function scheduleReport() {
162
225
  if (pendingFrame !== null) return
@@ -166,6 +229,11 @@ const ro = new ResizeObserver(scheduleReport)
166
229
  ro.observe(document.body)
167
230
  requestAnimationFrame(scheduleReport)
168
231
 
169
- if (import.meta.hot) {
170
- /* #__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())
171
239
  }