@uxfront/layer-docs 0.2.1 → 0.4.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,56 @@
1
1
  # @uxfront/layer-docs
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a4caf06: Ship OG image support and the two satori card templates.
8
+
9
+ Every consumer was registering `nuxt-og-image` itself and carrying its own copy of the templates, which meant carrying a page override too — `defineOgImage` cannot be called from the layer if the module is not registered by it. The layer now registers the module beside `@nuxtjs/sitemap` and `@nuxtjs/robots` (same class of SEO plumbing it already turns on unasked) and calls `defineOgImage` from its own docs and landing pages, so a consumer that extends the layer gets branded cards without writing anything. Opt out with the module's own switch, `ogImage: { enabled: false }`.
10
+
11
+ **The accent is a brand token, not a hardcoded colour.** The existing copies hardcoded a Tailwind class, which was wrong in a way that only showed up on the rendered card: every consumer shadows stock Tailwind scale names with its own values in `@theme`, so `text-teal-500` renders Tailwind's teal, not the brand's. Custom properties are not an option either — satori has no CSS cascade, and `var(--ui-primary)` resolves to nothing. The accent is therefore resolved to a literal at build time by following `--ui-primary` to the `--color-<scale>` declaration it aliases in the consumer's registered CSS, the same two hops the browser does and the same pair `describeBrandPaletteCss` already asserts. Override it with a literal via `ogImage.accent` in `app.config.ts`.
12
+
13
+ Two parts of the card are components rather than props, because `defineOgImage` serialises its arguments and a Vue slot cannot cross that boundary. Shadow `app/AppOgDecoration.vue` (a neutral radial wash) or `app/AppOgLogo.vue` (a wordmark from `header.title`) at the same path to replace either.
14
+
15
+ Cards render at 1200×630 rather than the module's 1200×600 default — 1.91:1 is the ratio crawlers crop to.
16
+
17
+ New peer dependencies: `nuxt-og-image`, `satori`, and `@resvg/resvg-js`. Satori renders the template to SVG and needs a separate rasteriser for the PNG; without one, prerendering logs `renderer.createImage error` per card and emits nothing.
18
+
19
+ - 5cc11f2: Add `StorybookEmbed` and the `@uxfront/layer-docs/storybook` bridge.
20
+
21
+ `StorybookEmbed` embeds a deployed Storybook story in a docs page, in one of
22
+ three modes — `preview` (canvas only), `full` (manager without the sidebar), and
23
+ `panel` (manager with the controls panel). It mounts lazily, keeps the
24
+ Storybook's colour mode in sync with the page's, and grows to fit its story
25
+ without shifting layout. The Storybook host comes from
26
+ `NUXT_PUBLIC_STORYBOOK_BASE_URL`, with an optional `{framework}` placeholder for
27
+ per-framework deployments.
28
+
29
+ The `/storybook` subpath export ships the other half of that contract:
30
+ `installDocsEmbedPreviewBridge` and `installDocsEmbedManagerBridge`, which are
31
+ framework-free and import nothing from Nuxt. A Storybook that already speaks a
32
+ branded message namespace can name it via
33
+ `NUXT_PUBLIC_STORYBOOK_LEGACY_MESSAGE_NAMESPACE`, and both sides accept and emit
34
+ both spellings so the two can deploy in either order.
35
+
36
+ ## 0.3.0
37
+
38
+ ### Minor Changes
39
+
40
+ - 751810d: Stop registering the layer's own `main.css`, and ship the brand-palette guards consumers were each expected to write themselves.
41
+
42
+ **Breaking for any consumer that does not already register its own CSS entry.** The layer registered `app/assets/css/main.css` in `css` while every consumer also imported that same base from its brand CSS — because a brand `@theme` only compiles into real `:root` custom properties when it lives inside a Tailwind pass, which means importing the base rather than sitting beside it. Two entries, two Tailwind passes, and a byte-for-byte duplicate of every base utility in the shipped stylesheet: **+212 KB raw / +26.7 KB gzip (+94%)** on inkline's render-blocking `entry.css`, on every page, for every visitor (UXF-118).
43
+
44
+ The layer no longer registers it. Consumers register exactly one CSS entry of their own, whose first line imports the layer base — which is what all three consumers were already doing, one of them via a `modules:done` hook that un-registered what the layer had just registered. That hook can now be deleted.
45
+
46
+ **Migration.** If your app already has `css: ["./app/assets/css/main.css"]` pointing at a file that starts with `@import "@uxfront/layer-docs/app/assets/css/main.css"`, nothing changes except that your stylesheet halves; drop any hook that stripped the layer's entry. If it does not, add both — see README § Styling. There is no silent middle state: without a CSS entry the app ships no Tailwind utilities at all.
47
+
48
+ **What the duplication actually cost.** Payload, not layout. The duplicate pass measured on inkline was a complete superset of the first and was emitted wholly after it, so the last `sm:`/`lg:` variant still landed after the last conflicting base and won by source order — computed-style A/B across 4 pages × 4 viewports found zero rendering difference. That rescue was incidental rather than designed, which is why the cause is fixed rather than the symptom.
49
+
50
+ **New: `@uxfront/layer-docs/test`.** The two guards that would have caught this before ship, now parameterised by brand scale instead of copy-pasted per repo. `describeBrandPaletteCss` asserts, without a build, that your CSS entry imports the layer base before its `@theme` and defines the scale it maps onto `--ui-primary`. `describeSingleTailwindPass` reads the built stylesheet and asserts every base utility is emitted **exactly once** — catching a duplicate pass and a CSS entry that never reached the bundle with the same assertion. `vitest` is an optional peer dependency, needed only for these.
51
+
52
+ Also fixed: three code comments claimed the second pass "kills every `sm:`/`lg:` responsive variant". It does not, in the equal-superset case measured; the guards now describe the duplicate payload they actually guard.
53
+
3
54
  ## 0.2.1
4
55
 
5
56
  ### Patch Changes
package/README.md CHANGED
@@ -20,8 +20,9 @@ npm install -D @uxfront/layer-docs
20
20
  Then install the peer dependencies the layer expects (the Nuxt documentation
21
21
  stack — see [`package.json`](./package.json) `peerDependencies` for pinned
22
22
  ranges): `nuxt`, `vue`, `@nuxt/ui`, `@nuxt/image`, `@nuxt/scripts`,
23
- `@nuxtjs/robots`, `nuxt-og-image`, `nuxt-llms`, `tailwindcss`, and — for content
24
- and translation — `@nuxt/content`, `@nuxtjs/i18n`, `@nuxtjs/mdc`.
23
+ `@nuxtjs/robots`, `nuxt-og-image`, `satori`, `@resvg/resvg-js`, `nuxt-llms`,
24
+ `tailwindcss`, and — for content and translation — `@nuxt/content`,
25
+ `@nuxtjs/i18n`, `@nuxtjs/mdc`.
25
26
 
26
27
  ## Usage
27
28
 
@@ -60,6 +61,186 @@ That renders `uxd by UXFront` — the wordmark still links home, `by` is plain
60
61
  text, and `UXFront` links out. Omit `attribution` (or leave `label` empty) to
61
62
  render the wordmark alone.
62
63
 
64
+ ## Styling
65
+
66
+ **The consumer owns the single Tailwind entry.** The layer ships a palette-free
67
+ Tailwind base at `@uxfront/layer-docs/app/assets/css/main.css` and deliberately
68
+ does not register it itself. A brand `@theme` only compiles into real `:root`
69
+ custom properties when it lives inside a Tailwind pass, so your CSS file has to
70
+ _import_ the layer base rather than sit beside it as a second entry — two
71
+ entries each re-emit every base utility into the shipped stylesheet.
72
+
73
+ Register one CSS entry:
74
+
75
+ ```ts
76
+ // nuxt.config.ts
77
+ export default defineNuxtConfig({
78
+ extends: ["@uxfront/layer-docs"],
79
+ css: ["./app/assets/css/main.css"],
80
+ });
81
+ ```
82
+
83
+ whose first line imports the layer base, followed by your palette:
84
+
85
+ ```css
86
+ /* app/assets/css/main.css */
87
+ @import "@uxfront/layer-docs/app/assets/css/main.css";
88
+
89
+ /* The layer's own `@source` paths are package-relative (they resolve inside
90
+ node_modules), so re-scan your content and config for utility classes. */
91
+ @source "../../../content/**/*";
92
+ @source "../../app.config.ts";
93
+
94
+ @theme static {
95
+ --color-teal: hsl(189, 53%, 41%);
96
+ /* … the rest of the scale … */
97
+ }
98
+
99
+ :root {
100
+ --ui-primary: var(--color-teal);
101
+ }
102
+ ```
103
+
104
+ The scale name must match `ui.colors.primary` in your `app.config.ts` for Nuxt
105
+ UI to resolve component variants onto it.
106
+
107
+ ### Guardrail tests
108
+
109
+ Both halves of that contract fail silently, so the layer ships the guards.
110
+ `vitest` is an optional peer dependency, needed only for these.
111
+
112
+ ```ts
113
+ // test/brand-palette-css.test.ts — source-level, no build required
114
+ import { describeBrandPaletteCss } from "@uxfront/layer-docs/test";
115
+
116
+ describeBrandPaletteCss({
117
+ entry: new URL("../app/assets/css/main.css", import.meta.url),
118
+ scale: "teal",
119
+ });
120
+ ```
121
+
122
+ ```ts
123
+ // test/brand-palette-compiled.build.test.ts — requires a prior `nuxt build`
124
+ import { describeSingleTailwindPass } from "@uxfront/layer-docs/test";
125
+
126
+ describeSingleTailwindPass({
127
+ output: new URL("../.output/public/_nuxt", import.meta.url),
128
+ scale: "teal",
129
+ });
130
+ ```
131
+
132
+ The first asserts your CSS entry is a Tailwind entry and defines the palette.
133
+ The second reads the built stylesheet and asserts every base utility is emitted
134
+ **exactly once** — catching both a duplicate Tailwind pass (double payload) and
135
+ a CSS entry that never reached the bundle (no base utilities at all). Run it in
136
+ whichever CI job already builds the app; it needs `.output/` on disk.
137
+
138
+ ## Storybook embeds
139
+
140
+ `StorybookEmbed` renders a deployed Storybook story inside a docs page. Point it
141
+ at your Storybook once, from env — the host is a deployment fact, not a code
142
+ fact, so it lives in runtime config rather than `app.config`:
143
+
144
+ ```bash
145
+ # a single Storybook
146
+ NUXT_PUBLIC_STORYBOOK_BASE_URL="https://storybook.example.com"
147
+
148
+ # or one per framework — `{framework}` is substituted per embed
149
+ NUXT_PUBLIC_STORYBOOK_BASE_URL="https://{framework}.storybook.example.com"
150
+ ```
151
+
152
+ Then use it from markdown:
153
+
154
+ ```mdc
155
+ :storybook-embed{story="components-actions-button--default"}
156
+
157
+ :storybook-embed{story="components-actions-button--default" mode="panel"}
158
+
159
+ :storybook-embed{story="components-actions-button--default" title="Button"}
160
+ ```
161
+
162
+ | Prop | Default | Notes |
163
+ | ----------- | ---------------- | --------------------------------------------------------------------------------------------------- |
164
+ | `story` | — | Storybook story id, e.g. `components-actions-button--default`. |
165
+ | `framework` | `useFramework()` | Fills `{framework}` in the base URL. Follows the tab by default. |
166
+ | `mode` | `"preview"` | `preview` (canvas only) · `full` (manager, no sidebar) · `panel` (manager with the controls panel). |
167
+ | `height` | mode default | Number (px) or any CSS length. Set it and auto-height is off. |
168
+ | `title` | — | Wraps the embed in `BrowserFrame` with this title. |
169
+
170
+ The embed mounts its iframe only once it is 200px from the viewport, keeps the
171
+ Storybook's colour mode in sync with the page's, and grows to fit its story.
172
+ Height is always reserved, so none of that shifts layout.
173
+
174
+ ### The Storybook side
175
+
176
+ Auto-height and theme sync are a two-way `postMessage` contract, so the
177
+ Storybook has to answer. Install the bridge — it is framework-free and imports
178
+ nothing from Nuxt:
179
+
180
+ ```ts
181
+ // .storybook/preview.ts
182
+ import { installDocsEmbedPreviewBridge } from "@uxfront/layer-docs/storybook";
183
+
184
+ installDocsEmbedPreviewBridge({ onTheme: (theme) => applyTheme(theme) });
185
+ ```
186
+
187
+ ```ts
188
+ // .storybook/manager.ts — only needed for `full` / `panel`
189
+ import { installDocsEmbedManagerBridge } from "@uxfront/layer-docs/storybook";
190
+
191
+ installDocsEmbedManagerBridge({ onTheme: (theme) => applyTheme(theme) });
192
+ ```
193
+
194
+ Without the bridge the embed still renders; it just holds its default height and
195
+ does not follow the page's colour mode.
196
+
197
+ ### Migrating an existing Storybook
198
+
199
+ If your Storybook already speaks a branded namespace (`<brand>:theme`,
200
+ `<brand>:height`), name it and both sides accept **and** emit both spellings, so
201
+ the docs site and the Storybook can deploy in either order:
202
+
203
+ ```bash
204
+ NUXT_PUBLIC_STORYBOOK_LEGACY_MESSAGE_NAMESPACE="acme"
205
+ ```
206
+
207
+ Unset it once both sides are on `@uxfront/layer-docs/storybook`.
208
+
209
+ ## OG images
210
+
211
+ Social share cards are on by default — the layer registers `nuxt-og-image` and
212
+ calls `defineOgImage` from the docs and landing pages, so a consumer that sets up
213
+ the CSS entry above gets branded 1200×630 cards without writing anything. Opt out
214
+ with the module's own switch: `ogImage: { enabled: false }`.
215
+
216
+ The accent is resolved to a **literal colour at build time** by following
217
+ `--ui-primary` to the `--color-<scale>` value it aliases in your CSS entry.
218
+ Satori — the renderer behind `.satori.vue` templates — has no CSS cascade and no
219
+ custom properties, so `var(--ui-primary)` would render as nothing, and a utility
220
+ class like `text-teal-500` would render Tailwind's teal rather than yours (your
221
+ `@theme` shadows the stock scale name). Override it with a literal if you need
222
+ something other than the brand colour:
223
+
224
+ ```ts
225
+ export default defineAppConfig({
226
+ ogImage: { accent: "#a78bfa" },
227
+ });
228
+ ```
229
+
230
+ Two pieces of the card are components rather than props, because `defineOgImage`
231
+ serialises its arguments and a Vue slot cannot cross that boundary. Shadow either
232
+ by creating a file at the same path in your own `app/components/`:
233
+
234
+ | Component | Default |
235
+ | ------------------------- | ----------------------------------- |
236
+ | `app/AppOgDecoration.vue` | A neutral radial-gradient flourish |
237
+ | `app/AppOgLogo.vue` | A text wordmark from `header.title` |
238
+
239
+ The wordmark is text, not `AppHeaderLogo`: that component renders
240
+ `UColorModeImage`, and satori has neither a colour mode nor a browser to resolve
241
+ relative image paths against. A brand that wants its mark on the card overrides
242
+ `AppOgLogo.vue` with an `<img>` pointing at an **absolute** URL.
243
+
63
244
  ## Compatibility
64
245
 
65
246
  Pinned to the Nuxt 4 documentation stack (Nuxt 4.4, Nuxt UI 4.8, Content 3.14,
@@ -0,0 +1,40 @@
1
+ <script lang="ts" setup>
2
+ /**
3
+ * Default OG card for docs, changelog and landing pages — `defineOgImage("DocsSatori", ...)`.
4
+ *
5
+ * Brandable without touching this package: the accent comes from the consumer's
6
+ * own `--ui-primary` token (resolved in `modules/config.ts`), and both the
7
+ * decoration and the wordmark are components a consumer can shadow. See
8
+ * README § OG images.
9
+ */
10
+ withDefaults(defineProps<{ title?: string; description?: string; headline?: string }>(), {
11
+ title: "",
12
+ description: "",
13
+ headline: "",
14
+ });
15
+
16
+ const appConfig = useAppConfig();
17
+ const accent = computed(() => appConfig.ogImage?.accent);
18
+ </script>
19
+
20
+ <template>
21
+ <div class="w-full h-full flex flex-col justify-center bg-neutral-900">
22
+ <AppOgDecoration />
23
+
24
+ <div class="pl-[100px]">
25
+ <p
26
+ v-if="headline"
27
+ class="uppercase text-[24px] mb-4 font-semibold"
28
+ :style="{ color: accent }"
29
+ >
30
+ {{ headline }}
31
+ </p>
32
+ <h1 v-if="title" class="m-0 text-[75px] font-semibold mb-4 text-white flex items-center">
33
+ <span>{{ title.slice(0, 60) }}</span>
34
+ </h1>
35
+ <p v-if="description" class="text-[32px] text-neutral-300 leading-tight w-[700px]">
36
+ {{ description.slice(0, 200) }}
37
+ </p>
38
+ </div>
39
+ </div>
40
+ </template>
@@ -0,0 +1,41 @@
1
+ <script lang="ts" setup>
2
+ /**
3
+ * Centred OG card variant for landing pages — `defineOgImage("LandingSatori", ...)`.
4
+ *
5
+ * Same brand contract as {@link OgImageDocs}: accent from the consumer's token,
6
+ * decoration and wordmark as overridable components.
7
+ */
8
+ withDefaults(defineProps<{ title?: string; description?: string; headline?: string }>(), {
9
+ title: "",
10
+ description: "",
11
+ headline: "",
12
+ });
13
+
14
+ const appConfig = useAppConfig();
15
+ const accent = computed(() => appConfig.ogImage?.accent);
16
+ </script>
17
+
18
+ <template>
19
+ <div class="w-full h-full flex items-center justify-center bg-neutral-900">
20
+ <AppOgDecoration />
21
+
22
+ <div class="flex flex-col justify-center p-8">
23
+ <div class="flex justify-center mb-8">
24
+ <AppOgLogo />
25
+ </div>
26
+ <p
27
+ v-if="headline"
28
+ class="flex justify-center uppercase text-[24px] mb-4 font-semibold"
29
+ :style="{ color: accent }"
30
+ >
31
+ {{ headline }}
32
+ </p>
33
+ <h1 v-if="title" class="flex justify-center m-0 text-5xl font-semibold mb-4 text-white">
34
+ <span>{{ title.slice(0, 60) }}</span>
35
+ </h1>
36
+ <p v-if="description" class="text-center text-2xl text-neutral-300 leading-tight">
37
+ {{ description.slice(0, 200) }}
38
+ </p>
39
+ </div>
40
+ </div>
41
+ </template>
@@ -0,0 +1,27 @@
1
+ <script lang="ts" setup>
2
+ /**
3
+ * Decorative flourish behind the OG card — the "slot" of the OG templates.
4
+ *
5
+ * `defineOgImage(name, props)` serialises its props into the payload, so a real
6
+ * Vue slot cannot cross that boundary. The Nuxt-native equivalent is app-dir
7
+ * component precedence: a consumer shadows this file at the same path to supply
8
+ * its own decoration, or ships an empty template to render nothing. That is the
9
+ * same override mechanism consumers already use for `DocsAsideLeftBody`.
10
+ *
11
+ * Geometry only, no brand mark: a soft radial wash in the top-right corner.
12
+ * Deliberately not the source SVG blob — satori implements a subset of SVG and
13
+ * does not apply `filter` / `feGaussianBlur`, so the blurred shape would render
14
+ * as a hard-edged starburst. A `radial-gradient` background is satori-native
15
+ * and preserves what the blur was there for.
16
+ *
17
+ * Bound rather than written as a `style` attribute so it stays on one line:
18
+ * satori's gradient parser rejects a value containing newlines, and a formatter
19
+ * will wrap an attribute this long across lines given the chance.
20
+ */
21
+ const backgroundImage =
22
+ "radial-gradient(circle at 75% 15%, rgba(255, 255, 255, 0.28) 0%, rgba(255, 255, 255, 0.1) 35%, rgba(255, 255, 255, 0) 70%)";
23
+ </script>
24
+
25
+ <template>
26
+ <div class="absolute right-0 top-0 h-[593px] w-[629px]" :style="{ backgroundImage }" />
27
+ </template>
@@ -0,0 +1,19 @@
1
+ <script lang="ts" setup>
2
+ /**
3
+ * Wordmark for the OG card. Overridable at the same path, like
4
+ * {@link AppOgDecoration}.
5
+ *
6
+ * Text, not `AppHeaderLogo`: that component renders `UColorModeImage`, which
7
+ * needs a colour mode and resolves relative image paths against the browser —
8
+ * satori has neither, so it needs an absolute URL and no colour mode at all. A
9
+ * brand that wants its mark here overrides this file with an `<img>` pointing
10
+ * at an absolute URL; stating that limit rather than half-solving it.
11
+ */
12
+ const appConfig = useAppConfig();
13
+ </script>
14
+
15
+ <template>
16
+ <span class="text-[32px] font-semibold text-white">
17
+ {{ appConfig.header?.title }}
18
+ </span>
19
+ </template>
@@ -0,0 +1,160 @@
1
+ <script setup lang="ts">
2
+ import { useIntersectionObserver } from "@vueuse/core";
3
+ // Resolved through `#components` rather than imported by path so a consumer
4
+ // that ships its own `BrowserFrame` overrides this one, as with any other
5
+ // component in the layer.
6
+ import { BrowserFrame } from "#components";
7
+ import { docsEmbedMessageNames, readDocsEmbedHeight } from "../../../storybook";
8
+ import {
9
+ buildStorybookEmbedUrl,
10
+ STORYBOOK_EMBED_DEFAULT_HEIGHTS,
11
+ storybookEmbedCssHeight,
12
+ type StorybookEmbedMode,
13
+ } from "../../utils/storybookEmbed";
14
+
15
+ /**
16
+ * Embeds a single story from the consumer's deployed Storybook.
17
+ *
18
+ * The Storybook host is a consumer fact, never a layer one: it comes from
19
+ * `runtimeConfig.public.storybookBaseUrl`, which is empty here and set per
20
+ * consumer — in `nuxt.config.ts` or, without a rebuild,
21
+ * `NUXT_PUBLIC_STORYBOOK_BASE_URL`.
22
+ *
23
+ * ```md
24
+ * :storybook-embed{story="components-actions-button--default"}
25
+ * :storybook-embed{story="components-forms-input--default" mode="panel"}
26
+ * ```
27
+ *
28
+ * Three behaviours are always on, and all three degrade rather than fail:
29
+ *
30
+ * - **Lazy mount.** The iframe is not created until the embed comes near the
31
+ * viewport, so a page with twenty embeds loads one Storybook, not twenty.
32
+ * - **Theme sync.** The page's colour mode is posted to the story on load and
33
+ * on every change, so an embed never sits light inside a dark page.
34
+ * - **Auto-height.** The story reports what it needs and the frame follows.
35
+ *
36
+ * Theme and height both need `@uxfront/layer-docs/storybook` installed in the
37
+ * Storybook config. Without it the frame keeps `height` (or the mode's
38
+ * default) and the story renders in its own theme — degraded, not broken.
39
+ */
40
+ interface Props {
41
+ /** Storybook story id, e.g. `components-actions-button--default`. */
42
+ story: string;
43
+ /**
44
+ * Framework substituted into the base URL's `{framework}` placeholder.
45
+ * Defaults to the reader's selected framework, so an embed inside a
46
+ * `FrameworkSwitcher` tab needs it and a standalone one does not.
47
+ */
48
+ framework?: string;
49
+ /** Storybook surface to embed. */
50
+ mode?: StorybookEmbedMode;
51
+ /**
52
+ * Fixed height. Set this and auto-height is off — the story's own report is
53
+ * ignored, which is what you want for a story whose height oscillates.
54
+ */
55
+ height?: number | string;
56
+ /** Caption for the browser frame. Omit to render the story unframed. */
57
+ title?: string;
58
+ }
59
+
60
+ const props = withDefaults(defineProps<Props>(), {
61
+ framework: undefined,
62
+ mode: "preview",
63
+ height: undefined,
64
+ title: undefined,
65
+ });
66
+
67
+ const config = useRuntimeConfig();
68
+ const storybookBaseUrl = computed(() => (config.public.storybookBaseUrl as string) ?? "");
69
+ const messageNames = computed(() =>
70
+ docsEmbedMessageNames(config.public.storybookLegacyMessageNamespace as string),
71
+ );
72
+
73
+ const { framework: selectedFramework } = useFramework();
74
+ const framework = computed(() => props.framework ?? selectedFramework.value);
75
+
76
+ const colorMode = useColorMode();
77
+ const containerRef = useTemplateRef<HTMLElement>("container");
78
+ const iframeRef = useTemplateRef<HTMLIFrameElement>("iframe");
79
+
80
+ // Mount the iframe just before it is scrolled to, then stop observing — the
81
+ // answer cannot change back.
82
+ const visible = ref(false);
83
+ const { stop } = useIntersectionObserver(
84
+ containerRef,
85
+ ([entry]) => {
86
+ if (!entry?.isIntersecting) return;
87
+ visible.value = true;
88
+ stop();
89
+ },
90
+ { rootMargin: "200px" },
91
+ );
92
+
93
+ const src = computed(() =>
94
+ buildStorybookEmbedUrl({
95
+ template: storybookBaseUrl.value,
96
+ story: props.story,
97
+ framework: framework.value,
98
+ mode: props.mode,
99
+ }),
100
+ );
101
+
102
+ const reportedHeight = ref<number | null>(null);
103
+ const cssHeight = computed(() =>
104
+ storybookEmbedCssHeight(
105
+ props.height ?? reportedHeight.value ?? STORYBOOK_EMBED_DEFAULT_HEIGHTS[props.mode],
106
+ ),
107
+ );
108
+
109
+ // An iframe is an unlabelled frame to a screen reader. The caption names it
110
+ // when there is one; otherwise say what it holds rather than leaving it silent.
111
+ const frameTitle = computed(
112
+ () => props.title ?? `Storybook preview: ${props.story.replace(/-+/g, " ")}`,
113
+ );
114
+
115
+ const wrapper = computed(() => (props.title ? BrowserFrame : "div"));
116
+ const wrapperProps = computed(() => (props.title ? { title: props.title } : {}));
117
+
118
+ function sendTheme() {
119
+ const target = iframeRef.value?.contentWindow;
120
+ if (!target) return;
121
+
122
+ const theme = colorMode.value === "dark" ? "dark" : "light";
123
+ for (const type of messageNames.value.theme) {
124
+ target.postMessage({ type, theme }, "*");
125
+ }
126
+ }
127
+
128
+ function onMessage(event: MessageEvent) {
129
+ // Scope by source, not by origin: the Storybook host is consumer-configured
130
+ // and may differ per framework, but only one window can be this iframe.
131
+ if (event.source !== iframeRef.value?.contentWindow) return;
132
+
133
+ const height = readDocsEmbedHeight(event.data, messageNames.value.height);
134
+ if (height !== null) reportedHeight.value = height;
135
+ }
136
+
137
+ onMounted(() => window.addEventListener("message", onMessage));
138
+ onUnmounted(() => window.removeEventListener("message", onMessage));
139
+
140
+ watch(() => colorMode.value, sendTheme);
141
+ </script>
142
+
143
+ <template>
144
+ <component :is="wrapper" v-bind="wrapperProps">
145
+ <div
146
+ ref="container"
147
+ class="storybook-embed border border-default rounded overflow-hidden min-h-25 resize-y transition-[height] duration-150 ease-out motion-reduce:transition-none"
148
+ :style="{ height: cssHeight }"
149
+ >
150
+ <iframe
151
+ v-if="visible"
152
+ ref="iframe"
153
+ :src="src"
154
+ :title="frameTitle"
155
+ class="block w-full h-full border-0"
156
+ @load="sendTheme"
157
+ />
158
+ </div>
159
+ </component>
160
+ </template>
@@ -35,11 +35,21 @@ useSeoMeta({
35
35
  ogDescription: description,
36
36
  });
37
37
 
38
+ const appConfig = useAppConfig();
39
+
40
+ // An explicit `seo.ogImage` in the page's front matter wins; otherwise the card
41
+ // is generated from the shared template.
38
42
  if (page.value?.seo?.ogImage) {
39
43
  useSeoMeta({
40
44
  ogImage: page.value.seo.ogImage,
41
45
  twitterImage: page.value.seo.ogImage,
42
46
  });
47
+ } else {
48
+ defineOgImage("DocsSatori", {
49
+ headline: appConfig.header?.title,
50
+ title,
51
+ description,
52
+ });
43
53
  }
44
54
  </script>
45
55
 
@@ -83,6 +83,12 @@ watch(
83
83
  },
84
84
  );
85
85
 
86
+ defineOgImage("DocsSatori", {
87
+ headline: headline.value,
88
+ title,
89
+ description,
90
+ });
91
+
86
92
  const github = computed(() => (appConfig.github ? appConfig.github : null));
87
93
 
88
94
  const editLink = computed(() => {