@reliability-design/web 1.4.8 → 1.4.9

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/dist/index.mjs CHANGED
@@ -105,11 +105,23 @@ RelAccordion.styles = css`
105
105
  pointer-events: none;
106
106
  }
107
107
 
108
+ /* No CSS border property here, deliberately. Figma's Plain stroke is
109
+ strokeAlign=INSIDE, which OVERLAYS the content: verified against the
110
+ live file, the Head frame is 539 wide inside a 539 component in every
111
+ one of the 24 variants, Plain and Shadow alike. A CSS border insets
112
+ its children instead, which pushed the header in by 1px on all four
113
+ sides — so the hover wash stopped 1px short of the card edge and a
114
+ ring of untinted --rel-bg-neutral survived around it. That ring is
115
+ what read as "a border appears on hover", and it appeared on EVERY
116
+ variant because the border lived on this shared base; Shadow merely
117
+ set border-color: transparent, which still reserves the 1px.
118
+ An outline with a negative offset reproduces strokeAlign=INSIDE
119
+ exactly: no layout inset, painted on top, follows border-radius, and
120
+ not clipped by this element's own overflow: hidden. */
108
121
  .accordion {
109
122
  background: var(--rel-bg-neutral);
110
123
  box-sizing: border-box;
111
124
  border-radius: var(--_acc-radius, var(--rel-border-radius-xs));
112
- border: 1px solid var(--rel-border-subtle);
113
125
  overflow: hidden;
114
126
  box-shadow: var(--rel-shadows-sm);
115
127
  }
@@ -118,8 +130,9 @@ RelAccordion.styles = css`
118
130
  --_acc-radius: var(--rel-border-radius-xs);
119
131
  }
120
132
 
133
+ /* Live Figma: Style=Shadow carries NO stroke at all (strokes: []). */
121
134
  :host([appearance="shadow"]) .accordion {
122
- border-color: transparent;
135
+ outline: none;
123
136
  box-shadow: var(--rel-shadows-sm);
124
137
  }
125
138
 
@@ -127,11 +140,32 @@ RelAccordion.styles = css`
127
140
  --_acc-radius: 0px;
128
141
  }
129
142
 
143
+ /* Live Figma: Style=Plain is strokeWeight 1, strokeAlign INSIDE. */
130
144
  :host([appearance="plain"]) .accordion {
131
- border-color: var(--rel-border-subtle);
145
+ outline: 1px solid var(--rel-border-subtle);
146
+ outline-offset: -1px;
132
147
  box-shadow: none;
133
148
  }
134
149
 
150
+ /* box-shadow is NOT painted in forced-colors mode (Windows High Contrast)
151
+ but outline is — the same UA behaviour styles/focus-ring.ts documents,
152
+ and the reason its outer ring is an outline rather than a third shadow
153
+ layer. It matters more here than there: appearance="shadow" has no
154
+ stroke in Figma, so once the shadow is dropped the card has NO boundary
155
+ at all and the header, body and page all read as one surface.
156
+ This used to work by accident — while the base rule declared a 1px solid
157
+ transparent border, the UA forced that transparent border to a visible
158
+ colour. Removing the border to fix the hover inset took the accident
159
+ away with it, so the edge is restored deliberately.
160
+ CanvasText rather than a token: in forced-colors the user's palette is
161
+ the whole point. Plain already has an outline and simply re-asserts it. */
162
+ @media (forced-colors: active) {
163
+ .accordion {
164
+ outline: 1px solid CanvasText;
165
+ outline-offset: -1px;
166
+ }
167
+ }
168
+
135
169
  /* ── Header ───────────────────────────────────────────────── */
136
170
 
137
171
  .header {
@@ -552,8 +586,12 @@ RelAlert.styles = css2`
552
586
  gap: var(--rel-spacing-lg);
553
587
  padding: var(--rel-spacing-md);
554
588
  border-radius: var(--rel-border-radius-sm);
555
- min-width: 360px;
556
589
  box-sizing: border-box;
590
+ /* No min-width. Figma's Alert is a fixed 400 with minWidth UNSET on all
591
+ twelve variants (verified on the live file), so a 360px floor was
592
+ code-invented. It stopped the alert fitting any column narrower than
593
+ 360px and is what forced the docs Variants grid to minmax(360px, 1fr).
594
+ A consumer who wants a floor can set one on the host. */
557
595
  }
558
596
 
559
597
  /* Figma "Frame 10": the leading icon and the content column, 12px apart.
@@ -566,10 +604,17 @@ RelAlert.styles = css2`
566
604
  min-width: 0;
567
605
  }
568
606
 
607
+ /* Figma "Content": layoutGrow 1, layoutSizingHorizontal FILL — it takes the
608
+ width left over beside the leading icon. Without flex it hugged its text,
609
+ so a short message left the column narrow and a long one wrapped earlier
610
+ than it needed to. min-width: 0 is the companion every flex child needs
611
+ before it is allowed to shrink below its content. */
569
612
  .rel-content {
570
613
  display: flex;
571
614
  flex-direction: column;
572
615
  gap: var(--rel-spacing-3xs);
616
+ flex: 1;
617
+ min-width: 0;
573
618
  }
574
619
 
575
620
  .rel-message1 {
@@ -653,9 +698,16 @@ RelAlert.styles = css2`
653
698
  /* COLORS — semantic status tokens carry both light and dark modes.
654
699
  Base classes are the SUBTLE style (Figma Style=Subtle): tone light fill,
655
700
  no border, tone-colored title. */
656
- .rel-alert {
657
- border: 1px solid transparent;
658
- }
701
+ /* No border here. Only Style=Outline has a stroke in Figma — Solid and
702
+ Subtle report strokes: NONE on the live file — and Outline's is
703
+ strokeAlign: INSIDE, which OVERLAYS the content rather than insetting it.
704
+ A CSS border does inset, so declaring a transparent one on the shared
705
+ base made every variant's content box 1px tighter than Figma and
706
+ reserved a pixel that only one appearance ever paints in.
707
+ The outline block below draws the real thing with outline-offset: -1px,
708
+ which reproduces inside-aligned stroke exactly: no layout inset, painted
709
+ on top, and it follows border-radius. Same fix as rel-accordion, which
710
+ had the identical bug. */
659
711
 
660
712
  .alert-neutral {
661
713
  background: var(--rel-bg-neutral);
@@ -752,26 +804,45 @@ RelAlert.styles = css2`
752
804
  /* Outline style (Figma Style=Outline): no fill, tone border, tone title */
753
805
  :host([appearance="outline"]) .rel-alert {
754
806
  background: transparent;
807
+ outline: 1px solid transparent;
808
+ outline-offset: -1px;
755
809
  }
756
810
 
757
811
  :host([appearance="outline"]) .alert-neutral {
758
- border-color: var(--rel-border-default);
812
+ outline-color: var(--rel-border-default);
759
813
  }
760
814
 
761
815
  :host([appearance="outline"]) .alert-info {
762
- border-color: var(--rel-border-info);
816
+ outline-color: var(--rel-border-info);
763
817
  }
764
818
 
765
819
  :host([appearance="outline"]) .alert-success {
766
- border-color: var(--rel-border-success);
820
+ outline-color: var(--rel-border-success);
767
821
  }
768
822
 
769
823
  :host([appearance="outline"]) .alert-warning {
770
- border-color: var(--rel-border-warning);
824
+ outline-color: var(--rel-border-warning);
771
825
  }
772
826
 
773
827
  :host([appearance="outline"]) .alert-destructive {
774
- border-color: var(--rel-border-danger);
828
+ outline-color: var(--rel-border-danger);
829
+ }
830
+
831
+ /* Every appearance keeps a boundary in forced-colors mode (Windows High
832
+ Contrast), where the UA overrides fills and a Solid or Subtle alert would
833
+ otherwise be indistinguishable from the page. This used to work by
834
+ accident: while the shared base declared a 1px transparent border, the UA
835
+ forced it to a visible colour. Removing that border to fix the content
836
+ inset took the accident with it, so the edge is restored deliberately —
837
+ outline rather than border because outline is painted in that mode and
838
+ because it does not re-introduce the inset. CanvasText, not a token: in
839
+ forced-colors the user's palette is the point. Outline appearance simply
840
+ re-asserts what it already has. Same treatment as rel-accordion. */
841
+ @media (forced-colors: active) {
842
+ .rel-alert {
843
+ outline: 1px solid CanvasText;
844
+ outline-offset: -1px;
845
+ }
775
846
  }
776
847
 
777
848
  /* Close-icon colour on Subtle and Outline: the tone colour, matching the
@@ -1127,16 +1198,28 @@ var RelTextfield = class extends LitElement3 {
1127
1198
  </div>`;
1128
1199
  const belowTemplate = html3`<div part="helper" class="${["textfield-support", isError ? "error" : "", belowText ? "" : "empty"].filter(Boolean).join(" ")}" id="${describedById}" aria-live="polite">${this.helperIcon && belowText ? html3`<rel-icon part="helper-icon" name="${this.helperIcon}" aria-hidden="true"></rel-icon>` : ""}${belowText ?? ""}</div>`;
1129
1200
  if (this.variant === "legend") {
1130
- const fieldsetClass = ["field-fieldset", this.multiline ? "multiline" : ""].filter(Boolean).join(" ");
1201
+ const legendContainerClass = [
1202
+ "textfield-container",
1203
+ this.isDisabled ? "disabled" : "",
1204
+ this.multiline ? "multiline" : "",
1205
+ prefixClear ? "prefix-clear" : ""
1206
+ ].filter(Boolean).join(" ");
1131
1207
  return html3`
1132
1208
  <div class="${wrapperClasses}">
1133
- <fieldset part="control" class="${fieldsetClass}" @mousedown="${this.handleControlMousedown}"><legend class="field-legend" id="${legendId}">${labelHidden ? "" : this.label ?? ""}${this.required && !labelHidden ? html3`<span class="required-mark" aria-hidden="true">*</span>` : ""}</legend>
1134
- <div class="field-row">
1135
- ${prefixTemplate}
1136
- <div class="textfield-field">${inputTemplate}</div>
1137
- ${suffixTemplate}
1138
- </div>
1139
- </fieldset>
1209
+ <div part="control" class="${legendContainerClass}" @mousedown="${this.handleControlMousedown}">
1210
+ <!-- aria-hidden on the FIELDSET, and deliberately not countered on
1211
+ the legend: aria-hidden="false" cannot un-hide a descendant of
1212
+ a hidden element, so writing it would only mislead. Hiding is
1213
+ correct here — the fieldset now wraps nothing but the legend,
1214
+ so leaving it exposed would announce an empty group before the
1215
+ field. The name still works: accname step 2B includes hidden
1216
+ nodes when they are reached through aria-labelledby, which is
1217
+ how the input gets its name from this legend's id. -->
1218
+ <fieldset class="legend-outline" aria-hidden="true"><legend class="field-legend" id="${legendId}"><span class="legend-text">${labelHidden ? "" : this.label ?? ""}${this.required && !labelHidden ? html3`<span class="required-mark" aria-hidden="true">*</span>` : ""}</span></legend></fieldset>
1219
+ ${prefixTemplate}
1220
+ <div class="textfield-field">${inputTemplate}</div>
1221
+ ${suffixTemplate}
1222
+ </div>
1140
1223
  ${belowTemplate}
1141
1224
  </div>
1142
1225
  `;
@@ -1249,7 +1332,23 @@ RelTextfield.styles = css3`
1249
1332
 
1250
1333
  :host([floatinglabel]) .textfield-label {
1251
1334
  position: absolute;
1252
- left: var(--_rel-tf-pad-x, var(--rel-spacing-sm));
1335
+ /* ZERO, and the Figma numbers are why. Live spec, TextField Size=Medium
1336
+ Label Type=Floating: the "Inner Content" frame sits at dx=0 with
1337
+ padding-left 12, and the "Floating Label" frame sits at dx=12 with its
1338
+ own padding-left 4 — so the label BOX aligns with the content edge and
1339
+ its TEXT lands 4px in, that 4px being the notch padding rather than an
1340
+ indent.
1341
+ .textfield-field already begins at the control's border plus inline
1342
+ padding, i.e. at the content edge, so left: 0 puts the box exactly
1343
+ where Figma puts it. The old --_rel-tf-pad-x applied that inset a
1344
+ SECOND time and pushed the text to 29px from the control edge; briefly
1345
+ correcting it to -4px then over-shot the other way to 13px and left
1346
+ almost no border between the corner and the notch. Figma's own value is
1347
+ 16px from the control edge; we land on 17px, the extra 1px being our
1348
+ border, which Figma's inside-aligned stroke absorbs into its padding.
1349
+ With a prefix affix present .textfield-field starts after it, so the
1350
+ label follows the icon. */
1351
+ left: 0;
1253
1352
  top: 50%;
1254
1353
  transform: translateY(-50%);
1255
1354
  color: var(--rel-text-placeholder);
@@ -1280,19 +1379,59 @@ RelTextfield.styles = css3`
1280
1379
  but a ring and a notch cannot coexist on one box — see the legend
1281
1380
  variant's own note below — and Recipe B requires the ring. The surface
1282
1381
  colour is therefore a documented seam: --rel-textfield-surface. */
1382
+ /* TWO OVERLAPPING strips, not one exact one. The reference: the label is
1383
+ top: 0 against .textfield-field, whose top edge is the container's
1384
+ CONTENT-box top, so in the label's own coordinates 50% is one
1385
+ border-width below the border-box top. Measured from there the ring
1386
+ occupies [50% - notch, 50% - border] and the border [50% - border, 50%].
1387
+ A single strip cut to exactly that band leaked at some zoom levels and
1388
+ not others (v1.4.9 "slash" report): the border and box-shadow are
1389
+ pixel-snapped by Blink, while a strip inside a translateY(-50%) label
1390
+ rasterises at a fractional offset, so at 105%, 115%, 135% zoom, and at
1391
+ Windows 125% scaling with NO zoom, their edges disagreed by under a
1392
+ device pixel and a hairline of ring or border showed through the text.
1393
+ No offset tuning can fix that; it is right at one scale factor only.
1394
+ So each strip overshoots into paint of ITS OWN colour, and every edge
1395
+ lands either on same-colour paint or inside the other strip's opaque
1396
+ interior:
1397
+ ::before surface colour, the whole band + 2px above + 2px below
1398
+ ::after the control's FILL, the border + 3px below, painted on top
1399
+ The strips must OVERLAP, not abut. Abutting them at the ring/border
1400
+ junction leaked a constant quarter of the ring colour: two anti-aliased
1401
+ edges on one device row cover a*(1-a) of what is underneath, 0.25 at
1402
+ a = 0.5. Measured as exactly 61/244 before the overlap, 0 after.
1403
+ The overshoot is 2px, not 1: the strip snaps to the grid in the label's
1404
+ own (fractionally offset) space and the ring in the container's, so
1405
+ the two can disagree by up to a full device pixel, and a 1px overshoot
1406
+ still left a 34/255 residue on the ring's outermost row at 105% zoom.
1407
+ Every term derives from --_rel-tf-notch-h / --_rel-tf-border-w, so a
1408
+ ring change carries through. The ::after fill is the container's own
1409
+ background, not the surface seam, so the overshoot into the interior
1410
+ is invisible. Gate: qa/paint-geometry.mjs SLASH pass, zoom x DSF. */
1283
1411
  :host([floatinglabel]) .textfield-wrapper.focused .textfield-label::before,
1284
1412
  :host([floatinglabel]) .textfield-wrapper.has-value .textfield-label::before {
1285
1413
  content: "";
1286
1414
  position: absolute;
1287
1415
  left: 0;
1288
1416
  right: 0;
1289
- top: 50%;
1290
- height: var(--_rel-tf-notch-h, 1px);
1291
- transform: translateY(-50%);
1417
+ top: calc(50% - var(--_rel-tf-notch-h, 1px) - 2px);
1418
+ height: calc(var(--_rel-tf-notch-h, 1px) + 4px);
1292
1419
  background: var(--rel-textfield-surface, var(--rel-bg-neutral));
1293
1420
  z-index: -1;
1294
1421
  }
1295
1422
 
1423
+ :host([floatinglabel]) .textfield-wrapper.focused .textfield-label::after,
1424
+ :host([floatinglabel]) .textfield-wrapper.has-value .textfield-label::after {
1425
+ content: "";
1426
+ position: absolute;
1427
+ left: 0;
1428
+ right: 0;
1429
+ top: calc(50% - var(--_rel-tf-border-w, 1px));
1430
+ height: calc(var(--_rel-tf-border-w, 1px) + 3px);
1431
+ background: var(--rel-bg-neutral);
1432
+ z-index: -1;
1433
+ }
1434
+
1296
1435
  /* Figma keeps the docked label on --rel-text-label and ramps it per size:
1297
1436
  body/md/regular (L), body/sm/regular (M), body/xs/regular (S). */
1298
1437
  :host([floatinglabel]) .textfield-wrapper.focused .textfield-label,
@@ -1366,6 +1505,9 @@ RelTextfield.styles = css3`
1366
1505
  :host([floatinglabel]) .textfield-wrapper.disabled .textfield-label::before {
1367
1506
  background: var(--rel-textfield-surface, var(--rel-bg-neutral-elevated-2));
1368
1507
  }
1508
+ :host([floatinglabel]) .textfield-wrapper.disabled .textfield-label::after {
1509
+ background: var(--rel-bg-neutral-elevated-2);
1510
+ }
1369
1511
 
1370
1512
  /* Container */
1371
1513
  .textfield-container {
@@ -1778,11 +1920,17 @@ RelTextfield.styles = css3`
1778
1920
  color: var(--rel-text-placeholder);
1779
1921
  }
1780
1922
 
1781
- :host([floatinglabel]) .textfield-input::placeholder {
1923
+ /* Hiding the placeholder only makes sense where a floating label is
1924
+ standing in its place. The legend variant has no floating label — its
1925
+ label lives in the notch at every state, and the legend branch renders
1926
+ no .textfield-label at all — so applying this there left an empty field
1927
+ at rest: no placeholder AND no label. Excluding legend restores the
1928
+ placeholder for the one variant that has nothing to replace it. */
1929
+ :host([floatinglabel]:not([variant="legend"])) .textfield-input::placeholder {
1782
1930
  opacity: 0;
1783
1931
  }
1784
1932
 
1785
- :host([floatinglabel]) .textfield-wrapper.focused .textfield-input::placeholder {
1933
+ :host([floatinglabel]:not([variant="legend"])) .textfield-wrapper.focused .textfield-input::placeholder {
1786
1934
  opacity: 1;
1787
1935
  }
1788
1936
 
@@ -1850,56 +1998,134 @@ RelTextfield.styles = css3`
1850
1998
  /* ── Legend (notched-outline) variant ──
1851
1999
  Native <fieldset>/<legend>: the legend notches the top border and stays
1852
2000
  fixed in place across all states (no float/animation). */
1853
- :host([variant="legend"]) .field-fieldset {
1854
- position: relative;
2001
+ /* The container keeps a TRANSPARENT border rather than dropping it. The
2002
+ border is 1px of layout that the default variant also has, and the
2003
+ inline padding is measured from inside it — remove it and a legend
2004
+ field's text sits 1px left of its default-variant neighbour in the same
2005
+ form. The visible stroke is painted by .legend-outline on top, exactly
2006
+ over this transparent one, so the two never double up (including in
2007
+ forced-colors, where they coincide rather than stack). */
2008
+ :host([variant="legend"]) .textfield-container {
2009
+ border-color: transparent;
2010
+ background: transparent;
2011
+ /* The legend protrudes above the top edge; overflow: hidden from the
2012
+ base rule would slice its top half off. */
2013
+ overflow: visible;
2014
+ }
2015
+
2016
+ /* The decorative outline layer. inset: 0 makes its border box identical to
2017
+ the container's, so the painted outline IS --_rel-tf-h — 32 / 40 / 48,
2018
+ with no legend-half subtracted, which is the whole bug this replaced.
2019
+ pointer-events: none keeps clicks going to the control beneath, and the
2020
+ content is lifted above it rather than this being pushed to z-index: -1,
2021
+ because .textfield-container is position: relative with z-index: auto
2022
+ and therefore does NOT create a stacking context — a negative z-index
2023
+ here could slip behind an ancestor's background. */
2024
+ :host([variant="legend"]) .legend-outline {
2025
+ position: absolute;
2026
+ /* NOT inset: 0. An absolutely positioned box's insets resolve against
2027
+ its containing block's PADDING box, and the container keeps a 1px
2028
+ transparent border (see above), so inset: 0 put this layer's border
2029
+ box 1px inside the container's on every side — a painted outline 2px
2030
+ short of the ladder at every size, measured 30/38/46 against 32/40/48.
2031
+ Pulling out by the border width lands the two border boxes exactly on
2032
+ each other, which is what makes the painted height the ladder value. */
2033
+ inset: calc(-1 * var(--_rel-tf-border-w, 1px));
2034
+ /* The legend has a small height (see .field-legend), and Blink then
2035
+ paints the fieldset's top border (legend-height - border) / 2 lower
2036
+ than the layer's top edge. Pull the top edge up by that outset so the
2037
+ painted line stays exactly on the container's top edge and the
2038
+ painted height stays on the 32 / 40 / 48 ladder. Only the top moves;
2039
+ the bottom stays put. */
2040
+ --_rel-tf-legend-h: 3px;
2041
+ --_rel-tf-legend-outset: calc(
2042
+ (var(--_rel-tf-legend-h) - var(--_rel-tf-border-w, 1px)) / 2
2043
+ );
2044
+ top: calc(-1 * var(--_rel-tf-border-w, 1px) - var(--_rel-tf-legend-outset));
1855
2045
  margin: 0;
1856
2046
  min-inline-size: 0;
1857
- padding: 0;
2047
+ /* Aligns the legend TEXT with the input text. The legend carries its own
2048
+ --rel-spacing-2xs of side padding to cut the notch a little wider than
2049
+ the glyphs, so the layer insets by that much less than the control's
2050
+ own inline padding. Derived from --_rel-tf-pad-x rather than restated,
2051
+ which is what makes the small size line up: the old rule hard-coded
2052
+ --rel-spacing-sm and could not see the size ramp at all, because
2053
+ --_rel-tf-pad-x is declared on .textfield-container and the legend
2054
+ variant never rendered one. */
2055
+ padding-inline: calc(
2056
+ var(--_rel-tf-pad-x, var(--rel-spacing-sm)) - var(--rel-spacing-2xs)
2057
+ );
2058
+ padding-block: 0;
1858
2059
  border: 1px solid var(--rel-border-default);
1859
2060
  border-radius: var(--rel-border-radius-xs);
1860
2061
  background: var(--rel-bg-neutral);
2062
+ pointer-events: none;
1861
2063
  transition: border-color 0.2s;
1862
2064
  }
1863
2065
 
2066
+ /* Lift the real content above the decorative layer. */
2067
+ :host([variant="legend"]) .textfield-field,
2068
+ :host([variant="legend"]) .textfield-affix {
2069
+ position: relative;
2070
+ z-index: 1;
2071
+ }
2072
+
2073
+ /* The legend is taken OUT OF FLOW — height: 0 with visible overflow.
2074
+ Blink draws a fieldset's top border through the legend's VERTICAL
2075
+ MIDDLE, so an in-flow legend pushes the painted line down by half its
2076
+ own line-height: measured 23 / 30 / 37 against the 32 / 40 / 48 ladder,
2077
+ which is exactly half of the 18 / 20 / 22 legend line-heights. Collapsing
2078
+ the legend's height puts its middle on the layer's top edge, so the
2079
+ border is drawn there and the painted outline is the full ladder value.
2080
+ The notch still cuts, because the gap spans the legend's WIDTH and that
2081
+ is unaffected. The text is then lifted back onto the line by
2082
+ .legend-text, which needs its own element: translateY(-50%) on a
2083
+ zero-height box resolves to zero, so the transform has to live on a
2084
+ child that still has height. */
2085
+ /* NOT height: 0 any more, and the reason is Blink's cutout rule. The gap
2086
+ it cuts in the top border for the legend is max(legend height, border
2087
+ width) tall, measured from the layer's top edge. A zero-height legend
2088
+ therefore gets a cutout EXACTLY the border's height — and at any
2089
+ fractional scale factor (Windows 125%: DSF 1.25) the pixel-snapped
2090
+ border lands partly outside that rect, so it painted straight through
2091
+ the legend text at half strength (measured 122/244 on every legend cell
2092
+ at DSF 1.25, clean at DSF 1). A 3px legend makes the cutout 3px tall
2093
+ with a pixel of margin either side of the border. The price is Blink's
2094
+ other rule — the border is drawn through the legend's vertical middle,
2095
+ (3 - 1) / 2 = 1px down — which .legend-outline cancels by starting 1px
2096
+ higher, so the painted outline is still the ladder value. The text is
2097
+ lifted onto that line by .legend-text below. */
1864
2098
  :host([variant="legend"]) .field-legend {
1865
- margin-left: var(--rel-spacing-sm);
2099
+ display: block;
2100
+ height: var(--_rel-tf-legend-h, 3px);
2101
+ overflow: visible;
2102
+ margin: 0;
1866
2103
  padding: 0 var(--rel-spacing-2xs);
1867
2104
  color: var(--rel-text-label);
1868
- font-family: var(--rel-body-sm-medium-font-family);
1869
- font-weight: var(--rel-body-sm-medium-font-weight);
1870
- font-size: var(--rel-body-sm-medium-font-size);
1871
- line-height: var(--rel-body-sm-medium-line-height);
1872
- letter-spacing: var(--rel-body-sm-medium-letter-spacing);
2105
+ /* REGULAR, not medium. Figma's floated label is 400 at every size — the
2106
+ same finding already recorded at rel-select.ts. This rule used to be
2107
+ the only one in the ramp at 500, so the DEFAULT size rendered heavier
2108
+ than both small and large, which set their own regular weights below. */
2109
+ font-family: var(--rel-body-sm-regular-font-family);
2110
+ font-weight: var(--rel-body-sm-regular-font-weight);
2111
+ font-size: var(--rel-body-sm-regular-font-size);
2112
+ line-height: var(--rel-body-sm-regular-line-height);
2113
+ letter-spacing: var(--rel-body-sm-regular-letter-spacing);
1873
2114
  }
1874
2115
 
1875
- :host([variant="legend"]) .textfield-input {
1876
- width: 100%;
1877
- box-sizing: border-box;
2116
+ /* Carries the legend's text back onto the border line the zero-height
2117
+ legend now sits on. inline-block so it has a height of its own for the
2118
+ percentage to resolve against. */
2119
+ :host([variant="legend"]) .legend-text {
2120
+ display: inline-block;
2121
+ /* Centre the text on the painted line, which sits at the legend's
2122
+ vertical middle (half the legend's height below its top). */
2123
+ transform: translateY(calc(-50% + var(--_rel-tf-legend-h, 3px) / 2));
1878
2124
  }
1879
2125
 
1880
- /* Legend on the same 32/40/48 scale. The fieldset's border box excludes
1881
- the legend's protruding top half, so sizing the fieldset itself keeps
1882
- the visible outline at exactly --_rel-tf-h; the row fills the remaining
1883
- content area and centres the input. Multiline keeps auto height. */
1884
- :host([variant="legend"]) .field-fieldset:not(.multiline) {
2126
+ :host([variant="legend"]) .textfield-input {
2127
+ width: 100%;
1885
2128
  box-sizing: border-box;
1886
- height: var(--_rel-tf-h, 40px);
1887
- /* Flex column so the row fills the content box below the legend (a
1888
- percentage height on the row doesn't resolve inside a fieldset). The
1889
- <legend> is never a flex item, so the notch is unaffected. */
1890
- display: flex;
1891
- flex-direction: column;
1892
- }
1893
- :host([variant="legend"]) .field-fieldset:not(.multiline) .field-row {
1894
- flex: 1;
1895
- min-height: 0;
1896
- }
1897
- :host([variant="legend"]) .field-fieldset:not(.multiline) .textfield-input {
1898
- padding-top: 0;
1899
- padding-bottom: 0;
1900
- /* Fill the row so the whole outline is clickable (see the default
1901
- variant's note above). */
1902
- align-self: stretch;
1903
2129
  }
1904
2130
 
1905
2131
  /* labelStyle="none" + legend: nothing to seat in the notch, so drop the
@@ -1907,58 +2133,73 @@ RelTextfield.styles = css3`
1907
2133
  :host([label-style="none"][variant="legend"]) .field-legend {
1908
2134
  display: none;
1909
2135
  }
1910
-
1911
- /* Legend: prefix | field | suffix sit in a flex row under the legend. The
1912
- fieldset's content box starts below the legend's midline, so a fill
1913
- prefix can't reach the top border here — square it off instead. */
1914
- :host([variant="legend"]) .field-row {
1915
- display: flex;
1916
- align-items: center;
1917
- }
1918
- :host([variant="legend"]) .textfield-affix.prefix.fill {
1919
- border-radius: 0;
2136
+ /* No legend rendered means no outset either, so the layer goes back to
2137
+ sitting exactly on the container's border box. */
2138
+ :host([label-style="none"][variant="legend"]) .legend-outline {
2139
+ top: calc(-1 * var(--_rel-tf-border-w, 1px));
1920
2140
  }
1921
2141
 
1922
- /* Hover */
1923
- :host([variant="legend"]) .field-fieldset:hover {
1924
- border-color: var(--rel-border-accent);
2142
+ /* A fill prefix used to be squared off because the fieldset's content box
2143
+ started below the legend's midline, so it could not reach the top
2144
+ border. The content now rides the ordinary container row, whose top IS
2145
+ the border, so the fill prefix can keep the control's own radius. */
2146
+
2147
+ /* Every state below paints on the OUTLINE LAYER, not the container: the
2148
+ container's own border is transparent by design. The container must also
2149
+ be told to drop the shared focus ring — see the note on focus. */
2150
+ :host([variant="legend"]) .textfield-wrapper:not(.focused):not(.error):not(.disabled)
2151
+ .textfield-container:hover .legend-outline {
1925
2152
  border-color: var(--rel-text-placeholder);
1926
2153
  }
1927
2154
 
1928
- /* Focus — border-color only. A box-shadow/outline ring hugs the full
1929
- rectangular border-box and would draw across the notch, appearing to
1930
- strike through the legend. The legend also recolors on focus (below). */
1931
- :host([variant="legend"]) .textfield-wrapper.focused .field-fieldset,
1932
- :host([variant="legend"]) .field-fieldset:focus-within {
1933
- border-color: var(--rel-border-strong);
2155
+ /* Focus — border-color only, no ring. A box-shadow or outline ring hugs
2156
+ the full rectangular border box and would draw straight across the
2157
+ notch, reading as a line struck through the legend. That is the same
2158
+ collision the floating label has on the other variants, except here
2159
+ there is no mask strip to hide it, so the ring is dropped outright and
2160
+ the legend recolours instead. */
2161
+ :host([variant="legend"]) .textfield-wrapper.focused .textfield-container,
2162
+ :host([variant="legend"]) .textfield-container:focus-within {
1934
2163
  box-shadow: none;
2164
+ border-color: transparent;
2165
+ }
2166
+ :host([variant="legend"]) .textfield-wrapper.focused .legend-outline,
2167
+ :host([variant="legend"]) .textfield-container:focus-within .legend-outline {
2168
+ border-color: var(--rel-border-strong);
1935
2169
  }
1936
2170
  :host([variant="legend"]) .textfield-wrapper.focused .field-legend,
1937
- :host([variant="legend"]) .field-fieldset:focus-within .field-legend {
2171
+ :host([variant="legend"]) .textfield-container:focus-within .field-legend {
1938
2172
  color: var(--rel-border-strong);
1939
2173
  }
1940
2174
 
1941
2175
  /* Error */
1942
- :host([variant="legend"]) .textfield-wrapper.error .field-fieldset {
1943
- border-color: var(--rel-border-danger);
2176
+ :host([variant="legend"]) .textfield-wrapper.error .textfield-container {
1944
2177
  box-shadow: none;
2178
+ border-color: transparent;
2179
+ }
2180
+ :host([variant="legend"]) .textfield-wrapper.error .legend-outline {
2181
+ border-color: var(--rel-border-danger);
1945
2182
  }
1946
2183
  :host([variant="legend"]) .textfield-wrapper.error .field-legend {
1947
2184
  color: var(--rel-text-danger);
1948
2185
  }
1949
2186
 
1950
- /* Disabled */
1951
- :host([variant="legend"]) .textfield-wrapper.disabled .field-fieldset {
2187
+ /* Disabled — the fill moves to the layer with the stroke, since the
2188
+ container is transparent and would otherwise paint the dulled surface
2189
+ underneath the layer's own background. */
2190
+ :host([variant="legend"]) .textfield-wrapper.disabled .textfield-container {
2191
+ background: transparent;
2192
+ }
2193
+ :host([variant="legend"]) .textfield-wrapper.disabled .legend-outline {
1952
2194
  background: var(--rel-bg-neutral-elevated-2);
1953
2195
  border-color: var(--rel-border-accent);
1954
- cursor: not-allowed;
1955
2196
  }
1956
2197
  :host([variant="legend"]) .textfield-wrapper.disabled .field-legend {
1957
2198
  color: var(--rel-text-disabled);
1958
2199
  }
1959
2200
 
1960
2201
  /* Readonly */
1961
- :host([variant="legend"]) .textfield-wrapper.readonly .field-fieldset {
2202
+ :host([variant="legend"]) .textfield-wrapper.readonly .textfield-container {
1962
2203
  cursor: default;
1963
2204
  }
1964
2205
 
@@ -2177,20 +2418,28 @@ var RelCheckbox = class extends LitElement4 {
2177
2418
  .checked=${this.status === "checked"}
2178
2419
  .indeterminate=${this.status === "indeterminate"}
2179
2420
  aria-checked="${this.status === "indeterminate" ? "mixed" : nothing3}"
2421
+ aria-label="${this.fieldAriaLabel ?? nothing3}"
2180
2422
  @click=${this.toggle}
2181
2423
  @blur=${() => this._onTouched()}
2182
2424
  ?disabled=${this.state === "disabled"}
2183
2425
  />
2184
2426
 
2427
+ <!-- Both marks are Figma's own paths, read from the live Published DS
2428
+ file (set 8418:1987) on 2026-09-25 and left in their authored
2429
+ 16-unit box. Figma knocks these out of the filled square; we paint
2430
+ them in the inverse tone over it instead, which is the same
2431
+ picture and keeps the existing per-state fill rules working.
2432
+ Do not re-scale the coordinates — the svg is sized to the drawn
2433
+ square so one unit here is one Figma unit. -->
2185
2434
  <span class="box">
2186
- <svg class="check-icon" viewBox="0 0 18 14" fill="var(--rel-text-inverse)">
2435
+ <svg class="check-icon" viewBox="0 0 16 16" fill="var(--rel-text-inverse)">
2187
2436
  <path
2188
- d="M6.44 14L0.5 7.36l1.49-1.66L6.44 10.68 16.01 0 17.5 1.66z"
2437
+ d="M6.22222 12.4444L1.77778 8L3.03111 6.74667L6.22222 9.92889L12.9689 3.18222L14.2222 4.44444L6.22222 12.4444Z"
2189
2438
  />
2190
2439
  </svg>
2191
2440
 
2192
- <svg class="indeterminate-icon" viewBox="0 0 16 2" fill="var(--rel-text-inverse)">
2193
- <rect width="16" height="2" />
2441
+ <svg class="indeterminate-icon" viewBox="0 0 16 16" fill="var(--rel-text-inverse)">
2442
+ <rect x="3.55556" y="7.11111" width="8.88889" height="1.77778" />
2194
2443
  </svg>
2195
2444
  </span>
2196
2445
 
@@ -2291,11 +2540,25 @@ RelCheckbox.styles = css4`
2291
2540
  The inset is expressed as a ratio rather than four fractional pixel
2292
2541
  values, so it stays exact at any device pixel ratio and follows the
2293
2542
  frame if the ramp ever changes.
2294
- NOTE: the 2px border weight is UNVERIFIED against Figma. The control is
2295
- flattened artwork (a filled VECTOR, not a frame with a stroke — see
2296
- figma-bug E), so the spec gives the square's outer size but says nothing
2297
- about its stroke. Carried forward from the previous implementation pending the unflattening
2298
- request; one line to change if the designer publishes it. */
2543
+
2544
+ The control was UNFLATTENED in the 2026-09-23 Published DS revision, so
2545
+ the stroke and the marks are no longer guesses. Verified against the live
2546
+ file (set 8418:1987, node paths read 2026-09-25): the artwork box is 16
2547
+ units wide and EVERY value in it is 16/9 = 1.7778 units —
2548
+
2549
+ corner radius 1.7778 (outer rect: C0.8 0 0 0.8 0 1.77778)
2550
+ border thickness 1.7778 (outer 0…16 hollowed to 1.7778…14.2222)
2551
+ indeterminate dash height 1.7778 (y 7.11111 → 8.88889)
2552
+ indeterminate dash width 8.8889 (x 3.55556 → 12.4444, centred on 8)
2553
+
2554
+ Because the artwork box IS the drawn square (both are 0.8 × the frame),
2555
+ one sixteenth of the square is one unit, so a ninth of the square is the
2556
+ single number that governs radius, border and dash alike. That is why
2557
+ they are derived below rather than tabulated per size: four hand-written
2558
+ radii are what let large and extra-large silently share medium's 4px.
2559
+ Figma leaves the INNER corner square, which border-radius reproduces
2560
+ for free — an outer radius equal to the border width leaves no inner
2561
+ curve. */
2299
2562
  .box {
2300
2563
  width: var(--_rel-cb-size);
2301
2564
  height: var(--_rel-cb-size);
@@ -2306,6 +2569,12 @@ RelCheckbox.styles = css4`
2306
2569
  flex-shrink: 0;
2307
2570
  }
2308
2571
 
2572
+ /* ONE box-shadow declaration, and every state contributes by setting
2573
+ --_rel-cb-border-color instead of restating the shadow. Restating it per
2574
+ state is the trap rel-stepper's connector hit and styles/focus-ring.ts
2575
+ documents at length: box-shadow is a single property, so two rules that
2576
+ both declare it do not compose — specificity picks a winner and the loser
2577
+ vanishes silently. */
2309
2578
  .box::before {
2310
2579
  content: "";
2311
2580
  position: absolute;
@@ -2313,11 +2582,30 @@ RelCheckbox.styles = css4`
2313
2582
  inset: 10%;
2314
2583
  box-sizing: border-box;
2315
2584
  border-radius: var(--_rel-cb-radius);
2316
- border: 2px solid var(--rel-text-default);
2585
+ /* Resting wall colour; the state rules below override just this. */
2586
+ --_rel-cb-border-color: var(--rel-text-default);
2587
+ box-shadow: inset 0 0 0 var(--_rel-cb-border) var(--_rel-cb-border-color);
2317
2588
  background: transparent;
2318
2589
  transition: all 0.2s;
2319
2590
  }
2320
2591
 
2592
+ /* box-shadow is NOT painted in forced-colors mode (Windows High Contrast) —
2593
+ the same UA behaviour that styles/focus-ring.ts keeps its outer ring on an
2594
+ outline for. For a focus ring that costs a refinement; here it would erase
2595
+ the ENTIRE affordance, because the wall is all there is to see on an
2596
+ unchecked box. So forced-colors gets a real border back.
2597
+
2598
+ Safe to add rather than swap: the 10% inset pins all four edges, so the
2599
+ drawn square's outer edge is 80% of the frame whatever the wall is made
2600
+ of, and the shadow it sits beside is not painted here anyway. Bound to
2601
+ CanvasText because the forced palette, not the token, is what the user
2602
+ asked for. */
2603
+ @media (forced-colors: active) {
2604
+ .box::before {
2605
+ border: 1px solid CanvasText;
2606
+ }
2607
+ }
2608
+
2321
2609
  /* Figma puts the focus ring on the WHOLE component at 1.5px OUTSIDE, not
2322
2610
  around the control alone. Matches rel-radio, which already does this. */
2323
2611
  .wrapper:has(input:focus-visible) {
@@ -2329,9 +2617,16 @@ RelCheckbox.styles = css4`
2329
2617
  }
2330
2618
 
2331
2619
  svg {
2332
- /* Proportional to the frame, so the mark tracks the drawn square. */
2333
- width: 55%;
2334
- height: 55%;
2620
+ /* Both marks are authored in Figma's 16-unit artwork box, and that box is
2621
+ the drawn square — 80% of the frame, the same as ::before's inset. So
2622
+ the svg covers the square exactly and every coordinate inside it is a
2623
+ Figma unit; position and size fall out of the path instead of being
2624
+ re-derived here. This was 55%/55% against an 8:1 viewBox, which (with
2625
+ preserveAspectRatio defaulting to meet) rendered the dash at 55%/8 =
2626
+ 6.875% of the frame — 1.6× thinner than Figma's 11.11%, and fractional
2627
+ at every size, which is what made it look blurred. */
2628
+ width: 80%;
2629
+ height: 80%;
2335
2630
  position: relative;
2336
2631
  display: none;
2337
2632
  }
@@ -2347,7 +2642,7 @@ RelCheckbox.styles = css4`
2347
2642
  tokens for this same glyph. */
2348
2643
  .checked .box::before {
2349
2644
  background: var(--rel-text-default);
2350
- border-color: var(--rel-text-default);
2645
+ --_rel-cb-border-color: var(--rel-text-default);
2351
2646
  }
2352
2647
 
2353
2648
  .checked .check-icon {
@@ -2369,13 +2664,13 @@ RelCheckbox.styles = css4`
2369
2664
  glyph with --rel-text-danger, a text token, never a border-* one; the two
2370
2665
  are the same value in light mode but diverge in dark (red-300 vs -400). */
2371
2666
  .error .box::before {
2372
- border-color: var(--rel-text-danger);
2667
+ --_rel-cb-border-color: var(--rel-text-danger);
2373
2668
  }
2374
2669
 
2375
2670
  /* Error checked — error fill, transparent checkmark */
2376
2671
  .error.checked .box::before {
2377
2672
  background: var(--rel-action-danger-fill);
2378
- border-color: var(--rel-action-danger-fill);
2673
+ --_rel-cb-border-color: var(--rel-action-danger-fill);
2379
2674
  }
2380
2675
 
2381
2676
  /* Figma State=Error tints the SUPPORTING text danger; the label stays
@@ -2398,7 +2693,7 @@ RelCheckbox.styles = css4`
2398
2693
  (gray-500 vs gray-700) and identical to --rel-text-caption, so a hovered
2399
2694
  label was indistinguishable from supporting text. */
2400
2695
  .default .row:hover .box::before {
2401
- border-color: var(--rel-text-label);
2696
+ --_rel-cb-border-color: var(--rel-text-label);
2402
2697
  }
2403
2698
 
2404
2699
  .default .row:hover .label {
@@ -2407,7 +2702,7 @@ RelCheckbox.styles = css4`
2407
2702
 
2408
2703
  .default.checked .row:hover .box::before {
2409
2704
  background: var(--rel-text-label);
2410
- border-color: var(--rel-text-label);
2705
+ --_rel-cb-border-color: var(--rel-text-label);
2411
2706
  }
2412
2707
 
2413
2708
  .default.indeterminate .row:hover .indeterminate-icon {
@@ -2463,19 +2758,80 @@ RelCheckbox.styles = css4`
2463
2758
  role and compact indent token. The drawn square is 80% of the frame
2464
2759
  (see .box::before above), so these numbers are footprints, not squares:
2465
2760
  small 16 body-sm / body-xs indent xl (24)
2466
- medium 18 body-md / body-xs indent xl (24)
2467
- large 20 body-lg / body-sm indent 2xl (28)
2468
- extra-large 24 body-xl / body-md indent 3xl (32)
2469
- Medium is the base declared here; the other three override. */
2761
+ medium 18 body-md / body-sm indent xl (24)
2762
+ large 20 body-lg / body-md indent 2xl (28)
2763
+ extra-large 24 body-xl / body-lg indent 3xl (32)
2764
+ Verified against the live Published DS file 2026-09-25: .Check Control is
2765
+ 16/18/20/24 across all 15 variants per size. This ramp is correct — do
2766
+ not "fix" it because the three lower steps look alike; Figma's own steps
2767
+ are 2px apart and the difference it leans on is the type role.
2768
+ Supporting text (2026-09-26 Trello ticket, "visual adjustment"): ONE step
2769
+ below the label on the body scale at every size — 12/10, 14/12, 16/14,
2770
+ 20/16. It used to be two steps below at medium and above. The live file
2771
+ (lastModified 2026-09-23) carries the new pair at Small, Medium and
2772
+ Extra-Large; its Large variants still say body-sm (12) for supporting,
2773
+ which would give Medium and Large the same supporting size and breaks
2774
+ the ticket's own rule, so Large follows the ticket (body-md) and the
2775
+ Figma omission is flagged to the designer rather than copied.
2776
+
2777
+ Only the FRAME and the type roles vary per size. Radius and border are
2778
+ derived from the frame (a ninth of the drawn square, see .box::before),
2779
+ so they cannot fall out of step the way four literals did: large and
2780
+ extra-large previously declared no radius at all and inherited medium's
2781
+ 4px, leaving a 24px box and an 18px box with the same corner. */
2782
+ /* DO NOT ROUND THESE. The expression lands on fractional pixels at two of
2783
+ the four sizes and that is deliberate:
2784
+
2785
+ small 16px frame → 12.8px square → 1.4222px (2.84 device px @2x)
2786
+ medium 18px frame → 14.4px square → 1.6px (3.2)
2787
+ large 20px frame → 16px square → 1.7778px (3.56)
2788
+ extra-large 24px frame → 19.2px square → 2.1333px (4.27)
2789
+
2790
+ The fractions are downstream of the FRAME ramp, which is Figma's and
2791
+ already shipped, so rounding the derived value would break the fixed
2792
+ relationship between the radius and the square it sits on — and that
2793
+ relationship is the only thing making the four sizes read as one control
2794
+ rather than four drawings.
2795
+
2796
+ The blur this replaced was not caused by fractionality. It was a 1.6x
2797
+ ERROR: the dash rendered 1.10px where Figma wanted 1.78px, because an 8:1
2798
+ viewBox was being letterboxed into a square box. Fixing the ratio fixes
2799
+ the symptom; rounding on top would be a second, unrelated invention —
2800
+ and inventing a tidier-looking number than the derived one is exactly
2801
+ what produced the rel-radio Small regression that 1.4.9 reverts.
2802
+
2803
+ ── WHY THE WALL IS AN INSET SHADOW AND NOT A BORDER ──
2804
+ Because border-width is the one length here that the engine refuses to
2805
+ keep. Blink snaps it to whole CSS pixels at used-value time: declared as
2806
+ a border, the derived value computed to 1 / 1 / 1 / 2px for the four
2807
+ sizes, and read the same at device pixel ratios 1, 2 AND 3, while the
2808
+ radius on the very same element read 1.42222px throughout. One property
2809
+ keeping its fraction is the control group for the other losing it, so
2810
+ that is snapping and not a getComputedStyle artefact (measured
2811
+ 2026-09-25).
2812
+
2813
+ Snapped, the faithful value painted WORSE than the flat 2px guess it
2814
+ replaced — sum of absolute painted error 1.933px against 1.333px:
2815
+
2816
+ size Figma as border error flat 2px error
2817
+ s 1.4222 1px 0.422 2px 0.578
2818
+ m 1.6 1px 0.600 2px 0.400
2819
+ l 1.7778 1px 0.778 2px 0.222
2820
+ xl 2.1333 2px 0.133 2px 0.133
2821
+
2822
+ box-shadow spread is NOT snapped — it antialiases sub-pixel — so an inset
2823
+ shadow paints the derived value exactly at every size and the error goes
2824
+ to ~0, with no CSS round() and so no browser-floor question. */
2470
2825
  :host {
2471
2826
  --_rel-cb-size: 18px;
2472
- --_rel-cb-radius: 4px;
2827
+ /* drawn square = frame × 0.8; every Figma value in it = square ÷ 9 */
2828
+ --_rel-cb-radius: calc(var(--_rel-cb-size) * 0.8 / 9);
2829
+ --_rel-cb-border: calc(var(--_rel-cb-size) * 0.8 / 9);
2473
2830
  --_rel-cb-indent: var(--rel-spacing-xl);
2474
2831
  }
2475
2832
 
2476
2833
  :host([size="small"]) {
2477
2834
  --_rel-cb-size: 16px;
2478
- --_rel-cb-radius: 3px;
2479
2835
  --_rel-cb-indent: var(--rel-spacing-xl);
2480
2836
  }
2481
2837
 
@@ -2499,11 +2855,11 @@ RelCheckbox.styles = css4`
2499
2855
  }
2500
2856
 
2501
2857
  .text {
2502
- font-family: var(--rel-body-xs-regular-font-family);
2503
- font-weight: var(--rel-body-xs-regular-font-weight);
2504
- font-size: var(--rel-body-xs-regular-font-size);
2505
- line-height: var(--rel-body-xs-regular-line-height);
2506
- letter-spacing: var(--rel-body-xs-regular-letter-spacing);
2858
+ font-family: var(--rel-body-sm-regular-font-family);
2859
+ font-weight: var(--rel-body-sm-regular-font-weight);
2860
+ font-size: var(--rel-body-sm-regular-font-size);
2861
+ line-height: var(--rel-body-sm-regular-line-height);
2862
+ letter-spacing: var(--rel-body-sm-regular-letter-spacing);
2507
2863
  }
2508
2864
 
2509
2865
  :host([size="small"]) .label {
@@ -2514,6 +2870,14 @@ RelCheckbox.styles = css4`
2514
2870
  letter-spacing: var(--rel-body-sm-regular-letter-spacing);
2515
2871
  }
2516
2872
 
2873
+ :host([size="small"]) .text {
2874
+ font-family: var(--rel-body-xs-regular-font-family);
2875
+ font-weight: var(--rel-body-xs-regular-font-weight);
2876
+ font-size: var(--rel-body-xs-regular-font-size);
2877
+ line-height: var(--rel-body-xs-regular-line-height);
2878
+ letter-spacing: var(--rel-body-xs-regular-letter-spacing);
2879
+ }
2880
+
2517
2881
  :host([size="large"]) .label {
2518
2882
  font-family: var(--rel-body-lg-regular-font-family);
2519
2883
  font-weight: var(--rel-body-lg-regular-font-weight);
@@ -2523,11 +2887,11 @@ RelCheckbox.styles = css4`
2523
2887
  }
2524
2888
 
2525
2889
  :host([size="large"]) .text {
2526
- font-family: var(--rel-body-sm-regular-font-family);
2527
- font-weight: var(--rel-body-sm-regular-font-weight);
2528
- font-size: var(--rel-body-sm-regular-font-size);
2529
- line-height: var(--rel-body-sm-regular-line-height);
2530
- letter-spacing: var(--rel-body-sm-regular-letter-spacing);
2890
+ font-family: var(--rel-body-md-regular-font-family);
2891
+ font-weight: var(--rel-body-md-regular-font-weight);
2892
+ font-size: var(--rel-body-md-regular-font-size);
2893
+ line-height: var(--rel-body-md-regular-line-height);
2894
+ letter-spacing: var(--rel-body-md-regular-letter-spacing);
2531
2895
  }
2532
2896
 
2533
2897
  :host([size="extra-large"]) .label {
@@ -2539,11 +2903,11 @@ RelCheckbox.styles = css4`
2539
2903
  }
2540
2904
 
2541
2905
  :host([size="extra-large"]) .text {
2542
- font-family: var(--rel-body-md-regular-font-family);
2543
- font-weight: var(--rel-body-md-regular-font-weight);
2544
- font-size: var(--rel-body-md-regular-font-size);
2545
- line-height: var(--rel-body-md-regular-line-height);
2546
- letter-spacing: var(--rel-body-md-regular-letter-spacing);
2906
+ font-family: var(--rel-body-lg-regular-font-family);
2907
+ font-weight: var(--rel-body-lg-regular-font-weight);
2908
+ font-size: var(--rel-body-lg-regular-font-size);
2909
+ line-height: var(--rel-body-lg-regular-line-height);
2910
+ letter-spacing: var(--rel-body-lg-regular-letter-spacing);
2547
2911
  }
2548
2912
  `;
2549
2913
  __decorateClass([
@@ -2573,6 +2937,9 @@ __decorateClass([
2573
2937
  __decorateClass([
2574
2938
  property4({ type: String, reflect: true })
2575
2939
  ], RelCheckbox.prototype, "alignment", 2);
2940
+ __decorateClass([
2941
+ property4({ type: String, attribute: "field-aria-label" })
2942
+ ], RelCheckbox.prototype, "fieldAriaLabel", 2);
2576
2943
  RelCheckbox = __decorateClass([
2577
2944
  customElement4("rel-checkbox")
2578
2945
  ], RelCheckbox);
@@ -3658,6 +4025,14 @@ var RelAutocomplete = class extends LitElement7 {
3658
4025
  get optionCheckboxSize() {
3659
4026
  return this.size;
3660
4027
  }
4028
+ /** Native tooltip carrying the row's full text. Both lines truncate to one
4029
+ * line with an ellipsis, so a long supporting line is otherwise unrecoverable
4030
+ * by sight; hovering the row reveals it. Only rows that carry a second line
4031
+ * get one, so plain lists do not sprout tooltips. */
4032
+ rowTitle(item) {
4033
+ return item.supportingText ? `${item.label}
4034
+ ${item.supportingText}` : nothing4;
4035
+ }
3661
4036
  get selectAllStatus() {
3662
4037
  const filtered = this.filteredItems.filter((i) => !i.disabled);
3663
4038
  if (filtered.length === 0) return "unchecked";
@@ -3935,6 +4310,7 @@ var RelAutocomplete = class extends LitElement7 {
3935
4310
  role="option"
3936
4311
  aria-selected="${this.selectedValues.includes(item.value)}"
3937
4312
  aria-disabled="${item.disabled ? "true" : nothing4}"
4313
+ title="${this.rowTitle(item)}"
3938
4314
  @mousedown="${(e) => e.preventDefault()}"
3939
4315
  @click="${() => this.toggleMultiOption(item)}"
3940
4316
  @pointerenter="${() => {
@@ -3958,6 +4334,7 @@ var RelAutocomplete = class extends LitElement7 {
3958
4334
  role="option"
3959
4335
  aria-selected="${item.value === this.value}"
3960
4336
  aria-disabled="${item.disabled ? "true" : nothing4}"
4337
+ title="${this.rowTitle(item)}"
3961
4338
  @mousedown="${(e) => e.preventDefault()}"
3962
4339
  @click="${() => this.selectItem(item)}"
3963
4340
  @pointerenter="${() => {
@@ -4000,7 +4377,11 @@ RelAutocomplete.styles = css8`
4000
4377
  surface with a 4px radius and shadow/md, and no border. ── */
4001
4378
  .ac-panel {
4002
4379
  position: fixed;
4003
- z-index: 1001;
4380
+ /* Only consulted on the no-Popover-API fallback path: once the panel is
4381
+ promoted to the top layer, z-index is inert and stacking is by
4382
+ promotion order. Shared with the pickers so the whole overlay family
4383
+ is overridable from one place. */
4384
+ z-index: var(--rel-overlay-fallback-z, 1001);
4004
4385
  display: none;
4005
4386
  /* Reset native [popover] UA styles (the panel is promoted to the
4006
4387
  top layer so transformed ancestors — e.g. a centered rel-modal
@@ -4086,13 +4467,32 @@ RelAutocomplete.styles = css8`
4086
4467
  }
4087
4468
 
4088
4469
  /* Hover and the keyboard highlight share Figma's Hover wash; Active —
4089
- the committed selection — reuses it and bumps the label to medium. */
4090
- .ac-option:hover,
4470
+ the committed selection — reuses it and bumps the label to medium.
4471
+
4472
+ NOTE there is deliberately no :hover in this rule. The pointerenter
4473
+ handler already moves highlightedIndex onto the hovered row, so hover
4474
+ is expressed through .highlighted and a :hover selector here would be
4475
+ redundant — except in one case, where it was a real defect: arrowing
4476
+ the list moves .highlighted but CANNOT clear a CSS :hover, so the row
4477
+ the mouse happens to rest on stayed lit alongside the keyboard cursor
4478
+ and the committed selection. Three rows painted the same wash for
4479
+ three different meanings, and the user could not tell which one Enter
4480
+ would commit. Measured before the fix: rows 0/2/4 all rgb(243,243,243)
4481
+ with row 2 carrying :hover and .highlighted=false. */
4091
4482
  .ac-option.highlighted,
4092
4483
  .ac-option[aria-selected="true"] {
4093
4484
  background: var(--rel-autocomplete-option-hover, var(--rel-action-overlay-hover));
4094
4485
  }
4095
4486
 
4487
+ /* The Select-all row is the one row with no pointerenter handler (it is
4488
+ not in the options list, so it has no index to highlight), so it keeps
4489
+ a plain :hover. rel-select instead carries a selectAllHighlighted
4490
+ state for this; autocomplete has no such state and does not need one
4491
+ for a row that cannot be reached by the arrow keys. */
4492
+ .ac-select-all:hover {
4493
+ background: var(--rel-autocomplete-option-hover, var(--rel-action-overlay-hover));
4494
+ }
4495
+
4096
4496
  .ac-option:active {
4097
4497
  background: var(--rel-autocomplete-option-pressed, var(--rel-action-overlay-pressed));
4098
4498
  }
@@ -4190,6 +4590,28 @@ RelAutocomplete.styles = css8`
4190
4590
  letter-spacing: var(--rel-body-sm-regular-letter-spacing);
4191
4591
  }
4192
4592
 
4593
+ /* Multi-select rows delegate to a nested rel-checkbox, which wraps its
4594
+ text by default, so the SAME item rendered as one truncated line in
4595
+ single mode and as three wrapped lines — or a sideways-overflowing
4596
+ identifier — under multiple (measured 2026-09-26: row 58px vs 120px,
4597
+ panel scrollWidth 326 in a 320 panel). The checkbox's label and helper
4598
+ are exposed as parts, so the row can reach them from here and apply the
4599
+ same one-line ellipsis; the host itself needs min-width: 0 or the flex
4600
+ row lets it grow past the panel instead of shrinking its text. */
4601
+ .ac-option > rel-checkbox {
4602
+ flex: 1;
4603
+ min-width: 0;
4604
+ }
4605
+
4606
+ .ac-option > rel-checkbox::part(label),
4607
+ .ac-option > rel-checkbox::part(helper) {
4608
+ display: block;
4609
+ min-width: 0;
4610
+ overflow: hidden;
4611
+ text-overflow: ellipsis;
4612
+ white-space: nowrap;
4613
+ }
4614
+
4193
4615
  :host([size="large"]) .ac-option-supporting {
4194
4616
  font-family: var(--rel-body-md-regular-font-family);
4195
4617
  font-weight: var(--rel-body-md-regular-font-weight);
@@ -6554,9 +6976,23 @@ RelCarousel.styles = css14`
6554
6976
  height: 100%;
6555
6977
  }
6556
6978
 
6979
+ /* The height override stretches a content-height slide to fill the fixed
6980
+ viewport, so a caption that used to hug its text was left anchored to
6981
+ the top of a much taller box — measured 84px of content top-aligned in
6982
+ 180px, i.e. ~96px of dead space beneath it. Figma centres it: every
6983
+ vertical variant has the Content slot on justify=CENTER align=CENTER.
6984
+ Centring needs a formatting context the slide does not otherwise have,
6985
+ hence the flex display. It is behind a hook because forcing flex onto a
6986
+ consumer's slotted element can reflow that element's own children —
6987
+ set --rel-carousel-slide-display: block to opt out. Scoped to vertical
6988
+ only: horizontal slides are content-height, so there is nothing to
6989
+ centre and no reason to touch their box model. */
6557
6990
  :host([orientation="vertical"]) ::slotted(*) {
6558
6991
  width: 100% !important;
6559
6992
  height: var(--rel-carousel-slide-height, 100%) !important;
6993
+ display: var(--rel-carousel-slide-display, flex);
6994
+ flex-direction: column;
6995
+ justify-content: center;
6560
6996
  }
6561
6997
 
6562
6998
  .track.dragging {
@@ -6676,6 +7112,50 @@ RelCarousel.styles = css14`
6676
7112
  transform: none;
6677
7113
  }
6678
7114
 
7115
+ /* Vertical orientation: the arrows ride the axis the carousel actually
7116
+ moves on. Without this they kept the horizontal placement — measured
7117
+ prev at x=32 y=98 and next at x=264 y=98, i.e. mid-left and mid-right,
7118
+ identical to the horizontal case — so they flanked the caption from
7119
+ both sides while the motion was up and down. Figma nests both arrow
7120
+ buttons in a layout=col Stack for the vertical variants. */
7121
+ :host([orientation="vertical"]) .nav-button {
7122
+ top: auto;
7123
+ left: 50%;
7124
+ right: auto;
7125
+ transform: translateX(-50%);
7126
+ }
7127
+
7128
+ :host([orientation="vertical"]) .nav-button.prev {
7129
+ top: var(--rel-spacing-xs);
7130
+ left: 50%;
7131
+ }
7132
+
7133
+ :host([orientation="vertical"]) .nav-button.next {
7134
+ bottom: var(--rel-spacing-xs);
7135
+ right: auto;
7136
+ }
7137
+
7138
+ /* chevron-left becomes up, chevron-right becomes down. Applied to the
7139
+ bottom-arrows row too: those buttons still page the carousel along the
7140
+ vertical axis, so a left/right glyph would misdescribe them wherever
7141
+ they sit. */
7142
+ :host([orientation="vertical"]) .nav-button svg {
7143
+ transform: rotate(90deg);
7144
+ }
7145
+
7146
+ /* Type=Bottom Arrows keeps its arrows inline in the indicators row even
7147
+ when the carousel is vertical. This rule has to restate the reset
7148
+ because the vertical block above outranks the plain .dots .nav-button
7149
+ selector (0,3,0 against 0,2,0). */
7150
+ :host([orientation="vertical"]) .dots .nav-button {
7151
+ position: static;
7152
+ transform: none;
7153
+ top: auto;
7154
+ bottom: auto;
7155
+ left: auto;
7156
+ right: auto;
7157
+ }
7158
+
6679
7159
  .nav-button svg {
6680
7160
  width: 16px;
6681
7161
  height: 16px;
@@ -6910,6 +7390,7 @@ RelCardMedia = __decorateClass([
6910
7390
  import { LitElement as LitElement15, html as html15, css as css16, nothing as nothing8 } from "lit";
6911
7391
  import { customElement as customElement15, property as property15, state as state6, query as query3 } from "lit/decorators.js";
6912
7392
  import { live } from "lit/directives/live.js";
7393
+ import "@reliability-design/icons/rel-icon";
6913
7394
 
6914
7395
  // src/components/rel-date-picker/date-format.ts
6915
7396
  function formatDatePattern(date, pattern, locale = "en-US") {
@@ -7270,6 +7751,34 @@ var RelDatePicker = class extends LitElement15 {
7270
7751
  this.onReposition = () => {
7271
7752
  if (this.open) this.updatePopoverPosition();
7272
7753
  };
7754
+ /* Outside scroll CLOSES the panel; it used to reposition.
7755
+ *
7756
+ * Repositioning is what produced the reported "panel floats over the site
7757
+ * header" bug: the panel is in the top layer, so it paints above all normal
7758
+ * content by definition, and tracking a trigger that scrolls under a sticky
7759
+ * nav leaves a calendar anchored to something nobody can see. No z-index can
7760
+ * fix that — the top layer is not in the page's stacking order at all.
7761
+ *
7762
+ * rel-select reached the same conclusion for its own listbox and its comment
7763
+ * records the other half of the reason: re-deriving fixed coordinates on
7764
+ * every scroll event is fragile and was making the panel jump to the top of
7765
+ * the viewport. This is that pattern, so the family now behaves alike.
7766
+ *
7767
+ * Scrolling the panel's OWN content must not close it. The test is
7768
+ * CONTAINMENT, not equality with one known scroller: the panel is itself
7769
+ * `overflow-y: auto` today, but the same guard has to hold for any scroller
7770
+ * nested inside it (rel-time-picker's per-column lists are exactly that
7771
+ * case), and an equality test would silently close the panel the first time
7772
+ * one is added.
7773
+ *
7774
+ * resize still repositions — a resize keeps the trigger on screen, so there
7775
+ * is nothing to run away from. */
7776
+ this.onPageScroll = (e) => {
7777
+ if (!this.open) return;
7778
+ const panel = this.popoverElement;
7779
+ if (panel && e.composedPath().includes(panel)) return;
7780
+ this.closeCalendarPopover(false);
7781
+ };
7273
7782
  this.updatePopoverPosition = () => {
7274
7783
  const panel = this.popoverElement;
7275
7784
  const anchor = this.triggerButton;
@@ -7395,7 +7904,7 @@ var RelDatePicker = class extends LitElement15 {
7395
7904
  document.removeEventListener("click", this.handleClickOutside);
7396
7905
  document.removeEventListener("keydown", this.handleEscapeKey);
7397
7906
  window.removeEventListener("resize", this.onReposition);
7398
- window.removeEventListener("scroll", this.onReposition, true);
7907
+ window.removeEventListener("scroll", this.onPageScroll, true);
7399
7908
  this.hideTopLayer();
7400
7909
  }
7401
7910
  updated(changedProperties) {
@@ -7409,7 +7918,7 @@ var RelDatePicker = class extends LitElement15 {
7409
7918
  if (changedProperties.has("open")) {
7410
7919
  if (!this.open) {
7411
7920
  window.removeEventListener("resize", this.onReposition);
7412
- window.removeEventListener("scroll", this.onReposition, true);
7921
+ window.removeEventListener("scroll", this.onPageScroll, true);
7413
7922
  this.hideTopLayer();
7414
7923
  this.focusPanelOnOpen = true;
7415
7924
  this.panelView = "days";
@@ -7417,7 +7926,7 @@ var RelDatePicker = class extends LitElement15 {
7417
7926
  }
7418
7927
  this.showTopLayer();
7419
7928
  window.addEventListener("resize", this.onReposition);
7420
- window.addEventListener("scroll", this.onReposition, true);
7929
+ window.addEventListener("scroll", this.onPageScroll, true);
7421
7930
  const focusPanel = this.focusPanelOnOpen;
7422
7931
  this.focusPanelOnOpen = true;
7423
7932
  if (focusPanel) {
@@ -7929,6 +8438,28 @@ var RelDatePicker = class extends LitElement15 {
7929
8438
  * Index of the single grid cell carrying tabindex="0". The old expression
7930
8439
  * (`isSelected || (index === 0 && !value)`) left the grid with ZERO tab
7931
8440
  * stops once the user arrowed around without a selection.
8441
+ *
8442
+ * KNOWN GAP (deferred from 1.4.9, needs a ticket covering BOTH calendars):
8443
+ * `focusedDate` is read here as the first candidate but is never assigned
8444
+ * anywhere in this file, so it is permanently null and the tab stop tracks
8445
+ * the SELECTION rather than focus. Tab out of the grid and back and you
8446
+ * return to the selected day, not the day you left.
8447
+ *
8448
+ * Wiring it from each cell's `@focus` was tried and is NOT sufficient, which
8449
+ * is why this is still open. Measured 2026-09-25 by driving real keys: from
8450
+ * Sun 27 Sep, one ArrowDown lands focus on Sun 25 Oct instead of Sun 4 Oct,
8451
+ * because handleDayKeydown moves by grid INDEX and then lets the view flip
8452
+ * month, so index+7 addresses a different date in the new grid. lit reuses
8453
+ * the same button elements across that re-render, so the focused element
8454
+ * never changes and NO focus event fires — the handler cannot observe the
8455
+ * move it is supposed to record.
8456
+ *
8457
+ * The real fix is to navigate by DATE and set the rove explicitly before
8458
+ * focusing, the way rel-range-picker.moveGridFocusTo already does (verified
8459
+ * correct in the same run: Sep 27 → Oct 4 → Oct 11, tab stop and focus
8460
+ * agreeing at every step). That is a rewrite of this component's arrow-key
8461
+ * model, not a contained fix, and it must land on both calendars together or
8462
+ * they diverge again.
7932
8463
  */
7933
8464
  getRovingIndex(days) {
7934
8465
  const candidates = [
@@ -8143,11 +8674,11 @@ var RelDatePicker = class extends LitElement15 {
8143
8674
  ?disabled=${this.disabled}
8144
8675
  @click=${this.handleIconClick}
8145
8676
  >
8146
- <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
8147
- <path
8148
- d="M4 2h1V1h2v1h2V1h2v1h1a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm8 3H4v7h8V5z"
8149
- />
8150
- </svg>
8677
+ <!-- Figma pins this suffix to node 5137:1877, which resolves to
8678
+ COMPONENT_SET 5137:1876 "Calendar Month" — verified against
8679
+ the live Published DS file 2026-09-25. This was a hand-drawn
8680
+ inline path that matched no icon in the set. -->
8681
+ <rel-icon name="calendar-month"></rel-icon>
8151
8682
  </button>
8152
8683
  </span>
8153
8684
  </div>
@@ -8506,18 +9037,28 @@ RelDatePicker.styles = css16`
8506
9037
  }
8507
9038
 
8508
9039
  /* Figma's suffix icon is 18px at medium; rel-textfield ramps 16 / 18 / 20
8509
- across the size scale, so the trigger follows the same ramp. */
8510
- .icon-button svg {
9040
+ across the size scale, so the trigger follows the same ramp.
9041
+ Both selectors are needed and neither is redundant: the CLEAR (×) button
9042
+ is still a raw inline <svg>, while the calendar suffix is now a
9043
+ <rel-icon> whose own svg lives in a shadow root that .icon-button svg
9044
+ cannot reach. Sizing the rel-icon ELEMENT works because a declaration
9045
+ from the outer tree beats the :host([size]) rules inside it — which is
9046
+ also why the element carries no size attribute: one source for the
9047
+ ramp, not two that have to agree. */
9048
+ .icon-button svg,
9049
+ .icon-button rel-icon {
8511
9050
  width: 18px;
8512
9051
  height: 18px;
8513
9052
  }
8514
9053
 
8515
- .date-picker-wrapper.small .icon-button svg {
9054
+ .date-picker-wrapper.small .icon-button svg,
9055
+ .date-picker-wrapper.small .icon-button rel-icon {
8516
9056
  width: 16px;
8517
9057
  height: 16px;
8518
9058
  }
8519
9059
 
8520
- .date-picker-wrapper.large .icon-button svg {
9060
+ .date-picker-wrapper.large .icon-button svg,
9061
+ .date-picker-wrapper.large .icon-button rel-icon {
8521
9062
  width: 20px;
8522
9063
  height: 20px;
8523
9064
  }
@@ -8541,7 +9082,14 @@ RelDatePicker.styles = css16`
8541
9082
  position: absolute;
8542
9083
  top: calc(100% + 8px);
8543
9084
  left: 0;
8544
- z-index: 2147483647;
9085
+ /* Fallback stacking ONLY: while the panel is in the top layer this is
9086
+ inert, because top-layer elements are not in the page stacking order at
9087
+ all and order of promotion decides. It applies solely where the Popover
9088
+ API is unavailable. One overridable hook across every overlay, so a
9089
+ consumer with its own stacking scheme sets it once; the default matches
9090
+ rel-select / rel-autocomplete / rel-popover / rel-menubar. This was
9091
+ 2147483647, which cannot be layered against anything. */
9092
+ z-index: var(--rel-overlay-fallback-z, 1001);
8545
9093
  background: var(--rel-bg-neutral);
8546
9094
  border-radius: var(--rel-border-radius-sm);
8547
9095
  /* Figma ".Date Picker Calendar" is a shadowed card with no border.
@@ -9205,6 +9753,21 @@ var RelRangePicker = class extends LitElement16 {
9205
9753
  this.onReposition = () => {
9206
9754
  if (this.open && this.mode === "popover") this.updatePopoverPosition();
9207
9755
  };
9756
+ /* Outside scroll CLOSES the panel; it used to reposition. Same change as
9757
+ * rel-date-picker and rel-time-picker — see the long note in
9758
+ * rel-date-picker.onPageScroll for why tracking the trigger is what put the
9759
+ * panel over the sticky site header, and why z-index cannot fix it.
9760
+ *
9761
+ * ONLY in popover mode. `popup` mode centres the shell in the viewport behind
9762
+ * a backdrop and never anchors to the trigger, so there is no trigger to
9763
+ * scroll away from and closing on scroll would just be a dialog that vanishes
9764
+ * when the page moves. Guarded exactly as onReposition above is. */
9765
+ this.onPageScroll = (e) => {
9766
+ if (!this.open || this.mode !== "popover") return;
9767
+ const panel = this.popoverEl;
9768
+ if (panel && e.composedPath().includes(panel)) return;
9769
+ this.cancelPopover();
9770
+ };
9208
9771
  this.updatePopoverPosition = () => {
9209
9772
  const panel = this.popoverEl;
9210
9773
  if (!panel || !this.open || this.mode !== "popover") return;
@@ -9417,7 +9980,7 @@ var RelRangePicker = class extends LitElement16 {
9417
9980
  document.removeEventListener("click", this.handleClickOutside);
9418
9981
  document.removeEventListener("keydown", this.handleGlobalKeydown);
9419
9982
  window.removeEventListener("resize", this.onReposition);
9420
- window.removeEventListener("scroll", this.onReposition, true);
9983
+ window.removeEventListener("scroll", this.onPageScroll, true);
9421
9984
  this.hideTopLayer();
9422
9985
  }
9423
9986
  updated(changed) {
@@ -9431,10 +9994,10 @@ var RelRangePicker = class extends LitElement16 {
9431
9994
  if (this.open) {
9432
9995
  this.showTopLayer();
9433
9996
  window.addEventListener("resize", this.onReposition);
9434
- window.addEventListener("scroll", this.onReposition, true);
9997
+ window.addEventListener("scroll", this.onPageScroll, true);
9435
9998
  } else {
9436
9999
  window.removeEventListener("resize", this.onReposition);
9437
- window.removeEventListener("scroll", this.onReposition, true);
10000
+ window.removeEventListener("scroll", this.onPageScroll, true);
9438
10001
  this.hideTopLayer();
9439
10002
  }
9440
10003
  }
@@ -10048,6 +10611,21 @@ var RelRangePicker = class extends LitElement16 {
10048
10611
  * The cell that carries tabindex="0". Prefers the last roved date, then the
10049
10612
  * draft start, then today, then the first selectable day — always exactly
10050
10613
  * one Tab stop, even after arrowing away with nothing selected.
10614
+ *
10615
+ * `focusedDate` is written by moveGridFocusTo, which sets it from the DATE
10616
+ * before moving focus rather than reading it back off a focus event. That
10617
+ * ordering is what makes the arrow keys correct across a month boundary —
10618
+ * verified 2026-09-25 by driving real keys: Sep 27 → Oct 4 → Oct 11 with the
10619
+ * tab stop and focus agreeing at every step, including the view change.
10620
+ * rel-date-picker moves by grid INDEX instead and lands on the wrong date
10621
+ * there; see the note on its getRovingIndex. Do not "simplify" this to a
10622
+ * focus listener — lit reuses the same cell elements across the re-render, so
10623
+ * the focused element does not change and no focus event fires.
10624
+ *
10625
+ * Still open for BOTH calendars: focus arriving from a click or from Tab does
10626
+ * not update the rove, only the arrow keys do. Deferred from 1.4.9 as a
10627
+ * shared ticket, since fixing one calendar alone reintroduces the divergence
10628
+ * the shared period-page module exists to prevent.
10051
10629
  */
10052
10630
  getRovingIso(days) {
10053
10631
  const inView = days.filter((d) => d.isCurrentMonth);
@@ -10103,7 +10681,14 @@ var RelRangePicker = class extends LitElement16 {
10103
10681
  aria-label=${triggerLabel || this.placeholder}
10104
10682
  @click=${() => this.toggleOpen()}
10105
10683
  >
10106
- <rel-icon name="calendar-range"></rel-icon>
10684
+ <!-- Deliberately the SAME icon as rel-date-picker, at the user's
10685
+ request, so the two triggers read as one family. Figma does
10686
+ NOT specify a trigger icon here: the range-picker spec lines
10687
+ only carry node 5137:2842, which resolves to "Plus" and
10688
+ appears in 440 places across the component specs — it is the
10689
+ un-overridden INSTANCE_SWAP default, not a choice. So this is
10690
+ ours to pick, and it should move with the date picker. -->
10691
+ <rel-icon name="calendar-month"></rel-icon>
10107
10692
  </rel-button>
10108
10693
  ` : html16`
10109
10694
  <div
@@ -10158,9 +10743,7 @@ var RelRangePicker = class extends LitElement16 {
10158
10743
  ?disabled=${this.disabled}
10159
10744
  @click=${this.handleIconClick}
10160
10745
  >
10161
- <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
10162
- <path d="M4 2h1V1h2v1h2V1h2v1h1a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm8 3H4v7h8V5z"/>
10163
- </svg>
10746
+ <rel-icon name="calendar-month"></rel-icon>
10164
10747
  </button>
10165
10748
  </span>
10166
10749
  </div>
@@ -10531,9 +11114,18 @@ RelRangePicker.styles = css17`
10531
11114
  color: var(--rel-text-default);
10532
11115
  }
10533
11116
 
10534
- .icon-button svg {
11117
+ /* Both selectors, deliberately. The calendar trigger is a <rel-icon>,
11118
+ whose own svg lives in a shadow root that a plain .icon-button svg
11119
+ selector cannot reach — so sizing only the raw svg would leave it at its
11120
+ component default. The clear (x) button is still a hand-drawn inline
11121
+ svg, so the raw selector has to stay as well. Sizing the rel-icon
11122
+ ELEMENT works because a declaration from the outer tree beats the
11123
+ :host([size]) rules inside it. Same fix as rel-date-picker. */
11124
+ .icon-button svg,
11125
+ .icon-button rel-icon {
10535
11126
  width: 16px;
10536
11127
  height: 16px;
11128
+ --rel-icon-size: 16px;
10537
11129
  }
10538
11130
 
10539
11131
  /* ── Popover shell ── */
@@ -10561,7 +11153,14 @@ RelRangePicker.styles = css17`
10561
11153
  position: absolute;
10562
11154
  top: calc(100% + 8px);
10563
11155
  left: 0;
10564
- z-index: 2147483647;
11156
+ /* Fallback stacking ONLY: while the panel is in the top layer this is
11157
+ inert, because top-layer elements are not in the page stacking order at
11158
+ all and order of promotion decides. It applies solely where the Popover
11159
+ API is unavailable. One overridable hook across every overlay, so a
11160
+ consumer with its own stacking scheme sets it once; the default matches
11161
+ rel-select / rel-autocomplete / rel-popover / rel-menubar. This was
11162
+ 2147483647, which cannot be layered against anything. */
11163
+ z-index: var(--rel-overlay-fallback-z, 1001);
10565
11164
  border-radius: var(--rel-border-radius-sm, 8px);
10566
11165
  background: var(--rel-bg-neutral);
10567
11166
  /* Figma effectStyle=shadow/xl — the token now exists. */
@@ -10595,7 +11194,8 @@ RelRangePicker.styles = css17`
10595
11194
  width: auto;
10596
11195
  height: auto;
10597
11196
  border: none;
10598
- z-index: 2147483646;
11197
+ /* One below the shell, so the backdrop never covers its own panel. */
11198
+ z-index: calc(var(--rel-overlay-fallback-z, 1001) - 1);
10599
11199
  background: rgba(0,0,0,0.4);
10600
11200
  }
10601
11201
 
@@ -12315,18 +12915,27 @@ var RelRadio = class extends LitElement23 {
12315
12915
  };
12316
12916
  this._onTouched = () => {
12317
12917
  };
12918
+ /** Whether the author wrote `density` themselves, as opposed to inheriting
12919
+ * the default. Read once at connect: Lit reflects during update, which is
12920
+ * after this runs, so the attribute is present here only if authored. */
12921
+ this.densityExplicit = false;
12318
12922
  }
12319
12923
  willUpdate(changed) {
12320
12924
  if (changed.has("size")) {
12321
12925
  const canonical = SIZE_ALIASES5[this.size];
12322
12926
  if (canonical) this.size = canonical;
12323
12927
  }
12324
- if ((changed.has("alignment") || changed.has("density")) && this.alignment === "trailing" && this.density === "compact") {
12928
+ const densityChosen = this.densityExplicit || changed.get("density") !== void 0;
12929
+ if ((changed.has("alignment") || changed.has("density")) && this.alignment === "trailing" && this.density === "compact" && densityChosen) {
12325
12930
  console.warn(
12326
12931
  '[rel-radio] density="compact" has no trailing layout; rendering comfortable density.'
12327
12932
  );
12328
12933
  }
12329
12934
  }
12935
+ connectedCallback() {
12936
+ super.connectedCallback();
12937
+ this.densityExplicit = this.hasAttribute("density");
12938
+ }
12330
12939
  /** Trailing has no compact layout in the spec — trailing wins. */
12331
12940
  get effectiveDensity() {
12332
12941
  return this.alignment === "trailing" ? "comfortable" : this.density;
@@ -12594,13 +13203,22 @@ RelRadio.styles = css24`
12594
13203
  letter-spacing: var(--rel-body-lg-regular-letter-spacing);
12595
13204
  }
12596
13205
 
13206
+ /* Supporting text sits ONE step below the label on the body scale at every
13207
+ size — 14/12 small, 16/14 medium, 20/16 large (2026-09-26 Trello ticket,
13208
+ "visual adjustment", confirmed by the designer as applying to every
13209
+ label + supporting pair, not just the checkbox). It used to be two steps
13210
+ below. The Figma ticket covered the Checkbox set only; extending the
13211
+ rule to Radio was proposed from the code side and approved by the
13212
+ designer on 2026-09-26, so the Radio Button set (lastModified
13213
+ 2026-09-23) still shows the old two-step pairs (body-xs / body-sm /
13214
+ body-md) and will be updated to match the code, not the other way. */
12597
13215
  .radio-supporting {
12598
13216
  color: var(--rel-text-caption);
12599
- font-family: var(--rel-body-sm-regular-font-family);
12600
- font-weight: var(--rel-body-sm-regular-font-weight);
12601
- font-size: var(--rel-body-sm-regular-font-size);
12602
- line-height: var(--rel-body-sm-regular-line-height);
12603
- letter-spacing: var(--rel-body-sm-regular-letter-spacing);
13217
+ font-family: var(--rel-body-md-regular-font-family);
13218
+ font-weight: var(--rel-body-md-regular-font-weight);
13219
+ font-size: var(--rel-body-md-regular-font-size);
13220
+ line-height: var(--rel-body-md-regular-line-height);
13221
+ letter-spacing: var(--rel-body-md-regular-letter-spacing);
12604
13222
  }
12605
13223
 
12606
13224
  /* Compact density — supporting text sits under the whole row, indented
@@ -12660,11 +13278,11 @@ RelRadio.styles = css24`
12660
13278
  }
12661
13279
 
12662
13280
  :host([size="large"]) .radio-supporting {
12663
- font-family: var(--rel-body-md-regular-font-family);
12664
- font-weight: var(--rel-body-md-regular-font-weight);
12665
- font-size: var(--rel-body-md-regular-font-size);
12666
- line-height: var(--rel-body-md-regular-line-height);
12667
- letter-spacing: var(--rel-body-md-regular-letter-spacing);
13281
+ font-family: var(--rel-body-lg-regular-font-family);
13282
+ font-weight: var(--rel-body-lg-regular-font-weight);
13283
+ font-size: var(--rel-body-lg-regular-font-size);
13284
+ line-height: var(--rel-body-lg-regular-line-height);
13285
+ letter-spacing: var(--rel-body-lg-regular-letter-spacing);
12668
13286
  }
12669
13287
 
12670
13288
  :host([size="large"]) .radio-supporting.indent {
@@ -12681,39 +13299,29 @@ RelRadio.styles = css24`
12681
13299
  letter-spacing: var(--rel-body-sm-semibold-letter-spacing);
12682
13300
  }
12683
13301
 
12684
- /* Small's own control (ticket 11b). The ramp is 16 / 20 / 24, and every
12685
- number falls out of the invariant the other two sizes already keep:
12686
- the RING is inset 2px from the BOX on each side (box - 4), and the DOT
12687
- is half the ring. M is 20/16/8, L is 24/20/10, so S is 16/12/6 — all
12688
- integers, so the 2px ring stays crisp at 1x and 2x. Deriving from the
12689
- stated ring/box = 0.8 instead would have given 12.8 and a 6.4 dot,
12690
- which is where a 2px border on a fractional circle goes soft. */
12691
- :host([size="small"]) .radio-custom {
12692
- width: 16px;
12693
- height: 16px;
12694
- }
13302
+ /* SMALL DELIBERATELY DECLARES NO CONTROL — it shares medium's 20px box,
13303
+ 16px ring and 8px dot, and only the TYPE below steps down.
12695
13304
 
12696
- :host([size="small"]) .radio-custom::before {
12697
- width: 12px;
12698
- height: 12px;
12699
- }
13305
+ v1.4.8 ticket 11b gave Small its own 16/12/6 control, derived from the
13306
+ premise "the ramp is 16 / 20 / 24". That premise was never read off
13307
+ Figma, and it is wrong. Verified against the LIVE Published DS file on
13308
+ 2026-09-25 (set "Radio Button" 8408:395, 45 variants): .Radio Control
13309
+ measures 20×20 in all 15 Small variants, 20×20 in all 15 Medium, and
13310
+ 24×24 in all 15 Large. Figma discriminates Small from Medium by type
13311
+ alone. memory/project_web_components.md recorded exactly this on
13312
+ 2026-08-21 — "S and M share the 20px control, only typography steps
13313
+ down" — before 1.4.8 overrode it.
12700
13314
 
12701
- :host([size="small"]) .radio-custom::after {
12702
- width: 6px;
12703
- height: 6px;
12704
- }
13315
+ So the correct Small is the ABSENCE of a rule. Adding one back means
13316
+ re-deriving it from Figma, not from the invariant the other two sizes
13317
+ happen to satisfy: that invariant (ring = box − 4, dot = ring ÷ 2) is a
13318
+ coincidence of rounding, since Figma's ring is proportional at 0.8 and
13319
+ our Large ring is a deliberate 19.2 → 20. */
12705
13320
 
12706
- /* The compact indent is box + the 8px row gap, bound to the token that
12707
- equals it — 2xl (28) for M, 3xl (32) for L, so xl (24) for a 16px S. */
13321
+ /* The compact indent is box + the 8px row gap, so Small takes medium's
13322
+ 2xl (28) along with medium's 20px box. */
12708
13323
  :host([size="small"]) .radio-supporting.indent {
12709
- padding-left: var(--rel-spacing-xl);
12710
- }
12711
-
12712
- /* The option avatar matches the control box at every size (20 at M, 24 at
12713
- L), so it follows Small down to 16. */
12714
- :host([size="small"]) .radio-avatar {
12715
- width: 16px;
12716
- height: 16px;
13324
+ padding-left: var(--rel-spacing-2xl);
12717
13325
  }
12718
13326
 
12719
13327
  :host([size="small"]) .radio-label {
@@ -12725,11 +13333,11 @@ RelRadio.styles = css24`
12725
13333
  }
12726
13334
 
12727
13335
  :host([size="small"]) .radio-supporting {
12728
- font-family: var(--rel-body-xs-regular-font-family);
12729
- font-weight: var(--rel-body-xs-regular-font-weight);
12730
- font-size: var(--rel-body-xs-regular-font-size);
12731
- line-height: var(--rel-body-xs-regular-line-height);
12732
- letter-spacing: var(--rel-body-xs-regular-letter-spacing);
13336
+ font-family: var(--rel-body-sm-regular-font-family);
13337
+ font-weight: var(--rel-body-sm-regular-font-weight);
13338
+ font-size: var(--rel-body-sm-regular-font-size);
13339
+ line-height: var(--rel-body-sm-regular-line-height);
13340
+ letter-spacing: var(--rel-body-sm-regular-letter-spacing);
12733
13341
  }
12734
13342
 
12735
13343
  /* Error state (Figma State=Error) — ring, dot, and supporting text go
@@ -13344,12 +13952,23 @@ RelMenu._css = `
13344
13952
  flex: 1 1 auto;
13345
13953
  }
13346
13954
 
13347
- /* Submenu affordance \u2014 same rel-icon chevron the menubar uses. */
13955
+ /* Submenu affordance \u2014 same rel-icon chevron the menubar uses.
13956
+
13957
+ No opacity here: verified against the live Figma file, "Chevron Right"
13958
+ is opacity 1 on both the instance and its vector, on every State and
13959
+ Size, bound to the same colour variable as the row label beside it.
13960
+ The 0.6 that used to sit here dulled it to a mid grey.
13961
+
13962
+ The colour is declared rather than inherited on purpose. rel-menu
13963
+ renders to the LIGHT DOM (createRenderRoot returns this), so these
13964
+ selectors are unscoped globals and an inherited colour would travel
13965
+ through the consumer's page cascade \u2014 an app setting a colour on any
13966
+ ancestor would have been repainting this chevron. */
13348
13967
  .submenu-chevron {
13349
13968
  margin-left: auto;
13350
13969
  display: inline-flex;
13351
13970
  align-items: center;
13352
- opacity: 0.6;
13971
+ color: var(--rel-text-default);
13353
13972
  pointer-events: none;
13354
13973
  }
13355
13974
 
@@ -16700,12 +17319,12 @@ var RelSegmentedButton = class extends LitElement35 {
16700
17319
  setActive(option) {
16701
17320
  if (!option.disabled) {
16702
17321
  this.active = option.value;
17322
+ const detail = { value: option.value };
16703
17323
  this.dispatchEvent(
16704
- new CustomEvent("changed", {
16705
- detail: { value: option.value },
16706
- bubbles: true,
16707
- composed: true
16708
- })
17324
+ new CustomEvent("rel-change", { detail, bubbles: true, composed: true })
17325
+ );
17326
+ this.dispatchEvent(
17327
+ new CustomEvent("changed", { detail, bubbles: true, composed: true })
16709
17328
  );
16710
17329
  }
16711
17330
  }
@@ -17156,6 +17775,14 @@ var RelSelect = class extends LitElement36 {
17156
17775
  get optionCheckboxSize() {
17157
17776
  return this.size;
17158
17777
  }
17778
+ /** Native tooltip carrying the row's full text. Both lines truncate to one
17779
+ * line with an ellipsis, so a long supporting line is otherwise unrecoverable
17780
+ * by sight; hovering the row reveals it. Only rows that carry a second line
17781
+ * get one, so plain lists do not sprout tooltips. */
17782
+ rowTitle(option) {
17783
+ return option.supportingText ? `${option.label}
17784
+ ${option.supportingText}` : nothing18;
17785
+ }
17159
17786
  get selectAllStatus() {
17160
17787
  const filtered = this.filteredOptions;
17161
17788
  if (filtered.length === 0) return "unchecked";
@@ -17174,13 +17801,27 @@ var RelSelect = class extends LitElement36 {
17174
17801
  window.removeEventListener("resize", this.updatePosition);
17175
17802
  window.removeEventListener("scroll", this.onPageScroll, true);
17176
17803
  }
17804
+ /**
17805
+ * BOTH events fire, deliberately — do not delete either.
17806
+ *
17807
+ * This repo is split down the middle on the convention, not inconsistent in
17808
+ * one place: rel-select and rel-segmented-button emit `changed`, while
17809
+ * rel-switch, rel-checkbox and rel-radio emit `rel-change`. Renaming
17810
+ * `changed` would break every existing rel-select consumer, so 1.4.9 adds
17811
+ * the prefixed name alongside it rather than moving anyone. New code should
17812
+ * listen for `rel-change`; `changed` stays for as long as 1.x does.
17813
+ *
17814
+ * One `detail` object is shared by both dispatches so the two events cannot
17815
+ * drift apart — a listener on one sees exactly what a listener on the other
17816
+ * sees.
17817
+ */
17177
17818
  emitChange() {
17819
+ const detail = { value: this.multiple ? this.selectedValues : this.selected };
17178
17820
  this.dispatchEvent(
17179
- new CustomEvent("changed", {
17180
- detail: { value: this.multiple ? this.selectedValues : this.selected },
17181
- bubbles: true,
17182
- composed: true
17183
- })
17821
+ new CustomEvent("changed", { detail, bubbles: true, composed: true })
17822
+ );
17823
+ this.dispatchEvent(
17824
+ new CustomEvent("rel-change", { detail, bubbles: true, composed: true })
17184
17825
  );
17185
17826
  }
17186
17827
  openListbox() {
@@ -17624,6 +18265,7 @@ var RelSelect = class extends LitElement36 {
17624
18265
  role="option"
17625
18266
  aria-selected="${this.selectedValues.includes(option.value)}"
17626
18267
  aria-label="${option.label}"
18268
+ title="${this.rowTitle(option)}"
17627
18269
  @pointerenter="${() => {
17628
18270
  this.highlightedIndex = index;
17629
18271
  this.selectAllHighlighted = false;
@@ -17655,6 +18297,7 @@ var RelSelect = class extends LitElement36 {
17655
18297
  part="option" class="select-option ${index === this.highlightedIndex ? "highlighted" : ""}"
17656
18298
  role="option"
17657
18299
  aria-selected="${option.value === this.selected}"
18300
+ title="${this.rowTitle(option)}"
17658
18301
  @mousedown="${(e) => e.preventDefault()}"
17659
18302
  @click="${() => this.selectSingleOption(option)}"
17660
18303
  @pointerenter="${() => this.highlightedIndex = index}"
@@ -17975,12 +18618,40 @@ RelSelect.styles = css34`
17975
18618
  --_rel-sel-icon: 16px;
17976
18619
  }
17977
18620
 
18621
+ /* Border geometry, in one place, so the mask strip below is DERIVED from
18622
+ the paint it has to cover rather than restating it. Declared on the
18623
+ wrapper and not on .select-trigger because .floating-label is the
18624
+ trigger's SIBLING, not its child, and would not inherit from it. */
18625
+ .select-wrapper {
18626
+ --_rel-sel-border-w: 1px;
18627
+ --_rel-sel-ring-w: 0px;
18628
+ --_rel-sel-notch-h: calc(var(--_rel-sel-border-w) + var(--_rel-sel-ring-w));
18629
+ }
18630
+
18631
+ .select-wrapper.focused {
18632
+ --_rel-sel-ring-w: 2px;
18633
+ }
18634
+
17978
18635
  .floating-label {
17979
18636
  position: absolute;
18637
+ /* Matches Figma directly and needs no arithmetic: the live spec puts the
18638
+ "Floating Label" frame at dx=12 from the Input frame's edge, and this
18639
+ element's containing block (.select-container) IS the trigger's outer
18640
+ edge, so --_rel-sel-pad lands it exactly there. rel-textfield needs a
18641
+ different value (0) for the same result, because ITS containing block
18642
+ already starts inside the border and padding. Do not copy one into the
18643
+ other — I briefly "corrected" this one to subtract the notch padding
18644
+ and moved a label that was already right. */
17980
18645
  left: var(--_rel-sel-pad, var(--rel-spacing-sm));
17981
18646
  top: 50%;
17982
18647
  transform: translateY(-50%);
17983
- background: var(--rel-select-bg, var(--rel-bg-neutral));
18648
+ /* NO background here. It used to paint --rel-select-bg across the
18649
+ label's whole ~20px line box to hide a 1px border, which erased about
18650
+ 10px of the trigger's interior and 10px of whatever sat above it.
18651
+ Figma does not do that: the "Floating Label" frame is a strip whose
18652
+ HEIGHT EQUALS THE BORDER IT MASKS — size=45x1 at rest and 45x2 when
18653
+ focused (rel-select.md). rel-textfield already moved off the chip for
18654
+ exactly this reason; the strip is now on ::before below. */
17984
18655
  /* Figma pads the floating-label chip by --rel-spacing-2xs; this was a
17985
18656
  raw 4px that happened to match but was not on the scale. */
17986
18657
  padding: 0 var(--rel-spacing-2xs);
@@ -18026,6 +18697,8 @@ RelSelect.styles = css34`
18026
18697
  .select-wrapper.has-value .floating-label,
18027
18698
  .select-wrapper.open .floating-label {
18028
18699
  top: 0;
18700
+ /* Same inset as the resting rule above, so the label does not shift
18701
+ horizontally as it floats. */
18029
18702
  left: var(--_rel-sel-pad, var(--rel-spacing-sm));
18030
18703
  /* Figma's floated label is REGULAR 400, not medium 500. */
18031
18704
  font-family: var(--rel-body-sm-regular-font-family);
@@ -18054,10 +18727,66 @@ RelSelect.styles = css34`
18054
18727
  letter-spacing: var(--rel-body-xs-regular-letter-spacing);
18055
18728
  }
18056
18729
 
18057
- /* Focus thickens the border to 2px; the chip rides up with it so it stays
18058
- optically centred on the stroke rather than sinking into the field. */
18059
- .select-wrapper.focused .floating-label {
18060
- top: -2px;
18730
+ /* The mask: TWO OVERLAPPING strips, same idea as rel-textfield, different
18731
+ reference — and the reference is the whole reason this cannot be copied
18732
+ across. In rel-textfield the label is positioned inside the control, so
18733
+ its 50% is one border-width BELOW the border-box top. Here
18734
+ .floating-label is a SIBLING of .select-trigger inside
18735
+ .select-container, so its 50% IS the trigger's border-box top. Measured
18736
+ from there the focus outline occupies [50% - ringW, 50%] (outlines draw
18737
+ outside the border box) and the border [50%, 50% + borderW].
18738
+ A single strip cut to exactly that band leaked at some zoom levels and
18739
+ not others (v1.4.9 "slash" report): border and outline are
18740
+ pixel-snapped by Blink, while a strip inside a translateY(-50%) label
18741
+ rasterises at a fractional offset, so their edges disagreed by under a
18742
+ device pixel and a hairline showed through the text. So each strip
18743
+ overshoots into paint of ITS OWN colour, and the two overlap so no two
18744
+ anti-aliased edges share a row (abutting edges leak a*(1-a) of the band
18745
+ — measured as exactly a quarter of the ring colour on rel-textfield):
18746
+ ::before surface colour, ring + border + 2px above + 2px below
18747
+ ::after the trigger's FILL, border + 3px below, painted on top
18748
+ 2px of overshoot, not 1: the strip and the band snap to the device grid
18749
+ in different coordinate spaces and can disagree by a full pixel. */
18750
+ .select-wrapper.focused .floating-label::before,
18751
+ .select-wrapper.has-value .floating-label::before,
18752
+ .select-wrapper.open .floating-label::before {
18753
+ content: "";
18754
+ position: absolute;
18755
+ left: 0;
18756
+ right: 0;
18757
+ top: calc(50% - var(--_rel-sel-ring-w, 0px) - 2px);
18758
+ height: calc(var(--_rel-sel-notch-h, 1px) + 4px);
18759
+ background: var(--rel-select-surface, var(--rel-select-bg, var(--rel-bg-neutral)));
18760
+ z-index: -1;
18761
+ }
18762
+
18763
+ .select-wrapper.focused .floating-label::after,
18764
+ .select-wrapper.has-value .floating-label::after,
18765
+ .select-wrapper.open .floating-label::after {
18766
+ content: "";
18767
+ position: absolute;
18768
+ left: 0;
18769
+ right: 0;
18770
+ top: 50%;
18771
+ height: calc(var(--_rel-sel-border-w, 1px) + 3px);
18772
+ background: var(--rel-select-bg, var(--rel-bg-neutral));
18773
+ z-index: -1;
18774
+ }
18775
+
18776
+ /* Figma swaps the strip's fill on the disabled variant
18777
+ (--rel-bg-neutral-elevated-2), matching the disabled trigger fill at
18778
+ .select-wrapper.disabled .select-trigger. rel-select had no equivalent of
18779
+ rel-textfield's disabled swap at all, so the old chip painted
18780
+ --rel-bg-neutral over a trigger filled --rel-bg-neutral-elevated-2: a
18781
+ visibly lighter block sitting on the field. */
18782
+ .select-wrapper.disabled .floating-label::before {
18783
+ background: var(
18784
+ --rel-select-surface-disabled,
18785
+ var(--rel-select-bg-disabled, var(--rel-bg-neutral-elevated-2))
18786
+ );
18787
+ }
18788
+ .select-wrapper.disabled .floating-label::after {
18789
+ background: var(--rel-select-bg-disabled, var(--rel-bg-neutral-elevated-2));
18061
18790
  }
18062
18791
 
18063
18792
  /* The real placeholder hides while the float-label rests in its spot,
@@ -18113,7 +18842,12 @@ RelSelect.styles = css34`
18113
18842
  /* ── Dropdown listbox ── */
18114
18843
  .select-listbox {
18115
18844
  position: fixed;
18116
- z-index: 1001;
18845
+ /* Only consulted on the no-Popover-API fallback path: once the listbox
18846
+ is promoted to the top layer, z-index is inert and stacking is by
18847
+ promotion order. Shared with the pickers so the whole overlay family
18848
+ is overridable from one place instead of disagreeing (they were on
18849
+ 2147483647, which beat consumer app chrome unconditionally). */
18850
+ z-index: var(--rel-overlay-fallback-z, 1001);
18117
18851
  display: none;
18118
18852
  /* Reset native [popover] UA styles (the listbox is promoted to the
18119
18853
  top layer so transformed ancestors — e.g. a centered rel-modal
@@ -18338,6 +19072,26 @@ RelSelect.styles = css34`
18338
19072
  white-space: nowrap;
18339
19073
  }
18340
19074
 
19075
+ /* Multi-select rows delegate to a nested rel-checkbox, which wraps its
19076
+ text by default, so the same option truncated to one line in single
19077
+ mode and wrapped to three (or overflowed the panel sideways on an
19078
+ unbroken identifier) under multiple. The checkbox's label and helper
19079
+ are parts, so the row reaches them from here and applies the same
19080
+ one-line ellipsis. Same fix as rel-autocomplete, same date. */
19081
+ .checkbox-option > rel-checkbox {
19082
+ flex: 1;
19083
+ min-width: 0;
19084
+ }
19085
+
19086
+ .checkbox-option > rel-checkbox::part(label),
19087
+ .checkbox-option > rel-checkbox::part(helper) {
19088
+ display: block;
19089
+ min-width: 0;
19090
+ overflow: hidden;
19091
+ text-overflow: ellipsis;
19092
+ white-space: nowrap;
19093
+ }
19094
+
18341
19095
  /* One line, truncated like the label. supportingText used to render only in
18342
19096
  multi-select rows (through rel-checkbox); single-select rows drop it on
18343
19097
  the floor, which made the same option object render differently
@@ -20580,6 +21334,7 @@ var RelStepper = class extends LitElement41 {
20580
21334
  this.orientation = "horizontal";
20581
21335
  this.size = "large";
20582
21336
  this.type = "default";
21337
+ this.connector = "solid";
20583
21338
  }
20584
21339
  willUpdate(changed) {
20585
21340
  if (changed.has("size") && this.size === "small") {
@@ -20590,6 +21345,14 @@ var RelStepper = class extends LitElement41 {
20590
21345
  "Figma has no Size axis on the Stepper: the connector geometry is fixed, so size no longer changes anything. Retune the connector with the CSS hook instead."
20591
21346
  );
20592
21347
  }
21348
+ if (changed.has("textAlign") && this.textAlign !== "below" && !this.labelPlacement) {
21349
+ warnDeprecated(
21350
+ "rel-stepper",
21351
+ "textAlign",
21352
+ "label-placement",
21353
+ 'Use label-placement="inverted" for textalign="below" and label-placement="centered" for textalign="right".'
21354
+ );
21355
+ }
20593
21356
  }
20594
21357
  isDone(step) {
20595
21358
  return step.status === "done";
@@ -20972,9 +21735,15 @@ RelStepper.styles = css39`
20972
21735
  /********************************************
20973
21736
  * Connector Lines
20974
21737
  ********************************************/
21738
+ /* The line colour is held in a custom property rather than written straight
21739
+ to background, because the dashed variant below needs the SAME colour
21740
+ inside a gradient. With the colour in background the state rules and the
21741
+ dash pattern would each have to win the same declaration, and whichever
21742
+ came last would erase the other. */
20975
21743
  .connector {
20976
21744
  position: absolute;
20977
- background: var(--rel-bg-neutral-elevated-2);
21745
+ --_rel-stepper-line-color: var(--rel-bg-neutral-elevated-2);
21746
+ background: var(--_rel-stepper-line-color);
20978
21747
  z-index: 1;
20979
21748
  }
20980
21749
 
@@ -20986,7 +21755,6 @@ RelStepper.styles = css39`
20986
21755
  transform: translateY(-50%);
20987
21756
  height: 1px;
20988
21757
  position: absolute;
20989
- background: var(--rel-bg-neutral-elevated-2);
20990
21758
  }
20991
21759
 
20992
21760
  .rel-stepper.horizontal .rel-step:last-child .connector {
@@ -21021,6 +21789,37 @@ RelStepper.styles = css39`
21021
21789
  display: none;
21022
21790
  }
21023
21791
 
21792
+ /* ── connector="dot" (Figma ".Line Types" Type=Dot, set 6437:428) ──
21793
+ Verified against the LIVE Published DS file 2026-09-25: both Dot variants
21794
+ carry RECTANGLE "Line" 1×20 with strokeWeight=1 and strokeDashes=[2,2],
21795
+ i.e. a 1px line, 2px on, 2px off. (The Default variants carry no dashes;
21796
+ the COMPONENT_SET node's own [10,5] is the set frame's border, not a
21797
+ variant value.)
21798
+
21799
+ A repeating gradient rather than a dashed border-style, because the dashed
21800
+ border style is engine-defined — no browser lets you state a 2/2 period,
21801
+ and each one picks its own dash length from the border width. The
21802
+ gradient is exact and stays exact at any device pixel ratio.
21803
+
21804
+ The line thickness still comes from the height/width declared above, so
21805
+ this only replaces the paint. Colour comes through the custom property so
21806
+ done / progress steps dash in their own tone. */
21807
+ :host([connector="dot"]) .rel-stepper.horizontal .connector {
21808
+ background: repeating-linear-gradient(
21809
+ to right,
21810
+ var(--_rel-stepper-line-color) 0 2px,
21811
+ transparent 2px 4px
21812
+ );
21813
+ }
21814
+
21815
+ :host([connector="dot"]) .rel-stepper.vertical .connector {
21816
+ background: repeating-linear-gradient(
21817
+ to bottom,
21818
+ var(--_rel-stepper-line-color) 0 2px,
21819
+ transparent 2px 4px
21820
+ );
21821
+ }
21822
+
21024
21823
  /********************************************
21025
21824
  * Step States
21026
21825
  ********************************************/
@@ -21046,11 +21845,11 @@ RelStepper.styles = css39`
21046
21845
  }
21047
21846
 
21048
21847
  .rel-step.done .connector {
21049
- background: var(--rel-border-strong);
21848
+ --_rel-stepper-line-color: var(--rel-border-strong);
21050
21849
  }
21051
21850
 
21052
21851
  .rel-step.progress .connector {
21053
- background: var(--rel-border-strong);
21852
+ --_rel-stepper-line-color: var(--rel-border-strong);
21054
21853
  }
21055
21854
 
21056
21855
  /* Figma "Content": body/md/medium, text default; Inactive at 40% */
@@ -21136,6 +21935,9 @@ __decorateClass([
21136
21935
  __decorateClass([
21137
21936
  property40({ type: String, reflect: true })
21138
21937
  ], RelStepper.prototype, "type", 2);
21938
+ __decorateClass([
21939
+ property40({ type: String, reflect: true })
21940
+ ], RelStepper.prototype, "connector", 2);
21139
21941
  __decorateClass([
21140
21942
  property40({ type: String, reflect: true, attribute: "label-placement" })
21141
21943
  ], RelStepper.prototype, "labelPlacement", 2);
@@ -21690,6 +22492,13 @@ var RelTab = class extends LitElement43 {
21690
22492
  composed: true
21691
22493
  })
21692
22494
  );
22495
+ this.dispatchEvent(
22496
+ new CustomEvent("rel-change", {
22497
+ detail: index,
22498
+ bubbles: true,
22499
+ composed: true
22500
+ })
22501
+ );
21693
22502
  }
21694
22503
  onKeyDown(event, index) {
21695
22504
  if (event.key === "Enter" || event.key === " ") {
@@ -22071,6 +22880,23 @@ var RelTimePicker = class extends LitElement44 {
22071
22880
  this.onReposition = () => {
22072
22881
  if (this.open) this.updatePopoverPosition();
22073
22882
  };
22883
+ /* Outside scroll CLOSES the panel; it used to reposition. Same change as
22884
+ * rel-date-picker and rel-range-picker, and the pattern rel-select has used
22885
+ * all along — see the long note in rel-date-picker.onPageScroll for why
22886
+ * repositioning is what produced the "panel floats over the sticky header"
22887
+ * report, and why no z-index can fix it.
22888
+ *
22889
+ * This component is the reason the exemption tests CONTAINMENT rather than
22890
+ * equality with the panel: the hour / minute / period columns are
22891
+ * `overflow-y: auto` in their own right (`.column-list`), so a scroll that
22892
+ * must NOT close the panel very often has one of them as its target, not the
22893
+ * panel. Discards the draft, like every other outside dismissal. */
22894
+ this.onPageScroll = (e) => {
22895
+ if (!this.open) return;
22896
+ const panel = this.popoverElement;
22897
+ if (panel && e.composedPath().includes(panel)) return;
22898
+ this.cancelPopover(false);
22899
+ };
22074
22900
  this.updatePopoverPosition = () => {
22075
22901
  const panel = this.popoverElement;
22076
22902
  const anchor = this.triggerButton;
@@ -22130,7 +22956,7 @@ var RelTimePicker = class extends LitElement44 {
22130
22956
  document.removeEventListener("click", this.handleClickOutside);
22131
22957
  document.removeEventListener("keydown", this.handleEscapeKey);
22132
22958
  window.removeEventListener("resize", this.onReposition);
22133
- window.removeEventListener("scroll", this.onReposition, true);
22959
+ window.removeEventListener("scroll", this.onPageScroll, true);
22134
22960
  this.hideTopLayer();
22135
22961
  }
22136
22962
  // `open` is a public reflected property (consumers may set it directly,
@@ -22140,7 +22966,7 @@ var RelTimePicker = class extends LitElement44 {
22140
22966
  if (changedProperties.has("open")) {
22141
22967
  if (!this.open) {
22142
22968
  window.removeEventListener("resize", this.onReposition);
22143
- window.removeEventListener("scroll", this.onReposition, true);
22969
+ window.removeEventListener("scroll", this.onPageScroll, true);
22144
22970
  this.hideTopLayer();
22145
22971
  this.focusPanelOnOpen = true;
22146
22972
  return;
@@ -22148,7 +22974,7 @@ var RelTimePicker = class extends LitElement44 {
22148
22974
  this.resetDraftFromValue();
22149
22975
  this.showTopLayer();
22150
22976
  window.addEventListener("resize", this.onReposition);
22151
- window.addEventListener("scroll", this.onReposition, true);
22977
+ window.addEventListener("scroll", this.onPageScroll, true);
22152
22978
  const focusPanel = this.focusPanelOnOpen;
22153
22979
  this.focusPanelOnOpen = true;
22154
22980
  setTimeout(() => {
@@ -23125,7 +23951,14 @@ RelTimePicker.styles = css42`
23125
23951
  position: absolute;
23126
23952
  top: calc(100% + 8px);
23127
23953
  left: 0;
23128
- z-index: 2147483647;
23954
+ /* Fallback stacking ONLY: while the panel is in the top layer this is
23955
+ inert, because top-layer elements are not in the page stacking order at
23956
+ all and order of promotion decides. It applies solely where the Popover
23957
+ API is unavailable. One overridable hook across every overlay, so a
23958
+ consumer with its own stacking scheme sets it once; the default matches
23959
+ rel-select / rel-autocomplete / rel-popover / rel-menubar. This was
23960
+ 2147483647, which cannot be layered against anything. */
23961
+ z-index: var(--rel-overlay-fallback-z, 1001);
23129
23962
  background: var(--rel-bg-surface);
23130
23963
  border: 1px solid var(--rel-border-subtle);
23131
23964
  border-radius: var(--rel-border-radius-sm);
@@ -24680,7 +25513,11 @@ var RelUpload = class extends LitElement47 {
24680
25513
  }
24681
25514
  renderRow(f) {
24682
25515
  return html47`
24683
- <div class="row ${f.status}">
25516
+ <!-- role="listitem" is required, not decorative: the container at
25517
+ .rows carries role="list", and a list whose children are plain divs
25518
+ fails axe's aria-required-children. It was failing on the uploading,
25519
+ failure, disabled and delayed stories alike. -->
25520
+ <div class="row ${f.status}" role="listitem">
24684
25521
  <rel-icon name="document" size="md"></rel-icon>
24685
25522
 
24686
25523
  <div class="row-content">
@@ -24785,8 +25622,19 @@ RelUpload.styles = css45`
24785
25622
  font-family: var(--rel-fonts-font-family);
24786
25623
  }
24787
25624
 
25625
+ /* not-allowed must live on the HOST, which keeps pointer-events: auto.
25626
+ It used to sit on .dropzone, where it was unreachable: .dropzone is
25627
+ inside .body, .body is pointer-events: none when disabled, and an
25628
+ element that is not a hit target never has its cursor consulted —
25629
+ the cursor resolves from the nearest ancestor that IS one. Measured on
25630
+ a disabled upload before the fix: .dropzone computed not-allowed
25631
+ while the user saw the default arrow. getComputedStyle agreed with the
25632
+ CSS and disagreed with the screen, so anyone re-verifying this must
25633
+ HOVER it rather than read the computed value.
25634
+ rel-button hit the same trap and documents the same resolution. */
24788
25635
  :host([disabled]) {
24789
25636
  opacity: 0.6;
25637
+ cursor: not-allowed;
24790
25638
  }
24791
25639
 
24792
25640
  :host([disabled]) .body {
@@ -24799,7 +25647,6 @@ RelUpload.styles = css45`
24799
25647
  instead of a faded live one. */
24800
25648
  :host([disabled]) .dropzone {
24801
25649
  background: var(--rel-bg-neutral-elevated-2);
24802
- cursor: not-allowed;
24803
25650
  }
24804
25651
 
24805
25652
  .body {