@poveste/app 0.7.0 → 0.8.1

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.
@@ -1,9 +1,9 @@
1
1
  @scope (:root) to (.__poveste-render-story:not(.__poveste-render-custom-controls)) {
2
- .bind-tree-margin[data-v-0fa5d0aa] {
3
- margin-left: var(--v00f90f2e);
2
+ .bind-tree-margin[data-v-a603aebf] {
3
+ margin-left: var(--v18724f1f);
4
4
  }
5
- .bind-icon-color[data-v-0fa5d0aa] {
6
- color: var(--fbc82fb6);
5
+ .bind-icon-color[data-v-a603aebf] {
6
+ color: var(--v45b473db);
7
7
  }
8
8
  }
9
9
  @scope (:root) to (.__poveste-render-story:not(.__poveste-render-custom-controls)) {
@@ -129,8 +129,8 @@
129
129
  }
130
130
  }
131
131
  @scope (:root) to (.__poveste-render-story:not(.__poveste-render-custom-controls)) {
132
- .bind-icon-color[data-v-e5d28031] {
133
- color: var(--v97886b12);
132
+ .bind-icon-color[data-v-8112241f] {
133
+ color: var(--v29a7233a);
134
134
  }
135
135
  }
136
136
  @scope (:root) to (.__poveste-render-story:not(.__poveste-render-custom-controls)) {
@@ -2,6 +2,6 @@ import _plugin_vue_export_helper_default from "../../_virtual/_plugin-vue_export
2
2
  import StoryVariantListItem_vue_vue_type_script_setup_true_lang_default from "./StoryVariantListItem.vue_vue_type_script_setup_true_lang.js";
3
3
  /* empty css */
4
4
  //#region src/app/components/story/StoryVariantListItem.vue
5
- var StoryVariantListItem_default = /*#__PURE__*/ _plugin_vue_export_helper_default(StoryVariantListItem_vue_vue_type_script_setup_true_lang_default, [["__scopeId", "data-v-e5d28031"]]);
5
+ var StoryVariantListItem_default = /*#__PURE__*/ _plugin_vue_export_helper_default(StoryVariantListItem_vue_vue_type_script_setup_true_lang_default, [["__scopeId", "data-v-8112241f"]]);
6
6
  //#endregion
7
7
  export { StoryVariantListItem_default as default };
@@ -1,7 +1,9 @@
1
+ import { useStoryStore } from "../../stores/story.js";
2
+ import { useStoryErrorStore } from "../../stores/story-errors.js";
1
3
  import { useScrollOnActive } from "../../util/scroll.js";
2
4
  import BaseListItemLink_default from "../base/BaseListItemLink.js";
3
5
  import { useCurrentVariantRoute } from "../../util/variant.js";
4
- import { createElementBlock, createElementVNode, createVNode, defineComponent, normalizeClass, openBlock, ref, toDisplayString, toRefs, unref, useCssVars, withCtx } from "@poveste/vendors/vue";
6
+ import { computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createVNode, defineComponent, normalizeClass, openBlock, ref, toDisplayString, toRefs, unref, useCssVars, withCtx } from "@poveste/vendors/vue";
5
7
  import { Icon } from "@poveste/vendors/iconify";
6
8
  //#region src/app/components/story/StoryVariantListItem.vue?vue&type=script&setup=true&lang.ts
7
9
  var _hoisted_1 = { class: "truncate" };
@@ -12,11 +14,18 @@ var StoryVariantListItem_vue_vue_type_script_setup_true_lang_default = /*@__PURE
12
14
  required: true
13
15
  } },
14
16
  setup(__props) {
15
- useCssVars((_ctx) => ({ "v97886b12": unref(variant).iconColor }));
16
- const { variant } = toRefs(__props);
17
+ useCssVars((_ctx) => ({ "v29a7233a": unref(variant).iconColor }));
18
+ const props = __props;
19
+ const { variant } = toRefs(props);
17
20
  const { isActive, targetRoute } = useCurrentVariantRoute(variant);
18
21
  const el = ref();
19
22
  useScrollOnActive(isActive, el);
23
+ const storyStore = useStoryStore();
24
+ const errorStore = useStoryErrorStore();
25
+ const error = computed(() => {
26
+ const storyId = storyStore.currentStory?.id;
27
+ return storyId ? errorStore.forVariant(storyId, props.variant.id) : void 0;
28
+ });
20
29
  return (_ctx, _cache) => {
21
30
  return openBlock(), createElementBlock("div", {
22
31
  ref_key: "el",
@@ -28,13 +37,23 @@ var StoryVariantListItem_vue_vue_type_script_setup_true_lang_default = /*@__PURE
28
37
  "is-active": unref(isActive),
29
38
  class: "px-2 py-2 md:py-1.5 m-1 rounded-sm flex items-center gap-2"
30
39
  }, {
31
- default: withCtx(({ active }) => [createVNode(unref(Icon), {
32
- icon: unref(variant).icon ?? "carbon:cube",
33
- class: normalizeClass(["w-5 h-5 sm:w-4 sm:h-4 flex-none", {
34
- "text-gray-500": !active && !unref(variant).iconColor,
35
- "bind-icon-color": !active && unref(variant).iconColor
36
- }])
37
- }, null, 8, ["icon", "class"]), createElementVNode("span", _hoisted_1, toDisplayString(unref(variant).title), 1)]),
40
+ default: withCtx(({ active }) => [
41
+ createVNode(unref(Icon), {
42
+ icon: unref(variant).icon ?? "carbon:cube",
43
+ class: normalizeClass(["w-5 h-5 sm:w-4 sm:h-4 flex-none", {
44
+ "text-gray-500": !active && !unref(variant).iconColor,
45
+ "bind-icon-color": !active && unref(variant).iconColor
46
+ }])
47
+ }, null, 8, ["icon", "class"]),
48
+ createElementVNode("span", _hoisted_1, toDisplayString(unref(variant).title), 1),
49
+ error.value ? (openBlock(), createBlock(unref(Icon), {
50
+ key: 0,
51
+ icon: "carbon:warning-alt",
52
+ class: "w-4 h-4 flex-none ml-auto text-red-500",
53
+ "data-testid": "variant-error-marker",
54
+ "aria-label": `Threw while rendering: ${error.value.message}`
55
+ }, null, 8, ["aria-label"])) : createCommentVNode("", true)
56
+ ]),
38
57
  _: 1
39
58
  }, 8, ["to", "is-active"])], 512);
40
59
  };
@@ -1,6 +1,7 @@
1
1
  import { EVENT_SEND, PREVIEW_SETTINGS_REQUEST, PREVIEW_SETTINGS_SYNC, SANDBOX_HEIGHT, SANDBOX_READY, SANDBOX_RETARGET, STATE_SYNC } from "../../util/const.js";
2
2
  import { trackWindow } from "../../util/keyboard.js";
3
3
  import { useStoryStore } from "../../stores/story.js";
4
+ import { useStoryErrorStore } from "../../stores/story-errors.js";
4
5
  import { usePreviewSettingsStore } from "../../stores/preview-settings.js";
5
6
  import { getSandboxUrl } from "../../util/sandbox.js";
6
7
  import { createStateBridge } from "../../util/state-bridge.js";
@@ -9,9 +10,25 @@ import { useEventsStore } from "../../stores/events.js";
9
10
  import { firstReportedHeight } from "../../util/grid-cell-height.js";
10
11
  import StoryResponsivePreview_default from "./StoryResponsivePreview.js";
11
12
  import { useEventListener } from "@poveste/vendors/vue-use";
12
- import { computed, createBlock, createElementVNode, defineComponent, normalizeClass, normalizeStyle, onBeforeUnmount, openBlock, ref, toRaw, watch, withCtx } from "@poveste/vendors/vue";
13
+ import { computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createVNode, defineComponent, normalizeClass, normalizeStyle, onBeforeUnmount, openBlock, ref, toDisplayString, toRaw, unref, watch, withCtx } from "@poveste/vendors/vue";
14
+ import { Icon } from "@poveste/vendors/iconify";
15
+ import { SANDBOX_ERROR } from "@poveste/shared";
13
16
  //#region src/app/components/story/StoryVariantSinglePreviewRemote.vue?vue&type=script&setup=true&lang.ts
14
17
  var _hoisted_1 = ["src"];
18
+ var _hoisted_2 = {
19
+ key: 0,
20
+ class: "poveste-story-error absolute inset-0 overflow-auto p-6 bg-white dark:bg-gray-900",
21
+ "data-testid": "story-error"
22
+ };
23
+ var _hoisted_3 = { class: "flex items-center gap-2 text-red-600 dark:text-red-400 font-medium" };
24
+ var _hoisted_4 = {
25
+ class: "mt-2 font-mono text-sm text-red-700 dark:text-red-300",
26
+ "data-testid": "story-error-message"
27
+ };
28
+ var _hoisted_5 = {
29
+ key: 0,
30
+ class: "mt-4 text-xs whitespace-pre-wrap text-gray-600 dark:text-gray-400"
31
+ };
15
32
  var MIN_HEIGHT = 40;
16
33
  var MAX_AUTO_HEIGHT = 400;
17
34
  var REUSE_LIMIT = 30;
@@ -30,6 +47,11 @@ var StoryVariantSinglePreviewRemote_vue_vue_type_script_setup_true_lang_default
30
47
  setup(__props) {
31
48
  const props = __props;
32
49
  const settings = usePreviewSettingsStore().currentSettings;
50
+ const errorStore = useStoryErrorStore();
51
+ const storyError = computed(() => errorStore.forVariant(props.story.id, props.variant.id));
52
+ watch(() => [props.story.id, props.variant.id], () => {
53
+ errorStore.clear(props.story.id, props.variant.id);
54
+ });
33
55
  const iframe = ref();
34
56
  function postState(changes) {
35
57
  iframe.value?.contentWindow?.postMessage({
@@ -67,6 +89,15 @@ var StoryVariantSinglePreviewRemote_vue_vue_type_script_setup_true_lang_default
67
89
  if (!fromCurrentOccupant(event.data)) break;
68
90
  setPreviewReady();
69
91
  break;
92
+ case SANDBOX_ERROR:
93
+ if (!fromCurrentOccupant(event.data)) break;
94
+ errorStore.report({
95
+ message: event.data.message,
96
+ stack: event.data.stack,
97
+ storyId: props.story.id,
98
+ variantId: props.variant.id
99
+ });
100
+ break;
70
101
  case PREVIEW_SETTINGS_REQUEST:
71
102
  syncSettings();
72
103
  break;
@@ -192,7 +223,14 @@ var StoryVariantSinglePreviewRemote_vue_vue_type_script_setup_true_lang_default
192
223
  style: normalizeStyle(previewStyle(isResponsiveEnabled, finalWidth, finalHeight)),
193
224
  "data-testid": "preview-iframe",
194
225
  onLoad: _cache[0] || (_cache[0] = ($event) => onIframeLoad())
195
- }, null, 46, _hoisted_1)]),
226
+ }, null, 46, _hoisted_1), storyError.value ? (openBlock(), createElementBlock("div", _hoisted_2, [
227
+ createElementVNode("div", _hoisted_3, [createVNode(unref(Icon), {
228
+ icon: "carbon:warning-alt",
229
+ class: "w-5 h-5 flex-none"
230
+ }), _cache[1] || (_cache[1] = createElementVNode("span", null, "This component threw while rendering", -1))]),
231
+ createElementVNode("p", _hoisted_4, toDisplayString(storyError.value.message), 1),
232
+ storyError.value.stack ? (openBlock(), createElementBlock("pre", _hoisted_5, toDisplayString(storyError.value.stack), 1)) : createCommentVNode("", true)
233
+ ])) : createCommentVNode("", true)]),
196
234
  _: 1
197
235
  }, 8, ["variant", "responsive"]);
198
236
  };
@@ -2,6 +2,6 @@ import _plugin_vue_export_helper_default from "../../_virtual/_plugin-vue_export
2
2
  import StoryListItem_vue_vue_type_script_setup_true_lang_default from "./StoryListItem.vue_vue_type_script_setup_true_lang.js";
3
3
  /* empty css */
4
4
  //#region src/app/components/tree/StoryListItem.vue
5
- var StoryListItem_default = /*#__PURE__*/ _plugin_vue_export_helper_default(StoryListItem_vue_vue_type_script_setup_true_lang_default, [["__scopeId", "data-v-0fa5d0aa"]]);
5
+ var StoryListItem_default = /*#__PURE__*/ _plugin_vue_export_helper_default(StoryListItem_vue_vue_type_script_setup_true_lang_default, [["__scopeId", "data-v-a603aebf"]]);
6
6
  //#endregion
7
7
  export { StoryListItem_default as default };
@@ -1,13 +1,14 @@
1
+ import { useStoryErrorStore } from "../../stores/story-errors.js";
1
2
  import { useScrollOnActive } from "../../util/scroll.js";
2
3
  import BaseListItemLink_default from "../base/BaseListItemLink.js";
3
- import { computed, createCommentVNode, createElementBlock, createElementVNode, createVNode, defineComponent, normalizeClass, openBlock, ref, toDisplayString, unref, useCssVars, withCtx } from "@poveste/vendors/vue";
4
+ import { computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createVNode, defineComponent, normalizeClass, openBlock, ref, toDisplayString, unref, useCssVars, withCtx } from "@poveste/vendors/vue";
4
5
  import { Icon } from "@poveste/vendors/iconify";
5
6
  import { useRoute } from "@poveste/vendors/vue-router";
6
7
  //#region src/app/components/tree/StoryListItem.vue?vue&type=script&setup=true&lang.ts
7
8
  var _hoisted_1 = { class: "bind-tree-margin flex items-center gap-2 pl-4 min-w-0" };
8
9
  var _hoisted_2 = { class: "truncate" };
9
10
  var _hoisted_3 = {
10
- key: 0,
11
+ key: 1,
11
12
  class: "opacity-40 text-sm"
12
13
  };
13
14
  var StoryListItem_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ defineComponent({
@@ -18,10 +19,12 @@ var StoryListItem_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ de
18
19
  },
19
20
  setup(__props) {
20
21
  useCssVars((_ctx) => ({
21
- "v00f90f2e": filePadding.value,
22
- "fbc82fb6": _ctx.story.iconColor
22
+ "v18724f1f": filePadding.value,
23
+ "v45b473db": _ctx.story.iconColor
23
24
  }));
24
25
  const props = __props;
26
+ const errorStore = useStoryErrorStore();
27
+ const hasError = computed(() => errorStore.storyHasError(props.story.id));
25
28
  const filePadding = computed(() => {
26
29
  return `${props.depth * 12}px`;
27
30
  });
@@ -42,13 +45,23 @@ var StoryListItem_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ de
42
45
  },
43
46
  class: "pl-0.5 pr-2 py-2 md:py-1.5 mx-1 rounded-sm"
44
47
  }, {
45
- default: withCtx(({ active }) => [createElementVNode("span", _hoisted_1, [createVNode(unref(Icon), {
46
- icon: __props.story.icon ?? "carbon:cube",
47
- class: normalizeClass(["w-5 h-5 sm:w-4 sm:h-4 flex-none", {
48
- "text-primary-500": !active && !__props.story.iconColor,
49
- "bind-icon-color": !active && __props.story.iconColor
50
- }])
51
- }, null, 8, ["icon", "class"]), createElementVNode("span", _hoisted_2, toDisplayString(__props.story.title), 1)]), !__props.story.docsOnly ? (openBlock(), createElementBlock("span", _hoisted_3, toDisplayString(__props.story.variants.length), 1)) : createCommentVNode("", true)]),
48
+ default: withCtx(({ active }) => [
49
+ createElementVNode("span", _hoisted_1, [createVNode(unref(Icon), {
50
+ icon: __props.story.icon ?? "carbon:cube",
51
+ class: normalizeClass(["w-5 h-5 sm:w-4 sm:h-4 flex-none", {
52
+ "text-primary-500": !active && !__props.story.iconColor,
53
+ "bind-icon-color": !active && __props.story.iconColor
54
+ }])
55
+ }, null, 8, ["icon", "class"]), createElementVNode("span", _hoisted_2, toDisplayString(__props.story.title), 1)]),
56
+ hasError.value ? (openBlock(), createBlock(unref(Icon), {
57
+ key: 0,
58
+ icon: "carbon:warning-alt",
59
+ class: "w-4 h-4 flex-none ml-auto text-red-500",
60
+ "data-testid": "story-error-marker",
61
+ "aria-label": "A variant of this story threw while rendering"
62
+ })) : createCommentVNode("", true),
63
+ !__props.story.docsOnly ? (openBlock(), createElementBlock("span", _hoisted_3, toDisplayString(__props.story.variants.length), 1)) : createCommentVNode("", true)
64
+ ]),
52
65
  _: 1
53
66
  }, 8, ["to"])], 512);
54
67
  };
@@ -12,6 +12,7 @@ import { computed, createApp, h, onMounted, ref, watch } from "@poveste/vendors/
12
12
  import { createPinia } from "@poveste/vendors/pinia";
13
13
  import { files, onUpdate } from "virtual:$poveste-stories";
14
14
  import { parseQuery } from "@poveste/vendors/vue-router";
15
+ import { reportStoryError } from "@poveste/shared";
15
16
  //#region src/app/sandbox.ts
16
17
  var query = parseQuery(window.location.search);
17
18
  var storyId = ref(typeof query.storyId === "string" ? query.storyId : null);
@@ -29,6 +30,14 @@ onUpdate((newFiles) => {
29
30
  var storedSettings = loadStoredPreviewSettings();
30
31
  if (storedSettings) applyPreviewSettings(storedSettings);
31
32
  if (window.parent && window.parent !== window) window.parent.postMessage({ type: PREVIEW_SETTINGS_REQUEST }, window.location.origin);
33
+ function reportUncaught(error) {
34
+ reportStoryError(error, {
35
+ storyId: storyId.value,
36
+ variantId: variantId.value
37
+ });
38
+ }
39
+ window.addEventListener("error", (event) => reportUncaught(event.error ?? event.message));
40
+ window.addEventListener("unhandledrejection", (event) => reportUncaught(event.reason));
32
41
  var prefersDark = usePreferredDark();
33
42
  var previewDark = computed(() => resolvePreviewDark(receivedSettings.colorScheme, prefersDark.value, isDark.value));
34
43
  var darkClassTargets = [document.documentElement, document.querySelector("#app")];
@@ -0,0 +1,26 @@
1
+ import { computed, ref } from "@poveste/vendors/vue";
2
+ import { defineStore } from "@poveste/vendors/pinia";
3
+ //#region src/app/stores/story-errors.ts
4
+ var useStoryErrorStore = defineStore("story-errors", () => {
5
+ const errors = ref(/* @__PURE__ */ new Map());
6
+ const key = (storyId, variantId) => `${storyId}::${variantId ?? ""}`;
7
+ function report(error) {
8
+ if (!error.storyId) return;
9
+ errors.value.set(key(error.storyId, error.variantId), error);
10
+ }
11
+ function clear(storyId, variantId) {
12
+ errors.value.delete(key(storyId, variantId));
13
+ }
14
+ return {
15
+ errors,
16
+ report,
17
+ clear,
18
+ forVariant: computed(() => (storyId, variantId) => errors.value.get(key(storyId, variantId))),
19
+ storyHasError: computed(() => (storyId) => {
20
+ for (const [entry] of errors.value) if (entry.startsWith(`${storyId}::`)) return true;
21
+ return false;
22
+ })
23
+ };
24
+ });
25
+ //#endregion
26
+ export { useStoryErrorStore };
package/dist/style.css CHANGED
@@ -335,7 +335,11 @@
335
335
  :scope, :host {
336
336
  --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
337
337
  monospace;
338
+ --color-red-300: oklch(80.8% 0.114 19.571);
339
+ --color-red-400: oklch(70.4% 0.191 22.216);
338
340
  --color-red-500: oklch(63.7% 0.237 25.331);
341
+ --color-red-600: oklch(57.7% 0.245 27.325);
342
+ --color-red-700: oklch(50.5% 0.213 27.518);
339
343
  --color-orange-500: oklch(70.5% 0.213 47.604);
340
344
  --color-emerald-50: oklch(97.9% 0.021 166.113);
341
345
  --color-emerald-100: oklch(95% 0.052 163.051);
@@ -990,6 +994,9 @@
990
994
  .mt-2 {
991
995
  margin-top: calc(var(--spacing) * 2);
992
996
  }
997
+ .mt-4 {
998
+ margin-top: calc(var(--spacing) * 4);
999
+ }
993
1000
  .mr-2 {
994
1001
  margin-right: calc(var(--spacing) * 2);
995
1002
  }
@@ -1669,6 +1676,9 @@
1669
1676
  .p-4 {
1670
1677
  padding: calc(var(--spacing) * 4);
1671
1678
  }
1679
+ .p-6 {
1680
+ padding: calc(var(--spacing) * 6);
1681
+ }
1672
1682
  .p-8 {
1673
1683
  padding: calc(var(--spacing) * 8);
1674
1684
  }
@@ -1812,6 +1822,9 @@
1812
1822
  .whitespace-nowrap {
1813
1823
  white-space: nowrap;
1814
1824
  }
1825
+ .whitespace-pre-wrap {
1826
+ white-space: pre-wrap;
1827
+ }
1815
1828
  .text-black\/\[1\%\] {
1816
1829
  color: color-mix(in srgb, #000 1%, transparent);
1817
1830
  @supports (color: color-mix(in lab, red, red)) {
@@ -1830,6 +1843,9 @@
1830
1843
  color: color-mix(in oklab, var(--color-gray-500) 20%, transparent);
1831
1844
  }
1832
1845
  }
1846
+ .text-gray-600 {
1847
+ color: var(--color-gray-600);
1848
+ }
1833
1849
  .text-gray-700 {
1834
1850
  color: var(--color-gray-700);
1835
1851
  }
@@ -1854,6 +1870,12 @@
1854
1870
  .text-red-500 {
1855
1871
  color: var(--color-red-500);
1856
1872
  }
1873
+ .text-red-600 {
1874
+ color: var(--color-red-600);
1875
+ }
1876
+ .text-red-700 {
1877
+ color: var(--color-red-700);
1878
+ }
1857
1879
  .text-white {
1858
1880
  color: var(--color-white);
1859
1881
  }
@@ -2307,6 +2329,12 @@
2307
2329
  .dark\:text-primary-400:where(.ptw-dark, .ptw-dark *) {
2308
2330
  color: var(--color-primary-400);
2309
2331
  }
2332
+ .dark\:text-red-300:where(.ptw-dark, .ptw-dark *) {
2333
+ color: var(--color-red-300);
2334
+ }
2335
+ .dark\:text-red-400:where(.ptw-dark, .ptw-dark *) {
2336
+ color: var(--color-red-400);
2337
+ }
2310
2338
  .dark\:text-white\/\[1\%\]:where(.ptw-dark, .ptw-dark *) {
2311
2339
  color: color-mix(in srgb, #fff 1%, transparent);
2312
2340
  @supports (color: color-mix(in lab, red, red)) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@poveste/app",
3
3
  "type": "module",
4
- "version": "0.7.0",
4
+ "version": "0.8.1",
5
5
  "description": "Pre-bundled UI for Poveste",
6
6
  "author": {
7
7
  "name": "Sorin Gitlan"
@@ -40,9 +40,9 @@
40
40
  "@fontsource/noto-sans-display": "^5.3.0",
41
41
  "fuse.js": "^7.0.0",
42
42
  "shiki": "^3.0.0",
43
- "@poveste/controls": "^0.7.0",
44
- "@poveste/vendors": "^0.7.0",
45
- "@poveste/shared": "^0.7.0"
43
+ "@poveste/controls": "^0.8.1",
44
+ "@poveste/shared": "^0.8.1",
45
+ "@poveste/vendors": "^0.8.1"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@iconify-json/carbon": "^1.2.25",
@@ -2,7 +2,9 @@
2
2
  import type { PropType } from 'vue'
3
3
  import type { Variant } from '../../types'
4
4
  import { Icon } from '@iconify/vue'
5
- import { ref, toRefs } from 'vue'
5
+ import { computed, ref, toRefs } from 'vue'
6
+ import { useStoryStore } from '../../stores/story'
7
+ import { useStoryErrorStore } from '../../stores/story-errors'
6
8
  import { useScrollOnActive } from '../../util/scroll'
7
9
  import { useCurrentVariantRoute } from '../../util/variant'
8
10
  import BaseListItemLink from '../base/BaseListItemLink.vue'
@@ -18,6 +20,14 @@ const { variant } = toRefs(props)
18
20
  const { isActive, targetRoute } = useCurrentVariantRoute(variant)
19
21
  const el = ref<HTMLDivElement>()
20
22
  useScrollOnActive(isActive, el)
23
+
24
+ // This list only ever shows the current story's variants.
25
+ const storyStore = useStoryStore()
26
+ const errorStore = useStoryErrorStore()
27
+ const error = computed(() => {
28
+ const storyId = storyStore.currentStory?.id
29
+ return storyId ? errorStore.forVariant(storyId, props.variant.id) : undefined
30
+ })
21
31
  </script>
22
32
 
23
33
  <template>
@@ -41,6 +51,13 @@ useScrollOnActive(isActive, el)
41
51
  }"
42
52
  />
43
53
  <span class="truncate">{{ variant.title }}</span>
54
+ <Icon
55
+ v-if="error"
56
+ icon="carbon:warning-alt"
57
+ class="w-4 h-4 flex-none ml-auto text-red-500"
58
+ data-testid="variant-error-marker"
59
+ :aria-label="`Threw while rendering: ${error.message}`"
60
+ />
44
61
  </BaseListItemLink>
45
62
  </div>
46
63
  </template>
@@ -1,11 +1,14 @@
1
1
  <script lang="ts" setup>
2
2
  import type { HstEvent } from '../../stores/events'
3
3
  import type { Story, Variant } from '../../types'
4
+ import { Icon } from '@iconify/vue'
5
+ import { SANDBOX_ERROR } from '@poveste/shared'
4
6
  import { useEventListener } from '@vueuse/core'
5
7
  import { computed, onBeforeUnmount, ref, toRaw, watch } from 'vue'
6
8
  import { useEventsStore } from '../../stores/events'
7
9
  import { usePreviewSettingsStore } from '../../stores/preview-settings'
8
10
  import { useStoryStore } from '../../stores/story'
11
+ import { useStoryErrorStore } from '../../stores/story-errors'
9
12
  import { EVENT_SEND, PREVIEW_SETTINGS_REQUEST, PREVIEW_SETTINGS_SYNC, SANDBOX_HEIGHT, SANDBOX_READY, SANDBOX_RETARGET, STATE_SYNC } from '../../util/const'
10
13
  import { firstReportedHeight } from '../../util/grid-cell-height'
11
14
  import { trackWindow } from '../../util/keyboard'
@@ -24,6 +27,15 @@ const props = withDefaults(defineProps<{
24
27
  })
25
28
 
26
29
  const settings = usePreviewSettingsStore().currentSettings
30
+ const errorStore = useStoryErrorStore()
31
+
32
+ const storyError = computed(() => errorStore.forVariant(props.story.id, props.variant.id))
33
+
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
+ })
27
39
 
28
40
  // Iframe
29
41
 
@@ -104,6 +116,15 @@ useEventListener(window, 'message', (event) => {
104
116
  if (!fromCurrentOccupant(event.data)) break
105
117
  setPreviewReady()
106
118
  break
119
+ case SANDBOX_ERROR:
120
+ if (!fromCurrentOccupant(event.data)) break
121
+ errorStore.report({
122
+ message: event.data.message,
123
+ stack: event.data.stack,
124
+ storyId: props.story.id,
125
+ variantId: props.variant.id,
126
+ })
127
+ break
107
128
  case PREVIEW_SETTINGS_REQUEST:
108
129
  syncSettings()
109
130
  break
@@ -303,5 +324,28 @@ function onIframeLoad() {
303
324
  data-testid="preview-iframe"
304
325
  @load="onIframeLoad()"
305
326
  />
327
+
328
+ <!--
329
+ Covers the preview rather than sitting beside it: a component that threw
330
+ has rendered something, but nothing about it can be trusted — the template
331
+ below is exactly what makes a broken story look like a working one (#323).
332
+ -->
333
+ <div
334
+ v-if="storyError"
335
+ class="poveste-story-error absolute inset-0 overflow-auto p-6 bg-white dark:bg-gray-900"
336
+ data-testid="story-error"
337
+ >
338
+ <div class="flex items-center gap-2 text-red-600 dark:text-red-400 font-medium">
339
+ <Icon icon="carbon:warning-alt" class="w-5 h-5 flex-none" />
340
+ <span>This component threw while rendering</span>
341
+ </div>
342
+ <p class="mt-2 font-mono text-sm text-red-700 dark:text-red-300" data-testid="story-error-message">
343
+ {{ storyError.message }}
344
+ </p>
345
+ <pre
346
+ v-if="storyError.stack"
347
+ class="mt-4 text-xs whitespace-pre-wrap text-gray-600 dark:text-gray-400"
348
+ >{{ storyError.stack }}</pre>
349
+ </div>
306
350
  </StoryResponsivePreview>
307
351
  </template>
@@ -3,6 +3,7 @@ import type { Story } from '../../types'
3
3
  import { Icon } from '@iconify/vue'
4
4
  import { computed, ref } from 'vue'
5
5
  import { useRoute } from 'vue-router'
6
+ import { useStoryErrorStore } from '../../stores/story-errors'
6
7
  import { useScrollOnActive } from '../../util/scroll'
7
8
  import BaseListItemLink from '../base/BaseListItemLink.vue'
8
9
 
@@ -13,6 +14,12 @@ const props = withDefaults(defineProps<{
13
14
  depth: 0,
14
15
  })
15
16
 
17
+ // Only ever means "a variant of this story was seen to throw". A variant that
18
+ // has not rendered cannot have reported, so no marker is not a clean bill of
19
+ // health — see the store, and the open half of #323.
20
+ const errorStore = useStoryErrorStore()
21
+ const hasError = computed(() => errorStore.storyHasError(props.story.id))
22
+
16
23
  const filePadding = computed(() => {
17
24
  return `${props.depth * 12}px`
18
25
  })
@@ -51,6 +58,14 @@ useScrollOnActive(isActive, el)
51
58
  <span class="truncate">{{ story.title }}</span>
52
59
  </span>
53
60
 
61
+ <Icon
62
+ v-if="hasError"
63
+ icon="carbon:warning-alt"
64
+ class="w-4 h-4 flex-none ml-auto text-red-500"
65
+ data-testid="story-error-marker"
66
+ aria-label="A variant of this story threw while rendering"
67
+ />
68
+
54
69
  <span
55
70
  v-if="!story.docsOnly"
56
71
  class="opacity-40 text-sm"
@@ -1,4 +1,5 @@
1
1
  import type { StoryFile } from './types'
2
+ import { reportStoryError } from '@poveste/shared'
2
3
  import { usePreferredDark } from '@vueuse/core'
3
4
  import { createPinia } from 'pinia'
4
5
  import { files, onUpdate } from 'virtual:$poveste-stories'
@@ -56,6 +57,17 @@ if (window.parent && window.parent !== window) {
56
57
  window.parent.postMessage({ type: PREVIEW_SETTINGS_REQUEST }, window.location.origin)
57
58
  }
58
59
 
60
+ // The framework hooks catch what the framework swallows; this catches what
61
+ // escapes it — a throw from an event handler, a rejected promise, an async
62
+ // effect. Neither alone is enough: Vue never lets a render error reach the
63
+ // window, and no framework hook sees an error raised outside its own call
64
+ // stack (#323).
65
+ function reportUncaught(error: unknown) {
66
+ reportStoryError(error, { storyId: storyId.value, variantId: variantId.value })
67
+ }
68
+ window.addEventListener('error', event => reportUncaught(event.error ?? event.message))
69
+ window.addEventListener('unhandledrejection', event => reportUncaught(event.reason))
70
+
59
71
  // The story preview has its own color scheme, independent from the app chrome.
60
72
  // The fallback covers settings stored before the option existed.
61
73
  const prefersDark = usePreferredDark()
@@ -0,0 +1,48 @@
1
+ import type { StoryError } from '@poveste/shared'
2
+ import { defineStore } from 'pinia'
3
+ import { computed, ref } from 'vue'
4
+
5
+ // Errors reported by a sandbox while rendering a variant (#323).
6
+ //
7
+ // This only knows about variants that have actually rendered. A variant is not
8
+ // mounted until it scrolls into the grid's window (#103), so the absence of a
9
+ // marker means "not seen to fail", never "fine" — telling a book it is healthy
10
+ // would need a rendering pass, which is the open half of #323.
11
+ export const useStoryErrorStore = defineStore('story-errors', () => {
12
+ const errors = ref(new Map<string, StoryError>())
13
+
14
+ const key = (storyId: string, variantId?: string | null) => `${storyId}::${variantId ?? ''}`
15
+
16
+ function report(error: StoryError) {
17
+ if (!error.storyId) {
18
+ return
19
+ }
20
+ errors.value.set(key(error.storyId, error.variantId), error)
21
+ }
22
+
23
+ // A variant is re-rendered on every retarget, so last render wins: a story
24
+ // that has been fixed must stop being marked without a reload.
25
+ function clear(storyId: string, variantId?: string | null) {
26
+ errors.value.delete(key(storyId, variantId))
27
+ }
28
+
29
+ const forVariant = computed(() => (storyId: string, variantId?: string | null) =>
30
+ errors.value.get(key(storyId, variantId)))
31
+
32
+ const storyHasError = computed(() => (storyId: string) => {
33
+ for (const [entry] of errors.value) {
34
+ if (entry.startsWith(`${storyId}::`)) {
35
+ return true
36
+ }
37
+ }
38
+ return false
39
+ })
40
+
41
+ return {
42
+ errors,
43
+ report,
44
+ clear,
45
+ forVariant,
46
+ storyHasError,
47
+ }
48
+ })