@lotics/ui 27.11.0 → 27.12.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/docs/catalog.md CHANGED
@@ -382,7 +382,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
382
382
  - **`control_surface`** — `CONTROL_HEIGHT` (40), `CONTROL_RADIUS` (10), `CONTROL_TEXT_INSET`
383
383
  (9 — how far a control insets its OWN text: 1px border + 8px padding; anything that must line
384
384
  up with a control's WORDS rather than its box carries it, and `TASK_TEXT_INSET` IS it),
385
- `FOCUS_RING`, `HOVER_BORDER`, `CONTROL_TRANSITION`, `chipSurfaceStyle` — the shared
385
+ `FOCUS_RING` (a 2px OUTSET ring, so a container that CLIPS — any `ScrollView` — has to
386
+ keep padding on an edge a focusable child sits against, or the ring shears off on the
387
+ one interaction it signals; the padding still comes off the 8-grid, the ring is only
388
+ the floor), `HOVER_BORDER`, `CONTROL_TRANSITION`, `chipSurfaceStyle` — the shared
386
389
  control-surface tokens.
387
390
  - **`fonts.css`** — the Inter sheet (400/500/600, served by absolute URL so it resolves on
388
391
  every origin an app runs from); the app entry imports it ONCE or every `Text` falls back
@@ -1178,6 +1181,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1178
1181
  stack (a Drawer drilling into a LINKED record swaps its header to a BACK `IconButton`
1179
1182
  while `canGoBack`). Key the router by record id so stepping between records resets the
1180
1183
  stack. Worked example: [`tpl_item_list`](../examples/tpl_item_list.tsx) drawer.
1184
+ **`useScreenActive()` says whether the surrounding `Screen` is the one on top** —
1185
+ because "stays mounted" means its effects keep running, so anything a screen claims
1186
+ GLOBALLY (a keyboard shortcut) must stand down while it is buried, or the covered
1187
+ screen answers input aimed at the one in front of it. `true` outside any `Screen`.
1181
1188
  - **`route_matching`** — pure `:param` route-pattern utilities — `routeMatches` /
1182
1189
  `parseRouteParams` / `findBestPattern` (exact beats parameterized) / `shouldRouteMatch`;
1183
1190
  the matching core under `ScreenRouter`/`Dialog`.
@@ -493,8 +493,8 @@ All view controls are 40px tall (`CONTROL_HEIGHT`), `sm` labels, in ONE wrapping
493
493
  "success"/green button. Decision UIs put positive/negative color on the STATUS (dot) and verdict
494
494
  (colored `Text`), not the buttons.
495
495
  - **Create buttons carry NO `+` icon** — "Add fee", not "+ Add fee". A create is `primary` and
496
- sits BELOW the list it extends, never in the section heading — see the verb-placement law for
497
- why, and for the one-verb-one-weight rule that keeps it primary in the `EmptyState` too. (The
496
+ rides its section's HEADING row at the right edge — see § The add-placement law for why, and
497
+ for the one-verb-one-weight rule that keeps it primary in the `EmptyState` too. (The
498
498
  icon-only `+` `IconButton` — a composer's attach — is a different thing and keeps its glyph.)
499
499
 
500
500
  ## The action-gating law — disabled + the reason as a co-located Callout
@@ -594,15 +594,13 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
594
594
  |---|---|
595
595
  | a VALUE (copy it, open what it points at) | ON the value — `InlineButton` in the editor's `actions` |
596
596
  | a ROW (remove it, move it) | ON the row — an `ActionMenu`, or the row's own controls |
597
- | the LIST (one more of them) | BELOW the list, left-aligned, `primary` |
597
+ | the LIST (one more of them) | its section's HEADING row, right edge, `primary` — see [the add-placement law](#the-add-placement-law--a-sections-add-rides-its-heading-row-right-edge) |
598
598
  | the RECORD (issue, print, hand off) | its own action row at the section's END |
599
599
  | the record's EXISTENCE | the `DangerZone`, last on the page |
600
600
 
601
- The heading row is therefore identity + meta + view controls, and holds no content verb. An
602
- **Add in the heading sits above the thing it extends**, at a different altitude from the rows
603
- it affects, competing with the title for the one line that names the section while the row it
604
- creates appears at the BOTTOM, nowhere near the button that made it. Put it under the last row,
605
- on the list's own left edge, and the button is where its result will be.
601
+ The ADD is the one verb this law does NOT place next to its effect, and the add-placement law
602
+ says why: a position that never moves beats a position that is adjacent, because "below the
603
+ last row" is a different place on every list and no place at all on an empty one.
606
604
  **An add is `primary`, in the `EmptyState` too.** It is the act its section offers, so it
607
605
  carries that section's weight; a lone `secondary` button reads as though the real action were
608
606
  somewhere else. The rule that matters is that ONE VERB KEEPS ONE WEIGHT — the same add must
package/docs/templates.md CHANGED
@@ -92,11 +92,11 @@ Laws for any screen where a record's data is edited and a primary action produce
92
92
  7. **Header = identity + provenance.** Id, the key identifier, created-at. No metrics or badges
93
93
  duplicating data a section already owns.
94
94
  8. **Action placement — a verb goes where its effect lands.** Value verbs on the value
95
- (`InlineButton`), row verbs on the row, an ADD below the list it extends (primary, left
96
- where the new row will appear, and primary in the `EmptyState` too: one verb, one weight),
95
+ (`InlineButton`), row verbs on the row, an ADD on its section's HEADING row at the right edge
96
+ (primary the one spot that does not move with the row count, and primary in the
97
+ `EmptyState` too: one verb, one weight),
97
98
  a committing act in its own row at the section's end,
98
- deletion in the `DangerZone`. Section headings carry identity and view controls only; an Add
99
- in the heading sits above what it extends and competes with the title. A committing act rides
99
+ deletion in the `DangerZone`. A committing act rides
100
100
  an empty-label `DetailRow` so it lands on the CONTROL column — never right-floated, which
101
101
  aligns to nothing (only overlay footers right-align).
102
102
  9. **Destructive placement.** Solid `danger`, bottom-left after the entity's fields, ONE
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "27.11.0",
3
+ "version": "27.12.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -23,6 +23,13 @@ export const MIN_CONTROL_WIDTH = 160;
23
23
  * (avatars, dots, switches, sliders, progress, icon buttons) stay full. */
24
24
  export const CONTROL_RADIUS = 10;
25
25
 
26
+ /** How far {@link FOCUS_RING} extends BEYOND the control's border box. Module-local
27
+ * on purpose: it is a FLOOR for anyone who clips (a container reserving less than
28
+ * this on an edge a focusable child sits against shears that child's ring), never a
29
+ * spacing value — padding comes off the 8-grid in `spacing.ts`, which clears this
30
+ * several times over without coupling a layout number to a decoration one. */
31
+ const FOCUS_RING_WIDTH = 2;
32
+
26
33
  /** THE focus ring — a 2px zinc-900 box-shadow flush against the box (offset 0),
27
34
  * layout-neutral (never reflows). The kit no longer ships a global `:focus-visible`
28
35
  * CSS rule, so every interactive control paints THIS inline when keyboard-focused
@@ -30,7 +37,7 @@ export const CONTROL_RADIUS = 10;
30
37
  * trigger (Picker / Combobox / InlineSelect / InlineDatePicker) wears it on its open
31
38
  * state so an open trigger reads identically to a keyboard-focused one — one token,
32
39
  * one look. Apps building their own controls reuse it. */
33
- export const FOCUS_RING = `0 0 0 2px ${colors.zinc[900]}`;
40
+ export const FOCUS_RING = `0 0 0 ${FOCUS_RING_WIDTH}px ${colors.zinc[900]}`;
34
41
 
35
42
  /**
36
43
  * How far a control surface insets its OWN text: 1px of border (transparent on an
package/src/dialog.tsx CHANGED
@@ -313,6 +313,16 @@ const styles = StyleSheet.create({
313
313
  minHeight: 56,
314
314
  },
315
315
  scrollAreaContent: {
316
+ // A ScrollView clips, so the FIRST child sat flush against the boundary and lost
317
+ // the outer half of its focus ring — an input at the top of a dialog showed a
318
+ // sheared ring on the one interaction the ring exists to signal. 8 clears that
319
+ // several times over, but the ring is only the FLOOR here, not the reason: this
320
+ // is separation from the header, so it comes off the 8-grid like every other gap.
321
+ // `DialogHeader` already pays 8 below itself, making the visible gap a clean 16.
322
+ //
323
+ // Asymmetric with the bottom on purpose — 24 there is scroll RUNOUT, so the last
324
+ // row isn't jammed against the edge at the end of a scroll. Different job.
325
+ paddingTop: 8,
316
326
  paddingBottom: 24,
317
327
  },
318
328
  footer: {
@@ -159,6 +159,24 @@ export function useNavigationStack(
159
159
  // Screen
160
160
  // ============================================================================
161
161
 
162
+ /**
163
+ * Whether the surrounding {@link Screen} is the one on top.
164
+ *
165
+ * A screen stacked BEHIND another stays mounted (`display: none`) so its scroll
166
+ * position survives, which means its effects keep running — including any
167
+ * keyboard shortcut it registered. Anything that claims a global key must read
168
+ * this and stand down while hidden, or the buried screen answers a keystroke the
169
+ * user aimed at the screen in front of it.
170
+ *
171
+ * Defaults to `true`, so a subtree outside any `Screen` behaves as if visible.
172
+ */
173
+ const ScreenActiveContext = createContext(true);
174
+
175
+ /** @see ScreenActiveContext */
176
+ export function useScreenActive(): boolean {
177
+ return useContext(ScreenActiveContext);
178
+ }
179
+
162
180
  export interface ScreenProps {
163
181
  /** Route pattern to match. Use empty string "" for the default/initial screen. */
164
182
  route: string;
@@ -208,9 +226,11 @@ export function Screen(props: ScreenProps) {
208
226
  const params = parseRouteParams(route, matchingStackPath);
209
227
 
210
228
  return (
211
- <View style={isActive ? screenStyles.active : screenStyles.hidden}>
212
- {typeof children === "function" ? children(params) : children}
213
- </View>
229
+ <ScreenActiveContext.Provider value={isActive}>
230
+ <View style={isActive ? screenStyles.active : screenStyles.hidden}>
231
+ {typeof children === "function" ? children(params) : children}
232
+ </View>
233
+ </ScreenActiveContext.Provider>
214
234
  );
215
235
  }
216
236