@fusion.dev/helix-headless 0.53.6 → 0.55.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.
@@ -544,7 +544,7 @@ let HelixHeadlessDataTable = class HelixHeadlessDataTable extends MixinCollectio
544
544
  }
545
545
  }
546
546
  renderCell(column, row, picked) {
547
- const edge = edgeOf(column.align);
547
+ const edge = edgeOf(column.textAlign);
548
548
  return html `<span
549
549
  part="cell${picked ? ' cell-selected' : ''}"
550
550
  role="gridcell"
@@ -22,10 +22,12 @@ import { type PropertyValues, type TemplateResult } from 'lit';
22
22
  * @aliases Key, Series Key
23
23
  * @documentation https://fusion.dev/helix/components/legend
24
24
  *
25
- * @csspart entry - One name and its swatch.
25
+ * @csspart entry - One name and its swatch. The swatch is drawn on the entry,
26
+ * which is `::part(entry)::before` from a consumer's sheet: the series is
27
+ * already on the entry as `--chart-series`, so a box would have carried nothing,
28
+ * and this tier ships no CSS.
26
29
  * @csspart list - The list itself.
27
30
  * @csspart name - One series name.
28
- * @csspart swatch - The color a series is drawn in.
29
31
  */
30
32
  export declare class HelixHeadlessLegend extends HelixElement {
31
33
  /**
@@ -33,7 +33,6 @@ let HelixHeadlessLegend = class HelixHeadlessLegend extends HelixElement {
33
33
  style="--chart-series: var(--chart-series-${seriesIndex(index)})"
34
34
  role="listitem"
35
35
  >
36
- <span aria-hidden="true" part="swatch"></span>
37
36
  <span part="name">${name}</span>
38
37
  </li>`)}
39
38
  </ul>`;
@@ -38,7 +38,7 @@ declare const HelixHeadlessMegamenu_base: (new (...args: any[]) => import("@fusi
38
38
  *
39
39
  * @cssproperty --megamenu-top - Where the panel starts, measured from the bar above it.
40
40
  *
41
- * @cssstate narrow - While the page is under `full-below`, so the panel is a band whether or not one was asked for.
41
+ * @cssstate narrow - While the page is under `band-below`, so the panel is a band whether or not one was asked for.
42
42
  * @cssstate open - While the panel is open, which is when the trigger reads as current.
43
43
  */
44
44
  export declare class HelixHeadlessMegamenu extends HelixHeadlessMegamenu_base {
@@ -67,7 +67,7 @@ export declare class HelixHeadlessMegamenu extends HelixHeadlessMegamenu_base {
67
67
  * become true long after hydration, and a viewport measured before the first
68
68
  * paint is the opposite case. See `MixinControllerOpen` on `open`.
69
69
  */
70
- fullBelow: number;
70
+ bandBelow: number;
71
71
  /**
72
72
  * The section's name, which names the default trigger and the panel.
73
73
  */
@@ -85,18 +85,25 @@ export declare class HelixHeadlessMegamenu extends HelixHeadlessMegamenu_base {
85
85
  * Whether the panel is a card under the trigger or a band across the page.
86
86
  *
87
87
  * The resting one is the card, hanging off the trigger and as wide as it
88
- * needs to be. `full` is the other shape navigations use: a band from edge to
88
+ * needs to be. `band` is the other shape navigations use: a run from edge to
89
89
  * edge under the header, with its contents held to the page's own measure, so
90
90
  * a set of destinations too wide to sit under one word gets the whole width
91
91
  * to lay out in.
92
92
  *
93
- * It changes where the panel goes as well as how it looks, which is why this
94
- * tier knows about it at all: a band spans the viewport, so there is nothing
95
- * to measure it against and nothing to flip or shift to, and the placing pass
96
- * is told not to run. Everything else the popover gives, the top layer and
97
- * the dismissal, is exactly what such a band needs.
93
+ * **A band and not a width, which is why the word is not `full`.** It changes
94
+ * where the panel goes as well as how wide it is, which is why this tier
95
+ * knows about it at all: a band spans the viewport, so there is nothing to
96
+ * measure it against and nothing to flip or shift to, and the placing pass is
97
+ * told not to run. Everything else the popover gives, the top layer and the
98
+ * dismissal, is exactly what such a band needs. `full` is the registry's word
99
+ * for a width, and `Button` and `Dialog` are the two families answering that
100
+ * question; borrowing it here would put a third meaning on it.
101
+ *
102
+ * `variant` and not an axis, for the reason the registry gives: a card under
103
+ * a word and a band under a header are two arrangements this family has, and
104
+ * no other family is being asked the same question.
98
105
  */
99
- variant?: 'full';
106
+ variant?: 'band';
100
107
  private narrow;
101
108
  private query;
102
109
  /** A band, whether because it was asked for or because there is no room. */
@@ -12,7 +12,7 @@ let showing;
12
12
  let HelixHeadlessMegamenu = class HelixHeadlessMegamenu extends MixinControllerOpen(HelixElement) {
13
13
  constructor() {
14
14
  super(...arguments);
15
- this.fullBelow = 640;
15
+ this.bandBelow = 640;
16
16
  this.label = '';
17
17
  this.openOn = 'hover';
18
18
  this.narrow = false;
@@ -84,16 +84,16 @@ let HelixHeadlessMegamenu = class HelixHeadlessMegamenu extends MixinControllerO
84
84
  }
85
85
  static { HelixHeadlessMegamenu_1 = this; }
86
86
  get banded() {
87
- return this.variant === 'full' || this.narrow;
87
+ return this.variant === 'band' || this.narrow;
88
88
  }
89
89
  watchWidth() {
90
90
  this.query?.removeEventListener('change', this.handleWidth);
91
91
  this.query = undefined;
92
- if (!(this.fullBelow > 0) || typeof matchMedia !== 'function') {
92
+ if (!(this.bandBelow > 0) || typeof matchMedia !== 'function') {
93
93
  this.narrow = false;
94
94
  return;
95
95
  }
96
- this.query = matchMedia(`(max-width: ${this.fullBelow - 0.02}px)`);
96
+ this.query = matchMedia(`(max-width: ${this.bandBelow - 0.02}px)`);
97
97
  this.query.addEventListener('change', this.handleWidth);
98
98
  this.handleWidth(this.query);
99
99
  }
@@ -186,7 +186,7 @@ let HelixHeadlessMegamenu = class HelixHeadlessMegamenu extends MixinControllerO
186
186
  }
187
187
  updated(changed) {
188
188
  super.updated?.(changed);
189
- if (changed.has('fullBelow')) {
189
+ if (changed.has('bandBelow')) {
190
190
  this.watchWidth();
191
191
  }
192
192
  this.toggleState('narrow', this.narrow);
@@ -222,8 +222,8 @@ let HelixHeadlessMegamenu = class HelixHeadlessMegamenu extends MixinControllerO
222
222
  }
223
223
  };
224
224
  __decorate([
225
- property({ attribute: 'full-below', type: Number })
226
- ], HelixHeadlessMegamenu.prototype, "fullBelow", void 0);
225
+ property({ attribute: 'band-below', type: Number })
226
+ ], HelixHeadlessMegamenu.prototype, "bandBelow", void 0);
227
227
  __decorate([
228
228
  property()
229
229
  ], HelixHeadlessMegamenu.prototype, "label", void 0);
@@ -35,10 +35,12 @@ declare const HelixHeadlessRadio_base: (new (...args: any[]) => import("@fusion.
35
35
  *
36
36
  * @event {HelixSelectEvent} helix-select - Fired when this choice is made. Cancelable: refusing it takes the decision and nothing moves.
37
37
  *
38
- * @csspart box - The circle.
38
+ * @csspart box - The circle. The dot inside it is drawn on the box itself,
39
+ * which is `::part(box)::after` from a consumer's sheet: this tier ships no CSS,
40
+ * so a box it renders for one to paint is an empty child costing whatever the
41
+ * consumer's own layout charges for a child. `ListItem` measured that.
39
42
  * @csspart item - The button, which is the whole row.
40
43
  * @csspart label - The words beside it.
41
- * @csspart mark - The dot inside it.
42
44
  *
43
45
  * @cssstate selected - While it is the one chosen.
44
46
  */
@@ -32,7 +32,7 @@ let HelixHeadlessRadio = class HelixHeadlessRadio extends MixinSelectableItem(He
32
32
  tabindex=${this.selected || this.focusable || !this.childrenKnown ? '0' : '-1'}
33
33
  @click=${() => this.select()}
34
34
  >
35
- <span part="box"><span part="mark" aria-hidden="true"></span></span>
35
+ <span part="box"></span>
36
36
  <slot name="icon"></slot>
37
37
  <span part="label"><slot></slot></span>
38
38
  </button>`;
@@ -21,6 +21,13 @@ import '../../button/index.js';
21
21
  * with two hidden in CSS: the accessibility tree would otherwise carry marks
22
22
  * that are not there.
23
23
  *
24
+ * **The two half lines either side of the mark are a sheet's, on `::before` and
25
+ * `::after` of the step.** Both are placed entirely from properties the list
26
+ * hands down, so there was nothing about them for a box to carry, and this tier
27
+ * ships no CSS: a box it rendered for one to paint would be an empty child
28
+ * costing whatever the consumer's own layout charges for a child. `ListItem`
29
+ * measured that.
30
+ *
24
31
  * @since 0.1.0
25
32
  * @status stable
26
33
  * @aliases Stage
@@ -38,10 +45,8 @@ import '../../button/index.js';
38
45
  * @csspart description - The line under it.
39
46
  * @csspart label - The step's name, which is a control.
40
47
  * @csspart label-button - The inner control.
41
- * @csspart lead - The half line arriving from the step before.
42
48
  * @csspart mark - The glyph inside the marker: a tick, a dot, or an empty ring.
43
49
  * @csspart marker - The shape carrying the number or the check.
44
- * @csspart trail - The half line running on to the next.
45
50
  */
46
51
  export declare class HelixHeadlessStep extends HelixElement {
47
52
  /**
@@ -49,9 +49,7 @@ let HelixHeadlessStep = class HelixHeadlessStep extends HelixElement {
49
49
  </${this.tag('button')}>`;
50
50
  }
51
51
  render() {
52
- return html `<span part="lead" aria-hidden="true"></span>
53
- <span part="trail" aria-hidden="true"></span>
54
- <span part="marker" aria-hidden="true">
52
+ return html `<span part="marker" aria-hidden="true">
55
53
  ${this.renderMarker()}
56
54
  </span>
57
55
  <span part="body">
@@ -38,8 +38,11 @@ declare const HelixHeadlessSwitch_base: (new (...args: any[]) => import("@fusion
38
38
  * @csspart field - The row: the track and its label.
39
39
  * @csspart hint - Help under the control, while nothing is wrong.
40
40
  * @csspart label - The words beside it.
41
- * @csspart thumb - The part that moves.
42
- * @csspart track - The groove the thumb travels in.
41
+ * @csspart track - The groove. The thumb that travels in it is drawn on the
42
+ * groove itself, which is `::part(track)::after` from a consumer's sheet: this
43
+ * tier ships no CSS, so a box it renders for one to paint is an empty child
44
+ * costing whatever the consumer's own layout charges for a child. `ListItem`
45
+ * measured that.
43
46
  *
44
47
  * @cssstate checked - While it is on.
45
48
  * @cssstate invalid - While there is feedback to show: the platform's verdict, or the consumer's `feedback`.
@@ -54,7 +54,6 @@ let HelixHeadlessSwitch = class HelixHeadlessSwitch extends MixinControllerCheck
54
54
  @change=${this.handleChange}
55
55
  @input=${this.stopInner}
56
56
  />
57
- <span part="thumb" aria-hidden="true"></span>
58
57
  </span>
59
58
  <span part="label"><slot>${this.label}</slot></span>
60
59
  </label>
@@ -23,7 +23,7 @@ export declare const at: (row: Readonly<Record<string, unknown>>, path: string)
23
23
  /**
24
24
  * The edge a column names, where it names one of the three.
25
25
  *
26
- * **`align` is a string anybody can write**, the union on the property being a
26
+ * **`text-align` is a string anybody can write**, the union on the property being a
27
27
  * compile-time claim and nothing else, and both tables put the value straight
28
28
  * into CSS: `Table` into a rule in each row's shadow root, `DataTable` into the
29
29
  * cell it draws. One guard, here with the property, rather than one in each
@@ -46,7 +46,7 @@ export declare const edgeOf: (align: string | undefined) => string | undefined;
46
46
  *
47
47
  * **It is also where every per-column fact goes**, which is the reason
48
48
  * `DataTable` needs no column config object. `field` names the key the cell
49
- * reads, `sortable` says a reader may reorder by it, `align` says which edge
49
+ * reads, `sortable` says a reader may reorder by it, `text-align` says which edge
50
50
  * the values line up against, and `cell` takes a function for anything the
51
51
  * field alone cannot express. The schema is markup and the data is a property:
52
52
  * a schema is small, declarative, and something a reader should be able to see
@@ -96,28 +96,6 @@ export declare const edgeOf: (align: string | undefined) => string | undefined;
96
96
  * @cssstate sorted-descending - While the table is sorted by this column, largest first.
97
97
  */
98
98
  export declare class HelixHeadlessTableColumn extends HelixElement {
99
- /**
100
- * Which edge the values line up against.
101
- *
102
- * `end` is what a column of numbers wants, so digits of the same magnitude
103
- * sit above each other. Left unset the cell inherits the table's own
104
- * alignment, which is `start` for text in either writing direction.
105
- * `DataTable` aligns the cells it draws. A `Table`'s cells are markup the
106
- * consumer wrote, so the table writes nothing onto them: it publishes the
107
- * edge to each cell under this column as `--table-cell-align`, which
108
- * `@fusion.dev/helix` reads. The heading is this element's own, and
109
- * `@fusion.dev/helix` sets it to the same edge in either table. `end` is the
110
- * inline end, so in a right-to-left table it is the left.
111
- *
112
- * **Reflected, because the heading is drawn by a stylesheet and has no other
113
- * way to learn it.** Set as a property, which is how React sets it, an
114
- * unreflected `align` left no attribute to select on, so the heading stayed at
115
- * the start over a column of digits set to the end. It is the author's
116
- * declaration and nothing moves it afterwards, so an attribute cannot come to
117
- * disagree with the element, which is the reason `open` records a custom
118
- * state instead. No default, so an unset column writes nothing onto its host.
119
- */
120
- align?: 'center' | 'end' | 'start';
121
99
  /**
122
100
  * How to read the cell, where the field alone will not do.
123
101
  *
@@ -177,6 +155,36 @@ export declare class HelixHeadlessTableColumn extends HelixElement {
177
155
  * onto its host.
178
156
  */
179
157
  sortable: boolean;
158
+ /**
159
+ * Which edge the values line up against.
160
+ *
161
+ * **Text alignment, and the name says so.** `Flex` and `Grid` ask the CSS
162
+ * box-alignment question, where a thing sits in a container, and they are
163
+ * named for the properties they mirror; this is `text-align` down a column of
164
+ * values, and `Dialog` answers the same question under the same name. One
165
+ * word for both questions is how a consumer who learned it on a layout writes
166
+ * it here and gets no error, a value a component does not have leaving the
167
+ * default in place and looking deliberate.
168
+ *
169
+ * `end` is what a column of numbers wants, so digits of the same magnitude
170
+ * sit above each other. Left unset the cell inherits the table's own
171
+ * alignment, which is `start` for text in either writing direction.
172
+ * `DataTable` aligns the cells it draws. A `Table`'s cells are markup the
173
+ * consumer wrote, so the table writes nothing onto them: it publishes the
174
+ * edge to each cell under this column as `--table-cell-align`, which
175
+ * `@fusion.dev/helix` reads. The heading is this element's own, and
176
+ * `@fusion.dev/helix` sets it to the same edge in either table. `end` is the
177
+ * inline end, so in a right-to-left table it is the left.
178
+ *
179
+ * **Reflected, because the heading is drawn by a stylesheet and has no other
180
+ * way to learn it.** Set as a property, which is how React sets it, an
181
+ * unreflected edge left no attribute to select on, so the heading stayed at
182
+ * the start over a column of digits set to the end. It is the author's
183
+ * declaration and nothing moves it afterwards, so an attribute cannot come to
184
+ * disagree with the element, which is the reason `open` records a custom
185
+ * state instead. No default, so an unset column writes nothing onto its host.
186
+ */
187
+ textAlign?: 'center' | 'end' | 'start';
180
188
  /**
181
189
  * The track this column takes, as CSS.
182
190
  *
@@ -137,7 +137,7 @@ let HelixHeadlessTableColumn = class HelixHeadlessTableColumn extends HelixEleme
137
137
  }
138
138
  updated(changed) {
139
139
  super.updated(changed);
140
- if (changed.has('align') || changed.has('width')) {
140
+ if (changed.has('textAlign') || changed.has('width')) {
141
141
  this.tellHolder();
142
142
  }
143
143
  }
@@ -192,9 +192,6 @@ let HelixHeadlessTableColumn = class HelixHeadlessTableColumn extends HelixEleme
192
192
  : nothing}</span>`;
193
193
  }
194
194
  };
195
- __decorate([
196
- property({ reflect: true })
197
- ], HelixHeadlessTableColumn.prototype, "align", void 0);
198
195
  __decorate([
199
196
  property({ attribute: false })
200
197
  ], HelixHeadlessTableColumn.prototype, "cell", void 0);
@@ -210,6 +207,9 @@ __decorate([
210
207
  __decorate([
211
208
  property({ reflect: true, type: Boolean })
212
209
  ], HelixHeadlessTableColumn.prototype, "sortable", void 0);
210
+ __decorate([
211
+ property({ attribute: 'text-align', reflect: true })
212
+ ], HelixHeadlessTableColumn.prototype, "textAlign", void 0);
213
213
  __decorate([
214
214
  property()
215
215
  ], HelixHeadlessTableColumn.prototype, "width", void 0);
@@ -124,7 +124,7 @@ export declare class HelixHeadlessTable extends HelixHeadlessTable_base {
124
124
  protected get slotted(): Element[];
125
125
  private assign;
126
126
  /**
127
- * The rules handing each column's `align` to the cells under it. One sheet for
127
+ * The rules handing each column's `text-align` to the cells under it. One sheet for
128
128
  * every row this table holds, rewritten in place, so a column changing its
129
129
  * edge reaches every row in one write.
130
130
  */
@@ -120,7 +120,7 @@ let HelixHeadlessTable = class HelixHeadlessTable extends MixinCollection(HelixE
120
120
  return;
121
121
  }
122
122
  const rules = this.tableColumns.flatMap((column, index) => {
123
- const edge = edgeOf(column.align);
123
+ const edge = edgeOf(column.textAlign);
124
124
  return edge ? [`::slotted(:nth-child(${index + 1})) { --table-cell-align: ${edge}; }`] : [];
125
125
  });
126
126
  const text = ['::slotted(*) { --table-cell-align: initial; }', ...rules].join('\n');
@@ -31,11 +31,14 @@ import { type PropertyValues, type TemplateResult } from 'lit';
31
31
  * platform can answer, and it answers it at assignment time.
32
32
  * @slot time - When it happened, as a reader should see it written.
33
33
  *
34
- * @csspart connector - The line arriving at this entry.
35
34
  * @csspart content - What happened, and the time above it.
36
35
  * @csspart dot - The mark drawn where nothing was slotted for one.
37
36
  * @csspart marker - The mark on the line.
38
- * @csspart rail - The column holding the mark and the line.
37
+ * @csspart rail - The column holding the mark and the line. Both halves of the
38
+ * line are drawn on the rail, which is `::part(rail)::before` and `::after` from
39
+ * a consumer's sheet: this tier ships no CSS, so a box it renders for one to
40
+ * paint is an empty child costing whatever the consumer's own layout charges for
41
+ * a child. `ListItem` measured that.
39
42
  * @csspart time - When it happened.
40
43
  */
41
44
  export declare class HelixHeadlessTimelineItem extends HelixElement {
@@ -31,10 +31,6 @@ let HelixHeadlessTimelineItem = class HelixHeadlessTimelineItem extends HelixEle
31
31
  render() {
32
32
  const when = html `<slot name="time"></slot>`;
33
33
  return html `<div part="rail">
34
- <div
35
- part="connector"
36
- aria-hidden="true"
37
- ></div>
38
34
  ${this.renderMarker()}
39
35
  </div>
40
36
  ${this.datetime === ''
@@ -29,13 +29,16 @@ declare const HelixHeadlessToggleGroup_base: (new (...args: any[]) => import("@f
29
29
  * @event {HelixSelectEvent} helix-select - Fired when an item is chosen. Dispatched by the item and listened for here. Cancelable: refusing it takes the decision and nothing moves.
30
30
  * @event {Event} input - Announced once the value has moved.
31
31
  *
32
- * @csspart divider - The rule after an inline label, separating it from the items.
33
32
  * @csspart feedback - What is wrong with it.
34
33
  * @csspart hint - Help under the row, while nothing is wrong.
35
34
  * @csspart indicator - The moving marker behind the selected item.
36
35
  * @csspart label - The question, over the row.
37
36
  * @csspart row - The line the track sits on, with whatever flanks it.
38
- * @csspart track - The bar the items sit in.
37
+ * @csspart track - The bar the items sit in. The rule after an inline label,
38
+ * separating it from the items, is drawn on the bar, which is
39
+ * `::part(track)::before` from a consumer's sheet: this tier ships no CSS, so a
40
+ * box it renders for one to paint is an empty child costing whatever the
41
+ * consumer's own layout charges for a child. `ListItem` measured that.
39
42
  *
40
43
  * @cssstate invalid - While there is feedback to show: the platform's verdict, or the consumer's `feedback`.
41
44
  */
@@ -83,7 +83,7 @@ let HelixHeadlessToggleGroup = class HelixHeadlessToggleGroup extends MixinContr
83
83
  <div part="row">
84
84
  <slot name="start"></slot>
85
85
  <div part="track">
86
- ${inside ? html `${label}<div part="divider" aria-hidden="true"></div>` : nothing}
86
+ ${inside ? label : nothing}
87
87
  <div part="indicator" aria-hidden="true"></div>
88
88
  <slot
89
89
  @slotchange=${() => {
@@ -69,7 +69,10 @@ import '../../menu/index.js';
69
69
  * @csspart forward - The control that goes on ten seconds.
70
70
  * @csspart fullscreen - The whole screen, and back.
71
71
  * @csspart loading - What is said while the picture waits for more of itself.
72
- * @csspart loading-mark - The part of that which spins.
72
+ * The part of that which spins is drawn on it, which is `::part(loading)::before`
73
+ * from a consumer's sheet: this tier ships no CSS, so a box it renders for one to
74
+ * paint is an empty child costing whatever the consumer's own layout charges for
75
+ * a child. `ListItem` measured that.
73
76
  * @csspart more - The button that opens it.
74
77
  * @csspart mute - Sound on and off.
75
78
  * @csspart next - Asks for the next item, where it was asked for.
@@ -826,7 +826,6 @@ let HelixHeadlessVideo = class HelixHeadlessVideo extends HelixElement {
826
826
  role="status"
827
827
  aria-live="polite"
828
828
  >
829
- <span part="loading-mark" aria-hidden="true"></span>
830
829
  ${this.loadingLabel}
831
830
  </div>`
832
831
  : nothing}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fusion.dev/helix-headless",
3
3
  "description": "Headless component tier. Structure, accessibility, state, and form participation. No styling.",
4
- "version": "0.53.6",
4
+ "version": "0.55.0",
5
5
  "license": "MIT",
6
6
  "author": "Ross Sheppard <ross.sheppard@fusion.dev>",
7
7
  "repository": {
@@ -46,14 +46,14 @@
46
46
  },
47
47
  "bugs": "https://github.com/project-fusion/fusion/issues",
48
48
  "dependencies": {
49
- "@fusion.dev/helix-core": "0.53.6",
50
- "@fusion.dev/helix-icons": "0.53.6",
49
+ "@fusion.dev/helix-core": "0.55.0",
50
+ "@fusion.dev/helix-icons": "0.55.0",
51
51
  "uqr": "^0.1.3"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@custom-elements-manifest/analyzer": "^0.11.0",
55
- "@fusion.dev/helix-cem": "0.53.6",
56
- "@fusion.dev/helix-testing": "0.53.6",
55
+ "@fusion.dev/helix-cem": "0.55.0",
56
+ "@fusion.dev/helix-testing": "0.55.0",
57
57
  "@fusion.dev/scripts": "0.0.0-dev",
58
58
  "@happy-dom/global-registrator": "^20.0.0",
59
59
  "@lit-labs/ssr": "^4.1.0",