@instruments/materia-ui 0.4.4 → 0.4.6

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/agent-docs.md CHANGED
@@ -80,6 +80,7 @@ Constrains its content to a fixed width-to-height ratio, sizing itself to the av
80
80
 
81
81
  - Give it exactly one child and let it fill - set the media to `size-full object-cover`; `AspectRatio` sizes to the available width and derives height from `ratio`.
82
82
  - Use `as` to change the element (a tag); use `render` only to swap in a component (e.g. a router Link). Never `render={<Box as=…/>}` - pass `as` directly.
83
+ - Show a material, swatch or sample at the default 1:1 - never landscape. A sample is cut square, and a wide crop reads as room photography; set a wide `ratio` only for a scene, an interior or video.
83
84
 
84
85
  ## AttributeCard
85
86
 
@@ -1033,6 +1034,7 @@ Extracted from the product-card and editorial-card media frames; available to an
1033
1034
  - Use `as` to change the element (a tag); use `render` only to swap in a component (e.g. a router Link). Never `render={<Box as=…/>}` - pass `as` directly.
1034
1035
  - Set `edge` to frame photography with an inset hairline that stays above the media; do not fake it with a border or an inset shadow on the frame, which the media hides.
1035
1036
  - Reach for `raised` only when the frame sits on the same fill as its surface; in a dense row the edge already separates it.
1037
+ - Frame a material, swatch or sample square: give the frame `aspect-square` (or wrap it in `AspectRatio`, which defaults to 1:1). Never crop a material landscape.
1036
1038
 
1037
1039
  ## MediaFrameEmbed
1038
1040
 
@@ -1244,6 +1246,7 @@ This is a presentational component that accepts handlers via props for app-speci
1244
1246
 
1245
1247
  - In Next.js apps pass `renderImage` wrapping `next/image` and `linkAs={Link}` - the defaults are a plain `<img>` and `<a>`.
1246
1248
  - Cart UI renders only when a `renderAddToCart` slot is provided; keep `dragAdapter` referentially stable (module-level constant).
1249
+ - Use ProductCard for a material in a grid or list; its image is square, which is how a material is always shown. Don't rebuild the card with a landscape image.
1247
1250
 
1248
1251
  ## ProductCardProvider
1249
1252
 
@@ -1833,6 +1836,7 @@ Chips paint the darkening overlay on every fill and draw the double-ring selecti
1833
1836
  - Feed fills through `color`/`background`/`colors`, never a hex in `className`/`style` - the props drive the contrast-aware text colour, selection ring, and darkening overlay.
1834
1837
  - Reach for `Swatch` for a selectable colour/gradient/material chip or tile; a tiny non-interactive status marker is `Dot`, and a display-only status/category label is `Badge`.
1835
1838
  - For a gradient or image chip fill pass `light` - auto contrast detection (`isLightColor`) only reads solid colours, so overlaid icons/labels lose legibility without it.
1839
+ - A material swatch is always square, as Swatch draws it; never stretch a tile into a landscape crop, and in a grid let the column set the size.
1836
1840
 
1837
1841
  ## SwatchFan
1838
1842
 
@@ -14,7 +14,9 @@ interface AspectRatioProps extends useRender.ComponentProps<"div"> {
14
14
  as?: LayoutElement;
15
15
  /**
16
16
  * Desired width-to-height ratio, expressed as a single number (width / height).
17
- * @default 16 / 9
17
+ * Square unless asked: a material is shown at 1:1, and only scenes and
18
+ * video take a wide ratio.
19
+ * @default 1
18
20
  */
19
21
  ratio?: number;
20
22
  }
@@ -25,13 +27,15 @@ interface AspectRatioProps extends useRender.ComponentProps<"div"> {
25
27
  *
26
28
  * @example
27
29
  * ```tsx
28
- * <AspectRatio ratio={16 / 9}>
29
- * <img alt="" className="size-full object-cover" src="/hero.jpg" />
30
+ * <AspectRatio>
31
+ * <img alt="" className="size-full object-cover" src="/oak-plank.jpg" />
30
32
  * </AspectRatio>
31
33
  * ```
32
34
  *
33
35
  * @bestPractice Give it exactly one child and let it fill - set the media to `size-full object-cover`; `AspectRatio` sizes to the available width and derives height from `ratio`.
34
36
  * @bestPractice Use `as` to change the element (a tag); use `render` only to swap in a component (e.g. a router Link). Never `render={<Box as=…/>}` - pass `as` directly.
37
+ * @bestPractice Show a material, swatch or sample at the default 1:1 - never landscape. A sample is cut square, and a wide crop reads as room photography; set a wide `ratio` only for a scene, an interior or video.
38
+ * @keywords material image, swatch image, sample image, thumbnail, square, image ratio
35
39
  * @category layout
36
40
  */
37
41
  declare const AspectRatio: ({ as, className, ratio, ref, render, style, ...props }: AspectRatioProps) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
@@ -2,7 +2,7 @@
2
2
  import { useRender } from "@base-ui/react/use-render";
3
3
  import { cn } from "../../utils/cn.js";
4
4
  import { reportDevIssue } from "../../utils/dev-issue.js";
5
- const DEFAULT_RATIO = 16 / 9;
5
+ const DEFAULT_RATIO = 1;
6
6
  /**
7
7
  * Constrains its content to a fixed width-to-height ratio, sizing itself to the
8
8
  * available width with CSS `aspect-ratio`; drop a single child (image, video,
@@ -10,13 +10,15 @@ const DEFAULT_RATIO = 16 / 9;
10
10
  *
11
11
  * @example
12
12
  * ```tsx
13
- * <AspectRatio ratio={16 / 9}>
14
- * <img alt="" className="size-full object-cover" src="/hero.jpg" />
13
+ * <AspectRatio>
14
+ * <img alt="" className="size-full object-cover" src="/oak-plank.jpg" />
15
15
  * </AspectRatio>
16
16
  * ```
17
17
  *
18
18
  * @bestPractice Give it exactly one child and let it fill - set the media to `size-full object-cover`; `AspectRatio` sizes to the available width and derives height from `ratio`.
19
19
  * @bestPractice Use `as` to change the element (a tag); use `render` only to swap in a component (e.g. a router Link). Never `render={<Box as=…/>}` - pass `as` directly.
20
+ * @bestPractice Show a material, swatch or sample at the default 1:1 - never landscape. A sample is cut square, and a wide crop reads as room photography; set a wide `ratio` only for a scene, an interior or video.
21
+ * @keywords material image, swatch image, sample image, thumbnail, square, image ratio
20
22
  * @category layout
21
23
  */
22
24
  const AspectRatio = ({ as, className, ratio = DEFAULT_RATIO, ref, render, style, ...props }) => {
@@ -51,6 +51,8 @@ export interface MediaFrameProps extends useRender.ComponentProps<"div"> {
51
51
  * @bestPractice Use `as` to change the element (a tag); use `render` only to swap in a component (e.g. a router Link). Never `render={<Box as=…/>}` - pass `as` directly.
52
52
  * @bestPractice Set `edge` to frame photography with an inset hairline that stays above the media; do not fake it with a border or an inset shadow on the frame, which the media hides.
53
53
  * @bestPractice Reach for `raised` only when the frame sits on the same fill as its surface; in a dense row the edge already separates it.
54
+ * @bestPractice Frame a material, swatch or sample square: give the frame `aspect-square` (or wrap it in `AspectRatio`, which defaults to 1:1). Never crop a material landscape.
55
+ * @keywords material image, product image, photo frame, thumbnail
54
56
  * @category layout
55
57
  */
56
58
  export declare const MediaFrame: ({ as, className, children, edge, raised, ref, render, ...props }: MediaFrameProps) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
@@ -25,6 +25,8 @@ import { reportDevIssue } from "../../utils/dev-issue.js";
25
25
  * @bestPractice Use `as` to change the element (a tag); use `render` only to swap in a component (e.g. a router Link). Never `render={<Box as=…/>}` - pass `as` directly.
26
26
  * @bestPractice Set `edge` to frame photography with an inset hairline that stays above the media; do not fake it with a border or an inset shadow on the frame, which the media hides.
27
27
  * @bestPractice Reach for `raised` only when the frame sits on the same fill as its surface; in a dense row the edge already separates it.
28
+ * @bestPractice Frame a material, swatch or sample square: give the frame `aspect-square` (or wrap it in `AspectRatio`, which defaults to 1:1). Never crop a material landscape.
29
+ * @keywords material image, product image, photo frame, thumbnail
28
30
  * @category layout
29
31
  */
30
32
  export const MediaFrame = ({ as, className, children, edge = false, raised = false, ref, render, ...props }) => {
@@ -38,6 +38,8 @@ type SwatchProps = ChipSwatchProps | TileSwatchProps;
38
38
  * @bestPractice Feed fills through `color`/`background`/`colors`, never a hex in `className`/`style` - the props drive the contrast-aware text colour, selection ring, and darkening overlay.
39
39
  * @bestPractice Reach for `Swatch` for a selectable colour/gradient/material chip or tile; a tiny non-interactive status marker is `Dot`, and a display-only status/category label is `Badge`.
40
40
  * @bestPractice For a gradient or image chip fill pass `light` - auto contrast detection (`isLightColor`) only reads solid colours, so overlaid icons/labels lose legibility without it.
41
+ * @bestPractice A material swatch is always square, as Swatch draws it; never stretch a tile into a landscape crop, and in a grid let the column set the size.
42
+ * @keywords material swatch, material sample, finish, colourway, colour chip
41
43
  * @category color
42
44
  */
43
45
  declare const Swatch: (props: SwatchProps) => import("react").JSX.Element;
@@ -29,6 +29,8 @@ import { SwatchTile } from "./swatch-tile.js";
29
29
  * @bestPractice Feed fills through `color`/`background`/`colors`, never a hex in `className`/`style` - the props drive the contrast-aware text colour, selection ring, and darkening overlay.
30
30
  * @bestPractice Reach for `Swatch` for a selectable colour/gradient/material chip or tile; a tiny non-interactive status marker is `Dot`, and a display-only status/category label is `Badge`.
31
31
  * @bestPractice For a gradient or image chip fill pass `light` - auto contrast detection (`isLightColor`) only reads solid colours, so overlaid icons/labels lose legibility without it.
32
+ * @bestPractice A material swatch is always square, as Swatch draws it; never stretch a tile into a landscape crop, and in a grid let the column set the size.
33
+ * @keywords material swatch, material sample, finish, colourway, colour chip
32
34
  * @category color
33
35
  */
34
36
  const Swatch = (props) => {
@@ -6,6 +6,7 @@ import { cn } from "@instruments/materia-ui/utils/cn";
6
6
  import { memo, useMemo } from "react";
7
7
  import { ProductCardAvailabilityStatus } from "./product-card-availability.js";
8
8
  import { useProductCard } from "./product-card-context.js";
9
+ import { PRODUCT_CARD_IMAGE_RADIUS, PRODUCT_CARD_SWATCH_IMAGE_RADIUS, } from "./product-card-radius.js";
9
10
  /**
10
11
  * Handle used when no `dragAdapter` is provided: a static, non-draggable
11
12
  * image. Module-level so its identity is stable across renders.
@@ -24,7 +25,7 @@ const NoPhotographyState = ({ className, inspiration, productName, }) => {
24
25
  if (inspiration) {
25
26
  return (_jsx("output", { "aria-label": `Photography unavailable for ${productName}`, className: cn("bg-muted/45 absolute inset-0 overflow-hidden rounded-3xl", className), "data-component": "product-card-image-unavailable" }));
26
27
  }
27
- return (_jsx("output", { "aria-label": `Photography unavailable for ${productName}`, className: cn("block w-full @[180px]/product-card:px-4 @[180px]/product-card:pt-4", className), "data-component": "product-card-image-unavailable", children: _jsx("span", { className: "bg-muted/45 block aspect-square w-full overflow-hidden rounded-3xl @[180px]/product-card:rounded-2xl" }) }));
28
+ return (_jsx("output", { "aria-label": `Photography unavailable for ${productName}`, className: cn("block w-full @[180px]/product-card:px-4 @[180px]/product-card:pt-4", className), "data-component": "product-card-image-unavailable", children: _jsx("span", { className: cn("bg-muted/45 block aspect-square w-full overflow-hidden", PRODUCT_CARD_IMAGE_RADIUS) }) }));
28
29
  };
29
30
  /**
30
31
  * The photograph itself, filling the frame `DraggableImage` insets - the
@@ -46,7 +47,7 @@ const ImageContent = ({ imageSrc, hoverImageSrc, showingInstallShot, productName
46
47
  productImageFade = showingInstallShot ? "opacity-0" : "";
47
48
  installImageFade = showingInstallShot ? "opacity-100" : "opacity-0";
48
49
  }
49
- return (_jsx(Box, { className: "absolute inset-0 overflow-hidden", children: _jsxs(MediaFrame, { className: "size-full rounded-3xl @[180px]/product-card:rounded-2xl", children: [renderImage({
50
+ return (_jsx(Box, { className: "absolute inset-0 overflow-hidden", children: _jsxs(MediaFrame, { className: cn("size-full", PRODUCT_CARD_IMAGE_RADIUS), children: [renderImage({
50
51
  alt: `${productName} - Product image`,
51
52
  className: cn("object-cover", hasHoverImage && "duration-normal transition-opacity ease-out", hasHoverImage && productImageFade),
52
53
  draggable: false,
@@ -89,11 +90,11 @@ const DraggableImage = ({ className, imageSrc, hoverImageSrc, variantName, showS
89
90
  scale: dragging ? 0.9 : 1,
90
91
  touchAction: "none",
91
92
  transition: "opacity 300ms ease-out, scale 300ms ease-out",
92
- }, ...dragListeners, ...props, children: _jsxs(MediaFrame, { className: "@[180px]/product-card:bg-card relative aspect-square w-full rounded-3xl bg-gray-100 @[180px]/product-card:rounded-2xl", children: [_jsx(ImageContent, { hoverImageSrc: hoverImageSrc, imageSrc: imageSrc, showingInstallShot: showingInstallShot, priority: priority, productName: variantName, sizes: sizes }), shouldShowSimilarityScore ? (_jsx(Box, { className: "absolute right-2 bottom-2 z-10", children: _jsxs(Box, { as: "span", className: "glass-media text-micro rounded px-1.5 py-0.5 text-white/70", children: [(similarityScore * 100).toFixed(0), "%"] }) })) : null, _jsx(ProductCardAvailabilityStatus, {})] }) }));
93
+ }, ...dragListeners, ...props, children: _jsxs(MediaFrame, { className: cn("@[180px]/product-card:bg-card relative aspect-square w-full bg-gray-100", PRODUCT_CARD_IMAGE_RADIUS), children: [_jsx(ImageContent, { hoverImageSrc: hoverImageSrc, imageSrc: imageSrc, showingInstallShot: showingInstallShot, priority: priority, productName: variantName, sizes: sizes }), shouldShowSimilarityScore ? (_jsx(Box, { className: "absolute right-2 bottom-2 z-10", children: _jsxs(Box, { as: "span", className: "glass-media text-micro rounded px-1.5 py-0.5 text-white/70", children: [(similarityScore * 100).toFixed(0), "%"] }) })) : null, _jsx(ProductCardAvailabilityStatus, {})] }) }));
93
94
  };
94
95
  const SwatchImage = ({ className, dragListeners, imageSizes, isDragging, mainImage, priority, ref, ...props }) => {
95
96
  const { currentVariant, renderImage } = useProductCard();
96
- return (_jsxs(Box, { className: cn("relative size-full overflow-hidden rounded-2xl bg-transparent", className), "data-component": "product-card-image", onClickCapture: (e) => {
97
+ return (_jsxs(Box, { className: cn("relative size-full overflow-hidden bg-transparent", PRODUCT_CARD_SWATCH_IMAGE_RADIUS, className), "data-component": "product-card-image", onClickCapture: (e) => {
97
98
  if (isDragging) {
98
99
  e.preventDefault();
99
100
  e.stopPropagation();
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The photograph's corner, concentric with the card's. Below 180px the
3
+ * photograph is the card and takes its corner; from 180px it sits 16px inside
4
+ * the card, so its corner is the card's less 16px (8px, then 20px once the card
5
+ * takes the 4xl corner at 240px).
6
+ */
7
+ export declare const PRODUCT_CARD_IMAGE_RADIUS = "rounded-3xl @[180px]/product-card:rounded-[calc(var(--radius-3xl)_-_1rem)] @[240px]/product-card:rounded-[calc(var(--radius-4xl)_-_1rem)]";
8
+ /** The swatch tile's photograph, 4px inside the tile's 3xl corner. */
9
+ export declare const PRODUCT_CARD_SWATCH_IMAGE_RADIUS = "rounded-[calc(var(--radius-3xl)_-_0.25rem)]";
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The photograph's corner, concentric with the card's. Below 180px the
3
+ * photograph is the card and takes its corner; from 180px it sits 16px inside
4
+ * the card, so its corner is the card's less 16px (8px, then 20px once the card
5
+ * takes the 4xl corner at 240px).
6
+ */
7
+ export const PRODUCT_CARD_IMAGE_RADIUS = "rounded-3xl @[180px]/product-card:rounded-[calc(var(--radius-3xl)_-_1rem)] @[240px]/product-card:rounded-[calc(var(--radius-4xl)_-_1rem)]";
8
+ /** The swatch tile's photograph, 4px inside the tile's 3xl corner. */
9
+ export const PRODUCT_CARD_SWATCH_IMAGE_RADIUS = "rounded-[calc(var(--radius-3xl)_-_0.25rem)]";
@@ -33,6 +33,8 @@ declare const ProductCardBase: ({ className, product, variantId, mode, width, ac
33
33
  * `linkAs={Link}` - the defaults are a plain `<img>` and `<a>`.
34
34
  * @bestPractice Cart UI renders only when a `renderAddToCart` slot is
35
35
  * provided; keep `dragAdapter` referentially stable (module-level constant).
36
+ * @bestPractice Use ProductCard for a material in a grid or list; its image is square, which is how a material is always shown. Don't rebuild the card with a landscape image.
37
+ * @keywords material card, material tile, sample card, catalogue card, product tile
36
38
  * @category catalogue
37
39
  */
38
40
  export declare const ProductCard: import("react").MemoExoticComponent<typeof ProductCardBase>;
@@ -42,6 +42,8 @@ const ProductCardBase = ({ className, product, variantId, mode = "group", width,
42
42
  * `linkAs={Link}` - the defaults are a plain `<img>` and `<a>`.
43
43
  * @bestPractice Cart UI renders only when a `renderAddToCart` slot is
44
44
  * provided; keep `dragAdapter` referentially stable (module-level constant).
45
+ * @bestPractice Use ProductCard for a material in a grid or list; its image is square, which is how a material is always shown. Don't rebuild the card with a landscape image.
46
+ * @keywords material card, material tile, sample card, catalogue card, product tile
45
47
  * @category catalogue
46
48
  */
47
49
  export const ProductCard = memo(ProductCardBase);
@@ -3,6 +3,7 @@ import { Box } from "@instruments/materia-ui/components/box";
3
3
  import { Skeleton } from "@instruments/materia-ui/components/skeleton";
4
4
  import { HStack, VStack } from "@instruments/materia-ui/components/stack";
5
5
  import { cn } from "@instruments/materia-ui/utils/cn";
6
+ import { PRODUCT_CARD_IMAGE_RADIUS } from "./product-card-radius.js";
6
7
  /** The fifth dot, which the card itself only shows from 240px up. */
7
8
  const SWATCH_PLACEHOLDERS = [0, 1, 2, 3, 4];
8
9
  const SwatchRow = () => (_jsx(HStack, { className: "min-h-5 gap-2.5", children: SWATCH_PLACEHOLDERS.map((index) => (_jsx(Skeleton, { className: cn("size-5 rounded-full", index === SWATCH_PLACEHOLDERS.length - 1 &&
@@ -29,4 +30,4 @@ const SwatchRow = () => (_jsx(HStack, { className: "min-h-5 gap-2.5", children:
29
30
  * misjudged the common case instead. A grid that turns either off on every
30
31
  * card passes the same flags here.
31
32
  */
32
- export const ProductCardSkeleton = ({ className, showAddToCart = true, showSwatches = true, style, }) => (_jsxs(Box, { className: cn("@container/product-card w-full", className), style: style, children: [_jsxs(VStack, { className: "flex @[180px]/product-card:hidden", gap: "none", children: [_jsx(Skeleton, { className: "aspect-square w-full rounded-3xl" }), _jsxs(VStack, { className: "gap-2.5 px-1.5 py-2", gap: "none", children: [_jsxs(VStack, { className: "gap-1 px-0.5", gap: "none", children: [_jsxs(VStack, { className: "gap-0.5", gap: "none", children: [_jsx(Skeleton, { className: "h-5 w-3/5" }), _jsx(Skeleton, { className: "h-4 w-4/5" })] }), _jsx(Skeleton, { className: "h-4 w-2/3" }), showSwatches ? _jsx(SwatchRow, {}) : null] }), showAddToCart ? (_jsx(Skeleton, { className: "h-9 w-full rounded-full" })) : null] })] }), _jsxs(VStack, { className: "bg-card hidden overflow-hidden rounded-3xl shadow-xs @[180px]/product-card:flex @[240px]/product-card:rounded-4xl", gap: "none", children: [_jsx(Box, { className: "w-full px-4 pt-4", children: _jsx(Skeleton, { className: "aspect-square w-full rounded-2xl", variant: "light" }) }), _jsx(VStack, { className: "pt-2 pb-4", gap: "none", children: _jsxs(VStack, { className: "gap-2.5 px-4 @[240px]/product-card:gap-4", gap: "none", children: [_jsxs(VStack, { className: "gap-1 px-0.5", gap: "none", children: [_jsxs(VStack, { gap: "none", children: [_jsx(Skeleton, { className: "h-5.5 w-3/5 rounded", variant: "light" }), _jsx(Skeleton, { className: "h-5 w-4/5 rounded", variant: "light" })] }), _jsx(Skeleton, { className: "h-4 w-2/3 rounded", variant: "light" }), showSwatches ? (_jsx(Box, { className: "pt-1", children: _jsx(SwatchRow, {}) })) : null] }), showAddToCart ? (_jsx(HStack, { children: _jsx(Skeleton, { className: "h-9 w-full rounded-full @[240px]/product-card:w-32", variant: "light" }) })) : null] }) })] })] }));
33
+ export const ProductCardSkeleton = ({ className, showAddToCart = true, showSwatches = true, style, }) => (_jsxs(Box, { className: cn("@container/product-card w-full", className), style: style, children: [_jsxs(VStack, { className: "flex @[180px]/product-card:hidden", gap: "none", children: [_jsx(Skeleton, { className: "aspect-square w-full rounded-3xl" }), _jsxs(VStack, { className: "gap-2.5 px-1.5 py-2", gap: "none", children: [_jsxs(VStack, { className: "gap-1 px-0.5", gap: "none", children: [_jsxs(VStack, { className: "gap-0.5", gap: "none", children: [_jsx(Skeleton, { className: "h-5 w-3/5" }), _jsx(Skeleton, { className: "h-4 w-4/5" })] }), _jsx(Skeleton, { className: "h-4 w-2/3" }), showSwatches ? _jsx(SwatchRow, {}) : null] }), showAddToCart ? (_jsx(Skeleton, { className: "h-9 w-full rounded-full" })) : null] })] }), _jsxs(VStack, { className: "bg-card hidden overflow-hidden rounded-3xl shadow-xs @[180px]/product-card:flex @[240px]/product-card:rounded-4xl", gap: "none", children: [_jsx(Box, { className: "w-full px-4 pt-4", children: _jsx(Skeleton, { className: cn("aspect-square w-full", PRODUCT_CARD_IMAGE_RADIUS), variant: "light" }) }), _jsx(VStack, { className: "pt-2 pb-4", gap: "none", children: _jsxs(VStack, { className: "gap-2.5 px-4 @[240px]/product-card:gap-4", gap: "none", children: [_jsxs(VStack, { className: "gap-1 px-0.5", gap: "none", children: [_jsxs(VStack, { gap: "none", children: [_jsx(Skeleton, { className: "h-5.5 w-3/5 rounded", variant: "light" }), _jsx(Skeleton, { className: "h-5 w-4/5 rounded", variant: "light" })] }), _jsx(Skeleton, { className: "h-4 w-2/3 rounded", variant: "light" }), showSwatches ? (_jsx(Box, { className: "pt-1", children: _jsx(SwatchRow, {}) })) : null] }), showAddToCart ? (_jsx(HStack, { children: _jsx(Skeleton, { className: "h-9 w-full rounded-full @[240px]/product-card:w-32", variant: "light" }) })) : null] }) })] })] }));
@@ -9,6 +9,7 @@ import { ProductCardImage } from "./product-card-image.js";
9
9
  import { ProductCardLink } from "./product-card-link.js";
10
10
  import { ProductCardProcessing } from "./product-card-processing.js";
11
11
  import { ProductCardQuickView } from "./product-card-quick-view.js";
12
+ import { PRODUCT_CARD_SWATCH_IMAGE_RADIUS } from "./product-card-radius.js";
12
13
  import { ProductCardSwatchAdd } from "./product-card-swatch-add.js";
13
14
  const SwatchShell = ({ active, children, dragging, processing, }) => (_jsx(Box, { className: cn(
14
15
  // Bare at rest, so the tile reads as its image alone; the card surface
@@ -32,7 +33,7 @@ const SwatchCaption = () => {
32
33
  /** The square photograph with the add control and Quick view over it. */
33
34
  const SwatchMedia = ({ overrideImageUrl, priority, }) => {
34
35
  const { active, addGlyph, availability, currentVariant, dragging, labels, linkComponent, linkPrefetch, menuOpen, onAdd, onCardClick, onQuickView, processing, productUrl, } = useProductCard();
35
- return (_jsxs(Box, { className: "relative aspect-square w-full overflow-hidden rounded-2xl", children: [_jsx(ProductCardLink, { blocked: availability === "blocked", className: "block size-full", href: productUrl, label: `View ${currentVariant.name}`, linkComponent: linkComponent, onActivate: onCardClick, prefetch: linkPrefetch, children: _jsx(ProductCardImage, { overrideImageUrl: overrideImageUrl, priority: priority }) }), dragging ? null : (_jsx(ProductCardSwatchAdd, { active: active, glyph: addGlyph, labels: labels, menuOpen: menuOpen, onAdd: onAdd })), dragging || processing !== undefined ? null : (_jsx(ProductCardQuickView, { align: "start", onQuickView: onQuickView }))] }));
36
+ return (_jsxs(Box, { className: cn("relative aspect-square w-full overflow-hidden", PRODUCT_CARD_SWATCH_IMAGE_RADIUS), children: [_jsx(ProductCardLink, { blocked: availability === "blocked", className: "block size-full", href: productUrl, label: `View ${currentVariant.name}`, linkComponent: linkComponent, onActivate: onCardClick, prefetch: linkPrefetch, children: _jsx(ProductCardImage, { overrideImageUrl: overrideImageUrl, priority: priority }) }), dragging ? null : (_jsx(ProductCardSwatchAdd, { active: active, glyph: addGlyph, labels: labels, menuOpen: menuOpen, onAdd: onAdd })), dragging || processing !== undefined ? null : (_jsx(ProductCardQuickView, { align: "start", onQuickView: onQuickView }))] }));
36
37
  };
37
38
  /**
38
39
  * ProductCardSwatch - the image-first tile for boards and canvases.
@@ -458,8 +458,8 @@ export const docsManifest = {
458
458
  name: "ratio",
459
459
  type: "number",
460
460
  required: false,
461
- defaultValue: "16 / 9",
462
- description: "Desired width-to-height ratio, expressed as a single number (width / height).",
461
+ defaultValue: "1",
462
+ description: "Desired width-to-height ratio, expressed as a single number (width / height).\nSquare unless asked: a material is shown at 1:1, and only scenes and\nvideo take a wide ratio.",
463
463
  },
464
464
  ],
465
465
  },
@@ -472,8 +472,16 @@ export const docsManifest = {
472
472
  bestPractices: [
473
473
  "Give it exactly one child and let it fill - set the media to `size-full object-cover`; `AspectRatio` sizes to the available width and derives height from `ratio`.",
474
474
  "Use `as` to change the element (a tag); use `render` only to swap in a component (e.g. a router Link). Never `render={<Box as=…/>}` - pass `as` directly.",
475
+ "Show a material, swatch or sample at the default 1:1 - never landscape. A sample is cut square, and a wide crop reads as room photography; set a wide `ratio` only for a scene, an interior or video.",
476
+ ],
477
+ keywords: [
478
+ "material image",
479
+ "swatch image",
480
+ "sample image",
481
+ "thumbnail",
482
+ "square",
483
+ "image ratio",
475
484
  ],
476
- keywords: [],
477
485
  uses: [],
478
486
  usedBy: [],
479
487
  anatomy: {
@@ -6957,8 +6965,9 @@ export const docsManifest = {
6957
6965
  "Use `as` to change the element (a tag); use `render` only to swap in a component (e.g. a router Link). Never `render={<Box as=…/>}` - pass `as` directly.",
6958
6966
  "Set `edge` to frame photography with an inset hairline that stays above the media; do not fake it with a border or an inset shadow on the frame, which the media hides.",
6959
6967
  "Reach for `raised` only when the frame sits on the same fill as its surface; in a dense row the edge already separates it.",
6968
+ "Frame a material, swatch or sample square: give the frame `aspect-square` (or wrap it in `AspectRatio`, which defaults to 1:1). Never crop a material landscape.",
6960
6969
  ],
6961
- keywords: [],
6970
+ keywords: ["material image", "product image", "photo frame", "thumbnail"],
6962
6971
  uses: [],
6963
6972
  usedBy: ["editorial-card", "product-card"],
6964
6973
  },
@@ -11704,8 +11713,15 @@ export const docsManifest = {
11704
11713
  "Feed fills through `color`/`background`/`colors`, never a hex in `className`/`style` - the props drive the contrast-aware text colour, selection ring, and darkening overlay.",
11705
11714
  "Reach for `Swatch` for a selectable colour/gradient/material chip or tile; a tiny non-interactive status marker is `Dot`, and a display-only status/category label is `Badge`.",
11706
11715
  "For a gradient or image chip fill pass `light` - auto contrast detection (`isLightColor`) only reads solid colours, so overlaid icons/labels lose legibility without it.",
11716
+ "A material swatch is always square, as Swatch draws it; never stretch a tile into a landscape crop, and in a grid let the column set the size.",
11717
+ ],
11718
+ keywords: [
11719
+ "material swatch",
11720
+ "material sample",
11721
+ "finish",
11722
+ "colourway",
11723
+ "colour chip",
11707
11724
  ],
11708
- keywords: [],
11709
11725
  uses: ["center", "flex", "icon"],
11710
11726
  usedBy: [
11711
11727
  "swatch-fan",
@@ -19657,10 +19673,17 @@ export const docsManifest = {
19657
19673
  bestPractices: [
19658
19674
  "In Next.js apps pass `renderImage` wrapping `next/image` and\n`linkAs={Link}` - the defaults are a plain `<img>` and `<a>`.",
19659
19675
  "Cart UI renders only when a `renderAddToCart` slot is\nprovided; keep `dragAdapter` referentially stable (module-level constant).",
19676
+ "Use ProductCard for a material in a grid or list; its image is square, which is how a material is always shown. Don't rebuild the card with a landscape image.",
19660
19677
  "Pass `renderImage` wrapping `next/image` in Next.js apps -\nthe default renderer is a plain `<img>` and skips Next's optimisation.",
19661
19678
  "Keep `dragAdapter` referentially stable (a module-level\nconstant) - an inline factory remounts the image subtree and drops the\ndrag ref mid-drag.",
19662
19679
  ],
19663
- keywords: [],
19680
+ keywords: [
19681
+ "material card",
19682
+ "material tile",
19683
+ "sample card",
19684
+ "catalogue card",
19685
+ "product tile",
19686
+ ],
19664
19687
  uses: [
19665
19688
  "badge",
19666
19689
  "box",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@instruments/materia-ui",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "Materia design system: React components, tokens and Tailwind v4 styles.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "repository": {
@@ -898,6 +898,7 @@
898
898
  "scripts": {
899
899
  "build": "rm -rf dist && tsc -p tsconfig.build.json && node scripts/finish-dist.ts",
900
900
  "deploy:figma-connect": "tsx figma/connect-templates.ts --publish",
901
+ "deploy:figma-descriptions": "tsx figma/descriptions.ts push",
901
902
  "deploy:figma-storybook-links": "tsx figma/storybook-links.ts --publish",
902
903
  "deploy:figma-tokens": "tsx figma/deploy-tokens.ts",
903
904
  "generate:agent-docs": "node scripts/generate-agent-docs.ts",