@lessly/ui 2.0.0 → 2.1.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,14 @@ 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
+ * **The selected slot is a tint and a heavier word, and the track draws nothing else.** The
481
+ * `bg-brand-subtle` fill measures 1.104:1 against the page in light and 1.144:1 in dark; the label
482
+ * goes to `text-brand` at 600 against the unselected 500, which is 1.55:1 light and 1.26:1 dark
483
+ * against the unselected word — 1.19:1 and 1.35:1 with hue removed, so the weight step is the one
484
+ * channel that does not depend on seeing colour. There is no edge on the slot and no frame around
485
+ * the set: down a stacked list a frame put a rounded rectangle around every row.
486
486
  */
487
487
  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
488
  treatment?: "track" | "frame" | null | undefined;
@@ -1389,6 +1389,15 @@ interface ExtensionIconProps extends LucideProps {
1389
1389
  /**
1390
1390
  * Renders the lucide-react icon named by a kebab-case manifest id, falling back
1391
1391
  * to the Puzzle glyph (plus a one-time dev warning) for unknown ids.
1392
+ *
1393
+ * A fallback carries `data-icon-fallback="<the unresolved id>"`, in every
1394
+ * environment. That is the contract a consumer gates on — one DOM query in a
1395
+ * snapshot, an E2E assertion, or a grep over built output names both the fact
1396
+ * and the id, without a throw and without a line of production console output:
1397
+ *
1398
+ * ```ts
1399
+ * expect(container.querySelector('[data-icon-fallback]')).toBeNull();
1400
+ * ```
1392
1401
  */
1393
1402
  declare const ExtensionIcon: React$1.ForwardRefExoticComponent<Omit<ExtensionIconProps, "ref"> & React$1.RefAttributes<SVGSVGElement>>;
1394
1403
 
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,8 +909,8 @@ 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) => {
@@ -929,8 +935,10 @@ var ToggleGroup = React16.forwardRef(({ className, variant, size, treatment, sli
929
935
  className: cn(
930
936
  resolved === "track" ? (
931
937
  // `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"
938
+ // measured, the track spanned the full 1400px inside Storybook's column. Nothing is
939
+ // drawn here: no frame, no fill, and so no padding to hold slots off a frame — which is
940
+ // what lets the thumb be exactly `100% / count` wide and travel in multiples of itself.
941
+ "inline-flex w-fit items-center gap-0.5"
934
942
  ) : "flex items-center justify-center gap-1",
935
943
  // A sliding track is as wide as what holds it and its slots share that width equally, because
936
944
  // the thumb is one slot wide and travels in multiples of itself. The gap goes for the same
@@ -960,12 +968,12 @@ var ToggleGroupItem = React16.forwardRef(({ className, children, variant, size,
960
968
  // the height its size declares. A slot is the one place this matters most — the items of a
961
969
  // group sit side by side, so one that is 8px wider than its neighbours is visible.
962
970
  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.
971
+ // Inside a track, the selected slot is also the heavier word: weight is the one channel that
972
+ // survives hue removal, and the tint under it is 1.104:1 light / 1.144:1 dark on its own.
965
973
  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"
974
+ // Under a sliding thumb the tint belongs to the thumb, which is the one that travels; a second
975
+ // copy on the slot would sit still while the first one moved. The word stays the slot's own.
976
+ context.sliding && "relative z-10 flex-1 data-[state=on]:bg-transparent"
969
977
  );
970
978
  if (inert) {
971
979
  return /* @__PURE__ */ jsx20("span", { ref, "aria-hidden": "true", className: cn(box, "invisible", className), children });
@@ -1158,7 +1166,13 @@ var AlertDialogContent = React21.forwardRef(({ className, ...props }, ref) => /*
1158
1166
  {
1159
1167
  ref,
1160
1168
  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",
1169
+ // The gap at both screen edges is an inset, not a narrower width: `inset-x-4 w-auto` sets
1170
+ // the two edges and `mx-auto` centres whatever `max-w-lg` leaves over. Capping the width
1171
+ // instead — a `max-w-[calc(100vw-2rem)]` beside `max-w-lg` — is the same tailwind-merge
1172
+ // group, so it evicts the 512px cap, and a caller's own `max-w-2xl` would take the gap
1173
+ // with it. The 16 is `AppShell`'s own mobile inset (#337), so this edge lands on the edge
1174
+ // of the page behind the overlay.
1175
+ "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
1176
  className
1163
1177
  ),
1164
1178
  ...props
@@ -3426,7 +3440,7 @@ var ExtensionIcon = React51.forwardRef(
3426
3440
  const Icon = resolveIcon(name);
3427
3441
  if (!Icon) {
3428
3442
  warnUnknownIconOnce(name);
3429
- return /* @__PURE__ */ jsx62(Puzzle, { ref, ...props });
3443
+ return /* @__PURE__ */ jsx62(Puzzle, { ref, ...props, "data-icon-fallback": name });
3430
3444
  }
3431
3445
  return /* @__PURE__ */ jsx62(Icon, { ref, ...props });
3432
3446
  }
@@ -6058,25 +6072,15 @@ function ThresholdControl({
6058
6072
  onChange
6059
6073
  }) {
6060
6074
  return /* @__PURE__ */ jsx95(
6061
- "span",
6075
+ ToggleGroup,
6062
6076
  {
6063
- role: "group",
6077
+ type: "single",
6078
+ size: "sm",
6064
6079
  "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
- ))
6080
+ value,
6081
+ onValueChange: (next) => next && onChange(next),
6082
+ className: "shrink-0",
6083
+ children: THRESHOLDS.map(([threshold, text]) => /* @__PURE__ */ jsx95(ToggleGroupItem, { value: threshold, children: text }, threshold))
6080
6084
  }
6081
6085
  );
6082
6086
  }
@@ -6138,20 +6142,18 @@ var NotificationSettings = React79.forwardRef(
6138
6142
  return /* @__PURE__ */ jsxs63("div", { ref, className: cn("space-y-5", className), children: [
6139
6143
  products && products.length > 0 && /* @__PURE__ */ jsxs63("div", { children: [
6140
6144
  /* @__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",
6145
+ /* @__PURE__ */ jsx95(
6146
+ ToggleGroup,
6143
6147
  {
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
- )) })
6148
+ type: "single",
6149
+ size: "sm",
6150
+ "aria-label": "Preferences for",
6151
+ value: tab,
6152
+ onValueChange: (next) => next && setTab(next),
6153
+ className: "flex-wrap",
6154
+ children: [{ id: DEFAULT_KEY, label: "Default" }, ...products].map((t) => /* @__PURE__ */ jsx95(ToggleGroupItem, { value: t.id, children: t.label }, t.id))
6155
+ }
6156
+ )
6155
6157
  ] }),
6156
6158
  isDefault ? /* @__PURE__ */ jsxs63(Fragment11, { children: [
6157
6159
  /* @__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.1.0",
4
4
  "description": "Lessly design system — shared UI primitives, tokens, and theme",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.30.3",