@poveste/app 0.13.1 → 0.15.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 (132) hide show
  1. package/dist/bundled/App.vue_vue_type_script_setup_true_lang.js +1 -1
  2. package/dist/bundled/app.css +31 -31
  3. package/dist/bundled/components/HomeView.vue_vue_type_script_setup_true_lang.js +1 -1
  4. package/dist/bundled/components/app/AppLogo.vue_vue_type_script_setup_true_lang.js +2 -2
  5. package/dist/bundled/components/app/Breadcrumb.js +1 -1
  6. package/dist/bundled/components/app/Breadcrumb.vue_vue_type_script_setup_true_lang.js +2 -2
  7. package/dist/bundled/components/app/TopBar.vue_vue_type_script_setup_true_lang.js +4 -3
  8. package/dist/bundled/components/app/TopBarChipItem.js +1 -1
  9. package/dist/bundled/components/base/BaseCheckbox.vue_vue_type_script_setup_true_lang.js +1 -1
  10. package/dist/bundled/components/base/BaseEmpty.js +2 -2
  11. package/dist/bundled/components/base/BaseOverflowMenu.vue_vue_type_script_setup_true_lang.js +18 -15
  12. package/dist/bundled/components/base/BaseSelect.vue_vue_type_script_setup_true_lang.js +158 -26
  13. package/dist/bundled/components/base/BaseSplitPane.js +1 -1
  14. package/dist/bundled/components/base/BaseSplitPane.vue_vue_type_script_setup_true_lang.js +6 -4
  15. package/dist/bundled/components/command/CommandPrompts.vue_vue_type_script_setup_true_lang.js +1 -1
  16. package/dist/bundled/components/command/CommandPromptsModal.vue_vue_type_script_setup_true_lang.js +4 -3
  17. package/dist/bundled/components/command/PromptSelect.vue_vue_type_script_setup_true_lang.js +2 -2
  18. package/dist/bundled/components/command/PromptText.vue_vue_type_script_setup_true_lang.js +1 -1
  19. package/dist/bundled/components/panel/ControlsComponentPropItem.vue_vue_type_script_setup_true_lang.js +20 -14
  20. package/dist/bundled/components/panel/PaneTabs.vue_vue_type_script_setup_true_lang.js +6 -6
  21. package/dist/bundled/components/panel/StatePresets.vue_vue_type_script_setup_true_lang.js +94 -55
  22. package/dist/bundled/components/panel/StoryControls.vue_vue_type_script_setup_true_lang.js +1 -1
  23. package/dist/bundled/components/panel/StoryDocs.vue_vue_type_script_setup_true_lang.js +3 -1
  24. package/dist/bundled/components/panel/StoryEvent.vue_vue_type_script_setup_true_lang.js +5 -1
  25. package/dist/bundled/components/panel/StoryEvents.vue_vue_type_script_setup_true_lang.js +2 -1
  26. package/dist/bundled/components/panel/StorySidePanel.vue_vue_type_script_setup_true_lang.js +8 -6
  27. package/dist/bundled/components/panel/StorySourceCode.js +1 -1
  28. package/dist/bundled/components/panel/StorySourceCode.vue_vue_type_script_setup_true_lang.js +8 -6
  29. package/dist/bundled/components/search/SearchItemContent.vue_vue_type_script_setup_true_lang.js +2 -1
  30. package/dist/bundled/components/search/SearchPane.vue_vue_type_script_setup_true_lang.js +16 -9
  31. package/dist/bundled/components/story/GenericMountStory.vue_vue_type_script_setup_true_lang.js +2 -1
  32. package/dist/bundled/components/story/GenericRenderStory.vue_vue_type_script_setup_true_lang.js +2 -1
  33. package/dist/bundled/components/story/StoryResponsivePreview.js +1 -1
  34. package/dist/bundled/components/story/StoryResponsivePreview.vue_vue_type_script_setup_true_lang.js +23 -8
  35. package/dist/bundled/components/story/StoryVariantGrid.js +1 -1
  36. package/dist/bundled/components/story/StoryVariantGrid.vue_vue_type_script_setup_true_lang.js +15 -10
  37. package/dist/bundled/components/story/StoryVariantGridItem.js +1 -1
  38. package/dist/bundled/components/story/StoryVariantGridItem.vue_vue_type_script_setup_true_lang.js +3 -3
  39. package/dist/bundled/components/story/StoryVariantSinglePreviewRemote.vue_vue_type_script_setup_true_lang.js +9 -6
  40. package/dist/bundled/components/story/StoryView.vue_vue_type_script_setup_true_lang.js +7 -3
  41. package/dist/bundled/components/story/StoryViewer.js +1 -1
  42. package/dist/bundled/components/story/StoryViewer.vue_vue_type_script_setup_true_lang.js +3 -3
  43. package/dist/bundled/components/toolbar/ToolbarBackground.vue_vue_type_script_setup_true_lang.js +1 -1
  44. package/dist/bundled/components/toolbar/ToolbarResponsiveSize.vue_vue_type_script_setup_true_lang.js +17 -16
  45. package/dist/bundled/components/toolbar/ToolbarTitle.js +1 -1
  46. package/dist/bundled/components/toolbar/ToolbarTitle.vue_vue_type_script_setup_true_lang.js +1 -1
  47. package/dist/bundled/components/tree/StoryGroup.vue_vue_type_script_setup_true_lang.js +2 -2
  48. package/dist/bundled/components/tree/StoryList.vue_vue_type_script_setup_true_lang.js +3 -3
  49. package/dist/bundled/components/tree/StoryListFolder.js +1 -1
  50. package/dist/bundled/components/tree/StoryListFolder.vue_vue_type_script_setup_true_lang.js +3 -3
  51. package/dist/bundled/components/tree/StoryListItem.js +1 -1
  52. package/dist/bundled/components/tree/StoryListItem.vue_vue_type_script_setup_true_lang.js +3 -3
  53. package/dist/bundled/plugin.js +4 -3
  54. package/dist/bundled/sandbox.js +5 -4
  55. package/dist/bundled/stores/folder.js +1 -1
  56. package/dist/bundled/stores/story.js +2 -2
  57. package/dist/bundled/util/commands.js +21 -10
  58. package/dist/bundled/util/dark.js +1 -1
  59. package/dist/bundled/util/docs.js +2 -1
  60. package/dist/bundled/util/events.js +1 -1
  61. package/dist/bundled/util/grid-slots.js +2 -2
  62. package/dist/bundled/util/icons.generated.js +0 -1
  63. package/dist/bundled/util/keyboard.js +9 -14
  64. package/dist/bundled/util/mapping.js +3 -3
  65. package/dist/bundled/util/preview-settings.js +1 -1
  66. package/dist/bundled/util/render-tracker.js +33 -0
  67. package/dist/bundled/util/variant.js +12 -2
  68. package/dist/style.css +23 -1
  69. package/package.json +14 -18
  70. package/src/__tests__/render-tracker.spec.ts +38 -0
  71. package/src/app/App.vue +7 -4
  72. package/src/app/components/HomeView.vue +1 -1
  73. package/src/app/components/app/AppLogo.vue +6 -2
  74. package/src/app/components/app/Breadcrumb.vue +2 -1
  75. package/src/app/components/app/TopBar.vue +7 -2
  76. package/src/app/components/app/TopBarChipItem.vue +1 -1
  77. package/src/app/components/base/BaseCheckbox.vue +1 -1
  78. package/src/app/components/base/BaseEmpty.vue +1 -1
  79. package/src/app/components/base/BaseListItemLink.vue +4 -2
  80. package/src/app/components/base/BaseOverflowMenu.vue +25 -18
  81. package/src/app/components/base/BaseSelect.vue +171 -15
  82. package/src/app/components/base/BaseSplitPane.vue +11 -6
  83. package/src/app/components/command/CommandPrompts.vue +1 -1
  84. package/src/app/components/command/CommandPromptsModal.vue +1 -0
  85. package/src/app/components/command/PromptSelect.vue +3 -2
  86. package/src/app/components/command/PromptText.vue +7 -3
  87. package/src/app/components/panel/ControlsComponentPropItem.vue +26 -15
  88. package/src/app/components/panel/PaneTabs.vue +6 -6
  89. package/src/app/components/panel/StatePresets.vue +125 -68
  90. package/src/app/components/panel/StoryControls.vue +1 -1
  91. package/src/app/components/panel/StoryDocs.vue +3 -1
  92. package/src/app/components/panel/StoryEvent.vue +7 -2
  93. package/src/app/components/panel/StoryEvents.vue +4 -1
  94. package/src/app/components/panel/StorySidePanel.vue +3 -1
  95. package/src/app/components/panel/StorySourceCode.vue +12 -8
  96. package/src/app/components/search/SearchItemContent.vue +2 -1
  97. package/src/app/components/search/SearchPane.vue +16 -8
  98. package/src/app/components/story/GenericMountStory.vue +2 -1
  99. package/src/app/components/story/GenericRenderStory.vue +2 -1
  100. package/src/app/components/story/StoryResponsivePreview.vue +36 -7
  101. package/src/app/components/story/StoryVariantGrid.vue +33 -16
  102. package/src/app/components/story/StoryVariantGridItem.vue +1 -1
  103. package/src/app/components/story/StoryVariantSinglePreviewRemote.vue +14 -8
  104. package/src/app/components/story/StoryView.vue +10 -4
  105. package/src/app/components/story/StoryViewer.vue +3 -3
  106. package/src/app/components/toolbar/ToolbarBackground.vue +1 -1
  107. package/src/app/components/toolbar/ToolbarResponsiveSize.vue +23 -3
  108. package/src/app/components/toolbar/ToolbarTitle.vue +2 -2
  109. package/src/app/components/tree/StoryGroup.vue +2 -2
  110. package/src/app/components/tree/StoryList.vue +2 -2
  111. package/src/app/components/tree/StoryListFolder.vue +2 -2
  112. package/src/app/components/tree/StoryListItem.vue +1 -1
  113. package/src/app/plugin.ts +7 -3
  114. package/src/app/sandbox.ts +9 -3
  115. package/src/app/stores/folder.ts +1 -1
  116. package/src/app/stores/story.ts +2 -2
  117. package/src/app/types.ts +6 -6
  118. package/src/app/util/commands.ts +27 -17
  119. package/src/app/util/dark.ts +1 -1
  120. package/src/app/util/docs.ts +2 -1
  121. package/src/app/util/env.ts +0 -1
  122. package/src/app/util/events.ts +6 -5
  123. package/src/app/util/grid-slots.ts +2 -2
  124. package/src/app/util/icons.generated.ts +1 -1
  125. package/src/app/util/keyboard.ts +10 -11
  126. package/src/app/util/mapping.ts +3 -3
  127. package/src/app/util/preview-settings.ts +1 -1
  128. package/src/app/util/render-tracker.ts +31 -0
  129. package/src/app/util/scroll.ts +5 -1
  130. package/src/app/util/state.ts +4 -4
  131. package/src/app/util/variant.ts +16 -3
  132. package/src/app/components/base/BaseCopyIcon.vue +0 -34
@@ -8,12 +8,19 @@ import StoryVariantGridItem from './StoryVariantGridItem.vue'
8
8
 
9
9
  const storyStore = useStoryStore()
10
10
 
11
+ // `currentStory` is a `.find()` over the loaded stories, so it is undefined for
12
+ // a route naming a story that is not there, and `layout` is optional on top of
13
+ // that. `StoryView` only mounts this component inside its `v-else` on the same
14
+ // value, so neither is ever absent here — but that guarantee lives in another
15
+ // component's template and cannot be seen from this one, which is why these
16
+ // read through `?.` rather than an assertion.
11
17
  const gridTemplateWidth = computed(() => {
12
- if (storyStore.currentStory.layout.type !== 'grid') {
18
+ const layout = storyStore.currentStory?.layout
19
+ if (layout?.type !== 'grid') {
13
20
  return
14
21
  }
15
22
 
16
- const layoutWidth = storyStore.currentStory.layout.width
23
+ const layoutWidth = layout.width
17
24
 
18
25
  if (!layoutWidth) {
19
26
  return '200px'
@@ -38,11 +45,11 @@ const initialCount = 10
38
45
 
39
46
  const maxItemHeight = ref(0)
40
47
 
41
- const el = ref<HTMLDivElement>(null)
48
+ const el = ref<HTMLDivElement>()
42
49
  const scrollTop = ref(0)
43
50
  const viewportHeight = ref(0)
44
51
 
45
- const variants = computed(() => storyStore.currentStory.variants)
52
+ const variants = computed(() => storyStore.currentStory?.variants ?? [])
46
53
 
47
54
  useResizeObserver(el, () => {
48
55
  updateWindow()
@@ -103,7 +110,7 @@ function onItemResize(_w: number, h: number) {
103
110
 
104
111
  // Grid size
105
112
 
106
- const gridEl = ref<HTMLDivElement>(null)
113
+ const gridEl = ref<HTMLDivElement>()
107
114
  const gridColumnWidth = ref(1)
108
115
  const viewWidth = ref(1)
109
116
 
@@ -113,8 +120,12 @@ function updateSize() {
113
120
 
114
121
  if (!gridEl.value) return
115
122
 
116
- if (gridTemplateWidth.value.endsWith('%')) {
117
- gridColumnWidth.value = viewWidth.value * Number.parseInt(gridTemplateWidth.value) / 100 - gap
123
+ // Undefined when the story is not a grid, which is when this never runs.
124
+ const templateWidth = gridTemplateWidth.value
125
+ if (!templateWidth) return
126
+
127
+ if (templateWidth.endsWith('%')) {
128
+ gridColumnWidth.value = viewWidth.value * Number.parseInt(templateWidth) / 100 - gap
118
129
  }
119
130
  else {
120
131
  gridColumnWidth.value = Number.parseInt(gridTemplateWidth.value)
@@ -184,7 +195,9 @@ function scheduleTrim() {
184
195
  }
185
196
 
186
197
  watch(visibleVariants, (visible) => {
187
- slots.value = assignSlots(slots.value, storyStore.currentStory.id, visible)
198
+ const story = storyStore.currentStory
199
+ if (!story) return
200
+ slots.value = assignSlots(slots.value, story.id, visible)
188
201
  scheduleTrim()
189
202
  }, { immediate: true })
190
203
 
@@ -241,14 +254,18 @@ watch(() => storyStore.currentVariant, (variant) => {
241
254
  transform: offsetY ? `translateY(${offsetY}px)` : undefined,
242
255
  }"
243
256
  >
244
- <StoryVariantGridItem
245
- v-for="(slot, index) of slots"
246
- :key="index"
247
- :variant="slot.variant"
248
- :story="storyStore.currentStory"
249
- :style="{ order: slot.order, display: slot.visible ? undefined : 'none' }"
250
- @resize="onItemResize"
251
- />
257
+ <!-- A `template`, so the guard adds no element: `gridEl` above is
258
+ measured by a resize observer and must not move. -->
259
+ <template v-if="storyStore.currentStory">
260
+ <StoryVariantGridItem
261
+ v-for="(slot, index) of slots"
262
+ :key="index"
263
+ :variant="slot.variant"
264
+ :story="storyStore.currentStory"
265
+ :style="{ order: slot.order, display: slot.visible ? undefined : 'none' }"
266
+ @resize="onItemResize"
267
+ />
268
+ </template>
252
269
  </div>
253
270
  </div>
254
271
  </div>
@@ -132,7 +132,7 @@ const useIframe = computed(() => {
132
132
  </RouterLink>
133
133
 
134
134
  <!-- Toolbar -->
135
- <div class="flex-none ml-auto hidden group-hover:flex items-center">
135
+ <div class="flex-none ml-auto hidden group-hover:flex group-focus-within:flex items-center">
136
136
  <HstCopyIcon
137
137
  :content="() => getSourceCode(story, variant)"
138
138
  />
@@ -12,6 +12,7 @@ import { useStoryErrorStore } from '../../stores/story-errors'
12
12
  import { EVENT_SEND, PREVIEW_SETTINGS_REQUEST, PREVIEW_SETTINGS_SYNC, SANDBOX_HEIGHT, SANDBOX_READY, SANDBOX_RETARGET, STATE_SYNC } from '../../util/const'
13
13
  import { firstReportedHeight } from '../../util/grid-cell-height'
14
14
  import { trackWindow } from '../../util/keyboard'
15
+ import { createRenderTracker } from '../../util/render-tracker'
15
16
  import { getSandboxUrl } from '../../util/sandbox'
16
17
  import { toRawDeep } from '../../util/state'
17
18
  import { createStateBridge } from '../../util/state-bridge'
@@ -31,11 +32,7 @@ const errorStore = useStoryErrorStore()
31
32
 
32
33
  const storyError = computed(() => errorStore.forVariant(props.story.id, props.variant.id))
33
34
 
34
- // The realm re-renders on every retarget, so a stale error must not outlive the
35
- // render that produced it — otherwise a fixed story stays marked until reload.
36
- watch(() => [props.story.id, props.variant.id], () => {
37
- errorStore.clear(props.story.id, props.variant.id)
38
- })
35
+ const render = createRenderTracker()
39
36
 
40
37
  // Iframe
41
38
 
@@ -103,6 +100,8 @@ function fromCurrentOccupant(data: { storyId?: string, variantId?: string }) {
103
100
  useEventListener(window, 'message', (event) => {
104
101
  // With many grid iframes mounted, every sandbox postMessage hits every
105
102
  // parent listener. Skip events that didn't originate from this iframe.
103
+ // Paired with the origin check in `sandbox.ts`, which guards the other
104
+ // direction (#379).
106
105
  if (event.source !== iframe.value?.contentWindow) return
107
106
  switch (event.data?.type) {
108
107
  case STATE_SYNC:
@@ -115,10 +114,15 @@ useEventListener(window, 'message', (event) => {
115
114
  break
116
115
  case SANDBOX_READY:
117
116
  if (!fromCurrentOccupant(event.data)) break
117
+ // Last render wins, which is what makes a fixed story stop being marked
118
+ // without a reload. Clearing on the id change instead cleared the pair
119
+ // being navigated *to*, so arriving at a broken story hid its error (#597).
120
+ if (render.wasClean()) errorStore.clear(props.story.id, props.variant.id)
118
121
  setPreviewReady()
119
122
  break
120
123
  case SANDBOX_ERROR:
121
124
  if (!fromCurrentOccupant(event.data)) break
125
+ render.threw()
122
126
  errorStore.report({
123
127
  message: event.data.message,
124
128
  stack: event.data.stack,
@@ -207,6 +211,7 @@ let stopTrackKeyboard: (() => void) | undefined
207
211
  let unmounted = false
208
212
 
209
213
  function reload(url: string) {
214
+ render.begin()
210
215
  retargets = 0
211
216
  retargeting.value = false
212
217
  clearTimeout(retargetTimer)
@@ -223,6 +228,7 @@ function reload(url: string) {
223
228
  }
224
229
 
225
230
  watch(sandboxUrl, (url) => {
231
+ render.begin()
226
232
  // A new occupant has agreed to nothing, so neither has this end. Kept as a
227
233
  // new bridge rather than a reset so there is one way to be in that state.
228
234
  bridge = createStateBridge(postState)
@@ -285,12 +291,12 @@ function previewStyle(isResponsiveEnabled: boolean, finalWidth: number | null, f
285
291
  const style: Record<string, string> = {}
286
292
 
287
293
  if (isResponsiveEnabled) {
288
- if (finalWidth) style.width = `${finalWidth}px`
289
- if (finalHeight) style.height = `${finalHeight}px`
294
+ if (finalWidth) style['width'] = `${finalWidth}px`
295
+ if (finalHeight) style['height'] = `${finalHeight}px`
290
296
  }
291
297
 
292
298
  if (props.autoHeight) {
293
- style.height = `${Math.min(reportedHeight.value ?? placeholderHeight.value, MAX_AUTO_HEIGHT)}px`
299
+ style['height'] = `${Math.min(reportedHeight.value ?? placeholderHeight.value, MAX_AUTO_HEIGHT)}px`
294
300
  }
295
301
 
296
302
  return style
@@ -33,8 +33,12 @@ const route = useRoute()
33
33
  // Restore variant selection
34
34
 
35
35
  watch(() => storyStore.currentVariant, (value) => {
36
- if (value) {
37
- storyStore.currentStory.lastSelectedVariant = value
36
+ // A current variant is found *inside* the current story, so one implies the
37
+ // other — but that is a fact about two computeds in the store, not something
38
+ // the compiler can see from here.
39
+ const story = storyStore.currentStory
40
+ if (value && story) {
41
+ story.lastSelectedVariant = value
38
42
  }
39
43
  }, {
40
44
  immediate: true,
@@ -43,7 +47,9 @@ watch(() => storyStore.currentVariant, (value) => {
43
47
  watch(() => [storyStore.currentStory, storyStore.currentVariant], () => {
44
48
  if (!storyStore.currentVariant && autoSelectsVariant(storyStore.currentStory)) {
45
49
  const variant = storyStore.currentStory.lastSelectedVariant ?? storyStore.currentStory.variants[0]
46
- setVariant(variant.id)
50
+ if (variant) {
51
+ setVariant(variant.id)
52
+ }
47
53
  }
48
54
  }, {
49
55
  immediate: true,
@@ -61,7 +67,7 @@ function setVariant(variantId: string) {
61
67
 
62
68
  // Docs auto-scroll to top
63
69
 
64
- const docsOnlyScroller = ref<HTMLElement>(null)
70
+ const docsOnlyScroller = ref<HTMLElement>()
65
71
 
66
72
  function scrollDocsToTop() {
67
73
  docsOnlyScroller.value?.scrollTo(0, 0)
@@ -24,10 +24,10 @@ watch(variant, () => {
24
24
  <template>
25
25
  <div class="poveste-story-viewer bg-gray-50 h-full dark:bg-gray-750">
26
26
  <StoryVariantGrid
27
- v-if="storyStore.currentStory.layout.type === 'grid'"
27
+ v-if="storyStore.currentStory?.layout?.type === 'grid'"
28
28
  />
29
29
  <StoryVariantSingle
30
- v-else-if="storyStore.currentStory.layout.type === 'single'"
30
+ v-else-if="storyStore.currentStory?.layout?.type === 'single'"
31
31
  @open-variant-menu="isMenuOpened = true"
32
32
  />
33
33
  </div>
@@ -38,7 +38,7 @@ watch(variant, () => {
38
38
  @close="closeMenu"
39
39
  >
40
40
  <StoryVariantListItem
41
- v-for="(v, index) of storyStore.currentStory.variants"
41
+ v-for="(v, index) of storyStore.currentStory?.variants ?? []"
42
42
  :key="index"
43
43
  :variant="v"
44
44
  />
@@ -17,7 +17,7 @@ const colorSchemeOptions: { value: SandboxColorScheme, label: string, icon: stri
17
17
  // A book that hides the app color scheme switch doesn't want the preview one
18
18
  // either — it keeps whatever `theme.defaultColorScheme` says.
19
19
  const showColorScheme = !povesteConfig.theme.hideColorSchemeSwitch
20
- const showDropdown = showColorScheme || !!povesteConfig.backgroundPresets.length
20
+ const showDropdown = showColorScheme || !!povesteConfig.backgroundPresets?.length
21
21
  </script>
22
22
 
23
23
  <template>
@@ -1,10 +1,30 @@
1
1
  <script lang="ts" setup>
2
2
  import { Icon } from '@iconify/vue'
3
+ import { computed } from 'vue'
3
4
  import { usePreviewSettingsStore } from '../../stores/preview-settings'
4
5
  import { povesteConfig } from '../../util/config'
5
6
  import BaseCheckbox from '../base/BaseCheckbox.vue'
6
7
 
7
8
  const settings = usePreviewSettingsStore().currentSettings
9
+
10
+ // Not `v-model.number`: Vue's `looseToNumber` returns the original value when
11
+ // `parseFloat` gives NaN, so clearing the field stored the string `''`. That is
12
+ // a third value for a two-valued setting, and it is not nullish — so the `??`
13
+ // in the size tooltip and in the dragger's start value both stopped firing, and
14
+ // dragging after clearing the field set the size to the drag distance (#440).
15
+ // The placeholder already says empty means auto, so empty becomes `null`.
16
+ function autoWhenEmpty(key: 'responsiveWidth' | 'responsiveHeight') {
17
+ return computed<number | null>({
18
+ get: () => settings[key],
19
+ set: (value) => {
20
+ const parsed = typeof value === 'number' ? value : Number.parseFloat(value ?? '')
21
+ settings[key] = Number.isFinite(parsed) ? parsed : null
22
+ },
23
+ })
24
+ }
25
+
26
+ const responsiveWidth = autoWhenEmpty('responsiveWidth')
27
+ const responsiveHeight = autoWhenEmpty('responsiveHeight')
8
28
  </script>
9
29
 
10
30
  <template>
@@ -40,7 +60,7 @@ const settings = usePreviewSettingsStore().currentSettings
40
60
 
41
61
  <div class="flex items-center gap-2 px-4 py-3">
42
62
  <input
43
- v-model.number="settings.responsiveWidth"
63
+ v-model="responsiveWidth"
44
64
  v-tooltip="'Responsive width (px)'"
45
65
  type="number"
46
66
  class="bg-transparent border border-gray-200 dark:border-gray-850 rounded w-20 opacity-50 focus:opacity-100 flex-1 min-w-0"
@@ -49,7 +69,7 @@ const settings = usePreviewSettingsStore().currentSettings
49
69
  >
50
70
  <span class="opacity-50">×</span>
51
71
  <input
52
- v-model.number="settings.responsiveHeight"
72
+ v-model="responsiveHeight"
53
73
  v-tooltip="'Responsive height (px)'"
54
74
  type="number"
55
75
  class="bg-transparent border border-gray-200 dark:border-gray-850 rounded w-20 opacity-50 focus:opacity-100 flex-1 min-w-0"
@@ -67,7 +87,7 @@ const settings = usePreviewSettingsStore().currentSettings
67
87
  ? 'bg-primary-500 hover:bg-primary-600 text-white dark:text-black'
68
88
  : 'bg-transparent hover:bg-primary-100 dark:hover:bg-primary-700',
69
89
  ]"
70
- @click="settings.responsiveWidth = preset.width;settings.responsiveHeight = preset.height;hide()"
90
+ @click="settings.responsiveWidth = preset.width;settings.responsiveHeight = preset.height ?? null;hide()"
71
91
  >
72
92
  {{ preset.label }}
73
93
  <span class="ml-auto opacity-70 flex gap-1">
@@ -4,8 +4,8 @@ import { Icon } from '@iconify/vue'
4
4
  import { computed } from 'vue'
5
5
 
6
6
  const props = defineProps<{
7
- variant?: Variant
8
- story?: Story
7
+ variant?: Variant | undefined
8
+ story?: Story | undefined
9
9
  }>()
10
10
 
11
11
  const icon = computed(() => props.variant?.icon ?? props.story?.icon ?? 'carbon:cube')
@@ -63,8 +63,8 @@ function toggleOpen() {
63
63
  :depth="0"
64
64
  />
65
65
  <StoryListItem
66
- v-else
67
- :story="stories[(element as TreeLeaf).index]"
66
+ v-else-if="stories[(element as TreeLeaf).index]"
67
+ :story="stories[(element as TreeLeaf).index]!"
68
68
  :depth="0"
69
69
  />
70
70
  </template>
@@ -27,8 +27,8 @@ defineProps<{
27
27
  :stories="stories"
28
28
  />
29
29
  <StoryListItem
30
- v-else
31
- :story="stories[(element as TreeLeaf).index]"
30
+ v-else-if="stories[(element as TreeLeaf).index]"
31
+ :story="stories[(element as TreeLeaf).index]!"
32
32
  />
33
33
  </template>
34
34
  </div>
@@ -72,8 +72,8 @@ const folderPadding = computed(() => {
72
72
  :depth="depth + 1"
73
73
  />
74
74
  <StoryListItem
75
- v-else
76
- :story="stories[(element as TreeLeaf).index]"
75
+ v-else-if="stories[(element as TreeLeaf).index]"
76
+ :story="stories[(element as TreeLeaf).index]!"
77
77
  :depth="depth + 1"
78
78
  />
79
79
  </template>
@@ -25,7 +25,7 @@ const filePadding = computed(() => {
25
25
  })
26
26
 
27
27
  const route = useRoute()
28
- const isActive = computed(() => route.params.storyId === props.story.id)
28
+ const isActive = computed(() => route.params['storyId'] === props.story.id)
29
29
  const el = ref<HTMLDivElement>()
30
30
  useScrollOnActive(isActive, el)
31
31
  </script>
package/src/app/plugin.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  import { router } from './router.js'
2
2
 
3
3
  export function setupPluginApi() {
4
- if (!import.meta.hot) return
4
+ // Captured rather than re-read: the narrowing from this guard does not reach
5
+ // into the callbacks below, and `hot?.send` there would leave `sendEvent`'s
6
+ // promise waiting for a reply to a message that was never sent.
7
+ const hot = import.meta.hot
8
+ if (!hot) return
5
9
 
6
10
  const listeners: Record<string, Set<(result: any) => unknown>> = {}
7
11
 
8
- import.meta.hot.on('poveste:dev-event-result', ({ event, result }) => {
12
+ hot.on('poveste:dev-event-result', ({ event, result }) => {
9
13
  const set = listeners[event]
10
14
  if (set) {
11
15
  for (const listener of set) {
@@ -36,7 +40,7 @@ export function setupPluginApi() {
36
40
  window.__HST_PLUGIN_API__ = {
37
41
  sendEvent: (event: string, payload?: any) => {
38
42
  return new Promise((resolve) => {
39
- import.meta.hot.send(`poveste:dev-event`, {
43
+ hot.send(`poveste:dev-event`, {
40
44
  event,
41
45
  payload,
42
46
  })
@@ -22,8 +22,8 @@ const query = parseQuery(window.location.search)
22
22
  // What this realm serves. Set from the URL on boot, and again by a
23
23
  // SANDBOX_RETARGET from the host: a warm realm is handed the next story or
24
24
  // variant instead of being torn down for a new document (#240).
25
- const storyId = ref(typeof query.storyId === 'string' ? query.storyId : null)
26
- const variantId = ref(typeof query.variantId === 'string' ? query.variantId : null)
25
+ const storyId = ref(typeof query['storyId'] === 'string' ? query['storyId'] : null)
26
+ const variantId = ref(typeof query['variantId'] === 'string' ? query['variantId'] : null)
27
27
 
28
28
  // Kept in step so `logEvent` can name the occupant it is speaking for; it posts
29
29
  // from the story's own code and cannot see these refs.
@@ -72,7 +72,7 @@ if (window.parent && window.parent !== window) {
72
72
  // window, and no framework hook sees an error raised outside its own call
73
73
  // stack (#323).
74
74
  function reportUncaught(error: unknown) {
75
- reportStoryError(error, { storyId: storyId.value, variantId: variantId.value })
75
+ reportStoryError(error, { storyId: storyId.value ?? undefined, variantId: variantId.value ?? undefined })
76
76
  }
77
77
  window.addEventListener('error', (event) => {
78
78
  if (!isBenignResizeObserverEvent(event)) {
@@ -147,6 +147,12 @@ const app = createApp({
147
147
  let mounted = false
148
148
 
149
149
  window.addEventListener('message', (event) => {
150
+ // The sandbox end of the check the host makes in
151
+ // `StoryVariantSinglePreviewRemote`. `__sandbox.html` is a public URL in
152
+ // every published book, and a page that iframes it is its `parent`, so
153
+ // without this anyone embedding it could write story state, retarget it,
154
+ // or restyle it. Only the book drives its own sandbox (#379).
155
+ if (event.origin !== window.location.origin) return
150
156
  // console.log('[sandbox] received message', event.data)
151
157
  if (event.data?.type === STATE_SYNC) {
152
158
  if (!mounted || !variant.value) return
@@ -51,7 +51,7 @@ export const useFolderStore = defineStore('folder', () => {
51
51
  const storyStore = useStoryStore()
52
52
 
53
53
  watch(() => storyStore.currentStory, (story) => {
54
- if (story) {
54
+ if (story?.file) {
55
55
  openFileFolders(story.file.path)
56
56
  }
57
57
  })
@@ -9,9 +9,9 @@ export const useStoryStore = defineStore('story', () => {
9
9
  stories.value = value
10
10
  }
11
11
 
12
- const currentStory = computed(() => stories.value.find(s => s.id === router.currentRoute.value.params.storyId))
12
+ const currentStory = computed(() => stories.value.find(s => s.id === router.currentRoute.value.params['storyId']))
13
13
 
14
- const currentVariant = computed(() => currentStory.value?.variants.find(v => v.id === router.currentRoute.value.query.variantId))
14
+ const currentVariant = computed(() => currentStory.value?.variants.find(v => v.id === router.currentRoute.value.query['variantId']))
15
15
 
16
16
  const maps = computed(() => {
17
17
  const storyMap = new Map<string, Story>()
package/src/app/types.ts CHANGED
@@ -33,10 +33,10 @@ export interface SearchResultBase {
33
33
  rank: number
34
34
  id: string
35
35
  title: string
36
- path?: string[]
37
- icon?: string
38
- iconColor?: string
39
- type?: SearchResultType
36
+ path?: string[] | undefined
37
+ icon?: string | undefined
38
+ iconColor?: string | undefined
39
+ type?: SearchResultType | undefined
40
40
  }
41
41
 
42
42
  export type SearchResult = SearchResultBase & ({
@@ -54,14 +54,14 @@ export interface PreviewSettings {
54
54
  *
55
55
  * @default 720
56
56
  */
57
- responsiveWidth: number
57
+ responsiveWidth: number | null
58
58
  /**
59
59
  * Height of the responsive preview, in pixels. `null` sizes it to the
60
60
  * available space.
61
61
  *
62
62
  * @default null
63
63
  */
64
- responsiveHeight: number
64
+ responsiveHeight: number | null
65
65
  /**
66
66
  * Swap `responsiveWidth` and `responsiveHeight`, to preview a size in
67
67
  * landscape without editing both values.
@@ -1,29 +1,39 @@
1
- import type { ClientCommand, ClientCommandContext } from '@poveste/shared'
1
+ import type { ClientCommand, ClientCommandContext, Story } from '@poveste/shared'
2
2
  import { router } from '../router.js'
3
3
  import { useStoryStore } from '../stores/story.js'
4
4
  import { openInEditor } from './open-in-editor.js'
5
5
 
6
+ /**
7
+ * The path "Open file in editor" should open, or undefined when there is none.
8
+ *
9
+ * `Story.file` is optional, and `let file: string` used to be assigned
10
+ * `story.file?.filePath` in both branches — so a story without a file produced
11
+ * `file: undefined`, and `openInEditor` sent
12
+ * `__open-in-editor?file=undefined` to the dev server: a request to open a file
13
+ * literally named "undefined", with nothing said in the UI (#667).
14
+ *
15
+ * A docs-only story prefers its markdown, falling back to the component file
16
+ * when the story has both.
17
+ */
18
+ function editableFilePath(story: Story | undefined): string | undefined {
19
+ const file = story?.file
20
+ if (!file) return undefined
21
+ return story?.docsOnly ? file.docsFilePath ?? file.filePath : file.filePath
22
+ }
23
+
6
24
  export const builtinCommands: ClientCommand[] = [
7
25
  {
8
26
  id: 'builtin:open-in-editor',
9
27
  label: 'Open file in editor',
10
28
  icon: 'carbon:script-reference',
11
- showIf: ({ route }) => route.name === 'story' && !!useStoryStore().currentStory,
12
- getParams: () => {
13
- const story = useStoryStore().currentStory
14
- let file: string
15
- if (story.docsOnly) {
16
- file = story.file?.docsFilePath ?? story.file?.filePath
17
- }
18
- else {
19
- file = story.file?.filePath
20
- }
21
- return {
22
- file,
23
- }
24
- },
29
+ // On the path rather than on the story: a story with no file has nothing
30
+ // to open, and offering the command was how the bad request got sent.
31
+ showIf: ({ route }) => route.name === 'story' && !!editableFilePath(useStoryStore().currentStory),
32
+ getParams: () => ({
33
+ file: editableFilePath(useStoryStore().currentStory),
34
+ }),
25
35
  clientAction: ({ file }) => {
26
- openInEditor(file)
36
+ if (file) openInEditor(file)
27
37
  },
28
38
  },
29
39
  {
@@ -31,7 +41,7 @@ export const builtinCommands: ClientCommand[] = [
31
41
  label: 'Open Poveste Documentation',
32
42
  icon: 'carbon:help',
33
43
  clientAction: () => {
34
- window.open('https://poveste.dev/guide/getting-started.html', '_blank')
44
+ window.open('https://poveste.dev/guide/getting-started', '_blank')
35
45
  },
36
46
  },
37
47
  ]
@@ -4,7 +4,7 @@ import { povesteConfig } from './config.js'
4
4
 
5
5
  export const isDark = useDark({
6
6
  valueDark: 'ptw-dark',
7
- initialValue: povesteConfig.theme.defaultColorScheme,
7
+ ...povesteConfig.theme.defaultColorScheme ? { initialValue: povesteConfig.theme.defaultColorScheme } : {},
8
8
  storageKey: 'poveste-color-scheme',
9
9
  storage: povesteConfig.theme.storeColorScheme ? localStorage : sessionStorage,
10
10
  })
@@ -13,7 +13,8 @@ export async function getSourceCode(story: Story, variant: Variant) {
13
13
  }
14
14
  }
15
15
  else {
16
- const clientPlugin = clientSupportPlugins[story.file?.supportPluginId]
16
+ const supportPluginId = story.file?.supportPluginId
17
+ const clientPlugin = supportPluginId ? clientSupportPlugins[supportPluginId] : undefined
17
18
  if (clientPlugin) {
18
19
  const pluginModule = await clientPlugin()
19
20
  return pluginModule.generateSourceCode(variant)
@@ -2,5 +2,4 @@
2
2
  // `navigator.userAgentData` is Chromium-only, so reading it here would simply
3
3
  // stop detecting macOS in Safari and Firefox — the two places where getting the
4
4
  // ⌘ shortcuts right matters most.
5
- // eslint-disable-next-line ts/no-deprecated
6
5
  export const isMac = navigator.platform.toLowerCase().includes('mac')
@@ -1,7 +1,7 @@
1
1
  import { EVENT_SEND } from './const'
2
2
  import { occupant } from './occupant'
3
3
 
4
- export async function logEvent(name: string, argument) {
4
+ export async function logEvent(name: string, argument: unknown) {
5
5
  const event = {
6
6
  name,
7
7
  argument: JSON.parse(stringifyEvent(argument)), // Needed for HTMLEvent that can't be cloned
@@ -20,10 +20,11 @@ export async function logEvent(name: string, argument) {
20
20
  }
21
21
  }
22
22
 
23
- function stringifyEvent(e) {
24
- const obj = {}
25
- for (const k in e) {
26
- obj[k] = e[k]
23
+ function stringifyEvent(e: unknown) {
24
+ const obj: Record<string, unknown> = {}
25
+ // `for...in` rather than `Object.entries`: an event's fields are inherited.
26
+ for (const k in e as object) {
27
+ obj[k] = Reflect.get(e as object, k)
27
28
  }
28
29
  return JSON.stringify(obj, (k, v) => {
29
30
  if (v instanceof Node) return 'Node'
@@ -26,7 +26,7 @@ export function assignSlots(slots: Slot[], storyId: string, visible: Variant[]):
26
26
  const position = new Map(visible.map((v, i) => [v.id, i]))
27
27
  const next: Slot[] = slots.map(slot => (
28
28
  slot.storyId === storyId && position.has(slot.variant.id)
29
- ? { ...slot, visible: true, order: position.get(slot.variant.id) }
29
+ ? { ...slot, visible: true, order: position.get(slot.variant.id) ?? 0 }
30
30
  : { ...slot, visible: false, order: 0 }
31
31
  ))
32
32
  const placed = new Set(next.filter(s => s.visible).map(s => s.variant.id))
@@ -67,6 +67,6 @@ export function trimSlots(slots: Slot[]): Slot[] {
67
67
  }
68
68
 
69
69
  let end = next.length
70
- while (end > 0 && !next[end - 1].visible) end--
70
+ while (end > 0 && !next[end - 1]?.visible) end--
71
71
  return end === next.length ? next : next.slice(0, end)
72
72
  }