@poveste/app 0.6.1 → 0.8.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.
@@ -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)) {
@@ -124,13 +124,13 @@
124
124
  /* Cells are placed with `order`, so without this Tab and a screen reader walk
125
125
  them in slot order — the order they were recycled in, not the screen's.
126
126
  Chromium only for now; elsewhere the rule is ignored. */
127
- .poveste-story-variant-grid-cells[data-v-15980005] {
127
+ .poveste-story-variant-grid-cells[data-v-5f7bc1e1] {
128
128
  reading-flow: grid-order;
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 StoryVariantGrid_vue_vue_type_script_setup_true_lang_default from "./StoryVariantGrid.vue_vue_type_script_setup_true_lang.js";
3
3
  /* empty css */
4
4
  //#region src/app/components/story/StoryVariantGrid.vue
5
- var StoryVariantGrid_default = /*#__PURE__*/ _plugin_vue_export_helper_default(StoryVariantGrid_vue_vue_type_script_setup_true_lang_default, [["__scopeId", "data-v-15980005"]]);
5
+ var StoryVariantGrid_default = /*#__PURE__*/ _plugin_vue_export_helper_default(StoryVariantGrid_vue_vue_type_script_setup_true_lang_default, [["__scopeId", "data-v-5f7bc1e1"]]);
6
6
  //#endregion
7
7
  export { StoryVariantGrid_default as default };
@@ -1,7 +1,7 @@
1
1
  import { useStoryStore } from "../../stores/story.js";
2
2
  import { assignSlots, trimSlots } from "../../util/grid-slots.js";
3
3
  import StoryVariantGridItem_default from "./StoryVariantGridItem.js";
4
- import { useResizeObserver } from "@poveste/vendors/vue-use";
4
+ import { useDebounceFn, useResizeObserver } from "@poveste/vendors/vue-use";
5
5
  import { Fragment, computed, createBlock, createElementBlock, createElementVNode, defineComponent, normalizeStyle, onBeforeUnmount, onMounted, openBlock, ref, renderList, unref, watch } from "@poveste/vendors/vue";
6
6
  //#region src/app/components/story/StoryVariantGrid.vue?vue&type=script&setup=true&lang.ts
7
7
  var _hoisted_1 = { class: "poveste-story-variant-grid flex flex-col items-stretch h-full __poveste-pane-shadow-from-right" };
@@ -9,6 +9,8 @@ var _hoisted_2 = { class: "flex w-0 flex-1 mx-4" };
9
9
  var gap = 16;
10
10
  var overscanRows = 2;
11
11
  var initialCount = 10;
12
+ var FAST_PX_PER_MS = 8;
13
+ var FAST_REFRESH_MS = 150;
12
14
  var TRIM_DELAY = 2e3;
13
15
  var StoryVariantGrid_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ defineComponent({
14
16
  __name: "StoryVariantGrid",
@@ -35,6 +37,23 @@ var StoryVariantGrid_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/
35
37
  scrollTop.value = el.value.scrollTop;
36
38
  viewportHeight.value = el.value.clientHeight;
37
39
  }
40
+ let lastScrollTop = 0;
41
+ let lastScrollTime = 0;
42
+ let lastWindowUpdate = 0;
43
+ const settleWindow = useDebounceFn(updateWindow, 120);
44
+ function onScroll() {
45
+ const now = performance.now();
46
+ const top = el.value?.scrollTop ?? 0;
47
+ const dt = now - lastScrollTime;
48
+ const velocity = dt > 0 ? Math.abs(top - lastScrollTop) / dt : 0;
49
+ lastScrollTop = top;
50
+ lastScrollTime = now;
51
+ if (velocity <= FAST_PX_PER_MS || now - lastWindowUpdate >= FAST_REFRESH_MS) {
52
+ updateWindow();
53
+ lastWindowUpdate = now;
54
+ }
55
+ settleWindow();
56
+ }
38
57
  function onItemResize(_w, h) {
39
58
  if (maxItemHeight.value < h) {
40
59
  maxItemHeight.value = h;
@@ -91,6 +110,7 @@ var StoryVariantGrid_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/
91
110
  }, { immediate: true });
92
111
  onBeforeUnmount(() => {
93
112
  clearTimeout(trimTimer);
113
+ settleWindow.cancel();
94
114
  });
95
115
  const totalHeight = computed(() => measured.value ? rowCount.value * rowHeight.value - gap : void 0);
96
116
  const offsetY = computed(() => measured.value ? startIndex.value / columnCount.value * rowHeight.value : 0);
@@ -111,7 +131,7 @@ var StoryVariantGrid_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/
111
131
  ref_key: "el",
112
132
  ref: el,
113
133
  class: "overflow-y-auto flex flex-1",
114
- onScroll: _cache[0] || (_cache[0] = ($event) => updateWindow())
134
+ onScroll: _cache[0] || (_cache[0] = ($event) => onScroll())
115
135
  }, [createElementVNode("div", _hoisted_2, [createElementVNode("div", {
116
136
  class: "m-auto",
117
137
  style: normalizeStyle({ minHeight: totalHeight.value != null ? `${totalHeight.value}px` : void 0 })
@@ -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,17 +1,23 @@
1
1
  {
2
2
  "name": "@poveste/app",
3
3
  "type": "module",
4
- "version": "0.6.1",
4
+ "version": "0.8.0",
5
5
  "description": "Pre-bundled UI for Poveste",
6
6
  "author": {
7
7
  "name": "Sorin Gitlan"
8
8
  },
9
9
  "license": "MIT",
10
+ "homepage": "https://poveste.dev",
10
11
  "repository": {
11
12
  "url": "https://github.com/poveste-dev/poveste.git",
12
13
  "type": "git",
13
14
  "directory": "packages/poveste-app"
14
15
  },
16
+ "bugs": "https://github.com/poveste-dev/poveste/issues",
17
+ "keywords": [
18
+ "poveste",
19
+ "ui"
20
+ ],
15
21
  "publishConfig": {
16
22
  "access": "public"
17
23
  },
@@ -27,13 +33,16 @@
27
33
  "dist",
28
34
  "src"
29
35
  ],
36
+ "engines": {
37
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
38
+ },
30
39
  "dependencies": {
31
40
  "@fontsource/noto-sans-display": "^5.3.0",
32
41
  "fuse.js": "^7.0.0",
33
42
  "shiki": "^3.0.0",
34
- "@poveste/controls": "^0.6.1",
35
- "@poveste/shared": "^0.6.1",
36
- "@poveste/vendors": "^0.6.1"
43
+ "@poveste/controls": "^0.8.0",
44
+ "@poveste/shared": "^0.8.0",
45
+ "@poveste/vendors": "^0.8.0"
37
46
  },
38
47
  "devDependencies": {
39
48
  "@iconify-json/carbon": "^1.2.25",
@@ -1,6 +1,6 @@
1
1
  <script lang="ts" setup>
2
2
  import type { Slot } from '../../util/grid-slots'
3
- import { useResizeObserver } from '@vueuse/core'
3
+ import { useDebounceFn, useResizeObserver } from '@vueuse/core'
4
4
  import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
5
5
  import { useStoryStore } from '../../stores/story'
6
6
  import { assignSlots, trimSlots } from '../../util/grid-slots'
@@ -55,6 +55,43 @@ function updateWindow() {
55
55
  viewportHeight.value = el.value.clientHeight
56
56
  }
57
57
 
58
+ // A window shift retargets a row of sandbox iframes, and each retarget re-renders
59
+ // a story — fine while reading, ruinous while flicking a 1000-cell grid, which
60
+ // would render every row it flies past (#283). So the window follows a slow
61
+ // scroll promptly but skips most events during a fast flick. It is not frozen
62
+ // solid though: a bounded refresh keeps it tracking the scroller so a sustained
63
+ // fast drag still shows cells instead of a blank band, and the final window
64
+ // lands on settle. Resize/mount call `updateWindow` directly — not the churn path.
65
+ //
66
+ // The threshold sits above an ordinary mouse-wheel notch (a few px/ms) so plain
67
+ // reading takes the prompt path; only a genuine fling defers.
68
+ const FAST_PX_PER_MS = 8
69
+ // Cap on how long the window may lag the scroller during a fast flick: at most
70
+ // one retarget per this interval, versus one per row on the prompt path.
71
+ const FAST_REFRESH_MS = 150
72
+ let lastScrollTop = 0
73
+ let lastScrollTime = 0
74
+ let lastWindowUpdate = 0
75
+
76
+ // Lands the final window shortly after scrolling stops, whatever the speed was —
77
+ // this is what fills the tail of a fast scroll back in.
78
+ const settleWindow = useDebounceFn(updateWindow, 120)
79
+
80
+ function onScroll() {
81
+ const now = performance.now()
82
+ const top = el.value?.scrollTop ?? 0
83
+ const dt = now - lastScrollTime
84
+ const velocity = dt > 0 ? Math.abs(top - lastScrollTop) / dt : 0
85
+ lastScrollTop = top
86
+ lastScrollTime = now
87
+
88
+ if (velocity <= FAST_PX_PER_MS || now - lastWindowUpdate >= FAST_REFRESH_MS) {
89
+ updateWindow()
90
+ lastWindowUpdate = now
91
+ }
92
+ settleWindow()
93
+ }
94
+
58
95
  // The reported width is ignored: `columnCount` is what CSS lays out, so it is
59
96
  // the authority on how many items sit in a row.
60
97
  function onItemResize(_w: number, h: number) {
@@ -153,6 +190,8 @@ watch(visibleVariants, (visible) => {
153
190
 
154
191
  onBeforeUnmount(() => {
155
192
  clearTimeout(trimTimer)
193
+ // Drop any settle still pending, so it cannot fire updateWindow after teardown.
194
+ settleWindow.cancel()
156
195
  })
157
196
 
158
197
  // The spacer holds the full scroll extent; the grid is translated into place
@@ -185,7 +224,7 @@ watch(() => storyStore.currentVariant, (variant) => {
185
224
  <div
186
225
  ref="el"
187
226
  class="overflow-y-auto flex flex-1"
188
- @scroll="updateWindow()"
227
+ @scroll="onScroll()"
189
228
  >
190
229
  <div class="flex w-0 flex-1 mx-4">
191
230
  <div
@@ -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
+ })