@qoretechnologies/reqraft 0.10.2 → 0.10.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/.claude/CLAUDE.md +5 -0
  2. package/design/COMPACT_ENGINE_REDESIGN.md +156 -0
  3. package/design/FORM_ENGINE_COMPACT_UX_PLAN.md +353 -0
  4. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  5. package/dist/components/form/engine/CompactRow.js +158 -101
  6. package/dist/components/form/engine/CompactRow.js.map +1 -1
  7. package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
  8. package/dist/components/form/engine/CompactToolbar.js +122 -105
  9. package/dist/components/form/engine/CompactToolbar.js.map +1 -1
  10. package/dist/components/form/engine/FormEngine.d.ts +9 -1
  11. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  12. package/dist/components/form/engine/FormEngine.js +272 -82
  13. package/dist/components/form/engine/FormEngine.js.map +1 -1
  14. package/dist/components/form/engine/compactRowStyles.d.ts +6 -3
  15. package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
  16. package/dist/components/form/engine/compactRowStyles.js +76 -49
  17. package/dist/components/form/engine/compactRowStyles.js.map +1 -1
  18. package/dist/components/form/engine/compactToolbarContext.d.ts +1 -0
  19. package/dist/components/form/engine/compactToolbarContext.d.ts.map +1 -1
  20. package/dist/components/form/engine/compactToolbarContext.js.map +1 -1
  21. package/dist/components/form/engine/readFirst.d.ts +19 -0
  22. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  23. package/dist/components/form/engine/readFirst.js +22 -1
  24. package/dist/components/form/engine/readFirst.js.map +1 -1
  25. package/dist/components/form/engine/variants/VariantCalmTable.d.ts +6 -0
  26. package/dist/components/form/engine/variants/VariantCalmTable.d.ts.map +1 -0
  27. package/dist/components/form/engine/variants/VariantCalmTable.js +94 -0
  28. package/dist/components/form/engine/variants/VariantCalmTable.js.map +1 -0
  29. package/dist/components/form/engine/variants/VariantCards.d.ts +6 -0
  30. package/dist/components/form/engine/variants/VariantCards.d.ts.map +1 -0
  31. package/dist/components/form/engine/variants/VariantCards.js +80 -0
  32. package/dist/components/form/engine/variants/VariantCards.js.map +1 -0
  33. package/dist/components/form/engine/variants/VariantFocus.d.ts +7 -0
  34. package/dist/components/form/engine/variants/VariantFocus.d.ts.map +1 -0
  35. package/dist/components/form/engine/variants/VariantFocus.js +138 -0
  36. package/dist/components/form/engine/variants/VariantFocus.js.map +1 -0
  37. package/dist/components/form/engine/variants/VariantMinimal.d.ts +6 -0
  38. package/dist/components/form/engine/variants/VariantMinimal.d.ts.map +1 -0
  39. package/dist/components/form/engine/variants/VariantMinimal.js +73 -0
  40. package/dist/components/form/engine/variants/VariantMinimal.js.map +1 -0
  41. package/dist/components/form/engine/variants/focusDemo.d.ts +13 -0
  42. package/dist/components/form/engine/variants/focusDemo.d.ts.map +1 -0
  43. package/dist/components/form/engine/variants/focusDemo.js +139 -0
  44. package/dist/components/form/engine/variants/focusDemo.js.map +1 -0
  45. package/dist/components/form/engine/variants/variantModel.d.ts +70 -0
  46. package/dist/components/form/engine/variants/variantModel.d.ts.map +1 -0
  47. package/dist/components/form/engine/variants/variantModel.js +133 -0
  48. package/dist/components/form/engine/variants/variantModel.js.map +1 -0
  49. package/dist/components/form/engine/variants/variantParts.d.ts +79 -0
  50. package/dist/components/form/engine/variants/variantParts.d.ts.map +1 -0
  51. package/dist/components/form/engine/variants/variantParts.js +191 -0
  52. package/dist/components/form/engine/variants/variantParts.js.map +1 -0
  53. package/dist/components/form/fields/auto/AutoFormField.d.ts +3 -0
  54. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  55. package/dist/components/form/fields/auto/AutoFormField.js +5 -2
  56. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  57. package/package.json +1 -1
  58. package/src/components/form/engine/CompactRow.tsx +273 -258
  59. package/src/components/form/engine/CompactToolbar.tsx +112 -85
  60. package/src/components/form/engine/FormEngine.stories.tsx +239 -115
  61. package/src/components/form/engine/FormEngine.tsx +332 -83
  62. package/src/components/form/engine/compactRowStyles.ts +221 -144
  63. package/src/components/form/engine/compactToolbarContext.ts +1 -0
  64. package/src/components/form/engine/readFirst.ts +35 -0
  65. package/src/components/form/engine/variants/FormEngineVariants.stories.tsx +119 -0
  66. package/src/components/form/engine/variants/VariantCalmTable.tsx +242 -0
  67. package/src/components/form/engine/variants/VariantCards.tsx +212 -0
  68. package/src/components/form/engine/variants/VariantFocus.tsx +382 -0
  69. package/src/components/form/engine/variants/VariantMinimal.tsx +170 -0
  70. package/src/components/form/engine/variants/focusDemo.ts +145 -0
  71. package/src/components/form/engine/variants/variantModel.ts +216 -0
  72. package/src/components/form/engine/variants/variantParts.tsx +313 -0
  73. package/src/components/form/fields/auto/AutoFormField.stories.tsx +9 -2
  74. package/src/components/form/fields/auto/AutoFormField.tsx +8 -0
@@ -18,11 +18,10 @@ export const COMPACT_ROW_GAP = 14; // grid column gap
18
18
  export const COMPACT_VALUE_LEFT = COMPACT_ROW_PAD_X + COMPACT_LABEL_COL + COMPACT_ROW_GAP;
19
19
  // The surface starts a touch left of the value text, for inner left padding.
20
20
  export const COMPACT_PANEL_LEFT = COMPACT_VALUE_LEFT - 10;
21
- // Fluid indent applied to every field block under a group header. Shared so the
22
- // group spine and the required-group rail land on the SAME vertical line (the
23
- // rail sits at the block's left gutter, `-9px`, and the spine at `indent - 9px`
24
- // in the group body's coordinate space — both resolve `3%` against the body).
25
- export const GROUP_INDENT = 'clamp(8px, 3%, 32px)';
21
+ // Left offset for the group sub-labels / cluster header, kept equal to the row's
22
+ // own horizontal padding so the labels line up flush with the field labels below
23
+ // them (no extra gutter the spine/rail that used a fluid indent are gone).
24
+ export const GROUP_INDENT = `${COMPACT_ROW_PAD_X}px`;
26
25
 
27
26
  // Measured label column (GLOBAL). The label column sizes to the WIDEST field
28
27
  // label across the whole form, clamped to [MIN, MAX]. FormEngine measures the
@@ -35,7 +34,6 @@ export const LABEL_COL_MIN = 120;
35
34
  export const LABEL_COL_MAX = COMPACT_LABEL_COL; // 220
36
35
  export const LABEL_COL_VAR = '--readfirst-label-col';
37
36
  export const LABEL_COL = `var(${LABEL_COL_VAR}, ${COMPACT_LABEL_COL}px)`;
38
- export const VALUE_LEFT_CSS = `calc(${LABEL_COL} + ${COMPACT_ROW_PAD_X + COMPACT_ROW_GAP}px)`;
39
37
  export const PANEL_LEFT_CSS = `calc(${LABEL_COL} + ${COMPACT_ROW_PAD_X + COMPACT_ROW_GAP - 10}px)`;
40
38
 
41
39
  // Glass sticky header: override `.reqore-panel-title` (the surface ReqorePanel
@@ -46,12 +44,17 @@ export const PANEL_LEFT_CSS = `calc(${LABEL_COL} + ${COMPACT_ROW_PAD_X + COMPACT
46
44
  // content blurs softly through.
47
45
  export const StyledCompactPanel = styled(ReqorePanel)<{
48
46
  $headerBg: string;
47
+ $nested?: boolean;
49
48
  }>`
50
49
  > .reqore-panel-title {
51
50
  background: ${({ $headerBg }) => $headerBg};
52
- backdrop-filter: blur(6px);
53
- -webkit-backdrop-filter: blur(6px);
54
- transform: translateZ(0);
51
+ /* The blur + translateZ exist only to make the STICKY top-level toolbar ghost
52
+ content beneath it; a nested sub-form's header isn't sticky, so skip them
53
+ (and the stacking context translateZ creates). */
54
+ ${({ $nested }) =>
55
+ $nested ?
56
+ ''
57
+ : 'backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); transform: translateZ(0);'}
55
58
  padding-top: ${GAP_FROM_SIZE[HEADER_GAP]}px;
56
59
  padding-bottom: ${GAP_FROM_SIZE[HEADER_GAP]}px;
57
60
  }
@@ -89,6 +92,62 @@ export const StyledGroupHeaderLine = styled.span<{ $color: string }>`
89
92
  background: linear-gradient(to right, ${({ $color }) => $color}, transparent);
90
93
  `;
91
94
 
95
+ // A status box (Needs attention / Set / Optional). Matches the "Focus" prototype:
96
+ // a barely-there accent border + a ~5%-opacity tint, NOT the loud intent border a
97
+ // stock ReqorePanel draws. `$accent` is the box's theme colour (warning/success/
98
+ // muted).
99
+ export const StyledStatusBox = styled(ReqorePanel)<{ $accent: string; $bg?: string }>`
100
+ &&& {
101
+ border: 1px solid ${({ $accent }) => `${$accent}33`};
102
+ /* $bg lets the muted "Optional" box opt into a darker, recessed surface
103
+ instead of the faint accent tint the coloured boxes use. */
104
+ background: ${({ $accent, $bg }) => $bg || `${$accent}1f`};
105
+ border-radius: 10px;
106
+ }
107
+ `;
108
+
109
+ // "One of the below is required" cluster box — wraps the members of an unmet
110
+ // one-of required group (in the Needs-attention box) so the constraint reads as
111
+ // one unit. The connection rail + status nodes still render inside (they convey
112
+ // which member satisfies the group); this box adds the explicit heading.
113
+ export const StyledRequiredClusterBox = styled.div<{ $border: string; $tint: string }>`
114
+ border: 1px solid ${({ $border }) => $border};
115
+ border-radius: 8px;
116
+ background: ${({ $tint }) => $tint};
117
+ padding: 2px 6px 6px;
118
+ margin: 4px 0;
119
+ /* The members render directly here (not via the gapped group body), so give
120
+ them the same modest gap the rest of the rows have. Pin the divider-centring
121
+ var to that gap (it doesn't widen in narrow like the group body's does). */
122
+ display: flex;
123
+ flex-flow: column;
124
+ --readfirst-row-gap: 4px;
125
+ gap: 4px;
126
+ `;
127
+ export const StyledRequiredClusterHeader = styled.div<{ $color: string }>`
128
+ display: flex;
129
+ align-items: center;
130
+ gap: 6px;
131
+ padding: 8px 0 4px ${GROUP_INDENT};
132
+ font-size: 10px;
133
+ font-weight: 600;
134
+ letter-spacing: 1px;
135
+ text-transform: uppercase;
136
+ color: ${({ $color }) => $color};
137
+ `;
138
+
139
+ // Thin schema-group sub-label inside a status box (CONNECTION / AUTHENTICATION /
140
+ // …). Quiet by design — the box header is the loud heading; this just keeps each
141
+ // field's group context as you scan. Indented to the rows' content line.
142
+ export const StyledStatusBoxGroupLabel = styled.div`
143
+ font-size: 10px;
144
+ font-weight: 500;
145
+ letter-spacing: 1px;
146
+ text-transform: uppercase;
147
+ opacity: 0.5;
148
+ padding: 8px 0 2px ${GROUP_INDENT};
149
+ `;
150
+
92
151
  // Required-group "connection" rail: contiguous members are linked by a continuous
93
152
  // vertical rail (StyledGroupBody draws it; the line bridges the row gaps and is
94
153
  // trimmed to the first/last node). Each member carries a status node — absolutely
@@ -180,10 +239,11 @@ export const StyledCardHeading = styled.div`
180
239
  min-width: 0;
181
240
  `;
182
241
 
242
+ /* The card (expanded) label matches the read-row label exactly — same size /
243
+ weight / case — so a field's name doesn't switch styles when you open it. */
183
244
  export const StyledCardLabel = styled.div<{ $color: string }>`
184
- font-size: 11px;
185
- text-transform: uppercase;
186
- letter-spacing: 0.07em;
245
+ font-size: 13px;
246
+ font-weight: 600;
187
247
  color: ${({ $color }) => $color};
188
248
  display: flex;
189
249
  align-items: center;
@@ -194,20 +254,66 @@ export const StyledCardLabel = styled.div<{ $color: string }>`
194
254
  so the ellipsis engages instead of overflowing. */
195
255
  export const StyledRowValue = styled.div<{ $color: string; $empty?: boolean }>`
196
256
  min-width: 0;
197
- color: ${({ $color }) => $color};
198
- font-style: ${({ $empty }) => ($empty ? 'italic' : 'normal')};
199
257
  font-size: 13px;
200
- overflow: hidden;
201
- text-overflow: ellipsis;
202
- white-space: nowrap;
258
+ /* Value + inline reason(s) share the line, wrapping the reason below only when
259
+ it doesn't fit (the Focus prototype's layout). */
260
+ display: flex;
261
+ flex-wrap: wrap;
262
+ align-items: baseline;
263
+ column-gap: 10px;
264
+ row-gap: 2px;
265
+ /* The muted/translucent colour applies to the VALUE TEXT only — not the whole
266
+ cell — so the message panels and the structured preview below render at full
267
+ opacity instead of inheriting the dimmed value colour. */
268
+ .options-readfirst-valuetext {
269
+ min-width: 0;
270
+ max-width: 100%;
271
+ color: ${({ $color }) => $color};
272
+ font-style: ${({ $empty }) => ($empty ? 'italic' : 'normal')};
273
+ overflow: hidden;
274
+ text-overflow: ellipsis;
275
+ white-space: nowrap;
276
+ }
277
+ .options-readfirst-reason {
278
+ font-style: italic;
279
+ font-size: 12px;
280
+ line-height: 1.3;
281
+ }
282
+ /* Schema message panels: full width of the value column, on their own line
283
+ directly beneath the value. */
284
+ .options-readfirst-info-panel {
285
+ flex-basis: 100%;
286
+ width: 100%;
287
+ display: flex;
288
+ flex-flow: column;
289
+ gap: 4px;
290
+ margin-top: 4px;
291
+ }
203
292
  `;
204
293
 
205
294
  export const StyledRowActions = styled.div`
206
295
  display: flex;
207
296
  align-items: center;
297
+ /* No align-self override: the actions (incl. the status dot) follow the row's
298
+ own vertical alignment — CENTRED on the common single-line row, TOP-aligned
299
+ (first line) on the tall rows that opt into align-items:start (descriptions /
300
+ message panels / hash previews). */
208
301
  gap: 6px;
209
302
  `;
210
303
 
304
+ // A single status mark pinned at the row's trailing edge: one dot, colour =
305
+ // severity (danger/warning/success). This is the "Focus" read-first signal that
306
+ // replaces the recessed value surface's intent stripe — attention dots carry a
307
+ // faint ring, a plain "set" dot does not. `unset` rows render no dot.
308
+ export const StyledStatusDot = styled.span<{ $color: string; $ring?: boolean }>`
309
+ width: 7px;
310
+ height: 7px;
311
+ border-radius: 50%;
312
+ flex: 0 0 auto;
313
+ background: ${({ $color }) => $color};
314
+ box-shadow: ${({ $color, $ring }) => ($ring ? `0 0 0 3px ${$color}22` : 'none')};
315
+ `;
316
+
211
317
  export const StyledActionSlot = styled.span<{ $width: number }>`
212
318
  display: inline-flex;
213
319
  justify-content: center;
@@ -220,18 +326,13 @@ export const StyledColumn = styled.div`
220
326
  flex-flow: column;
221
327
  `;
222
328
 
223
- // Sub-panels (messages, and the hash preview) sit BELOW the value row, indented
224
- // to the value column (StyledGroupBody) so they land on the recessed value
225
- // surface instead of the bare label gutter.
226
- export const StyledInfoPanel = styled.div`
227
- display: flex;
228
- flex-flow: column;
229
- gap: 4px;
230
- padding: 0 10px 8px 24px;
231
- `;
232
-
329
+ // The structured hash/list preview, rendered inside the value cell directly
330
+ // under the value summary. Full-width of the value column (it's a flex child of
331
+ // the wrapping value cell) so it lines up with the value, not the label gutter.
233
332
  export const StyledRowInset = styled.div`
234
- padding: 0 10px 6px 24px;
333
+ flex-basis: 100%;
334
+ width: 100%;
335
+ margin-top: 4px;
235
336
  `;
236
337
 
237
338
  // A small inline colour swatch shown before an rgbcolor value's hex string.
@@ -255,32 +356,21 @@ export const StyledGroupBody = styled.div<{
255
356
  display: flex;
256
357
  flex-flow: column;
257
358
  position: relative;
258
- gap: 8px;
359
+ /* Exposed as a var so the inter-field divider can centre itself in the gap
360
+ (the gap differs wide vs narrow). */
361
+ --readfirst-row-gap: 4px;
362
+ gap: var(--readfirst-row-gap);
259
363
 
260
364
  /* Indent each field block under the group header by a FLUID step. The %
261
365
  resolves against this container's width (not the screen), so it tracks the
262
- form even inside a narrow drawer; the clamp keeps it from vanishing on a
263
- slim form or ballooning into a big gutter on a wide one. */
366
+ (Field blocks no longer get a left gutter the spine/rail that needed it are
367
+ gone, so rows sit flush against the box, like the Focus prototype.) */
264
368
  > * {
265
- margin-left: ${GROUP_INDENT};
369
+ margin-left: 0;
266
370
  }
267
371
 
268
- /* The group spine: a faint vertical line down the block gutter. Drawn here (not
269
- on the panel) so it lives in the SAME coordinate space as the required-group
270
- rail and lands on the exact same x — GROUP_INDENT minus 9px matches the rail's
271
- left: -9px on each (indented) block. The rail is a descendant ::after, so it
272
- paints over this spine and wins where a required cluster overlaps it. */
273
- &::before {
274
- content: '';
275
- position: absolute;
276
- left: calc(${GROUP_INDENT} - 9px);
277
- top: 0;
278
- bottom: 8px;
279
- width: 2px;
280
- background: linear-gradient(to bottom, ${({ $lineColor }) => $lineColor}, transparent);
281
- pointer-events: none;
282
- z-index: 0;
283
- }
372
+ /* No group spine: the "Focus" look keeps the rows flat against the box. (The
373
+ required-group rail is a separate descendant ::after and still renders.) */
284
374
 
285
375
  .readfirst-row {
286
376
  display: grid;
@@ -291,19 +381,44 @@ export const StyledGroupBody = styled.div<{
291
381
  grid wider than its container and produce a horizontal scrollbar. The 0
292
382
  minimum lets it shrink and the value cell's ellipsis take over instead. */
293
383
  grid-template-columns: ${LABEL_COL} minmax(0, 1fr) auto;
384
+ /* CENTRE the cells vertically: on the common single-line read row the label,
385
+ value and status dot all sit on one centred line. Tall rows (a shown
386
+ description, message panels or a hash preview) opt back into top-alignment
387
+ (.readfirst-row-info-open / .readfirst-row-tall below) so the label + dot
388
+ stay on the value's FIRST line instead of floating to the middle. */
294
389
  align-items: center;
295
390
  gap: 14px;
296
- min-height: 38px;
297
- /* 3px vertical: the hover-revealed action buttons (revert/delete) are
298
- ~32px tall and occupy layout even at opacity 0 — with 8px padding they
299
- inflated removable rows to ~48px while plain rows sat at the 38px
300
- min-height. 32 + 6 = 38 keeps every one-line row the same height; the
301
- min-height keeps the click target for rows with shorter content. */
302
- padding: 3px 10px;
391
+ /* Generous, SYMMETRIC vertical padding so a single-line row isn't cramped
392
+ (content sits with even breathing room top + bottom); a min-height floor
393
+ keeps the rare shorter row a comfortable tap target. */
394
+ min-height: 40px;
395
+ padding: 9px 10px;
303
396
  border-radius: 6px;
304
397
  cursor: pointer;
305
398
  transition: background 0.12s ease;
306
399
  }
400
+ /* A dim hairline in the gap below each field so its start/end reads clearly.
401
+ Absolutely positioned (not a border) so it stays straight + full-width and
402
+ the row's rounded hover highlight is unaffected; sits in the inter-row gap. */
403
+ .readfirst-row::after {
404
+ content: '';
405
+ position: absolute;
406
+ /* Inset to the row's horizontal padding so the line spans the content, not
407
+ the full box edge-to-edge. */
408
+ left: ${COMPACT_ROW_PAD_X}px;
409
+ right: ${COMPACT_ROW_PAD_X}px;
410
+ /* Centred in the inter-field gap (which differs wide vs narrow) so the space
411
+ above and below each line is equal. */
412
+ bottom: calc(var(--readfirst-row-gap, 4px) / -2);
413
+ height: 1px;
414
+ background: ${({ $divider }) => $divider};
415
+ opacity: 0.5;
416
+ pointer-events: none;
417
+ z-index: 0;
418
+ }
419
+ .readfirst-row:last-child::after {
420
+ display: none;
421
+ }
307
422
  .readfirst-row:hover {
308
423
  background: ${({ $hover }) => $hover};
309
424
  }
@@ -319,8 +434,10 @@ export const StyledGroupBody = styled.div<{
319
434
  /* A disabled field (schema disabled flag or unmet dependencies) is not a
320
435
  click target — no hover invite, not-allowed cursor; a lock replaces the
321
436
  pencil. */
437
+ /* A disabled field reads dimmed (its name + value at 0.6) — clearly inactive. */
322
438
  .readfirst-row-disabled {
323
439
  cursor: not-allowed;
440
+ opacity: 0.6;
324
441
  }
325
442
  .readfirst-row-disabled:hover {
326
443
  background: transparent;
@@ -341,15 +458,6 @@ export const StyledGroupBody = styled.div<{
341
458
  background: transparent;
342
459
  }
343
460
  }
344
- .readfirst-action {
345
- opacity: 0;
346
- transition: opacity 0.12s ease;
347
- }
348
- .readfirst-row:hover .readfirst-action,
349
- .readfirst-row:focus-visible .readfirst-action {
350
- opacity: 0.85;
351
- }
352
-
353
461
  /* A scalar row being edited in place: the real editor replaces the value
354
462
  cell. The row stops being a click target (the editor owns the clicks) and
355
463
  keeps a constant active background. Vertical padding is tightened so the
@@ -363,10 +471,11 @@ export const StyledGroupBody = styled.div<{
363
471
  the ✓/↺ cluster get small offsets to sit optically centred on it. */
364
472
  align-items: start;
365
473
  background: ${({ $hover }) => $hover};
366
- /* Zero vertical padding: the pinned min-height (captured from the read
367
- row at activation) owns the height; the editor centres within it. */
474
+ /* No top padding (the per-cell nudges below anchor the editor to the first
475
+ line), but a real BOTTOM padding so the editor never sits flush against
476
+ the row's bottom edge — a tall input used to look clipped/unfinished. */
368
477
  padding-top: 0;
369
- padding-bottom: 0;
478
+ padding-bottom: 9px;
370
479
  /* Tighter column gap: the editor's trailing template ⋮ and our ✓ should
371
480
  read as one control cluster, not two separated groups. */
372
481
  column-gap: 6px;
@@ -396,9 +505,15 @@ export const StyledGroupBody = styled.div<{
396
505
  rather than a viewport media query, so a slim desktop drawer stacks too.
397
506
  The 3 row children are placed explicitly:
398
507
  label (1,1) · actions (1,2) · value (2, span both). */
508
+ /* Generous breathing room BETWEEN fields when stacked — far easier to scan and
509
+ less overwhelming on a phone. */
510
+ &.readfirst-narrow {
511
+ --readfirst-row-gap: 18px;
512
+ }
399
513
  &.readfirst-narrow .readfirst-row {
400
514
  grid-template-columns: minmax(0, 1fr) auto;
401
- gap: 4px 14px;
515
+ gap: 2px 14px;
516
+ position: relative;
402
517
  }
403
518
  &.readfirst-narrow .readfirst-row > :nth-child(1) {
404
519
  grid-column: 1;
@@ -412,8 +527,26 @@ export const StyledGroupBody = styled.div<{
412
527
  grid-column: 1 / -1;
413
528
  grid-row: 2;
414
529
  }
530
+ /* Read (non-editing) rows: float the trailing actions (status dot + the
531
+ hover-revealed delete/revert) out of the grid flow so a delete button can't
532
+ inflate the first row and push the value down. They're pinned to a band the
533
+ height of the LABEL line and vertically centred in it — so the dot always
534
+ sits at the same place (centred on the field name), never the bare corner. */
535
+ &.readfirst-narrow .readfirst-row:not(.readfirst-row-editing) > :nth-child(3) {
536
+ position: absolute;
537
+ top: 2px;
538
+ right: 10px;
539
+ height: 22px;
540
+ }
541
+ &.readfirst-narrow .readfirst-row:not(.readfirst-row-editing) > :nth-child(1) {
542
+ padding-right: 46px;
543
+ }
415
544
 
416
- /* The surface backs every field BLOCK (direct children of the body). */
545
+ /* Focus calm rows: NO recessed value surface and NO intent stripe. Row status
546
+ is carried by the trailing status dot; message/preview panels keep their own
547
+ intent backgrounds. The (now invisible) pseudo is retained only to preserve
548
+ the block's positioning/stacking context that the required-group rail and its
549
+ cluster nodes resolve against — removing it would unmoor them. */
417
550
  > *:not(.options-readfirst-card) {
418
551
  position: relative;
419
552
  }
@@ -422,9 +555,7 @@ export const StyledGroupBody = styled.div<{
422
555
  position: absolute;
423
556
  inset: 0;
424
557
  left: ${PANEL_LEFT_CSS};
425
- background: ${({ $rowBg }) => $rowBg};
426
- border-radius: 6px;
427
- border-left: 3px solid var(--readfirst-stripe, transparent);
558
+ background: transparent;
428
559
  pointer-events: none;
429
560
  z-index: 0;
430
561
  }
@@ -435,64 +566,25 @@ export const StyledGroupBody = styled.div<{
435
566
  z-index: 1;
436
567
  }
437
568
 
438
- /* Required-group connection rail. Drawn on the member's BLOCK ROOT (so it spans
439
- the whole member, including a message panel below the row), the rail is one
440
- continuous line: each segment BRIDGES the 8px gap into the next member
441
- (bottom: -8px) and the end members trim to their node centre, so it reads as a
442
- single unbroken rail node-to-node. It sits in the existing left gutter (node
443
- centre ~10px left of the row), so labels keep their place. The node (drawn by
444
- the row) is opaque and overlaps the rail, masking it no line through the
445
- ring. The node centre sits ~19px below the block top (13px node top + 6px). */
446
- .readfirst-cluster-rail::after {
447
- content: '';
448
- position: absolute;
449
- left: -9px;
450
- top: 0;
451
- bottom: -8px;
452
- width: 2px;
453
- /* muted info — the connection is a quiet structural hint, not a loud accent */
454
- background: ${({ $focus }) => `${$focus}99`};
455
- box-shadow: 0 0 4px ${({ $focus }) => `${$focus}99`};
456
- pointer-events: none;
457
- z-index: 0;
458
- }
459
- .readfirst-cluster-rail.readfirst-cluster-first::after {
460
- top: 19px;
461
- }
462
- .readfirst-cluster-rail.readfirst-cluster-last::after {
463
- bottom: calc(100% - 19px);
464
- }
465
- /* Group fulfilled (any member set): the whole rail reads success, not just the
466
- satisfying node. */
467
- .readfirst-cluster-rail.readfirst-cluster-satisfied::after {
468
- background: ${({ $success }) => `${$success}99`};
469
- box-shadow: 0 0 4px ${({ $success }) => `${$success}99`};
470
- }
471
- /* Sub-panels (messages, hash preview) indent to the value column so they sit on
472
- the surface, not in the bare label gutter. */
473
- .options-readfirst-info-panel,
474
- .options-readfirst-inset {
475
- padding-left: ${VALUE_LEFT_CSS};
476
- }
477
- /* A field's short_desc renders under its NAME (revealed by the ⓘ toggle),
478
- growing the label block to multiple lines. Top-anchor those open rows so the
479
- value lines up with the name rather than the middle of the taller label;
480
- closed (single-line) rows keep the centred read-row rhythm. */
481
- .readfirst-row-info-open {
569
+ /* (The required-group connection rail was removed the "One of the below is
570
+ required" box now carries the grouping.) */
571
+
572
+ /* Tall rows top-anchor (overriding the row's centred default) so the label and
573
+ dot stay on the value's FIRST line rather than floating to the vertical
574
+ middle: .readfirst-row-info-open = a shown short_desc grows the LABEL;
575
+ .readfirst-row-tall = message panels / a hash preview grow the VALUE cell. */
576
+ .readfirst-row-info-open,
577
+ .readfirst-row-tall {
482
578
  align-items: start;
483
579
  }
484
- /* Narrow stacks label-over-value, so the value-column offset is meaningless
485
- the surface spans the full block and the sub-panels drop to the 12px rail. */
580
+ /* Narrow stacks label-over-value: the value aligns flush UNDER the label (no
581
+ indent), like the Focus prototype. */
486
582
  &.readfirst-narrow .readfirst-row > :nth-child(2) {
487
- padding-left: 12px;
583
+ padding-left: 0;
488
584
  }
489
585
  &.readfirst-narrow > *:not(.options-readfirst-card)::before {
490
586
  left: 0;
491
587
  }
492
- &.readfirst-narrow .options-readfirst-info-panel,
493
- &.readfirst-narrow .options-readfirst-inset {
494
- padding-left: 12px;
495
- }
496
588
  /* Touch layouts have no hover: a slot reserved for the hover-revealed edit
497
589
  pencil is permanent dead space that insets every chip from the edge —
498
590
  drop it (rows are tap-to-edit; the lock/add slots stay, they're static). */
@@ -500,32 +592,17 @@ export const StyledGroupBody = styled.div<{
500
592
  /* !important: the slot carries an inline display for the desktop layout. */
501
593
  display: none !important;
502
594
  }
503
- /* Phone air: stacked blocks get slightly taller inner padding. The in-place
504
- editor keeps zero padding so its pinned height still matches. */
595
+ /* Stacked rows: keep them tight (match the Focus prototype). The min-height is
596
+ dropped so a 2-line label+value row sizes to its content instead of being
597
+ padded out to 38px. The in-place editor keeps zero padding (below). */
505
598
  &.readfirst-narrow .readfirst-row {
506
- padding-top: 6px;
507
- padding-bottom: 6px;
599
+ padding-top: 2px;
600
+ padding-bottom: 2px;
601
+ min-height: 0;
508
602
  }
509
603
  &.readfirst-narrow .readfirst-row-editing {
510
604
  padding-top: 0;
511
605
  padding-bottom: 0;
512
606
  }
513
607
 
514
- /* A hash block = its parent row + the revealed sub-rows. Highlight the whole
515
- block as one unit on hover (rather than only the parent row), and neutralise
516
- the parent row's own hover so the two don't stack into a darker band. The
517
- parent row's hover actions still surface whenever the block is hovered. */
518
- .options-readfirst-hash-row {
519
- border-radius: 6px;
520
- transition: background 0.12s ease;
521
- }
522
- .options-readfirst-hash-row:hover {
523
- background: ${({ $hover }) => $hover};
524
- }
525
- .options-readfirst-hash-row:hover .readfirst-row {
526
- background: transparent;
527
- }
528
- .options-readfirst-hash-row:hover .readfirst-action {
529
- opacity: 0.85;
530
- }
531
608
  `;
@@ -26,6 +26,7 @@ export interface ICompactToolbarContext {
26
26
  readOnly?: boolean;
27
27
  // Completion meter
28
28
  invalidCount: number;
29
+ attentionCount: number;
29
30
  completion: { set: number; total: number; pct: number };
30
31
  // Invalid-fields banner (pinned in the sticky header)
31
32
  showInvalidOnly: boolean;
@@ -435,6 +435,41 @@ export interface IReadFirstCompletion {
435
435
  pct: number;
436
436
  }
437
437
 
438
+ /** Read-first row status, shared by the status DOT (CompactRow) and the
439
+ * status BOX bucketing (FormEngine) so the two can never disagree:
440
+ * invalid — a value fails validation, or a danger message is present (red)
441
+ * todo — empty & required (or has a warning message), needs a value (amber)
442
+ * set — has a valid value (green)
443
+ * optional — empty & not required, or covered by a one-of sibling (calm) */
444
+ export type TReadFirstStatus = 'invalid' | 'todo' | 'set' | 'optional';
445
+
446
+ export const getReadFirstStatus = (s: {
447
+ empty: boolean;
448
+ required: boolean;
449
+ /** Empty member of a one-of required group already satisfied by a sibling. */
450
+ covered: boolean;
451
+ /** Non-empty value fails validation, or a danger message is attached. */
452
+ invalid: boolean;
453
+ /** A warning message is attached (surfaces an empty field for attention). */
454
+ warned: boolean;
455
+ }): TReadFirstStatus => {
456
+ if (s.invalid) return 'invalid';
457
+ if (!s.empty) return 'set';
458
+ // A one-of member covered by a satisfied sibling reads as "set" (green) — the
459
+ // requirement is met; the row just shows a "Covered by …" note.
460
+ if (s.covered) return 'set';
461
+ if (s.required || s.warned) return 'todo';
462
+ return 'optional';
463
+ };
464
+
465
+ /** Coarse bucket for the three status boxes (Needs attention / Set / Optional). */
466
+ export const getReadFirstBucket = (
467
+ status: TReadFirstStatus
468
+ ): 'attention' | 'set' | 'optional' =>
469
+ status === 'set' ? 'set'
470
+ : status === 'optional' ? 'optional'
471
+ : 'attention';
472
+
438
473
  /** Count how many of the shown options have a value set, for the progress meter. */
439
474
  export const getReadFirstCompletion = (
440
475
  shownOptions: Record<string, IQorusFormField | undefined> = {}