@poveste/app 0.14.0 → 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 (79) hide show
  1. package/dist/bundled/app.css +26 -26
  2. package/dist/bundled/components/app/TopBarChipItem.js +1 -1
  3. package/dist/bundled/components/base/BaseEmpty.js +2 -2
  4. package/dist/bundled/components/base/BaseOverflowMenu.vue_vue_type_script_setup_true_lang.js +14 -14
  5. package/dist/bundled/components/base/BaseSelect.vue_vue_type_script_setup_true_lang.js +158 -26
  6. package/dist/bundled/components/base/BaseSplitPane.js +1 -1
  7. package/dist/bundled/components/command/PromptSelect.vue_vue_type_script_setup_true_lang.js +1 -1
  8. package/dist/bundled/components/panel/ControlsComponentPropItem.vue_vue_type_script_setup_true_lang.js +20 -14
  9. package/dist/bundled/components/panel/PaneTabs.vue_vue_type_script_setup_true_lang.js +6 -6
  10. package/dist/bundled/components/panel/StatePresets.vue_vue_type_script_setup_true_lang.js +78 -49
  11. package/dist/bundled/components/panel/StorySidePanel.vue_vue_type_script_setup_true_lang.js +1 -1
  12. package/dist/bundled/components/panel/StorySourceCode.js +1 -1
  13. package/dist/bundled/components/panel/StorySourceCode.vue_vue_type_script_setup_true_lang.js +5 -4
  14. package/dist/bundled/components/search/SearchItemContent.vue_vue_type_script_setup_true_lang.js +2 -1
  15. package/dist/bundled/components/search/SearchPane.vue_vue_type_script_setup_true_lang.js +1 -2
  16. package/dist/bundled/components/story/StoryResponsivePreview.js +1 -1
  17. package/dist/bundled/components/story/StoryResponsivePreview.vue_vue_type_script_setup_true_lang.js +8 -4
  18. package/dist/bundled/components/story/StoryVariantGridItem.js +1 -1
  19. package/dist/bundled/components/story/StoryVariantGridItem.vue_vue_type_script_setup_true_lang.js +3 -3
  20. package/dist/bundled/components/story/StoryVariantSinglePreviewRemote.vue_vue_type_script_setup_true_lang.js +9 -6
  21. package/dist/bundled/components/story/StoryView.vue_vue_type_script_setup_true_lang.js +4 -1
  22. package/dist/bundled/components/toolbar/ToolbarTitle.js +1 -1
  23. package/dist/bundled/components/toolbar/ToolbarTitle.vue_vue_type_script_setup_true_lang.js +1 -1
  24. package/dist/bundled/components/tree/StoryGroup.vue_vue_type_script_setup_true_lang.js +2 -2
  25. package/dist/bundled/components/tree/StoryList.vue_vue_type_script_setup_true_lang.js +3 -3
  26. package/dist/bundled/components/tree/StoryListFolder.js +1 -1
  27. package/dist/bundled/components/tree/StoryListFolder.vue_vue_type_script_setup_true_lang.js +3 -3
  28. package/dist/bundled/components/tree/StoryListItem.js +1 -1
  29. package/dist/bundled/components/tree/StoryListItem.vue_vue_type_script_setup_true_lang.js +3 -3
  30. package/dist/bundled/sandbox.js +3 -2
  31. package/dist/bundled/stores/story.js +2 -2
  32. package/dist/bundled/util/commands.js +1 -1
  33. package/dist/bundled/util/dark.js +1 -1
  34. package/dist/bundled/util/events.js +1 -1
  35. package/dist/bundled/util/grid-slots.js +1 -1
  36. package/dist/bundled/util/keyboard.js +8 -14
  37. package/dist/bundled/util/mapping.js +2 -2
  38. package/dist/bundled/util/render-tracker.js +33 -0
  39. package/dist/bundled/util/variant.js +12 -2
  40. package/dist/style.css +23 -1
  41. package/package.json +13 -17
  42. package/src/__tests__/render-tracker.spec.ts +38 -0
  43. package/src/app/App.vue +1 -1
  44. package/src/app/components/app/TopBarChipItem.vue +1 -1
  45. package/src/app/components/base/BaseEmpty.vue +1 -1
  46. package/src/app/components/base/BaseListItemLink.vue +4 -2
  47. package/src/app/components/base/BaseOverflowMenu.vue +17 -16
  48. package/src/app/components/base/BaseSelect.vue +171 -15
  49. package/src/app/components/base/BaseSplitPane.vue +2 -2
  50. package/src/app/components/command/PromptSelect.vue +1 -1
  51. package/src/app/components/panel/ControlsComponentPropItem.vue +26 -15
  52. package/src/app/components/panel/PaneTabs.vue +6 -6
  53. package/src/app/components/panel/StatePresets.vue +100 -64
  54. package/src/app/components/panel/StorySidePanel.vue +1 -1
  55. package/src/app/components/panel/StorySourceCode.vue +6 -5
  56. package/src/app/components/search/SearchItemContent.vue +2 -1
  57. package/src/app/components/search/SearchPane.vue +2 -3
  58. package/src/app/components/story/StoryResponsivePreview.vue +6 -2
  59. package/src/app/components/story/StoryVariantGridItem.vue +1 -1
  60. package/src/app/components/story/StoryVariantSinglePreviewRemote.vue +14 -8
  61. package/src/app/components/story/StoryView.vue +3 -1
  62. package/src/app/components/toolbar/ToolbarTitle.vue +2 -2
  63. package/src/app/components/tree/StoryGroup.vue +2 -2
  64. package/src/app/components/tree/StoryList.vue +2 -2
  65. package/src/app/components/tree/StoryListFolder.vue +2 -2
  66. package/src/app/components/tree/StoryListItem.vue +1 -1
  67. package/src/app/sandbox.ts +8 -2
  68. package/src/app/stores/story.ts +2 -2
  69. package/src/app/types.ts +4 -4
  70. package/src/app/util/commands.ts +1 -1
  71. package/src/app/util/dark.ts +1 -1
  72. package/src/app/util/env.ts +0 -1
  73. package/src/app/util/events.ts +6 -5
  74. package/src/app/util/grid-slots.ts +1 -1
  75. package/src/app/util/keyboard.ts +7 -11
  76. package/src/app/util/mapping.ts +2 -2
  77. package/src/app/util/render-tracker.ts +31 -0
  78. package/src/app/util/state.ts +3 -3
  79. package/src/app/util/variant.ts +12 -2
@@ -176,7 +176,7 @@ let startPosition = 0
176
176
  let startSplit = 0
177
177
  const el = ref<HTMLElement>()
178
178
 
179
- function dragStart(e) {
179
+ function dragStart(e: MouseEvent) {
180
180
  dragging.value = true
181
181
  startPosition = props.orientation === 'landscape' ? e.pageX : e.pageY
182
182
  startSplit = boundSplit.value
@@ -184,7 +184,7 @@ function dragStart(e) {
184
184
  window.addEventListener('mouseup', dragEnd)
185
185
  }
186
186
 
187
- function dragMove(e) {
187
+ function dragMove(e: MouseEvent) {
188
188
  if (dragging.value) {
189
189
  // The move listener is on `window`, so it can outlive the element for the
190
190
  // rest of a drag if the pane unmounts under it.
@@ -77,7 +77,7 @@ const {
77
77
  } = useSelection(formattedOptions)
78
78
 
79
79
  function selectIndex(index: number) {
80
- const result = formattedOptions.value[index].value
80
+ const result = formattedOptions.value[index]?.value
81
81
  if (result) {
82
82
  model.value = result
83
83
  }
@@ -50,24 +50,35 @@ const canReset = computed(() => props.variant.state?._hPropState?.[props.compone
50
50
  </script>
51
51
 
52
52
  <template>
53
- <component
54
- :is="comp"
53
+ <!-- The remove action is beside the control, not in its actions slot. Inside
54
+ the control's <label> or checkbox, a label forwarded clicks to it, its
55
+ name joined the control's name, and Space on it toggled the checkbox (#827). -->
56
+ <div
55
57
  v-if="comp"
56
- v-model="model"
57
- :placeholder="model === undefined ? definition?.default : null"
58
- class="poveste-controls-component-prop-item"
59
- :title="`${definition.name}${canReset ? ' *' : ''}`"
58
+ class="flex items-end hover:bg-primary-100 dark:hover:bg-primary-800"
60
59
  >
61
- <template #actions>
60
+ <component
61
+ :is="comp"
62
+ v-model="model"
63
+ :placeholder="model === undefined ? definition?.default : null"
64
+ class="poveste-controls-component-prop-item grow min-w-0"
65
+ :title="`${definition.name}${canReset ? ' *' : ''}`"
66
+ />
67
+ <button
68
+ v-tooltip="'Remove override'"
69
+ type="button"
70
+ :aria-label="`Remove override of ${definition.name}`"
71
+ :disabled="!canReset"
72
+ class="flex-none flex mr-2 mb-[10px] p-0 bg-transparent border-0 cursor-pointer text-gray-900 dark:text-gray-100 hover:text-primary-500 dark:hover:text-primary-400"
73
+ :class="[
74
+ canReset ? 'opacity-50 hover:opacity-100 focus-visible:opacity-100' : 'opacity-25 pointer-events-none',
75
+ ]"
76
+ @click="reset()"
77
+ >
62
78
  <Icon
63
- v-tooltip="'Remove override'"
64
79
  icon="carbon:erase"
65
- class="cursor-pointer w-4 h-4 hover:text-primary-500 dark:hover:text-primary-400 text-gray-900 dark:text-gray-100"
66
- :class="[
67
- canReset ? 'opacity-50 hover:opacity-100' : 'opacity-25 pointer-events-none',
68
- ]"
69
- @click.stop="reset()"
80
+ class="w-4 h-4"
70
81
  />
71
- </template>
72
- </component>
82
+ </button>
83
+ </div>
73
84
  </template>
@@ -25,13 +25,13 @@ const hasEvents = computed(() => eventsStore.events.length)
25
25
  <BaseOverflowMenu class="poveste-pane-tabs h-10 flex-none border-b border-gray-100 dark:border-gray-750">
26
26
  <BaseTab
27
27
  :to="{ ...$route, query: { ...$route.query, tab: '' } }"
28
- :matched="!$route.query.tab"
28
+ :matched="!$route.query['tab']"
29
29
  >
30
30
  Controls
31
31
  </BaseTab>
32
32
  <BaseTab
33
33
  :to="{ ...$route, query: { ...$route.query, tab: 'docs' } }"
34
- :matched="$route.query.tab === 'docs'"
34
+ :matched="$route.query['tab'] === 'docs'"
35
35
  :class="{
36
36
  'opacity-70': !renderedDoc,
37
37
  }"
@@ -40,7 +40,7 @@ const hasEvents = computed(() => eventsStore.events.length)
40
40
  </BaseTab>
41
41
  <BaseTab
42
42
  :to="{ ...$route, query: { ...$route.query, tab: 'events' } }"
43
- :matched="$route.query.tab === 'events'"
43
+ :matched="$route.query['tab'] === 'events'"
44
44
  :class="{
45
45
  'opacity-70': !hasEvents,
46
46
  }"
@@ -54,13 +54,13 @@ const hasEvents = computed(() => eventsStore.events.length)
54
54
  <template #overflow>
55
55
  <BaseOverflowTab
56
56
  :to="{ ...$route, query: { ...$route.query, tab: '' } }"
57
- :matched="!$route.query.tab"
57
+ :matched="!$route.query['tab']"
58
58
  >
59
59
  Controls
60
60
  </BaseOverflowTab>
61
61
  <BaseOverflowTab
62
62
  :to="{ ...$route, query: { ...$route.query, tab: 'docs' } }"
63
- :matched="$route.query.tab === 'docs'"
63
+ :matched="$route.query['tab'] === 'docs'"
64
64
  :class="{
65
65
  'opacity-70': !renderedDoc,
66
66
  }"
@@ -69,7 +69,7 @@ const hasEvents = computed(() => eventsStore.events.length)
69
69
  </BaseOverflowTab>
70
70
  <BaseOverflowTab
71
71
  :to="{ ...$route, query: { ...$route.query, tab: 'events' } }"
72
- :matched="$route.query.tab === 'events'"
72
+ :matched="$route.query['tab'] === 'events'"
73
73
  :class="{
74
74
  'opacity-70': !hasEvents,
75
75
  }"
@@ -2,7 +2,7 @@
2
2
  import type { Story, Variant } from '../../types'
3
3
  import { Icon } from '@iconify/vue'
4
4
  import { applyState, clone } from '@poveste/shared'
5
- import { onClickOutside, useStorage, useTimeoutFn } from '@vueuse/core'
5
+ import { onClickOutside, useLiveAnnouncer, useStorage, useTimeoutFn } from '@vueuse/core'
6
6
  import { computed, nextTick, onMounted, ref } from 'vue'
7
7
  import { toPresetState, toRawDeep } from '../../util/state'
8
8
  import BaseSelect from '../base/BaseSelect.vue'
@@ -41,7 +41,7 @@ const presetStates = useStorage<Map<string, { state: Record<string, unknown>, la
41
41
  )
42
42
 
43
43
  const presetsOptions = computed(() => {
44
- const options = { [DEFAULT_ID]: 'Initial state' }
44
+ const options: Record<string, string> = { [DEFAULT_ID]: 'Initial state' }
45
45
  presetStates.value.forEach((value, key) => {
46
46
  options[key] = value.label
47
47
  })
@@ -53,7 +53,7 @@ function resetState() {
53
53
  applyState(props.variant.state, clone(defaultState))
54
54
  }
55
55
 
56
- function applyPreset(id) {
56
+ function applyPreset(id: string) {
57
57
  if (id === DEFAULT_ID) {
58
58
  resetState()
59
59
  }
@@ -75,6 +75,7 @@ onMounted(() => {
75
75
 
76
76
  const input = ref<HTMLInputElement>()
77
77
  const select = ref<HTMLInputElement>()
78
+ const picker = ref<InstanceType<typeof BaseSelect>>()
78
79
  const canEdit = computed(() => selectedOption.value !== DEFAULT_ID)
79
80
  const isEditing = ref(false)
80
81
 
@@ -102,6 +103,7 @@ const editingLabel = computed({
102
103
  })
103
104
 
104
105
  const savedNotif = ref(false)
106
+ const { polite } = useLiveAnnouncer()
105
107
  const savedTimeout = useTimeoutFn(() => {
106
108
  savedNotif.value = false
107
109
  }, 1000)
@@ -114,9 +116,10 @@ async function savePreset() {
114
116
  preset.state = toPresetState(props.variant.state, omitKeys)
115
117
  savedNotif.value = true
116
118
  savedTimeout.start()
119
+ polite('Preset saved')
117
120
  }
118
121
 
119
- function deletePreset(id) {
122
+ function deletePreset(id: string) {
120
123
  // @TODO custom confirm modal UI
121
124
  // eslint-disable-next-line no-alert
122
125
  if (!confirm('Are you sure you want to delete this preset?')) {
@@ -141,11 +144,20 @@ async function startEditing() {
141
144
  input.value?.select()
142
145
  }
143
146
 
144
- function stopEditing() {
147
+ async function stopEditing(refocus = false) {
148
+ if (!isEditing.value) {
149
+ return
150
+ }
145
151
  isEditing.value = false
152
+ // Finishing a rename from the keyboard unmounts the field that had focus, so
153
+ // hand it to the picker, which now shows the new name.
154
+ if (refocus) {
155
+ await nextTick()
156
+ picker.value?.focus()
157
+ }
146
158
  }
147
159
 
148
- onClickOutside(select, stopEditing)
160
+ onClickOutside(select, () => stopEditing())
149
161
  </script>
150
162
 
151
163
  <template>
@@ -154,79 +166,103 @@ onClickOutside(select, stopEditing)
154
166
  ref="select"
155
167
  class="flex-1 min-w-0"
156
168
  >
169
+ <!-- Swapped for the picker rather than put inside it: a text field inside a
170
+ combobox is a control nested in a control (#828). -->
171
+ <input
172
+ v-if="isEditing"
173
+ ref="input"
174
+ v-model="editingLabel"
175
+ type="text"
176
+ aria-label="Preset name"
177
+ class="text-inherit bg-transparent w-full px-2 h-[27px] -my-1 border border-solid border-primary-500 rounded-sm outline-none"
178
+ @keydown.enter="stopEditing(true)"
179
+ @keydown.escape="stopEditing(true)"
180
+ >
157
181
  <BaseSelect
182
+ v-else
183
+ ref="picker"
158
184
  v-model="selectedOption"
185
+ label="Preset"
159
186
  :options="presetsOptions"
160
187
  @dblclick="startEditing()"
161
- @keydown.enter="stopEditing()"
162
- @keydown.escape="stopEditing()"
163
188
  @select="id => applyPreset(id)"
164
- >
165
- <template #default="{ label }">
166
- <input
167
- v-if="isEditing"
168
- ref="input"
169
- v-model="editingLabel"
170
- type="text"
171
- class="text-inherit bg-transparent w-full h-full outline-none"
172
- @click.stop.prevent
173
- >
174
-
175
- <div
176
- v-else
177
- class="flex items-center gap-2"
178
- >
179
- <span class="flex-1 truncate">
180
- {{ label }}
181
- </span>
182
- <Icon
183
- v-if="canEdit"
184
- v-tooltip="'Rename this preset'"
185
- icon="carbon:edit"
186
- class="flex-none cursor-pointer w-4 h-4 hover:text-primary-500 opacity-50 hover:opacity-100 dark:hover:text-primary-400 text-gray-900 dark:text-gray-100"
187
- @click.stop="startEditing()"
188
- />
189
- </div>
190
- </template>
191
-
192
- <template #option="{ label, value }">
193
- <div class="flex gap-2 items-center">
194
- <span
195
- class="flex-1 truncate"
196
- >{{ label }}</span>
197
- <Icon
198
- v-if="value !== DEFAULT_ID"
199
- v-tooltip="'Delete this preset'"
200
- icon="carbon:trash-can"
201
- class="flex-none cursor-pointer w-4 h-4 hover:text-primary-500 opacity-50 hover:opacity-100 dark:hover:text-primary-400 text-gray-900 dark:text-gray-100"
202
- @click.stop="deletePreset(value)"
203
- />
204
- </div>
205
- </template>
206
- </BaseSelect>
189
+ />
207
190
  </div>
208
- <Icon
191
+ <button
192
+ v-tooltip="canEdit ? 'Rename this preset' : null"
193
+ type="button"
194
+ data-testid="preset-rename"
195
+ aria-label="Rename preset"
196
+ :disabled="!canEdit"
197
+ class="flex-none flex p-0 bg-transparent border-0 cursor-pointer text-gray-900 dark:text-gray-100 hover:text-primary-500 dark:hover:text-primary-400"
198
+ :class="[
199
+ canEdit ? 'opacity-50 hover:opacity-100 focus-visible:opacity-100' : 'opacity-25 pointer-events-none',
200
+ ]"
201
+ @click="startEditing()"
202
+ >
203
+ <Icon
204
+ icon="carbon:edit"
205
+ class="w-4 h-4"
206
+ />
207
+ </button>
208
+ <button
209
+ v-tooltip="canEdit ? 'Delete this preset' : null"
210
+ type="button"
211
+ data-testid="preset-delete"
212
+ aria-label="Delete preset"
213
+ :disabled="!canEdit"
214
+ class="flex-none flex p-0 bg-transparent border-0 cursor-pointer text-gray-900 dark:text-gray-100 hover:text-primary-500 dark:hover:text-primary-400"
215
+ :class="[
216
+ canEdit ? 'opacity-50 hover:opacity-100 focus-visible:opacity-100' : 'opacity-25 pointer-events-none',
217
+ ]"
218
+ @click="deletePreset(selectedOption)"
219
+ >
220
+ <Icon
221
+ icon="carbon:trash-can"
222
+ class="w-4 h-4"
223
+ />
224
+ </button>
225
+ <button
209
226
  v-tooltip="savedNotif ? 'Saved!' : canEdit ? 'Save to preset' : null"
227
+ type="button"
210
228
  data-testid="preset-save"
211
- :icon="savedNotif ? 'carbon:checkmark' : 'carbon:save'"
212
- class="cursor-pointer w-4 h-4 hover:text-primary-500 dark:hover:text-primary-400 text-gray-900 dark:text-gray-100"
229
+ aria-label="Save to preset"
230
+ :disabled="!canEdit"
231
+ class="flex-none flex p-0 bg-transparent border-0 cursor-pointer text-gray-900 dark:text-gray-100 hover:text-primary-500 dark:hover:text-primary-400"
213
232
  :class="[
214
- canEdit ? 'opacity-50 hover:opacity-100' : 'opacity-25 pointer-events-none',
233
+ canEdit ? 'opacity-50 hover:opacity-100 focus-visible:opacity-100' : 'opacity-25 pointer-events-none',
215
234
  ]"
216
235
  @click="savePreset()"
217
- />
218
- <Icon
236
+ >
237
+ <Icon
238
+ :icon="savedNotif ? 'carbon:checkmark' : 'carbon:save'"
239
+ class="w-4 h-4"
240
+ />
241
+ </button>
242
+ <button
219
243
  v-tooltip="'Create new preset'"
244
+ type="button"
220
245
  data-testid="preset-create"
221
- icon="carbon:add-alt"
222
- class="cursor-pointer w-4 h-4 hover:text-primary-500 opacity-50 hover:opacity-100 dark:hover:text-primary-400 text-gray-900 dark:text-gray-100"
246
+ aria-label="Create new preset"
247
+ class="flex-none flex p-0 bg-transparent border-0 cursor-pointer text-gray-900 dark:text-gray-100 hover:text-primary-500 dark:hover:text-primary-400 opacity-50 hover:opacity-100 focus-visible:opacity-100"
223
248
  @click="createPreset()"
224
- />
225
- <Icon
249
+ >
250
+ <Icon
251
+ icon="carbon:add-alt"
252
+ class="w-4 h-4"
253
+ />
254
+ </button>
255
+ <button
226
256
  v-tooltip="'Reset to initial state'"
227
- icon="carbon:reset"
228
- class="cursor-pointer w-4 h-4 hover:text-primary-500 opacity-50 hover:opacity-100 dark:hover:text-primary-400 text-gray-900 dark:text-gray-100"
257
+ type="button"
258
+ aria-label="Reset to initial state"
259
+ class="flex-none flex p-0 bg-transparent border-0 cursor-pointer text-gray-900 dark:text-gray-100 hover:text-primary-500 dark:hover:text-primary-400 opacity-50 hover:opacity-100 focus-visible:opacity-100"
229
260
  @click="resetState()"
230
- />
261
+ >
262
+ <Icon
263
+ icon="carbon:reset"
264
+ class="w-4 h-4"
265
+ />
266
+ </button>
231
267
  </div>
232
268
  </template>
@@ -22,7 +22,7 @@ const innerOrientation = computed(() =>
22
22
  const route = useRoute()
23
23
 
24
24
  const panelContentComponent = computed(() => {
25
- switch (route.query.tab) {
25
+ switch (route.query['tab']) {
26
26
  case 'docs':
27
27
  return StoryDocs
28
28
  case 'events':
@@ -58,7 +58,7 @@ watch(() => [props.variant, generateSourceCodeFn.value], async () => {
58
58
  }
59
59
  catch (e) {
60
60
  console.error(e)
61
- error.value = e.message
61
+ error.value = e instanceof Error ? e.message : String(e)
62
62
  }
63
63
 
64
64
  // Auto-switch
@@ -121,8 +121,8 @@ watch(() => props.variant, () => {
121
121
 
122
122
  const scroller = ref<HTMLElement>()
123
123
 
124
- function onScroll(event) {
125
- if (sourceHtml.value) {
124
+ function onScroll(event: Event) {
125
+ if (sourceHtml.value && event.target instanceof HTMLElement) {
126
126
  lastScroll = event.target.scrollTop
127
127
  }
128
128
  }
@@ -156,7 +156,7 @@ watch(sourceHtml, async () => {
156
156
  class="flex items-center gap-1 h-full px-1 bg-gray-500/10 rounded-l transition-all ease-[cubic-bezier(0,1,.6,1)] duration-300 overflow-hidden"
157
157
  :class="[
158
158
  displayedSource !== 'dynamic' ? 'max-w-6 opacity-70' : 'max-w-[82px] text-primary-800 dark:text-primary-400',
159
- dynamicSourceCode ? 'cursor-pointer hover:bg-gray-500/30 active:bg-gray-600/50' : 'opacity-50',
159
+ dynamicSourceCode ? 'cursor-pointer hover:bg-gray-500/30 active:bg-gray-600/50' : displayedSource !== 'dynamic' && 'opacity-50',
160
160
  ]"
161
161
  @click="dynamicSourceCode && (displayedSource = 'dynamic')"
162
162
  >
@@ -178,7 +178,7 @@ watch(sourceHtml, async () => {
178
178
  class="flex items-center gap-1 h-full px-1 bg-gray-500/10 rounded-r transition-all ease-[cubic-bezier(0,1,.6,1)] duration-300 overflow-hidden"
179
179
  :class="[
180
180
  displayedSource !== 'static' ? 'max-w-6 opacity-70' : 'max-w-[63px] text-primary-800 dark:text-primary-400',
181
- staticSourceCode ? 'cursor-pointer hover:bg-gray-500/30 active:bg-gray-600/50' : 'opacity-50',
181
+ staticSourceCode ? 'cursor-pointer hover:bg-gray-500/30 active:bg-gray-600/50' : displayedSource !== 'static' && 'opacity-50',
182
182
  ]"
183
183
  @click="staticSourceCode && (displayedSource = 'static')"
184
184
  >
@@ -223,6 +223,7 @@ watch(sourceHtml, async () => {
223
223
  ref="scroller"
224
224
  class="__poveste-code-placeholder w-full h-full p-4 outline-none bg-transparent resize-none m-0"
225
225
  :value="displayedSourceCode"
226
+ aria-label="Source code"
226
227
  readonly
227
228
  data-testid="story-source-code"
228
229
  @scroll="onScroll"
@@ -8,9 +8,10 @@ defineProps<{
8
8
  selected: boolean
9
9
  }>()
10
10
 
11
- const defaultIcons = {
11
+ const defaultIcons: Record<SearchResult['kind'], string> = {
12
12
  story: 'carbon:cube',
13
13
  variant: 'carbon:cube',
14
+ command: 'carbon:chevron-right',
14
15
  }
15
16
 
16
17
  const kindLabels = {
@@ -138,8 +138,7 @@ watch(rateLimitedSearch, async (value) => {
138
138
  break
139
139
  }
140
140
  case 'variant': {
141
- const [storyId] = idMapData.id.split(':')
142
- const story = storyStore.getStoryById(storyId)
141
+ const story = storyStore.getStoryById(idMapData.id.replace(/:.*$/s, ''))
143
142
  const variant = storyStore.getVariantById(idMapData.id)
144
143
  if (!story || !variant) continue
145
144
  list.push(variantResultFactory(story, variant, rank))
@@ -268,7 +267,7 @@ const results = computed<SearchResult[]>(() => {
268
267
  ...commandResults.value,
269
268
  ...titleResults.value,
270
269
  ]
271
- const seen = {}
270
+ const seen: Record<string, boolean> = {}
272
271
  for (const r of titleResults.value) {
273
272
  seen[r.id] = true
274
273
  }
@@ -101,7 +101,9 @@ function useDragger(el: Ref<HTMLDivElement | undefined>, value: Ref<number | nul
101
101
  function onTouchStart(event: TouchEvent) {
102
102
  event.preventDefault()
103
103
  event.stopPropagation()
104
- const start = axis === 'x' ? event.touches[0].clientX : event.touches[0].clientY
104
+ const [touch] = event.touches
105
+ if (!touch) return
106
+ const start = axis === 'x' ? touch.clientX : touch.clientY
105
107
  const startValue = startFrom()
106
108
  resizing.value = true
107
109
 
@@ -112,7 +114,9 @@ function useDragger(el: Ref<HTMLDivElement | undefined>, value: Ref<number | nul
112
114
  ]
113
115
 
114
116
  function onTouchMove(event: TouchEvent) {
115
- const delta = (axis === 'x' ? event.touches[0].clientX : event.touches[0].clientY) - start
117
+ const [touch] = event.touches
118
+ if (!touch) return
119
+ const delta = (axis === 'x' ? touch.clientX : touch.clientY) - start
116
120
  value.value = Math.max(min, Math.min(max, startValue + delta))
117
121
  }
118
122
 
@@ -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
@@ -47,7 +47,9 @@ watch(() => storyStore.currentVariant, (value) => {
47
47
  watch(() => [storyStore.currentStory, storyStore.currentVariant], () => {
48
48
  if (!storyStore.currentVariant && autoSelectsVariant(storyStore.currentStory)) {
49
49
  const variant = storyStore.currentStory.lastSelectedVariant ?? storyStore.currentStory.variants[0]
50
- setVariant(variant.id)
50
+ if (variant) {
51
+ setVariant(variant.id)
52
+ }
51
53
  }
52
54
  }, {
53
55
  immediate: true,
@@ -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>
@@ -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.
@@ -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