@lessly/ui 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -429,27 +429,25 @@ declare const Slider: React$1.ForwardRefExoticComponent<Omit<SliderPrimitive.Sli
429
429
  /**
430
430
  * Two signals on two channels that never touch (#446), and two treatments for two shapes.
431
431
  *
432
- * **Fill means the pointer is here.** Selection reads as an edge, and `ToggleGroup` picks which
433
- * edge from its `type` the question being "can nothing be selected?".
432
+ * **Fill means the pointer is here except inside a track, where it means "this one".**
433
+ * `ToggleGroup` picks the treatment from its `type`, the question being "can nothing be selected?".
434
434
  *
435
435
  * - `frame` (a standalone `Toggle`, and every `type="multiple"` group): each item carries its own
436
436
  * 1px hairline and selection steps it up to `border-strong` — 5.17:1 against the page in light and
437
- * 5.47:1 in dark, and 3.63:1 / 4.31:1 above the resting hairline it replaces. Fill used to say both
438
- * things at 1.07:1, which is why a filter row could not say which of its filters were applied.
439
- * - `track` (every `type="single"` group): the group draws the track and the selected slot takes the
440
- * selection pair (#452) — `bg-bg-selected` filled inside a `ring-border-selected` edge. The fill is
441
- * 1.08:1 against the page in dark and 1.04:1 in light, so it is the **edge** that carries the
442
- * reading: 3.81:1 above the slot's own fill in dark and 3.75:1 in light, against 1.07:1 / 1.53:1
443
- * for the `border-subtle` ring over `bg-bg-overlay` it replaces. A fill that faint is only placed
444
- * at all against the track's own frame, which is why the track belongs to the component and not to
445
- * the call site.
446
- *
447
- * The mark is a **ring**, not a border: a ring is a box-shadow, so it costs no layout and an
448
- * icon-only toggle stays square at the height its `size` declares. It is also the whole mark —
449
- * `track` carries no `shadow-sm`. The shadow was the raised reading a 1.18:1 fill could not give
450
- * alone; under a 3.81:1 edge it is a second, weaker statement of one state, its blur compositing to
451
- * 1.11:1 against the page in dark and 1.14:1 in light, and `shadow-sm` is the one dark step
452
- * `@lessly/tokens@0.7.0` leaves without a rim, so in dark it can only darken.
437
+ * 5.47:1 in dark, and 3.63:1 / 4.31:1 above the resting hairline it replaces. Nothing is filled but
438
+ * the hovered item, so a filter row still says which of its filters are applied.
439
+ * - `track` (every `type="single"` group): the selected slot is a `bg-brand-subtle` tint with its
440
+ * label in `text-brand` at 600 against the unselected 500. No edge on the slot and no frame around
441
+ * the set. The tint measures 1.104:1 against the page in light and 1.144:1 in dark; the selected
442
+ * word measures 1.55:1 light and 1.26:1 against the unselected word — 1.19:1 and 1.35:1 with hue
443
+ * removed and 5.89:1 light / 5.07:1 dark against the tint it sits on. The weight step is the one
444
+ * channel that survives hue removal.
445
+ *
446
+ * The `frame` mark is a **ring**, not a border: a ring is a box-shadow, so it costs no layout and an
447
+ * icon-only toggle stays square at the height its `size` declares. `track` draws no ring and no
448
+ * `shadow-sm`: `shadow-sm` is the one dark step `@lessly/tokens@0.7.0` leaves without a rim, so in
449
+ * dark it can only darken, and its blur composites to 1.11:1 against the page in dark and 1.14:1 in
450
+ * light.
453
451
  *
454
452
  * Focus is an **outline held 2px off the box** — a flush ring painted over the selection mark, and
455
453
  * focused-selected and focused-unselected measured 1.55:1 apart, which is #446 on the keyboard.
@@ -477,12 +475,18 @@ declare const Toggle: React$1.ForwardRefExoticComponent<Omit<TogglePrimitive.Tog
477
475
  *
478
476
  * `multiple` is a set of independent chips — a filter row, where nothing need be on — so each chip
479
477
  * carries its own edge and selection steps that edge up. `single` is one object with a moving part,
480
- * so it takes the segmented-control treatment (#346): the group draws the **track** — a hairline
481
- * frame with no fill — and the selected slot is the only filled thing in the row.
478
+ * so it takes the segmented-control treatment (#346).
482
479
  *
483
- * The track is the component's, not the call site's: the slot fill measures 1.04:1 against the page
484
- * in light and 1.08:1 in dark, so without the track's edge a selected slot reads as nothing at all —
485
- * the defect #446 reports. A component cannot own half of a two-part signal.
480
+ * A `single` set of **glyphs** is the exception, and it is the treatment's own limit rather than a
481
+ * special case: with no word to thicken the mark would be the tint alone, which greyscale erases.
482
+ * That set takes `frame`, the same as a `multiple` one (#755).
483
+ *
484
+ * **The selected slot is a tint and a heavier word, and the track draws nothing else.** The
485
+ * `bg-brand-subtle` fill measures 1.104:1 against the page in light and 1.144:1 in dark; the label
486
+ * goes to `text-brand` at 600 against the unselected 500, which is 1.55:1 light and 1.26:1 dark
487
+ * against the unselected word — 1.19:1 and 1.35:1 with hue removed, so the weight step is the one
488
+ * channel that does not depend on seeing colour. There is no edge on the slot and no frame around
489
+ * the set: down a stacked list a frame put a rounded rectangle around every row.
486
490
  */
487
491
  declare const ToggleGroup: React$1.ForwardRefExoticComponent<((Omit<ToggleGroupPrimitive.ToggleGroupSingleProps & React$1.RefAttributes<HTMLDivElement>, "ref"> | Omit<ToggleGroupPrimitive.ToggleGroupMultipleProps & React$1.RefAttributes<HTMLDivElement>, "ref">) & VariantProps<(props?: ({
488
492
  treatment?: "track" | "frame" | null | undefined;
@@ -1389,6 +1393,15 @@ interface ExtensionIconProps extends LucideProps {
1389
1393
  /**
1390
1394
  * Renders the lucide-react icon named by a kebab-case manifest id, falling back
1391
1395
  * to the Puzzle glyph (plus a one-time dev warning) for unknown ids.
1396
+ *
1397
+ * A fallback carries `data-icon-fallback="<the unresolved id>"`, in every
1398
+ * environment. That is the contract a consumer gates on — one DOM query in a
1399
+ * snapshot, an E2E assertion, or a grep over built output names both the fact
1400
+ * and the id, without a throw and without a line of production console output:
1401
+ *
1402
+ * ```ts
1403
+ * expect(container.querySelector('[data-icon-fallback]')).toBeNull();
1404
+ * ```
1392
1405
  */
1393
1406
  declare const ExtensionIcon: React$1.ForwardRefExoticComponent<Omit<ExtensionIconProps, "ref"> & React$1.RefAttributes<SVGSVGElement>>;
1394
1407
 
package/dist/index.js CHANGED
@@ -158,7 +158,13 @@ var DialogContent = React3.forwardRef(({ className, children, showClose = true,
158
158
  {
159
159
  ref,
160
160
  className: cn(
161
- "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border-subtle bg-bg-elevated p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
161
+ // The gap at both screen edges is an inset, not a narrower width: `inset-x-4 w-auto` sets
162
+ // the two edges and `mx-auto` centres whatever `max-w-lg` leaves over. Capping the width
163
+ // instead — a `max-w-[calc(100vw-2rem)]` beside `max-w-lg` — is the same tailwind-merge
164
+ // group, so it evicts the 512px cap, and a caller's own `max-w-2xl` would take the gap
165
+ // with it. The 16 is `AppShell`'s own mobile inset (#337), so this edge lands on the edge
166
+ // of the page behind the overlay.
167
+ "fixed inset-x-4 top-[50%] z-50 mx-auto grid w-auto max-w-lg translate-y-[-50%] gap-4 rounded-lg border border-border-subtle bg-bg-elevated p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
162
168
  className
163
169
  ),
164
170
  ...props,
@@ -842,7 +848,7 @@ import * as TogglePrimitive from "@radix-ui/react-toggle";
842
848
  import { cva as cva3 } from "class-variance-authority";
843
849
  import { jsx as jsx19 } from "react/jsx-runtime";
844
850
  var toggleVariants = cva3(
845
- "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium text-text-secondary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-border-focus disabled:pointer-events-none disabled:opacity-50 data-[state=on]:text-text-primary [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
851
+ "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium text-text-secondary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-border-focus disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
846
852
  {
847
853
  variants: {
848
854
  /**
@@ -850,8 +856,8 @@ var toggleVariants = cva3(
850
856
  * lone toggle is by definition something that may be off.
851
857
  */
852
858
  treatment: {
853
- frame: "ring-1 ring-inset ring-border-subtle hover:bg-bg-secondary hover:text-text-primary data-[state=on]:ring-border-strong",
854
- track: "data-[state=off]:hover:bg-bg-secondary data-[state=off]:hover:text-text-primary data-[state=on]:bg-bg-selected data-[state=on]:ring-1 data-[state=on]:ring-inset data-[state=on]:ring-border-selected"
859
+ frame: "ring-1 ring-inset ring-border-subtle hover:bg-bg-secondary hover:text-text-primary data-[state=on]:text-text-primary data-[state=on]:ring-border-strong",
860
+ track: "data-[state=off]:hover:text-text-primary data-[state=on]:bg-bg-brand-subtle data-[state=on]:text-text-brand"
855
861
  },
856
862
  variant: {
857
863
  default: "bg-transparent",
@@ -903,17 +909,18 @@ var SlidingThumb = ({ index, count }) => /* @__PURE__ */ jsx20(
903
909
  "span",
904
910
  {
905
911
  "aria-hidden": "true",
906
- className: "motion-segment-thumb pointer-events-none absolute inset-y-0.5 left-0.5 rounded-md border border-border-selected bg-bg-selected",
907
- style: { width: `calc((100% - 0.25rem) / ${count})`, transform: `translateX(${index * 100}%)` }
912
+ className: "motion-segment-thumb pointer-events-none absolute inset-y-0 left-0 rounded-md bg-bg-brand-subtle",
913
+ style: { width: `calc(100% / ${count})`, transform: `translateX(${index * 100}%)` }
908
914
  }
909
915
  );
910
916
  var ToggleGroup = React16.forwardRef(({ className, variant, size, treatment, sliding, children, ...props }, ref) => {
911
- const resolved = treatment ?? (props.type === "single" ? "track" : "frame");
917
+ const slots = React16.Children.toArray(children).filter(React16.isValidElement);
918
+ const wordless = slots.length > 0 && slots.every((slot) => isIconOnly(slot.props.children));
919
+ const resolved = treatment ?? (props.type === "single" && !wordless ? "track" : "frame");
912
920
  const [mirror, setMirror] = React16.useState(
913
921
  () => typeof props.defaultValue === "string" ? props.defaultValue : void 0
914
922
  );
915
923
  const selected = typeof props.value === "string" ? props.value : mirror;
916
- const slots = React16.Children.toArray(children).filter(React16.isValidElement);
917
924
  const index = slots.findIndex((slot) => slot.props.value === selected);
918
925
  const rootProps = sliding && typeof props.value !== "string" ? {
919
926
  ...props,
@@ -929,13 +936,18 @@ var ToggleGroup = React16.forwardRef(({ className, variant, size, treatment, sli
929
936
  className: cn(
930
937
  resolved === "track" ? (
931
938
  // `w-fit` because a flex or grid parent blockifies `inline-flex` and then stretches it:
932
- // measured, the track spanned the full 1400px inside Storybook's column.
933
- "inline-flex w-fit items-center gap-0.5 rounded-lg border border-border-subtle bg-transparent p-0.5"
939
+ // measured, the track spanned the full 1400px inside Storybook's column. Nothing is
940
+ // drawn here: no frame, no fill, and so no padding to hold slots off a frame — which is
941
+ // what lets the thumb be exactly `100% / count` wide and travel in multiples of itself.
942
+ "inline-flex w-fit items-center gap-0.5"
934
943
  ) : "flex items-center justify-center gap-1",
935
- // A sliding track is as wide as what holds it and its slots share that width equally, because
936
- // the thumb is one slot wide and travels in multiples of itself. The gap goes for the same
937
- // reason: a gap between slots is width the thumb's own step does not account for.
938
- sliding && "relative flex w-full gap-0",
944
+ // A sliding track's slots share its width equally, because the thumb is one slot wide and
945
+ // travels in multiples of itself. The gap goes for the same reason: a gap between slots is
946
+ // width the thumb's own step does not account for. The track sizes to those slots rather
947
+ // than to whatever holds it — given a parent and no width of its own it used to span the
948
+ // whole of it, and with no frame drawn the slots then read as loose words with a tint
949
+ // somewhere in them (#754). A caller wanting full width writes one, as every call site did.
950
+ sliding && "relative flex w-fit gap-0",
939
951
  className
940
952
  ),
941
953
  ...rootProps,
@@ -960,12 +972,12 @@ var ToggleGroupItem = React16.forwardRef(({ className, children, variant, size,
960
972
  // the height its size declares. A slot is the one place this matters most — the items of a
961
973
  // group sit side by side, so one that is 8px wider than its neighbours is visible.
962
974
  isIconOnly(children) && ICON_ONLY_BOX,
963
- // Inside a track, the selected slot is also the heavier word: weight is the one channel left
964
- // once colour marks actions and the fill measures barely above the page.
975
+ // Inside a track, the selected slot is also the heavier word: weight is the one channel that
976
+ // survives hue removal, and the tint under it is 1.104:1 light / 1.144:1 dark on its own.
965
977
  context.treatment === "track" && "data-[state=on]:font-semibold",
966
- // Under a sliding thumb the slot draws neither half of the selection pair the thumb is both,
967
- // and a second copy of it would sit still while the first one travelled.
968
- context.sliding && "relative z-10 flex-1 data-[state=on]:bg-transparent data-[state=on]:ring-0"
978
+ // Under a sliding thumb the tint belongs to the thumb, which is the one that travels; a second
979
+ // copy on the slot would sit still while the first one moved. The word stays the slot's own.
980
+ context.sliding && "relative z-10 flex-1 data-[state=on]:bg-transparent"
969
981
  );
970
982
  if (inert) {
971
983
  return /* @__PURE__ */ jsx20("span", { ref, "aria-hidden": "true", className: cn(box, "invisible", className), children });
@@ -1158,7 +1170,13 @@ var AlertDialogContent = React21.forwardRef(({ className, ...props }, ref) => /*
1158
1170
  {
1159
1171
  ref,
1160
1172
  className: cn(
1161
- "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border-subtle bg-bg-elevated p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
1173
+ // The gap at both screen edges is an inset, not a narrower width: `inset-x-4 w-auto` sets
1174
+ // the two edges and `mx-auto` centres whatever `max-w-lg` leaves over. Capping the width
1175
+ // instead — a `max-w-[calc(100vw-2rem)]` beside `max-w-lg` — is the same tailwind-merge
1176
+ // group, so it evicts the 512px cap, and a caller's own `max-w-2xl` would take the gap
1177
+ // with it. The 16 is `AppShell`'s own mobile inset (#337), so this edge lands on the edge
1178
+ // of the page behind the overlay.
1179
+ "fixed inset-x-4 top-[50%] z-50 mx-auto grid w-auto max-w-lg translate-y-[-50%] gap-4 rounded-lg border border-border-subtle bg-bg-elevated p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
1162
1180
  className
1163
1181
  ),
1164
1182
  ...props
@@ -3426,7 +3444,7 @@ var ExtensionIcon = React51.forwardRef(
3426
3444
  const Icon = resolveIcon(name);
3427
3445
  if (!Icon) {
3428
3446
  warnUnknownIconOnce(name);
3429
- return /* @__PURE__ */ jsx62(Puzzle, { ref, ...props });
3447
+ return /* @__PURE__ */ jsx62(Puzzle, { ref, ...props, "data-icon-fallback": name });
3430
3448
  }
3431
3449
  return /* @__PURE__ */ jsx62(Icon, { ref, ...props });
3432
3450
  }
@@ -6058,25 +6076,15 @@ function ThresholdControl({
6058
6076
  onChange
6059
6077
  }) {
6060
6078
  return /* @__PURE__ */ jsx95(
6061
- "span",
6079
+ ToggleGroup,
6062
6080
  {
6063
- role: "group",
6081
+ type: "single",
6082
+ size: "sm",
6064
6083
  "aria-label": `Interrupt me for ${label}`,
6065
- className: "inline-flex shrink-0 rounded-lg border border-border-subtle bg-transparent p-0.5",
6066
- children: THRESHOLDS.map(([threshold, text]) => /* @__PURE__ */ jsx95(
6067
- "button",
6068
- {
6069
- type: "button",
6070
- "aria-pressed": value === threshold,
6071
- onClick: () => onChange(threshold),
6072
- className: cn(
6073
- "rounded-[7px] border border-transparent px-2.5 py-1 text-sm transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-border-focus",
6074
- value === threshold ? "border-border-selected bg-bg-selected font-semibold text-text-primary" : "text-text-secondary hover:text-text-primary"
6075
- ),
6076
- children: text
6077
- },
6078
- threshold
6079
- ))
6084
+ value,
6085
+ onValueChange: (next) => next && onChange(next),
6086
+ className: "shrink-0",
6087
+ children: THRESHOLDS.map(([threshold, text]) => /* @__PURE__ */ jsx95(ToggleGroupItem, { value: threshold, children: text }, threshold))
6080
6088
  }
6081
6089
  );
6082
6090
  }
@@ -6138,20 +6146,18 @@ var NotificationSettings = React79.forwardRef(
6138
6146
  return /* @__PURE__ */ jsxs63("div", { ref, className: cn("space-y-5", className), children: [
6139
6147
  products && products.length > 0 && /* @__PURE__ */ jsxs63("div", { children: [
6140
6148
  /* @__PURE__ */ jsx95("p", { className: "mb-2 px-0.5 text-xs font-bold uppercase tracking-wider text-text-tertiary", children: "Preferences for" }),
6141
- /* @__PURE__ */ jsx95("div", { className: "flex flex-wrap gap-2", children: [{ id: DEFAULT_KEY, label: "Default" }, ...products].map((t) => /* @__PURE__ */ jsx95(
6142
- "button",
6149
+ /* @__PURE__ */ jsx95(
6150
+ ToggleGroup,
6143
6151
  {
6144
- type: "button",
6145
- "aria-pressed": tab === t.id,
6146
- onClick: () => setTab(t.id),
6147
- className: cn(
6148
- "rounded-lg border border-transparent px-3 py-1.5 text-sm transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-border-focus",
6149
- tab === t.id ? "border-border-selected bg-bg-selected font-semibold text-text-primary" : "bg-bg-elevated text-text-secondary hover:text-text-primary"
6150
- ),
6151
- children: t.label
6152
- },
6153
- t.id
6154
- )) })
6152
+ type: "single",
6153
+ size: "sm",
6154
+ "aria-label": "Preferences for",
6155
+ value: tab,
6156
+ onValueChange: (next) => next && setTab(next),
6157
+ className: "flex-wrap",
6158
+ children: [{ id: DEFAULT_KEY, label: "Default" }, ...products].map((t) => /* @__PURE__ */ jsx95(ToggleGroupItem, { value: t.id, children: t.label }, t.id))
6159
+ }
6160
+ )
6155
6161
  ] }),
6156
6162
  isDefault ? /* @__PURE__ */ jsxs63(Fragment11, { children: [
6157
6163
  /* @__PURE__ */ jsx95("p", { className: "text-sm leading-relaxed text-text-secondary", children: "Your defaults, applied to every product unless you customize one. Everything addressed to you always lands in the in-app record; that cannot be turned off." }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessly/ui",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "Lessly design system — shared UI primitives, tokens, and theme",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.30.3",