@bsuite/page-builder 0.8.0 → 0.9.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.
@@ -0,0 +1,52 @@
1
+ import type { ReactNode } from 'react';
2
+ export interface CanvasCardProps {
3
+ /** Stable widget key used by PageGridLayout for layout persistence. Required. */
4
+ cardKey: string;
5
+ /** Default grid width in columns (12-col grid). Defaults to 12. */
6
+ w?: 2 | 3 | 4 | 6 | 8 | 12;
7
+ /** Minimum width in columns when the user resizes. Defaults to 4. */
8
+ minW?: 2 | 3 | 4 | 6 | 8 | 12;
9
+ /** Default grid height in 32px row units (seed before first measurement). Defaults to 6. */
10
+ h?: number;
11
+ /** Minimum height in row units when the user resizes. Defaults to 2. */
12
+ minH?: number;
13
+ /**
14
+ * When true, this card's height tracks its measured content height instead
15
+ * of a fixed manual size. `h` is only the seed row count used before the
16
+ * first ResizeObserver measurement lands. Threaded through to the underlying
17
+ * {@link GridLayoutItem.autoHeight} by `DraggableCardPage`.
18
+ *
19
+ * Defaults to TRUE. Cards grow to fit their content so forms and sections
20
+ * never render clipped.
21
+ *
22
+ * `autoHeight={false}` is RESERVED FOR VIRTUALIZED / WINDOWED LISTS ONLY
23
+ * (operator card-surfaces doctrine, 2026-07-23): a card whose body renders a
24
+ * windowed list that owns its own internal scroll and must NOT balloon to
25
+ * the full row count. An ordinary data table must NOT opt out — the doctrine
26
+ * is expand-by-default so every row is visible without an internal
27
+ * scrollbar. A blanket opt-out across ~120 crm7 pages was the 192px clip bug
28
+ * and was swept. Do not reintroduce it on a plain card or table.
29
+ */
30
+ autoHeight?: boolean;
31
+ /** Card body — typically a single card or page-header element. */
32
+ children: ReactNode;
33
+ }
34
+ /**
35
+ * CanvasCard — marker component consumed by `DraggableCardPage`.
36
+ *
37
+ * It renders `null` by design: `DraggableCardPage` reads its props off the JSX
38
+ * tree rather than rendering it, so accidental flat usage outside a
39
+ * `DraggableCardPage` is harmless rather than broken.
40
+ *
41
+ * Kept in its own module so a consumer can import the marker without pulling
42
+ * in `DraggableCardPage`'s transitive dependency on `react-grid-layout`. The
43
+ * `displayName` check inside `DraggableCardPage` compares against the literal
44
+ * string `'CanvasCard'`, so it keeps working across module boundaries, bundler
45
+ * chunk splits and duplicated package instances — an `instanceof`/identity
46
+ * check would not.
47
+ */
48
+ export declare function CanvasCard(_props: CanvasCardProps): null;
49
+ export declare namespace CanvasCard {
50
+ var displayName: string;
51
+ }
52
+ //# sourceMappingURL=CanvasCard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CanvasCard.d.ts","sourceRoot":"","sources":["../src/CanvasCard.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC,MAAM,WAAW,eAAe;IAC9B,iFAAiF;IACjF,OAAO,EAAE,MAAM,CAAC;IAChB,mEAAmE;IACnE,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC;IAC3B,qEAAqE;IACrE,IAAI,CAAC,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC;IAC9B,4FAA4F;IAC5F,CAAC,CAAC,EAAE,MAAM,CAAC;IACX,wEAAwE;IACxE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;;;;;;;;;;;;;OAgBG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,kEAAkE;IAClE,QAAQ,EAAE,SAAS,CAAC;CACrB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,eAAe,GAAG,IAAI,CAExD;yBAFe,UAAU"}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * CanvasCard — marker component consumed by `DraggableCardPage`.
3
+ *
4
+ * It renders `null` by design: `DraggableCardPage` reads its props off the JSX
5
+ * tree rather than rendering it, so accidental flat usage outside a
6
+ * `DraggableCardPage` is harmless rather than broken.
7
+ *
8
+ * Kept in its own module so a consumer can import the marker without pulling
9
+ * in `DraggableCardPage`'s transitive dependency on `react-grid-layout`. The
10
+ * `displayName` check inside `DraggableCardPage` compares against the literal
11
+ * string `'CanvasCard'`, so it keeps working across module boundaries, bundler
12
+ * chunk splits and duplicated package instances — an `instanceof`/identity
13
+ * check would not.
14
+ */
15
+ export function CanvasCard(_props) {
16
+ return null;
17
+ }
18
+ CanvasCard.displayName = 'CanvasCard';
@@ -0,0 +1,118 @@
1
+ /**
2
+ * DraggableCardPage — declarative wrapper around `PageGridLayout` that gives
3
+ * each child card its own draggable, independently-resizable widget slot.
4
+ *
5
+ * Why this exists
6
+ * ---------------
7
+ * The default way to reach `PageGridLayout` is
8
+ * `widgets={{ content: <Card/><Card/>… }}`, which packs an entire page into
9
+ * ONE widget. The canvas editor then drags the whole page as a single block
10
+ * instead of letting the user rearrange individual cards, and the page's
11
+ * height is capped at that one grid item's pixel height, so long forms render
12
+ * truncated. That is the operator's most-repeated complaint about this estate
13
+ * ("cards move as one block"), and it is a property of how the page calls the
14
+ * grid, not of the grid itself.
15
+ *
16
+ * Why it lives HERE rather than in an app
17
+ * ---------------------------------------
18
+ * It previously existed as three hand-copied forms (crm7, braden, throughput)
19
+ * and was ABSENT from two apps (BSU, conduit). The absence had a cost beyond
20
+ * duplication: BSU's exclusion ledger recorded that its dynamic card lists
21
+ * "cannot be split into static widget keys without a dynamic-widget
22
+ * registration mechanism", which is FALSE — `buildCanvasCardLayout` derives
23
+ * the widget dict from the children at render time, so a `.map()` over a
24
+ * runtime-variable list already produces N independent grid items. crm7 ships
25
+ * exactly that against a Postgres-backed 0..N array. The objection was an
26
+ * artifact of the primitive not being present, not a real constraint.
27
+ * Tracked as crm7#412 / bsuite#1995.
28
+ *
29
+ * Usage
30
+ * -----
31
+ * <DraggableCardPage pageKey="/people/:id" layoutVersion={3}>
32
+ * <CanvasCard cardKey="header" h={4}>
33
+ * <PageHeader heading="Person" />
34
+ * </CanvasCard>
35
+ * <CanvasCard cardKey="personalInfo" h={12}>
36
+ * <Card>…</Card>
37
+ * </CanvasCard>
38
+ * {isTraining && (
39
+ * <CanvasCard cardKey="trainingContract" h={16}>
40
+ * <Card>…</Card>
41
+ * </CanvasCard>
42
+ * )}
43
+ * </DraggableCardPage>
44
+ *
45
+ * Heights are 32px row units. Falsy children are skipped, so conditional cards
46
+ * collapse cleanly without leaving empty slots. Fragments are flattened.
47
+ *
48
+ * What this component deliberately does NOT do
49
+ * --------------------------------------------
50
+ * - **Permissions.** Gating is an app concern with an app-specific permission
51
+ * vocabulary. Wrap this component in the app's own gate.
52
+ * - **`compactType`.** It is NOT accepted, on purpose. `usePageGridLayout`
53
+ * selects `verticalCompactor` unconditionally for BOTH view and edit mode,
54
+ * and that is load-bearing: the previous edit-mode config
55
+ * (`preventCollision: true` with `compactType: null`) made react-grid-layout
56
+ * full-revert any gesture landing on an occupied cell, and `onDragStop` only
57
+ * emits when the layout changed — so a reverted gesture emitted nothing and
58
+ * NOTHING COULD EVER SAVE (bsuite#1588). Accepting a `compactType` prop here
59
+ * would re-open that hole. crm7's local wrapper passed `compactType ?? null`
60
+ * through a cast to a component that does not read it; the prop was dead,
61
+ * and dead is the correct state for it.
62
+ */
63
+ import { type ComponentType, type ReactNode } from 'react';
64
+ import { CanvasCard, type CanvasCardProps } from './CanvasCard.js';
65
+ import type { PageGridLayoutProps } from './types.js';
66
+ export { CanvasCard };
67
+ export type { CanvasCardProps };
68
+ export type DraggableCardPageProps = Omit<PageGridLayoutProps, 'widgets' | 'defaultLayouts'> & {
69
+ /**
70
+ * App-wide layout epoch, added to this page's `layoutVersion` before it
71
+ * reaches `PageGridLayout`. Defaults to 0.
72
+ *
73
+ * This is the lever for "a DraggableCardPage-level default changed for EVERY
74
+ * page in this app" — bump it once instead of hand-bumping `layoutVersion`
75
+ * at hundreds of call sites, which is guaranteed to miss some. Per-page
76
+ * `layoutVersion` bumps keep working on top of it.
77
+ *
78
+ * It is deliberately a PROP rather than a package constant, because each app
79
+ * has its own history of saved layouts: crm7 is at 101 (E5a autoHeight flip,
80
+ * then the 2026-07-23 card-surfaces sweep), braden and throughput at 100
81
+ * (the D-74 sweep), and a fresh adopter starts at 0. Baking a single number
82
+ * into the package would silently reset or fail to reset saved layouts
83
+ * depending on which app imported it.
84
+ *
85
+ * Distinct from the package-level `PACKAGE_LAYOUT_EPOCH`, which is a
86
+ * one-time reset applied by `usePageGridLayout` to every consumer at once
87
+ * when a package behaviour change poisons stored layouts suite-wide.
88
+ */
89
+ layoutEpoch?: number;
90
+ /**
91
+ * The grid component to render. Defaults to this package's `PageGridLayout`.
92
+ *
93
+ * Apps that wrap `PageGridLayout` in a local adapter (to inject permissions,
94
+ * a preference adapter, tenant scoping or entity-widget factories) pass that
95
+ * adapter here, so they get the shared card algorithm without giving up
96
+ * their adapter. An app with no adapter passes nothing.
97
+ */
98
+ gridComponent?: ComponentType<PageGridLayoutProps>;
99
+ /**
100
+ * Called with the display names of children that occupied no grid slot and
101
+ * were discarded. Defaults to a development-only `console.error`.
102
+ *
103
+ * A dropped child is a defect, not a style note: if it was a dialog, its
104
+ * trigger appears to do nothing — the button renders, the handler runs, and
105
+ * the element it toggles was never mounted.
106
+ */
107
+ onDroppedChildren?: (dropped: string[], pageKey: string) => void;
108
+ /**
109
+ * CanvasCard children. Falsy entries are ignored. Fragments are flattened so
110
+ * a conditional multi-card branch still registers each CanvasCard.
111
+ */
112
+ children: ReactNode;
113
+ };
114
+ export declare function DraggableCardPage({ layoutEpoch, gridComponent, onDroppedChildren, children, ...pageProps }: DraggableCardPageProps): import("react").JSX.Element;
115
+ export declare namespace DraggableCardPage {
116
+ var displayName: string;
117
+ }
118
+ //# sourceMappingURL=DraggableCardPage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"DraggableCardPage.d.ts","sourceRoot":"","sources":["../src/DraggableCardPage.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6DG;AAEH,OAAO,EAAW,KAAK,aAAa,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAGpE,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,iBAAiB,CAAC;AACnE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAItD,OAAO,EAAE,UAAU,EAAE,CAAC;AACtB,YAAY,EAAE,eAAe,EAAE,CAAC;AAEhC,MAAM,MAAM,sBAAsB,GAAG,IAAI,CACvC,mBAAmB,EACnB,SAAS,GAAG,gBAAgB,CAC7B,GAAG;IACF;;;;;;;;;;;;;;;;;;;OAmBG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;OAOG;IACH,aAAa,CAAC,EAAE,aAAa,CAAC,mBAAmB,CAAC,CAAC;IACnD;;;;;;;OAOG;IACH,iBAAiB,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACjE;;;OAGG;IACH,QAAQ,EAAE,SAAS,CAAC;CACrB,CAAC;AAgCF,wBAAgB,iBAAiB,CAAC,EAChC,WAAe,EACf,aAAa,EACb,iBAAiB,EACjB,QAAQ,EACR,GAAG,SAAS,EACb,EAAE,sBAAsB,+BA2BxB;yBAjCe,iBAAiB"}
@@ -0,0 +1,110 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * DraggableCardPage — declarative wrapper around `PageGridLayout` that gives
4
+ * each child card its own draggable, independently-resizable widget slot.
5
+ *
6
+ * Why this exists
7
+ * ---------------
8
+ * The default way to reach `PageGridLayout` is
9
+ * `widgets={{ content: <Card/><Card/>… }}`, which packs an entire page into
10
+ * ONE widget. The canvas editor then drags the whole page as a single block
11
+ * instead of letting the user rearrange individual cards, and the page's
12
+ * height is capped at that one grid item's pixel height, so long forms render
13
+ * truncated. That is the operator's most-repeated complaint about this estate
14
+ * ("cards move as one block"), and it is a property of how the page calls the
15
+ * grid, not of the grid itself.
16
+ *
17
+ * Why it lives HERE rather than in an app
18
+ * ---------------------------------------
19
+ * It previously existed as three hand-copied forms (crm7, braden, throughput)
20
+ * and was ABSENT from two apps (BSU, conduit). The absence had a cost beyond
21
+ * duplication: BSU's exclusion ledger recorded that its dynamic card lists
22
+ * "cannot be split into static widget keys without a dynamic-widget
23
+ * registration mechanism", which is FALSE — `buildCanvasCardLayout` derives
24
+ * the widget dict from the children at render time, so a `.map()` over a
25
+ * runtime-variable list already produces N independent grid items. crm7 ships
26
+ * exactly that against a Postgres-backed 0..N array. The objection was an
27
+ * artifact of the primitive not being present, not a real constraint.
28
+ * Tracked as crm7#412 / bsuite#1995.
29
+ *
30
+ * Usage
31
+ * -----
32
+ * <DraggableCardPage pageKey="/people/:id" layoutVersion={3}>
33
+ * <CanvasCard cardKey="header" h={4}>
34
+ * <PageHeader heading="Person" />
35
+ * </CanvasCard>
36
+ * <CanvasCard cardKey="personalInfo" h={12}>
37
+ * <Card>…</Card>
38
+ * </CanvasCard>
39
+ * {isTraining && (
40
+ * <CanvasCard cardKey="trainingContract" h={16}>
41
+ * <Card>…</Card>
42
+ * </CanvasCard>
43
+ * )}
44
+ * </DraggableCardPage>
45
+ *
46
+ * Heights are 32px row units. Falsy children are skipped, so conditional cards
47
+ * collapse cleanly without leaving empty slots. Fragments are flattened.
48
+ *
49
+ * What this component deliberately does NOT do
50
+ * --------------------------------------------
51
+ * - **Permissions.** Gating is an app concern with an app-specific permission
52
+ * vocabulary. Wrap this component in the app's own gate.
53
+ * - **`compactType`.** It is NOT accepted, on purpose. `usePageGridLayout`
54
+ * selects `verticalCompactor` unconditionally for BOTH view and edit mode,
55
+ * and that is load-bearing: the previous edit-mode config
56
+ * (`preventCollision: true` with `compactType: null`) made react-grid-layout
57
+ * full-revert any gesture landing on an occupied cell, and `onDragStop` only
58
+ * emits when the layout changed — so a reverted gesture emitted nothing and
59
+ * NOTHING COULD EVER SAVE (bsuite#1588). Accepting a `compactType` prop here
60
+ * would re-open that hole. crm7's local wrapper passed `compactType ?? null`
61
+ * through a cast to a component that does not read it; the prop was dead,
62
+ * and dead is the correct state for it.
63
+ */
64
+ import { useMemo } from 'react';
65
+ import { PageGridLayout as DefaultPageGridLayout } from './PageGridLayout.js';
66
+ import { buildCanvasCardLayout } from './canvasCardLayout.js';
67
+ import { CanvasCard } from './CanvasCard.js';
68
+ // Re-exported so a consumer can import both from one module, matching the
69
+ // long-standing crm7 import shape used by ~284 call sites.
70
+ export { CanvasCard };
71
+ /**
72
+ * `process.env.NODE_ENV` rather than `import.meta.env`: every bundler in the
73
+ * estate (Vite, Next, Rollup) statically replaces it, and the `typeof` guard
74
+ * keeps the expression safe in a raw browser ESM context where `process` does
75
+ * not exist. This package must not assume a Vite consumer.
76
+ */
77
+ function isDevelopment() {
78
+ return (typeof process !== 'undefined' &&
79
+ process.env?.NODE_ENV !== 'production');
80
+ }
81
+ function defaultOnDroppedChildren(dropped, pageKey) {
82
+ if (!isDevelopment())
83
+ return;
84
+ // console.error, not warn: React uses error for "you have written something
85
+ // that will not work", and this is that.
86
+ //
87
+ // Deliberately NOT a thrown error. Hundreds of pages use this component and
88
+ // a throw would turn a missing dialog into a blank page for a shape that has
89
+ // been shipping for months — trading a partial page for no page at all is
90
+ // not an improvement for the user in front of it.
91
+ console.error(`[DraggableCardPage] ${dropped.length} child(ren) of "${pageKey}" were DISCARDED and will not render: ${dropped.join(', ')}. ` +
92
+ `Only <CanvasCard> children (optionally inside a Fragment) occupy a grid slot — everything else is dropped. ` +
93
+ `If this is a dialog or a modal, its trigger will appear to do nothing: the button renders, the handler runs, and the element it toggles was never mounted. ` +
94
+ `Wrap it in <CanvasCard cardKey="…"> or move it outside <DraggableCardPage>.`);
95
+ }
96
+ export function DraggableCardPage({ layoutEpoch = 0, gridComponent, onDroppedChildren, children, ...pageProps }) {
97
+ const { widgets, layouts, dropped } = useMemo(() => buildCanvasCardLayout(children), [children]);
98
+ if (dropped.length > 0) {
99
+ (onDroppedChildren ?? defaultOnDroppedChildren)(dropped, pageProps.pageKey);
100
+ }
101
+ // Ensure a sensible default 12-column grid when the consumer does not
102
+ // override. Without this, PageGridLayout's fallback collapses to cols=1 at
103
+ // every breakpoint and the page renders as a single vertical column with no
104
+ // horizontal canvas area — the user-reported "drag cards into these white
105
+ // spaces" surface had no white spaces because the grid was 1 column wide.
106
+ const defaultCols = pageProps.defaultCols ?? 12;
107
+ const Grid = gridComponent ?? DefaultPageGridLayout;
108
+ return (_jsx(Grid, { ...pageProps, layoutVersion: (pageProps.layoutVersion ?? 1) + layoutEpoch, defaultCols: defaultCols, widgets: widgets, defaultLayouts: layouts }));
109
+ }
110
+ DraggableCardPage.displayName = 'DraggableCardPage';
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The CanvasCard → grid-layout algorithm, extracted from the component so it
3
+ * can be unit-tested and reused without rendering anything.
4
+ *
5
+ * This is the half of `DraggableCardPage` that was copied verbatim into three
6
+ * apps (crm7, braden, throughput) and was absent from two more (BSU, conduit).
7
+ * It has no app dependencies, no permission model and no adapter — those are
8
+ * the parts that legitimately differ per app and stay there.
9
+ */
10
+ import { type ReactElement, type ReactNode } from 'react';
11
+ import type { CanvasCardProps } from './CanvasCard.js';
12
+ import type { GridLayouts } from './types.js';
13
+ /** The 12-column grid every BSuite page canvas is laid out against. */
14
+ export declare const CANVAS_GRID_COLUMNS = 12;
15
+ export declare function isCanvasCardElement(node: ReactNode): node is ReactElement<CanvasCardProps>;
16
+ /**
17
+ * Best-effort display name for a node that is about to be discarded.
18
+ * "Something was dropped" is not actionable across hundreds of pages; the
19
+ * component's own name is what makes it findable.
20
+ */
21
+ export declare function describeNode(node: ReactNode): string;
22
+ /**
23
+ * Depth-first flatten: CanvasCards kept, Fragments expanded, everything else
24
+ * dropped — and RECORDED rather than vanishing.
25
+ *
26
+ * The dropping itself is correct and deliberate: this maps children onto grid
27
+ * slots, and a node with no `cardKey` has no slot to occupy. What was wrong is
28
+ * that it happened in total silence, so a page could render with a confirm
29
+ * dialog missing and look completely fine — the button appears, the click
30
+ * handler runs, state flips, and the element it toggles was never mounted.
31
+ *
32
+ * Fragments must be expanded explicitly because `Children.forEach` is shallow:
33
+ * it sees one Fragment node, not the CanvasCards inside it. A Fragment-wrapped
34
+ * conditional branch used to drop every nested card silently (the crm7 burn-7
35
+ * CI class, `leave/[id]`).
36
+ */
37
+ export declare function flattenCanvasCards(nodes: ReactNode): {
38
+ cards: ReactElement<CanvasCardProps>[];
39
+ dropped: string[];
40
+ };
41
+ export declare function clampColumns(value: number, min?: number, max?: number): number;
42
+ export interface CanvasCardLayoutResult {
43
+ /** Widget dict keyed by `cardKey`, ready for `PageGridLayout.widgets`. */
44
+ widgets: Record<string, ReactNode>;
45
+ /** `lg` breakpoint layout, ready for `PageGridLayout.defaultLayouts`. */
46
+ layouts: GridLayouts;
47
+ /** Display names of children that occupied no grid slot and were discarded. */
48
+ dropped: string[];
49
+ }
50
+ /**
51
+ * Build the `widgets` dict and `defaultLayouts.lg` array from CanvasCard
52
+ * children.
53
+ *
54
+ * Cards flow left-to-right and wrap to a new row when the next card would
55
+ * overflow the 12-column grid. Because the dict is built FROM THE CHILDREN AT
56
+ * RENDER TIME, a `.map()` producing N cards is fully supported and needs no
57
+ * registration mechanism — each mapped card becomes its own independent grid
58
+ * item. (This is the fact that disproves the "dynamic card lists cannot be
59
+ * split into static widget keys" objection recorded in several app ledgers.)
60
+ */
61
+ export declare function buildCanvasCardLayout(children: ReactNode): CanvasCardLayoutResult;
62
+ //# sourceMappingURL=canvasCardLayout.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"canvasCardLayout.d.ts","sourceRoot":"","sources":["../src/canvasCardLayout.tsx"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAIL,KAAK,YAAY,EACjB,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AACf,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AACvD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C,uEAAuE;AACvE,eAAO,MAAM,mBAAmB,KAAK,CAAC;AAEtC,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,SAAS,GACd,IAAI,IAAI,YAAY,CAAC,eAAe,CAAC,CAKvC;AAQD;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,SAAS,GAAG,MAAM,CAQpD;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,SAAS,GAAG;IACpD,KAAK,EAAE,YAAY,CAAC,eAAe,CAAC,EAAE,CAAC;IACvC,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB,CAqBA;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,SAAI,EAAE,GAAG,SAAsB,UAE7E;AAED,MAAM,WAAW,sBAAsB;IACrC,0EAA0E;IAC1E,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;IACnC,yEAAyE;IACzE,OAAO,EAAE,WAAW,CAAC;IACrB,+EAA+E;IAC/E,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,SAAS,GAClB,sBAAsB,CA0CxB"}
@@ -0,0 +1,120 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * The CanvasCard → grid-layout algorithm, extracted from the component so it
4
+ * can be unit-tested and reused without rendering anything.
5
+ *
6
+ * This is the half of `DraggableCardPage` that was copied verbatim into three
7
+ * apps (crm7, braden, throughput) and was absent from two more (BSU, conduit).
8
+ * It has no app dependencies, no permission model and no adapter — those are
9
+ * the parts that legitimately differ per app and stay there.
10
+ */
11
+ import { Children, Fragment, isValidElement, } from 'react';
12
+ /** The 12-column grid every BSuite page canvas is laid out against. */
13
+ export const CANVAS_GRID_COLUMNS = 12;
14
+ export function isCanvasCardElement(node) {
15
+ return (isValidElement(node) &&
16
+ node.type.displayName === 'CanvasCard');
17
+ }
18
+ function isFragmentElement(node) {
19
+ return isValidElement(node) && node.type === Fragment;
20
+ }
21
+ /**
22
+ * Best-effort display name for a node that is about to be discarded.
23
+ * "Something was dropped" is not actionable across hundreds of pages; the
24
+ * component's own name is what makes it findable.
25
+ */
26
+ export function describeNode(node) {
27
+ if (!isValidElement(node))
28
+ return typeof node === 'string'
29
+ ? `text ${JSON.stringify(node)}`
30
+ : String(node);
31
+ const t = node.type;
32
+ if (typeof t === 'string')
33
+ return `<${t}>`;
34
+ return `<${t?.displayName ?? t?.name ?? 'Unknown'}>`;
35
+ }
36
+ /**
37
+ * Depth-first flatten: CanvasCards kept, Fragments expanded, everything else
38
+ * dropped — and RECORDED rather than vanishing.
39
+ *
40
+ * The dropping itself is correct and deliberate: this maps children onto grid
41
+ * slots, and a node with no `cardKey` has no slot to occupy. What was wrong is
42
+ * that it happened in total silence, so a page could render with a confirm
43
+ * dialog missing and look completely fine — the button appears, the click
44
+ * handler runs, state flips, and the element it toggles was never mounted.
45
+ *
46
+ * Fragments must be expanded explicitly because `Children.forEach` is shallow:
47
+ * it sees one Fragment node, not the CanvasCards inside it. A Fragment-wrapped
48
+ * conditional branch used to drop every nested card silently (the crm7 burn-7
49
+ * CI class, `leave/[id]`).
50
+ */
51
+ export function flattenCanvasCards(nodes) {
52
+ const cards = [];
53
+ const dropped = [];
54
+ Children.forEach(nodes, (child) => {
55
+ // Falsy entries are the documented conditional shape — `{cond && <CanvasCard/>}`
56
+ // collapses to `false`, which is intentional and must stay silent.
57
+ if (child == null || child === false || child === true || child === '')
58
+ return;
59
+ if (isCanvasCardElement(child)) {
60
+ cards.push(child);
61
+ return;
62
+ }
63
+ if (isFragmentElement(child)) {
64
+ const inner = flattenCanvasCards(child.props.children);
65
+ cards.push(...inner.cards);
66
+ dropped.push(...inner.dropped);
67
+ return;
68
+ }
69
+ dropped.push(describeNode(child));
70
+ });
71
+ return { cards, dropped };
72
+ }
73
+ export function clampColumns(value, min = 1, max = CANVAS_GRID_COLUMNS) {
74
+ return Math.min(Math.max(value, min), max);
75
+ }
76
+ /**
77
+ * Build the `widgets` dict and `defaultLayouts.lg` array from CanvasCard
78
+ * children.
79
+ *
80
+ * Cards flow left-to-right and wrap to a new row when the next card would
81
+ * overflow the 12-column grid. Because the dict is built FROM THE CHILDREN AT
82
+ * RENDER TIME, a `.map()` producing N cards is fully supported and needs no
83
+ * registration mechanism — each mapped card becomes its own independent grid
84
+ * item. (This is the fact that disproves the "dynamic card lists cannot be
85
+ * split into static widget keys" objection recorded in several app ledgers.)
86
+ */
87
+ export function buildCanvasCardLayout(children) {
88
+ const widgets = {};
89
+ const lg = [];
90
+ let x = 0;
91
+ let y = 0;
92
+ let rowHeight = 0;
93
+ const { cards, dropped } = flattenCanvasCards(children);
94
+ for (const child of cards) {
95
+ // autoHeight defaults to TRUE — cards track measured content height unless
96
+ // a card explicitly opts out for a virtualized list. See CanvasCardProps.
97
+ const { cardKey, h = 6, minH = 2, minW = 4, autoHeight = true, children: body, } = child.props;
98
+ const normalizedMinW = clampColumns(minW);
99
+ const width = clampColumns(child.props.w ?? CANVAS_GRID_COLUMNS, normalizedMinW);
100
+ if (x + width > CANVAS_GRID_COLUMNS) {
101
+ y += rowHeight;
102
+ x = 0;
103
+ rowHeight = 0;
104
+ }
105
+ widgets[cardKey] = (_jsx("div", { className: "relative h-full group/canvas-card", children: body }));
106
+ lg.push({
107
+ i: cardKey,
108
+ x,
109
+ y,
110
+ w: width,
111
+ h,
112
+ minW: normalizedMinW,
113
+ minH,
114
+ ...(autoHeight ? { autoHeight: true } : {}),
115
+ });
116
+ x += width;
117
+ rowHeight = Math.max(rowHeight, h);
118
+ }
119
+ return { widgets, layouts: { lg }, dropped };
120
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  export { PageGridLayout } from './PageGridLayout.js';
2
2
  export { PageEditorLauncher } from './PageEditorLauncher.js';
3
+ export { CanvasCard } from './CanvasCard.js';
4
+ export type { CanvasCardProps } from './CanvasCard.js';
5
+ export { DraggableCardPage } from './DraggableCardPage.js';
6
+ export type { DraggableCardPageProps } from './DraggableCardPage.js';
7
+ export { buildCanvasCardLayout, flattenCanvasCards, isCanvasCardElement, describeNode, clampColumns, CANVAS_GRID_COLUMNS, } from './canvasCardLayout.js';
8
+ export type { CanvasCardLayoutResult } from './canvasCardLayout.js';
3
9
  export type { WidgetConfig, PageEditorLauncherProps } from './PageEditorLauncher.js';
4
10
  export { usePageGridLayout, DEFAULT_EDITOR_EVENT_NAMES, PAGE_GRID_EDITING_EVENT, } from './usePageGridLayout.js';
5
11
  export type { PageGridEditingEventDetail } from './usePageGridLayout.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAC7D,YAAY,EAAE,YAAY,EAAE,uBAAuB,EAAE,MAAM,yBAAyB,CAAC;AACrF,OAAO,EACL,iBAAiB,EACjB,0BAA0B,EAC1B,uBAAuB,GACxB,MAAM,wBAAwB,CAAC;AAChC,YAAY,EAAE,0BAA0B,EAAE,MAAM,wBAAwB,CAAC;AACzE,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AACxD,YAAY,EAAE,4BAA4B,EAAE,MAAM,iBAAiB,CAAC;AACpE,OAAO,EACL,2BAA2B,EAC3B,yBAAyB,EACzB,8BAA8B,EAC9B,yBAAyB,GAC1B,MAAM,sBAAsB,CAAC;AAC9B,YAAY,EACV,gBAAgB,EAChB,sBAAsB,EACtB,gBAAgB,GACjB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,sBAAsB,EACtB,sBAAsB,GACvB,MAAM,6BAA6B,CAAC;AACrC,YAAY,EACV,6BAA6B,EAC7B,oBAAoB,GACrB,MAAM,6BAA6B,CAAC;AACrC,OAAO,EAAE,kBAAkB,EAAE,wBAAwB,EAAE,MAAM,kBAAkB,CAAC;AAChF,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAC3D,YAAY,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAC;AACrE,OAAO,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AAClE,YAAY,EACV,kBAAkB,EAClB,0BAA0B,EAC1B,cAAc,EACd,WAAW,EACX,mBAAmB,EACnB,yBAAyB,EACzB,yBAAyB,EACzB,mBAAmB,EACnB,wBAAwB,EACxB,uBAAuB,EACvB,wBAAwB,EACxB,gCAAgC,EAChC,wBAAwB,EACxB,uBAAuB,EACvB,UAAU,GACX,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAC7D,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,YAAY,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AACvD,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAC3D,YAAY,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAC;AACrE,OAAO,EACL,qBAAqB,EACrB,kBAAkB,EAClB,mBAAmB,EACnB,YAAY,EACZ,YAAY,EACZ,mBAAmB,GACpB,MAAM,uBAAuB,CAAC;AAC/B,YAAY,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAC;AACpE,YAAY,EAAE,YAAY,EAAE,uBAAuB,EAAE,MAAM,yBAAyB,CAAC;AACrF,OAAO,EACL,iBAAiB,EACjB,0BAA0B,EAC1B,uBAAuB,GACxB,MAAM,wBAAwB,CAAC;AAChC,YAAY,EAAE,0BAA0B,EAAE,MAAM,wBAAwB,CAAC;AACzE,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AACxD,YAAY,EAAE,4BAA4B,EAAE,MAAM,iBAAiB,CAAC;AACpE,OAAO,EACL,2BAA2B,EAC3B,yBAAyB,EACzB,8BAA8B,EAC9B,yBAAyB,GAC1B,MAAM,sBAAsB,CAAC;AAC9B,YAAY,EACV,gBAAgB,EAChB,sBAAsB,EACtB,gBAAgB,GACjB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,sBAAsB,EACtB,sBAAsB,GACvB,MAAM,6BAA6B,CAAC;AACrC,YAAY,EACV,6BAA6B,EAC7B,oBAAoB,GACrB,MAAM,6BAA6B,CAAC;AACrC,OAAO,EAAE,kBAAkB,EAAE,wBAAwB,EAAE,MAAM,kBAAkB,CAAC;AAChF,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAC3D,YAAY,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAC;AACrE,OAAO,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AAClE,YAAY,EACV,kBAAkB,EAClB,0BAA0B,EAC1B,cAAc,EACd,WAAW,EACX,mBAAmB,EACnB,yBAAyB,EACzB,yBAAyB,EACzB,mBAAmB,EACnB,wBAAwB,EACxB,uBAAuB,EACvB,wBAAwB,EACxB,gCAAgC,EAChC,wBAAwB,EACxB,uBAAuB,EACvB,UAAU,GACX,MAAM,YAAY,CAAC"}
package/dist/index.js CHANGED
@@ -1,5 +1,8 @@
1
1
  export { PageGridLayout } from './PageGridLayout.js';
2
2
  export { PageEditorLauncher } from './PageEditorLauncher.js';
3
+ export { CanvasCard } from './CanvasCard.js';
4
+ export { DraggableCardPage } from './DraggableCardPage.js';
5
+ export { buildCanvasCardLayout, flattenCanvasCards, isCanvasCardElement, describeNode, clampColumns, CANVAS_GRID_COLUMNS, } from './canvasCardLayout.js';
3
6
  export { usePageGridLayout, DEFAULT_EDITOR_EVENT_NAMES, PAGE_GRID_EDITING_EVENT, } from './usePageGridLayout.js';
4
7
  export { rescaleLayout } from './rescaleLayout.js';
5
8
  export { computeAutoHeightRows } from './autoHeight.js';
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The card-surface contract scanner — ONE implementation, shipped from the
3
+ * package, replacing five divergent hand-rolled copies.
4
+ *
5
+ * Why this exists
6
+ * ---------------
7
+ * crm7 wrote a card-unglue contract test. BSU, conduit, throughput and braden
8
+ * each PORTED it, and each silently dropped checks during the port. The
9
+ * divergence was not cosmetic:
10
+ *
11
+ * - `findUngriddedMultiCard` — the check crm7's own comment calls "the actual
12
+ * root cause of the operator's platform-wide complaint" — was ABSENT from
13
+ * BSU, throughput and braden, and present only in a narrowed form in
14
+ * conduit. In throughput and braden it was absent structurally: their glue
15
+ * scan is gated on the file containing `CanvasCard` at all, so a page with
16
+ * two cards and no grid primitive is invisible by construction.
17
+ * - conduit's header documents THREE card idioms and its regexes detect TWO.
18
+ * The third (`rounded-lg border p-4` section panels) has no detector, which
19
+ * is how five known-glued conduit pages pass as an empty ledger.
20
+ * - BSU's scan only walks files containing the literal string
21
+ * `PageGridLayout`, so every BSU page that never adopted the grid is
22
+ * structurally invisible to BSU's own gate.
23
+ *
24
+ * A gate that cannot tell "checked nothing" from "found nothing" is not a gate
25
+ * (D-92). This module therefore ASSERTS ITS INPUTS: it fails closed if a scan
26
+ * root is missing or matches zero files, and it reports how it reached its
27
+ * verdict rather than only the verdict.
28
+ *
29
+ * What stays per-app
30
+ * ------------------
31
+ * Scan roots, card vocabulary, class-based surface patterns, and the exclusion
32
+ * ledgers. Those are operational truth about a specific app. Everything else —
33
+ * the detection logic — lives here so it can only be fixed once.
34
+ *
35
+ * Node-only: this module touches the filesystem and is exported from
36
+ * `@bsuite/page-builder/scanner`, NOT from the package root, so it never
37
+ * reaches a browser bundle.
38
+ */
39
+ export type CardSurfaceIdiom =
40
+ /** 2+ card-like components packed inside ONE grid slot. */
41
+ 'glued-widget'
42
+ /** 2+ card-like components on a page that never reaches a grid primitive. */
43
+ | 'ungridded-multi-card'
44
+ /** A retired single-widget primitive still present or still imported. */
45
+ | 'retired-primitive'
46
+ /** autoHeight opt-out on a card that is not a virtualized list. */
47
+ | 'autoheight-optout'
48
+ /** autoHeight={false} with a seed height so small the card must clip. */
49
+ | 'clipped-card';
50
+ export interface CardSurfaceFinding {
51
+ file: string;
52
+ idiom: CardSurfaceIdiom;
53
+ detail: string;
54
+ /** Number of card-like components involved, where meaningful. */
55
+ cardCount?: number;
56
+ }
57
+ export interface CardSurfaceScannerConfig {
58
+ /** Absolute path to the repo root. */
59
+ projectRoot: string;
60
+ /**
61
+ * Directories to walk, repo-relative. Every one MUST exist and MUST contain
62
+ * at least one scannable file, or the scan fails closed.
63
+ */
64
+ scanRoots: string[];
65
+ /**
66
+ * Component names that count as a card. Apps differ materially here: crm7
67
+ * uses `Card`/`StatCard`/`SummaryCard`, BSU adds `ServiceCard`, conduit uses
68
+ * `SummaryCard`/`CounterCard`.
69
+ */
70
+ cardTags: string[];
71
+ /**
72
+ * Class-based card surfaces — a card that is a styled `div`, not a
73
+ * component. This is the idiom conduit documented and never implemented, and
74
+ * the one BSU's `glass-card` tiles use. Supply e.g.
75
+ * `[/glass-card/, /rounded-lg border p-4/]`.
76
+ */
77
+ classSurfaces?: RegExp[];
78
+ /** Identifiers that prove a file reached the grid. */
79
+ gridPrimitives?: string[];
80
+ /** Repo-relative path of a retired single-widget primitive that must not exist. */
81
+ retiredPrimitivePaths?: string[];
82
+ /** file -> reason. A glued widget listed here is allowed. */
83
+ gluedExclusions?: Record<string, string>;
84
+ /** file -> reason. An ungridded multi-card page listed here is allowed. */
85
+ ungriddedExclusions?: Record<string, string>;
86
+ /**
87
+ * Directories deliberately NOT scanned, each with a reason. Declaring a
88
+ * blind spot is required; having an undeclared one is the defect.
89
+ */
90
+ blindSpots?: Record<string, string>;
91
+ /**
92
+ * If a file matches this, an `autoHeight={false}` in it is permitted — the
93
+ * opt-out is reserved for genuinely virtualized/windowed lists.
94
+ */
95
+ autoHeightEscapeHatch?: RegExp;
96
+ /** File extensions to scan. Defaults to .tsx. */
97
+ extensions?: string[];
98
+ }
99
+ export interface CardSurfaceScanResult {
100
+ findings: CardSurfaceFinding[];
101
+ /** Files actually read. If this is 0 the scan proved nothing. */
102
+ filesScanned: number;
103
+ scanRootsResolved: string[];
104
+ /** Exclusion-ledger keys that no longer match any finding — stale entries. */
105
+ staleExclusions: string[];
106
+ /** Human-readable account of how the verdict was reached. */
107
+ summary: string;
108
+ }
109
+ /**
110
+ * Strip block comments, line comments and JSX comments before matching.
111
+ *
112
+ * Three of the five hand-rolled copies matched against raw file content, which
113
+ * produced two opposite errors at once: a commented-out `<Card/><Card/>`
114
+ * example false-POSITIVES as glue, and a file that merely MENTIONS
115
+ * `CanvasCard` in a TODO comment false-NEGATIVES as "already gridded" and is
116
+ * skipped entirely. Stripping first fixes both.
117
+ */
118
+ export declare function stripComments(source: string): string;
119
+ export declare function scanCardSurfaces(config: CardSurfaceScannerConfig): CardSurfaceScanResult;
120
+ //# sourceMappingURL=cardSurfaceScanner.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cardSurfaceScanner.d.ts","sourceRoot":"","sources":["../../src/scanner/cardSurfaceScanner.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAKH,MAAM,MAAM,gBAAgB;AAC1B,2DAA2D;AACzD,cAAc;AAChB,6EAA6E;GAC3E,sBAAsB;AACxB,yEAAyE;GACvE,mBAAmB;AACrB,mEAAmE;GACjE,mBAAmB;AACrB,yEAAyE;GACvE,cAAc,CAAC;AAEnB,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,gBAAgB,CAAC;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,iEAAiE;IACjE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,wBAAwB;IACvC,sCAAsC;IACtC,WAAW,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB;;;;OAIG;IACH,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,sDAAsD;IACtD,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,mFAAmF;IACnF,qBAAqB,CAAC,EAAE,MAAM,EAAE,CAAC;IACjC,6DAA6D;IAC7D,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACzC,2EAA2E;IAC3E,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7C;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC;;;OAGG;IACH,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,iDAAiD;IACjD,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;CACvB;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,kBAAkB,EAAE,CAAC;IAC/B,iEAAiE;IACjE,YAAY,EAAE,MAAM,CAAC;IACrB,iBAAiB,EAAE,MAAM,EAAE,CAAC;IAC5B,8EAA8E;IAC9E,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B,6DAA6D;IAC7D,OAAO,EAAE,MAAM,CAAC;CACjB;AASD;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAKpD;AAwHD,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,wBAAwB,GAC/B,qBAAqB,CAwIvB"}
@@ -0,0 +1,289 @@
1
+ /**
2
+ * The card-surface contract scanner — ONE implementation, shipped from the
3
+ * package, replacing five divergent hand-rolled copies.
4
+ *
5
+ * Why this exists
6
+ * ---------------
7
+ * crm7 wrote a card-unglue contract test. BSU, conduit, throughput and braden
8
+ * each PORTED it, and each silently dropped checks during the port. The
9
+ * divergence was not cosmetic:
10
+ *
11
+ * - `findUngriddedMultiCard` — the check crm7's own comment calls "the actual
12
+ * root cause of the operator's platform-wide complaint" — was ABSENT from
13
+ * BSU, throughput and braden, and present only in a narrowed form in
14
+ * conduit. In throughput and braden it was absent structurally: their glue
15
+ * scan is gated on the file containing `CanvasCard` at all, so a page with
16
+ * two cards and no grid primitive is invisible by construction.
17
+ * - conduit's header documents THREE card idioms and its regexes detect TWO.
18
+ * The third (`rounded-lg border p-4` section panels) has no detector, which
19
+ * is how five known-glued conduit pages pass as an empty ledger.
20
+ * - BSU's scan only walks files containing the literal string
21
+ * `PageGridLayout`, so every BSU page that never adopted the grid is
22
+ * structurally invisible to BSU's own gate.
23
+ *
24
+ * A gate that cannot tell "checked nothing" from "found nothing" is not a gate
25
+ * (D-92). This module therefore ASSERTS ITS INPUTS: it fails closed if a scan
26
+ * root is missing or matches zero files, and it reports how it reached its
27
+ * verdict rather than only the verdict.
28
+ *
29
+ * What stays per-app
30
+ * ------------------
31
+ * Scan roots, card vocabulary, class-based surface patterns, and the exclusion
32
+ * ledgers. Those are operational truth about a specific app. Everything else —
33
+ * the detection logic — lives here so it can only be fixed once.
34
+ *
35
+ * Node-only: this module touches the filesystem and is exported from
36
+ * `@bsuite/page-builder/scanner`, NOT from the package root, so it never
37
+ * reaches a browser bundle.
38
+ */
39
+ import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
40
+ import { join, relative, sep } from 'node:path';
41
+ const DEFAULT_GRID_PRIMITIVES = [
42
+ 'CanvasCard',
43
+ 'DraggableCardPage',
44
+ 'PageGridLayout',
45
+ 'usePageGridLayout',
46
+ ];
47
+ /**
48
+ * Strip block comments, line comments and JSX comments before matching.
49
+ *
50
+ * Three of the five hand-rolled copies matched against raw file content, which
51
+ * produced two opposite errors at once: a commented-out `<Card/><Card/>`
52
+ * example false-POSITIVES as glue, and a file that merely MENTIONS
53
+ * `CanvasCard` in a TODO comment false-NEGATIVES as "already gridded" and is
54
+ * skipped entirely. Stripping first fixes both.
55
+ */
56
+ export function stripComments(source) {
57
+ return source
58
+ .replace(/\{\s*\/\*[\s\S]*?\*\/\s*\}/g, '') // {/* JSX comment */}
59
+ .replace(/\/\*[\s\S]*?\*\//g, '') // /* block */
60
+ .replace(/(^|[^:])\/\/[^\n]*/g, '$1'); // // line, but not http://
61
+ }
62
+ function tagPattern(tags) {
63
+ // `[\s/>]` not `[\s>]`: `<Card/>` with no space before the slash was missed
64
+ // by three of the five copies. The trailing alternation keeps `<Card>` and
65
+ // `<Card ...>` matching too.
66
+ return new RegExp(`<(${tags.join('|')})[\\s/>]`, 'g');
67
+ }
68
+ function countCardTags(source, config) {
69
+ let count = (source.match(tagPattern(config.cardTags)) ?? []).length;
70
+ for (const re of config.classSurfaces ?? []) {
71
+ const global = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g');
72
+ count += (source.match(global) ?? []).length;
73
+ }
74
+ return count;
75
+ }
76
+ /**
77
+ * Does this source render cards from a `.map()`?
78
+ *
79
+ * The non-greedy `[\s\S]*?` form is deliberate. Three copies used
80
+ * `\.map\s*\([^)]*\)\s*=>` which requires a PARENTHESISED parameter, so
81
+ * `items.map(item => <Card/>)` — a bare param with an expression body — was
82
+ * missed. That gap is currently dormant across the estate but it is a landmine,
83
+ * not a theoretical concern.
84
+ */
85
+ function hasMappedCards(source, config) {
86
+ const tags = config.cardTags.join('|');
87
+ if (new RegExp(`\\.map\\s*\\([\\s\\S]*?=>\\s*\\(?\\s*<(${tags})[\\s/>]`).test(source) ||
88
+ new RegExp(`\\.map\\s*\\([\\s\\S]*?\\{[\\s\\S]*?return\\s*\\(?\\s*<(${tags})[\\s/>]`).test(source))
89
+ return true;
90
+ // A mapped CLASS surface is still a mapped card.
91
+ //
92
+ // This was missed on the first pass: the tag alternation above is built from
93
+ // `cardTags` only, so `.map(api => <div className="glass-card">…)` produced N
94
+ // cards inside one grid slot and read as clean. BSU's `Government.tsx#apis`
95
+ // is exactly that shape, and only its hand-maintained ledger knew — which is
96
+ // the failure mode this whole module exists to end. `countCardTags` already
97
+ // counts class surfaces; the mapped check has to as well or the two disagree.
98
+ for (const re of config.classSurfaces ?? []) {
99
+ const src = re.source;
100
+ if (new RegExp(`\\.map\\s*\\([\\s\\S]*?=>\\s*\\(?\\s*<[^>]*?${src}`).test(source) ||
101
+ new RegExp(`\\.map\\s*\\([\\s\\S]*?\\{[\\s\\S]*?return\\s*\\(?\\s*<[^>]*?${src}`).test(source))
102
+ return true;
103
+ }
104
+ return false;
105
+ }
106
+ /** Extract the body of every `<CanvasCard …> … </CanvasCard>` block. */
107
+ function extractCanvasCardBlocks(source) {
108
+ const blocks = [];
109
+ const re = /<CanvasCard[\s>][\s\S]*?<\/CanvasCard>/g;
110
+ let m;
111
+ while ((m = re.exec(source)) !== null)
112
+ blocks.push(m[0]);
113
+ return blocks;
114
+ }
115
+ /**
116
+ * Extract each value of an inline `widgets={{ key: <jsx/> }}` object literal.
117
+ * Brace-balanced rather than regex-terminated, because a widget body contains
118
+ * arbitrary nested JSX and braces.
119
+ */
120
+ function extractWidgetEntries(source) {
121
+ const out = [];
122
+ const start = /widgets=\{\{/g;
123
+ let m;
124
+ while ((m = start.exec(source)) !== null) {
125
+ let depth = 2;
126
+ let i = m.index + m[0].length;
127
+ const objStart = i;
128
+ while (i < source.length && depth > 0) {
129
+ if (source[i] === '{')
130
+ depth++;
131
+ else if (source[i] === '}')
132
+ depth--;
133
+ i++;
134
+ }
135
+ const obj = source.slice(objStart, i - 2);
136
+ // Split top-level `key:` entries at depth 0.
137
+ let d = 0;
138
+ let keyStart = 0;
139
+ let currentKey = null;
140
+ let bodyStart = 0;
141
+ for (let j = 0; j < obj.length; j++) {
142
+ const c = obj[j];
143
+ if (c === '{' || c === '(' || c === '[')
144
+ d++;
145
+ else if (c === '}' || c === ')' || c === ']')
146
+ d--;
147
+ else if (c === ':' && d === 0 && currentKey === null) {
148
+ currentKey = obj.slice(keyStart, j).trim().replace(/['"]/g, '');
149
+ bodyStart = j + 1;
150
+ }
151
+ else if (c === ',' && d === 0 && currentKey !== null) {
152
+ out.push({ key: currentKey, body: obj.slice(bodyStart, j) });
153
+ currentKey = null;
154
+ keyStart = j + 1;
155
+ }
156
+ }
157
+ if (currentKey !== null)
158
+ out.push({ key: currentKey, body: obj.slice(bodyStart) });
159
+ }
160
+ return out;
161
+ }
162
+ function walk(dir, extensions, acc) {
163
+ for (const entry of readdirSync(dir)) {
164
+ if (entry === 'node_modules' || entry === 'dist' || entry === '.git')
165
+ continue;
166
+ const full = join(dir, entry);
167
+ const st = statSync(full);
168
+ if (st.isDirectory())
169
+ walk(full, extensions, acc);
170
+ else if (extensions.some((e) => entry.endsWith(e)) && !/\.(test|spec)\./.test(entry))
171
+ acc.push(full);
172
+ }
173
+ }
174
+ export function scanCardSurfaces(config) {
175
+ const extensions = config.extensions ?? ['.tsx'];
176
+ const gridPrimitives = config.gridPrimitives ?? DEFAULT_GRID_PRIMITIVES;
177
+ const findings = [];
178
+ const files = [];
179
+ const scanRootsResolved = [];
180
+ // D-92: assert the inputs were present BEFORE reporting any verdict. A
181
+ // missing scan root must fail loudly, not produce a confident zero.
182
+ for (const root of config.scanRoots) {
183
+ const abs = join(config.projectRoot, root);
184
+ if (!existsSync(abs))
185
+ throw new Error(`[cardSurfaceScanner] scan root "${root}" does not exist at ${abs}. ` +
186
+ `A scanner that silently skips a missing root reports a false pass, which is worse than a failure.`);
187
+ const before = files.length;
188
+ walk(abs, extensions, files);
189
+ if (files.length === before)
190
+ throw new Error(`[cardSurfaceScanner] scan root "${root}" matched ZERO files with extensions ${extensions.join(', ')}. ` +
191
+ `Refusing to report a verdict from an empty scan.`);
192
+ scanRootsResolved.push(root);
193
+ }
194
+ // Retired single-widget primitives must not exist and must not be imported.
195
+ for (const retired of config.retiredPrimitivePaths ?? []) {
196
+ const abs = join(config.projectRoot, retired);
197
+ if (existsSync(abs))
198
+ findings.push({
199
+ file: retired,
200
+ idiom: 'retired-primitive',
201
+ detail: `${retired} still exists. It builds ONE grid item for the whole page, so every card on it ` +
202
+ `moves as a single block — the exact defect this contract exists to prevent.`,
203
+ });
204
+ const base = retired.split(sep).pop()?.replace(/\.tsx?$/, '');
205
+ if (base) {
206
+ for (const file of files) {
207
+ const src = stripComments(readFileSync(file, 'utf8'));
208
+ if (new RegExp(`\\b${base}\\b`).test(src) && !file.endsWith(retired))
209
+ findings.push({
210
+ file: relative(config.projectRoot, file),
211
+ idiom: 'retired-primitive',
212
+ detail: `imports or references the retired primitive ${base}`,
213
+ });
214
+ }
215
+ }
216
+ }
217
+ for (const file of files) {
218
+ const rel = relative(config.projectRoot, file);
219
+ const raw = readFileSync(file, 'utf8');
220
+ const src = stripComments(raw);
221
+ // --- glued widget: 2+ cards inside ONE grid slot -----------------------
222
+ const slots = [
223
+ ...extractCanvasCardBlocks(src).map((b, i) => ({ label: `CanvasCard#${i + 1}`, body: b })),
224
+ ...extractWidgetEntries(src).map((e) => ({ label: `widgets.${e.key}`, body: e.body })),
225
+ ];
226
+ for (const slot of slots) {
227
+ const n = countCardTags(slot.body, config);
228
+ const mapped = hasMappedCards(slot.body, config);
229
+ if (n > 1 || mapped) {
230
+ if (config.gluedExclusions?.[rel])
231
+ continue;
232
+ findings.push({
233
+ file: rel,
234
+ idiom: 'glued-widget',
235
+ cardCount: n,
236
+ detail: `${slot.label} packs ${mapped ? 'a .map() of cards' : `${n} card-like components`} into one grid slot. ` +
237
+ `Dragging any one of them moves them all.`,
238
+ });
239
+ }
240
+ }
241
+ // --- ungridded multi-card: the check three of five ports DROPPED -------
242
+ // Gate on the COMMENT-STRIPPED source so a TODO mentioning CanvasCard does
243
+ // not mask a page that never actually reaches the grid.
244
+ const usesGrid = gridPrimitives.some((p) => new RegExp(`\\b${p}\\b`).test(src));
245
+ if (!usesGrid) {
246
+ const n = countCardTags(src, config);
247
+ if (n > 1 && !config.ungriddedExclusions?.[rel]) {
248
+ findings.push({
249
+ file: rel,
250
+ idiom: 'ungridded-multi-card',
251
+ cardCount: n,
252
+ detail: `renders ${n} card-like surfaces and never reaches a grid primitive, so none of them is draggable at all. ` +
253
+ `This is the idiom crm7's scanner calls "the actual root cause of the operator's platform-wide complaint".`,
254
+ });
255
+ }
256
+ }
257
+ // --- autoHeight opt-out + clip invariant -------------------------------
258
+ const optOut = /autoHeight\s*[:=]\s*\{?\s*false/g;
259
+ if (optOut.test(src) && !(config.autoHeightEscapeHatch?.test(src) ?? false)) {
260
+ findings.push({
261
+ file: rel,
262
+ idiom: 'autoheight-optout',
263
+ detail: `opts out of autoHeight. That is reserved for genuinely virtualized/windowed lists that own their own scroll; ` +
264
+ `an ordinary table must expand so every row is visible.`,
265
+ });
266
+ }
267
+ // A card that opts out AND seeds h<=2 (64px) cannot show its content.
268
+ if (/autoHeight\s*[:=]\s*\{?\s*false/.test(src) && /\bh=\{?[12]\}?/.test(src)) {
269
+ findings.push({
270
+ file: rel,
271
+ idiom: 'clipped-card',
272
+ detail: `autoHeight={false} with h<=2 (<=64px) — the card is guaranteed to clip its content.`,
273
+ });
274
+ }
275
+ }
276
+ // Stale ledger entries: a claim that is no longer true is a claim that will
277
+ // be trusted next time. An exclusions ledger is a claim, not a fact.
278
+ const hit = new Set(findings.map((f) => `${f.idiom}:${f.file}`));
279
+ const staleExclusions = [
280
+ ...Object.keys(config.gluedExclusions ?? {}).filter((f) => !hit.has(`glued-widget:${f}`) && !hit.has(`autoheight-optout:${f}`)),
281
+ ...Object.keys(config.ungriddedExclusions ?? {}).filter((f) => !hit.has(`ungridded-multi-card:${f}`)),
282
+ ];
283
+ const summary = `scanned ${files.length} files across ${scanRootsResolved.length} root(s) [${scanRootsResolved.join(', ')}] ` +
284
+ `with cardTags [${config.cardTags.join(', ')}]` +
285
+ `${config.classSurfaces?.length ? ` + ${config.classSurfaces.length} class surface pattern(s)` : ''}; ` +
286
+ `${findings.length} finding(s); ${Object.keys(config.blindSpots ?? {}).length} declared blind spot(s); ` +
287
+ `${staleExclusions.length} stale exclusion(s).`;
288
+ return { findings, filesScanned: files.length, scanRootsResolved, staleExclusions, summary };
289
+ }
@@ -0,0 +1,3 @@
1
+ export { scanCardSurfaces, stripComments } from './cardSurfaceScanner.js';
2
+ export type { CardSurfaceScannerConfig, CardSurfaceScanResult, CardSurfaceFinding, CardSurfaceIdiom, } from './cardSurfaceScanner.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/scanner/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AAC1E,YAAY,EACV,wBAAwB,EACxB,qBAAqB,EACrB,kBAAkB,EAClB,gBAAgB,GACjB,MAAM,yBAAyB,CAAC"}
@@ -0,0 +1 @@
1
+ export { scanCardSurfaces, stripComments } from './cardSurfaceScanner.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bsuite/page-builder",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Shared BSuite responsive page-builder grid and layout persistence primitives",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -23,7 +23,11 @@
23
23
  "import": "./dist/index.js",
24
24
  "types": "./dist/index.d.ts"
25
25
  },
26
- "./styles.css": "./src/styles/react-grid-layout-overrides.css"
26
+ "./styles.css": "./src/styles/react-grid-layout-overrides.css",
27
+ "./scanner": {
28
+ "import": "./dist/scanner/index.js",
29
+ "types": "./dist/scanner/index.d.ts"
30
+ }
27
31
  },
28
32
  "scripts": {
29
33
  "build": "tsc -p tsconfig.build.json",
@@ -70,6 +74,7 @@
70
74
  "react-resizable": "^4.0.1",
71
75
  "typescript": "~6.0.3",
72
76
  "vite": "^8.0.16",
73
- "vitest": "^4.1.8"
77
+ "vitest": "^4.1.8",
78
+ "@types/node": "^25.9.2"
74
79
  }
75
80
  }