@lessly/ui 2.3.1 → 4.0.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/router.d.ts CHANGED
@@ -54,28 +54,38 @@ interface SidebarNavProps {
54
54
  isItemActive?: (item: NavItem, pathname: string) => boolean;
55
55
  className?: string;
56
56
  }
57
+ /**
58
+ * The rail's rows, and the part of it that reads the router: it takes `useLocation`, marks the row
59
+ * whose path is open, and renders each through `LinkComponent`. That import is why it and
60
+ * `AppSidebar` ship from `@lessly/ui/router` rather than the main barrel.
61
+ *
62
+ * **Beside `AppSidebar`.** `AppSidebar` is the rail itself — the identity slot above the rows, the
63
+ * collapse toggle at the foot, the dividers and the two widths — and it renders this component for
64
+ * the rows in the middle. Take `AppSidebar` for the console's rail; take `SidebarNav`
65
+ * when the surrounding chrome is yours and only the rows and their active-path rule are not, or
66
+ * when one rail carries a second list. Either way the row is `NavRow`, so the two cannot drift.
67
+ */
57
68
  declare function SidebarNav({ items, collapsed, basePath, LinkComponent, isItemActive, className, }: SidebarNavProps): React.JSX.Element;
58
69
 
59
70
  interface AppSidebarProps {
60
71
  items: NavItem[];
61
- /** Expanded-slot mark/wordmark, rendered when `header` is absent. Also the collapsed-rail
62
- * fallback when `logoCollapsed` isn't given, on a rail that isn't `collapsible` — see
63
- * `logoCollapsed`. */
72
+ /** Expanded-slot mark/wordmark, rendered when `header` is absent. Also the narrow rail's fallback
73
+ * when `logoCollapsed` isn't given. */
64
74
  logo?: React.ReactNode;
65
- /** Collapsed-rail identity — a small mark, for a rail that cannot be expanded from inside it.
66
- * A `collapsible` rail shows the pin here instead and never reads this: the 52px rail holds
67
- * one box, and the way back to expanded is what it holds. */
75
+ /** Narrow-rail identity — a small mark, for the width where `header` has no room to be read.
76
+ * Falls back to `logo`. */
68
77
  logoCollapsed?: React.ReactNode;
69
78
  /** Identity slot above the rail — e.g. an <OrgProductSwitcher/> (#266). Falls back to `logo`.
70
- * Expanded-only: rendering it in the 52px collapsed rail would overflow, so the collapsed
71
- * branch never reads it. What stands in depends on `collapsible` the pin when it is set,
72
- * `logoCollapsed ?? logo` when it is not. */
79
+ * Expanded-only: `logoCollapsed ?? logo` stands in it at the narrow width, because a switcher
80
+ * clipped to the rail's own width reads as a tile with a severed word beside it. */
73
81
  header?: React.ReactNode;
74
- /** Account slot below the rail — e.g. a <UserMenu/>. Its divider renders only with it. */
75
- footer?: React.ReactNode;
76
- /** Render the built-in collapse pin. Off by default: the shipped rail has none.
77
- * `collapsed` is still honoured without it, for consumers driving collapse themselves. */
78
- collapsible?: boolean;
82
+ /** Top of the rail — e.g. a <QuickSearchRow/>. It sits between the header's rule and the rows,
83
+ * and it is pinned: outside the scroll region, so it holds one place while the rows below it
84
+ * scroll. No rule of its own what is above it belongs to the organization and what is below
85
+ * is the rail, while this is the rail's own first row, and a line would file it elsewhere.
86
+ * Unlike `header` it is drawn at both widths, so it takes a function to read `collapsed` with —
87
+ * the state a caller has no other way to reach when the rail drives its own collapse. */
88
+ railTop?: React.ReactNode | ((collapsed: boolean) => React.ReactNode);
79
89
  collapsed?: boolean;
80
90
  defaultCollapsed?: boolean;
81
91
  onCollapsedChange?: (collapsed: boolean) => void;
@@ -84,11 +94,11 @@ interface AppSidebarProps {
84
94
  isItemActive?: (item: NavItem, pathname: string) => boolean;
85
95
  className?: string;
86
96
  }
87
- declare function AppSidebar({ items, logo, logoCollapsed, header, footer, collapsible, collapsed: controlled, defaultCollapsed, onCollapsedChange, basePath, LinkComponent, isItemActive, className, }: AppSidebarProps): React.JSX.Element;
97
+ declare function AppSidebar({ items, logo, logoCollapsed, header, railTop, collapsed: controlled, defaultCollapsed, onCollapsedChange, basePath, LinkComponent, isItemActive, className, }: AppSidebarProps): React.JSX.Element;
88
98
 
89
99
  interface AppShellProps {
90
- /** The sidebar, typically an <AppSidebar/>. Rendered in the desktop rail and,
91
- * on mobile, inside a Sheet (force-expanded via cloneElement collapsed=false). */
100
+ /** The sidebar, typically an <AppSidebar/>. Rendered in the desktop rail and, on mobile, inside a
101
+ * Sheet where an <AppSidebar/> at any depth draws the drawer's shape rather than the rail's. */
92
102
  sidebar: React.ReactNode;
93
103
  topBar?: React.ReactNode;
94
104
  children?: React.ReactNode;
package/dist/router.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import {
2
2
  Button,
3
3
  NavRow,
4
+ RAIL_COLUMN_BOX,
5
+ RailToggle,
4
6
  Sheet,
5
7
  SheetContent,
6
8
  SheetDescription,
@@ -13,7 +15,7 @@ import {
13
15
  cn,
14
16
  useIsMobile,
15
17
  useSidebar
16
- } from "./chunk-CEXND5YW.js";
18
+ } from "./chunk-DYHP7W3X.js";
17
19
 
18
20
  // src/components/extension-link.tsx
19
21
  import { Link } from "react-router";
@@ -52,7 +54,7 @@ function SidebarNavLink({
52
54
  label: item.label,
53
55
  hideLabel: collapsed,
54
56
  icon: Icon ? /* @__PURE__ */ jsx2(Icon, { "aria-hidden": "true" }) : void 0,
55
- className: cn(collapsed && "size-9 justify-center px-0 py-0", className),
57
+ className,
56
58
  asChild: true,
57
59
  children: /* @__PURE__ */ jsx2(Comp, { to, "aria-current": active ? "page" : void 0 })
58
60
  }
@@ -72,57 +74,47 @@ function SidebarNav({
72
74
  className
73
75
  }) {
74
76
  const { pathname } = useLocation();
75
- const nav = /* @__PURE__ */ jsx2(
76
- "nav",
77
- {
78
- className: cn(
79
- "flex flex-col gap-2",
80
- collapsed && "items-center",
81
- className
82
- ),
83
- children: items.map((item) => {
84
- const full = `${basePath}${item.href}`;
85
- const active = isItemActive ? isItemActive(item, pathname) : isActivePath(pathname, full, item.exact);
86
- return /* @__PURE__ */ jsx2(
87
- SidebarNavLink,
88
- {
89
- item,
90
- active,
91
- collapsed,
92
- basePath,
93
- LinkComponent
94
- },
95
- item.href
96
- );
97
- })
98
- }
77
+ const nav = (
78
+ // One layout at both widths. A row is `w-full` and its glyph sits at the row's own left
79
+ // padding, so the column a mark stands on is a fact about the rail around these rows and not
80
+ // about their width — a narrow box of their own is what took the glyph off that column.
81
+ /* @__PURE__ */ jsx2("nav", { className: cn("flex flex-col gap-2", className), children: items.map((item) => {
82
+ const full = `${basePath}${item.href}`;
83
+ const active = isItemActive ? isItemActive(item, pathname) : isActivePath(pathname, full, item.exact);
84
+ return /* @__PURE__ */ jsx2(
85
+ SidebarNavLink,
86
+ {
87
+ item,
88
+ active,
89
+ collapsed,
90
+ basePath,
91
+ LinkComponent
92
+ },
93
+ item.href
94
+ );
95
+ }) })
99
96
  );
100
97
  return collapsed ? /* @__PURE__ */ jsx2(TooltipProvider, { children: nav }) : nav;
101
98
  }
102
99
 
103
100
  // src/components/app-sidebar.tsx
104
- import { PanelLeft } from "lucide-react";
101
+ import * as React from "react";
105
102
  import { Fragment, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
106
- function Divider({ collapsed, testId, className }) {
107
- return /* @__PURE__ */ jsx3(
108
- "div",
109
- {
110
- "data-testid": testId,
111
- className: cn(
112
- "h-px shrink-0 bg-border-subtle",
113
- collapsed ? "ml-[6px] w-[40px]" : "ml-[14px] w-[220px]",
114
- className
115
- )
116
- }
117
- );
103
+ var SidebarDrawerContext = React.createContext(false);
104
+ function SidebarDrawer({ children }) {
105
+ return /* @__PURE__ */ jsx3(SidebarDrawerContext.Provider, { value: true, children });
106
+ }
107
+ function Divider({ testId, className }) {
108
+ return /* @__PURE__ */ jsx3("div", { "data-testid": testId, className: cn("h-px shrink-0 bg-border-subtle", className) });
118
109
  }
110
+ var RAIL_WIDE = "w-[248px]";
111
+ var RAIL_NARROW = "w-[64px]";
119
112
  function AppSidebar({
120
113
  items,
121
114
  logo,
122
115
  logoCollapsed,
123
116
  header,
124
- footer,
125
- collapsible = false,
117
+ railTop,
126
118
  collapsed: controlled,
127
119
  defaultCollapsed,
128
120
  onCollapsedChange,
@@ -131,89 +123,69 @@ function AppSidebar({
131
123
  isItemActive,
132
124
  className
133
125
  }) {
134
- const { collapsed, toggle } = useSidebar({
126
+ const drawer = React.useContext(SidebarDrawerContext);
127
+ const { collapsed: railCollapsed, toggle } = useSidebar({
135
128
  collapsed: controlled,
136
129
  defaultCollapsed,
137
130
  onCollapsedChange
138
131
  });
139
- const pin = collapsible ? /* @__PURE__ */ jsx3(
140
- "button",
141
- {
142
- type: "button",
143
- onClick: toggle,
144
- "aria-label": collapsed ? "Expand sidebar" : "Collapse sidebar",
145
- "aria-expanded": !collapsed,
146
- className: "flex size-6 shrink-0 items-center justify-center rounded-md text-text-secondary transition-colors duration-fast hover:bg-[var(--hov-bg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-focus",
147
- children: /* @__PURE__ */ jsx3(PanelLeft, { className: "size-4", "aria-hidden": "true" })
148
- }
149
- ) : null;
150
- return /* @__PURE__ */ jsxs2(
132
+ const collapsed = drawer ? false : railCollapsed;
133
+ const railTopNode = typeof railTop === "function" ? railTop(collapsed) : railTop;
134
+ return /* @__PURE__ */ jsx3(
151
135
  "aside",
152
136
  {
153
137
  "data-collapsed": collapsed,
154
138
  className: cn(
155
- "flex h-full shrink-0 flex-col overflow-hidden transition-[width] duration-200 ease-in-out",
156
- collapsed ? "w-[52px]" : "w-[248px]",
139
+ "flex h-full shrink-0 flex-col overflow-hidden motion-rail-collapse",
140
+ // A nav row drops its own label at the narrow width. What this fades is a label whatever
141
+ // stands in `railTop` keeps mounted at both — QuickSearchRow's, for one — so the closing
142
+ // edge slides over the words instead of cutting through them.
143
+ "[&_[data-slot=nav-row-label]]:transition-opacity [&_[data-slot=nav-row-label]]:duration-fast",
144
+ collapsed ? RAIL_NARROW : RAIL_WIDE,
145
+ collapsed && "[&_[data-slot=nav-row-label]]:opacity-0",
157
146
  className
158
147
  ),
159
- children: [
160
- /* @__PURE__ */ jsx3("div", { className: cn("flex h-14 items-center gap-2", collapsed ? "justify-center px-1" : "px-3"), children: collapsed ? collapsible ? (
161
- /* The 52px rail holds one 32px box, and the way back to expanded is what it holds.
162
- The logo has the expanded band and the top bar; the pin has nowhere else, and a
163
- pin hidden behind the mark is no pin at all on a device that cannot hover. */
164
- /* @__PURE__ */ jsx3(
165
- "button",
166
- {
167
- type: "button",
168
- onClick: toggle,
169
- "aria-label": "Expand sidebar",
170
- "aria-expanded": false,
171
- className: "flex size-8 shrink-0 items-center justify-center rounded-md text-text-secondary transition-colors duration-fast hover:bg-[var(--hov-bg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-focus",
172
- children: /* @__PURE__ */ jsx3(PanelLeft, { className: "size-4", "aria-hidden": "true" })
173
- }
174
- )
175
- ) : logoCollapsed ?? logo : /* @__PURE__ */ jsxs2(Fragment, { children: [
176
- header ?? logo,
177
- pin
178
- ] }) }),
179
- /* @__PURE__ */ jsx3(Divider, { collapsed, testId: "sidebar-divider-header" }),
148
+ children: /* @__PURE__ */ jsxs2("div", { className: cn("flex h-full shrink-0 flex-col", RAIL_WIDE), children: [
149
+ /* @__PURE__ */ jsx3("div", { className: cn("flex h-14 shrink-0 items-center gap-2", drawer ? "pl-3 pr-10" : "px-3"), children: collapsed ? /* @__PURE__ */ jsx3("div", { className: RAIL_COLUMN_BOX, children: logoCollapsed ?? logo }) : header ?? logo }),
150
+ /* @__PURE__ */ jsx3(Divider, { testId: "sidebar-divider-header" }),
151
+ railTopNode && /* @__PURE__ */ jsx3("div", { "data-testid": "sidebar-rail-top", className: "shrink-0 px-3 pt-3", children: railTopNode }),
180
152
  /* @__PURE__ */ jsx3(
181
153
  "div",
182
154
  {
183
155
  "data-testid": "sidebar-scroll",
184
156
  className: cn(
185
- "min-h-0 flex-1 overflow-y-auto overflow-x-hidden pt-3 pb-2",
186
- collapsed ? "px-1" : "px-3"
157
+ "min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 pb-2",
158
+ railTopNode ? "pt-2" : "pt-3"
187
159
  ),
188
160
  children: /* @__PURE__ */ jsx3(SidebarNav, { items, collapsed, basePath, LinkComponent, isItemActive })
189
161
  }
190
162
  ),
191
- footer && /* @__PURE__ */ jsxs2(Fragment, { children: [
192
- /* @__PURE__ */ jsx3(Divider, { collapsed, testId: "sidebar-divider-account" }),
193
- /* @__PURE__ */ jsx3("div", { className: cn("py-2", collapsed ? "px-1" : "px-3"), children: footer })
163
+ !drawer && /* @__PURE__ */ jsxs2(Fragment, { children: [
164
+ /* @__PURE__ */ jsx3(Divider, { testId: "sidebar-divider-foot" }),
165
+ /* @__PURE__ */ jsx3("div", { className: "flex shrink-0 px-3 py-2", children: /* @__PURE__ */ jsx3(RailToggle, { collapsed, onClick: toggle }) })
194
166
  ] })
195
- ]
167
+ ] })
196
168
  }
197
169
  );
198
170
  }
199
171
 
200
172
  // src/components/app-shell.tsx
201
- import * as React from "react";
173
+ import * as React2 from "react";
202
174
  import { Menu } from "lucide-react";
203
175
  import { useLocation as useLocation2 } from "react-router";
204
- import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
176
+ import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
205
177
  function expandedSidebar(sidebar) {
206
- return React.isValidElement(sidebar) ? React.cloneElement(sidebar, { collapsed: false }) : sidebar;
178
+ return React2.isValidElement(sidebar) ? React2.cloneElement(sidebar, { collapsed: false }) : sidebar;
207
179
  }
208
180
  function AppShell({ sidebar, topBar, children, className }) {
209
181
  const { pathname } = useLocation2();
210
- const [mobileOpen, setMobileOpen] = React.useState(false);
211
- React.useEffect(() => {
182
+ const [mobileOpen, setMobileOpen] = React2.useState(false);
183
+ React2.useEffect(() => {
212
184
  setMobileOpen(false);
213
185
  }, [pathname]);
214
186
  const isMobile = useIsMobile();
215
187
  if (isMobile) {
216
- return /* @__PURE__ */ jsx4(TooltipProvider, { children: /* @__PURE__ */ jsxs3("div", { className: cn("flex min-h-screen flex-col bg-bg-surface", className), children: [
188
+ return /* @__PURE__ */ jsx4(TooltipProvider, { children: /* @__PURE__ */ jsxs3("div", { className: cn("flex min-h-screen flex-col bg-bg-primary", className), children: [
217
189
  /* @__PURE__ */ jsxs3(
218
190
  "header",
219
191
  {
@@ -222,10 +194,10 @@ function AppShell({ sidebar, topBar, children, className }) {
222
194
  children: [
223
195
  /* @__PURE__ */ jsxs3(Sheet, { open: mobileOpen, onOpenChange: setMobileOpen, children: [
224
196
  /* @__PURE__ */ jsx4(SheetTrigger, { asChild: true, children: /* @__PURE__ */ jsx4(Button, { variant: "ghost", size: "icon", className: "size-8 text-text-secondary", "aria-label": "Open navigation", children: /* @__PURE__ */ jsx4(Menu, { className: "size-5" }) }) }),
225
- /* @__PURE__ */ jsxs3(SheetContent, { side: "left", className: "w-[248px] bg-bg-surface p-0", children: [
197
+ /* @__PURE__ */ jsxs3(SheetContent, { side: "left", className: "w-[248px] bg-bg-primary p-0", children: [
226
198
  /* @__PURE__ */ jsx4(SheetTitle, { className: "sr-only", children: "Navigation" }),
227
199
  /* @__PURE__ */ jsx4(SheetDescription, { className: "sr-only", children: "Application navigation menu" }),
228
- expandedSidebar(sidebar)
200
+ /* @__PURE__ */ jsx4(SidebarDrawer, { children: expandedSidebar(sidebar) })
229
201
  ] })
230
202
  ] }),
231
203
  /* @__PURE__ */ jsx4("div", { className: "flex items-center gap-2", children: topBar })
@@ -235,10 +207,13 @@ function AppShell({ sidebar, topBar, children, className }) {
235
207
  /* @__PURE__ */ jsx4("main", { className: "flex-1 overflow-auto px-4", children })
236
208
  ] }) });
237
209
  }
238
- return /* @__PURE__ */ jsx4(TooltipProvider, { children: /* @__PURE__ */ jsxs3("div", { className: cn("flex min-h-screen gap-3 bg-bg-surface p-4", className), children: [
239
- /* @__PURE__ */ jsx4("div", { className: "sticky top-4 h-[calc(100vh-32px)] shrink-0", children: sidebar }),
210
+ return /* @__PURE__ */ jsx4(TooltipProvider, { children: /* @__PURE__ */ jsxs3("div", { className: cn("flex min-h-screen bg-bg-primary", className), children: [
211
+ /* @__PURE__ */ jsx4("div", { className: "sticky top-0 h-screen shrink-0 border-r border-border-subtle", children: sidebar }),
240
212
  /* @__PURE__ */ jsxs3("div", { className: "flex min-w-0 flex-1 flex-col overflow-hidden", children: [
241
- topBar && /* @__PURE__ */ jsx4("div", { "data-slot": "app-shell-topbar", className: "flex h-14 shrink-0 items-center gap-2 px-6", children: topBar }),
213
+ topBar && /* @__PURE__ */ jsxs3(Fragment2, { children: [
214
+ /* @__PURE__ */ jsx4("div", { "data-slot": "app-shell-topbar", className: "flex h-14 shrink-0 items-center gap-2 px-6", children: topBar }),
215
+ /* @__PURE__ */ jsx4("div", { "data-slot": "app-shell-topbar-rule", className: "h-px shrink-0 bg-border-subtle" })
216
+ ] }),
242
217
  /* @__PURE__ */ jsx4("main", { className: "flex-1 overflow-y-auto px-6", children })
243
218
  ] })
244
219
  ] }) });
@@ -535,8 +535,8 @@
535
535
  /* A semantic token is a whole colour with no `<alpha-value>` placeholder, so `bg-bg-secondary/50`
536
536
  compiles to nothing on Tailwind 3 while Tailwind 4 answers it with color-mix. These are the
537
537
  three alphas the kit writes, as variables the preset maps a colour name onto so both majors
538
- and both consumption paths resolve them. Like --hov-bg below, the inner var() resolves at the
539
- element that paints, so `.light` needs no override. Delete each the release @lessly/tokens
538
+ and both consumption paths resolve them. The inner var() resolves on the element `:root` and
539
+ `.light` both match, so `.light` needs no override. Delete each the release @lessly/tokens
540
540
  publishes its semantic colours with an `<alpha-value>` placeholder. */
541
541
  --bg-secondary-a50: color-mix(in oklab, var(--bg-secondary) 50%, transparent);
542
542
  --bg-secondary-a80: color-mix(in oklab, var(--bg-secondary) 80%, transparent);
@@ -545,8 +545,32 @@
545
545
  /* === Nav / menu surfaces (0.3.0 sidebar) === */
546
546
  /* The floating surface sits on the package's overlay pair: --menu-bg on
547
547
  --bg-overlay, --menu-shadow on --shadow-overlay. theme.css re-resolves both
548
- per theme, so `.light` needs no override for either. */
549
- --hov-bg: color-mix(in srgb, var(--text-primary) 7%, transparent);
548
+ per theme, so `.light` needs no override for either.
549
+
550
+ The pointer contrasts with whatever it lands on, and the direction flips by itself: this is a
551
+ wash of --text-primary, which is light ink in dark and dark ink in light, so one declaration
552
+ lightens on a dark ground and darkens on a light one. A wash rather than a rung because it is
553
+ ground-relative by construction — the same 4% reads on the page, a rail, a card, a menu and a
554
+ nested card alike, where every opaque step fails on at least one of them (--bg-secondary is a
555
+ nested card's own fill, so a hover painted with it disappears there). Measured: dark 1.099:1
556
+ up from the page and 1.117:1 from a card; light 1.083:1 down from the page and 1.090:1 from a
557
+ card (Guidelines/Look/Surface tone).
558
+
559
+ --current-bg is the plate a resting selection takes, and it is the end of the ladder the ink
560
+ points at: the wash above travels in the ink's direction, so the current row is the last rung
561
+ that way and the four states of a rail row read as one walk. Dark ink is light, so the top,
562
+ --bg-overlay, 1.184:1 up from the page. Light ink is dark, so the bottom, --bg-sunken,
563
+ 1.208:1 down. Two declarations because the two ends are two different tokens, not because the
564
+ rule differs.
565
+
566
+ --hov-current is what the pointer paints on the row that already holds that plate, and the 4%
567
+ cap is why it has to exist: in dark the whole gap between the page and the plate is 1.184:1,
568
+ so a hover step big enough to be seen ON the plate would overshoot it on the ground. Same
569
+ rule, one layer up — the wash taken over --current-bg rather than over the ground, which is
570
+ also what carries the pointer past a rung the ladder has no successor for. */
571
+ --hov-bg: color-mix(in srgb, var(--text-primary) 4%, transparent);
572
+ --current-bg: var(--bg-overlay);
573
+ --hov-current: color-mix(in srgb, var(--text-primary) 4%, var(--current-bg));
550
574
  --menu-bg: var(--bg-overlay);
551
575
  --menu-shadow: var(--shadow-overlay);
552
576
  --menu-hov: var(--hov-bg);
@@ -565,11 +589,10 @@
565
589
  }
566
590
 
567
591
  :where(.light) {
568
- /* Light keeps opaque hover plates: a translucent text-mix would vanish on a
569
- white menu. Pinned 0.3.0-prototype literalsthe package has no hover-surface
570
- roles yet, so these can't move onto tokens the way --menu-bg did. */
571
- --hov-bg: #ffffff;
572
- --menu-hov: #f2f4f7;
592
+
593
+ /* The other end of the ladder see --current-bg in :root above. --hov-current is declared once
594
+ and reads this, so the pointer's step off the plate follows without a second copy. */
595
+ --current-bg: var(--bg-sunken);
573
596
 
574
597
  /* Auth screen — see :root above. */
575
598
  --auth-glow-a: rgba(11, 76, 213, 0.09);
@@ -604,6 +627,19 @@
604
627
  transition-timing-function: var(--motion-easing-standard);
605
628
  }
606
629
 
630
+ /* Backs AppSidebar's collapse: the rail's width, and the bar in the toggle's mark that crosses the
631
+ frame as it travels. One class on both, so the mark cannot be retuned away from the edge it
632
+ answers for — the same reason two carets read one `caretMotion`. `transform` is here rather than
633
+ in a class of its own because the bar is that edge said in miniature: retune one and the two stop
634
+ arriving together. A real class for the reason the two above are, and
635
+ token-driven because a Tailwind `duration-200` is a literal 200ms: under prefers-reduced-motion
636
+ the rail kept sliding for its full 200ms while every token-driven motion beside it went instant. */
637
+ .motion-rail-collapse {
638
+ transition-property: width, transform;
639
+ transition-duration: var(--motion-duration-normal);
640
+ transition-timing-function: var(--motion-easing-standard);
641
+ }
642
+
607
643
  /* Backs the `useFreshHighlight` hook: a freshly-created row gets a brief background wash that fades
608
644
  back to the resting surface, so the eye lands on the thing you just made. The tint is driven by
609
645
  --fresh-tint (default the amber wash — closest to GitHub's yellow flash, chosen over the
package/dist/styles.css CHANGED
@@ -576,8 +576,8 @@
576
576
  /* A semantic token is a whole colour with no `<alpha-value>` placeholder, so `bg-bg-secondary/50`
577
577
  compiles to nothing on Tailwind 3 while Tailwind 4 answers it with color-mix. These are the
578
578
  three alphas the kit writes, as variables the preset maps a colour name onto so both majors
579
- and both consumption paths resolve them. Like --hov-bg below, the inner var() resolves at the
580
- element that paints, so `.light` needs no override. Delete each the release @lessly/tokens
579
+ and both consumption paths resolve them. The inner var() resolves on the element `:root` and
580
+ `.light` both match, so `.light` needs no override. Delete each the release @lessly/tokens
581
581
  publishes its semantic colours with an `<alpha-value>` placeholder. */
582
582
  --bg-secondary-a50: color-mix(in oklab, var(--bg-secondary) 50%, transparent);
583
583
  --bg-secondary-a80: color-mix(in oklab, var(--bg-secondary) 80%, transparent);
@@ -586,8 +586,32 @@
586
586
  /* === Nav / menu surfaces (0.3.0 sidebar) === */
587
587
  /* The floating surface sits on the package's overlay pair: --menu-bg on
588
588
  --bg-overlay, --menu-shadow on --shadow-overlay. theme.css re-resolves both
589
- per theme, so `.light` needs no override for either. */
590
- --hov-bg: color-mix(in srgb, var(--text-primary) 7%, transparent);
589
+ per theme, so `.light` needs no override for either.
590
+
591
+ The pointer contrasts with whatever it lands on, and the direction flips by itself: this is a
592
+ wash of --text-primary, which is light ink in dark and dark ink in light, so one declaration
593
+ lightens on a dark ground and darkens on a light one. A wash rather than a rung because it is
594
+ ground-relative by construction — the same 4% reads on the page, a rail, a card, a menu and a
595
+ nested card alike, where every opaque step fails on at least one of them (--bg-secondary is a
596
+ nested card's own fill, so a hover painted with it disappears there). Measured: dark 1.099:1
597
+ up from the page and 1.117:1 from a card; light 1.083:1 down from the page and 1.090:1 from a
598
+ card (Guidelines/Look/Surface tone).
599
+
600
+ --current-bg is the plate a resting selection takes, and it is the end of the ladder the ink
601
+ points at: the wash above travels in the ink's direction, so the current row is the last rung
602
+ that way and the four states of a rail row read as one walk. Dark ink is light, so the top,
603
+ --bg-overlay, 1.184:1 up from the page. Light ink is dark, so the bottom, --bg-sunken,
604
+ 1.208:1 down. Two declarations because the two ends are two different tokens, not because the
605
+ rule differs.
606
+
607
+ --hov-current is what the pointer paints on the row that already holds that plate, and the 4%
608
+ cap is why it has to exist: in dark the whole gap between the page and the plate is 1.184:1,
609
+ so a hover step big enough to be seen ON the plate would overshoot it on the ground. Same
610
+ rule, one layer up — the wash taken over --current-bg rather than over the ground, which is
611
+ also what carries the pointer past a rung the ladder has no successor for. */
612
+ --hov-bg: color-mix(in srgb, var(--text-primary) 4%, transparent);
613
+ --current-bg: var(--bg-overlay);
614
+ --hov-current: color-mix(in srgb, var(--text-primary) 4%, var(--current-bg));
591
615
  --menu-bg: var(--bg-overlay);
592
616
  --menu-shadow: var(--shadow-overlay);
593
617
  --menu-hov: var(--hov-bg);
@@ -606,11 +630,10 @@
606
630
  }
607
631
 
608
632
  .light {
609
- /* Light keeps opaque hover plates: a translucent text-mix would vanish on a
610
- white menu. Pinned 0.3.0-prototype literalsthe package has no hover-surface
611
- roles yet, so these can't move onto tokens the way --menu-bg did. */
612
- --hov-bg: #ffffff;
613
- --menu-hov: #f2f4f7;
633
+
634
+ /* The other end of the ladder see --current-bg in :root above. --hov-current is declared once
635
+ and reads this, so the pointer's step off the plate follows without a second copy. */
636
+ --current-bg: var(--bg-sunken);
614
637
 
615
638
  /* Auth screen — see :root above. */
616
639
  --auth-glow-a: rgba(11, 76, 213, 0.09);
@@ -651,6 +674,19 @@
651
674
  transition-timing-function: var(--motion-easing-standard);
652
675
  }
653
676
 
677
+ /* Backs AppSidebar's collapse: the rail's width, and the bar in the toggle's mark that crosses the
678
+ frame as it travels. One class on both, so the mark cannot be retuned away from the edge it
679
+ answers for — the same reason two carets read one `caretMotion`. `transform` is here rather than
680
+ in a class of its own because the bar is that edge said in miniature: retune one and the two stop
681
+ arriving together. A real class for the reason the two above are, and
682
+ token-driven because a Tailwind `duration-200` is a literal 200ms: under prefers-reduced-motion
683
+ the rail kept sliding for its full 200ms while every token-driven motion beside it went instant. */
684
+ .motion-rail-collapse {
685
+ transition-property: width, transform;
686
+ transition-duration: var(--motion-duration-normal);
687
+ transition-timing-function: var(--motion-easing-standard);
688
+ }
689
+
654
690
  /* Backs the `useFreshHighlight` hook: a freshly-created row gets a brief background wash that fades
655
691
  back to the resting surface, so the eye lands on the thing you just made. The tint is driven by
656
692
  --fresh-tint (default the amber wash — closest to GitHub's yellow flash, chosen over the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessly/ui",
3
- "version": "2.3.1",
3
+ "version": "4.0.0",
4
4
  "description": "Lessly design system — shared UI primitives, tokens, and theme",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.30.3",
@@ -34,23 +34,25 @@
34
34
  },
35
35
  "scripts": {
36
36
  "build": "tsup && node scripts/build-styles.mjs",
37
- "prepare": "pnpm build",
38
- "test": "vitest run",
39
- "type-check": "tsc --noEmit",
40
- "lint": "eslint .",
41
- "prepublishOnly": "pnpm build && pnpm test && node scripts/check-package-contract.mjs",
42
- "storybook": "storybook dev -p 6006 --no-open",
37
+ "build-site": "pnpm build && vite build --config site/vite.config.ts",
43
38
  "build-storybook": "storybook build",
39
+ "build:current-emails": "tsx scripts/build-current-emails.mts",
40
+ "build:email-logo": "tsx scripts/build-email-logo.mts",
44
41
  "changeset": "changeset",
45
- "release:version": "changeset version && node scripts/stamp-changelog-date.mjs && pnpm gen-changelog",
42
+ "dev-css-stale": "node scripts/dev-css-stale.mjs",
43
+ "dev-site": "vite --config site/vite.config.ts",
44
+ "gen-changelog": "node site/scripts/gen-changelog.mjs",
45
+ "lint": "eslint .",
46
+ "prepare": "pnpm build",
47
+ "prepublishOnly": "pnpm build && pnpm test && node scripts/check-package-contract.mjs",
46
48
  "release:publish": "node scripts/release-publish.mjs",
47
49
  "release:tag": "changeset tag",
48
- "dev-site": "vite --config site/vite.config.ts",
49
- "dev-css-stale": "node scripts/dev-css-stale.mjs",
50
- "build-site": "pnpm build && vite build --config site/vite.config.ts",
50
+ "release:version": "changeset version && node scripts/stamp-changelog-date.mjs && pnpm gen-changelog",
51
+ "storybook": "storybook dev -p 6006 --no-open",
52
+ "test": "vitest run",
51
53
  "test-site": "node --test site/*.test.mjs",
52
54
  "test-site-app": "vitest run --config site/vitest.config.ts",
53
- "gen-changelog": "node site/scripts/gen-changelog.mjs"
55
+ "type-check": "tsc --noEmit"
54
56
  },
55
57
  "publishConfig": {
56
58
  "registry": "https://registry.npmjs.org/",
@@ -111,6 +113,9 @@
111
113
  "devDependencies": {
112
114
  "@changesets/cli": "^2.31.0",
113
115
  "@eslint/js": "^9.39.5",
116
+ "@react-email/components": "^0.5.6",
117
+ "@react-email/render": "^1.3.2",
118
+ "@resvg/resvg-js": "^2.6.2",
114
119
  "@storybook/addon-essentials": "^8.6.14",
115
120
  "@storybook/blocks": "8.6.18",
116
121
  "@storybook/react": "^8.6.18",