@recursica/adapter-mantine-v8 1.2.2 → 1.3.1

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 (58) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/OVERSTYLING.md +1 -1
  3. package/SETUP.md +34 -3
  4. package/dist/adapter-mantine-v8.cjs +2 -2
  5. package/dist/adapter-mantine-v8.cjs.map +1 -1
  6. package/dist/adapter-mantine-v8.css +1 -1
  7. package/dist/adapter-mantine-v8.js +3171 -3010
  8. package/dist/adapter-mantine-v8.js.map +1 -1
  9. package/dist/index.d.ts +191 -164
  10. package/llms.txt +1 -1
  11. package/package.json +4 -4
  12. package/recursica_brand.json +2 -2
  13. package/recursica_tokens.json +2 -2
  14. package/recursica_ui-kit.json +2 -2
  15. package/recursica_variables_scoped.css +7 -3
  16. package/src/components/Checkbox/Checkbox.module.css +1 -3
  17. package/src/components/Container/USAGE.md +0 -1
  18. package/src/components/FileInput/FileInput.module.css +8 -0
  19. package/src/components/FileUpload/FileUpload.module.css +1 -0
  20. package/src/components/FileUpload/FileUpload.tsx +1 -0
  21. package/src/components/Flex/USAGE.md +2 -1
  22. package/src/components/Group/USAGE.md +2 -1
  23. package/src/components/LayoutGrid/LAYOUT_GRID_IMPLEMENTATION_NOTES.md +88 -0
  24. package/src/components/LayoutGrid/LayoutGrid.columns.dom.test.tsx +66 -0
  25. package/src/components/LayoutGrid/LayoutGrid.module.css +48 -0
  26. package/src/components/LayoutGrid/LayoutGrid.stories.tsx +61 -0
  27. package/src/components/LayoutGrid/LayoutGrid.test.tsx +1 -0
  28. package/src/components/LayoutGrid/LayoutGrid.tsx +309 -0
  29. package/src/components/LayoutGrid/USAGE.md +76 -0
  30. package/src/components/Menu/Menu.stories.tsx +0 -1
  31. package/src/components/Pagination/IMPLEMENTATION_NOTES.md +29 -8
  32. package/src/components/Pagination/Pagination.icons.tsx +0 -32
  33. package/src/components/Pagination/Pagination.manifest.dom.test.tsx +108 -0
  34. package/src/components/Pagination/Pagination.module.css +25 -155
  35. package/src/components/Pagination/Pagination.tsx +328 -197
  36. package/src/components/Pagination/USAGE.md +1 -0
  37. package/src/components/Panel/PANEL_IMPLEMENTATION_NOTES.md +8 -0
  38. package/src/components/Panel/Panel.module.css +27 -2
  39. package/src/components/Panel/Panel.stories.tsx +4 -4
  40. package/src/components/Panel/Panel.tsx +10 -1
  41. package/src/components/Radio/Radio.module.css +2 -2
  42. package/src/components/SegmentedControl/SegmentedControl.module.css +26 -0
  43. package/src/components/Stack/USAGE.md +2 -1
  44. package/src/components/Stepper/Stepper.module.css +15 -0
  45. package/src/components/Switch/Switch.module.css +5 -0
  46. package/src/components/Tabs/Tabs.module.css +5 -0
  47. package/src/components/Tabs/Tabs.tsx +1 -0
  48. package/src/components/index.ts +1 -1
  49. package/src/index.ts +1 -0
  50. package/src/utils/breakpointsFromRecManifest.test.ts +40 -0
  51. package/src/utils/breakpointsFromRecManifest.ts +75 -0
  52. package/src/utils/index.ts +1 -0
  53. package/src/components/Grid/GRID_IMPLEMENTATION_NOTES.md +0 -85
  54. package/src/components/Grid/Grid.module.css +0 -41
  55. package/src/components/Grid/Grid.stories.tsx +0 -200
  56. package/src/components/Grid/Grid.test.tsx +0 -1
  57. package/src/components/Grid/Grid.tsx +0 -179
  58. package/src/components/Grid/USAGE.md +0 -61
@@ -21,7 +21,7 @@
21
21
  /* SideBySide Variant */
22
22
  .groupRoot[data-layout="side-by-side"] {
23
23
  flex-direction: column;
24
- flex-wrap: wrap;
24
+ flex-wrap: nowrap;
25
25
  padding-top: var(
26
26
  --recursica_ui-kit_components_checkbox-group_variants_layouts_side-by-side_properties_vertical-padding
27
27
  );
@@ -259,8 +259,6 @@
259
259
  );
260
260
  }
261
261
  .groupRoot[data-layout="side-by-side"] {
262
- flex-direction: row;
263
- flex-wrap: wrap;
264
262
  gap: var(
265
263
  --recursica_ui-kit_components_checkbox-group_variants_layouts_side-by-side_properties_gutter
266
264
  );
@@ -35,7 +35,6 @@ All Recursica components in the `@recursica/adapter-mantine-v8` package adhere s
35
35
 
36
36
  > [!IMPORTANT]
37
37
  >
38
- > - **Anti-override protection**: Rogues style injections (like inline `style` or arbitrary `className`) are automatically blocked by our prop layer unless `overStyled={true}` is explicitly provided.
39
38
  > - **No Direct Layers**: Do not pass a `layer` prop to this component. To place it on a specific visual layer, wrap it in a `<Layer layer={0|1|2|3}>` component natively.
40
39
  > - **Variables and Theming**: Styling is entirely determined by local CSS variables defined in `recursica_variables_scoped.css` and mapped in the component's CSS module.
41
40
 
@@ -58,6 +58,14 @@
58
58
  min-height: var(
59
59
  --recursica_ui-kit_components_file-input_properties_min-height
60
60
  );
61
+ /* Fixed height (icon + padding + border) so the nested clear Button overflows the row instead of growing the control. */
62
+ height: calc(
63
+ var(--recursica_ui-kit_components_file-input_properties_icon-size) + 2 *
64
+ var(
65
+ --recursica_ui-kit_components_file-input_properties_vertical-padding
66
+ ) +
67
+ 2 * var(--recursica_ui-kit_components_file-input_properties_border-size)
68
+ );
61
69
  gap: var(--recursica_ui-kit_components_file-input_properties_icon-text-gap);
62
70
  padding-top: var(
63
71
  --recursica_ui-kit_components_file-input_properties_vertical-padding
@@ -35,6 +35,7 @@
35
35
  display: flex;
36
36
  flex-direction: column;
37
37
  width: 100%;
38
+ max-width: var(--recursica_ui-kit_globals_form_field_size_max-width);
38
39
  gap: var(--recursica_ui-kit_components_file-upload_properties_list-spacing);
39
40
  }
40
41
 
@@ -265,6 +265,7 @@ export const FileUpload = forwardRef<HTMLDivElement, FileUploadProps>(
265
265
  required={required}
266
266
  withAsterisk={withAsterisk}
267
267
  id={id}
268
+ controlMaxWidth="var(--recursica_ui-kit_globals_form_field_size_max-width)"
268
269
  >
269
270
  <div
270
271
  ref={ref}
@@ -36,10 +36,11 @@ All Recursica components in the `@recursica/adapter-mantine-v8` package adhere s
36
36
 
37
37
  > [!IMPORTANT]
38
38
  >
39
- > - **Anti-override protection**: Rogues style injections (like inline `style` or arbitrary `className`) are automatically blocked by our prop layer unless `overStyled={true}` is explicitly provided.
40
39
  > - **No Direct Layers**: Do not pass a `layer` prop to this component. To place it on a specific visual layer, wrap it in a `<Layer layer={0|1|2|3}>` component natively.
41
40
  > - **Variables and Theming**: Styling is entirely determined by local CSS variables defined in `recursica_variables_scoped.css` and mapped in the component's CSS module.
42
41
 
42
+ > [!NOTE] > **Responsive props use your Mantine theme's breakpoints, not Forge's.** Keys such as `{ base, sm, md }` come from `theme.breakpoints`; Forge's layout grids switch via plain CSS `@media` and never edit the Mantine theme. Build your theme with `breakpointsFromRecManifest` so the two agree (see [LayoutGrid](../LayoutGrid/USAGE.md)).
43
+
43
44
  ---
44
45
 
45
46
  ## 4. Key Integration Features & Constraints
@@ -36,10 +36,11 @@ All Recursica components in the `@recursica/adapter-mantine-v8` package adhere s
36
36
 
37
37
  > [!IMPORTANT]
38
38
  >
39
- > - **Anti-override protection**: Rogues style injections (like inline `style` or arbitrary `className`) are automatically blocked by our prop layer unless `overStyled={true}` is explicitly provided.
40
39
  > - **No Direct Layers**: Do not pass a `layer` prop to this component. To place it on a specific visual layer, wrap it in a `<Layer layer={0|1|2|3}>` component natively.
41
40
  > - **Variables and Theming**: Styling is entirely determined by local CSS variables defined in `recursica_variables_scoped.css` and mapped in the component's CSS module.
42
41
 
42
+ > [!NOTE] > **Responsive props use your Mantine theme's breakpoints, not Forge's.** Keys such as `{ base, sm, md }` come from `theme.breakpoints`; Forge's layout grids switch via plain CSS `@media` and never edit the Mantine theme. Build your theme with `breakpointsFromRecManifest` so the two agree (see [LayoutGrid](../LayoutGrid/USAGE.md)).
43
+
43
44
  ---
44
45
 
45
46
  ## 4. Key Integration Features & Constraints
@@ -0,0 +1,88 @@
1
+ # LayoutGrid Implementation Notes
2
+
3
+ `LayoutGrid` is the Recursica layout grid. It wraps Mantine's `Grid`/`Grid.Col`, but unlike the other
4
+ primitive layout components (`Flex`, `Stack`, `Group`, `Container`) it has a formal Recursica prop
5
+ contract (`RecursicaLayoutGridProps` in `adapter-common`) and wires the design system's
6
+ `layout-grids` tokens. It was named `Grid` before 2026-10-02.
7
+
8
+ ## Forge contract
9
+
10
+ Forge always emits a default grid with columns (default 6, editable), column-gutter, row-gutter and
11
+ side margin, in the CSS and in `recursica_manifest.json`. Additional breakpoints are overrides.
12
+ Because the variables are always defined, nothing here has a fallback value.
13
+
14
+ ## No integrator-facing layout props
15
+
16
+ Columns, column-gutter, row-gutter and margin are design-system-managed, not per-instance settings.
17
+ `columns` was removed 2026-10-01 because Forge defines `layout-grids` per breakpoint, so no fixed
18
+ number is right at every viewport. For an arbitrary N-column grid, use Mantine's `Grid` directly.
19
+
20
+ ## Breakpoints
21
+
22
+ Forge emits a responsive alias per grid var (`--recursica_brand_layout-grids_{columns,row-gutter,column-gutter,margin}`),
23
+ set from `_default_*` at `:root` and redefined inside Forge's `@media` blocks per non-default grid.
24
+ `LayoutGrid` reads only the alias, so tokens follow breakpoints with no JS. Overlapping ranges are
25
+ not guarded by Forge; the later `@media` block wins.
26
+
27
+ Responsive props (`span={{ base, md }}`, `visibleFrom`, and Flex/Stack/Group props) switch at the
28
+ Mantine theme's breakpoints, which Forge never edits. Integrators should build `theme.breakpoints`
29
+ with `breakpointsFromRecManifest` so both switch at the same widths. `RecursicaThemeProvider` is UI-kit agnostic and does not
30
+ compare the manifest against the theme.
31
+
32
+ ## Why it is more than a thin wrapper
33
+
34
+ Mantine's Grid divides widths by a JS number; Forge's column count is a CSS variable redefined in
35
+ `@media` blocks, so Mantine's math can't follow it. All of `LayoutGrid.Col`'s span/offset handling
36
+ (`ColLayoutStyles`) exists to bridge that gap. Alternatives considered and rejected (2026-10-02):
37
+
38
+ - **Read the manifest in JS and pass Mantine the active column count.** Removes the per-column
39
+ styles, but makes the manifest required, flips the layout after hydration (flash with SSR),
40
+ creates a second source of truth next to the CSS variable, and loses span clamping.
41
+ - **Rewrite on native CSS grid.** Numeric spans get trivial, but breakpoint-map spans still need
42
+ per-breakpoint rules, and Mantine's `grow`, `justify`, `align` and `type="container"` are lost.
43
+ - **Drop breakpoint-map spans.** Removes most of the code but is a real feature loss.
44
+
45
+ ## How each token is applied
46
+
47
+ - **Columns**: Mantine computes widths from a JS number, which can't follow a CSS var.
48
+ `LayoutGrid.Col` emits its own `<style>` rule (via Mantine's `InlineStyles`; inline styles would
49
+ beat the breakpoint rules) setting `--rec-col-basis`, `--rec-col-max-width` and `--rec-col-offset`
50
+ to `calc(min(span, columns) / columns * 100%)`, for the base value and every key of a
51
+ breakpoint-map `span`/`offset`. It uses the theme's breakpoints, or the grid's `breakpoints` with
52
+ `type="container"`. `.col.col` in `LayoutGrid.module.css` applies them in place of Mantine's
53
+ `--col-flex-basis`/`--col-max-width`/`--col-offset`. An omitted span is 12, like Mantine.
54
+ `"auto"` and `"content"` point the hooks back at Mantine's own variables, since they don't depend
55
+ on the column count. Mantine still needs a `columns` number but it never decides a width
56
+ (`MANTINE_UNUSED_COLUMNS`). `grow` is surfaced as `data-recursica-grow` on the root so CSS can keep
57
+ Mantine's `max-width: 100%` for grown columns.
58
+ - **Column gutter**: passed to Mantine's `gutter` as `var(--recursica_brand_layout-grids_column-gutter)`.
59
+ - **Row gutter / margin**: no Mantine equivalent; `LayoutGrid.module.css` reads them directly.
60
+ `.root.root` applies the margin, `.inner.inner` overrides `margin-block`, `.col.col` overrides
61
+ `padding-block`. The doubled-class selectors beat Mantine's single-class specificity.
62
+ - Internal override hooks use the `--rec-*` prefix, never `--recursica*` (the build treats that as a
63
+ real token) and never a name Mantine uses itself.
64
+
65
+ ## `LayoutGrid.Col` contract is scaffolded
66
+
67
+ `RecursicaLayoutGridColProps` currently only contributes `children`. `span`, `order`, `visibleFrom`
68
+ and `hiddenFrom` were drafted and commented out (2026-09-22), so `LayoutGrid.Col` types them straight
69
+ off Mantine's `GridColProps`. No runtime effect. Queued in
70
+ `adapter-common/src/components/LayoutGrid/RecursicaLayoutGridColProps.ts`:
71
+
72
+ - `span`/`order`: mantine-v8 and mui-v7 independently built matching versions, worth formalizing.
73
+ Mantine's name `span` was chosen over mui-v7's `size` (2026-09-22), so mui-v7 renames when this lands.
74
+ - `visibleFrom`/`hiddenFrom` and per-breakpoint maps: blocked on a Recursica breakpoint naming
75
+ convention; `xs`/`sm`/`md`/`lg`/`xl` are Mantine's names. Don't copy them in.
76
+ - `offset`: never added, still undiffed between the adapters.
77
+
78
+ ## Brand-layer exemption
79
+
80
+ `layout-grids` tokens have no `ui-kit` layer in Forge's model (a grid is a page-layout primitive, not
81
+ a component), so `LayoutGrid.module.css` reads `brand_layout-grids_*` directly, with
82
+ `recursica-allow-brand:` headers (see `packages/recursica-token-analyzer/README.md` in the monorepo).
83
+
84
+ ## Stories
85
+
86
+ A single `Default` story reads `brand.layout-grids.default.columns` from the manifest (via `useRecursicaManifest()`) and fills two rows of that many single-column cells. Stories only use what the
87
+ `adapter-common` contract defines plus `span`, so they stay valid for every adapter; Mantine-only
88
+ props (`offset`, `grow`, `visibleFrom`/`hiddenFrom`, breakpoint-map spans) are not demonstrated.
@@ -0,0 +1,66 @@
1
+ import React from "react";
2
+ import { describe, it, expect } from "vitest";
3
+ import { createRoot } from "react-dom/client";
4
+ import { flushSync } from "react-dom";
5
+ import { MantineProvider, createTheme } from "@mantine/core";
6
+ import { LayoutGrid } from "./LayoutGrid";
7
+
8
+ const COLUMNS = "var(--recursica_brand_layout-grids_columns)";
9
+ const basis = (n: number) => `calc(min(${n}, ${COLUMNS}) / ${COLUMNS} * 100%)`;
10
+
11
+ function renderStyles(node: React.ReactElement, theme = createTheme({})) {
12
+ const container = document.createElement("div");
13
+ document.body.appendChild(container);
14
+ const root = createRoot(container);
15
+ flushSync(() =>
16
+ root.render(<MantineProvider theme={theme}>{node}</MantineProvider>),
17
+ );
18
+ const css = [...container.querySelectorAll("style")]
19
+ .map((el) => el.innerHTML)
20
+ .join("\n");
21
+ root.unmount();
22
+ container.remove();
23
+ return css;
24
+ }
25
+
26
+ describe("Grid.Col column widths", () => {
27
+ it("derives breakpoint-map spans from the Forge column count", () => {
28
+ const css = renderStyles(
29
+ <LayoutGrid>
30
+ <LayoutGrid.Col span={{ base: 12, md: 6 }}>a</LayoutGrid.Col>
31
+ </LayoutGrid>,
32
+ );
33
+ expect(css).toContain(`--rec-col-basis:${basis(12)}`);
34
+ expect(css).toContain(`--rec-col-basis:${basis(6)}`);
35
+ expect(css).toMatch(/@media\(min-width: 62em\)/);
36
+ });
37
+
38
+ it("defaults an omitted span to 12 columns", () => {
39
+ const css = renderStyles(
40
+ <LayoutGrid>
41
+ <LayoutGrid.Col>a</LayoutGrid.Col>
42
+ </LayoutGrid>,
43
+ );
44
+ expect(css).toContain(`--rec-col-basis:${basis(12)}`);
45
+ });
46
+
47
+ it("hands auto and content back to Mantine's values", () => {
48
+ const css = renderStyles(
49
+ <LayoutGrid>
50
+ <LayoutGrid.Col span={{ base: 4, md: "auto" }}>a</LayoutGrid.Col>
51
+ </LayoutGrid>,
52
+ );
53
+ expect(css).toContain("--rec-col-basis:var(--col-flex-basis)");
54
+ });
55
+
56
+ it("uses custom breakpoint names from the theme", () => {
57
+ const css = renderStyles(
58
+ <LayoutGrid>
59
+ <LayoutGrid.Col span={{ base: 12, tablet: 4 }}>a</LayoutGrid.Col>
60
+ </LayoutGrid>,
61
+ createTheme({ breakpoints: { mobile: "0px", tablet: "481px" } }),
62
+ );
63
+ expect(css).toContain("(min-width: 481px)");
64
+ expect(css).toContain(`--rec-col-basis:${basis(4)}`);
65
+ });
66
+ });
@@ -0,0 +1,48 @@
1
+ /* HARDCODED VALUES:
2
+ * None. (--rec-col-basis / --rec-col-offset are internal hooks set by LayoutGrid.Col, not tokens.)
3
+ */
4
+
5
+ /* Brand-layer exemptions (recursica-allow-brand) — layout-grid tokens have no ui-kit-layer
6
+ * representation in Forge's model (a grid is a page-layout primitive, not a "component"), so
7
+ * LayoutGrid is the one component that reads brand_layout-grids_* directly (the responsive alias, which Forge
8
+ * redefines per breakpoint inside @media). See
9
+ * LAYOUT_GRID_IMPLEMENTATION_NOTES.md.
10
+ * recursica-allow-brand: --recursica_brand_layout-grids_column-gutter
11
+ * recursica-allow-brand: --recursica_brand_layout-grids_row-gutter
12
+ * recursica-allow-brand: --recursica_brand_layout-grids_columns
13
+ * recursica-allow-brand: --recursica_brand_layout-grids_margin
14
+ */
15
+
16
+ .root.root {
17
+ /* Mantine handles column geometry (span, gutter width/margin-inline math) natively — the
18
+ column-gutter design token is applied via LayoutGrid.tsx's `gutter` prop, not here. `margin` has no
19
+ Mantine equivalent at all, so it's applied directly on the root element, always from the
20
+ design token — margin is design-system-managed, not an integrator-facing override. Doubled
21
+ selector beats Mantine's own single-class specificity regardless of stylesheet load order
22
+ (same idiom as Chip/other components). */
23
+ margin: var(--recursica_brand_layout-grids_margin);
24
+ }
25
+
26
+ .inner.inner {
27
+ /* Mantine's own `.inner` margin is uniform on both axes, computed from `gutter`. Overriding just
28
+ the block (vertical) component here lets row spacing differ from column spacing without
29
+ touching Mantine's horizontal width/margin-inline math, which stays driven by `gutter`. Always
30
+ the design token — row-gutter is design-system-managed, not an integrator-facing override. */
31
+ margin-block: calc(var(--recursica_brand_layout-grids_row-gutter) / -2);
32
+ }
33
+
34
+ .col.col {
35
+ /* Column width/offset for numeric `span`/`offset`, from Forge's breakpoint-aware column count
36
+ (set by LayoutGrid.Col as --rec-col-basis / --rec-col-offset). LayoutGrid.Col sets the hooks at
37
+ every breakpoint a span/offset is given; "auto"/"content" point them back at Mantine's own
38
+ values. */
39
+ flex-basis: var(--rec-col-basis, var(--col-flex-basis));
40
+ max-width: var(--rec-col-max-width, var(--col-max-width));
41
+ margin-inline-start: var(--rec-col-offset, var(--col-offset));
42
+ /* Row-gutter design token, independent of column-gutter; padding-inline stays Mantine's own. */
43
+ padding-block: calc(var(--recursica_brand_layout-grids_row-gutter) / 2);
44
+ }
45
+
46
+ .root[data-recursica-grow] .col.col {
47
+ max-width: 100%;
48
+ }
@@ -0,0 +1,61 @@
1
+ import React from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import { useRecursicaManifest } from "@recursica/adapter-common";
4
+ import { LayoutGrid } from "./LayoutGrid";
5
+
6
+ type LayoutGridStoryProps = React.ComponentProps<typeof LayoutGrid>;
7
+
8
+ const meta: Meta<LayoutGridStoryProps> = {
9
+ title: "UI-Kit/LayoutGrid",
10
+ component: LayoutGrid,
11
+ tags: ["autodocs"],
12
+ parameters: {
13
+ docs: {
14
+ description: {
15
+ component:
16
+ "LayoutGrid is the Recursica layout grid. Its column count, column-gutter, row-gutter and margin come from the design system's layout-grid tokens, which Forge defines per breakpoint, so they respond to the viewport and are not integrator-configurable.",
17
+ },
18
+ },
19
+ },
20
+ };
21
+
22
+ export default meta;
23
+
24
+ type Story = StoryObj<LayoutGridStoryProps>;
25
+
26
+ // Plain bordered cell: Card has a token min-width wider than one column, which would overflow it.
27
+ const Swatch = ({ children }: { children: React.ReactNode }) => (
28
+ <div
29
+ style={{
30
+ border: "1px dashed currentColor",
31
+ padding: "8px",
32
+ textAlign: "center",
33
+ }}
34
+ >
35
+ {children}
36
+ </div>
37
+ );
38
+
39
+ // Reads the default layout grid from the manifest (brand.layout-grids.default) and fills two full
40
+ // rows of single-column cells, so the story shows exactly the design system's column count.
41
+ const DefaultGrid = () => {
42
+ const manifest = useRecursicaManifest() as {
43
+ brand: {
44
+ "layout-grids": { default: { columns: { $value: number } } };
45
+ };
46
+ };
47
+ const columns = manifest.brand["layout-grids"].default.columns.$value;
48
+ return (
49
+ <LayoutGrid>
50
+ {Array.from({ length: columns * 2 }, (_, i) => (
51
+ <LayoutGrid.Col key={i} span={1}>
52
+ <Swatch>{(i % columns) + 1}</Swatch>
53
+ </LayoutGrid.Col>
54
+ ))}
55
+ </LayoutGrid>
56
+ );
57
+ };
58
+
59
+ export const Default: Story = {
60
+ render: () => <DefaultGrid />,
61
+ };
@@ -0,0 +1 @@
1
+ // Placeholder for future LayoutGrid component tests. Add tests here if/when needed.
@@ -0,0 +1,309 @@
1
+ import { createContext, forwardRef, useContext } from "react";
2
+ import {
3
+ Grid as MantineGrid,
4
+ InlineStyles,
5
+ getSortedBreakpoints,
6
+ useMantineTheme,
7
+ useRandomClassName,
8
+ type GridProps as MantineGridProps,
9
+ type GridColProps as MantineGridColProps,
10
+ createPolymorphicComponent,
11
+ } from "@mantine/core";
12
+ import {
13
+ mapLayoutProps,
14
+ type WithRecursicaSpacing,
15
+ } from "../../utils/filterStylingProps";
16
+ import {
17
+ type RecursicaLayoutGridColProps,
18
+ type RecursicaLayoutGridProps,
19
+ } from "@recursica/adapter-common";
20
+ import styles from "./LayoutGrid.module.css";
21
+
22
+ // HOW THIS WORKS (why it is more than a thin Mantine wrapper)
23
+ // Mantine's Grid divides column widths by a JS number, but Forge's column count is a CSS variable
24
+ // (--recursica_brand_layout-grids_columns) that Forge redefines inside @media blocks, so Mantine's
25
+ // math can never follow it. Everything below the root component exists to bridge that:
26
+ // 1. LayoutGrid passes Mantine a dummy `columns` (it never decides a width) and the token-driven
27
+ // column gutter. Row-gutter and margin come from LayoutGrid.module.css.
28
+ // 2. LayoutGrid.Col emits its own <style> rule (ColLayoutStyles) that sets --rec-col-basis,
29
+ // --rec-col-max-width and --rec-col-offset to calc(min(span, columns) / columns * 100%), once
30
+ // for the base value and once per key of a breakpoint-map span/offset (switching at the
31
+ // Mantine theme's breakpoints). LayoutGrid.module.css applies them in place of Mantine's own.
32
+ // 3. "auto" and "content" don't depend on the column count, so they hand back to Mantine's vars.
33
+ // Alternatives considered and rejected (2026-10-02): reading the manifest in JS to pass Mantine the
34
+ // current column count (makes the manifest required, flips layout after hydration, no clamping),
35
+ // and a native CSS-grid rewrite (loses Mantine's Grid API). See LAYOUT_GRID_IMPLEMENTATION_NOTES.md.
36
+
37
+ // Mantine's Grid requires a column count for its own span math. Every width this component sets
38
+ // is re-derived from Forge's --recursica_brand_layout-grids_columns (see colVars below), so this
39
+ // number never determines a rendered width; it only has to be a valid value for Mantine.
40
+ const MANTINE_UNUSED_COLUMNS = 1;
41
+
42
+ interface LayoutGridContext {
43
+ type?: "media" | "container";
44
+ breakpoints?: Record<string, string>;
45
+ }
46
+ const LayoutGridContext = createContext<LayoutGridContext>({});
47
+
48
+ /**
49
+ * LayoutGrid: the Recursica layout grid.
50
+ *
51
+ * Note: Unlike complex UI components, primitive layout components (Flex, Stack, Group, Container, LayoutGrid)
52
+ * DO NOT use the `RecursicaOverStyled` gatekeeper. Developers must be able to freely pass
53
+ * width, height, padding, margins, and flexbox alignment props to construct structural layouts.
54
+ *
55
+ * Recursica's `layout-grids` tokens (columns, column-gutter, row-gutter, margin) are
56
+ * design-system-managed and breakpoint-aware (Forge redefines them inside `@media` blocks), so
57
+ * LayoutGrid applies them itself via CSS variables. Neither Mantine's `gutter` nor `columns` is
58
+ * accepted; use Mantine's Grid directly for a fixed N-column grid. See
59
+ * `LAYOUT_GRID_IMPLEMENTATION_NOTES.md`.
60
+ */
61
+ export type LayoutGridProps = WithRecursicaSpacing<
62
+ Omit<MantineGridProps, "gutter" | "columns"> &
63
+ Omit<RecursicaLayoutGridProps, "columns">
64
+ >;
65
+
66
+ const _LayoutGrid = forwardRef<HTMLDivElement, LayoutGridProps>(
67
+ function LayoutGrid({ children, ...rest }, ref) {
68
+ const mergedClassNames: Partial<Record<string, string>> = {
69
+ root: styles.root,
70
+ inner: styles.inner,
71
+ col: styles.col,
72
+ };
73
+
74
+ const classNamesProp = rest.classNames;
75
+ if (
76
+ classNamesProp &&
77
+ typeof classNamesProp === "object" &&
78
+ !Array.isArray(classNamesProp)
79
+ ) {
80
+ const o = classNamesProp as Partial<Record<string, string>>;
81
+ mergedClassNames.root = o.root ? `${styles.root} ${o.root}` : styles.root;
82
+ mergedClassNames.inner = o.inner
83
+ ? `${styles.inner} ${o.inner}`
84
+ : styles.inner;
85
+ mergedClassNames.col = o.col ? `${styles.col} ${o.col}` : styles.col;
86
+ }
87
+
88
+ const classNameProp = rest.className as string | undefined;
89
+ const finalClass = classNameProp
90
+ ? `${styles.root} ${classNameProp}`
91
+ : styles.root;
92
+
93
+ // `gutter` is no longer a supported prop — column-gutter is design-system-managed (see below),
94
+ // dropped defensively here so a caller still passing the old prop name at runtime can't shadow
95
+ // the token-driven value passed to Mantine below via the `{...mappedRest}` spread.
96
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
97
+ const { gutter: _legacyGutter, ...restWithoutGutter } = rest as Record<
98
+ string,
99
+ unknown
100
+ >;
101
+
102
+ const mappedRest = mapLayoutProps(
103
+ restWithoutGutter as Record<string, unknown>,
104
+ );
105
+
106
+ // Mantine computes column widths from a plain number, which can't follow Forge's per-breakpoint
107
+ // column count. Numeric and breakpoint-map `span`/`offset` are therefore re-derived from
108
+ // --recursica_brand_layout-grids_columns in LayoutGrid.Col (see colVars). Only "auto" and "content"
109
+ // spans still come from Mantine, and those don't depend on the column count.
110
+ return (
111
+ <LayoutGridContext.Provider
112
+ value={{
113
+ type: rest.type,
114
+ breakpoints: rest.breakpoints as Record<string, string> | undefined,
115
+ }}
116
+ >
117
+ <MantineGrid
118
+ ref={ref}
119
+ columns={MANTINE_UNUSED_COLUMNS}
120
+ gutter="var(--recursica_brand_layout-grids_column-gutter)"
121
+ data-recursica-grow={rest.grow ? "" : undefined}
122
+ className={finalClass}
123
+ classNames={mergedClassNames}
124
+ {...(mappedRest as unknown as Omit<
125
+ MantineGridProps,
126
+ "gutter" | "columns"
127
+ >)}
128
+ >
129
+ {children}
130
+ </MantineGrid>
131
+ </LayoutGridContext.Provider>
132
+ );
133
+ },
134
+ );
135
+ _LayoutGrid.displayName = "LayoutGrid";
136
+
137
+ /**
138
+ * Recursica LayoutGrid layout wrapper.
139
+ *
140
+ * Supports polymorphism via the `component` prop for custom element rendering.
141
+ * @example
142
+ * ```tsx
143
+ * <LayoutGrid>
144
+ * <LayoutGrid.Col span={3}>Half width at 6 columns (full width at 3)</LayoutGrid.Col>
145
+ * <LayoutGrid.Col span={3}>Half width at 6 columns</LayoutGrid.Col>
146
+ * </LayoutGrid>
147
+ * ```
148
+ */
149
+ const LayoutGridBase = createPolymorphicComponent<"div", LayoutGridProps>(
150
+ _LayoutGrid,
151
+ );
152
+
153
+ // ============================================================
154
+ // GRID.COL
155
+ // ============================================================
156
+
157
+ // TODO(grid-col-contract): `RecursicaLayoutGridColProps` currently only contributes `children` here —
158
+ // `span`, `order`, `visibleFrom`, and `hiddenFrom` are all drafted in that type (adapter-common)
159
+ // but commented out for now (2026-09-22, Matt — paused to get Grid merged). Until they're
160
+ // restored there, this stays a plain pass-through of Mantine's own `GridColProps`; no Omit needed
161
+ // since nothing here is contract-backed yet. Re-add the intersection/Omit once
162
+ // `RecursicaLayoutGridColProps` picks these back up. See `LAYOUT_GRID_IMPLEMENTATION_NOTES.md`.
163
+ export type LayoutGridColProps = WithRecursicaSpacing<
164
+ MantineGridColProps & RecursicaLayoutGridColProps
165
+ >;
166
+
167
+ const COLUMNS_VAR = "var(--recursica_brand_layout-grids_columns)";
168
+
169
+ // Numeric span/offset are expressed against Forge's (breakpoint-aware) column count. Span is
170
+ // clamped to the current column count so e.g. span 6 fills the row at 3 columns instead of
171
+ // overflowing. "auto" and "content" don't depend on the column count, so those hand back to
172
+ // Mantine's own per-breakpoint values.
173
+ const basisOf = (span: unknown): Record<string, string> => {
174
+ if (typeof span === "number") {
175
+ const basis = `calc(min(${span}, ${COLUMNS_VAR}) / ${COLUMNS_VAR} * 100%)`;
176
+ return { "--rec-col-basis": basis, "--rec-col-max-width": basis };
177
+ }
178
+ return {
179
+ "--rec-col-basis": "var(--col-flex-basis)",
180
+ "--rec-col-max-width": "var(--col-max-width)",
181
+ };
182
+ };
183
+
184
+ const offsetOf = (offset: unknown): Record<string, string> => ({
185
+ "--rec-col-offset":
186
+ typeof offset === "number"
187
+ ? `calc(${offset} / ${COLUMNS_VAR} * 100%)`
188
+ : "var(--col-offset)",
189
+ });
190
+
191
+ type ResponsiveValue = Record<string, unknown>;
192
+ const isResponsive = (value: unknown): value is ResponsiveValue =>
193
+ typeof value === "object" && value !== null;
194
+ const valueAt = (value: unknown, breakpoint: string) =>
195
+ isResponsive(value)
196
+ ? value[breakpoint]
197
+ : breakpoint === "base"
198
+ ? value
199
+ : undefined;
200
+
201
+ const varsAt = (span: unknown, offset: unknown, breakpoint: string) => ({
202
+ ...(valueAt(span, breakpoint) === undefined
203
+ ? {}
204
+ : basisOf(valueAt(span, breakpoint))),
205
+ ...(valueAt(offset, breakpoint) === undefined
206
+ ? {}
207
+ : offsetOf(valueAt(offset, breakpoint))),
208
+ });
209
+
210
+ // Emitted as a <style> rule (like Mantine's own column styles) and not as inline style, because
211
+ // an inline custom property would beat the breakpoint rules. `span` defaults to 12 columns, like
212
+ // Mantine's Grid.Col.
213
+ function ColLayoutStyles({
214
+ selector,
215
+ span,
216
+ offset,
217
+ }: {
218
+ selector: string;
219
+ span: unknown;
220
+ offset: unknown;
221
+ }) {
222
+ const theme = useMantineTheme();
223
+ const { type, breakpoints: gridBreakpoints } = useContext(LayoutGridContext);
224
+ const breakpoints = gridBreakpoints ?? theme.breakpoints;
225
+ const names = Object.keys(breakpoints).filter(
226
+ (name) =>
227
+ valueAt(span, name) !== undefined || valueAt(offset, name) !== undefined,
228
+ );
229
+ const media = getSortedBreakpoints(
230
+ names,
231
+ breakpoints as Parameters<typeof getSortedBreakpoints>[1],
232
+ ).map(({ value }) => ({
233
+ query:
234
+ type === "container"
235
+ ? `mantine-grid (min-width: ${breakpoints[value]})`
236
+ : `(min-width: ${breakpoints[value]})`,
237
+ styles: varsAt(span, offset, value),
238
+ }));
239
+ return (
240
+ <InlineStyles
241
+ selector={selector}
242
+ styles={varsAt(span ?? 12, offset, "base")}
243
+ media={type === "container" ? undefined : media}
244
+ container={type === "container" ? media : undefined}
245
+ />
246
+ );
247
+ }
248
+
249
+ const _LayoutGridCol = forwardRef<HTMLDivElement, LayoutGridColProps>(
250
+ function LayoutGridCol({ children, ...rest }, ref) {
251
+ const mergedClassNames: Partial<Record<string, string>> = {
252
+ col: styles.col,
253
+ };
254
+
255
+ const classNamesProp = rest.classNames;
256
+ if (
257
+ classNamesProp &&
258
+ typeof classNamesProp === "object" &&
259
+ !Array.isArray(classNamesProp)
260
+ ) {
261
+ const o = classNamesProp as Partial<Record<string, string>>;
262
+ mergedClassNames.col = o.col ? `${styles.col} ${o.col}` : styles.col;
263
+ }
264
+
265
+ const classNameProp = rest.className as string | undefined;
266
+ const finalClass = classNameProp
267
+ ? `${styles.col} ${classNameProp}`
268
+ : styles.col;
269
+
270
+ const layoutClassName = useRandomClassName();
271
+
272
+ return (
273
+ <>
274
+ <ColLayoutStyles
275
+ selector={`.${layoutClassName}`}
276
+ span={rest.span}
277
+ offset={rest.offset}
278
+ />
279
+ <MantineGrid.Col
280
+ ref={ref}
281
+ className={`${finalClass} ${layoutClassName}`}
282
+ classNames={mergedClassNames}
283
+ {...(mapLayoutProps(
284
+ rest as Record<string, unknown>,
285
+ ) as unknown as MantineGridColProps)}
286
+ >
287
+ {children}
288
+ </MantineGrid.Col>
289
+ </>
290
+ );
291
+ },
292
+ );
293
+ _LayoutGridCol.displayName = "LayoutGridCol";
294
+
295
+ export const LayoutGridCol = createPolymorphicComponent<
296
+ "div",
297
+ LayoutGridColProps
298
+ >(_LayoutGridCol);
299
+
300
+ // ============================================================
301
+ // DOT NOTATION EXPORT
302
+ // ============================================================
303
+
304
+ type LayoutGridComponent = typeof LayoutGridBase & {
305
+ Col: typeof LayoutGridCol;
306
+ };
307
+
308
+ export const LayoutGrid = LayoutGridBase as LayoutGridComponent;
309
+ LayoutGrid.Col = LayoutGridCol;