@poveste/app 0.10.0 → 0.12.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 (48) hide show
  1. package/dist/app/style/main.pcss +2 -3
  2. package/dist/bundled/App.vue_vue_type_script_setup_true_lang.js +14 -11
  3. package/dist/bundled/app.css +9 -9
  4. package/dist/bundled/components/app/AppActions.vue_vue_type_script_setup_true_lang.js +2 -0
  5. package/dist/bundled/components/app/AppHeader.vue_vue_type_script_setup_true_lang.js +6 -5
  6. package/dist/bundled/components/app/TopBarChipItem.js +5 -2
  7. package/dist/bundled/components/base/BaseCheckbox.vue_vue_type_script_setup_true_lang.js +8 -6
  8. package/dist/bundled/components/base/BaseListItemLink.js +2 -0
  9. package/dist/bundled/components/panel/StatePresets.vue_vue_type_script_setup_true_lang.js +4 -2
  10. package/dist/bundled/components/panel/StoryEvents.vue_vue_type_script_setup_true_lang.js +1 -1
  11. package/dist/bundled/components/panel/StorySourceCode.js +1 -1
  12. package/dist/bundled/components/panel/StorySourceCode.vue_vue_type_script_setup_true_lang.js +1 -1
  13. package/dist/bundled/components/search/SearchItem.js +1 -1
  14. package/dist/bundled/components/search/SearchItem.vue_vue_type_script_setup_true_lang.js +4 -2
  15. package/dist/bundled/components/search/SearchPane.vue_vue_type_script_setup_true_lang.js +69 -33
  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 +3 -3
  18. package/dist/bundled/components/story/StoryVariantSinglePreviewRemote.vue_vue_type_script_setup_true_lang.js +2 -1
  19. package/dist/bundled/components/toolbar/DevOnlyToolbarOpenInEditor.vue_vue_type_script_setup_true_lang.js +1 -0
  20. package/dist/bundled/components/toolbar/ToolbarNewTab.vue_vue_type_script_setup_true_lang.js +1 -0
  21. package/dist/bundled/components/toolbar/ToolbarTextDirection.vue_vue_type_script_setup_true_lang.js +1 -0
  22. package/dist/bundled/components/tree/StoryListItem.js +1 -1
  23. package/dist/bundled/components/tree/StoryListItem.vue_vue_type_script_setup_true_lang.js +9 -7
  24. package/dist/bundled/sandbox.js +4 -1
  25. package/dist/bundled/util/uncaught.js +14 -0
  26. package/dist/style.css +1 -1
  27. package/package.json +9 -8
  28. package/src/__tests__/uncaught.spec.ts +36 -0
  29. package/src/app/App.vue +37 -18
  30. package/src/app/components/app/AppActions.vue +2 -0
  31. package/src/app/components/app/AppHeader.vue +7 -2
  32. package/src/app/components/app/TopBarChipItem.vue +3 -0
  33. package/src/app/components/base/BaseCheckbox.vue +4 -1
  34. package/src/app/components/base/BaseListItemLink.vue +1 -0
  35. package/src/app/components/panel/StatePresets.vue +12 -0
  36. package/src/app/components/panel/StoryEvents.vue +2 -2
  37. package/src/app/components/panel/StorySourceCode.vue +5 -2
  38. package/src/app/components/search/SearchItem.vue +2 -0
  39. package/src/app/components/search/SearchPane.vue +42 -0
  40. package/src/app/components/story/StoryResponsivePreview.vue +2 -1
  41. package/src/app/components/story/StoryVariantSinglePreviewRemote.vue +1 -0
  42. package/src/app/components/toolbar/DevOnlyToolbarOpenInEditor.vue +1 -0
  43. package/src/app/components/toolbar/ToolbarNewTab.vue +1 -0
  44. package/src/app/components/toolbar/ToolbarTextDirection.vue +1 -0
  45. package/src/app/components/tree/StoryListItem.vue +2 -0
  46. package/src/app/sandbox.ts +6 -1
  47. package/src/app/style/main.pcss +2 -3
  48. package/src/app/util/uncaught.ts +19 -0
@@ -7,10 +7,7 @@ import { useRoute } from "@poveste/vendors/vue-router";
7
7
  //#region src/app/components/tree/StoryListItem.vue?vue&type=script&setup=true&lang.ts
8
8
  var _hoisted_1 = { class: "bind-tree-margin flex items-center gap-2 pl-4 min-w-0" };
9
9
  var _hoisted_2 = { class: "truncate" };
10
- var _hoisted_3 = {
11
- key: 1,
12
- class: "opacity-40 text-sm"
13
- };
10
+ var _hoisted_3 = ["aria-label"];
14
11
  var StoryListItem_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ defineComponent({
15
12
  __name: "StoryListItem",
16
13
  props: {
@@ -19,8 +16,8 @@ var StoryListItem_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ de
19
16
  },
20
17
  setup(__props) {
21
18
  useCssVars((_ctx) => ({
22
- "v18724f1f": filePadding.value,
23
- "v45b473db": _ctx.story.iconColor
19
+ "v516f2a64": filePadding.value,
20
+ "v2fa7200a": _ctx.story.iconColor
24
21
  }));
25
22
  const props = __props;
26
23
  const errorStore = useStoryErrorStore();
@@ -60,7 +57,12 @@ var StoryListItem_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ de
60
57
  "data-testid": "story-error-marker",
61
58
  "aria-label": "A variant of this story threw while rendering"
62
59
  })) : createCommentVNode("", true),
63
- !__props.story.docsOnly ? (openBlock(), createElementBlock("span", _hoisted_3, toDisplayString(__props.story.variants.length), 1)) : createCommentVNode("", true)
60
+ !__props.story.docsOnly ? (openBlock(), createElementBlock("span", {
61
+ key: 1,
62
+ class: "opacity-40 text-sm",
63
+ "aria-label": `${__props.story.variants.length} ${__props.story.variants.length === 1 ? "variant" : "variants"}`,
64
+ role: "note"
65
+ }, toDisplayString(__props.story.variants.length), 9, _hoisted_3)) : createCommentVNode("", true)
64
66
  ]),
65
67
  _: 1
66
68
  }, 8, ["to"])], 512);
@@ -7,6 +7,7 @@ import { mapFile } from "./util/mapping.js";
7
7
  import GenericRenderStory_default from "./components/story/GenericRenderStory.js";
8
8
  import { createStateBridge } from "./util/state-bridge.js";
9
9
  import { toRawDeep } from "./util/state.js";
10
+ import { isBenignResizeObserverEvent } from "./util/uncaught.js";
10
11
  import { usePreferredDark } from "@poveste/vendors/vue-use";
11
12
  import { computed, createApp, h, onMounted, ref, watch } from "@poveste/vendors/vue";
12
13
  import { createPinia } from "@poveste/vendors/pinia";
@@ -36,7 +37,9 @@ function reportUncaught(error) {
36
37
  variantId: variantId.value
37
38
  });
38
39
  }
39
- window.addEventListener("error", (event) => reportUncaught(event.error ?? event.message));
40
+ window.addEventListener("error", (event) => {
41
+ if (!isBenignResizeObserverEvent(event)) reportUncaught(event.error ?? event.message);
42
+ });
40
43
  window.addEventListener("unhandledrejection", (event) => reportUncaught(event.reason));
41
44
  var prefersDark = usePreferredDark();
42
45
  var previewDark = computed(() => resolvePreviewDark(receivedSettings.colorScheme, prefersDark.value, isDark.value));
@@ -0,0 +1,14 @@
1
+ //#region src/app/util/uncaught.ts
2
+ var RESIZE_OBSERVER_LOOP = ["ResizeObserver loop limit exceeded", "ResizeObserver loop completed with undelivered notifications"];
3
+ /**
4
+ * A ResizeObserver loop notification is the browser saying it deferred a resize
5
+ * callback to the next frame, not a component throwing. It reaches the `error`
6
+ * event with a null `error`, so reporting `event.error ?? event.message` blames
7
+ * the story that happens to be rendering for it (#468). Requiring the null keeps
8
+ * a genuine `throw` of the same text reportable.
9
+ */
10
+ function isBenignResizeObserverEvent({ error, message }) {
11
+ return error == null && typeof message === "string" && RESIZE_OBSERVER_LOOP.some((known) => message.startsWith(known));
12
+ }
13
+ //#endregion
14
+ export { isBenignResizeObserverEvent };
package/dist/style.css CHANGED
@@ -2499,7 +2499,7 @@
2499
2499
  text-decoration: underline;
2500
2500
  }
2501
2501
  }
2502
- &:is(h1, h2, h3, h4, th) {
2502
+ &:is(h1, h2, h3, h4) {
2503
2503
  margin-bottom: 0.75rem;
2504
2504
  &:not(:first-child) {
2505
2505
  margin-top: 1.25rem;
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@poveste/app",
3
3
  "type": "module",
4
- "version": "0.10.0",
4
+ "version": "0.12.0",
5
5
  "description": "Pre-bundled UI for Poveste",
6
6
  "author": {
7
- "name": "Sorin Gitlan"
7
+ "name": "Sorin Gitlan",
8
+ "url": "https://x.com/50rayn"
8
9
  },
9
10
  "license": "MIT",
10
11
  "homepage": "https://poveste.dev",
@@ -38,11 +39,11 @@
38
39
  },
39
40
  "dependencies": {
40
41
  "@fontsource/noto-sans-display": "^5.3.0",
42
+ "@poveste/controls": "^0.12.0",
43
+ "@poveste/shared": "^0.12.0",
44
+ "@poveste/vendors": "^0.12.0",
41
45
  "fuse.js": "^7.0.0",
42
- "shiki": "^3.0.0",
43
- "@poveste/controls": "^0.10.0",
44
- "@poveste/shared": "^0.10.0",
45
- "@poveste/vendors": "^0.10.0"
46
+ "shiki": "^3.0.0"
46
47
  },
47
48
  "devDependencies": {
48
49
  "@iconify-json/carbon": "^1.2.25",
@@ -54,13 +55,13 @@
54
55
  "@tailwindcss/typography": "^0.5.15",
55
56
  "@types/fs-extra": "^11.0.4",
56
57
  "@types/node": "^22.10.1",
57
- "@vitejs/plugin-vue": "^5.2.1",
58
+ "@vitejs/plugin-vue": "^6.0.0",
58
59
  "chokidar": "^4.0.1",
59
60
  "concurrently": "^9.1.0",
60
61
  "fs-extra": "^11.2.0",
61
62
  "globby": "^14.0.2",
62
63
  "pathe": "^1.1.2",
63
- "postcss": "^8.4.49",
64
+ "postcss": "^8.5.23",
64
65
  "postcss-cli": "^11.0.1",
65
66
  "tailwindcss": "^4.3.3",
66
67
  "typescript": "5.6.3",
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { isBenignResizeObserverEvent } from '../app/util/uncaught'
3
+
4
+ /**
5
+ * The shape the sandbox's `error` listener sees. A browser-emitted
6
+ * ResizeObserver notification carries a null `error`; a real throw carries the
7
+ * `Error` that was thrown.
8
+ */
9
+ const notification = (message: string) => ({ error: null, message })
10
+ const thrown = (message: string) => ({ error: new Error(message), message: `Uncaught Error: ${message}` })
11
+
12
+ describe('a ResizeObserver loop notification', () => {
13
+ // Chrome's exact wording, trailing period included, as captured from a live
14
+ // observer loop.
15
+ it('is recognised as benign with the period Chrome sends', () => {
16
+ expect(isBenignResizeObserverEvent(notification('ResizeObserver loop completed with undelivered notifications.'))).toBe(true)
17
+ })
18
+
19
+ it('is recognised as benign without the period Firefox omits', () => {
20
+ expect(isBenignResizeObserverEvent(notification('ResizeObserver loop completed with undelivered notifications'))).toBe(true)
21
+ })
22
+
23
+ it('is recognised as benign in the older loop limit wording', () => {
24
+ expect(isBenignResizeObserverEvent(notification('ResizeObserver loop limit exceeded'))).toBe(true)
25
+ })
26
+ })
27
+
28
+ describe('an error the sandbox exists to report', () => {
29
+ it('is not treated as benign when a component throws the same text', () => {
30
+ expect(isBenignResizeObserverEvent(thrown('ResizeObserver loop completed with undelivered notifications'))).toBe(false)
31
+ })
32
+
33
+ it('is not treated as benign when an unrelated script fails with no error object', () => {
34
+ expect(isBenignResizeObserverEvent(notification('Script error.'))).toBe(false)
35
+ })
36
+ })
package/src/app/App.vue CHANGED
@@ -111,6 +111,9 @@ const layoutStore = useLayoutStore()
111
111
 
112
112
  <template>
113
113
  <div class="poveste-app-root h-full">
114
+ <!-- The book is a page and axe's page-level rules apply to it, unlike the
115
+ sandbox, which renders one isolated component (#310). -->
116
+
114
117
  <div
115
118
  v-if="storyStore.currentStory"
116
119
  class="poveste-app hidden"
@@ -144,7 +147,9 @@ const layoutStore = useLayoutStore()
144
147
  :tree="tree"
145
148
  :stories="stories"
146
149
  />
147
- <RouterView class="grow" />
150
+ <main class="grow flex flex-col min-h-0">
151
+ <RouterView class="grow" />
152
+ </main>
148
153
  </div>
149
154
 
150
155
  <BaseSplitPane
@@ -158,34 +163,48 @@ const layoutStore = useLayoutStore()
158
163
  <template #first>
159
164
  <div class="flex flex-col h-full bg-gray-100 dark:bg-gray-750 __poveste-pane-shadow-from-right">
160
165
  <AppHeader class="flex-none" />
161
- <StoryList
162
- :tree="tree"
163
- :stories="stories"
164
- class="flex-1"
165
- />
166
+ <nav
167
+ aria-label="Stories"
168
+ class="flex-1 flex flex-col min-h-0"
169
+ >
170
+ <StoryList
171
+ :tree="tree"
172
+ :stories="stories"
173
+ class="flex-1"
174
+ />
175
+ </nav>
166
176
  </div>
167
177
  </template>
168
178
 
169
179
  <template #last>
170
- <div class="flex flex-col h-full">
180
+ <main class="flex flex-col h-full">
171
181
  <TopBar
172
182
  @layout="isLayoutOpen = true"
173
183
  @search="isSearchOpen = true"
174
184
  />
175
185
  <RouterView class="flex-1 min-h-0" />
176
- </div>
186
+ </main>
177
187
  </template>
178
188
  </BaseSplitPane>
179
- <div
180
- v-else
181
- class="h-full flex flex-col"
182
- >
183
- <TopBar
184
- @layout="isLayoutOpen = true"
185
- @search="isSearchOpen = true"
186
- />
187
- <RouterView class="flex-1 min-h-0" />
188
- </div>
189
+ <template v-else>
190
+ <!--
191
+ The story list is hidden in this layout, and `AppHeader` went with it —
192
+ taking the page's only `h1` and its banner. A heading has to exist in
193
+ every layout, and it has to sit inside a landmark, so this branch
194
+ carries its own (#310).
195
+ -->
196
+ <header class="sr-only">
197
+ <h1>{{ povesteConfig.theme?.title ?? 'Poveste' }}</h1>
198
+ </header>
199
+
200
+ <main class="h-full flex flex-col">
201
+ <TopBar
202
+ @layout="isLayoutOpen = true"
203
+ @search="isSearchOpen = true"
204
+ />
205
+ <RouterView class="flex-1 min-h-0" />
206
+ </main>
207
+ </template>
189
208
 
190
209
  <LayoutModal
191
210
  v-if="!isMobile"
@@ -35,6 +35,7 @@ const themeIcon = computed(() => {
35
35
 
36
36
  <TopBarChipItem
37
37
  v-tooltip="makeTooltip('Search', ({ isMac }) => isMac ? 'meta+k' : 'ctrl+k')"
38
+ aria-label="Search stories"
38
39
  data-testid="search-btn"
39
40
  @click="$emit('search')"
40
41
  >
@@ -47,6 +48,7 @@ const themeIcon = computed(() => {
47
48
  <TopBarChipItem
48
49
  v-if="!povesteConfig.theme.hideColorSchemeSwitch"
49
50
  v-tooltip="makeTooltip('Toggle dark mode', ({ isMac }) => isMac ? 'meta+shift+d' : 'ctrl+shift+d')"
51
+ aria-label="Toggle dark mode"
50
52
  @click="toggleDark()"
51
53
  >
52
54
  <Icon
@@ -4,9 +4,14 @@ import AppLogo from './AppLogo.vue'
4
4
  </script>
5
5
 
6
6
  <template>
7
- <div
7
+ <header
8
8
  class="poveste-app-header px-4 h-14 flex items-center gap-2"
9
9
  >
10
+ <!-- A page needs exactly one h1, and it has to sit inside a landmark or it
11
+ is content outside any region itself (#310). -->
12
+ <h1 class="sr-only">
13
+ {{ povesteConfig.theme?.title ?? 'Poveste' }}
14
+ </h1>
10
15
  <div class="py-3 sm:py-4 flex-1 h-full flex items-center pr-2">
11
16
  <a
12
17
  :href="povesteConfig.theme?.logoHref"
@@ -18,5 +23,5 @@ import AppLogo from './AppLogo.vue'
18
23
  />
19
24
  </a>
20
25
  </div>
21
- </div>
26
+ </header>
22
27
  </template>
@@ -1,5 +1,8 @@
1
1
  <template>
2
+ <!-- Without an href this is not a link, so `aria-label` on it is prohibited and
3
+ it has no accessible role. Chip items are used both ways (#310). -->
2
4
  <a
5
+ :role="$attrs.href ? undefined : 'button'"
3
6
  class="poveste-top-bar-chip-item flex items-center gap-1 px-2.5 py-1.5 hover:bg-white/50 active:bg-white/70 dark:hover:bg-white/10 dark:active:bg-white/15 hover:text-primary-500 dark:hover:text-primary-400 cursor-pointer text-gray-900 dark:text-gray-100 transition-colors"
4
7
  >
5
8
  <slot />
@@ -12,6 +12,9 @@ const emit = defineEmits({
12
12
  'update:modelValue': (_newValue: boolean) => true,
13
13
  })
14
14
 
15
+ // Off until the first toggle, so the tick does not animate itself in on mount.
16
+ const animationEnabled = ref(false)
17
+
15
18
  function toggle() {
16
19
  emit('update:modelValue', !props.modelValue)
17
20
  animationEnabled.value = true
@@ -23,7 +26,6 @@ const path = ref<SVGPathElement>()
23
26
  const dasharray = ref(0)
24
27
  const progress = computed(() => props.modelValue ? 1 : 0)
25
28
  const dashoffset = computed(() => (1 - progress.value) * dasharray.value)
26
- const animationEnabled = ref(false)
27
29
 
28
30
  watch(path, () => {
29
31
  dasharray.value = path.value.getTotalLength?.() ?? 21.21
@@ -34,6 +36,7 @@ watch(path, () => {
34
36
  <div
35
37
  role="checkbox"
36
38
  tabindex="0"
39
+ :aria-checked="modelValue"
37
40
  class="poveste-base-checkbox flex items-center gap-2 select-none px-4 py-3 cursor-pointer hover:bg-primary-100 dark:hover:bg-primary-700"
38
41
  @click="toggle()"
39
42
  @keydown.enter.prevent="toggle()"
@@ -37,6 +37,7 @@ export default defineComponent({
37
37
  >
38
38
  <a
39
39
  :href="href"
40
+ :aria-current="(isActive != null ? isActive : linkIsActive) ? 'page' : undefined"
40
41
  class="flex items-center gap-2 text-gray-900 dark:text-gray-100"
41
42
  :class="[
42
43
  $attrs.class,
@@ -20,14 +20,24 @@ const omitKeys = ['_hPropDefs']
20
20
 
21
21
  const defaultState = toPresetState(props.variant.state, omitKeys)
22
22
 
23
+ /*
24
+ * `writeDefaults: false`: these are keyed per variant, so writing the default on
25
+ * mount persisted "no presets, default selected" for every variant merely
26
+ * browsed. Nothing prunes them, and a renamed variant orphans its keys forever
27
+ * because no code path can match them again (#326).
28
+ */
23
29
  const selectedOption = useStorage<string>(
24
30
  `_poveste-presets/${saveId.value}/selected`,
25
31
  DEFAULT_ID,
32
+ undefined,
33
+ { writeDefaults: false },
26
34
  )
27
35
 
28
36
  const presetStates = useStorage<Map<string, { state: Record<string, unknown>, label: string }>>(
29
37
  `_poveste-presets/${saveId.value}/states`,
30
38
  new Map(),
39
+ undefined,
40
+ { writeDefaults: false },
31
41
  )
32
42
 
33
43
  const presetsOptions = computed(() => {
@@ -176,6 +186,7 @@ onClickOutside(select, stopEditing)
176
186
  </div>
177
187
  <Icon
178
188
  v-tooltip="savedNotif ? 'Saved!' : canEdit ? 'Save to preset' : null"
189
+ data-testid="preset-save"
179
190
  :icon="savedNotif ? 'carbon:checkmark' : 'carbon:save'"
180
191
  class="cursor-pointer w-4 h-4 hover:text-primary-500 dark:hover:text-primary-400 text-gray-900 dark:text-gray-100"
181
192
  :class="[
@@ -185,6 +196,7 @@ onClickOutside(select, stopEditing)
185
196
  />
186
197
  <Icon
187
198
  v-tooltip="'Create new preset'"
199
+ data-testid="preset-create"
188
200
  icon="carbon:add-alt"
189
201
  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"
190
202
  @click="createPreset()"
@@ -12,6 +12,8 @@ const hasEvents = computed(() => eventsStore.events.length)
12
12
  onMounted(resetUnseen)
13
13
  watch(() => eventsStore.unseen, resetUnseen)
14
14
 
15
+ const eventsElement = ref<HTMLDivElement>()
16
+
15
17
  async function resetUnseen() {
16
18
  if (eventsStore.unseen > 0) {
17
19
  eventsStore.unseen = 0
@@ -19,8 +21,6 @@ async function resetUnseen() {
19
21
  await nextTick()
20
22
  eventsElement.value.scrollTo({ top: eventsElement.value.scrollHeight })
21
23
  }
22
-
23
- const eventsElement = ref<HTMLDivElement>()
24
24
  </script>
25
25
 
26
26
  <template>
@@ -30,6 +30,11 @@ const highlighter = shallowRef<Highlighter>()
30
30
  const dynamicSourceCode = ref('')
31
31
  const error = ref<string>(null)
32
32
 
33
+ // Above the watch that assigns it: the callback is async but runs immediately,
34
+ // and a story whose `slots()` throws reaches the auto-switch below before its
35
+ // first `await` — with the declaration underneath, that path hits the TDZ.
36
+ const displayedSource = ref<'dynamic' | 'static'>('dynamic')
37
+
33
38
  watch(() => [props.variant, generateSourceCodeFn.value], async () => {
34
39
  if (!generateSourceCodeFn.value) return
35
40
  error.value = null
@@ -75,8 +80,6 @@ watch(() => [props.story, props.story?.file?.source], async () => {
75
80
  immediate: true,
76
81
  })
77
82
 
78
- const displayedSource = ref<'dynamic' | 'static'>('dynamic')
79
-
80
83
  const displayedSourceCode = computed(() => {
81
84
  if (displayedSource.value === 'dynamic') {
82
85
  return dynamicSourceCode.value
@@ -39,6 +39,8 @@ function action() {
39
39
  <template>
40
40
  <div
41
41
  ref="el"
42
+ role="option"
43
+ :aria-selected="selected"
42
44
  class="poveste-search-item"
43
45
  data-testid="search-item"
44
46
  :data-selected="selected ? '' : undefined"
@@ -279,6 +279,23 @@ const {
279
279
  selectPrevious,
280
280
  } = useSelection(results)
281
281
 
282
+ // Announced to a screen reader on every change: the count when a search returns
283
+ // something, and which result is focused as the arrows move. Both are silent
284
+ // otherwise — arrowing only changes styling.
285
+ const resultsAnnouncement = computed(() => {
286
+ if (!rateLimitedSearch.value) {
287
+ return ''
288
+ }
289
+ if (!results.value.length) {
290
+ return 'No results'
291
+ }
292
+
293
+ const count = `${results.value.length} result${results.value.length === 1 ? '' : 's'}`
294
+ const current = results.value[selectedIndex.value]
295
+
296
+ return current ? `${count}. ${current.title}, ${selectedIndex.value + 1} of ${results.value.length}` : count
297
+ })
298
+
282
299
  // Activation
283
300
 
284
301
  const router = useRouter()
@@ -321,6 +338,13 @@ onKeyboardShortcut(['enter'], () => {
321
338
  <input
322
339
  ref="input"
323
340
  v-model="searchInputText"
341
+ type="search"
342
+ role="combobox"
343
+ aria-label="Search for stories and variants"
344
+ aria-autocomplete="list"
345
+ :aria-expanded="results.length > 0"
346
+ :aria-controls="results.length ? 'poveste-search-results' : undefined"
347
+ :aria-activedescendant="results.length ? `poveste-search-option-${selectedIndex}` : undefined"
324
348
  placeholder="Search for stories, variants..."
325
349
  class="bg-transparent w-full flex-1 pl-0 pr-6 py-4 outline-none"
326
350
  @keydown.down.prevent="selectNext()"
@@ -338,14 +362,32 @@ onKeyboardShortcut(['enter'], () => {
338
362
 
339
363
  <div
340
364
  v-else-if="results.length"
365
+ id="poveste-search-results"
366
+ role="listbox"
367
+ aria-label="Search results"
341
368
  class="max-h-[400px] overflow-y-auto rounded-b-lg"
342
369
  >
343
370
  <SearchItem
344
371
  v-for="(result, index) of results"
372
+ :id="`poveste-search-option-${index}`"
345
373
  :key="result.id"
346
374
  :result="result"
347
375
  :selected="index === selectedIndex"
348
376
  @close="close()"
349
377
  />
350
378
  </div>
379
+
380
+ <!--
381
+ Arrowing through results was a purely visual state change: `selectedIndex`
382
+ drove styling and nothing else, so a screen reader announced nothing on
383
+ typing, nothing on arrowing, and nothing on selecting (#311). The count is
384
+ what tells you the search did anything at all.
385
+ -->
386
+ <div
387
+ class="sr-only"
388
+ aria-live="polite"
389
+ role="status"
390
+ >
391
+ {{ resultsAnnouncement }}
392
+ </div>
351
393
  </template>
@@ -41,6 +41,8 @@ function addWindowListener(event: string, listener: (event: any) => unknown) {
41
41
  }
42
42
  }
43
43
 
44
+ const previewWrapper = ref<HTMLDivElement>()
45
+
44
46
  function useDragger(el: Ref<HTMLDivElement>, value: Ref<number>, min: number, max: number, axis: 'x' | 'y') {
45
47
  function onMouseDown(event: MouseEvent) {
46
48
  event.preventDefault()
@@ -109,7 +111,6 @@ const responsiveHeight = computed({
109
111
  const horizontalDragger = ref<HTMLDivElement>()
110
112
  const verticalDragger = ref<HTMLDivElement>()
111
113
  const cornerDragger = ref<HTMLDivElement>()
112
- const previewWrapper = ref<HTMLDivElement>()
113
114
 
114
115
  useDragger(horizontalDragger, responsiveWidth, 32, 20000, 'x')
115
116
  useDragger(verticalDragger, responsiveHeight, 32, 20000, 'y')
@@ -322,6 +322,7 @@ function onIframeLoad() {
322
322
  }"
323
323
  :style="previewStyle(isResponsiveEnabled, finalWidth, finalHeight)"
324
324
  data-testid="preview-iframe"
325
+ :title="`Preview of ${story.title} — ${variant.title}`"
325
326
  @load="onIframeLoad()"
326
327
  />
327
328
 
@@ -12,6 +12,7 @@ defineProps<{
12
12
  <template>
13
13
  <TopBarChipItem
14
14
  v-tooltip="tooltip"
15
+ aria-label="Open story in editor"
15
16
  target="_blank"
16
17
  class="poveste-toolbar-open-in-editor"
17
18
  @click="openInEditor(file)"
@@ -20,6 +20,7 @@ const sandboxUrl = computed(() => {
20
20
  v-tooltip="'Open variant in new tab'"
21
21
  :href="sandboxUrl"
22
22
  target="_blank"
23
+ aria-label="Open variant in new tab"
23
24
  class="poveste-toolbar-new-tab"
24
25
  >
25
26
  <Icon
@@ -9,6 +9,7 @@ const settings = usePreviewSettingsStore().currentSettings
9
9
  <template>
10
10
  <TopBarChipItem
11
11
  v-tooltip="`Switch to text direction ${settings.textDirection === 'ltr' ? 'Right to Left' : 'Left to Right'}`"
12
+ aria-label="Toggle text direction"
12
13
  class="poveste-toolbar-text-direction"
13
14
  @click="settings.textDirection = settings.textDirection === 'ltr' ? 'rtl' : 'ltr'"
14
15
  >
@@ -69,6 +69,8 @@ useScrollOnActive(isActive, el)
69
69
  <span
70
70
  v-if="!story.docsOnly"
71
71
  class="opacity-40 text-sm"
72
+ :aria-label="`${story.variants.length} ${story.variants.length === 1 ? 'variant' : 'variants'}`"
73
+ role="note"
72
74
  >
73
75
  {{ story.variants.length }}
74
76
  </span>
@@ -14,6 +14,7 @@ import { mapFile } from './util/mapping'
14
14
  import { applyPreviewSettings, loadStoredPreviewSettings, receivedSettings } from './util/preview-settings.js'
15
15
  import { createStateBridge } from './util/state-bridge.js'
16
16
  import { toRawDeep } from './util/state.js'
17
+ import { isBenignResizeObserverEvent } from './util/uncaught.js'
17
18
 
18
19
  const query = parseQuery(window.location.search)
19
20
 
@@ -65,7 +66,11 @@ if (window.parent && window.parent !== window) {
65
66
  function reportUncaught(error: unknown) {
66
67
  reportStoryError(error, { storyId: storyId.value, variantId: variantId.value })
67
68
  }
68
- window.addEventListener('error', event => reportUncaught(event.error ?? event.message))
69
+ window.addEventListener('error', (event) => {
70
+ if (!isBenignResizeObserverEvent(event)) {
71
+ reportUncaught(event.error ?? event.message)
72
+ }
73
+ })
69
74
  window.addEventListener('unhandledrejection', event => reportUncaught(event.reason))
70
75
 
71
76
  // The story preview has its own color scheme, independent from the app chrome.
@@ -25,7 +25,6 @@
25
25
  its utility classes live in a package v4 never scans. */
26
26
  @source "../../../../poveste/src/node/markdown.ts";
27
27
 
28
- /* TODO(#36): port the v3 typography theme() customization to v4 CSS. */
29
28
  @plugin '@tailwindcss/typography';
30
29
 
31
30
  /* Chrome dark mode. Deliberately a distinct class from the story-side
@@ -181,7 +180,7 @@
181
180
  /*
182
181
  * Typography (@tailwindcss/typography) customization — ported from the v3
183
182
  * `theme.extend.typography` JS config to CSS. Unlayered so it overrides the
184
- * plugin's layered defaults. Needs a visual pass on the docs/markdown pages.
183
+ * plugin's layered defaults.
185
184
  */
186
185
  .prose {
187
186
  --tw-prose-invert-quote-borders: var(--color-gray-800);
@@ -207,7 +206,7 @@
207
206
  }
208
207
  }
209
208
 
210
- &:is(h1, h2, h3, h4, th) {
209
+ &:is(h1, h2, h3, h4) {
211
210
  margin-bottom: 0.75rem;
212
211
 
213
212
  &:not(:first-child) {
@@ -0,0 +1,19 @@
1
+ // Chrome ends the sentence with a period and Firefox does not, so these are
2
+ // prefixes rather than whole messages.
3
+ const RESIZE_OBSERVER_LOOP = [
4
+ 'ResizeObserver loop limit exceeded',
5
+ 'ResizeObserver loop completed with undelivered notifications',
6
+ ]
7
+
8
+ /**
9
+ * A ResizeObserver loop notification is the browser saying it deferred a resize
10
+ * callback to the next frame, not a component throwing. It reaches the `error`
11
+ * event with a null `error`, so reporting `event.error ?? event.message` blames
12
+ * the story that happens to be rendering for it (#468). Requiring the null keeps
13
+ * a genuine `throw` of the same text reportable.
14
+ */
15
+ export function isBenignResizeObserverEvent({ error, message }: { error?: unknown, message?: unknown }): boolean {
16
+ return error == null
17
+ && typeof message === 'string'
18
+ && RESIZE_OBSERVER_LOOP.some(known => message.startsWith(known))
19
+ }