@fusion.dev/helix-docs 0.2.0 → 0.3.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.
@@ -59,10 +59,10 @@ repository does that. The per-component tables are what a page pays.
59
59
  | Package | Raw | Gzip | Brotli | |
60
60
  |---|---|---|---|---|
61
61
  | `@fusion.dev/helix-core` | 134,650 | 44,269 | 37,972 | Element base class, mixins, decorators, floating positioning. |
62
- | `@fusion.dev/helix-headless` | 519,156 | 131,702 | 103,107 | Every headless family, which is what the barrel means. |
62
+ | `@fusion.dev/helix-headless` | 519,154 | 131,672 | 103,150 | Every headless family, which is what the barrel means. |
63
63
  | `@fusion.dev/helix-icons` | 47,484 | 14,907 | 12,553 | The whole glyph set. |
64
64
  | `@fusion.dev/helix-theme` | 3,288 | 1,571 | 1,355 | The derivation engine. Build time for most consumers, runtime for a theme switcher. |
65
- | `@fusion.dev/helix` | 956,713 | 216,830 | 159,002 | Every styled family. |
65
+ | `@fusion.dev/helix` | 956,713 | 216,820 | 158,985 | Every styled family. |
66
66
  | `lit` | 15,394 | 6,118 | 5,460 | Not ours. Here so it can be subtracted from every row above it. |
67
67
 
68
68
  ## The stylesheets
@@ -85,9 +85,9 @@ is the whole cost of adopting the base layer on its own.
85
85
 
86
86
  **Most of a first component is the runtime under it.** The median styled family is 13,974 bytes gzipped alone and adds 2,409 to a page that already has Helix on it, so 83 percent of the first one is shared and 17 percent is the component. Ten typical components are one plus nine additions rather than ten of the first number, and the pair of columns below is what lets that be worked out rather than guessed.
87
87
 
88
- Across the tier: the cheapest is `code` at 9,065 gzipped alone, the dearest `data-table` at 49,868, over 93 components. Every family at once is 216,830.
88
+ Across the tier: the cheapest is `code` at 9,065 gzipped alone, the dearest `data-table` at 49,868, over 93 components. Every family at once is 216,820.
89
89
 
90
- **No family imports a barrel, and the `barrels` rule in `fusion workspace` is what keeps it that way.** `@fusion.dev/helix-headless` is `sideEffects` over every module in `dist`, which is true of a file that calls `customElements.define` and which means a bundler may drop none of it: one `import { HelixHeadlessButton } from '@fusion.dev/helix-headless'` costs 131,702 bytes gzipped whatever else the page does, which is 9 times the median family measured above. The styled tier imports the family it extends instead, all 93 of them, and the check refuses the shape rather than the package name, so the rule follows `sideEffects` wherever it goes.
90
+ **No family imports a barrel, and the `barrels` rule in `fusion workspace` is what keeps it that way.** `@fusion.dev/helix-headless` is `sideEffects` over every module in `dist`, which is true of a file that calls `customElements.define` and which means a bundler may drop none of it: one `import { HelixHeadlessButton } from '@fusion.dev/helix-headless'` costs 131,672 bytes gzipped whatever else the page does, which is 9 times the median family measured above. The styled tier imports the family it extends instead, all 93 of them, and the check refuses the shape rather than the package name, so the rule follows `sideEffects` wherever it goes.
91
91
 
92
92
  The headless tier never had the problem, and it is the control: `@fusion.dev/helix-core` is `sideEffects: false`, and a headless family is a median of 16,187 bytes gzipped alone.
93
93
 
@@ -126,7 +126,7 @@ Four other libraries of custom elements, pinned and measured on 2026-08-24 by `t
126
126
  | | Material Web | 50,155 | 14,577 | 12,935 |
127
127
  | | Shoelace | 37,833 | 11,814 | 10,568 |
128
128
  | | Spectrum | 62,322 | 15,059 | 13,321 |
129
- | `tabs` | **Helix** | 40,196 | 12,332 | 10,861 |
129
+ | `tabs` | **Helix** | 40,196 | 12,348 | 10,882 |
130
130
  | | Carbon | 578,766 | 73,537 | 43,235 |
131
131
  | | Material Web | 38,883 | 12,820 | 11,368 |
132
132
  | | Shoelace | 54,573 | 17,140 | 15,068 |
@@ -254,7 +254,7 @@ Structure, accessibility, state and form participation. No presentation, and no
254
254
  | `steps` | 82,900 | 27,205 | 22,347 | 6,303 | 1,832 | 1,249 |
255
255
  | `switch` | 34,314 | 11,106 | 9,826 | 1,798 | 832 | 358 |
256
256
  | `table` | 31,861 | 10,274 | 9,086 | 4,373 | 1,299 | 831 |
257
- | `tabs` | 33,935 | 11,035 | 9,722 | 4,469 | 1,279 | 803 |
257
+ | `tabs` | 33,935 | 11,060 | 9,721 | 4,469 | 1,277 | 735 |
258
258
  | `tag` | 74,439 | 24,760 | 20,435 | 897 | 464 | 219 |
259
259
  | `textarea` | 37,001 | 11,743 | 10,362 | 4,115 | 1,414 | 884 |
260
260
  | `time-input` | 136,201 | 43,547 | 36,687 | 28,643 | 7,661 | 6,120 |
@@ -353,7 +353,7 @@ The headless tier plus presentation. This is what `bun add @fusion.dev/helix` gi
353
353
  | `steps` | 96,252 | 29,575 | 24,410 | 19,643 | 4,202 | 3,353 |
354
354
  | `switch` | 38,603 | 12,160 | 10,668 | 6,072 | 1,891 | 1,318 |
355
355
  | `table` | 35,190 | 10,865 | 9,580 | 7,692 | 1,916 | 1,378 |
356
- | `tabs` | 40,196 | 12,332 | 10,861 | 10,719 | 2,598 | 1,976 |
356
+ | `tabs` | 40,196 | 12,348 | 10,882 | 10,719 | 2,597 | 1,971 |
357
357
  | `tag` | 77,633 | 25,634 | 21,148 | 4,076 | 1,245 | 995 |
358
358
  | `text` | 29,734 | 9,670 | 8,508 | 3,922 | 958 | 605 |
359
359
  | `textarea` | 40,350 | 12,544 | 11,034 | 7,447 | 2,227 | 1,615 |
@@ -381,12 +381,12 @@ column is what was removed rather than part of what remains.
381
381
 
382
382
  | Package | Unpacked | Files | Source maps stripped | Runtime dependencies |
383
383
  |---|---|---|---|---|
384
- | `@fusion.dev/helix-core` | 879,273 | 113 | 162,880 | `@floating-ui/dom`, `@fusion.dev/helix-icons` |
385
- | `@fusion.dev/helix-headless` | 9,741,797 | 422 | 738,193 | `@fusion.dev/helix-core`, `@fusion.dev/helix-icons`, `uqr` |
386
- | `@fusion.dev/helix-icons` | 66,379 | 11 | 20,959 | none |
384
+ | `@fusion.dev/helix-core` | 655,135 | 113 | 154,658 | `@floating-ui/dom`, `@fusion.dev/helix-icons` |
385
+ | `@fusion.dev/helix-headless` | 8,636,163 | 422 | 699,239 | `@fusion.dev/helix-core`, `@fusion.dev/helix-icons`, `uqr` |
386
+ | `@fusion.dev/helix-icons` | 65,943 | 11 | 20,928 | none |
387
387
  | `@fusion.dev/helix-styles` | 83,277 | 5 | 0 | none |
388
- | `@fusion.dev/helix-theme` | 463,476 | 8 | 4,764 | none |
389
- | `@fusion.dev/helix` | 9,714,436 | 718 | 287,181 | `@fusion.dev/helix-core`, `@fusion.dev/helix-headless`, `@fusion.dev/helix-styles` |
388
+ | `@fusion.dev/helix-theme` | 461,551 | 8 | 4,699 | none |
389
+ | `@fusion.dev/helix` | 9,306,931 | 718 | 278,170 | `@fusion.dev/helix-core`, `@fusion.dev/helix-headless`, `@fusion.dev/helix-styles` |
390
390
 
391
391
  ## Not measured yet
392
392
 
package/dist/catalog.js CHANGED
@@ -1,85 +1,25 @@
1
1
  import { displayName, startCase, tierOf } from './naming.js';
2
2
  import { place } from './reference.js';
3
- /**
4
- * The canonical example's slug, named once because three places ask for it.
5
- *
6
- * The tier pairing, the first-position pin and the label all read it. A fact
7
- * three places hold is a fact that drifts.
8
- */
9
3
  const CANONICAL = 'preview';
10
- /* The headless twin's slug, and the suffix every other one carries. */
11
4
  const HEADLESS = 'headless';
12
5
  const HEADLESS_SUFFIX = /\.headless$/;
13
- /**
14
- * Drops `undefined` from a union.
15
- *
16
- * Every reflected property is optional, because the default state of a Helix
17
- * component is the absent attribute: a reflected default would put an attribute
18
- * on the host that a React consumer's server markup never had. That makes the
19
- * declared type `'primary' | 'neutral' | undefined`, and the `undefined` is an
20
- * implementation detail of how the default is expressed rather than a value
21
- * anyone sets.
22
- */
23
6
  const withoutUndefined = (text) => text
24
7
  .split('|')
25
8
  .map((part) => part.trim())
26
9
  .filter((part) => part !== 'undefined')
27
10
  .join(' | ');
28
- /**
29
- * A method the platform calls, rather than one a consumer does.
30
- *
31
- * `formResetCallback` and its siblings are public because the platform has to
32
- * reach them, and they are 55 of the 152 public methods in the library:
33
- * documenting them puts a reader's own `focus()` and `show()` in a minority on
34
- * their own component's page. Matching the suffix rather than listing the names
35
- * covers the custom-element lifecycle and the form-associated one together, and
36
- * keeps covering them when the platform adds a third.
37
- */
38
11
  const PLATFORM_CALLBACK = /Callback$/;
39
- /** Public only. Private and protected are the component's business. */
40
12
  const isPublic = (row) => row.privacy === undefined || row.privacy === 'public';
41
- /**
42
- * Properties, with the default each one falls back to.
43
- *
44
- * Fields rather than attributes: an attribute carries no default in the
45
- * manifest, so a table built from attributes can say a component takes a
46
- * property and never what it does when you leave it alone.
47
- */
48
13
  const properties = (rows) => (rows ?? [])
49
- /* Instance fields only. A static is the class's, not an element's, and the
50
- two that reach here are the platform's rather than ours:
51
- `shadowRootOptions` is Lit's and `formAssociated` is the flag the browser
52
- reads to decide a control participates in a form. Thirty-five rows across
53
- the library, none of them anything a consumer sets on a tag. */
54
- /* `@internal` needs nothing here: the analyzer drops a member carrying it
55
- before the manifest is written. */
56
14
  .filter((row) => row.kind === 'field' && isPublic(row) && row.static !== true)
57
15
  .map((row) => ({
58
- /* `undefined` is not a default, it is the absence of one, and the
59
- analyzer records the initializer verbatim. Printed, the column reads
60
- "undefined" for every optional property, which is a table telling a
61
- reader a word rather than a value. Blank says the same thing and says
62
- it quietly. */
63
16
  default: row.default === 'undefined' ? '' : (row.default ?? ''),
64
17
  description: row.description ?? '',
65
18
  name: row.name ?? '',
66
- /* A getter reads as a field in the manifest and assigning to one throws,
67
- which is the same trap the React generator already skips these for. In
68
- a table they are indistinguishable from a property a reader may set:
69
- `AvatarGroup.avatars` sat beside its slot looking like a second way to
70
- pass the same thing, and there is no second way. A hundred and
71
- fifty-five of them. */
72
19
  readonly: row.readonly === true,
73
- /* From `@reference` on the property. A row for `command` can say it is
74
- the Invoker Commands API and point at where that is specified, which a
75
- description sentence can gesture at and cannot carry. */
76
20
  references: row.helix?.references ?? [],
77
21
  type: withoutUndefined(row.type?.text ?? ''),
78
22
  }));
79
- /**
80
- * Methods, typed by the signature a caller writes rather than by a return type
81
- * alone. `(next: string) => boolean` says what to pass; `boolean` does not.
82
- */
83
23
  const methods = (rows) => (rows ?? [])
84
24
  .filter((row) => row.kind === 'method' && isPublic(row) && !PLATFORM_CALLBACK.test(row.name ?? ''))
85
25
  .map((row) => {
@@ -97,60 +37,14 @@ const members = (rows) => (rows ?? []).map((row) => ({
97
37
  name: row.name ?? '',
98
38
  type: withoutUndefined(row.type?.text ?? ''),
99
39
  }));
100
- /**
101
- * An annotation comment: a leading `@` inside an HTML comment, and its line.
102
- *
103
- * **Only comments that begin with `@`.** An example is allowed one comment
104
- * about its own scaffolding, and those are written for the reader:
105
- * `<!-- Preview frame: the submit is swallowed. -->` exists so nobody copies
106
- * `onsubmit="event.preventDefault()"` into production. Stripping every comment
107
- * would take that note with it. The sigil is what separates a line addressed to
108
- * this documentation system from a line addressed to a person.
109
- */
110
40
  const ANNOTATION = /^[^\S\n]*<!--\s*@[\s\S]*?-->[^\S\n]*\n?/gm;
111
- /**
112
- * The example's markup as a reader should receive it.
113
- *
114
- * `@summary` is metadata about the example rather than part of it: the cem
115
- * plugin lifts it into the manifest and the reference prints it as the
116
- * description, so left in place it makes the copy button hand back a comment
117
- * describing the thing you just copied.
118
- *
119
- * Stripped here rather than at the reader, because every consumer of the
120
- * catalog wants the same thing and a site that trimmed it in its own renderer
121
- * would leave the sandbox and any other consumer showing it.
122
- */
123
41
  const withoutAnnotations = (html) => html.replace(ANNOTATION, '').trimStart();
124
- /**
125
- * The examples of one family, named and ordered.
126
- *
127
- * The prose comes from the manifest and the markup from the caller. The
128
- * composition plugin reads the same folder and records what each example is;
129
- * the payload stays on disk because it is large and the manifest is parsed by
130
- * everything. Keyed by slug so the two halves cannot drift into each other's
131
- * order.
132
- */
133
42
  const examplesOf = (slug, declared, sources) => {
134
43
  const described = new Map((declared?.examples ?? []).map((entry) => [entry.slug ?? '', entry.description ?? '']));
135
- /* Keyed on slug the same way the descriptions are, and for the same reason:
136
- the manifest half and the on-disk half are matched rather than zipped, so
137
- the two cannot drift into each other's order. */
138
44
  const layouts = new Map((declared?.examples ?? []).map((entry) => [entry.slug ?? '', entry.layout ?? '']));
139
45
  const minimums = new Map((declared?.examples ?? []).map((entry) => [entry.slug ?? '', entry.min ?? 0]));
140
46
  return (sources
141
47
  .filter((source) => source.family === slug)
142
- /* `preview` first, then alphabetical, with each headless twin directly
143
- behind the example it mirrors.
144
-
145
- By slug, never by filename. Comparing `compact-counts.html` against
146
- `compact.html` puts the longer name first, `-` preceding `.`, which is
147
- a list ordered by a file extension no reader ever sees.
148
-
149
- **Pair on the mirrored name, not on the raw slug.** Sorting on the raw
150
- slug puts the twin wherever the tier's name happens to fall in the
151
- alphabet, which is only ever right by luck. The tabs have to show the
152
- same list on both tiers, and `mirrored` below is that relationship: sort
153
- on it and the position follows from the pairing. */
154
48
  .sort((a, b) => {
155
49
  const pairOf = (slug) => slug === HEADLESS ? CANONICAL : slug.replace(HEADLESS_SUFFIX, '');
156
50
  const twin = (slug) => (slug === HEADLESS || HEADLESS_SUFFIX.test(slug) ? 1 : 0);
@@ -162,14 +56,6 @@ const examplesOf = (slug, declared, sources) => {
162
56
  return twin(a.slug) - twin(b.slug);
163
57
  })
164
58
  .map((source) => {
165
- /* Which styled example this one is the headless twin of. `headless` is
166
- the twin of `preview`, and `<name>.headless` of `<name>`. This is the
167
- one place that has to know the pairing, because a headless example is
168
- not named for itself. */
169
- /* Read once and narrowed here rather than spread from the map
170
- directly: `get` answers `string | undefined`, and under
171
- `exactOptionalPropertyTypes` an optional property will not take the
172
- `undefined`. */
173
59
  const layout = layouts.get(source.slug);
174
60
  const min = minimums.get(source.slug);
175
61
  const mirrored = source.slug === HEADLESS ? CANONICAL : source.slug.replace(HEADLESS_SUFFIX, '');
@@ -177,14 +63,6 @@ const examplesOf = (slug, declared, sources) => {
177
63
  css: source.css,
178
64
  description: described.get(source.slug) ?? '',
179
65
  html: withoutAnnotations(source.html),
180
- /* **The label is the file's name, with no special case.** `startCase`
181
- gives "Preview" for the canonical example on its own.
182
-
183
- **A headless example takes the name of the one it mirrors**, so the two
184
- tiers show the same list rather than a list that changes its labels
185
- when a reader changes a preference. Only one of a pair is ever on
186
- screen, the tier deciding which, so a differing name would be the only
187
- thing suggesting they are different examples. */
188
66
  label: startCase(mirrored),
189
67
  ...(layout ? { layout } : {}),
190
68
  ...(min ? { min } : {}),
@@ -194,15 +72,6 @@ const examplesOf = (slug, declared, sources) => {
194
72
  };
195
73
  }));
196
74
  };
197
- /**
198
- * Every family across the given manifests, alphabetical, each with its elements
199
- * and examples.
200
- *
201
- * The family a declaration belongs to comes from where its file is rather than
202
- * from its tag name, which is the same reasoning `place()` carries for the
203
- * reference: `helix-button-group` is one family and `helix-tab` is a part
204
- * of another, and nothing in the name says which.
205
- */
206
75
  export const catalog = (manifests, examples = []) => {
207
76
  const byFamily = new Map();
208
77
  for (const manifest of manifests) {
@@ -221,8 +90,6 @@ export const catalog = (manifests, examples = []) => {
221
90
  }
222
91
  return [...byFamily.entries()]
223
92
  .map(([slug, entries]) => {
224
- /* The family's own description comes from its styled root: the element
225
- whose tag is the family name, rather than a part of it. */
226
93
  const root = entries.find((entry) => entry.tagName === `helix-${slug}`) ??
227
94
  entries[0];
228
95
  return {
@@ -243,10 +110,6 @@ export const catalog = (manifests, examples = []) => {
243
110
  })),
244
111
  label: displayName(entry.tagName),
245
112
  methods: methods(entry.members),
246
- /* From the manifest, not by stripping the family name off the
247
- label. The composition plugin already derives this from the
248
- folder, and "Avatar Group" minus "Avatar" is a string operation
249
- that happens to work until a part's name contains the family's. */
250
113
  part: entry.helix?.part ?? '',
251
114
  properties: properties(entry.members),
252
115
  slots: members(entry.slots),
@@ -254,9 +117,6 @@ export const catalog = (manifests, examples = []) => {
254
117
  tagName: entry.tagName,
255
118
  tier: tierOf(entry.tagName),
256
119
  }))
257
- /* Styled first, then headless. Alphabetical order would put the
258
- headless tier on top, which is the one most readers never reach
259
- for. */
260
120
  .sort((a, b) => Number(a.tier === 'headless') - Number(b.tier === 'headless') ||
261
121
  a.tagName.localeCompare(b.tagName)),
262
122
  examples: examplesOf(slug, root.helix, examples),
@@ -270,5 +130,4 @@ export const catalog = (manifests, examples = []) => {
270
130
  })
271
131
  .sort((a, b) => a.label.localeCompare(b.label));
272
132
  };
273
- /** The family of a given slug, from an already-built catalog. */
274
133
  export const findInCatalog = (families, slug) => families.find((family) => family.slug === slug);
package/dist/naming.js CHANGED
@@ -1,28 +1,8 @@
1
- /**
2
- * Display names for components.
3
- *
4
- * The product is Helix, so every component being called "HelixCard" inside the
5
- * Helix site is noise: it repeats the one thing the reader already knows on
6
- * every row of the sidebar. The tag keeps its prefix, because that is a real
7
- * identifier a consumer types.
8
- */
9
- /**
10
- * Words a slug spells in lower case that are read as letters.
11
- *
12
- * Capitalizing the first letter of `qr` gives `Qr`, which is a word nobody says.
13
- * A slug is lower case because a URL is, and the initialism it came from is
14
- * lost by the time this sees it, so it has to be put back by name. Kept small
15
- * and explicit rather than guessed at from length: `ui`, `id` and `api` are
16
- * initialisms and `is`, `on` and `to` are not.
17
- */
18
1
  const INITIALISMS = new Set(['api', 'css', 'html', 'id', 'qr', 'ui', 'url']);
19
- /** `app-header` becomes `App Header`, and `qr-code` becomes `QR Code`. */
20
2
  export const startCase = (slug) => slug
21
3
  .split('-')
22
4
  .filter(Boolean)
23
5
  .map((word) => INITIALISMS.has(word) ? word.toUpperCase() : word.charAt(0).toUpperCase() + word.slice(1))
24
6
  .join(' ');
25
- /** `helix-app-header` becomes `App Header`, `helix-headless-card` becomes `Card`. */
26
7
  export const displayName = (tagName) => startCase(tagName.replace(/^helix-(?:headless-)?/, ''));
27
- /** Which tier a tag belongs to, read from the prefix rather than the manifest. */
28
8
  export const tierOf = (tagName) => tagName.startsWith('helix-headless-') ? 'headless' : 'styled';
package/dist/reference.js CHANGED
@@ -1,26 +1,10 @@
1
1
  import { startCase } from './naming.js';
2
- /**
3
- * Whether a member belongs in a reference at all.
4
- *
5
- * Public only, and nothing inherited from `HelixElement`. Every component in
6
- * the library extends it, so its members would appear on all of them: an
7
- * `internals` field and two lifecycle methods that exist so components do not
8
- * have to think about the shadow root. Printing them everywhere says the same
9
- * infrastructural thing once per component and buries the four properties a
10
- * consumer came to read.
11
- *
12
- * `HelixFormElement` is deliberately not on that list. What it contributes is
13
- * form participation, which is the API of a form control.
14
- */
15
2
  const INFRASTRUCTURE = new Set(['HelixElement']);
16
3
  const isPublic = (member) => member.privacy !== 'private' &&
17
4
  member.privacy !== 'protected' &&
18
5
  !INFRASTRUCTURE.has(member.inheritedFrom?.name ?? '');
19
- /** A cell's worth of a description: the first paragraph, on one line. */
20
6
  const cell = (text) => (text ?? '').split('\n\n')[0]?.replace(/\s+/g, ' ').replace(/\|/g, '\\|').trim() ?? '';
21
- /** A type, escaped so its unions do not end the column they are in. */
22
7
  const code = (text) => text ? `\`${text.replace(/\|/g, '\\|')}\`` : '-';
23
- /** A markdown table, or nothing at all where there are no rows to put in one. */
24
8
  const table = (depth, heading, columns, rows) => {
25
9
  if (rows.length === 0) {
26
10
  return '';
@@ -30,24 +14,10 @@ const table = (depth, heading, columns, rows) => {
30
14
  const body = rows.map((row) => `| ${row.join(' | ')} |`).join('\n');
31
15
  return `\n${'#'.repeat(depth)} ${heading}\n\n${head}\n${rule}\n${body}\n`;
32
16
  };
33
- /**
34
- * The family and part a declaration belongs to, from where its file is.
35
- *
36
- * Not from the tag name. A tag is a string the family prefix can only be
37
- * guessed out of: `helix-button-group` is one family and `helix-tab` is a
38
- * part of another, and nothing in the name says which. The path says both.
39
- */
40
17
  export const place = (path) => {
41
18
  const [, family = '', part = ''] = /^src\/([^/]+)\/([^/]+)\//.exec(path ?? '') ?? [];
42
19
  return { family, part };
43
20
  };
44
- /**
45
- * Every family in a manifest, alphabetical, each with its parts.
46
- *
47
- * Root first inside a family, then the rest alphabetically. A family reads from
48
- * the thing that holds the others, and `item` before `root` is alphabetical
49
- * order telling you about the alphabet rather than about the component.
50
- */
51
21
  export const referenceFamilies = (manifest) => {
52
22
  const families = new Map();
53
23
  for (const module of manifest.modules ?? []) {
@@ -69,7 +39,6 @@ export const referenceFamilies = (manifest) => {
69
39
  parts: [...parts].sort((a, b) => a.part === 'root' ? -1 : b.part === 'root' ? 1 : a.part.localeCompare(b.part)),
70
40
  }));
71
41
  };
72
- /** The tables for one element, in the order they are printed. */
73
42
  const element = (depth, declaration, part) => {
74
43
  const { attributes = [], cssParts = [], cssProperties = [], description, events = [], members = [], slots = [], summary, superclass, tagName, } = declaration;
75
44
  const lines = [`${'#'.repeat(depth)} ${startCase(part)} (\`${tagName}\`)`, ''];
@@ -100,26 +69,14 @@ const element = (depth, declaration, part) => {
100
69
  ])).trimEnd(), table(depth + 1, 'Events', ['Event', 'Type', 'Description'], events.map((event) => [`\`${event.name}\``, code(event.type?.text), cell(event.description)])).trimEnd(), table(depth + 1, 'Slots', ['Slot', 'Description'], slots.map((slot) => [
101
70
  slot.name ? `\`${slot.name}\`` : '*(default)*',
102
71
  cell(slot.description),
103
- ])).trimEnd(), table(depth + 1, 'Parts', ['Part', 'Description'], cssParts.map((one) => [`\`${one.name}\``, cell(one.description)])).trimEnd(),
104
- /* A "From" column, because a hook can now arrive from a component this one
105
- borrowed a stylesheet from rather than be its own. Without it a borrowed
106
- `--icon-button-radii` reads on the Copy Button page as a hook Copy Button
107
- named, and a consumer who guesses `--copy-button-radii` from that gets
108
- nothing. */
109
- table(depth + 1, 'Custom properties', ['Property', 'From', 'Description'], cssProperties.map((one) => [
72
+ ])).trimEnd(), table(depth + 1, 'Parts', ['Part', 'Description'], cssParts.map((one) => [`\`${one.name}\``, cell(one.description)])).trimEnd(), table(depth + 1, 'Custom properties', ['Property', 'From', 'Description'], cssProperties.map((one) => [
110
73
  `\`${one.name}\``,
111
74
  one.inheritedFrom?.name ? `\`${one.inheritedFrom.name}\`` : '-',
112
75
  cell(one.description),
113
76
  ])).trimEnd(), '');
114
77
  return lines;
115
78
  };
116
- /**
117
- * The leading `<!-- @summary -->` comment, which the description above already
118
- * carries. It is how an example declares its own one-liner to the analyzer, so
119
- * it belongs in the file and not a second time in the prose beside it.
120
- */
121
79
  const withoutSummary = (html) => html.replace(/^<!--\s*@summary[\s\S]*?-->\n*/, '');
122
- /** One family's examples, as the markdown a reader or an agent can run. */
123
80
  const shown = (depth, examples) => {
124
81
  if (examples.length === 0) {
125
82
  return [];
@@ -137,17 +94,6 @@ const shown = (depth, examples) => {
137
94
  }
138
95
  return lines;
139
96
  };
140
- /**
141
- * A manifest, as the markdown reference for everything in it.
142
- *
143
- * The caller supplies the manifest and whatever preamble belongs above it, so
144
- * the same renderer serves the whole-library document in `docs/` and a
145
- * per-family page without either one knowing about the other.
146
- *
147
- * **A single family gets no heading naming it**, because the title already
148
- * does: a document about the accordion should not say Accordion twice before
149
- * its first sentence. More than one and the headings are what separate them.
150
- */
151
97
  export const reference = (manifest, options = {}) => {
152
98
  const front = options.frontmatter ?? [];
153
99
  const lines = [
@@ -160,10 +106,6 @@ export const reference = (manifest, options = {}) => {
160
106
  ];
161
107
  const wanted = options.families;
162
108
  const families = referenceFamilies(manifest).filter((entry) => wanted === undefined || wanted.includes(entry.family));
163
- /* Everything under a family heading sits one level deeper than it would in a
164
- document that has no family heading to sit under. Without this a page about
165
- one component opens at `###`, which is a heading level with nothing above
166
- it and reads as a fragment of a larger file. */
167
109
  const depth = families.length > 1 ? 3 : 2;
168
110
  for (const { family, parts } of families) {
169
111
  if (families.length > 1) {
package/dist/registry.js CHANGED
@@ -1,40 +1,8 @@
1
- /**
2
- * Documentation registry for Helix.
3
- *
4
- * Docs are a package rather than a folder inside a site so that the docs are
5
- * pinned to the version of the code they describe. A site renders this
6
- * registry; it does not own it.
7
- *
8
- * **There is no version axis in here.** The package version is the version:
9
- * `@fusion.dev/helix-docs@0.2.0` documents Helix 0.2.0, because every package in a
10
- * domain moves in lockstep. A `versions` array beside that would be a second
11
- * source of truth with nothing keeping the two in step, and the first time they
12
- * disagreed the registry would win over the package that shipped it.
13
- *
14
- * A site serving several versions installs several versions, which is the
15
- * mechanism npm already provides and the only one that cannot drift.
16
- *
17
- * **Nothing about a page is written here.** `pages.generated.ts` is derived
18
- * from each file's frontmatter by `turbo run docs`, because the frontmatter is
19
- * what a `.md` consumer receives and a second copy would be a second source of
20
- * truth. This module is the types and the reading of them; `sections.ts` is the
21
- * spine that frontmatter has nowhere to live.
22
- */
23
1
  import { docPages } from './pages.generated.js';
24
2
  import { docSections } from './sections.js';
25
3
  export { docPages as pages, docSections as sections };
26
4
  export const findPage = (slug) => docPages.find((page) => page.slug === slug);
27
- /** Case-insensitive, so `Tokens` and `tokens` do not sort into two groups. */
28
5
  const byTitle = (a, b) => a.title.toLowerCase() < b.title.toLowerCase() ? -1 : 1;
29
- /**
30
- * The pages of one section, in the order a sidebar renders them.
31
- *
32
- * **Alphabetical, except a section that is a path, and except one pinned entry
33
- * page where a section has one.** The exemption is the same one `AGENTS.md`
34
- * grants positional arguments and middleware chains, that ordering is skipped
35
- * where order is semantic. Written down so a section is not silently reordered
36
- * by whoever next notices it is not alphabetical.
37
- */
38
6
  const ordered = (pages, section) => {
39
7
  if (section.path) {
40
8
  return [...pages].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
@@ -43,17 +11,6 @@ const ordered = (pages, section) => {
43
11
  const rest = pages.filter((page) => !page.pinned).sort(byTitle);
44
12
  return [...pinned, ...rest];
45
13
  };
46
- /**
47
- * The whole sidebar: every declared section, its pages in render order, and
48
- * each page's children beneath it.
49
- *
50
- * A section with no pages cannot appear, because `turbo run docs -- --check` refuses
51
- * to generate one. Children are alphabetical, because nesting exists for the
52
- * per-framework installation pages and a reader picking their framework out of
53
- * a list is looking one up rather than reading a sequence, and then the
54
- * `trailing` ones, which are the pages that answer "none of the above" and
55
- * which the alphabet was interleaving with the answers they follow from.
56
- */
57
14
  export const navigation = () => docSections.map((section) => {
58
15
  const own = docPages.filter((page) => page.section === section.title && page.parent === undefined);
59
16
  return {
@@ -66,5 +23,4 @@ export const navigation = () => docSections.map((section) => {
66
23
  })),
67
24
  };
68
25
  });
69
- /** Path to a page's MDX, relative to the package root. */
70
26
  export const contentPath = (page) => `content/${page.slug}.mdx`;
package/dist/sections.js CHANGED
@@ -1,26 +1,3 @@
1
- /**
2
- * The navigation's spine: which sections exist, in what order, and how each
3
- * one orders the pages inside it.
4
- *
5
- * **Hand-authored, where the pages are not.** A page's own facts come from its
6
- * frontmatter and are generated into `pages.generated.ts`, because the
7
- * frontmatter is what a `.md` consumer actually receives and a second copy in
8
- * a registry is a second source of truth. A section is not a page and has no
9
- * frontmatter to derive from, so it is written here.
10
- *
11
- * **A section with no pages is not declared.** A navigation entry is the
12
- * strongest existence claim a site can make, so a section is added with the
13
- * pages that fill it rather than ahead of them. `turbo run docs -- --check` fails
14
- * on an empty section rather than rendering a heading with nothing under it.
15
- */
16
- /**
17
- * Every section, in the order the sidebar renders them.
18
- *
19
- * Deliberately an array rather than an alphabetized record: this is the one
20
- * place where order is the content. Foundations sits above Theming because a
21
- * reader needs the vocabulary before the thing that changes it, and no
22
- * alphabetization expresses that.
23
- */
24
1
  export const docSections = [
25
2
  { path: true, title: 'Getting started' },
26
3
  { title: 'Foundations' },
@@ -28,5 +5,4 @@ export const docSections = [
28
5
  { title: 'Essentials' },
29
6
  { title: 'Tooling' },
30
7
  ];
31
- /** The section of a given name, if it is declared. */
32
8
  export const findSection = (title) => docSections.find((section) => section.title === title);
@@ -1,8 +1,3 @@
1
- /**
2
- * Every page's MDX, inlined by `turbo run docs`. Do not edit: the files in
3
- * `content/` are the source, and `turbo run docs -- --check` fails when this module
4
- * and they disagree.
5
- */
6
1
  export const docSources = {
7
2
  accessibility: `---
8
3
  description: What the library guarantees, what it cannot, and where the line is.
@@ -1918,10 +1913,10 @@ repository does that. The per-component tables are what a page pays.
1918
1913
  | Package | Raw | Gzip | Brotli | |
1919
1914
  |---|---|---|---|---|
1920
1915
  | \`@fusion.dev/helix-core\` | 134,650 | 44,269 | 37,972 | Element base class, mixins, decorators, floating positioning. |
1921
- | \`@fusion.dev/helix-headless\` | 519,156 | 131,702 | 103,107 | Every headless family, which is what the barrel means. |
1916
+ | \`@fusion.dev/helix-headless\` | 519,154 | 131,672 | 103,150 | Every headless family, which is what the barrel means. |
1922
1917
  | \`@fusion.dev/helix-icons\` | 47,484 | 14,907 | 12,553 | The whole glyph set. |
1923
1918
  | \`@fusion.dev/helix-theme\` | 3,288 | 1,571 | 1,355 | The derivation engine. Build time for most consumers, runtime for a theme switcher. |
1924
- | \`@fusion.dev/helix\` | 956,713 | 216,830 | 159,002 | Every styled family. |
1919
+ | \`@fusion.dev/helix\` | 956,713 | 216,820 | 158,985 | Every styled family. |
1925
1920
  | \`lit\` | 15,394 | 6,118 | 5,460 | Not ours. Here so it can be subtracted from every row above it. |
1926
1921
 
1927
1922
  ## The stylesheets
@@ -1944,9 +1939,9 @@ is the whole cost of adopting the base layer on its own.
1944
1939
 
1945
1940
  **Most of a first component is the runtime under it.** The median styled family is 13,974 bytes gzipped alone and adds 2,409 to a page that already has Helix on it, so 83 percent of the first one is shared and 17 percent is the component. Ten typical components are one plus nine additions rather than ten of the first number, and the pair of columns below is what lets that be worked out rather than guessed.
1946
1941
 
1947
- Across the tier: the cheapest is \`code\` at 9,065 gzipped alone, the dearest \`data-table\` at 49,868, over 93 components. Every family at once is 216,830.
1942
+ Across the tier: the cheapest is \`code\` at 9,065 gzipped alone, the dearest \`data-table\` at 49,868, over 93 components. Every family at once is 216,820.
1948
1943
 
1949
- **No family imports a barrel, and the \`barrels\` rule in \`fusion workspace\` is what keeps it that way.** \`@fusion.dev/helix-headless\` is \`sideEffects\` over every module in \`dist\`, which is true of a file that calls \`customElements.define\` and which means a bundler may drop none of it: one \`import { HelixHeadlessButton } from '@fusion.dev/helix-headless'\` costs 131,702 bytes gzipped whatever else the page does, which is 9 times the median family measured above. The styled tier imports the family it extends instead, all 93 of them, and the check refuses the shape rather than the package name, so the rule follows \`sideEffects\` wherever it goes.
1944
+ **No family imports a barrel, and the \`barrels\` rule in \`fusion workspace\` is what keeps it that way.** \`@fusion.dev/helix-headless\` is \`sideEffects\` over every module in \`dist\`, which is true of a file that calls \`customElements.define\` and which means a bundler may drop none of it: one \`import { HelixHeadlessButton } from '@fusion.dev/helix-headless'\` costs 131,672 bytes gzipped whatever else the page does, which is 9 times the median family measured above. The styled tier imports the family it extends instead, all 93 of them, and the check refuses the shape rather than the package name, so the rule follows \`sideEffects\` wherever it goes.
1950
1945
 
1951
1946
  The headless tier never had the problem, and it is the control: \`@fusion.dev/helix-core\` is \`sideEffects: false\`, and a headless family is a median of 16,187 bytes gzipped alone.
1952
1947
 
@@ -1985,7 +1980,7 @@ Four other libraries of custom elements, pinned and measured on 2026-08-24 by \`
1985
1980
  | | Material Web | 50,155 | 14,577 | 12,935 |
1986
1981
  | | Shoelace | 37,833 | 11,814 | 10,568 |
1987
1982
  | | Spectrum | 62,322 | 15,059 | 13,321 |
1988
- | \`tabs\` | **Helix** | 40,196 | 12,332 | 10,861 |
1983
+ | \`tabs\` | **Helix** | 40,196 | 12,348 | 10,882 |
1989
1984
  | | Carbon | 578,766 | 73,537 | 43,235 |
1990
1985
  | | Material Web | 38,883 | 12,820 | 11,368 |
1991
1986
  | | Shoelace | 54,573 | 17,140 | 15,068 |
@@ -2113,7 +2108,7 @@ Structure, accessibility, state and form participation. No presentation, and no
2113
2108
  | \`steps\` | 82,900 | 27,205 | 22,347 | 6,303 | 1,832 | 1,249 |
2114
2109
  | \`switch\` | 34,314 | 11,106 | 9,826 | 1,798 | 832 | 358 |
2115
2110
  | \`table\` | 31,861 | 10,274 | 9,086 | 4,373 | 1,299 | 831 |
2116
- | \`tabs\` | 33,935 | 11,035 | 9,722 | 4,469 | 1,279 | 803 |
2111
+ | \`tabs\` | 33,935 | 11,060 | 9,721 | 4,469 | 1,277 | 735 |
2117
2112
  | \`tag\` | 74,439 | 24,760 | 20,435 | 897 | 464 | 219 |
2118
2113
  | \`textarea\` | 37,001 | 11,743 | 10,362 | 4,115 | 1,414 | 884 |
2119
2114
  | \`time-input\` | 136,201 | 43,547 | 36,687 | 28,643 | 7,661 | 6,120 |
@@ -2212,7 +2207,7 @@ The headless tier plus presentation. This is what \`bun add @fusion.dev/helix\`
2212
2207
  | \`steps\` | 96,252 | 29,575 | 24,410 | 19,643 | 4,202 | 3,353 |
2213
2208
  | \`switch\` | 38,603 | 12,160 | 10,668 | 6,072 | 1,891 | 1,318 |
2214
2209
  | \`table\` | 35,190 | 10,865 | 9,580 | 7,692 | 1,916 | 1,378 |
2215
- | \`tabs\` | 40,196 | 12,332 | 10,861 | 10,719 | 2,598 | 1,976 |
2210
+ | \`tabs\` | 40,196 | 12,348 | 10,882 | 10,719 | 2,597 | 1,971 |
2216
2211
  | \`tag\` | 77,633 | 25,634 | 21,148 | 4,076 | 1,245 | 995 |
2217
2212
  | \`text\` | 29,734 | 9,670 | 8,508 | 3,922 | 958 | 605 |
2218
2213
  | \`textarea\` | 40,350 | 12,544 | 11,034 | 7,447 | 2,227 | 1,615 |
@@ -2240,12 +2235,12 @@ column is what was removed rather than part of what remains.
2240
2235
 
2241
2236
  | Package | Unpacked | Files | Source maps stripped | Runtime dependencies |
2242
2237
  |---|---|---|---|---|
2243
- | \`@fusion.dev/helix-core\` | 879,273 | 113 | 162,880 | \`@floating-ui/dom\`, \`@fusion.dev/helix-icons\` |
2244
- | \`@fusion.dev/helix-headless\` | 9,741,797 | 422 | 738,193 | \`@fusion.dev/helix-core\`, \`@fusion.dev/helix-icons\`, \`uqr\` |
2245
- | \`@fusion.dev/helix-icons\` | 66,379 | 11 | 20,959 | none |
2238
+ | \`@fusion.dev/helix-core\` | 655,135 | 113 | 154,658 | \`@floating-ui/dom\`, \`@fusion.dev/helix-icons\` |
2239
+ | \`@fusion.dev/helix-headless\` | 8,636,163 | 422 | 699,239 | \`@fusion.dev/helix-core\`, \`@fusion.dev/helix-icons\`, \`uqr\` |
2240
+ | \`@fusion.dev/helix-icons\` | 65,943 | 11 | 20,928 | none |
2246
2241
  | \`@fusion.dev/helix-styles\` | 83,277 | 5 | 0 | none |
2247
- | \`@fusion.dev/helix-theme\` | 463,476 | 8 | 4,764 | none |
2248
- | \`@fusion.dev/helix\` | 9,714,436 | 718 | 287,181 | \`@fusion.dev/helix-core\`, \`@fusion.dev/helix-headless\`, \`@fusion.dev/helix-styles\` |
2242
+ | \`@fusion.dev/helix-theme\` | 461,551 | 8 | 4,699 | none |
2243
+ | \`@fusion.dev/helix\` | 9,306,931 | 718 | 278,170 | \`@fusion.dev/helix-core\`, \`@fusion.dev/helix-headless\`, \`@fusion.dev/helix-styles\` |
2249
2244
 
2250
2245
  ## Not measured yet
2251
2246
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fusion.dev/helix-docs",
3
3
  "description": "Versioned documentation content and registry for Helix.",
4
- "version": "0.2.0",
4
+ "version": "0.3.0",
5
5
  "license": "MIT",
6
6
  "author": "Ross Sheppard <ross.sheppard@fusion.dev>",
7
7
  "repository": {
@@ -25,7 +25,7 @@
25
25
  ],
26
26
  "sideEffects": false,
27
27
  "scripts": {
28
- "build": "tsc -p tsconfig.json",
28
+ "build": "tsc -p tsconfig.json && tsc -p tsconfig.types.json",
29
29
  "docs": "node scripts/build-docs.mjs",
30
30
  "peers": "bun scripts/build-peers.mjs",
31
31
  "test": "bun test src",
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "bugs": "https://github.com/project-fusion/fusion/issues",
36
36
  "devDependencies": {
37
- "@fusion.dev/helix-cem": "0.2.0",
37
+ "@fusion.dev/helix-cem": "0.3.0",
38
38
  "@fusion.dev/scripts": "0.0.0-dev"
39
39
  },
40
40
  "homepage": "https://fusion.dev",