@component-anatomy/storybook 0.0.2 → 0.1.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.
@@ -0,0 +1,210 @@
1
+ /**
2
+ * The anatomy part table — pure presentation, no Storybook imports.
3
+ *
4
+ * This module is deliberately free of `storybook/manager-api` and
5
+ * `storybook/preview-api`: it is bundled into *both* the manager entry (the
6
+ * addon panel) and the blocks entry (the `<Anatomy>` MDX doc block), which
7
+ * run in different runtimes and cannot share either of those APIs. Only the
8
+ * data acquisition differs between the two; the markup must not.
9
+ */
10
+ import React from 'react';
11
+ import { useTheme } from 'storybook/theming';
12
+ import type { AnatomyPartDefinition } from '@component-anatomy/core';
13
+
14
+ /** Fallback accent when the story sets no `anatomy.theme.accent`. */
15
+ export const ACCENT_FALLBACK = '#4f46e5';
16
+
17
+ /**
18
+ * The subset of Storybook's theme this table reads. Typed loosely on purpose:
19
+ * `useTheme()` resolves to an empty `Theme` interface (Emotion augmentation),
20
+ * and outside a ThemeProvider it returns `{}` — so every read is optional and
21
+ * every value has a literal fallback below.
22
+ */
23
+ type PartialStorybookTheme = {
24
+ fgColor?: { default?: string; muted?: string };
25
+ bgColor?: { muted?: string };
26
+ borderColor?: { default?: string };
27
+ typography?: { fonts?: { base?: string; mono?: string } };
28
+ };
29
+
30
+ const FONT_BASE_FALLBACK =
31
+ '"Nunito Sans", -apple-system, ".SFNSText-Regular", "San Francisco", BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif';
32
+ const FONT_MONO_FALLBACK = 'ui-monospace, "Cascadia Code", "Fira Mono", monospace';
33
+
34
+ /**
35
+ * Resolves the table's chrome colors from Storybook's theme so the same
36
+ * markup reads correctly in the manager panel *and* in a docs page under a
37
+ * dark theme. The accent is intentionally not theme-derived — it is the
38
+ * addon's identity color and stays stable unless a story overrides it.
39
+ */
40
+ function usePalette() {
41
+ const theme = useTheme() as PartialStorybookTheme;
42
+ return {
43
+ text: theme.fgColor?.default,
44
+ muted: theme.fgColor?.muted ?? '#6b7280',
45
+ mutedBg: theme.bgColor?.muted ?? 'rgba(0,0,0,0.06)',
46
+ border: theme.borderColor?.default ?? 'rgba(0,0,0,0.08)',
47
+ fontBase: theme.typography?.fonts?.base ?? FONT_BASE_FALLBACK,
48
+ fontMono: theme.typography?.fonts?.mono ?? FONT_MONO_FALLBACK,
49
+ };
50
+ }
51
+
52
+ type Palette = ReturnType<typeof usePalette>;
53
+
54
+ const makeStyles = (p: Palette): Record<string, React.CSSProperties> => ({
55
+ container: {
56
+ padding: '12px 16px',
57
+ fontFamily: p.fontBase,
58
+ fontSize: 13,
59
+ lineHeight: 1.5,
60
+ color: p.text,
61
+ },
62
+ empty: {
63
+ color: p.muted,
64
+ margin: 0,
65
+ },
66
+ code: {
67
+ fontFamily: p.fontMono,
68
+ fontSize: '0.85em',
69
+ background: p.mutedBg,
70
+ borderRadius: 3,
71
+ padding: '1px 5px',
72
+ },
73
+ list: {
74
+ display: 'flex',
75
+ flexDirection: 'column',
76
+ gap: 2,
77
+ margin: 0,
78
+ padding: 0,
79
+ listStyle: 'none',
80
+ },
81
+ entry: {
82
+ padding: '8px 10px',
83
+ borderRadius: 6,
84
+ border: '1px solid transparent',
85
+ cursor: 'default',
86
+ outline: 'none',
87
+ transition: 'background 120ms ease, border-color 120ms ease',
88
+ },
89
+ header: {
90
+ display: 'flex',
91
+ alignItems: 'center',
92
+ gap: 8,
93
+ },
94
+ indicator: {
95
+ width: 8,
96
+ height: 8,
97
+ borderRadius: '50%',
98
+ border: `2px solid ${p.border}`,
99
+ flexShrink: 0,
100
+ transition: 'background 120ms ease, border-color 120ms ease',
101
+ },
102
+ name: {
103
+ fontWeight: 700,
104
+ flex: 1,
105
+ },
106
+ id: {
107
+ fontFamily: p.fontMono,
108
+ fontSize: 10,
109
+ padding: '1px 6px',
110
+ borderRadius: 4,
111
+ background: p.mutedBg,
112
+ border: `1px solid ${p.border}`,
113
+ color: p.muted,
114
+ flexShrink: 0,
115
+ },
116
+ description: {
117
+ margin: '4px 0 0',
118
+ paddingLeft: 16,
119
+ color: p.muted,
120
+ },
121
+ });
122
+
123
+ /**
124
+ * An inline `<code>` styled for the surrounding message text. Exported so
125
+ * both runtimes can compose their own empty-state copy without duplicating
126
+ * the style object.
127
+ */
128
+ export const AnatomyCode: React.FC<{ children: React.ReactNode }> = ({ children }) => {
129
+ const styles = makeStyles(usePalette());
130
+ return <code style={styles.code}>{children}</code>;
131
+ };
132
+
133
+ /** A padded, muted paragraph — used for every "nothing to show" state. */
134
+ export const AnatomyMessage: React.FC<{ children: React.ReactNode }> = ({ children }) => {
135
+ const styles = makeStyles(usePalette());
136
+ return (
137
+ <div style={styles.container}>
138
+ <p style={styles.empty}>{children}</p>
139
+ </div>
140
+ );
141
+ };
142
+
143
+ export type AnatomyTableProps = {
144
+ /** Parts to list, in the order they should be shown. */
145
+ parts: AnatomyPartDefinition[];
146
+ /** Id of the part currently highlighted in the canvas, if any. */
147
+ activeId?: string | null;
148
+ /** Accent color for the active state. Defaults to {@link ACCENT_FALLBACK}. */
149
+ accent?: string;
150
+ /** Called when the user hovers or focuses an entry. */
151
+ onItemEnter?: (partId: string) => void;
152
+ /** Called when the user leaves or blurs an entry. */
153
+ onItemLeave?: () => void;
154
+ };
155
+
156
+ export const AnatomyTable: React.FC<AnatomyTableProps> = ({
157
+ parts,
158
+ activeId = null,
159
+ accent = ACCENT_FALLBACK,
160
+ onItemEnter,
161
+ onItemLeave,
162
+ }) => {
163
+ const palette = usePalette();
164
+ const styles = makeStyles(palette);
165
+
166
+ return (
167
+ <div style={styles.container}>
168
+ <ul style={styles.list} role="list" aria-label="Anatomy parts">
169
+ {parts.map((part) => {
170
+ const active = activeId === part.id;
171
+ return (
172
+ <li
173
+ key={part.id}
174
+ role="listitem"
175
+ tabIndex={0}
176
+ aria-label={part.name}
177
+ style={{
178
+ ...styles.entry,
179
+ background: active ? `color-mix(in srgb, ${accent} 7%, transparent)` : undefined,
180
+ borderColor: active
181
+ ? `color-mix(in srgb, ${accent} 20%, transparent)`
182
+ : 'transparent',
183
+ }}
184
+ onMouseEnter={() => onItemEnter?.(part.id)}
185
+ onMouseLeave={() => onItemLeave?.()}
186
+ onFocus={() => onItemEnter?.(part.id)}
187
+ onBlur={() => onItemLeave?.()}
188
+ >
189
+ <div style={styles.header}>
190
+ <span
191
+ aria-hidden="true"
192
+ style={{
193
+ ...styles.indicator,
194
+ background: active ? accent : undefined,
195
+ borderColor: active ? accent : palette.border,
196
+ }}
197
+ />
198
+ <span style={{ ...styles.name, color: active ? accent : undefined }}>
199
+ {part.name}
200
+ </span>
201
+ <code style={styles.id}>{part.id}</code>
202
+ </div>
203
+ {part.description && <p style={styles.description}>{part.description}</p>}
204
+ </li>
205
+ );
206
+ })}
207
+ </ul>
208
+ </div>
209
+ );
210
+ };
package/src/Panel.tsx CHANGED
@@ -3,78 +3,11 @@ import { useChannel, useParameter, useStorybookApi } from 'storybook/manager-api
3
3
  import type { AnatomyPartDefinition } from '@component-anatomy/core';
4
4
 
5
5
  import { EVENTS, PARAM_KEY } from './constants.js';
6
+ import { matchesStory } from './channel.js';
7
+ import type { PartEnterEvent, PartsEvent, StoryScopedEvent } from './channel.js';
8
+ import { ACCENT_FALLBACK, AnatomyCode, AnatomyMessage, AnatomyTable } from './AnatomyTable.js';
6
9
  import type { AnatomyParameters } from './types.js';
7
10
 
8
- const ACCENT_FALLBACK = '#4f46e5';
9
-
10
- const styles: Record<string, React.CSSProperties> = {
11
- container: {
12
- padding: '12px 16px',
13
- fontFamily:
14
- '"Nunito Sans", -apple-system, ".SFNSText-Regular", "San Francisco", BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif',
15
- fontSize: 13,
16
- lineHeight: 1.5,
17
- },
18
- empty: {
19
- color: '#6b7280',
20
- },
21
- code: {
22
- fontFamily: 'ui-monospace, "Cascadia Code", "Fira Mono", monospace',
23
- fontSize: '0.85em',
24
- background: 'rgba(0,0,0,0.06)',
25
- borderRadius: 3,
26
- padding: '1px 5px',
27
- },
28
- list: {
29
- display: 'flex',
30
- flexDirection: 'column',
31
- gap: 2,
32
- margin: 0,
33
- padding: 0,
34
- listStyle: 'none',
35
- },
36
- entry: {
37
- padding: '8px 10px',
38
- borderRadius: 6,
39
- border: '1px solid transparent',
40
- cursor: 'default',
41
- outline: 'none',
42
- transition: 'background 120ms ease, border-color 120ms ease',
43
- },
44
- header: {
45
- display: 'flex',
46
- alignItems: 'center',
47
- gap: 8,
48
- },
49
- indicator: {
50
- width: 8,
51
- height: 8,
52
- borderRadius: '50%',
53
- border: '2px solid #d1d5db',
54
- flexShrink: 0,
55
- transition: 'background 120ms ease, border-color 120ms ease',
56
- },
57
- name: {
58
- fontWeight: 700,
59
- flex: 1,
60
- },
61
- id: {
62
- fontFamily: 'ui-monospace, "Cascadia Code", "Fira Mono", monospace',
63
- fontSize: 10,
64
- padding: '1px 6px',
65
- borderRadius: 4,
66
- background: 'rgba(0,0,0,0.05)',
67
- border: '1px solid rgba(0,0,0,0.08)',
68
- color: '#6b7280',
69
- flexShrink: 0,
70
- },
71
- description: {
72
- margin: '4px 0 0',
73
- paddingLeft: 16,
74
- color: '#6b7280',
75
- },
76
- };
77
-
78
11
  export const Panel: React.FC = () => {
79
12
  const params = useParameter<AnatomyParameters | undefined>(PARAM_KEY, undefined);
80
13
  const api = useStorybookApi();
@@ -83,17 +16,31 @@ export const Panel: React.FC = () => {
83
16
  const [discovered, setDiscovered] = useState<AnatomyPartDefinition[]>([]);
84
17
  const [activeId, setActiveId] = useState<string | null>(null);
85
18
 
86
- const emit = useChannel({
87
- [EVENTS.PARTS]: ({ parts }: { parts: AnatomyPartDefinition[] }) => setDiscovered(parts),
88
- [EVENTS.PART_ENTER]: ({ partId }: { partId: string }) => setActiveId(partId),
89
- [EVENTS.PART_LEAVE]: () => setActiveId(null),
90
- });
19
+ // `useChannel` captures its handlers on the given deps — `storyId` has to be
20
+ // listed or the filters below would close over a stale story.
21
+ const emit = useChannel(
22
+ {
23
+ [EVENTS.PARTS]: (event: PartsEvent) => {
24
+ if (!matchesStory(event?.storyId, storyId)) return;
25
+ setDiscovered(event.parts);
26
+ },
27
+ [EVENTS.PART_ENTER]: (event: PartEnterEvent) => {
28
+ if (!matchesStory(event?.storyId, storyId)) return;
29
+ setActiveId(event.partId);
30
+ },
31
+ [EVENTS.PART_LEAVE]: (event: StoryScopedEvent = {}) => {
32
+ if (!matchesStory(event?.storyId, storyId)) return;
33
+ setActiveId(null);
34
+ },
35
+ },
36
+ [storyId]
37
+ );
91
38
 
92
39
  // Ask the preview for the current part list on mount / story change.
93
40
  useEffect(() => {
94
41
  setDiscovered([]);
95
42
  setActiveId(null);
96
- emit(EVENTS.PARTS_REQUEST);
43
+ emit(EVENTS.PARTS_REQUEST, { storyId } satisfies StoryScopedEvent);
97
44
  }, [storyId]);
98
45
 
99
46
  const parts = params?.parts ?? discovered;
@@ -101,68 +48,30 @@ export const Panel: React.FC = () => {
101
48
 
102
49
  if (!params || params.disable) {
103
50
  return (
104
- <div style={styles.container}>
105
- <p style={styles.empty}>
106
- No anatomy configured for this story. Add{' '}
107
- <code style={styles.code}>parameters.anatomy</code> and annotate elements with{' '}
108
- <code style={styles.code}>data-part="name"</code>.
109
- </p>
110
- </div>
51
+ <AnatomyMessage>
52
+ No anatomy configured for this story. Add <AnatomyCode>parameters.anatomy</AnatomyCode> and
53
+ annotate elements with <AnatomyCode>data-part="name"</AnatomyCode>.
54
+ </AnatomyMessage>
111
55
  );
112
56
  }
113
57
 
114
58
  if (parts.length === 0) {
115
59
  return (
116
- <div style={styles.container}>
117
- <p style={styles.empty}>
118
- No parts found. Annotate elements in your story with{' '}
119
- <code style={styles.code}>data-part="name"</code> or pass{' '}
120
- <code style={styles.code}>parameters.anatomy.parts</code>.
121
- </p>
122
- </div>
60
+ <AnatomyMessage>
61
+ No parts found. Annotate elements in your story with{' '}
62
+ <AnatomyCode>data-part="name"</AnatomyCode> or pass{' '}
63
+ <AnatomyCode>parameters.anatomy.parts</AnatomyCode>.
64
+ </AnatomyMessage>
123
65
  );
124
66
  }
125
67
 
126
68
  return (
127
- <div style={styles.container}>
128
- <ul style={styles.list} role="list" aria-label="Anatomy parts">
129
- {parts.map((part) => {
130
- const active = activeId === part.id;
131
- return (
132
- <li
133
- key={part.id}
134
- role="listitem"
135
- tabIndex={0}
136
- aria-label={part.name}
137
- style={{
138
- ...styles.entry,
139
- background: active ? `color-mix(in srgb, ${accent} 7%, transparent)` : undefined,
140
- borderColor: active ? `color-mix(in srgb, ${accent} 20%, transparent)` : 'transparent',
141
- }}
142
- onMouseEnter={() => emit(EVENTS.HOVER_ITEM, { partId: part.id })}
143
- onMouseLeave={() => emit(EVENTS.LEAVE_ITEM)}
144
- onFocus={() => emit(EVENTS.HOVER_ITEM, { partId: part.id })}
145
- onBlur={() => emit(EVENTS.LEAVE_ITEM)}
146
- >
147
- <div style={styles.header}>
148
- <span
149
- aria-hidden="true"
150
- style={{
151
- ...styles.indicator,
152
- background: active ? accent : undefined,
153
- borderColor: active ? accent : '#d1d5db',
154
- }}
155
- />
156
- <span style={{ ...styles.name, color: active ? accent : undefined }}>
157
- {part.name}
158
- </span>
159
- <code style={styles.id}>{part.id}</code>
160
- </div>
161
- {part.description && <p style={styles.description}>{part.description}</p>}
162
- </li>
163
- );
164
- })}
165
- </ul>
166
- </div>
69
+ <AnatomyTable
70
+ parts={parts}
71
+ activeId={activeId}
72
+ accent={accent}
73
+ onItemEnter={(partId) => emit(EVENTS.HOVER_ITEM, { storyId, partId })}
74
+ onItemLeave={() => emit(EVENTS.LEAVE_ITEM, { storyId })}
75
+ />
167
76
  );
168
77
  };
package/src/blocks.tsx ADDED
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Docs blocks entry — the `<Anatomy>` block for MDX pages.
3
+ *
4
+ * Unlike the addon panel, this runs in the **preview iframe**, where
5
+ * `storybook/manager-api` does not exist. It reaches the story's controller
6
+ * over the addon channel instead: `Channel.emit` dispatches to local
7
+ * listeners as well as across transports, so a block and the decorator that
8
+ * mounted the story talk to each other directly, in-frame, with no extra
9
+ * plumbing.
10
+ *
11
+ * ```mdx
12
+ * import { Meta, Canvas } from '@storybook/addon-docs/blocks';
13
+ * import { Anatomy } from '@component-anatomy/storybook/blocks';
14
+ * import * as ButtonStories from './Button.stories';
15
+ *
16
+ * <Meta of={ButtonStories} />
17
+ *
18
+ * <Canvas of={ButtonStories.Anatomy} />
19
+ * <Anatomy of={ButtonStories.Anatomy} />
20
+ * ```
21
+ */
22
+ import React, { useEffect, useState } from 'react';
23
+ import { addons } from 'storybook/preview-api';
24
+ import { Unstyled, useOf } from '@storybook/addon-docs/blocks';
25
+ import type { Of } from '@storybook/addon-docs/blocks';
26
+ import type { AnatomyPartDefinition } from '@component-anatomy/core';
27
+
28
+ import { EVENTS, PARAM_KEY } from './constants.js';
29
+ import { matchesStory } from './channel.js';
30
+ import type { PartEnterEvent, PartsEvent, StoryScopedEvent } from './channel.js';
31
+ import { ACCENT_FALLBACK, AnatomyCode, AnatomyMessage, AnatomyTable } from './AnatomyTable.js';
32
+ import type { AnatomyParameters } from './types.js';
33
+
34
+ export type AnatomyBlockProps = {
35
+ /**
36
+ * The CSF export to document — a story export, or the whole module export
37
+ * of a CSF file to read the meta's parameters.
38
+ *
39
+ * Omit it on an attached docs page (one with `<Meta of={...} />`, or an
40
+ * autodocs page) to fall back to the page's current story, mirroring how
41
+ * the other docs blocks resolve `of`.
42
+ */
43
+ of?: Of;
44
+ /**
45
+ * Part list override. Skips both `parameters.anatomy.parts` and
46
+ * auto-discovery — useful for a hand-curated subset in prose.
47
+ */
48
+ parts?: AnatomyPartDefinition[];
49
+ /**
50
+ * Two-way hover sync with the rendered story. Default: `true`. Set to
51
+ * `false` for a purely static table (also skips auto-discovery, since that
52
+ * arrives over the channel).
53
+ */
54
+ sync?: boolean;
55
+ };
56
+
57
+ /**
58
+ * `addons.getChannel()` throws when no channel is installed. That should not
59
+ * happen inside a rendered docs page, but an MDX page is user-authored
60
+ * content and a throw here would blank the whole page — degrade to a static
61
+ * table instead.
62
+ */
63
+ function getChannelSafely() {
64
+ try {
65
+ return addons.getChannel();
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ export const Anatomy: React.FC<AnatomyBlockProps> = ({ of, parts: partsProp, sync = true }) => {
72
+ const resolved = useOf(of ?? 'story', ['story', 'meta']);
73
+
74
+ const params = (
75
+ resolved.type === 'meta'
76
+ ? resolved.preparedMeta.parameters?.[PARAM_KEY]
77
+ : resolved.story.parameters?.[PARAM_KEY]
78
+ ) as AnatomyParameters | undefined;
79
+
80
+ // Only a story has a canvas to sync with. `of={SomeStories}` (a meta)
81
+ // documents the component as a whole and can render static parts only.
82
+ const storyId = resolved.type === 'story' ? resolved.story.id : undefined;
83
+
84
+ const [discovered, setDiscovered] = useState<AnatomyPartDefinition[]>([]);
85
+ const [activeId, setActiveId] = useState<string | null>(null);
86
+
87
+ const staticParts = partsProp ?? params?.parts;
88
+ // Hover sync is wired even when the parts are static — an explicit list
89
+ // still wants the canvas to light up. Only discovery depends on the channel.
90
+ const wired = sync && !!storyId;
91
+
92
+ useEffect(() => {
93
+ setDiscovered([]);
94
+ setActiveId(null);
95
+ if (!wired) return;
96
+
97
+ const channel = getChannelSafely();
98
+ if (!channel) return;
99
+
100
+ const onParts = (event: PartsEvent) => {
101
+ if (!matchesStory(event?.storyId, storyId)) return;
102
+ setDiscovered(event.parts);
103
+ };
104
+ const onEnter = (event: PartEnterEvent) => {
105
+ if (!matchesStory(event?.storyId, storyId)) return;
106
+ setActiveId(event.partId);
107
+ };
108
+ const onLeave = (event: StoryScopedEvent = {}) => {
109
+ if (!matchesStory(event?.storyId, storyId)) return;
110
+ setActiveId(null);
111
+ };
112
+
113
+ channel.on(EVENTS.PARTS, onParts);
114
+ channel.on(EVENTS.PART_ENTER, onEnter);
115
+ channel.on(EVENTS.PART_LEAVE, onLeave);
116
+
117
+ // The block usually mounts before the story below it finishes rendering;
118
+ // the request covers the other order.
119
+ channel.emit(EVENTS.PARTS_REQUEST, { storyId } satisfies StoryScopedEvent);
120
+
121
+ return () => {
122
+ channel.off(EVENTS.PARTS, onParts);
123
+ channel.off(EVENTS.PART_ENTER, onEnter);
124
+ channel.off(EVENTS.PART_LEAVE, onLeave);
125
+ };
126
+ }, [wired, storyId]);
127
+
128
+ const parts = staticParts ?? discovered;
129
+ const accent = params?.theme?.accent ?? ACCENT_FALLBACK;
130
+
131
+ const emitHover = (partId: string) => {
132
+ if (!wired) return;
133
+ getChannelSafely()?.emit(EVENTS.HOVER_ITEM, { storyId, partId });
134
+ };
135
+ const emitLeave = () => {
136
+ if (!wired) return;
137
+ getChannelSafely()?.emit(EVENTS.LEAVE_ITEM, { storyId } satisfies StoryScopedEvent);
138
+ };
139
+
140
+ // `Unstyled` keeps the docs page's prose CSS (`.sbdocs` restyles ul/li/p/
141
+ // code) from reaching the table.
142
+ if (!params && !partsProp) {
143
+ return (
144
+ <Unstyled>
145
+ <AnatomyMessage>
146
+ No anatomy configured. Add <AnatomyCode>parameters.anatomy</AnatomyCode> to the story you
147
+ pass to <AnatomyCode>of</AnatomyCode>, or pass a{' '}
148
+ <AnatomyCode>parts</AnatomyCode> list to this block.
149
+ </AnatomyMessage>
150
+ </Unstyled>
151
+ );
152
+ }
153
+
154
+ if (params?.disable && !partsProp) {
155
+ return (
156
+ <Unstyled>
157
+ <AnatomyMessage>
158
+ Anatomy is disabled for this story (<AnatomyCode>anatomy.disable</AnatomyCode>).
159
+ </AnatomyMessage>
160
+ </Unstyled>
161
+ );
162
+ }
163
+
164
+ if (parts.length === 0) {
165
+ return (
166
+ <Unstyled>
167
+ <AnatomyMessage>
168
+ {resolved.type === 'meta'
169
+ ? 'No parts found. A meta has no canvas to discover parts from — pass a story to `of`, or list `parts` explicitly.'
170
+ : 'No parts found. Auto-discovery reads the rendered story, so make sure it is on this page (e.g. with a `<Canvas of={…} />` block above), or list `parts` explicitly.'}
171
+ </AnatomyMessage>
172
+ </Unstyled>
173
+ );
174
+ }
175
+
176
+ return (
177
+ <Unstyled>
178
+ <AnatomyTable
179
+ parts={parts}
180
+ activeId={activeId}
181
+ accent={accent}
182
+ onItemEnter={emitHover}
183
+ onItemLeave={emitLeave}
184
+ />
185
+ </Unstyled>
186
+ );
187
+ };
package/src/channel.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Shared channel payload contract between the preview decorator, the manager
3
+ * panel, and the MDX doc block.
4
+ *
5
+ * Every payload carries the `storyId` it refers to. In story view this is
6
+ * redundant — only one story is mounted — but a docs page mounts *many*
7
+ * stories at once, each with its own controller, and each `<Anatomy>` block
8
+ * must talk to exactly one of them. Without addressing, hovering a part in
9
+ * one block highlights the matching part in every other story on the page.
10
+ */
11
+ import type { AnatomyPartDefinition } from '@component-anatomy/core';
12
+
13
+ /** preview → consumers: the resolved part list for one story. */
14
+ export type PartsEvent = { storyId?: string; parts: AnatomyPartDefinition[] };
15
+
16
+ /** preview → consumers: a part became active in that story's canvas. */
17
+ export type PartEnterEvent = { storyId?: string; partId: string };
18
+
19
+ /** consumer → preview: highlight this part in that story's canvas. */
20
+ export type HoverItemEvent = { storyId?: string; partId: string };
21
+
22
+ /** Payload for the events that only need to name a story. */
23
+ export type StoryScopedEvent = { storyId?: string };
24
+
25
+ /**
26
+ * Whether an event addressed to `eventStoryId` concerns `storyId`.
27
+ *
28
+ * A missing id on *either* side matches everything. That keeps the protocol
29
+ * backward compatible: a manager panel from a newer build still understands
30
+ * an older preview bundle that emits unaddressed events, and vice versa.
31
+ */
32
+ export const matchesStory = (
33
+ eventStoryId: string | undefined,
34
+ storyId: string | undefined
35
+ ): boolean => !eventStoryId || !storyId || eventStoryId === storyId;
package/src/constants.ts CHANGED
@@ -4,18 +4,24 @@ export const PANEL_ID = `${ADDON_ID}/panel`;
4
4
  /** Story parameter key: `parameters.anatomy = { ... }` */
5
5
  export const PARAM_KEY = 'anatomy';
6
6
 
7
- /** Channel events used to sync the manager panel with the preview iframe. */
7
+ /**
8
+ * Channel events used to sync the manager panel — and the `<Anatomy>` MDX doc
9
+ * block, which runs in the preview iframe — with the story canvas.
10
+ *
11
+ * Every payload carries the `storyId` it concerns; see `channel.ts` for the
12
+ * payload types and the `matchesStory` filter each listener applies.
13
+ */
8
14
  export const EVENTS = {
9
- /** preview → manager: a part became active in the canvas (hover/programmatic). */
15
+ /** preview → consumers: a part became active in the canvas (hover/programmatic). */
10
16
  PART_ENTER: `${ADDON_ID}/part-enter`,
11
- /** preview → manager: no part is active anymore. */
17
+ /** preview → consumers: no part is active anymore. */
12
18
  PART_LEAVE: `${ADDON_ID}/part-leave`,
13
- /** preview → manager: resolved part list for the current story. */
19
+ /** preview → consumers: resolved part list for a story. */
14
20
  PARTS: `${ADDON_ID}/parts`,
15
- /** manager → preview: the user hovers/focuses a panel entry. */
21
+ /** consumers → preview: the user hovers/focuses a panel entry. */
16
22
  HOVER_ITEM: `${ADDON_ID}/hover-item`,
17
- /** manager → preview: the user left a panel entry. */
23
+ /** consumers → preview: the user left a panel entry. */
18
24
  LEAVE_ITEM: `${ADDON_ID}/leave-item`,
19
- /** manager → preview: the panel mounted and wants the current part list. */
25
+ /** consumers → preview: a panel/block mounted and wants the current part list. */
20
26
  PARTS_REQUEST: `${ADDON_ID}/parts-request`,
21
27
  } as const;
package/src/index.ts CHANGED
@@ -1,2 +1,7 @@
1
1
  export { ADDON_ID, PANEL_ID, PARAM_KEY, EVENTS } from './constants.js';
2
2
  export type { AnatomyParameters } from './types.js';
3
+
4
+ // The `<Anatomy>` doc block lives in the `./blocks` subpath, not here: this
5
+ // entry is loaded at config time by `.storybook/main.ts` (and built to CJS),
6
+ // while the block needs React and `@storybook/addon-docs`, both optional
7
+ // peers that must not become load-bearing for `addons: ['...']` to work.