@poveste/app 0.13.1 → 0.14.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 (89) hide show
  1. package/dist/bundled/App.vue_vue_type_script_setup_true_lang.js +1 -1
  2. package/dist/bundled/app.css +16 -16
  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/base/BaseCheckbox.vue_vue_type_script_setup_true_lang.js +1 -1
  9. package/dist/bundled/components/base/BaseOverflowMenu.vue_vue_type_script_setup_true_lang.js +5 -2
  10. package/dist/bundled/components/base/BaseSplitPane.js +1 -1
  11. package/dist/bundled/components/base/BaseSplitPane.vue_vue_type_script_setup_true_lang.js +6 -4
  12. package/dist/bundled/components/command/CommandPrompts.vue_vue_type_script_setup_true_lang.js +1 -1
  13. package/dist/bundled/components/command/CommandPromptsModal.vue_vue_type_script_setup_true_lang.js +4 -3
  14. package/dist/bundled/components/command/PromptSelect.vue_vue_type_script_setup_true_lang.js +1 -1
  15. package/dist/bundled/components/command/PromptText.vue_vue_type_script_setup_true_lang.js +1 -1
  16. package/dist/bundled/components/panel/StatePresets.vue_vue_type_script_setup_true_lang.js +30 -20
  17. package/dist/bundled/components/panel/StoryControls.vue_vue_type_script_setup_true_lang.js +1 -1
  18. package/dist/bundled/components/panel/StoryDocs.vue_vue_type_script_setup_true_lang.js +3 -1
  19. package/dist/bundled/components/panel/StoryEvent.vue_vue_type_script_setup_true_lang.js +5 -1
  20. package/dist/bundled/components/panel/StoryEvents.vue_vue_type_script_setup_true_lang.js +2 -1
  21. package/dist/bundled/components/panel/StorySidePanel.vue_vue_type_script_setup_true_lang.js +7 -5
  22. package/dist/bundled/components/panel/StorySourceCode.js +1 -1
  23. package/dist/bundled/components/panel/StorySourceCode.vue_vue_type_script_setup_true_lang.js +3 -2
  24. package/dist/bundled/components/search/SearchPane.vue_vue_type_script_setup_true_lang.js +15 -7
  25. package/dist/bundled/components/story/GenericMountStory.vue_vue_type_script_setup_true_lang.js +2 -1
  26. package/dist/bundled/components/story/GenericRenderStory.vue_vue_type_script_setup_true_lang.js +2 -1
  27. package/dist/bundled/components/story/StoryResponsivePreview.js +1 -1
  28. package/dist/bundled/components/story/StoryResponsivePreview.vue_vue_type_script_setup_true_lang.js +17 -6
  29. package/dist/bundled/components/story/StoryVariantGrid.js +1 -1
  30. package/dist/bundled/components/story/StoryVariantGrid.vue_vue_type_script_setup_true_lang.js +15 -10
  31. package/dist/bundled/components/story/StoryView.vue_vue_type_script_setup_true_lang.js +3 -2
  32. package/dist/bundled/components/story/StoryViewer.js +1 -1
  33. package/dist/bundled/components/story/StoryViewer.vue_vue_type_script_setup_true_lang.js +3 -3
  34. package/dist/bundled/components/toolbar/ToolbarBackground.vue_vue_type_script_setup_true_lang.js +1 -1
  35. package/dist/bundled/components/toolbar/ToolbarResponsiveSize.vue_vue_type_script_setup_true_lang.js +17 -16
  36. package/dist/bundled/plugin.js +4 -3
  37. package/dist/bundled/sandbox.js +2 -2
  38. package/dist/bundled/stores/folder.js +1 -1
  39. package/dist/bundled/util/commands.js +20 -9
  40. package/dist/bundled/util/docs.js +2 -1
  41. package/dist/bundled/util/grid-slots.js +1 -1
  42. package/dist/bundled/util/icons.generated.js +0 -1
  43. package/dist/bundled/util/keyboard.js +1 -0
  44. package/dist/bundled/util/mapping.js +1 -1
  45. package/dist/bundled/util/preview-settings.js +1 -1
  46. package/package.json +5 -5
  47. package/src/app/App.vue +6 -3
  48. package/src/app/components/HomeView.vue +1 -1
  49. package/src/app/components/app/AppLogo.vue +6 -2
  50. package/src/app/components/app/Breadcrumb.vue +2 -1
  51. package/src/app/components/app/TopBar.vue +7 -2
  52. package/src/app/components/base/BaseCheckbox.vue +1 -1
  53. package/src/app/components/base/BaseOverflowMenu.vue +9 -3
  54. package/src/app/components/base/BaseSplitPane.vue +9 -4
  55. package/src/app/components/command/CommandPrompts.vue +1 -1
  56. package/src/app/components/command/CommandPromptsModal.vue +1 -0
  57. package/src/app/components/command/PromptSelect.vue +2 -1
  58. package/src/app/components/command/PromptText.vue +7 -3
  59. package/src/app/components/panel/StatePresets.vue +26 -5
  60. package/src/app/components/panel/StoryControls.vue +1 -1
  61. package/src/app/components/panel/StoryDocs.vue +3 -1
  62. package/src/app/components/panel/StoryEvent.vue +7 -2
  63. package/src/app/components/panel/StoryEvents.vue +4 -1
  64. package/src/app/components/panel/StorySidePanel.vue +2 -0
  65. package/src/app/components/panel/StorySourceCode.vue +6 -3
  66. package/src/app/components/search/SearchPane.vue +14 -5
  67. package/src/app/components/story/GenericMountStory.vue +2 -1
  68. package/src/app/components/story/GenericRenderStory.vue +2 -1
  69. package/src/app/components/story/StoryResponsivePreview.vue +30 -5
  70. package/src/app/components/story/StoryVariantGrid.vue +33 -16
  71. package/src/app/components/story/StoryView.vue +7 -3
  72. package/src/app/components/story/StoryViewer.vue +3 -3
  73. package/src/app/components/toolbar/ToolbarBackground.vue +1 -1
  74. package/src/app/components/toolbar/ToolbarResponsiveSize.vue +23 -3
  75. package/src/app/plugin.ts +7 -3
  76. package/src/app/sandbox.ts +1 -1
  77. package/src/app/stores/folder.ts +1 -1
  78. package/src/app/types.ts +2 -2
  79. package/src/app/util/commands.ts +26 -16
  80. package/src/app/util/docs.ts +2 -1
  81. package/src/app/util/grid-slots.ts +1 -1
  82. package/src/app/util/icons.generated.ts +1 -1
  83. package/src/app/util/keyboard.ts +3 -0
  84. package/src/app/util/mapping.ts +1 -1
  85. package/src/app/util/preview-settings.ts +1 -1
  86. package/src/app/util/scroll.ts +5 -1
  87. package/src/app/util/state.ts +1 -1
  88. package/src/app/util/variant.ts +4 -1
  89. package/src/app/components/base/BaseCopyIcon.vue +0 -34
@@ -168,13 +168,13 @@ const rightStyle = computed(() => {
168
168
  if (!hasBothPanes.value) {
169
169
  return { [axis.value]: '100%' }
170
170
  }
171
- return { [axis.value]: props.fixed ? null : `${100 - boundSplit.value}%` }
171
+ return { [axis.value]: props.fixed ? undefined : `${100 - boundSplit.value}%` }
172
172
  })
173
173
 
174
174
  const dragging = ref(false)
175
175
  let startPosition = 0
176
176
  let startSplit = 0
177
- const el = ref(null)
177
+ const el = ref<HTMLElement>()
178
178
 
179
179
  function dragStart(e) {
180
180
  dragging.value = true
@@ -186,15 +186,20 @@ function dragStart(e) {
186
186
 
187
187
  function dragMove(e) {
188
188
  if (dragging.value) {
189
+ // The move listener is on `window`, so it can outlive the element for the
190
+ // rest of a drag if the pane unmounts under it.
191
+ const pane = el.value
192
+ if (!pane) return
193
+
189
194
  let position
190
195
  let totalSize
191
196
  if (props.orientation === 'landscape') {
192
197
  position = e.pageX
193
- totalSize = el.value.offsetWidth
198
+ totalSize = pane.offsetWidth
194
199
  }
195
200
  else {
196
201
  position = e.pageY
197
- totalSize = el.value.offsetHeight
202
+ totalSize = pane.offsetHeight
198
203
  }
199
204
  const dPosition = position - startPosition
200
205
  if (props.fixed) {
@@ -24,7 +24,7 @@ const promptTypes = {
24
24
  const answers = reactive<Record<string, any>>({})
25
25
 
26
26
  // Initial default values
27
- for (const prompt of props.command.prompts) {
27
+ for (const prompt of props.command.prompts ?? []) {
28
28
  let defaultValue
29
29
  if (typeof prompt.defaultValue === 'function') {
30
30
  defaultValue = prompt.defaultValue(answers)
@@ -33,6 +33,7 @@ const commandStore = useCommandStore()
33
33
  />
34
34
  <div class="bg-white dark:bg-gray-900 md:mt-16 md:mx-auto w-screen max-w-[512px] max-h-[80vh] overflow-y-auto scroll-smooth shadow-xl border border-gray-200 dark:border-gray-750 rounded-lg relative divide-y divide-gray-200 dark:divide-gray-850">
35
35
  <CommandPrompts
36
+ v-if="commandStore.selectedCommand"
36
37
  :command="commandStore.selectedCommand"
37
38
  @close="close()"
38
39
  />
@@ -16,8 +16,9 @@ const emit = defineEmits<{
16
16
  (e: 'next'): void
17
17
  }>()
18
18
 
19
+ // Optional coming in, required going out, so the empty case settles here.
19
20
  const model = computed({
20
- get: () => props.modelValue,
21
+ get: () => props.modelValue ?? '',
21
22
  set: (value) => {
22
23
  emit('update:modelValue', value)
23
24
  emit('next')
@@ -9,11 +9,15 @@ const props = defineProps<{
9
9
  }>()
10
10
 
11
11
  const emit = defineEmits<{
12
- (e: 'update:modelValue', value: string): void
12
+ (e: 'update:modelValue', value: string | undefined): void
13
13
  }>()
14
14
 
15
- const model = computed({
16
- get: () => props.modelValue,
15
+ // The input needs a string to bind to, so the getter fills in `''` — but an
16
+ // absent answer has to leave as absent. `''` is falsy and *not* nullish, so
17
+ // coercing on the way out would stop a command's `answers.x ?? fallback` from
18
+ // firing, which is the trap #440 was about one file over.
19
+ const model = computed<string | undefined>({
20
+ get: () => props.modelValue ?? '',
17
21
  set: value => emit('update:modelValue', value),
18
22
  })
19
23
 
@@ -57,8 +57,13 @@ function applyPreset(id) {
57
57
  if (id === DEFAULT_ID) {
58
58
  resetState()
59
59
  }
60
- else if (presetStates.value.has(id)) {
61
- applyState(props.variant.state, clone(toRawDeep(presetStates.value.get(id).state)))
60
+ else {
61
+ // `has` then `get` is two lookups and only the second one is the value;
62
+ // reading it once says the same thing and carries the type with it.
63
+ const preset = presetStates.value.get(id)
64
+ if (preset) {
65
+ applyState(props.variant.state, clone(toRawDeep(preset.state)))
66
+ }
62
67
  }
63
68
  }
64
69
 
@@ -80,9 +85,22 @@ async function createPreset() {
80
85
  selectedOption.value = id
81
86
  isEditing.value = true
82
87
  await nextTick()
83
- input.value.select()
88
+ // The input is rendered by the edit state set above, so it exists by now —
89
+ // unless editing was cancelled during the tick, which unmounts it.
90
+ input.value?.select()
84
91
  }
85
92
 
93
+ // The template bound `v-model` straight to `presetStates.get(selectedOption).label`,
94
+ // which is a Map lookup that can miss. Naming it once keeps the template honest
95
+ // and gives the miss somewhere to go.
96
+ const editingLabel = computed({
97
+ get: () => presetStates.value.get(selectedOption.value)?.label ?? '',
98
+ set: (value) => {
99
+ const preset = presetStates.value.get(selectedOption.value)
100
+ if (preset) preset.label = value
101
+ },
102
+ })
103
+
86
104
  const savedNotif = ref(false)
87
105
  const savedTimeout = useTimeoutFn(() => {
88
106
  savedNotif.value = false
@@ -92,6 +110,7 @@ async function savePreset() {
92
110
  if (!canEdit.value) return
93
111
 
94
112
  const preset = presetStates.value.get(selectedOption.value)
113
+ if (!preset) return
95
114
  preset.state = toPresetState(props.variant.state, omitKeys)
96
115
  savedNotif.value = true
97
116
  savedTimeout.start()
@@ -117,7 +136,9 @@ async function startEditing() {
117
136
 
118
137
  isEditing.value = true
119
138
  await nextTick()
120
- input.value.select()
139
+ // The input is rendered by the edit state set above, so it exists by now —
140
+ // unless editing was cancelled during the tick, which unmounts it.
141
+ input.value?.select()
121
142
  }
122
143
 
123
144
  function stopEditing() {
@@ -145,7 +166,7 @@ onClickOutside(select, stopEditing)
145
166
  <input
146
167
  v-if="isEditing"
147
168
  ref="input"
148
- v-model="presetStates.get(selectedOption).label"
169
+ v-model="editingLabel"
149
170
  type="text"
150
171
  class="text-inherit bg-transparent w-full h-full outline-none"
151
172
  @click.stop.prevent
@@ -28,7 +28,7 @@ watch(() => props.variant, () => {
28
28
  ready.value = false
29
29
  })
30
30
 
31
- const hasCustomControls = computed(() => props.variant.slots().controls || props.story.slots().controls)
31
+ const hasCustomControls = computed(() => props.variant.slots?.().controls || props.story.slots?.().controls)
32
32
 
33
33
  const hasInitState = computed(() => Object
34
34
  .entries(props.variant.state || {})
@@ -14,7 +14,9 @@ export function useStoryDoc(story: Ref<Story>) {
14
14
 
15
15
  watchEffect(async () => {
16
16
  // Markdown file
17
- const mdKey = story.value.file.filePath.replace(/\.(\w*)$/, '.md')
17
+ const filePath = story.value?.file?.filePath
18
+ if (!filePath) return
19
+ const mdKey = filePath.replace(/\.(\w*)$/, '.md')
18
20
  if (markdownFiles[mdKey]) {
19
21
  const md = await markdownFiles[mdKey]()
20
22
  renderedDoc.value = md.html
@@ -10,8 +10,13 @@ const formattedArgument = computed(() => {
10
10
  switch (typeof props.event.argument) {
11
11
  case 'string':
12
12
  return `"${props.event.argument}"`
13
- case 'object':
14
- return `{ ${Object.keys(props.event.argument).map(key => `${key}: ${props.event.argument[key]}`).join(', ')} }`
13
+ case 'object': {
14
+ // `argument` is `unknown`, and `typeof x === 'object'` narrows it only as
15
+ // far as `object | null` — enough for `Object.keys`, not for indexing.
16
+ const argument = props.event.argument as Record<string, unknown> | null
17
+ if (!argument) return 'null'
18
+ return `{ ${Object.keys(argument).map(key => `${key}: ${argument[key]}`).join(', ')} }`
19
+ }
15
20
  default:
16
21
  return props.event.argument
17
22
  }
@@ -19,7 +19,10 @@ async function resetUnseen() {
19
19
  eventsStore.unseen = 0
20
20
  }
21
21
  await nextTick()
22
- eventsElement.value.scrollTo({ top: eventsElement.value.scrollHeight })
22
+ // Read after the await, and it is a template ref: the panel can be closed
23
+ // during the tick, and then there is nothing to scroll.
24
+ const events = eventsElement.value
25
+ events?.scrollTo({ top: events.scrollHeight })
23
26
  }
24
27
  </script>
25
28
 
@@ -60,6 +60,7 @@ const panelContentComponent = computed(() => {
60
60
  <template #first>
61
61
  <div class="flex flex-col h-full">
62
62
  <PaneTabs
63
+ v-if="storyStore.currentStory"
63
64
  :story="storyStore.currentStory"
64
65
  :variant="storyStore.currentVariant"
65
66
  />
@@ -75,6 +76,7 @@ const panelContentComponent = computed(() => {
75
76
 
76
77
  <template #last>
77
78
  <StorySourceCode
79
+ v-if="storyStore.currentStory"
78
80
  :story="storyStore.currentStory"
79
81
  :variant="storyStore.currentVariant"
80
82
  class="h-full"
@@ -15,10 +15,13 @@ const props = defineProps<{
15
15
  variant: Variant
16
16
  }>()
17
17
 
18
- const generateSourceCodeFn = ref(null)
18
+ // `ref(null)` infers `Ref<null>`, so the assignment below and the call further
19
+ // down were both errors about a type nobody meant to declare.
20
+ const generateSourceCodeFn = shallowRef<((variant: Variant) => Promise<string>) | null>(null)
19
21
 
20
22
  watchEffect(async () => {
21
- const clientPlugin = clientSupportPlugins[props.story.file?.supportPluginId]
23
+ const supportPluginId = props.story.file?.supportPluginId
24
+ const clientPlugin = supportPluginId ? clientSupportPlugins[supportPluginId] : undefined
22
25
  if (clientPlugin) {
23
26
  const pluginModule = await clientPlugin()
24
27
  generateSourceCodeFn.value = markRaw(pluginModule.generateSourceCode)
@@ -28,7 +31,7 @@ watchEffect(async () => {
28
31
  const highlighter = shallowRef<HighlighterCore>()
29
32
 
30
33
  const dynamicSourceCode = ref('')
31
- const error = ref<string>(null)
34
+ const error = ref<string | null>(null)
32
35
 
33
36
  // Above the watch that assigns it: the callback is async but runs immediately,
34
37
  // and a story whose `slots()` throws reaches the auto-switch below before its
@@ -45,7 +45,9 @@ watch(() => props.shown, (value) => {
45
45
  if (value) {
46
46
  requestAnimationFrame(() => {
47
47
  focused.value = true
48
- input.value.select()
48
+ // A whole frame later, so the pane may have been closed again — a
49
+ // template ref is undefined once its element unmounts.
50
+ input.value?.select()
49
51
  })
50
52
  }
51
53
  })
@@ -127,7 +129,11 @@ watch(rateLimitedSearch, async (value) => {
127
129
  if (!idMapData) continue
128
130
  switch (idMapData.kind) {
129
131
  case 'story': {
130
- list.push(storyResultFactory(storyStore.getStoryById(idMapData.id), rank))
132
+ // The search index is built from the stories and outlives an
133
+ // individual one, so a hit can name a story that is no longer loaded.
134
+ const story = storyStore.getStoryById(idMapData.id)
135
+ if (!story) continue
136
+ list.push(storyResultFactory(story, rank))
131
137
  rank++
132
138
  break
133
139
  }
@@ -135,6 +141,7 @@ watch(rateLimitedSearch, async (value) => {
135
141
  const [storyId] = idMapData.id.split(':')
136
142
  const story = storyStore.getStoryById(storyId)
137
143
  const variant = storyStore.getVariantById(idMapData.id)
144
+ if (!story || !variant) continue
138
145
  list.push(variantResultFactory(story, variant, rank))
139
146
  rank++
140
147
  break
@@ -158,7 +165,9 @@ async function searchOnDocField(query: string) {
158
165
  if (!idMapData) continue
159
166
  switch (idMapData.kind) {
160
167
  case 'story': {
161
- list.push(storyResultFactory(storyStore.getStoryById(idMapData.id), rank, 'docs'))
168
+ const story = storyStore.getStoryById(idMapData.id)
169
+ if (!story) continue
170
+ list.push(storyResultFactory(story, rank, 'docs'))
162
171
  rank++
163
172
  break
164
173
  }
@@ -188,7 +197,7 @@ function storyResultFactory(story: Story, rank: number, type: SearchResultType =
188
197
  : {},
189
198
  },
190
199
  },
191
- path: story.file.path.slice(0, -1),
200
+ path: story.file?.path.slice(0, -1) ?? [],
192
201
  icon: story.icon,
193
202
  iconColor: story.iconColor,
194
203
  }
@@ -212,7 +221,7 @@ function variantResultFactory(story: Story, variant: Variant, rank: number, type
212
221
  : {},
213
222
  },
214
223
  },
215
- path: [...story.file.path ?? [], story.title],
224
+ path: [...story.file?.path ?? [], story.title],
216
225
  icon: variant.icon,
217
226
  iconColor: variant.iconColor,
218
227
  }
@@ -16,7 +16,8 @@ const props = defineProps<{
16
16
  const mountComponent = ref(null)
17
17
 
18
18
  watchEffect(async () => {
19
- const clientPlugin = clientSupportPlugins[props.story.file?.supportPluginId]
19
+ const supportPluginId = props.story.file?.supportPluginId
20
+ const clientPlugin = supportPluginId ? clientSupportPlugins[supportPluginId] : undefined
20
21
  if (clientPlugin) {
21
22
  const pluginModule = await clientPlugin()
22
23
  mountComponent.value = markRaw(pluginModule.MountStory)
@@ -16,7 +16,8 @@ const props = defineProps<{
16
16
  const mountComponent = ref(null)
17
17
 
18
18
  watchEffect(async () => {
19
- const clientPlugin = clientSupportPlugins[props.story.file?.supportPluginId]
19
+ const supportPluginId = props.story.file?.supportPluginId
20
+ const clientPlugin = supportPluginId ? clientSupportPlugins[supportPluginId] : undefined
20
21
  if (clientPlugin) {
21
22
  try {
22
23
  const pluginModule = await clientPlugin()
@@ -43,12 +43,34 @@ function addWindowListener(event: string, listener: (event: any) => unknown) {
43
43
 
44
44
  const previewWrapper = ref<HTMLDivElement>()
45
45
 
46
- function useDragger(el: Ref<HTMLDivElement>, value: Ref<number>, min: number, max: number, axis: 'x' | 'y') {
46
+ function useDragger(el: Ref<HTMLDivElement | undefined>, value: Ref<number | null>, min: number, max: number, axis: 'x' | 'y') {
47
+ // The wrapper is a template ref, so it is undefined before mount and after
48
+ // unmount. Every read below happens during a drag, when it is on screen — but
49
+ // the move and up listeners are on `window`, so "during a drag" is not the
50
+ // same as "while this component is alive".
51
+ function wrapperSize() {
52
+ const wrapper = previewWrapper.value
53
+ if (!wrapper) return null
54
+ return axis === 'x' ? wrapper.clientWidth : wrapper.clientHeight
55
+ }
56
+
57
+ // `null` is the value that means "size to the available space", so a drag
58
+ // beginning from auto has to begin from the size actually rendered. The touch
59
+ // path had no fallback and `null + delta` coerces to `delta`, which collapsed
60
+ // a full-height preview to the minimum on the first move (#440).
61
+ function startFrom() {
62
+ if (value.value !== null) return value.value
63
+ const size = wrapperSize()
64
+ // Nothing to measure against, so start from the floor rather than from NaN.
65
+ if (size === null) return min
66
+ return size - (axis === 'x' ? 67 : 70)
67
+ }
68
+
47
69
  function onMouseDown(event: MouseEvent) {
48
70
  event.preventDefault()
49
71
  event.stopPropagation()
50
72
  const start = axis === 'x' ? event.clientX : event.clientY
51
- const startValue = value.value ?? (axis === 'x' ? previewWrapper.value.clientWidth - 67 : previewWrapper.value.clientHeight - 70)
73
+ const startValue = startFrom()
52
74
  resizing.value = true
53
75
 
54
76
  const removeListeners = [
@@ -57,11 +79,14 @@ function useDragger(el: Ref<HTMLDivElement>, value: Ref<number>, min: number, ma
57
79
  ]
58
80
 
59
81
  function onMouseMove(event: MouseEvent) {
60
- const snapTarget = (axis === 'x' ? previewWrapper.value.clientWidth : previewWrapper.value.clientHeight)
82
+ const snapTarget = wrapperSize()
61
83
  const delta = (axis === 'x' ? event.clientX : event.clientY) - start
62
84
  value.value = Math.max(min, Math.min(max, startValue + delta))
63
85
 
64
- if (Math.abs(value.value - (snapTarget - 67)) < 16) {
86
+ // 67 on both axes, unlike `startFrom`. Left as it was: the snap threshold
87
+ // is a feel, not a measurement, and changing it here would be a UX edit
88
+ // wearing a type fix's clothes.
89
+ if (snapTarget !== null && Math.abs(value.value - (snapTarget - 67)) < 16) {
65
90
  value.value = null
66
91
  }
67
92
  }
@@ -77,7 +102,7 @@ function useDragger(el: Ref<HTMLDivElement>, value: Ref<number>, min: number, ma
77
102
  event.preventDefault()
78
103
  event.stopPropagation()
79
104
  const start = axis === 'x' ? event.touches[0].clientX : event.touches[0].clientY
80
- const startValue = value.value
105
+ const startValue = startFrom()
81
106
  resizing.value = true
82
107
 
83
108
  const removeListeners = [
@@ -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>
@@ -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,
@@ -61,7 +65,7 @@ function setVariant(variantId: string) {
61
65
 
62
66
  // Docs auto-scroll to top
63
67
 
64
- const docsOnlyScroller = ref<HTMLElement>(null)
68
+ const docsOnlyScroller = ref<HTMLElement>()
65
69
 
66
70
  function scrollDocsToTop() {
67
71
  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">
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
  })
@@ -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)) {
@@ -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
  })