@component-anatomy/astro 0.0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Julien Déramond
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # @component-anatomy/astro
2
+
3
+ > Astro integration for component-anatomy — SSR-rendered Markdown descriptions, live hover sync, zero client-side Markdown bundle.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @component-anatomy/astro
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ Import the `<ComponentAnatomy>` component and wrap any HTML you want to document:
14
+
15
+ ```astro
16
+ ---
17
+ import ComponentAnatomy from '@component-anatomy/astro/ComponentAnatomy.astro';
18
+
19
+ const parts = [
20
+ {
21
+ id: 'track',
22
+ name: 'Track',
23
+ description: 'The **rail** where the thumb slides. Takes full width of the container.',
24
+ },
25
+ {
26
+ id: 'thumb',
27
+ name: 'Thumb',
28
+ description: 'The draggable handle. Constrained to the `Track`.',
29
+ },
30
+ ];
31
+ ---
32
+
33
+ <ComponentAnatomy parts={parts}>
34
+ <div class="slider">
35
+ <div class="track" data-part="track"></div>
36
+ <div class="thumb" data-part="thumb"></div>
37
+ </div>
38
+ </ComponentAnatomy>
39
+ ```
40
+
41
+ ## Props
42
+
43
+ | Prop | Type | Required | Description |
44
+ |------|------|----------|-------------|
45
+ | `parts` | `AnatomyPartDefinition[]` | No | Part definitions. If omitted, parts are auto-discovered from `data-part` attributes. |
46
+ | `preset` | `'default' \| 'minimal' \| 'contrast' \| 'blueprint'` | No | Visual preset for overlays and panel accent. |
47
+ | `theme` | `AnatomyTheme` | No | Theme token overrides, e.g. `{ accent: '#0d9488', overlayRadius: 8 }`. |
48
+ | `overlayLabel` | `boolean` | No | Show the floating name chip. Default `true`. |
49
+ | `label` | `string` | No | Accessible label of the block. Default "Component anatomy". |
50
+ | `class` | `string` | No | Extra class on the wrapper. |
51
+
52
+ ## Theming
53
+
54
+ ```astro
55
+ <ComponentAnatomy parts={parts} preset="blueprint">
56
+ <Slider />
57
+ </ComponentAnatomy>
58
+
59
+ <ComponentAnatomy parts={parts} theme={{ accent: '#0d9488' }}>
60
+ <Slider />
61
+ <p slot="header">Hover a part below, or the component itself.</p>
62
+ </ComponentAnatomy>
63
+ ```
64
+
65
+ The theme applies to both the canvas overlays and the panel accents. Layout variables (`--ca-gap`, `--ca-preview-bg`, `--ca-preview-padding`, `--ca-panel-max-height`) can be set on the wrapper via `class`. Full guide: [customization docs](https://julien-deramond.github.io/component-anatomy/docs/customization).
66
+
67
+ `AnatomyPartDefinition`:
68
+
69
+ ```ts
70
+ {
71
+ id: string; // Matches the data-part value on the DOM element
72
+ name: string; // Display name shown in the panel
73
+ description?: string; // Raw Markdown — rendered server-side by marked
74
+ }
75
+ ```
76
+
77
+ ## How it works
78
+
79
+ - The server renders the two-column layout (preview + panel) with Markdown descriptions converted to HTML by [`marked`](https://marked.js.org/) — no Markdown library is shipped to the browser
80
+ - The client script calls `createAnatomy()` from `@component-anatomy/core` to wire up hover sync and overlays
81
+ - Multiple `<ComponentAnatomy>` instances on the same page are isolated via a random instance ID
82
+
83
+ ## Requirements
84
+
85
+ - Astro ≥ 4.0
86
+ - Node ≥ 18
87
+
88
+ ## License
89
+
90
+ MIT
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@component-anatomy/astro",
3
+ "version": "0.0.1",
4
+ "description": "Astro integration for component-anatomy — SSR-rendered anatomy panels with live hover sync",
5
+ "license": "MIT",
6
+ "author": "Julien Déramond",
7
+ "homepage": "https://github.com/julien-deramond/component-anatomy#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/julien-deramond/component-anatomy",
11
+ "directory": "packages/astro"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/julien-deramond/component-anatomy/issues"
15
+ },
16
+ "keywords": [
17
+ "component-anatomy",
18
+ "astro",
19
+ "astro-component",
20
+ "design-system",
21
+ "documentation",
22
+ "anatomy",
23
+ "interactive"
24
+ ],
25
+ "type": "module",
26
+ "exports": {
27
+ ".": {
28
+ "import": "./src/index.ts",
29
+ "types": "./src/index.ts"
30
+ },
31
+ "./ComponentAnatomy.astro": "./src/ComponentAnatomy.astro"
32
+ },
33
+ "files": [
34
+ "src"
35
+ ],
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "dependencies": {
40
+ "marked": "^12.0.0",
41
+ "@component-anatomy/core": "^0.0.1"
42
+ },
43
+ "devDependencies": {
44
+ "astro": "^4.11.0",
45
+ "typescript": "^5.4.5"
46
+ },
47
+ "peerDependencies": {
48
+ "astro": ">=4.0.0"
49
+ },
50
+ "engines": {
51
+ "node": ">=18.0.0"
52
+ },
53
+ "scripts": {
54
+ "build": "echo 'No build step — Astro resolves .astro files directly'",
55
+ "typecheck": "tsc --noEmit"
56
+ }
57
+ }
@@ -0,0 +1,411 @@
1
+ ---
2
+ import { marked } from 'marked';
3
+ import { resolveThemeVars } from '@component-anatomy/core';
4
+ import type {
5
+ AnatomyPartDefinition,
6
+ AnatomyPresetName,
7
+ AnatomyTheme,
8
+ } from '@component-anatomy/core';
9
+
10
+ export interface Props {
11
+ parts?: AnatomyPartDefinition[];
12
+ class?: string;
13
+ label?: string;
14
+ /** Named visual preset for overlays + panel accent. Default: 'default'. */
15
+ preset?: AnatomyPresetName;
16
+ /** Theme token overrides (applied on top of the preset). */
17
+ theme?: AnatomyTheme;
18
+ /** Show the floating name label chip over highlighted elements. Default: true. */
19
+ overlayLabel?: boolean;
20
+ }
21
+
22
+ const {
23
+ parts,
24
+ class: className,
25
+ label = 'Component anatomy',
26
+ preset,
27
+ theme,
28
+ overlayLabel = true,
29
+ } = Astro.props;
30
+
31
+ const instanceId = `ca-${Math.random().toString(36).slice(2, 9)}`;
32
+
33
+ // Resolved theme vars are applied twice on purpose:
34
+ // 1. Inline on .ca-root — so panel accents (active entry, pill) match the theme.
35
+ // 2. Serialized to the client — overlays live in document.body, outside this
36
+ // element's cascade, so createAnatomy() needs the tokens directly.
37
+ const themeVars = resolveThemeVars(preset, theme);
38
+ const themeStyle = Object.entries(themeVars)
39
+ .map(([k, v]) => `${k}: ${v};`)
40
+ .join(' ');
41
+ const clientConfig = JSON.stringify({ preset, theme, overlayLabel });
42
+
43
+ const renderedParts = await Promise.all(
44
+ (parts ?? []).map(async (part) => ({
45
+ ...part,
46
+ html: part.description ? await marked.parse(part.description) : null,
47
+ }))
48
+ );
49
+ ---
50
+
51
+ <div
52
+ class:list={['ca-root', className]}
53
+ data-anatomy-root={instanceId}
54
+ data-anatomy-config={clientConfig}
55
+ aria-label={label}
56
+ style={themeStyle || undefined}
57
+ >
58
+ <!-- Left: live component preview with dot-grid background -->
59
+ <div class="ca-preview" data-anatomy-preview={instanceId}>
60
+ <div class="ca-preview-inner">
61
+ <slot />
62
+ </div>
63
+ </div>
64
+
65
+ <!-- Right: documentation panel -->
66
+ <div class="ca-panel-wrapper">
67
+ {Astro.slots.has('header') && (
68
+ <div class="ca-panel-header">
69
+ <slot name="header" />
70
+ </div>
71
+ )}
72
+ <!-- Sticky "active part" indicator — visible when scrolled past the active entry -->
73
+ <div class="ca-active-pill" data-anatomy-pill={instanceId} aria-hidden="true">
74
+ <span class="ca-active-pill-dot"></span>
75
+ <span class="ca-active-pill-name"></span>
76
+ </div>
77
+
78
+ <div
79
+ class="ca-panel"
80
+ data-anatomy-panel={instanceId}
81
+ role="list"
82
+ aria-label="Anatomy parts"
83
+ >
84
+ {renderedParts.length > 0
85
+ ? renderedParts.map((part) => (
86
+ <div
87
+ class="ca-part-entry"
88
+ data-anatomy-item={part.id}
89
+ role="listitem"
90
+ tabindex="0"
91
+ aria-label={part.name}
92
+ >
93
+ <div class="ca-part-header">
94
+ <span class="ca-part-indicator" aria-hidden="true"></span>
95
+ <span class="ca-part-name">{part.name}</span>
96
+ <code class="ca-part-id">{part.id}</code>
97
+ </div>
98
+ {part.html && (
99
+ <div class="ca-part-description" set:html={part.html} />
100
+ )}
101
+ </div>
102
+ ))
103
+ : (
104
+ <p class="ca-no-parts">
105
+ No parts defined. Add <code>data-part="name"</code> to your component elements.
106
+ </p>
107
+ )
108
+ }
109
+ </div>
110
+ </div>
111
+ </div>
112
+
113
+ <style>
114
+ /* ─── Layout ─── */
115
+ .ca-root {
116
+ display: grid;
117
+ grid-template-columns: 1fr 1fr;
118
+ gap: var(--ca-gap, 2rem);
119
+ align-items: start;
120
+ font-family: inherit;
121
+ }
122
+
123
+ /* ─── Preview ─── */
124
+ .ca-preview {
125
+ position: sticky;
126
+ top: 1.5rem;
127
+ border-radius: var(--ca-preview-radius, 10px);
128
+ border: 1px solid var(--ca-preview-border, #e5e7eb);
129
+ overflow: hidden;
130
+ background:
131
+ /* dot grid */
132
+ radial-gradient(circle, #d1d5db 1px, transparent 1px),
133
+ var(--ca-preview-bg, #f9fafb);
134
+ background-size: 20px 20px;
135
+ }
136
+
137
+ .ca-preview-inner {
138
+ display: flex;
139
+ align-items: center;
140
+ justify-content: center;
141
+ min-height: 160px;
142
+ padding: var(--ca-preview-padding, 2.5rem 2rem);
143
+ /* Slight backdrop so the component pops against the dot grid */
144
+ background: transparent;
145
+ }
146
+
147
+ /* ─── Panel wrapper ─── */
148
+ .ca-panel-wrapper {
149
+ position: relative;
150
+ }
151
+
152
+ /* ─── Optional header slot ─── */
153
+ .ca-panel-header {
154
+ margin-bottom: 0.75rem;
155
+ font-size: 0.875rem;
156
+ color: #4b5563;
157
+ }
158
+ .ca-panel-header :global(> :first-child) { margin-top: 0; }
159
+ .ca-panel-header :global(> :last-child) { margin-bottom: 0; }
160
+
161
+ /* ─── Sticky active pill ─── */
162
+ .ca-active-pill {
163
+ position: sticky;
164
+ top: 0;
165
+ z-index: 10;
166
+ display: none; /* shown via JS when scrolled */
167
+ align-items: center;
168
+ gap: 6px;
169
+ padding: 5px 10px;
170
+ margin-bottom: 4px;
171
+ border-radius: 6px;
172
+ background: var(--ca-label-bg, #4f46e5);
173
+ color: #fff;
174
+ font-size: 0.72rem;
175
+ font-weight: 600;
176
+ text-transform: uppercase;
177
+ letter-spacing: 0.07em;
178
+ pointer-events: none;
179
+ box-shadow: 0 2px 8px rgba(79,70,229,0.25);
180
+ }
181
+
182
+ .ca-active-pill.ca-pill--visible {
183
+ display: flex;
184
+ }
185
+
186
+ .ca-active-pill-dot {
187
+ width: 6px;
188
+ height: 6px;
189
+ border-radius: 50%;
190
+ background: rgba(255,255,255,0.7);
191
+ flex-shrink: 0;
192
+ }
193
+
194
+ /* ─── Panel ─── */
195
+ .ca-panel {
196
+ display: flex;
197
+ flex-direction: column;
198
+ gap: 2px;
199
+ max-height: var(--ca-panel-max-height, 480px);
200
+ overflow-y: auto;
201
+ scroll-behavior: smooth;
202
+ padding-right: 2px; /* room for scrollbar */
203
+
204
+ /* Subtle scrollbar */
205
+ scrollbar-width: thin;
206
+ scrollbar-color: #e5e7eb transparent;
207
+ }
208
+
209
+ .ca-panel::-webkit-scrollbar { width: 4px; }
210
+ .ca-panel::-webkit-scrollbar-track { background: transparent; }
211
+ .ca-panel::-webkit-scrollbar-thumb { background: #e5e7eb; border-radius: 99px; }
212
+
213
+ /* ─── Part entries ─── */
214
+ .ca-part-entry {
215
+ padding: 0.8rem 0.9rem;
216
+ border-radius: 8px;
217
+ cursor: default;
218
+ transition: background 120ms ease, border-color 120ms ease;
219
+ border: 1px solid transparent;
220
+ outline: none;
221
+ }
222
+
223
+ .ca-part-entry:focus-visible {
224
+ outline: 2px solid var(--ca-overlay-border, rgba(99, 102, 241, 0.7));
225
+ outline-offset: 2px;
226
+ }
227
+
228
+ .ca-part-entry[data-active] {
229
+ background: var(--ca-item-active-bg, rgba(99, 102, 241, 0.06));
230
+ border-color: rgba(99, 102, 241, 0.15);
231
+ }
232
+
233
+ /* ─── Part header row ─── */
234
+ .ca-part-header {
235
+ display: flex;
236
+ align-items: center;
237
+ gap: 8px;
238
+ margin-bottom: 0.4rem;
239
+ }
240
+
241
+ .ca-part-indicator {
242
+ width: 8px;
243
+ height: 8px;
244
+ border-radius: 50%;
245
+ border: 2px solid #d1d5db;
246
+ flex-shrink: 0;
247
+ transition: background 120ms ease, border-color 120ms ease;
248
+ }
249
+
250
+ .ca-part-entry[data-active] .ca-part-indicator {
251
+ background: var(--ca-label-bg, #4f46e5);
252
+ border-color: var(--ca-label-bg, #4f46e5);
253
+ }
254
+
255
+ .ca-part-name {
256
+ font-size: 0.82rem;
257
+ font-weight: 650;
258
+ color: var(--ca-part-name-color, #111827);
259
+ flex: 1;
260
+ letter-spacing: 0.01em;
261
+ }
262
+
263
+ .ca-part-entry[data-active] .ca-part-name {
264
+ color: var(--ca-label-bg, #4f46e5);
265
+ }
266
+
267
+ .ca-part-id {
268
+ font-family: ui-monospace, 'Cascadia Code', 'Fira Mono', monospace;
269
+ font-size: 0.68rem;
270
+ padding: 1px 6px;
271
+ border-radius: 4px;
272
+ background: #f3f4f6;
273
+ color: #6b7280;
274
+ border: 1px solid #e5e7eb;
275
+ flex-shrink: 0;
276
+ }
277
+
278
+ .ca-part-entry[data-active] .ca-part-id {
279
+ background: rgba(99,102,241,0.08);
280
+ border-color: rgba(99,102,241,0.2);
281
+ color: #4f46e5;
282
+ }
283
+
284
+ /* ─── Markdown description ─── */
285
+ .ca-part-description {
286
+ font-size: 0.85rem;
287
+ line-height: 1.7;
288
+ color: var(--ca-part-desc-color, #4b5563);
289
+ padding-left: 16px; /* aligns with text after the indicator dot */
290
+ }
291
+
292
+ .ca-part-description :global(p) { margin: 0 0 0.5em; }
293
+ .ca-part-description :global(p:last-child) { margin-bottom: 0; }
294
+ .ca-part-description :global(h1),
295
+ .ca-part-description :global(h2),
296
+ .ca-part-description :global(h3) {
297
+ font-size: 0.75rem;
298
+ font-weight: 700;
299
+ text-transform: uppercase;
300
+ letter-spacing: 0.06em;
301
+ color: #9ca3af;
302
+ margin: 0.9em 0 0.3em;
303
+ }
304
+ .ca-part-description :global(code) {
305
+ font-size: 0.78em;
306
+ padding: 0.15em 0.4em;
307
+ border-radius: 3px;
308
+ background: #f3f4f6;
309
+ border: 1px solid #e5e7eb;
310
+ font-family: ui-monospace, 'Cascadia Code', monospace;
311
+ color: #374151;
312
+ }
313
+ .ca-part-description :global(a) {
314
+ color: var(--ca-label-bg, #4f46e5);
315
+ text-decoration: underline;
316
+ text-underline-offset: 2px;
317
+ }
318
+ .ca-part-description :global(ul), .ca-part-description :global(ol) {
319
+ margin: 0.3em 0 0.5em;
320
+ padding-left: 1.3em;
321
+ }
322
+ .ca-part-description :global(li) { margin-bottom: 0.2em; }
323
+ .ca-part-description :global(strong) { color: #111827; font-weight: 600; }
324
+
325
+ .ca-no-parts {
326
+ font-size: 0.875rem;
327
+ color: #9ca3af;
328
+ font-style: italic;
329
+ padding: 0.75rem;
330
+ }
331
+
332
+ /* ─── Responsive ─── */
333
+ @media (max-width: 720px) {
334
+ .ca-root { grid-template-columns: 1fr; }
335
+ .ca-preview { position: static; }
336
+ .ca-panel { max-height: 360px; }
337
+ }
338
+ </style>
339
+
340
+ <script>
341
+ import { createAnatomy } from '@component-anatomy/core';
342
+
343
+ function init() {
344
+ document.querySelectorAll<HTMLElement>('[data-anatomy-root]').forEach((root) => {
345
+ if (root.dataset.anatomyInit) return;
346
+ root.dataset.anatomyInit = '1';
347
+
348
+ const id = root.dataset.anatomyRoot!;
349
+ const preview = root.querySelector<HTMLElement>(`[data-anatomy-preview="${id}"]`);
350
+ const panel = root.querySelector<HTMLElement>(`[data-anatomy-panel="${id}"]`);
351
+ const pill = root.querySelector<HTMLElement>(`[data-anatomy-pill="${id}"]`);
352
+ const pillName = pill?.querySelector<HTMLElement>('.ca-active-pill-name');
353
+
354
+ if (!preview) return;
355
+
356
+ // The preview is a visual demo — hide it from the accessibility tree
357
+ // and remove all interactive elements from the tab order.
358
+ // We use tabindex="-1" rather than `inert` because `inert` also blocks
359
+ // pointer events, which would break our mouseenter/mouseleave detection.
360
+ preview.setAttribute('aria-hidden', 'true');
361
+ const FOCUSABLE = 'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])';
362
+ preview.querySelectorAll<HTMLElement>(FOCUSABLE).forEach((el) => {
363
+ el.setAttribute('tabindex', '-1');
364
+ });
365
+
366
+ let config: { preset?: string; theme?: object; overlayLabel?: boolean } = {};
367
+ try {
368
+ config = JSON.parse(root.dataset.anatomyConfig ?? '{}');
369
+ } catch { /* malformed config — fall back to defaults */ }
370
+
371
+ const anatomy = createAnatomy({
372
+ root: preview,
373
+ panel: panel ?? undefined,
374
+ preset: config.preset as import('@component-anatomy/core').AnatomyPresetName | undefined,
375
+ theme: config.theme as import('@component-anatomy/core').AnatomyTheme | undefined,
376
+ overlay: { label: config.overlayLabel !== false },
377
+ });
378
+
379
+ // Sticky pill: show/hide based on whether the active panel entry is in view
380
+ if (pill && panel) {
381
+ const observer = new IntersectionObserver(
382
+ (entries) => {
383
+ const activeEntry = panel.querySelector<HTMLElement>('[data-anatomy-item][data-active]');
384
+ if (!activeEntry) { pill.classList.remove('ca-pill--visible'); return; }
385
+ const isVisible = entries.some(
386
+ (e) => e.target === activeEntry && e.isIntersecting
387
+ );
388
+ pill.classList.toggle('ca-pill--visible', !isVisible);
389
+ },
390
+ { root: panel, threshold: 0.5 }
391
+ );
392
+
393
+ // Re-observe whenever a new part becomes active
394
+ anatomy.on('part:enter', (partId) => {
395
+ if (pillName) pillName.textContent = partId;
396
+ const activeEntry = panel.querySelector<HTMLElement>(`[data-anatomy-item="${partId}"]`);
397
+ if (activeEntry) observer.observe(activeEntry);
398
+ else pill.classList.remove('ca-pill--visible');
399
+ });
400
+
401
+ anatomy.on('part:leave', () => {
402
+ pill.classList.remove('ca-pill--visible');
403
+ if (pillName) pillName.textContent = '';
404
+ });
405
+ }
406
+ });
407
+ }
408
+
409
+ init();
410
+ document.addEventListener('astro:page-load', init);
411
+ </script>
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export type { AnatomyPartDefinition } from '@component-anatomy/core';