@dreamtree-org/twreact-ui 2.0.8 → 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.
Files changed (50) hide show
  1. package/DESIGN.md +3 -0
  2. package/README.md +9 -4
  3. package/ai-skills/dreamtree-ui.md +12 -8
  4. package/dist/ai-schema.json +314 -70
  5. package/dist/components/blocks/AppShell.d.ts.map +1 -1
  6. package/dist/components/blocks/ProductCard.d.ts +4 -0
  7. package/dist/components/blocks/ProductCard.d.ts.map +1 -0
  8. package/dist/components/blocks/ProductCardParts.d.ts +19 -0
  9. package/dist/components/blocks/ProductCardParts.d.ts.map +1 -0
  10. package/dist/components/blocks/StatCard.d.ts.map +1 -1
  11. package/dist/components/core/Button.d.ts.map +1 -1
  12. package/dist/components/core/OptionCard.d.ts +14 -0
  13. package/dist/components/core/OptionCard.d.ts.map +1 -0
  14. package/dist/components/core/OptionCardGroup.d.ts +2 -0
  15. package/dist/components/core/OptionCardGroup.d.ts.map +1 -0
  16. package/dist/components/core/QuantityStepper.d.ts +8 -0
  17. package/dist/components/core/QuantityStepper.d.ts.map +1 -0
  18. package/dist/components/utility/ImageGallery.d.ts +4 -0
  19. package/dist/components/utility/ImageGallery.d.ts.map +1 -0
  20. package/dist/components/utility/imageGalleryParts.d.ts +55 -0
  21. package/dist/components/utility/imageGalleryParts.d.ts.map +1 -0
  22. package/dist/esm/components/blocks/AppShell.js +17 -6
  23. package/dist/esm/components/blocks/ProductCard.js +122 -0
  24. package/dist/esm/components/blocks/ProductCardParts.js +128 -0
  25. package/dist/esm/components/blocks/StatCard.js +20 -5
  26. package/dist/esm/components/core/Button.js +17 -2
  27. package/dist/esm/components/core/OptionCard.js +340 -0
  28. package/dist/esm/components/core/QuantityStepper.js +194 -0
  29. package/dist/esm/components/utility/ImageGallery.js +150 -0
  30. package/dist/esm/components/utility/imageGalleryParts.js +380 -0
  31. package/dist/esm/index.js +4 -0
  32. package/dist/index.css +1 -1
  33. package/dist/index.d.ts +6 -1
  34. package/dist/index.esm.css +1 -1
  35. package/dist/index.esm.js +1843 -502
  36. package/dist/index.js +1847 -501
  37. package/dist/tokens/tailwind3-preset.cjs +4 -1
  38. package/dist/tokens/theme.css +3 -0
  39. package/dist/tokens/tokens.json +7 -0
  40. package/dist/tokens/vars.css +2 -0
  41. package/dist/types/blocks.d.ts +61 -1
  42. package/dist/types/blocks.d.ts.map +1 -1
  43. package/dist/types/index.d.ts +102 -1
  44. package/dist/types/index.d.ts.map +1 -1
  45. package/doc/README.md +5 -1
  46. package/mcp/catalog.snapshot.json +479 -8
  47. package/package.json +1 -1
  48. package/tokens/brand.schema.json +22 -0
  49. package/tokens/lib.cjs +26 -0
  50. package/tokens/tokens.json +2 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ProductCard.d.ts","sourceRoot":"","sources":["../../../src/components/blocks/ProductCard.jsx"],"names":[],"mappings":";AAgBA,qFA8FG;kBA9GsC,OAAO"}
@@ -0,0 +1,19 @@
1
+ export function defaultPriceFormatter(value: any): any;
2
+ export function formatPrice(value: any, formatter?: (value: any) => any): any;
3
+ export function computeDiscount(price: any, compareAtPrice: any): string | null;
4
+ export function normalizeImages(image: any, images: any, fallbackAlt?: string): any[];
5
+ export function ProductMedia({ images, ratio, hover, placeholder, row, children }: {
6
+ images: any;
7
+ ratio?: string | undefined;
8
+ hover?: string | undefined;
9
+ placeholder: any;
10
+ row: any;
11
+ children: any;
12
+ }): import("react/jsx-runtime").JSX.Element;
13
+ export function ProductPrice({ price, compareAtPrice, discountLabel, priceFormatter }: {
14
+ price: any;
15
+ compareAtPrice: any;
16
+ discountLabel: any;
17
+ priceFormatter: any;
18
+ }): import("react/jsx-runtime").JSX.Element | null;
19
+ //# sourceMappingURL=ProductCardParts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ProductCardParts.d.ts","sourceRoot":"","sources":["../../../src/components/blocks/ProductCardParts.jsx"],"names":[],"mappings":"AASO,uDAGN;AAGM,8EACyE;AAGzE,gFAKN;AAGM,sFAGN;AAIM;;;;;;;4CAgDN;AAEM;;;;;mDAuBN"}
@@ -1 +1 @@
1
- {"version":3,"file":"StatCard.d.ts","sourceRoot":"","sources":["../../../src/components/blocks/StatCard.jsx"],"names":[],"mappings":";AAiBA,kFAyCG;kBA1De,OAAO"}
1
+ {"version":3,"file":"StatCard.d.ts","sourceRoot":"","sources":["../../../src/components/blocks/StatCard.jsx"],"names":[],"mappings":";AA2BA,kFA6CG;kBAxEe,OAAO"}
@@ -1 +1 @@
1
- {"version":3,"file":"Button.d.ts","sourceRoot":"","sources":["../../../src/components/core/Button.jsx"],"names":[],"mappings":";AAYA,gFAgGG;kBA5G+B,OAAO"}
1
+ {"version":3,"file":"Button.d.ts","sourceRoot":"","sources":["../../../src/components/core/Button.jsx"],"names":[],"mappings":";AAsBA,gFAwGG;kBA9H+B,OAAO"}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * OptionCardGroup: lays out `OptionCard`s in a responsive grid and gives them
3
+ * radiogroup semantics (or checkbox-group semantics with `multiple`).
4
+ */
5
+ export const OptionCardGroup: React.ForwardRefExoticComponent<React.RefAttributes<any>>;
6
+ export default OptionCard;
7
+ import React from "react";
8
+ /**
9
+ * OptionCard: a selectable choice tile (title, description, optional media and
10
+ * badge). Inside an `OptionCardGroup` it is a radio (or a checkbox with
11
+ * `multiple`); on its own it is a toggle button with `aria-pressed`.
12
+ */
13
+ declare const OptionCard: React.ForwardRefExoticComponent<React.RefAttributes<any>>;
14
+ //# sourceMappingURL=OptionCard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"OptionCard.d.ts","sourceRoot":"","sources":["../../../src/components/core/OptionCard.jsx"],"names":[],"mappings":"AA4CA;;;GAGG;AACH,wFAsIG;;kBA3KI,OAAO;AA+Kd;;;;GAIG;AACH,oFAsJG"}
@@ -0,0 +1,2 @@
1
+ export { OptionCardGroup as default } from "./OptionCard.jsx";
2
+ //# sourceMappingURL=OptionCardGroup.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"OptionCardGroup.d.ts","sourceRoot":"","sources":["../../../src/components/core/OptionCardGroup.jsx"],"names":[],"mappings":""}
@@ -0,0 +1,8 @@
1
+ export default QuantityStepper;
2
+ /**
3
+ * QuantityStepper: a − / value / + control for cart lines and buy boxes.
4
+ * The value is a `role="spinbutton"`; the buttons step it and disable at the bounds.
5
+ */
6
+ declare const QuantityStepper: React.ForwardRefExoticComponent<React.RefAttributes<any>>;
7
+ import React from "react";
8
+ //# sourceMappingURL=QuantityStepper.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"QuantityStepper.d.ts","sourceRoot":"","sources":["../../../src/components/core/QuantityStepper.jsx"],"names":[],"mappings":";AA2BA;;;GAGG;AACH,yFA2JG;kBA1LsC,OAAO"}
@@ -0,0 +1,4 @@
1
+ export const ImageGallery: React.ForwardRefExoticComponent<React.RefAttributes<any>>;
2
+ export default ImageGallery;
3
+ import React from "react";
4
+ //# sourceMappingURL=ImageGallery.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ImageGallery.d.ts","sourceRoot":"","sources":["../../../src/components/utility/ImageGallery.jsx"],"names":[],"mappings":"AAQA,qFAuHE;;kBA/HgB,OAAO"}
@@ -0,0 +1,55 @@
1
+ export function PlaceholderBox({ children, className }: {
2
+ children: any;
3
+ className: any;
4
+ }): import("react/jsx-runtime").JSX.Element;
5
+ export function GalleryImg({ src, alt, placeholder, className, style }: {
6
+ src: any;
7
+ alt: any;
8
+ placeholder: any;
9
+ className: any;
10
+ style: any;
11
+ }): import("react/jsx-runtime").JSX.Element;
12
+ export function useSwipe({ onPrev, onNext, disabled }: {
13
+ onPrev: any;
14
+ onNext: any;
15
+ disabled: any;
16
+ }): {
17
+ handlers: {
18
+ onPointerDown: (e: any) => void;
19
+ onPointerUp: (e: any) => void;
20
+ onPointerCancel: () => void;
21
+ };
22
+ consumeSwipe: () => boolean;
23
+ };
24
+ export function handleRovingKeyDown(e: any): void;
25
+ export function Thumbnails({ images, index, onSelect, side, placeholder }: {
26
+ images: any;
27
+ index: any;
28
+ onSelect: any;
29
+ side: any;
30
+ placeholder: any;
31
+ }): import("react/jsx-runtime").JSX.Element;
32
+ export function Mosaic({ images, index, onActivate, ratio, placeholder }: {
33
+ images: any;
34
+ index: any;
35
+ onActivate: any;
36
+ ratio: any;
37
+ placeholder: any;
38
+ }): import("react/jsx-runtime").JSX.Element;
39
+ export function Viewer({ images, index, onSelect, ratio, zoom, showCounter, placeholder, label, onExpand, fit }: {
40
+ images: any;
41
+ index: any;
42
+ onSelect: any;
43
+ ratio: any;
44
+ zoom: any;
45
+ showCounter: any;
46
+ placeholder: any;
47
+ label: any;
48
+ onExpand: any;
49
+ fit?: string | undefined;
50
+ }): import("react/jsx-runtime").JSX.Element;
51
+ export function ratioClass(ratio: any): any;
52
+ export const SWIPE_THRESHOLD: 40;
53
+ export const MOSAIC_MAX_TILES: 5;
54
+ export function slideLabel(images: any, i: any): any;
55
+ //# sourceMappingURL=imageGalleryParts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"imageGalleryParts.d.ts","sourceRoot":"","sources":["../../../src/components/utility/imageGalleryParts.jsx"],"names":[],"mappings":"AAsCA;;;4CASC;AAGD;;;;;;4CAcC;AAID;;;;;;;;;;;EA2BC;AAID,kDAYC;AAED;;;;;;4CAoCC;AAED;;;;;;4CAgDC;AAiBD;;;;;;;;;;;4CAkGC;AApSM,4CAA0E;AAIjF,8BAA+B,EAAE,CAAA;AAEjC,+BAAgC,CAAC,CAAA;AAG1B,qDAA+F"}
@@ -1,14 +1,14 @@
1
1
  'use client';
2
2
  import { objectWithoutProperties as _objectWithoutProperties, slicedToArray as _slicedToArray, objectSpread2 as _objectSpread2, createForOfIteratorHelper as _createForOfIteratorHelper } from '../../_virtual/_rollupPluginBabelHelpers.js';
3
3
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
4
- import React, { useState, useRef, useCallback } from 'react';
4
+ import React, { useState, useCallback, useRef } from 'react';
5
5
  import { Menu, X } from 'lucide-react';
6
6
  import { cn } from '../../utils/cn.js';
7
7
  import { TOUCH_TARGET } from '../../utils/touchTarget.js';
8
8
  import Sheet from '../feedback/Sheet.js';
9
9
  import FootNav from '../navigation/FootNav.js';
10
10
 
11
- var _excluded = ["brand", "nav", "sidebarFooter", "topbar", "pageTitle", "bottomNav", "linkAs", "children", "className"];
11
+ var _excluded = ["brand", "nav", "sidebarFooter", "topbar", "pageTitle", "bottomNav", "linkAs", "navOpen", "onNavOpenChange", "topbarFrom", "children", "className"];
12
12
  // Extracted from procurementpro/components/app-shell.tsx (12 of 13 products hand-roll a shell).
13
13
  // Sidebar on lg+, top bar + drawer below, optional bottom nav on phones. Colours come from the
14
14
  // shell / role tokens, so a brand file restyles it and dark mode needs no extra classes. Nav items
@@ -102,17 +102,28 @@ var AppShell = /*#__PURE__*/React.forwardRef(function (_ref2, ref) {
102
102
  bottomNav = _ref2.bottomNav,
103
103
  _ref2$linkAs = _ref2.linkAs,
104
104
  Link = _ref2$linkAs === void 0 ? 'a' : _ref2$linkAs,
105
+ navOpen = _ref2.navOpen,
106
+ onNavOpenChange = _ref2.onNavOpenChange,
107
+ _ref2$topbarFrom = _ref2.topbarFrom,
108
+ topbarFrom = _ref2$topbarFrom === void 0 ? 'always' : _ref2$topbarFrom,
105
109
  children = _ref2.children,
106
110
  className = _ref2.className,
107
111
  props = _objectWithoutProperties(_ref2, _excluded);
112
+ // The drawer is controlled when `navOpen` is set (#187), e.g. so a FootNav "More" tab can open it.
108
113
  var _useState = useState(false),
109
114
  _useState2 = _slicedToArray(_useState, 2),
110
- open = _useState2[0],
111
- setOpen = _useState2[1];
115
+ openState = _useState2[0],
116
+ setOpenState = _useState2[1];
117
+ var controlled = navOpen !== undefined;
118
+ var open = controlled ? navOpen : openState;
119
+ var setOpen = useCallback(function (next) {
120
+ if (!controlled) setOpenState(next);
121
+ onNavOpenChange === null || onNavOpenChange === void 0 || onNavOpenChange(next);
122
+ }, [controlled, onNavOpenChange]);
112
123
  var menuButton = useRef(null);
113
124
  var close = useCallback(function () {
114
125
  return setOpen(false);
115
- }, []);
126
+ }, [setOpen]);
116
127
  // The mobile drawer is a left Sheet: a modal dialog that moves focus in, traps
117
128
  // Tab, closes on Escape / backdrop / drag, locks the page behind and returns
118
129
  // focus to the menu button.
@@ -171,7 +182,7 @@ var AppShell = /*#__PURE__*/React.forwardRef(function (_ref2, ref) {
171
182
  }), jsxs("div", {
172
183
  className: "flex min-w-0 flex-col",
173
184
  children: [jsx("header", {
174
- className: "sticky top-0 z-sticky border-b border-line bg-surface pt-safe px-safe",
185
+ className: cn('sticky top-0 z-sticky border-b border-line bg-surface pt-safe px-safe', topbarFrom === 'below-lg' && 'lg:hidden'),
175
186
  children: jsxs("div", {
176
187
  className: "flex h-14 items-center gap-3 px-4 lg:px-6",
177
188
  children: [jsx("button", {
@@ -0,0 +1,122 @@
1
+ 'use client';
2
+ import { objectWithoutProperties as _objectWithoutProperties, objectSpread2 as _objectSpread2 } from '../../_virtual/_rollupPluginBabelHelpers.js';
3
+ import { jsxs, jsx } from 'react/jsx-runtime';
4
+ import React, { forwardRef, useId } from 'react';
5
+ import { cn } from '../../utils/cn.js';
6
+ import Card from '../utility/Card.js';
7
+ import Checkbox from '../core/Checkbox.js';
8
+ import { normalizeImages, ProductMedia, ProductPrice } from './ProductCardParts.js';
9
+
10
+ var _excluded = ["image", "images", "imageAlt", "imageRatio", "hover", "title", "eyebrow", "meta", "badges", "price", "compareAtPrice", "discountLabel", "priceFormatter", "layout", "href", "to", "as", "actions", "selectable", "selected", "onSelectedChange", "placeholder", "className"];
11
+ // One link target per card: the title link's ::after stretches over the whole card, so the image
12
+ // and blank space are clickable without nesting interactive elements. Actions and the compare
13
+ // checkbox sit above it: the actions by DOM order, the compare checkbox (before the link) with a card-local
14
+ // stacking index inside the card's `isolate` context. The ring is inset because the card clips its overflow.
15
+ var STRETCHED_LINK = cn("after:absolute after:inset-0 after:rounded-card after:content-['']", 'focus:outline-none focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-primary-500', 'hover:text-primary-700 dark:hover:text-primary-300');
16
+ var ProductCard = /*#__PURE__*/forwardRef(function (_ref, ref) {
17
+ var image = _ref.image,
18
+ images = _ref.images,
19
+ imageAlt = _ref.imageAlt,
20
+ _ref$imageRatio = _ref.imageRatio,
21
+ imageRatio = _ref$imageRatio === void 0 ? '4:3' : _ref$imageRatio,
22
+ _ref$hover = _ref.hover,
23
+ hover = _ref$hover === void 0 ? 'none' : _ref$hover,
24
+ title = _ref.title,
25
+ eyebrow = _ref.eyebrow,
26
+ meta = _ref.meta,
27
+ badges = _ref.badges,
28
+ price = _ref.price,
29
+ compareAtPrice = _ref.compareAtPrice,
30
+ discountLabel = _ref.discountLabel,
31
+ priceFormatter = _ref.priceFormatter,
32
+ _ref$layout = _ref.layout,
33
+ layout = _ref$layout === void 0 ? 'stack' : _ref$layout,
34
+ href = _ref.href,
35
+ to = _ref.to,
36
+ as = _ref.as,
37
+ actions = _ref.actions,
38
+ _ref$selectable = _ref.selectable,
39
+ selectable = _ref$selectable === void 0 ? false : _ref$selectable,
40
+ selected = _ref.selected,
41
+ onSelectedChange = _ref.onSelectedChange,
42
+ placeholder = _ref.placeholder,
43
+ className = _ref.className,
44
+ props = _objectWithoutProperties(_ref, _excluded);
45
+ var titleId = useId();
46
+ var row = layout === 'row';
47
+ var altFallback = imageAlt !== null && imageAlt !== void 0 ? imageAlt : typeof title === 'string' ? title : '';
48
+ var media = normalizeImages(image, images, altFallback);
49
+ // Mirrors Button (#145): `as` renders any link component (e.g. a router Link, which gets `to`);
50
+ // a bare `href` or `to` implies an <a>. No target at all leaves the title as plain text.
51
+ var linked = Boolean(as) || href !== undefined || to !== undefined;
52
+ var LinkComponent = as || 'a';
53
+ // Only defined keys, so an `href: undefined` can't clobber what a router Link derives from `to`.
54
+ var linkProps = as ? _objectSpread2(_objectSpread2({}, href !== undefined && {
55
+ href: href
56
+ }), to !== undefined && {
57
+ to: to
58
+ }) : {
59
+ href: href !== null && href !== void 0 ? href : to
60
+ };
61
+ return jsxs(Card, _objectSpread2(_objectSpread2({
62
+ ref: ref,
63
+ hover: linked,
64
+ "data-layout": row ? 'row' : 'stack',
65
+ className: cn('group relative isolate flex h-full overflow-hidden', row ? 'flex-row sm:flex-col' : 'flex-col', className)
66
+ }, props), {}, {
67
+ children: [jsxs(ProductMedia, {
68
+ images: media,
69
+ ratio: imageRatio,
70
+ hover: hover,
71
+ placeholder: placeholder,
72
+ row: row,
73
+ children: [badges && badges.length > 0 && jsx("div", {
74
+ className: "pointer-events-none absolute left-2 top-2 flex flex-wrap gap-1",
75
+ "data-slot": "badges",
76
+ children: React.Children.toArray(badges)
77
+ }), selectable && jsx("div", {
78
+ className: "absolute right-2 top-2 z-10 rounded-md bg-surface/90 px-2 py-1 shadow-sm",
79
+ children: jsx(Checkbox, _objectSpread2(_objectSpread2({
80
+ size: "sm",
81
+ label: "Compare",
82
+ ariaLabel: typeof title === 'string' ? "Compare ".concat(title) : undefined,
83
+ "aria-describedby": typeof title === 'string' ? undefined : titleId
84
+ }, selected !== undefined ? {
85
+ checked: Boolean(selected)
86
+ } : {}), {}, {
87
+ onChange: function onChange(e) {
88
+ return onSelectedChange === null || onSelectedChange === void 0 ? void 0 : onSelectedChange(e.target.checked, e);
89
+ }
90
+ }))
91
+ })]
92
+ }), jsxs("div", {
93
+ className: "flex min-w-0 flex-1 flex-col p-3 sm:p-4",
94
+ children: [eyebrow && jsx("p", {
95
+ className: "text-xs font-medium uppercase tracking-wide text-muted",
96
+ children: eyebrow
97
+ }), jsx("h3", {
98
+ id: titleId,
99
+ className: "mt-0.5 line-clamp-2 text-sm font-semibold text-foreground sm:text-base",
100
+ children: linked ? jsx(LinkComponent, _objectSpread2(_objectSpread2({}, linkProps), {}, {
101
+ className: STRETCHED_LINK,
102
+ children: title
103
+ })) : title
104
+ }), meta && jsx("p", {
105
+ className: "mt-1 line-clamp-1 text-xs text-muted sm:text-sm",
106
+ children: meta
107
+ }), jsx(ProductPrice, {
108
+ price: price,
109
+ compareAtPrice: compareAtPrice,
110
+ discountLabel: discountLabel,
111
+ priceFormatter: priceFormatter
112
+ }), actions && jsx("div", {
113
+ className: "relative mt-auto flex flex-wrap gap-2 pt-3",
114
+ "data-slot": "actions",
115
+ children: actions
116
+ })]
117
+ })]
118
+ }));
119
+ });
120
+ ProductCard.displayName = 'ProductCard';
121
+
122
+ export { ProductCard as default };
@@ -0,0 +1,128 @@
1
+ 'use client';
2
+ import { toConsumableArray as _toConsumableArray, objectSpread2 as _objectSpread2, slicedToArray as _slicedToArray } from '../../_virtual/_rollupPluginBabelHelpers.js';
3
+ import { jsxs, Fragment, jsx } from 'react/jsx-runtime';
4
+ import { useState } from 'react';
5
+ import { ImageOff } from 'lucide-react';
6
+ import { cn } from '../../utils/cn.js';
7
+
8
+ // Class names are written out in full so Tailwind keeps them.
9
+ var RATIOS = {
10
+ '4:3': 'aspect-[4/3]',
11
+ '1:1': 'aspect-square',
12
+ '3:4': 'aspect-[3/4]'
13
+ };
14
+ // Created lazily: Intl.NumberFormat is costly to build and most grids render dozens of cards.
15
+ var inrFormatter;
16
+ var defaultPriceFormatter = function defaultPriceFormatter(value) {
17
+ if (!inrFormatter) inrFormatter = new Intl.NumberFormat('en-IN', {
18
+ style: 'currency',
19
+ currency: 'INR',
20
+ maximumFractionDigits: 0
21
+ });
22
+ return inrFormatter.format(value);
23
+ };
24
+ /** Numbers go through the formatter; strings (e.g. "From ₹999") pass through untouched. */
25
+ var formatPrice = function formatPrice(value) {
26
+ var formatter = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : defaultPriceFormatter;
27
+ return typeof value === 'number' && Number.isFinite(value) ? formatter(value) : value;
28
+ };
29
+ /** "26% off" when both prices are numbers and the compare-at price is higher; otherwise null. */
30
+ var computeDiscount = function computeDiscount(price, compareAtPrice) {
31
+ if (typeof price !== 'number' || typeof compareAtPrice !== 'number') return null;
32
+ if (!(compareAtPrice > price) || price < 0) return null;
33
+ var pct = Math.round((1 - price / compareAtPrice) * 100);
34
+ return pct > 0 ? "".concat(pct, "% off") : null;
35
+ };
36
+ /** `image` / `images` (strings or `{ src, alt }`) → `[{ src, alt }]`, cover first. */
37
+ var normalizeImages = function normalizeImages(image, images) {
38
+ var fallbackAlt = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : '';
39
+ var list = [image].concat(_toConsumableArray(images || [])).filter(Boolean);
40
+ return list.map(function (img) {
41
+ return typeof img === 'string' ? {
42
+ src: img,
43
+ alt: fallbackAlt
44
+ } : _objectSpread2({
45
+ alt: fallbackAlt
46
+ }, img);
47
+ });
48
+ };
49
+ var HOVER_SWAP = 'motion-safe:transition-opacity motion-safe:duration-base motion-safe:ease-standard';
50
+ var ProductMedia = function ProductMedia(_ref) {
51
+ var images = _ref.images,
52
+ _ref$ratio = _ref.ratio,
53
+ ratio = _ref$ratio === void 0 ? '4:3' : _ref$ratio,
54
+ _ref$hover = _ref.hover,
55
+ hover = _ref$hover === void 0 ? 'none' : _ref$hover,
56
+ placeholder = _ref.placeholder,
57
+ row = _ref.row,
58
+ children = _ref.children;
59
+ var cover = images[0];
60
+ var second = hover === 'second-image' ? images[1] : undefined;
61
+ // Remember which src failed, so a new cover gets a fresh chance to load.
62
+ var _useState = useState(null),
63
+ _useState2 = _slicedToArray(_useState, 2),
64
+ failedSrc = _useState2[0],
65
+ setFailedSrc = _useState2[1];
66
+ var showImage = cover && failedSrc !== cover.src;
67
+ return jsxs("div", {
68
+ className: cn('relative shrink-0 overflow-hidden bg-secondary-100 dark:bg-secondary-800', RATIOS[ratio] || RATIOS['4:3'], row ? 'w-28 sm:w-full' : 'w-full'),
69
+ "data-slot": "media",
70
+ children: [showImage ? jsxs(Fragment, {
71
+ children: [jsx("img", {
72
+ src: cover.src,
73
+ alt: cover.alt,
74
+ loading: "lazy",
75
+ onError: function onError() {
76
+ return setFailedSrc(cover.src);
77
+ },
78
+ className: cn('h-full w-full object-cover', hover === 'zoom' && 'motion-safe:transition-transform motion-safe:duration-slow motion-safe:ease-standard motion-safe:group-hover:scale-105', second && cn(HOVER_SWAP, 'group-hover:opacity-0'))
79
+ }), second &&
80
+ // Same product from another angle: decorative, the cover's alt already names it.
81
+ jsx("img", {
82
+ src: second.src,
83
+ alt: "",
84
+ "aria-hidden": "true",
85
+ loading: "lazy",
86
+ className: cn('absolute inset-0 h-full w-full object-cover opacity-0 group-hover:opacity-100', HOVER_SWAP)
87
+ })]
88
+ }) : jsx("div", {
89
+ className: "grid h-full w-full place-items-center text-muted",
90
+ "data-slot": "placeholder",
91
+ children: placeholder !== null && placeholder !== void 0 ? placeholder : jsx(ImageOff, {
92
+ className: "h-8 w-8",
93
+ "aria-hidden": "true"
94
+ })
95
+ }), children]
96
+ });
97
+ };
98
+ var ProductPrice = function ProductPrice(_ref2) {
99
+ var price = _ref2.price,
100
+ compareAtPrice = _ref2.compareAtPrice,
101
+ discountLabel = _ref2.discountLabel,
102
+ priceFormatter = _ref2.priceFormatter;
103
+ if (price == null && compareAtPrice == null) return null;
104
+ var discount = discountLabel !== null && discountLabel !== void 0 ? discountLabel : computeDiscount(price, compareAtPrice);
105
+ var hasCompare = compareAtPrice != null;
106
+ return jsxs("p", {
107
+ className: "mt-2 flex flex-wrap items-baseline gap-x-2 gap-y-0.5",
108
+ "data-slot": "price",
109
+ children: [price != null && jsxs("span", {
110
+ className: "text-lg font-semibold tracking-tight text-foreground tabular-nums",
111
+ children: [hasCompare && jsx("span", {
112
+ className: "sr-only",
113
+ children: "Sale price: "
114
+ }), formatPrice(price, priceFormatter)]
115
+ }), hasCompare && jsxs("s", {
116
+ className: "text-sm text-muted tabular-nums",
117
+ children: [jsx("span", {
118
+ className: "sr-only",
119
+ children: "Original price: "
120
+ }), formatPrice(compareAtPrice, priceFormatter)]
121
+ }), discount && jsx("span", {
122
+ className: "text-sm font-semibold text-success-700 dark:text-success-400",
123
+ children: discount
124
+ })]
125
+ });
126
+ };
127
+
128
+ export { ProductMedia, ProductPrice, computeDiscount, defaultPriceFormatter, formatPrice, normalizeImages };
@@ -7,7 +7,7 @@ import { cn } from '../../utils/cn.js';
7
7
  import Card from '../utility/Card.js';
8
8
  import { Bone } from './LoadingState.js';
9
9
 
10
- var _excluded = ["label", "value", "icon", "tone", "change", "loading", "className"];
10
+ var _excluded = ["label", "value", "icon", "tone", "change", "loading", "tinted", "children", "className"];
11
11
  // Extracted from PracticePro/components/ui/StatCard.tsx (28 imports), with tones mapped to the
12
12
  // semantic scales instead of product colours. Class names are written out in full so Tailwind keeps them.
13
13
  var TONES = {
@@ -18,6 +18,15 @@ var TONES = {
18
18
  error: 'bg-error-50 text-error-600 dark:bg-error-900/40 dark:text-error-300',
19
19
  info: 'bg-info-50 text-info-600 dark:bg-info-900/40 dark:text-info-300'
20
20
  };
21
+ // `tinted` (#188): the whole card in the tone, e.g. an overdue card in error-50 / error-200 / error-800.
22
+ var TINTS = {
23
+ primary: 'border-primary-200 bg-primary-50 text-primary-800 dark:border-primary-800 dark:bg-primary-900/30 dark:text-primary-200',
24
+ secondary: 'border-secondary-200 bg-secondary-50 text-secondary-800 dark:border-secondary-700 dark:bg-secondary-800/40 dark:text-secondary-200',
25
+ success: 'border-success-200 bg-success-50 text-success-800 dark:border-success-800 dark:bg-success-900/30 dark:text-success-200',
26
+ warning: 'border-warning-200 bg-warning-50 text-warning-800 dark:border-warning-800 dark:bg-warning-900/30 dark:text-warning-200',
27
+ error: 'border-error-200 bg-error-50 text-error-800 dark:border-error-800 dark:bg-error-900/30 dark:text-error-200',
28
+ info: 'border-info-200 bg-info-50 text-info-800 dark:border-info-800 dark:bg-info-900/30 dark:text-info-200'
29
+ };
21
30
  var StatCard = /*#__PURE__*/React.forwardRef(function (_ref, ref) {
22
31
  var _change$goodWhenUp, _change$unit;
23
32
  var label = _ref.label,
@@ -28,13 +37,16 @@ var StatCard = /*#__PURE__*/React.forwardRef(function (_ref, ref) {
28
37
  change = _ref.change,
29
38
  _ref$loading = _ref.loading,
30
39
  loading = _ref$loading === void 0 ? false : _ref$loading,
40
+ _ref$tinted = _ref.tinted,
41
+ tinted = _ref$tinted === void 0 ? false : _ref$tinted,
42
+ children = _ref.children,
31
43
  className = _ref.className,
32
44
  props = _objectWithoutProperties(_ref, _excluded);
33
45
  var up = change ? change.value >= 0 : false;
34
46
  var good = change ? ((_change$goodWhenUp = change.goodWhenUp) !== null && _change$goodWhenUp !== void 0 ? _change$goodWhenUp : true) === up : false;
35
47
  return jsx(Card, _objectSpread2(_objectSpread2({
36
48
  ref: ref,
37
- className: cn('h-full', className)
49
+ className: cn('h-full', tinted && (TINTS[tone] || TINTS.primary), className)
38
50
  }, props), {}, {
39
51
  children: jsxs("div", {
40
52
  className: "flex items-start gap-4 p-5",
@@ -43,16 +55,16 @@ var StatCard = /*#__PURE__*/React.forwardRef(function (_ref, ref) {
43
55
  "aria-hidden": "true",
44
56
  children: icon
45
57
  }), jsxs("div", {
46
- className: "min-w-0",
58
+ className: "min-w-0 flex-1",
47
59
  children: [jsx("p", {
48
- className: "text-sm text-muted",
60
+ className: cn('text-sm', tinted ? 'text-current opacity-80' : 'text-muted'),
49
61
  children: label
50
62
  }), loading ? jsx(Bone, {
51
63
  height: 32,
52
64
  width: 72,
53
65
  className: "mt-1"
54
66
  }) : jsx("p", {
55
- className: "mt-0.5 text-2xl font-semibold tracking-tight text-foreground tabular-nums sm:text-3xl",
67
+ className: cn('mt-0.5 text-2xl font-semibold tracking-tight tabular-nums sm:text-3xl', tinted ? 'text-current' : 'text-foreground'),
56
68
  children: value
57
69
  }), change && !loading && jsxs("p", {
58
70
  className: "mt-1 flex items-center gap-1 text-xs text-muted",
@@ -69,6 +81,9 @@ var StatCard = /*#__PURE__*/React.forwardRef(function (_ref, ref) {
69
81
  children: [up ? 'Up' : 'Down', " "]
70
82
  }), Math.abs(change.value), (_change$unit = change.unit) !== null && _change$unit !== void 0 ? _change$unit : '%']
71
83
  }), change.period]
84
+ }), children != null && children !== false && jsx("div", {
85
+ className: "mt-3 space-y-2 text-sm",
86
+ children: children
72
87
  })]
73
88
  })]
74
89
  })
@@ -6,19 +6,30 @@ import { cn } from '../../utils/cn.js';
6
6
  import { TOUCH_TARGET, PRESS_FEEDBACK } from '../../utils/touchTarget.js';
7
7
  import { Loader2 } from 'lucide-react';
8
8
 
9
- var _excluded = ["className", "variant", "size", "disabled", "loading", "children", "leftIcon", "rightIcon", "fullWidth", "as", "onClick"];
9
+ var _excluded = ["className", "variant", "size", "tone", "disabled", "loading", "children", "leftIcon", "rightIcon", "fullWidth", "as", "onClick"];
10
10
  // Square, icon-only sizes. Pair with an `aria-label` (there is no visible text).
11
11
  var ICON_SIZES = {
12
12
  'icon-sm': 'h-8 w-8 p-0',
13
13
  icon: 'h-10 w-10 p-0',
14
14
  'icon-lg': 'h-12 w-12 p-0'
15
15
  };
16
+ // Text colour of `variant="link"`, picked with `tone`. Written out in full so Tailwind keeps them.
17
+ var LINK_TONES = {
18
+ primary: 'text-primary-700 focus-visible:ring-primary-500 dark:text-primary-300',
19
+ secondary: 'text-secondary-700 focus-visible:ring-secondary-500 dark:text-secondary-300',
20
+ success: 'text-success-700 focus-visible:ring-success-500 dark:text-success-400',
21
+ warning: 'text-warning-800 focus-visible:ring-warning-500 dark:text-warning-400',
22
+ error: 'text-error-700 focus-visible:ring-error-500 dark:text-error-400',
23
+ info: 'text-info-700 focus-visible:ring-info-500 dark:text-info-300'
24
+ };
16
25
  var Button = /*#__PURE__*/forwardRef(function (_ref, ref) {
17
26
  var className = _ref.className,
18
27
  _ref$variant = _ref.variant,
19
28
  variant = _ref$variant === void 0 ? 'primary' : _ref$variant,
20
29
  _ref$size = _ref.size,
21
30
  size = _ref$size === void 0 ? 'md' : _ref$size,
31
+ _ref$tone = _ref.tone,
32
+ tone = _ref$tone === void 0 ? 'primary' : _ref$tone,
22
33
  disabled = _ref.disabled,
23
34
  loading = _ref.loading,
24
35
  children = _ref.children,
@@ -34,6 +45,7 @@ var Button = /*#__PURE__*/forwardRef(function (_ref, ref) {
34
45
  var isNativeButton = Component === 'button';
35
46
  var isDisabled = disabled || loading;
36
47
  var isIconSize = Boolean(ICON_SIZES[size]);
48
+ var isLink = variant === 'link';
37
49
  var baseClasses = cn('relative inline-flex items-center justify-center rounded-md font-medium',
38
50
  // Press shrink + an explicit transition for colours and the shrink;
39
51
  // both are off under prefers-reduced-motion.
@@ -53,7 +65,10 @@ var Button = /*#__PURE__*/forwardRef(function (_ref, ref) {
53
65
  'h-12 px-6 text-base': size === 'lg',
54
66
  'h-14 px-8 text-lg': size === 'xl',
55
67
  'h-16 px-10 text-xl': size === '2xl'
56
- }, TOUCH_TARGET, size === 'xs' || size === 'sm' || size === 'icon-sm' || size === 'icon'), 'w-full', fullWidth), ICON_SIZES[size], className);
68
+ }, TOUCH_TARGET, isLink || size === 'xs' || size === 'sm' || size === 'icon-sm' || size === 'icon'), 'w-full', fullWidth), ICON_SIZES[size],
69
+ // A text link: no background, border or padding (it keeps the size's text
70
+ // scale), underlined on hover, coloured by `tone`.
71
+ isLink && cn('h-auto bg-transparent p-0 underline-offset-4 hover:underline focus-visible:underline', LINK_TONES[tone] || LINK_TONES.primary), className);
57
72
  var content = jsxs(Fragment, {
58
73
  children: [leftIcon && jsx("span", {
59
74
  className: isIconSize ? undefined : 'mr-2',