@colixsystems/widget-sdk 0.155.0 → 0.156.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,6 +31,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
31
31
  | **CORE** | `useFill()` | `boolean` | `ctx.fill` — no scope. `true` when the host sized this widget to fill its page-grid tile's reserved height (containers + media fill by default; the author can override per tile). Media-style widgets switch to a `flex: 1` / `height: "100%"` layout; others ignore it. Defaults `false`. |
32
32
  | **CORE** | `useContainerWidth()` | `[width, onLayout]` | No context slice, no scope. Measures the width the widget's OWN box has, so it can lay itself out for the space it is in rather than for the screen. Spread the handler onto your outermost primitive. Use it for any widget with a wide form and a narrow one (a table, a toolbar, a row of tiles) — never switch on the device or window width, because a widget in a one-of-three grid cell on desktop has phone-width room and a widget filling a phone page does not. Width is 0 until the first layout: render the WIDE form then. One implementation covers web (react-native-web) and the native export. |
33
33
  | **CORE** | `isNarrowWidth(width)` | `boolean` | No context slice, no scope. True when a MEASURED width is below `NARROW_WIDTH_PX` (480) — the one threshold every widget switches at, so a page reflows together rather than raggedly. An unmeasured width (0) is NOT narrow, so nothing flashes through the narrow form on first paint. |
34
+ | **CORE** | `widthTier(width)` | `"narrow" \| "medium" \| "wide"` | No context slice, no scope. Which of THREE layouts a measured width calls for: narrow below `NARROW_WIDTH_PX` (480), wide at or above `WIDE_WIDTH_PX` (960), medium between. `widthTier(w) === "narrow"` exactly when `isNarrowWidth(w)`; an unmeasured width (0) is "wide". |
34
35
  | **CORE** | `useSectionEmpty(isEmpty)` | `void` | `ctx.section.reportEmpty` — no scope. Declares that the widget has NO content to show, so the host drops its layout slot instead of reserving space (and its parent's `gap`) for it. Returning `null` is not enough: the host wraps every widget in an entrance element, so a widget rendering nothing still leaves an empty box the parent stack gaps around. For a CONDITIONALLY ABSENT section (a per-record child collection with no rows for this record), never to suppress a genuine empty state. Stays mounted while collapsed, so passing `false` brings it back. Authoring surfaces never collapse. No-op on a host that doesn't implement it. |
35
36
  | **CORE** | `useScreenTitle(title)` | `void` | `ctx.screen.reportTitle` — no scope. Names the screen the widget is on, so the host paints that name in the app header beside the back control instead of the app brand. The page's own name is the default; only the widget knows the screen is really "Order #1423" rather than "Orders". The MOST RECENT call wins — with several widgets on one screen the last to report owns the title, and on unmount (or an empty title) the host falls back to the most recent surviving report and finally to the page name. Only a screen showing the back control paints a title; a root page keeps the brand. No-op on a host that doesn't implement it. |
36
37
  | **CORE** | `useRefresh(handler)` | `void` | `ctx.refresh.subscribe` — no scope. Subscribes the handler to the page-level refresh tick (pull-to-refresh on mobile). Handler may return a Promise — the host waits for `allSettled` before clearing the spinner. The three datastore hooks auto-subscribe their own `refetch`; widgets only call this directly to re-run non-datastore work. No-op on a host that doesn't implement refresh. |
@@ -72,7 +73,13 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
72
73
 
73
74
  ## Status
74
75
 
75
- `v0.155.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
76
+ `v0.156.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
77
+
78
+ ### What's new in 0.156.0 (contract 1.122.0)
79
+
80
+ **A third layout tier: `widthTier(width)` and `WIDE_WIDTH_PX` (sc-8508).** `isNarrowWidth` splits widths into narrow and everything else, so a widget alone on a desktop page rendered the same layout it uses in a half-width cell and left most of a 1200px box empty. `widthTier` returns `"narrow"`, `"medium"` or `"wide"` — lay a form out in one column, then paired fields, then two or three columns; a list as stacked cards, then one-line cards, then rows with real columns.
81
+
82
+ Additive: `isNarrowWidth` and `NARROW_WIDTH_PX` are unchanged, and `widthTier(w) === "narrow"` exactly when `isNarrowWidth(w)` is true.
76
83
 
77
84
  ### What's new in 0.155.0 (contract 1.121.0)
78
85
 
@@ -1746,10 +1753,10 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
1746
1753
  - **Tint the supporting cast.** `colors.primarySoft` is a tint of the workspace accent over the surface and `colors.onPrimarySoft` is guaranteed readable on it (WCAG AA, on light and dark themes alike). Use the pair for chips, secondary buttons, progress tracks, icon badges and selected rows. One saturated accent moment surrounded by several pale echoes of the same hue is what reads as designed — a row of grey-outlined buttons reads as a form. Never hand-mix a tint with `rgba(...)` or a translucent overlay.
1747
1754
  - **Spend one gradient.** `<Gradient colors={[theme.colors.primary, theme.colors.primaryStrong]} angle={160} style={…}>` is a `View` that paints a gradient behind its children, so it replaces the `View` you'd otherwise give a flat `backgroundColor`. `angle` is CSS degrees (0 = to top, 90 = to right, default 180); text on it uses `colors.onPrimary`. Exactly **one** per widget — on the focal element — and never behind body text. Both hosts render it identically (web paints CSS, native uses `expo-linear-gradient`), so there is no per-platform branching to write; don't import `expo-linear-gradient` yourself and don't write a `backgroundImage` string.
1748
1755
  - **Answer the touch.** Every tappable card, row and list entry lifts while the pointer is over it (web) or it is pressed (touch). One declaration does both: give the Pressable a style FUNCTION and spread `pressableLift` — `<Pressable onPress={open} style={(state) => [styles.card, ...pressableLift(state)]}>`. The lift is a -2px nudge plus one elevation step from `theme.interaction`, with the web transition built in. Never hand-write hover logic or your own pressed shadows, and never fake feedback with `opacity` — a dimmed surface reads as disabling itself.
1749
- - **Size to your container — measure it, don't stretch into it.** The same widget sits in a full-width desktop section (~1400px), a half-width grid cell (~700px) and a phone (~360px), so layout built only from `flex: 1` stretches to fill whatever it is handed — a month calendar ends up with 200px day cells and swallows the page. Measure your own width with `onLayout={(e) => setWidth(e.nativeEvent.layout.width)}` on the root `View` (a React Native primitive, so it behaves identically on both hosts), render nothing size-dependent while `width === 0`, and compute every threshold from the measured value: a widget gets no declared breakpoint prop, but it can always measure. **Cap a repeating cell** rather than giving a grid `flex: 1` — `const cell = Math.max(32, Math.min(Math.floor((usable - gap * (columns - 1)) / columns), 64));`, with a calendar day cell topping out at 56–72px on `aspectRatio: 1`, and the grid given its exact computed width plus `alignSelf: 'center'` when the cap leaves slack. **Split two co-equal surfaces above ~720px measured width** (`flexDirection: width >= 720 ? 'row' : 'column'`, each half `{ flex: 1, minWidth: 0 }`) — a picker beside the form it feeds on a wide canvas, stacked in reading order below it. Never hardcode a width, never put `flex: 1` / `height: '100%'` on a content widget's root, and don't read the screen with `Dimensions` — the screen is not the widget. **Whatever you narrow, you centre** — a block that caps its own measure carries all three of `{ width: '100%', maxWidth: FORM_MAX, alignSelf: 'center' }`, because `alignSelf` alone stops the block stretching and sizes it to its content instead, and `maxWidth` alone leaves the widget flush left with dead space beside it; a capped wrap row lays its cells out along the MAIN axis, so it centres with `justifyContent: 'center'` on the row instead. **A metric / stat-tile row WRAPS** — it is a card grid with smaller cells (`{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }`, each tile `{ flexGrow: 1, flexBasis: TILE_MIN }` plus `maxWidth: TILE_MIN * 1.6`), because a bare `flexDirection: 'row'` has no way to break and a React Native flex child defaults to `flexShrink: 0` (unlike CSS), so the tiles cannot give ground either: the row runs past its container, spilling out of the card natively and cut off by the scrolling page column on web, with nothing thrown and nothing logged, once there are enough of them (four fit where five do not). The basis is also the per-line ceiling — `Math.floor((usable + gap) / (TILE_MIN + gap))` tiles fit a line, so 240 tops out at five across a full-width ~1400px section and 320 at four; past four or five a line the tiles stop reading as cards.
1750
- - **Compose forms — pair fields into rows, don't stack one per row.** Cap the form column itself around 560–720px and centre it with the three declarations above, so it neither stretches across a full-width desktop section nor sits flush left with half the canvas empty beside it. Put short, related fields side by side (first + last name, city + postal code, expiry + CVC): a row of `{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }` with each field cell `{ flexGrow: 1, flexBasis: 160 }` splits the width on a wide card and wraps to stacked on a narrow phone — the right recipe for field pairs because it needs no measurement (a fixed-width column overflows a phone; when a layout needs a real column count instead of wrapping, measure your width as above). Keep wide fields (email, address, notes) full-width with `width: '100%'` — NEVER `flexBasis: '100%'`, which sizes the main axis and therefore claims the parent's whole HEIGHT in a column (sc-7274) — cap it at two–three per row, group a long form into labelled sections, and label every input above it (not placeholder-only). Give a `multiline` field BOTH a floor and a ceiling (`{ minHeight: 150, maxHeight: 260 }`) so a long value scrolls inside the box instead of growing past its card, and keep the Save / Cancel row in normal flow below the fields, never positioned over them. When a field carries an icon beside it (a search glyph, a clear button), the border belongs on the WRAPPER row and the `TextInput` inside it goes borderless and transparent with `flex: 1, minWidth: 0` (the `minWidth: 0` stops a long value pushing the border past its container) — React Native has no `:focus-within`, so drive the wrapper's `borderColor` between `colors.border` and `colors.primary` from the input's own `onFocus` / `onBlur`. A border left on the input rings only its own `<input>` box on web, leaving the icon outside the ring; never absolutely-position the icon over the field to work around it.
1756
+ - **Size to your container — measure it, don't stretch into it.** The same widget sits in a full-width desktop section (~1400px), a half-width grid cell (~700px) and a phone (~360px), so layout built only from `flex: 1` stretches to fill whatever it is handed — a month calendar ends up with 200px day cells and swallows the page. Measure your own width with `const [width, onLayout] = useContainerWidth();`, spreading `onLayout` on an unpadded root `View` (a React Native callback, so it behaves identically on both hosts). A width of `0` means "not yet measured" and the SDK helpers answer it with the wide form, so do not special-case it — except a cell whose definite size is computed from the width (a calendar day, a chart), which renders nothing size-dependent until the width arrives: a widget gets no declared breakpoint prop, but it can always measure. **Every form, list, table and filter bar lays out in three tiers** (sc-8508): `const [width, onLayout] = useContainerWidth();` then branch on `widthTier(width)` — a form is one column narrow, paired short fields at medium, a 2–3 column grid capped near 1000px wide; a list is stacked cards, one-line cards, then real columns under a header; a filter bar is one toolbar row (search, a dropdown per facet, sort) with the active filters as removable chips beneath, and a single "Filters" button opening a panel when narrow. **Cap a repeating cell** rather than giving a grid `flex: 1` — `const cell = Math.max(32, Math.min(Math.floor((usable - gap * (columns - 1)) / columns), 64));`, with a calendar day cell topping out at 56–72px on `aspectRatio: 1`, and the grid given its exact computed width plus `alignSelf: 'center'` when the cap leaves slack. **Split two co-equal surfaces in the wide tier** (`flexDirection: widthTier(width) === 'wide' ? 'row' : 'column'`, each half `{ flex: 1, minWidth: 0 }`) — a picker beside the form it feeds on a wide canvas, stacked in reading order below it. Never hardcode a width, never put `flex: 1` / `height: '100%'` on a content widget's root, and don't read the screen with `Dimensions` — the screen is not the widget. **Whatever you narrow, you centre** — a block that caps its own measure carries all three of `{ width: '100%', maxWidth: FORM_MAX, alignSelf: 'center' }`, because `alignSelf` alone stops the block stretching and sizes it to its content instead, and `maxWidth` alone leaves the widget flush left with dead space beside it; a capped wrap row lays its cells out along the MAIN axis, so it centres with `justifyContent: 'center'` on the row instead. **A metric / stat-tile row FILLS ITS ROW** — when you know the count, balance the lines (`const lines = Math.ceil(count / max); const base = Math.floor(count / lines); const extra = count % lines; const rows = Array.from({ length: lines }, (_, i) => tiles.slice(i * base + Math.min(i, extra), (i + 1) * base + Math.min(i + 1, extra)));`, with `max` 4 / 3 / 2 by tier, each tile `{ flex: 1, minWidth: 0 }`), and when the count comes from data wrap it (`{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }`, each tile `{ flexGrow: 1, flexBasis: TILE_MIN }`, never a `maxWidth` — capped tiles cluster to one side), because a bare `flexDirection: 'row'` has no way to break and a React Native flex child defaults to `flexShrink: 0` (unlike CSS), so the tiles cannot give ground either: the row runs past its container, spilling out of the card natively and cut off by the scrolling page column on web, with nothing thrown and nothing logged, once there are enough of them (four fit where five do not). The basis is also the per-line ceiling — `Math.floor((usable + gap) / (TILE_MIN + gap))` tiles fit a line, so 240 tops out at five across a full-width ~1400px section and 320 at four; past four or five a line the tiles stop reading as cards.
1757
+ - **Compose forms — pair fields into rows, don't stack one per row.** Lay the form out in three tiers and cap it near 1000px, centred with the three declarations above — never a 560–720px column alone in a wide box, which leaves a form that IS the page as a narrow strip in empty space. Put short, related fields side by side (first + last name, city + postal code, expiry + CVC): a row of `{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }` with each field cell `{ flexGrow: 1, flexBasis: 160 }` splits the width on a wide card and wraps to stacked on a narrow phone — the right recipe for field pairs because it needs no measurement (a fixed-width column overflows a phone; when a layout needs a real column count instead of wrapping, measure your width as above). Keep wide fields (email, address, notes) full-width with `width: '100%'` — NEVER `flexBasis: '100%'`, which sizes the main axis and therefore claims the parent's whole HEIGHT in a column (sc-7274) — cap it at two–three per row, group a long form into labelled sections, and label every input above it (not placeholder-only). Give a `multiline` field BOTH a floor and a ceiling (`{ minHeight: 150, maxHeight: 260 }`) so a long value scrolls inside the box instead of growing past its card, and keep the Save / Cancel row in normal flow below the fields, never positioned over them. When a field carries an icon beside it (a search glyph, a clear button), the border belongs on the WRAPPER row and the `TextInput` inside it goes borderless and transparent with `flex: 1, minWidth: 0` (the `minWidth: 0` stops a long value pushing the border past its container) — React Native has no `:focus-within`, so drive the wrapper's `borderColor` between `colors.border` and `colors.primary` from the input's own `onFocus` / `onBlur`. A border left on the input rings only its own `<input>` box on web, leaving the icon outside the ring; never absolutely-position the icon over the field to work around it.
1751
1758
  - **Respond to touch.** Give every `Pressable` the lift via the function-style `style={(state) => [base, ...pressableLift(state)]}` — see "Answer the touch" above. Never dim with `opacity`, which reads as the surface disabling itself.
1752
- - **Drag and drop — show what is being dragged.** A drag where the item stays put reads as broken. Three things change the moment a drag starts: the **drag proxy** (the item lifts and follows the finger — `...theme.elevation.lg`, `{ scale: 1.03 }`, `opacity: 0.9`; for a tall or full-width item drag a compact `primarySoft` pill with its icon + one line of label instead), the **source placeholder** (the vacated slot keeps its height as a quiet `colors.surfaceMuted` block so the list doesn't collapse), and the **drop target** (one slot at a time highlighted with `primarySoft` or a 2px `colors.primary` border). Always animate the release — settle into the new slot, or `Animated.spring(pan, { toValue: { x: 0, y: 0 }, useNativeDriver: false })` back to the origin on cancel. Build it with `Animated` + `PanResponder` from `react-native` (the only mechanism that behaves identically on both hosts) — never HTML5 drag events (`draggable` / `onDragStart` / `dataTransfer` are web-only, and `document` / `window` are banned) — and start the drag from a `GripVertical` grip handle whenever the row is also tappable or sits in a `ScrollView`. **A board (kanban, pipeline) drops onto ANOTHER column**, which needs four more things: the board's root renders the one proxy (a column clips whatever leaves it); you hit-test the drop yourself — `measureInWindow` every column and the board when the drag starts and compare the gesture's window `moveX` (never `onLayout` frames, which are relative to a padded or scrolled parent) — highlighting exactly one target; the drop WRITES (`update(id, { [statusField]: value })` through `useDatastoreMutation`, optimistic until `refetch()`, a toast and a spring-back on failure); and three or more columns go in a `horizontal` `ScrollView` below `isNarrowWidth`, with the grip claiming the gesture (`onStartShouldSetPanResponder`, `onPanResponderTerminationRequest: () => false`) and carrying `touchAction: 'none'` on web so a finger drag moves the card instead of scrolling the page. And because `useRef(PanResponder.create(...))` runs ONCE, everything its callbacks read — the `useCanWrite` flag included, which starts `false` — goes through a ref refreshed every render (`latest.current.canDrag`); the publish gate `widget.staleResponder` rejects a value read directly.
1759
+ - **Drag and drop — show what is being dragged.** A drag where the item stays put reads as broken. Three things change the moment a drag starts: the **drag proxy** (the item lifts and follows the finger — `...theme.elevation.lg`, `{ scale: 1.03 }`, `opacity: 0.9`; for a tall or full-width item drag a compact `primarySoft` pill with its icon + one line of label instead), the **source placeholder** (the vacated slot keeps its height as a quiet `colors.surfaceMuted` block so the list doesn't collapse), and the **drop target** (one slot at a time highlighted with `primarySoft` or a 2px `colors.primary` border). Always animate the release — settle into the new slot, or `Animated.spring(pan, { toValue: { x: 0, y: 0 }, useNativeDriver: false })` back to the origin on cancel. Build it with `Animated` + `PanResponder` from `react-native` (the only mechanism that behaves identically on both hosts) — never HTML5 drag events (`draggable` / `onDragStart` / `dataTransfer` are web-only, and `document` / `window` are banned) — and start the drag from a `GripVertical` grip handle whenever the row is also tappable or sits in a `ScrollView`. **A board (kanban, pipeline) drops onto ANOTHER column**, which needs four more things: the board's root renders the one proxy (a column clips whatever leaves it); you hit-test the drop yourself — `measureInWindow` every column and the board when the drag starts and compare the gesture's window `moveX` (never `onLayout` frames, which are relative to a padded or scrolled parent) — highlighting exactly one target; the drop WRITES (`update(id, { [statusField]: value })` through `useDatastoreMutation`, optimistic until `refetch()`, a toast and a spring-back on failure); and three or more columns go in a `horizontal` `ScrollView` when `widthTier(width) === "narrow"`, with the grip claiming the gesture (`onStartShouldSetPanResponder`, `onPanResponderTerminationRequest: () => false`) and carrying `touchAction: 'none'` on web so a finger drag moves the card instead of scrolling the page. And because `useRef(PanResponder.create(...))` runs ONCE, everything its callbacks read — the `useCanWrite` flag included, which starts `false` — goes through a ref refreshed every render (`latest.current.canDrag`); the publish gate `widget.staleResponder` rejects a value read directly.
1753
1760
  - **Use icons for clarity.** Pair a `lucide-react-native` icon with its label at a consistent size, coloured from the theme. The label never repeats the icon as a character — with a `Plus` icon the button says "Add item", never "+ Add item" (that renders a doubled plus).
1754
1761
  - **Use imagery deliberately.** Render pictures with the `Image` primitive (`source` takes a URL or `{ uri }`); resolve workspace assets via `useAsset()`. Give every image a sized, `radii`-clipped container so it never renders as a raw rectangle, and never hardcode a credentialed image URL — expose an `image`-type property instead. The frame is your decision, never the picture's: size it for the role (a 40–56 square avatar, a 72–96 square row thumbnail, a `16 / 9` card cover, a 160–240 tall band) and let `resizeMode="cover"` crop the photo into it — photos arrive at every size and ratio, so one left to its own proportions breaks the layout. Keep `contain` for art whose whole subject must stay visible (a logo, a diagram), inside a fixed frame.
1755
1762
  - **Design the empty, loading, and error states.** A blank box on a fresh install reads as broken — show a short helper line when a list is empty, a calm loading line, and a single human sentence in `colors.danger` on error.
@@ -26,3 +26,30 @@ export const NARROW_WIDTH_PX = 480;
26
26
  export function isNarrowWidth(width) {
27
27
  return typeof width === "number" && width > 0 && width < NARROW_WIDTH_PX;
28
28
  }
29
+
30
+ /**
31
+ * sc-8508 — the width above which a widget has room for its WIDE form: a form
32
+ * in two or three columns, a list whose rows carry real columns, a filter bar
33
+ * on one toolbar row. Twice the narrow threshold, which is the three-column
34
+ * break the Developer guide already showed as `NARROW_WIDTH_PX * 2`.
35
+ */
36
+ export const WIDE_WIDTH_PX = 960;
37
+
38
+ /**
39
+ * Which of the three layouts a MEASURED width calls for: "narrow" below
40
+ * `NARROW_WIDTH_PX`, "wide" at or above `WIDE_WIDTH_PX`, "medium" between.
41
+ *
42
+ * Two tiers were not enough. With only "narrow" and "not narrow", a widget
43
+ * alone on a desktop page rendered the layout it would use in a half-width
44
+ * cell and left the rest of its box empty.
45
+ *
46
+ * An unmeasured width (0, before the first `onLayout`) is "wide", for the same
47
+ * reason `isNarrowWidth(0)` is false: the wide rendering is the historical one,
48
+ * and a widget using both helpers must never get two different answers.
49
+ */
50
+ export function widthTier(width) {
51
+ if (typeof width !== "number" || !(width > 0)) return "wide";
52
+ if (width < NARROW_WIDTH_PX) return "narrow";
53
+ if (width < WIDE_WIDTH_PX) return "medium";
54
+ return "wide";
55
+ }
package/dist/contract.cjs CHANGED
@@ -771,7 +771,7 @@ const HOOKS = [
771
771
  "callback, so ONE implementation serves the web Player (through " +
772
772
  "react-native-web) and the native export. Before the first layout the " +
773
773
  "width is 0: treat that as \"not yet measured\" and render the wide " +
774
- "form. Pair it with `isNarrowWidth(width)` so every widget switches at " +
774
+ "form. Pair it with `widthTier(width)` (or `isNarrowWidth(width)`) so every widget switches at " +
775
775
  "the same threshold. Takes no host context, so it is safe everywhere " +
776
776
  "including the Studio canvas.",
777
777
  returnShape: {
@@ -796,6 +796,25 @@ const HOOKS = [
796
796
  requiredContextSlice: [],
797
797
  scopes: null,
798
798
  },
799
+ {
800
+ name: "widthTier",
801
+ signature: "widthTier(width)",
802
+ description:
803
+ "Which of the THREE layouts a MEASURED width calls for: \"narrow\" " +
804
+ "below `NARROW_WIDTH_PX` (480), \"wide\" at or above `WIDE_WIDTH_PX` " +
805
+ "(960), \"medium\" between. A widget alone on a desktop page has far " +
806
+ "more room than one in a half-width cell, and two tiers treated both " +
807
+ "the same, so the lone widget left most of its box empty. Branch on " +
808
+ "this for every list, form, table and filter bar: a form goes one " +
809
+ "column, then paired fields, then two or three columns; a list goes " +
810
+ "stacked cards, then one-line cards, then rows with real columns. An " +
811
+ "unmeasured width (0) is \"wide\", matching `isNarrowWidth(0)`.",
812
+ returnShape: {
813
+ "(returns)": "\"narrow\" | \"medium\" | \"wide\"",
814
+ },
815
+ requiredContextSlice: [],
816
+ scopes: null,
817
+ },
799
818
  {
800
819
  name: "useSectionEmpty",
801
820
  signature: "useSectionEmpty(isEmpty)",
@@ -4309,7 +4328,13 @@ const CONTRACT = deepFreeze({
4309
4328
  // untouched and renders byte-identically, and an unresolvable binding
4310
4329
  // fails the existing hex guard, dropping the wash rather than painting it
4311
4330
  // blank.
4312
- version: "1.121.0",
4331
+ // 1.122.0: additive (sc-8508) — `widthTier(width)` + `WIDE_WIDTH_PX`, a
4332
+ // third layout tier. `isNarrowWidth` split widths into "phone" and
4333
+ // "everything else", so a widget alone on a desktop page rendered the
4334
+ // layout it would use in a half-width cell. Additive: `isNarrowWidth` and
4335
+ // `NARROW_WIDTH_PX` are unchanged, and `widthTier(w) === "narrow"` exactly
4336
+ // when `isNarrowWidth(w)` is true.
4337
+ version: "1.122.0",
4313
4338
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4314
4339
  hooks: HOOKS,
4315
4340
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -771,7 +771,7 @@ const HOOKS = [
771
771
  "callback, so ONE implementation serves the web Player (through " +
772
772
  "react-native-web) and the native export. Before the first layout the " +
773
773
  "width is 0: treat that as \"not yet measured\" and render the wide " +
774
- "form. Pair it with `isNarrowWidth(width)` so every widget switches at " +
774
+ "form. Pair it with `widthTier(width)` (or `isNarrowWidth(width)`) so every widget switches at " +
775
775
  "the same threshold. Takes no host context, so it is safe everywhere " +
776
776
  "including the Studio canvas.",
777
777
  returnShape: {
@@ -796,6 +796,25 @@ const HOOKS = [
796
796
  requiredContextSlice: [],
797
797
  scopes: null,
798
798
  },
799
+ {
800
+ name: "widthTier",
801
+ signature: "widthTier(width)",
802
+ description:
803
+ "Which of the THREE layouts a MEASURED width calls for: \"narrow\" " +
804
+ "below `NARROW_WIDTH_PX` (480), \"wide\" at or above `WIDE_WIDTH_PX` " +
805
+ "(960), \"medium\" between. A widget alone on a desktop page has far " +
806
+ "more room than one in a half-width cell, and two tiers treated both " +
807
+ "the same, so the lone widget left most of its box empty. Branch on " +
808
+ "this for every list, form, table and filter bar: a form goes one " +
809
+ "column, then paired fields, then two or three columns; a list goes " +
810
+ "stacked cards, then one-line cards, then rows with real columns. An " +
811
+ "unmeasured width (0) is \"wide\", matching `isNarrowWidth(0)`.",
812
+ returnShape: {
813
+ "(returns)": "\"narrow\" | \"medium\" | \"wide\"",
814
+ },
815
+ requiredContextSlice: [],
816
+ scopes: null,
817
+ },
799
818
  {
800
819
  name: "useSectionEmpty",
801
820
  signature: "useSectionEmpty(isEmpty)",
@@ -4305,7 +4324,13 @@ const CONTRACT = deepFreeze({
4305
4324
  // untouched and renders byte-identically, and an unresolvable binding
4306
4325
  // fails the existing hex guard, dropping the wash rather than painting it
4307
4326
  // blank.
4308
- version: "1.121.0",
4327
+ // 1.122.0: additive (sc-8508) — `widthTier(width)` + `WIDE_WIDTH_PX`, a
4328
+ // third layout tier. `isNarrowWidth` split widths into "phone" and
4329
+ // "everything else", so a widget alone on a desktop page rendered the
4330
+ // layout it would use in a half-width cell. Additive: `isNarrowWidth` and
4331
+ // `NARROW_WIDTH_PX` are unchanged, and `widthTier(w) === "narrow"` exactly
4332
+ // when `isNarrowWidth(w)` is true.
4333
+ version: "1.122.0",
4309
4334
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4310
4335
  hooks: HOOKS,
4311
4336
  primitives: PRIMITIVES,
package/dist/index.d.ts CHANGED
@@ -1563,6 +1563,16 @@ export const NARROW_WIDTH_PX: number;
1563
1563
  */
1564
1564
  export function isNarrowWidth(width: number): boolean;
1565
1565
 
1566
+ /** The width at or above which a widget has room for its wide form (960). */
1567
+ export const WIDE_WIDTH_PX: number;
1568
+
1569
+ /**
1570
+ * Which of the three layouts a MEASURED width calls for: `"narrow"` below
1571
+ * `NARROW_WIDTH_PX`, `"wide"` at or above `WIDE_WIDTH_PX`, `"medium"` between.
1572
+ * An unmeasured width (0) is `"wide"`, matching `isNarrowWidth(0) === false`.
1573
+ */
1574
+ export function widthTier(width: number): "narrow" | "medium" | "wide";
1575
+
1566
1576
  /** Pass-through options for `useGeolocation().getCurrentPosition(...)`. */
1567
1577
  export interface GeolocationOptions {
1568
1578
  enableHighAccuracy?: boolean;
package/dist/index.js CHANGED
@@ -91,7 +91,12 @@ export {
91
91
  BarcodeError,
92
92
  WidgetTree,
93
93
  } from "./hooks.js";
94
- export { isNarrowWidth, NARROW_WIDTH_PX } from "./container-width.js";
94
+ export {
95
+ isNarrowWidth,
96
+ NARROW_WIDTH_PX,
97
+ widthTier,
98
+ WIDE_WIDTH_PX,
99
+ } from "./container-width.js";
95
100
  // REQ-WSDK-PLATFORM §6 — Tier A hooks. Each ships in a per-platform file
96
101
  // (./clipboard.js / .native.js, ./toast.js / .native.js); index.js picks
97
102
  // the web variant and index.native.js picks the native variant.
@@ -91,7 +91,12 @@ export {
91
91
  BarcodeError,
92
92
  WidgetTree,
93
93
  } from "./hooks.js";
94
- export { isNarrowWidth, NARROW_WIDTH_PX } from "./container-width.js";
94
+ export {
95
+ isNarrowWidth,
96
+ NARROW_WIDTH_PX,
97
+ widthTier,
98
+ WIDE_WIDTH_PX,
99
+ } from "./container-width.js";
95
100
  // REQ-WSDK-PLATFORM §6 — Tier A hooks (native variants).
96
101
  export { useClipboard, ClipboardError } from "./clipboard.native.js";
97
102
  export { useToast } from "./toast.native.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.155.0",
3
+ "version": "0.156.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "homepage": "https://github.com/Colix-AB/AppStudio",
6
6
  "type": "module",