@lessly/ui 3.0.0 → 4.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/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,9 @@
1
1
  import {
2
2
  Button,
3
3
  NavRow,
4
+ RAIL_COLUMN_BOX,
5
+ RailToggle,
6
+ ScrollArea,
4
7
  Sheet,
5
8
  SheetContent,
6
9
  SheetDescription,
@@ -13,7 +16,7 @@ import {
13
16
  cn,
14
17
  useIsMobile,
15
18
  useSidebar
16
- } from "./chunk-CEXND5YW.js";
19
+ } from "./chunk-LTHOLX7I.js";
17
20
 
18
21
  // src/components/extension-link.tsx
19
22
  import { Link } from "react-router";
@@ -52,7 +55,7 @@ function SidebarNavLink({
52
55
  label: item.label,
53
56
  hideLabel: collapsed,
54
57
  icon: Icon ? /* @__PURE__ */ jsx2(Icon, { "aria-hidden": "true" }) : void 0,
55
- className: cn(collapsed && "size-9 justify-center px-0 py-0", className),
58
+ className,
56
59
  asChild: true,
57
60
  children: /* @__PURE__ */ jsx2(Comp, { to, "aria-current": active ? "page" : void 0 })
58
61
  }
@@ -72,57 +75,47 @@ function SidebarNav({
72
75
  className
73
76
  }) {
74
77
  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
- }
78
+ const nav = (
79
+ // One layout at both widths. A row is `w-full` and its glyph sits at the row's own left
80
+ // padding, so the column a mark stands on is a fact about the rail around these rows and not
81
+ // about their width — a narrow box of their own is what took the glyph off that column.
82
+ /* @__PURE__ */ jsx2("nav", { className: cn("flex flex-col gap-2", className), children: items.map((item) => {
83
+ const full = `${basePath}${item.href}`;
84
+ const active = isItemActive ? isItemActive(item, pathname) : isActivePath(pathname, full, item.exact);
85
+ return /* @__PURE__ */ jsx2(
86
+ SidebarNavLink,
87
+ {
88
+ item,
89
+ active,
90
+ collapsed,
91
+ basePath,
92
+ LinkComponent
93
+ },
94
+ item.href
95
+ );
96
+ }) })
99
97
  );
100
98
  return collapsed ? /* @__PURE__ */ jsx2(TooltipProvider, { children: nav }) : nav;
101
99
  }
102
100
 
103
101
  // src/components/app-sidebar.tsx
104
- import { PanelLeft } from "lucide-react";
102
+ import * as React from "react";
105
103
  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
- );
104
+ var SidebarDrawerContext = React.createContext(false);
105
+ function SidebarDrawer({ children }) {
106
+ return /* @__PURE__ */ jsx3(SidebarDrawerContext.Provider, { value: true, children });
107
+ }
108
+ function Divider({ testId, className }) {
109
+ return /* @__PURE__ */ jsx3("div", { "data-testid": testId, className: cn("h-px shrink-0 bg-border-subtle", className) });
118
110
  }
111
+ var RAIL_WIDE = "w-[248px]";
112
+ var RAIL_NARROW = "w-[64px]";
119
113
  function AppSidebar({
120
114
  items,
121
115
  logo,
122
116
  logoCollapsed,
123
117
  header,
124
- footer,
125
- collapsible = false,
118
+ railTop,
126
119
  collapsed: controlled,
127
120
  defaultCollapsed,
128
121
  onCollapsedChange,
@@ -131,89 +124,67 @@ function AppSidebar({
131
124
  isItemActive,
132
125
  className
133
126
  }) {
134
- const { collapsed, toggle } = useSidebar({
127
+ const drawer = React.useContext(SidebarDrawerContext);
128
+ const { collapsed: railCollapsed, toggle } = useSidebar({
135
129
  collapsed: controlled,
136
130
  defaultCollapsed,
137
131
  onCollapsedChange
138
132
  });
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(
133
+ const collapsed = drawer ? false : railCollapsed;
134
+ const railTopNode = typeof railTop === "function" ? railTop(collapsed) : railTop;
135
+ return /* @__PURE__ */ jsx3(
151
136
  "aside",
152
137
  {
153
138
  "data-collapsed": collapsed,
154
139
  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]",
140
+ "flex h-full shrink-0 flex-col overflow-hidden motion-rail-collapse",
141
+ // A nav row drops its own label at the narrow width. What this fades is a label whatever
142
+ // stands in `railTop` keeps mounted at both — QuickSearchRow's, for one — so the closing
143
+ // edge slides over the words instead of cutting through them.
144
+ "[&_[data-slot=nav-row-label]]:transition-opacity [&_[data-slot=nav-row-label]]:duration-fast",
145
+ collapsed ? RAIL_NARROW : RAIL_WIDE,
146
+ collapsed && "[&_[data-slot=nav-row-label]]:opacity-0",
157
147
  className
158
148
  ),
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" }),
149
+ children: /* @__PURE__ */ jsxs2("div", { className: cn("flex h-full shrink-0 flex-col", RAIL_WIDE), children: [
150
+ /* @__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 }),
151
+ /* @__PURE__ */ jsx3(Divider, { testId: "sidebar-divider-header" }),
152
+ railTopNode && /* @__PURE__ */ jsx3("div", { "data-testid": "sidebar-rail-top", className: "shrink-0 px-3 pt-3", children: railTopNode }),
180
153
  /* @__PURE__ */ jsx3(
181
- "div",
154
+ ScrollArea,
182
155
  {
183
- "data-testid": "sidebar-scroll",
184
- className: cn(
185
- "min-h-0 flex-1 overflow-y-auto overflow-x-hidden pt-3 pb-2",
186
- collapsed ? "px-1" : "px-3"
187
- ),
156
+ className: "min-h-0 flex-1",
157
+ viewportClassName: cn("px-3 pb-2", railTopNode ? "pt-2" : "pt-3"),
158
+ viewportProps: { "data-testid": "sidebar-scroll" },
188
159
  children: /* @__PURE__ */ jsx3(SidebarNav, { items, collapsed, basePath, LinkComponent, isItemActive })
189
160
  }
190
161
  ),
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 })
162
+ !drawer && /* @__PURE__ */ jsxs2(Fragment, { children: [
163
+ /* @__PURE__ */ jsx3(Divider, { testId: "sidebar-divider-foot" }),
164
+ /* @__PURE__ */ jsx3("div", { className: "flex shrink-0 px-3 py-2", children: /* @__PURE__ */ jsx3(RailToggle, { collapsed, onClick: toggle }) })
194
165
  ] })
195
- ]
166
+ ] })
196
167
  }
197
168
  );
198
169
  }
199
170
 
200
171
  // src/components/app-shell.tsx
201
- import * as React from "react";
172
+ import * as React2 from "react";
202
173
  import { Menu } from "lucide-react";
203
174
  import { useLocation as useLocation2 } from "react-router";
204
- import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
175
+ import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
205
176
  function expandedSidebar(sidebar) {
206
- return React.isValidElement(sidebar) ? React.cloneElement(sidebar, { collapsed: false }) : sidebar;
177
+ return React2.isValidElement(sidebar) ? React2.cloneElement(sidebar, { collapsed: false }) : sidebar;
207
178
  }
208
179
  function AppShell({ sidebar, topBar, children, className }) {
209
180
  const { pathname } = useLocation2();
210
- const [mobileOpen, setMobileOpen] = React.useState(false);
211
- React.useEffect(() => {
181
+ const [mobileOpen, setMobileOpen] = React2.useState(false);
182
+ React2.useEffect(() => {
212
183
  setMobileOpen(false);
213
184
  }, [pathname]);
214
185
  const isMobile = useIsMobile();
215
186
  if (isMobile) {
216
- return /* @__PURE__ */ jsx4(TooltipProvider, { children: /* @__PURE__ */ jsxs3("div", { className: cn("flex min-h-screen flex-col bg-bg-surface", className), children: [
187
+ return /* @__PURE__ */ jsx4(TooltipProvider, { children: /* @__PURE__ */ jsxs3("div", { className: cn("flex min-h-screen flex-col bg-bg-primary", className), children: [
217
188
  /* @__PURE__ */ jsxs3(
218
189
  "header",
219
190
  {
@@ -222,10 +193,10 @@ function AppShell({ sidebar, topBar, children, className }) {
222
193
  children: [
223
194
  /* @__PURE__ */ jsxs3(Sheet, { open: mobileOpen, onOpenChange: setMobileOpen, children: [
224
195
  /* @__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: [
196
+ /* @__PURE__ */ jsxs3(SheetContent, { side: "left", className: "w-[248px] bg-bg-primary p-0", children: [
226
197
  /* @__PURE__ */ jsx4(SheetTitle, { className: "sr-only", children: "Navigation" }),
227
198
  /* @__PURE__ */ jsx4(SheetDescription, { className: "sr-only", children: "Application navigation menu" }),
228
- expandedSidebar(sidebar)
199
+ /* @__PURE__ */ jsx4(SidebarDrawer, { children: expandedSidebar(sidebar) })
229
200
  ] })
230
201
  ] }),
231
202
  /* @__PURE__ */ jsx4("div", { className: "flex items-center gap-2", children: topBar })
@@ -235,10 +206,13 @@ function AppShell({ sidebar, topBar, children, className }) {
235
206
  /* @__PURE__ */ jsx4("main", { className: "flex-1 overflow-auto px-4", children })
236
207
  ] }) });
237
208
  }
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 }),
209
+ return /* @__PURE__ */ jsx4(TooltipProvider, { children: /* @__PURE__ */ jsxs3("div", { className: cn("flex min-h-screen bg-bg-primary", className), children: [
210
+ /* @__PURE__ */ jsx4("div", { className: "sticky top-0 h-screen shrink-0 border-r border-border-subtle", children: sidebar }),
240
211
  /* @__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 }),
212
+ topBar && /* @__PURE__ */ jsxs3(Fragment2, { children: [
213
+ /* @__PURE__ */ jsx4("div", { "data-slot": "app-shell-topbar", className: "flex h-14 shrink-0 items-center gap-2 px-6", children: topBar }),
214
+ /* @__PURE__ */ jsx4("div", { "data-slot": "app-shell-topbar-rule", className: "h-px shrink-0 bg-border-subtle" })
215
+ ] }),
242
216
  /* @__PURE__ */ jsx4("main", { className: "flex-1 overflow-y-auto px-6", children })
243
217
  ] })
244
218
  ] }) });
@@ -514,7 +514,6 @@
514
514
 
515
515
 
516
516
 
517
-
518
517
  /* === Theme Variables === */
519
518
  /* The token palettes (colors, typography, motion, shadows, grid) live in
520
519
  @lessly/tokens/theme.css — dark `:root` + `.light`, unprefixed names
@@ -535,8 +534,8 @@
535
534
  /* A semantic token is a whole colour with no `<alpha-value>` placeholder, so `bg-bg-secondary/50`
536
535
  compiles to nothing on Tailwind 3 while Tailwind 4 answers it with color-mix. These are the
537
536
  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
537
+ and both consumption paths resolve them. The inner var() resolves on the element `:root` and
538
+ `.light` both match, so `.light` needs no override. Delete each the release @lessly/tokens
540
539
  publishes its semantic colours with an `<alpha-value>` placeholder. */
541
540
  --bg-secondary-a50: color-mix(in oklab, var(--bg-secondary) 50%, transparent);
542
541
  --bg-secondary-a80: color-mix(in oklab, var(--bg-secondary) 80%, transparent);
@@ -545,8 +544,32 @@
545
544
  /* === Nav / menu surfaces (0.3.0 sidebar) === */
546
545
  /* The floating surface sits on the package's overlay pair: --menu-bg on
547
546
  --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);
547
+ per theme, so `.light` needs no override for either.
548
+
549
+ The pointer contrasts with whatever it lands on, and the direction flips by itself: this is a
550
+ wash of --text-primary, which is light ink in dark and dark ink in light, so one declaration
551
+ lightens on a dark ground and darkens on a light one. A wash rather than a rung because it is
552
+ ground-relative by construction — the same 4% reads on the page, a rail, a card, a menu and a
553
+ nested card alike, where every opaque step fails on at least one of them (--bg-secondary is a
554
+ nested card's own fill, so a hover painted with it disappears there). Measured: dark 1.099:1
555
+ 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
556
+ card (Guidelines/Look/Surface tone).
557
+
558
+ --current-bg is the plate a resting selection takes, and it is the end of the ladder the ink
559
+ points at: the wash above travels in the ink's direction, so the current row is the last rung
560
+ that way and the four states of a rail row read as one walk. Dark ink is light, so the top,
561
+ --bg-overlay, 1.184:1 up from the page. Light ink is dark, so the bottom, --bg-sunken,
562
+ 1.208:1 down. Two declarations because the two ends are two different tokens, not because the
563
+ rule differs.
564
+
565
+ --hov-current is what the pointer paints on the row that already holds that plate, and the 4%
566
+ cap is why it has to exist: in dark the whole gap between the page and the plate is 1.184:1,
567
+ so a hover step big enough to be seen ON the plate would overshoot it on the ground. Same
568
+ rule, one layer up — the wash taken over --current-bg rather than over the ground, which is
569
+ also what carries the pointer past a rung the ladder has no successor for. */
570
+ --hov-bg: color-mix(in srgb, var(--text-primary) 4%, transparent);
571
+ --current-bg: var(--bg-overlay);
572
+ --hov-current: color-mix(in srgb, var(--text-primary) 4%, var(--current-bg));
550
573
  --menu-bg: var(--bg-overlay);
551
574
  --menu-shadow: var(--shadow-overlay);
552
575
  --menu-hov: var(--hov-bg);
@@ -565,11 +588,10 @@
565
588
  }
566
589
 
567
590
  :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;
591
+
592
+ /* The other end of the ladder see --current-bg in :root above. --hov-current is declared once
593
+ and reads this, so the pointer's step off the plate follows without a second copy. */
594
+ --current-bg: var(--bg-sunken);
573
595
 
574
596
  /* Auth screen — see :root above. */
575
597
  --auth-glow-a: rgba(11, 76, 213, 0.09);
@@ -604,6 +626,19 @@
604
626
  transition-timing-function: var(--motion-easing-standard);
605
627
  }
606
628
 
629
+ /* Backs AppSidebar's collapse: the rail's width, and the bar in the toggle's mark that crosses the
630
+ frame as it travels. One class on both, so the mark cannot be retuned away from the edge it
631
+ answers for — the same reason two carets read one `caretMotion`. `transform` is here rather than
632
+ in a class of its own because the bar is that edge said in miniature: retune one and the two stop
633
+ arriving together. A real class for the reason the two above are, and
634
+ token-driven because a Tailwind `duration-200` is a literal 200ms: under prefers-reduced-motion
635
+ the rail kept sliding for its full 200ms while every token-driven motion beside it went instant. */
636
+ .motion-rail-collapse {
637
+ transition-property: width, transform;
638
+ transition-duration: var(--motion-duration-normal);
639
+ transition-timing-function: var(--motion-easing-standard);
640
+ }
641
+
607
642
  /* Backs the `useFreshHighlight` hook: a freshly-created row gets a brief background wash that fades
608
643
  back to the resting surface, so the eye lands on the thing you just made. The tint is driven by
609
644
  --fresh-tint (default the amber wash — closest to GitHub's yellow flash, chosen over the
@@ -641,6 +676,24 @@
641
676
  margin-inline: auto;
642
677
  }
643
678
 
679
+ /* Backs the overlay scrollbar <ScrollArea> draws: the bar rides over the content and is only there
680
+ while it is wanted — while you scroll, and while the pointer is over the region — then fades. A
681
+ real class rather than a Tailwind arbitrary utility for the reason the motion classes above are,
682
+ and token-driven so prefers-reduced-motion takes the fade to 0ms and the bar simply appears. */
683
+ .motion-scroll-overlay {
684
+ transition-property: opacity;
685
+ transition-duration: var(--motion-duration-normal);
686
+ transition-timing-function: var(--motion-easing-standard);
687
+ }
688
+
689
+ /* Radix's viewport wraps its children in a `display: table` div, which is how it lets content
690
+ wider than the region push the horizontal bar. A rail is the opposite case: its rows are as wide
691
+ as the rail and a table row is as wide as its widest cell, so a long label stops truncating and
692
+ spills. `display: block` on that wrapper gives the rows the region's width back. */
693
+ .scroll-overlay-viewport > div {
694
+ display: block !important;
695
+ }
696
+
644
697
  /* === Animation layer (tw-animate-css, compiled) — see scripts/build-styles.mjs === */
645
698
  /*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */
646
699
  @layer properties;
package/dist/styles.css CHANGED
@@ -534,13 +534,6 @@
534
534
  font-style: normal;
535
535
  font-display: swap;
536
536
  }
537
- @font-face {
538
- font-family: "Instrument Serif";
539
- src: url("/fonts/instrument-serif/instrument-serif-regular.woff2") format("woff2");
540
- font-weight: 400;
541
- font-style: normal;
542
- font-display: swap;
543
- }
544
537
  @font-face {
545
538
  font-family: "Fira Mono";
546
539
  src: url("/fonts/fira-mono/fira-mono-regular.woff2") format("woff2");
@@ -576,8 +569,8 @@
576
569
  /* A semantic token is a whole colour with no `<alpha-value>` placeholder, so `bg-bg-secondary/50`
577
570
  compiles to nothing on Tailwind 3 while Tailwind 4 answers it with color-mix. These are the
578
571
  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
572
+ and both consumption paths resolve them. The inner var() resolves on the element `:root` and
573
+ `.light` both match, so `.light` needs no override. Delete each the release @lessly/tokens
581
574
  publishes its semantic colours with an `<alpha-value>` placeholder. */
582
575
  --bg-secondary-a50: color-mix(in oklab, var(--bg-secondary) 50%, transparent);
583
576
  --bg-secondary-a80: color-mix(in oklab, var(--bg-secondary) 80%, transparent);
@@ -586,8 +579,32 @@
586
579
  /* === Nav / menu surfaces (0.3.0 sidebar) === */
587
580
  /* The floating surface sits on the package's overlay pair: --menu-bg on
588
581
  --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);
582
+ per theme, so `.light` needs no override for either.
583
+
584
+ The pointer contrasts with whatever it lands on, and the direction flips by itself: this is a
585
+ wash of --text-primary, which is light ink in dark and dark ink in light, so one declaration
586
+ lightens on a dark ground and darkens on a light one. A wash rather than a rung because it is
587
+ ground-relative by construction — the same 4% reads on the page, a rail, a card, a menu and a
588
+ nested card alike, where every opaque step fails on at least one of them (--bg-secondary is a
589
+ nested card's own fill, so a hover painted with it disappears there). Measured: dark 1.099:1
590
+ 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
591
+ card (Guidelines/Look/Surface tone).
592
+
593
+ --current-bg is the plate a resting selection takes, and it is the end of the ladder the ink
594
+ points at: the wash above travels in the ink's direction, so the current row is the last rung
595
+ that way and the four states of a rail row read as one walk. Dark ink is light, so the top,
596
+ --bg-overlay, 1.184:1 up from the page. Light ink is dark, so the bottom, --bg-sunken,
597
+ 1.208:1 down. Two declarations because the two ends are two different tokens, not because the
598
+ rule differs.
599
+
600
+ --hov-current is what the pointer paints on the row that already holds that plate, and the 4%
601
+ cap is why it has to exist: in dark the whole gap between the page and the plate is 1.184:1,
602
+ so a hover step big enough to be seen ON the plate would overshoot it on the ground. Same
603
+ rule, one layer up — the wash taken over --current-bg rather than over the ground, which is
604
+ also what carries the pointer past a rung the ladder has no successor for. */
605
+ --hov-bg: color-mix(in srgb, var(--text-primary) 4%, transparent);
606
+ --current-bg: var(--bg-overlay);
607
+ --hov-current: color-mix(in srgb, var(--text-primary) 4%, var(--current-bg));
591
608
  --menu-bg: var(--bg-overlay);
592
609
  --menu-shadow: var(--shadow-overlay);
593
610
  --menu-hov: var(--hov-bg);
@@ -606,11 +623,10 @@
606
623
  }
607
624
 
608
625
  .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;
626
+
627
+ /* The other end of the ladder see --current-bg in :root above. --hov-current is declared once
628
+ and reads this, so the pointer's step off the plate follows without a second copy. */
629
+ --current-bg: var(--bg-sunken);
614
630
 
615
631
  /* Auth screen — see :root above. */
616
632
  --auth-glow-a: rgba(11, 76, 213, 0.09);
@@ -651,6 +667,19 @@
651
667
  transition-timing-function: var(--motion-easing-standard);
652
668
  }
653
669
 
670
+ /* Backs AppSidebar's collapse: the rail's width, and the bar in the toggle's mark that crosses the
671
+ frame as it travels. One class on both, so the mark cannot be retuned away from the edge it
672
+ answers for — the same reason two carets read one `caretMotion`. `transform` is here rather than
673
+ in a class of its own because the bar is that edge said in miniature: retune one and the two stop
674
+ arriving together. A real class for the reason the two above are, and
675
+ token-driven because a Tailwind `duration-200` is a literal 200ms: under prefers-reduced-motion
676
+ the rail kept sliding for its full 200ms while every token-driven motion beside it went instant. */
677
+ .motion-rail-collapse {
678
+ transition-property: width, transform;
679
+ transition-duration: var(--motion-duration-normal);
680
+ transition-timing-function: var(--motion-easing-standard);
681
+ }
682
+
654
683
  /* Backs the `useFreshHighlight` hook: a freshly-created row gets a brief background wash that fades
655
684
  back to the resting surface, so the eye lands on the thing you just made. The tint is driven by
656
685
  --fresh-tint (default the amber wash — closest to GitHub's yellow flash, chosen over the
@@ -688,6 +717,24 @@
688
717
  margin-inline: auto;
689
718
  }
690
719
 
720
+ /* Backs the overlay scrollbar <ScrollArea> draws: the bar rides over the content and is only there
721
+ while it is wanted — while you scroll, and while the pointer is over the region — then fades. A
722
+ real class rather than a Tailwind arbitrary utility for the reason the motion classes above are,
723
+ and token-driven so prefers-reduced-motion takes the fade to 0ms and the bar simply appears. */
724
+ .motion-scroll-overlay {
725
+ transition-property: opacity;
726
+ transition-duration: var(--motion-duration-normal);
727
+ transition-timing-function: var(--motion-easing-standard);
728
+ }
729
+
730
+ /* Radix's viewport wraps its children in a `display: table` div, which is how it lets content
731
+ wider than the region push the horizontal bar. A rail is the opposite case: its rows are as wide
732
+ as the rail and a table row is as wide as its widest cell, so a long label stops truncating and
733
+ spills. `display: block` on that wrapper gives the rows the region's width back. */
734
+ .scroll-overlay-viewport > div {
735
+ display: block !important;
736
+ }
737
+
691
738
  /* === Animation layer (tw-animate-css, compiled) — see scripts/build-styles.mjs === */
692
739
  /*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */
693
740
  @layer properties;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessly/ui",
3
- "version": "3.0.0",
3
+ "version": "4.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",
@@ -34,24 +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:company-facts": "tsx scripts/build-company-facts.mts",
40
+ "build:current-emails": "tsx scripts/build-current-emails.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",
54
- "build:email-logo": "tsx scripts/build-email-logo.mts"
55
+ "type-check": "tsc --noEmit"
55
56
  },
56
57
  "publishConfig": {
57
58
  "registry": "https://registry.npmjs.org/",
@@ -114,7 +115,6 @@
114
115
  "@eslint/js": "^9.39.5",
115
116
  "@react-email/components": "^0.5.6",
116
117
  "@react-email/render": "^1.3.2",
117
- "@resvg/resvg-js": "^2.6.2",
118
118
  "@storybook/addon-essentials": "^8.6.14",
119
119
  "@storybook/blocks": "8.6.18",
120
120
  "@storybook/react": "^8.6.18",