@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
@@ -3,9 +3,9 @@ import {
3
3
  ReqoreCollection,
4
4
  ReqoreControlGroup,
5
5
  ReqoreErrorBoundary,
6
+ ReqoreIcon,
6
7
  ReqoreMessage,
7
8
  ReqoreP,
8
- ReqorePanel,
9
9
  ReqoreSkeleton,
10
10
  ReqoreTag,
11
11
  ReqoreTagGroup,
@@ -86,15 +86,21 @@ import {
86
86
  StyledCompactPanel,
87
87
  StyledGroupBody,
88
88
  StyledGroupHeader,
89
- StyledGroupHeaderLine,
89
+ StyledRequiredClusterBox,
90
+ StyledRequiredClusterHeader,
91
+ StyledStatusBox,
92
+ StyledStatusBoxGroupLabel,
90
93
  } from './compactRowStyles';
91
94
  import { OptionFieldMessages } from './OptionFieldMessages';
92
95
  import { OptionsHelpDialog } from './OptionsHelpDialog';
93
96
  import {
94
97
  getOptionGroup,
95
98
  getOptionGroupLabel,
99
+ getReadFirstBucket,
96
100
  getReadFirstCompletion,
101
+ getReadFirstStatus,
97
102
  isOptionValueEmpty,
103
+ TReadFirstStatus,
98
104
  } from './readFirst';
99
105
 
100
106
  // Re-export types for consumers
@@ -469,6 +475,14 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
469
475
  * form should line up with the section description above it. Default `false`.
470
476
  */
471
477
  compactFlush?: boolean;
478
+ /**
479
+ * Compact mode only: this form is an EMBEDDED sub-form (e.g. an arg_schema
480
+ * field's nested form) rather than the top-level scroller. It doesn't own a
481
+ * scroll context, so the toolbar isn't sticky and its header drops the dark
482
+ * blurred backdrop (and the stacking context that goes with it) — it sits
483
+ * transparently inside the parent's edit card. Default `false`.
484
+ */
485
+ compactNested?: boolean;
472
486
  /** Compact mode only: per-group display metadata (label / icon / subtitle /
473
487
  * order) — the server only sends the bare group key. */
474
488
  groups?: Record<string, IFormEngineGroup>;
@@ -525,6 +539,7 @@ export const FormEngine = ({
525
539
  showTypeToggle = true,
526
540
  compact,
527
541
  compactFlush = false,
542
+ compactNested = false,
528
543
  commitMode = 'immediate',
529
544
  expandMode = 'single',
530
545
  onCommit,
@@ -560,6 +575,10 @@ export const FormEngine = ({
560
575
  const [showInvalidOptionsOnly, setShowInvalidOptionsOnly] = useState<boolean>(false);
561
576
  // Which options are expanded into their editor (several can be open at once).
562
577
  const [expandedOptions, setExpandedOptions] = useState<string[]>([]);
578
+ // Remembers each row's last settled status box, so an actively-edited field
579
+ // stays put when its status flips (e.g. becomes valid) instead of jumping to
580
+ // another box mid-edit and stealing focus. Keyed by option name.
581
+ const settledBucket = useRef<Record<string, 'attention' | 'set' | 'optional'>>({});
563
582
  // Measured form width (not viewport — the form lives in drawers/panels of
564
583
  // arbitrary width) drives the stacked narrow layout.
565
584
  const [compactWrapRef, { width: compactWrapWidth }] = useMeasure<HTMLDivElement>();
@@ -614,9 +633,15 @@ export const FormEngine = ({
614
633
  const flashTimeout = useRef<ReturnType<typeof setTimeout>>();
615
634
  const flashOptions = useCallback((optionNames: string[], scrollToFirst = false) => {
616
635
  if (scrollToFirst && optionNames[0]) {
617
- document
618
- .querySelector(`.readfirst-row[data-field="${optionNames[0]}"]`)
619
- ?.scrollIntoView({ block: 'center', behavior: 'smooth' });
636
+ // Defer to the next frame: when this fires for a field that just changed
637
+ // panels, its row has only just re-mounted in the new box — scrolling in the
638
+ // same tick targets the stale (pre-move) layout, so the page doesn't budge.
639
+ // A rAF lets the new position settle first.
640
+ requestAnimationFrame(() => {
641
+ document
642
+ .querySelector(`.readfirst-row[data-field="${optionNames[0]}"]`)
643
+ ?.scrollIntoView({ block: 'center', behavior: 'smooth' });
644
+ });
620
645
  }
621
646
  setFlashedOptions(optionNames);
622
647
  clearTimeout(flashTimeout.current);
@@ -627,6 +652,28 @@ export const FormEngine = ({
627
652
  [flashOptions]
628
653
  );
629
654
  useEffect(() => () => clearTimeout(flashTimeout.current), []);
655
+
656
+ // Follow a field across panels: when its status bucket changes — e.g. you fill
657
+ // an optional field and it jumps to Set / Needs attention — scroll to its new
658
+ // row and flash it so it's easy to keep track of. `settledBucket` holds each
659
+ // field's current panel (frozen while the field is being edited, it re-buckets
660
+ // on collapse), so diffing it after every render catches the move the instant it
661
+ // lands in the new panel. Runs every render; the diff is cheap and only fires a
662
+ // scroll on an ACTUAL move of a non-expanded field.
663
+ const prevSettledBucket = useRef<Record<string, 'attention' | 'set' | 'optional'>>({});
664
+ useEffect(() => {
665
+ if (!compact) return;
666
+ const cur = settledBucket.current;
667
+ const prev = prevSettledBucket.current;
668
+ const moved = Object.keys(cur).find(
669
+ (name) => prev[name] && prev[name] !== cur[name] && !expandedOptions.includes(name)
670
+ );
671
+ prevSettledBucket.current = { ...cur };
672
+ if (moved) {
673
+ flashOptions([moved], true);
674
+ }
675
+ });
676
+
630
677
  const compactNarrow = !!compactWrapWidth && compactWrapWidth < 480;
631
678
  // Info panels auto-open on Tier-1 content; the per-row user override sticks.
632
679
  const [infoPanelOverrides, setInfoPanelOverrides] = useState<Record<string, boolean>>({});
@@ -1028,6 +1075,10 @@ export const FormEngine = ({
1028
1075
  meta: undefined,
1029
1076
  };
1030
1077
  });
1078
+ // Collapse it too: a removed field drops back to the (collapsed) Optional box
1079
+ // as a quiet addable row — if it was being edited, that editor must close
1080
+ // rather than linger as an open editor for a field that's no longer added.
1081
+ setExpandedOptions((prev) => prev.filter((name) => name !== optionName));
1031
1082
  }, []);
1032
1083
 
1033
1084
  const handleAddOptionalFieldChange = useCallback(
@@ -1269,6 +1320,73 @@ export const FormEngine = ({
1269
1320
  );
1270
1321
  }, [showInvalidOptionsOnly, JSON.stringify(availableOptions)]);
1271
1322
 
1323
+ // Read-first STATUS / BOX for one option — lifted to component scope so the
1324
+ // status boxes (renderCompact) and the header's "needs attention" count share
1325
+ // exactly one definition. One-of group members travel together (bucket by the
1326
+ // group's satisfaction); everything else by its own status.
1327
+ const schemaMsgIntent = useCallback(
1328
+ (name: string): 'danger' | 'warning' | undefined => {
1329
+ const msgs = ((options?.[name] as { messages?: Array<{ intent?: string }> } | undefined)
1330
+ ?.messages || []) as Array<{ intent?: string }>;
1331
+ if (msgs.some((m) => m.intent === 'danger')) return 'danger';
1332
+ if (msgs.some((m) => m.intent === 'warning')) return 'warning';
1333
+ return undefined;
1334
+ },
1335
+ [JSON.stringify(options)]
1336
+ );
1337
+ const getOptionStatus = useCallback(
1338
+ (name: string, hidden = false): TReadFirstStatus => {
1339
+ if (hidden) return 'optional';
1340
+ const schema = options?.[name];
1341
+ const type = (schema?.ui_type || schema?.type) as TQorusType;
1342
+ const value = (availableOptions as TQorusForm)?.[name]?.value;
1343
+ const empty = isOptionValueEmpty(value);
1344
+ const reqGroups = (schema?.required_groups as string[] | undefined) || [];
1345
+ const required = !!(schema?.required || reqGroups.length);
1346
+ const covered =
1347
+ empty &&
1348
+ reqGroups.some((g) => {
1349
+ const by = requiredGroupsInfo.satisfiedBy[g];
1350
+ return !!by && by !== name;
1351
+ });
1352
+ const msgIntent = schemaMsgIntent(name);
1353
+ const invalid = (!empty && !isOptionValid(name, type, value)) || msgIntent === 'danger';
1354
+ return getReadFirstStatus({
1355
+ empty,
1356
+ required,
1357
+ covered,
1358
+ invalid,
1359
+ warned: msgIntent === 'warning',
1360
+ });
1361
+ },
1362
+ [
1363
+ JSON.stringify(options),
1364
+ JSON.stringify(availableOptions),
1365
+ isOptionValid,
1366
+ requiredGroupsInfo,
1367
+ schemaMsgIntent,
1368
+ ]
1369
+ );
1370
+ const getOptionBucket = useCallback(
1371
+ (name: string, hidden = false): 'attention' | 'set' | 'optional' => {
1372
+ if (!hidden) {
1373
+ const reqGroups = (options?.[name]?.required_groups as string[] | undefined) || [];
1374
+ if (reqGroups.length) {
1375
+ return reqGroups.some((g) => !requiredGroupsInfo.satisfiedBy[g]) ? 'attention' : 'set';
1376
+ }
1377
+ }
1378
+ return getReadFirstBucket(getOptionStatus(name, hidden));
1379
+ },
1380
+ [JSON.stringify(options), requiredGroupsInfo, getOptionStatus]
1381
+ );
1382
+ // How many fields are in the "Needs attention" box — drives the header link.
1383
+ const readFirstAttentionCount = useMemo(
1384
+ () =>
1385
+ Object.keys(availableOptions || {}).filter((name) => getOptionBucket(name) === 'attention')
1386
+ .length,
1387
+ [JSON.stringify(availableOptions), getOptionBucket]
1388
+ );
1389
+
1272
1390
  const getIntent = useCallback(
1273
1391
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
1274
1392
  (optName: string, type: TQorusType, optValue: any, _op: any): TReqoreIntent => {
@@ -1430,20 +1548,37 @@ export const FormEngine = ({
1430
1548
  const operatorParts = fixOperatorValue(other.op);
1431
1549
  return (
1432
1550
  <>
1433
- {(suppressSchemaMessages ? [] : (options?.[optionName] as any)?.messages || []).map(
1434
- ({ intent, title, content }: any, index: number) => (
1551
+ {(() => {
1552
+ const schemaMsgs = (
1553
+ suppressSchemaMessages ? [] : (options?.[optionName] as any)?.messages || []
1554
+ ) as { intent?: string; title?: string; content?: string }[];
1555
+ if (!schemaMsgs.length) return null;
1556
+ const items = schemaMsgs.map(({ intent, title, content }, index) => (
1435
1557
  <ReqoreMessage
1436
- intent={intent}
1558
+ intent={intent as never}
1437
1559
  title={title}
1438
1560
  key={title || index}
1439
1561
  opaque={false}
1440
1562
  size='small'
1441
- margin='bottom'
1563
+ // Compact: flat (no border) to match the read-row info panels;
1564
+ // classic forms keep the bordered, bottom-margined message.
1565
+ flat={compact || undefined}
1566
+ margin={compact ? undefined : 'bottom'}
1442
1567
  >
1443
1568
  {content}
1444
1569
  </ReqoreMessage>
1445
- )
1446
- )}
1570
+ ));
1571
+ // Compact: stack them in a 4px-gap panel so a field's messages look
1572
+ // identical whether the row is collapsed (read panel) or expanded.
1573
+ return compact ?
1574
+ <div
1575
+ className='options-readfirst-info-panel'
1576
+ style={{ display: 'flex', flexFlow: 'column', gap: 4, marginBottom: 8 }}
1577
+ >
1578
+ {items}
1579
+ </div>
1580
+ : <>{items}</>;
1581
+ })()}
1447
1582
  {operators && size(operators) ?
1448
1583
  <>
1449
1584
  <ReqoreControlGroup fill wrap className='operators'>
@@ -1498,6 +1633,9 @@ export const FormEngine = ({
1498
1633
  <TemplateField
1499
1634
  fluid
1500
1635
  {...(options?.[optionName] as any)}
1636
+ // Propagate compact so an arg_schema field renders a COMPACT sub-form
1637
+ // (consistent with the parent) rather than the classic FormEngine.
1638
+ compact={compact}
1501
1639
  // SEAM: forwarded through TemplateField's rest-spread to AutoFormField,
1502
1640
  // which renders consumer-injected editors by field type/ui_type.
1503
1641
  componentOverrides={componentOverrides}
@@ -1726,6 +1864,7 @@ export const FormEngine = ({
1726
1864
  () => ({
1727
1865
  readOnly,
1728
1866
  invalidCount: size(validityData.invalidFields),
1867
+ attentionCount: readFirstAttentionCount,
1729
1868
  completion: readFirstCompletion,
1730
1869
  showInvalidOnly: showInvalidOptionsOnly,
1731
1870
  onToggleInvalidOnly: handleToggleInvalidOnly,
@@ -1751,6 +1890,7 @@ export const FormEngine = ({
1751
1890
  [
1752
1891
  readOnly,
1753
1892
  validityData,
1893
+ readFirstAttentionCount,
1754
1894
  readFirstCompletion,
1755
1895
  showInvalidOptionsOnly,
1756
1896
  handleToggleInvalidOnly,
@@ -1809,15 +1949,18 @@ export const FormEngine = ({
1809
1949
  pushRow(optionName, false);
1810
1950
  }
1811
1951
  });
1812
- // When searching, also surface matching hidden optional fields (not yet
1813
- // added) so the search spans the whole schema, not just the visible rows.
1814
- if (query) {
1815
- forEach(filteredOptions, (_schema, optionName) => {
1816
- if (matchesQuery(optionName)) {
1817
- pushRow(optionName, true);
1818
- }
1819
- });
1820
- }
1952
+ // Surface EVERY not-yet-added optional field as an addable (hidden) row, so
1953
+ // the whole schema is browsable inline they all land in the Optional box
1954
+ // (hidden ⇒ 'optional' bucket) instead of being buried in the Fields menu.
1955
+ // Narrowed by the same filters as the listed rows (search query + required-
1956
+ // only). availableOptions (listed) and filteredOptions (these) are disjoint —
1957
+ // the former is built from fixedValue keys, the latter excludes them — so a
1958
+ // field is never both a listed and a hidden row.
1959
+ forEach(filteredOptions, (_schema, optionName) => {
1960
+ if (matchesFilters(optionName)) {
1961
+ pushRow(optionName, true);
1962
+ }
1963
+ });
1821
1964
 
1822
1965
  // User sort (Fields menu → "Sort by"), applied WITHIN each group so the
1823
1966
  // group sections and the required-group rails are preserved. Schema order is
@@ -1863,6 +2006,67 @@ export const FormEngine = ({
1863
2006
  return groupOrder.indexOf(a) - groupOrder.indexOf(b);
1864
2007
  });
1865
2008
 
2009
+ // Read-first STATUS per row → one of three boxes (Needs attention / Set /
2010
+ // Optional), via the component-scope getOptionBucket (shared with the header
2011
+ // count and the row dot, so box ↔ dot ↔ header always agree). Buckets keep
2012
+ // schema-group order (thin sub-labels) and the required-group clustering: an
2013
+ // unmet one-of group's members all land in attention and still rail up.
2014
+ type TRowEntry = { name: string; hidden: boolean };
2015
+ type TBucketKey = 'attention' | 'set' | 'optional';
2016
+ const buckets: Record<TBucketKey, Record<string, TRowEntry[]>> = {
2017
+ attention: {},
2018
+ set: {},
2019
+ optional: {},
2020
+ };
2021
+ const bucketGroups: Record<TBucketKey, string[]> = { attention: [], set: [], optional: [] };
2022
+ // Freeze the box of any field currently being edited (or whose one-of group
2023
+ // has an edited member) to its last settled box — so finishing an edit that
2024
+ // flips its status doesn't remount it in another box and steal focus.
2025
+ const stableBucketOf = (entry: TRowEntry): TBucketKey => {
2026
+ const fresh = getOptionBucket(entry.name, entry.hidden);
2027
+ const groupBeingEdited =
2028
+ !entry.hidden &&
2029
+ ((options?.[entry.name]?.required_groups as string[] | undefined) || []).some((g) =>
2030
+ (requiredGroupsInfo.members[g] || []).some((m) => expandedOptions.includes(m))
2031
+ );
2032
+ if (!entry.hidden && (expandedOptions.includes(entry.name) || groupBeingEdited)) {
2033
+ const memo = settledBucket.current[entry.name];
2034
+ if (memo) return memo;
2035
+ }
2036
+ settledBucket.current[entry.name] = fresh;
2037
+ return fresh;
2038
+ };
2039
+ groupKeys.forEach((groupName) => {
2040
+ grouped[groupName].forEach((entry) => {
2041
+ const b = stableBucketOf(entry);
2042
+ if (!buckets[b][groupName]) {
2043
+ buckets[b][groupName] = [];
2044
+ bucketGroups[b].push(groupName);
2045
+ }
2046
+ buckets[b][groupName].push(entry);
2047
+ });
2048
+ });
2049
+ const bucketCount = (b: TBucketKey) =>
2050
+ bucketGroups[b].reduce((n, g) => n + buckets[b][g].length, 0);
2051
+ // 'general' / 'optional' are the SYNTHETIC fallback group keys getOptionGroup
2052
+ // assigns to fields with no explicit `group` — printing a "General"/"Optional"
2053
+ // sub-label for those is just noise, so suppress it. BUT a consumer may also
2054
+ // use 'general' as a REAL group (defining it in the `groups` prop and tagging
2055
+ // fields with `group: 'general'`); in that case it's a named group like any
2056
+ // other and DOES get its sub-label.
2057
+ const showGroupSubLabel = (groupName: string) =>
2058
+ (groupName !== 'general' && groupName !== 'optional') || !!groups?.[groupName];
2059
+ const STATUS_BOXES: Array<{
2060
+ key: TBucketKey;
2061
+ label: string;
2062
+ intent?: 'warning' | 'success';
2063
+ icon: IReqoreIconName;
2064
+ }> = [
2065
+ { key: 'attention', label: 'Needs attention', intent: 'warning', icon: 'ErrorWarningLine' },
2066
+ { key: 'set', label: 'Set', intent: 'success', icon: 'CheckLine' },
2067
+ { key: 'optional', label: 'Optional', icon: 'CheckboxBlankCircleLine' },
2068
+ ];
2069
+
1866
2070
  // Build the rows for one group: contiguous required-group members are pulled
1867
2071
  // together at the first member's slot and rendered as a connected rail (flat
1868
2072
  // rows — no wrapper — so the value surface applies normally; the rail + nodes
@@ -1891,7 +2095,9 @@ export const FormEngine = ({
1891
2095
  clusterLast={clusterLast}
1892
2096
  />
1893
2097
  );
1894
- if (compactNarrow) return names.map((entry) => renderRow(entry, false));
2098
+ // (Clustering runs in narrow mode too now — the "One of the below is
2099
+ // required" box wraps the members regardless of width; it no longer relies
2100
+ // on a contiguous rail.)
1895
2101
  const emitted = new Set<string>();
1896
2102
  const groupOf = (name: string) =>
1897
2103
  (options?.[name]?.required_groups as string[] | undefined)?.[0];
@@ -1902,9 +2108,27 @@ export const FormEngine = ({
1902
2108
  const memberEntries = names.filter((e) => !e.hidden && groupOf(e.name) === grp);
1903
2109
  if (memberEntries.length < 2) return renderRow(entry, false);
1904
2110
  emitted.add(grp);
1905
- return memberEntries.map((e, idx) =>
2111
+ const railed = memberEntries.map((e, idx) =>
1906
2112
  renderRow(e, true, idx === 0, idx === memberEntries.length - 1)
1907
2113
  );
2114
+ // An UNMET one-of group gets the explicit "One of the below is required"
2115
+ // box (the Focus cluster). A met group needs no banner — the rail + the
2116
+ // "Covers"/"Covered by" chips already say which member satisfies it.
2117
+ if (requiredGroupsInfo.satisfiedBy[grp]) return railed;
2118
+ return (
2119
+ <StyledRequiredClusterBox
2120
+ key={grp}
2121
+ className='options-readfirst-required-cluster'
2122
+ $border={`${cWarning}33`}
2123
+ $tint={`${cWarning}0d`}
2124
+ >
2125
+ <StyledRequiredClusterHeader $color={cWarning}>
2126
+ <ReqoreIcon icon='LinkM' size='11px' style={{ color: cWarning }} />
2127
+ One of the below is required
2128
+ </StyledRequiredClusterHeader>
2129
+ {railed}
2130
+ </StyledRequiredClusterBox>
2131
+ );
1908
2132
  });
1909
2133
  };
1910
2134
 
@@ -1922,19 +2146,35 @@ export const FormEngine = ({
1922
2146
  <StyledCompactWrap
1923
2147
  ref={setCompactWrap}
1924
2148
  className='options-readfirst-scroll'
1925
- $flush={compactFlush}
2149
+ // A nested sub-form sits flush inside the parent's card — no outer
2150
+ // gutter (the card already provides the breathing room).
2151
+ $flush={compactFlush || compactNested}
1926
2152
  >
1927
2153
  <StyledCompactPanel
1928
- $headerBg={headerBg}
2154
+ // The top-level form scrolls, so its toolbar STICKS and carries a
2155
+ // dark blurred backdrop so content ghosts cleanly beneath it. A
2156
+ // nested (arg_schema) sub-form owns no scroll context — drop the
2157
+ // sticky, the backdrop, and the stacking context so its header is
2158
+ // transparent inside the parent's card.
2159
+ $headerBg={compactNested ? 'transparent' : headerBg}
2160
+ $nested={compactNested}
1929
2161
  flat
1930
- stickyHeader
2162
+ // No panel background: the form sits transparently on whatever
2163
+ // hosts it (page, drawer, or — for an arg_schema field — the
2164
+ // parent's edit card) instead of stacking its own dark surface.
2165
+ // The status boxes keep their own tints; the sticky toolbar keeps
2166
+ // its blurred header via the $headerBg override.
2167
+ transparent
2168
+ stickyHeader={!compactNested}
1931
2169
  padded={false}
1932
2170
  actions={compactHeaderActions}
1933
2171
  contentStyle={{
1934
2172
  display: 'flex',
1935
2173
  flexFlow: 'column',
1936
2174
  gap: '10px',
1937
- padding: '0 0 12px',
2175
+ // Nested sub-form: no surrounding panel padding (it's flush in
2176
+ // the parent card); top-level keeps a small bottom gutter.
2177
+ padding: compactNested ? '0' : '0 0 12px',
1938
2178
  }}
1939
2179
  >
1940
2180
  {size(groupKeys) === 0 ?
@@ -1943,78 +2183,61 @@ export const FormEngine = ({
1943
2183
  </ReqoreMessage>
1944
2184
  : null}
1945
2185
 
1946
- {groupKeys.map((groupName) => {
1947
- const names = grouped[groupName];
1948
- const groupConfig = groups?.[groupName];
1949
- const invalidCount = names.filter(
1950
- (entry) =>
1951
- !entry.hidden &&
1952
- !isOptionValid(
1953
- entry.name,
1954
- (options?.[entry.name]?.ui_type as TQorusType) ||
1955
- (options?.[entry.name]?.type as TQorusType),
1956
- (shownOptions as TQorusForm)[entry.name]?.value
1957
- )
1958
- ).length;
1959
-
2186
+ {STATUS_BOXES.map((box) => {
2187
+ const groupsInBox = bucketGroups[box.key];
2188
+ const count = bucketCount(box.key);
2189
+ if (!count) return null;
2190
+ const accent =
2191
+ box.key === 'attention' ? cWarning
2192
+ : box.key === 'set' ? cSuccess
2193
+ : cMuted;
2194
+ // The muted "Optional" box reads as a quieter, recessed
2195
+ // surface — a touch darker than the page rather than the
2196
+ // faint grey tint the accent would give.
2197
+ const boxBg =
2198
+ box.key === 'optional' ?
2199
+ changeDarkness(getMainBackgroundColor(theme), 0.06)
2200
+ : undefined;
1960
2201
  return (
1961
- <ReqorePanel
1962
- key={groupName}
2202
+ <StyledStatusBox
2203
+ $accent={accent}
2204
+ $bg={boxBg}
2205
+ key={box.key}
1963
2206
  flat
1964
2207
  minimal
1965
2208
  collapseButtonProps={{ flat: true, minimal: true, size: 'small' }}
1966
2209
  collapsible
2210
+ // The Optional box now holds every not-yet-added field, so
2211
+ // it starts COLLAPSED to keep the form focused on what's in
2212
+ // use. But a SEARCH must surface matching addable fields —
2213
+ // and ReqorePanel unmounts collapsed content — so force it
2214
+ // open whenever a query is active. (isCollapsed is the
2215
+ // panel's controllable state; manual toggling still works
2216
+ // when no query is set.)
2217
+ isCollapsed={box.key === 'optional' && !query}
1967
2218
  label={
1968
2219
  <StyledGroupHeader>
1969
- <ReqoreP effect={{ weight: 'bold' }} size='big'>
1970
- {getOptionGroupLabel(groupName, groups)}
2220
+ <ReqoreP effect={{ weight: 'bold' }} size='normal'>
2221
+ {box.label}
1971
2222
  </ReqoreP>
1972
- <StyledGroupHeaderLine $color={cGroupLine} />
1973
- <ReqoreButton
1974
- readOnly
1975
- size='tiny'
2223
+ <ReqoreTag
2224
+ size='small'
1976
2225
  minimal
1977
- flat
1978
2226
  compact
1979
- effect={{uppercase: true, spaced: 1}}
1980
- {...(groupName === 'optional' ? { label: `${names.length} optional` }
1981
- : invalidCount ?
1982
- {
1983
- label: `${invalidCount} to resolve`,
1984
- intent: 'warning' as const,
1985
- icon: 'ErrorWarningLine' as const,
1986
- }
1987
- : {
1988
- label: 'All set',
1989
- intent: 'success' as const,
1990
- icon: 'CheckLine' as const,
1991
- })}
2227
+ intent={box.intent}
2228
+ label={String(count)}
1992
2229
  />
1993
2230
  </StyledGroupHeader>
1994
2231
  }
1995
- icon={groupConfig?.icon}
2232
+ icon={box.icon}
1996
2233
  className='options-readfirst-group'
1997
2234
  padded={false}
1998
2235
  contentStyle={{ padding: '4px 4px 6px' }}
1999
2236
  >
2000
- {groupConfig?.subtitle ?
2001
- <ReqoreP
2002
- size='small'
2003
- effect={{ opacity: 0.6 }}
2004
- // Indent to the same content line as the rows (StyledGroupBody's
2005
- // `margin-left` clamp) so the subtitle sits tucked under the group
2006
- // name instead of at the panel edge — and clears the group's
2007
- // vertical rule (left:16px) rather than crossing it.
2008
- style={{
2009
- marginTop: 2,
2010
- marginBottom: 8,
2011
- marginLeft: GROUP_INDENT,
2012
- paddingRight: 10,
2013
- }}
2014
- >
2015
- {groupConfig.subtitle}
2016
- </ReqoreP>
2017
- : null}
2237
+ {/* ONE group body per box: every field block (and the
2238
+ thin schema sub-labels) is a direct flex child, so the
2239
+ inter-field gap is identical everywhere — including
2240
+ across schema-group boundaries. */}
2018
2241
  <StyledGroupBody
2019
2242
  $divider={cDivider}
2020
2243
  $hover={cHover}
@@ -2024,9 +2247,35 @@ export const FormEngine = ({
2024
2247
  $lineColor={cGroupLine}
2025
2248
  className={compactNarrow ? 'readfirst-narrow' : undefined}
2026
2249
  >
2027
- {renderGroupRows(names)}
2250
+ {groupsInBox.map((groupName) => {
2251
+ const groupConfig = groups?.[groupName];
2252
+ return (
2253
+ <React.Fragment key={groupName}>
2254
+ {showGroupSubLabel(groupName) ?
2255
+ <StyledStatusBoxGroupLabel>
2256
+ {getOptionGroupLabel(groupName, groups)}
2257
+ </StyledStatusBoxGroupLabel>
2258
+ : null}
2259
+ {showGroupSubLabel(groupName) && groupConfig?.subtitle ?
2260
+ <ReqoreP
2261
+ size='small'
2262
+ effect={{ opacity: 0.6 }}
2263
+ style={{
2264
+ marginTop: 2,
2265
+ marginBottom: 8,
2266
+ marginLeft: GROUP_INDENT,
2267
+ paddingRight: 10,
2268
+ }}
2269
+ >
2270
+ {groupConfig.subtitle}
2271
+ </ReqoreP>
2272
+ : null}
2273
+ {renderGroupRows(buckets[box.key][groupName])}
2274
+ </React.Fragment>
2275
+ );
2276
+ })}
2028
2277
  </StyledGroupBody>
2029
- </ReqorePanel>
2278
+ </StyledStatusBox>
2030
2279
  );
2031
2280
  })}
2032
2281
  </StyledCompactPanel>