@qoretechnologies/reqraft 0.10.65 → 0.10.66-pr.145.g2d1b4c8

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 (101) hide show
  1. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  2. package/dist/components/form/engine/CompactRow.js +128 -24
  3. package/dist/components/form/engine/CompactRow.js.map +1 -1
  4. package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
  5. package/dist/components/form/engine/CompactToolbar.js +78 -15
  6. package/dist/components/form/engine/CompactToolbar.js.map +1 -1
  7. package/dist/components/form/engine/FormEngine.d.ts +48 -1
  8. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  9. package/dist/components/form/engine/FormEngine.js +243 -85
  10. package/dist/components/form/engine/FormEngine.js.map +1 -1
  11. package/dist/components/form/engine/OptionFieldMessages.d.ts +18 -2
  12. package/dist/components/form/engine/OptionFieldMessages.d.ts.map +1 -1
  13. package/dist/components/form/engine/OptionFieldMessages.js +22 -3
  14. package/dist/components/form/engine/OptionFieldMessages.js.map +1 -1
  15. package/dist/components/form/engine/_structuredData/SchemaDataView.d.ts.map +1 -1
  16. package/dist/components/form/engine/_structuredData/SchemaDataView.js +27 -1
  17. package/dist/components/form/engine/_structuredData/SchemaDataView.js.map +1 -1
  18. package/dist/components/form/engine/compactRowContext.d.ts +5 -0
  19. package/dist/components/form/engine/compactRowContext.d.ts.map +1 -1
  20. package/dist/components/form/engine/compactRowContext.js.map +1 -1
  21. package/dist/components/form/engine/compactRowStyles.d.ts +12 -0
  22. package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
  23. package/dist/components/form/engine/compactRowStyles.js +72 -39
  24. package/dist/components/form/engine/compactRowStyles.js.map +1 -1
  25. package/dist/components/form/engine/compactToolbarContext.d.ts +3 -0
  26. package/dist/components/form/engine/compactToolbarContext.d.ts.map +1 -1
  27. package/dist/components/form/engine/compactToolbarContext.js.map +1 -1
  28. package/dist/components/form/engine/formAppearance.d.ts +48 -0
  29. package/dist/components/form/engine/formAppearance.d.ts.map +1 -0
  30. package/dist/components/form/engine/formAppearance.js +50 -0
  31. package/dist/components/form/engine/formAppearance.js.map +1 -0
  32. package/dist/components/form/engine/readFirst.d.ts +14 -2
  33. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  34. package/dist/components/form/engine/readFirst.js +6 -2
  35. package/dist/components/form/engine/readFirst.js.map +1 -1
  36. package/dist/components/form/engine/rowFocus.d.ts +9 -1
  37. package/dist/components/form/engine/rowFocus.d.ts.map +1 -1
  38. package/dist/components/form/engine/rowFocus.js +17 -5
  39. package/dist/components/form/engine/rowFocus.js.map +1 -1
  40. package/dist/components/form/fields/ChoiceGroupHeading.d.ts +21 -0
  41. package/dist/components/form/fields/ChoiceGroupHeading.d.ts.map +1 -0
  42. package/dist/components/form/fields/ChoiceGroupHeading.js +33 -0
  43. package/dist/components/form/fields/ChoiceGroupHeading.js.map +1 -0
  44. package/dist/components/form/fields/Field.d.ts.map +1 -1
  45. package/dist/components/form/fields/Field.js +5 -3
  46. package/dist/components/form/fields/Field.js.map +1 -1
  47. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts.map +1 -1
  48. package/dist/components/form/fields/allowed-values/AllowedValues.js +4 -2
  49. package/dist/components/form/fields/allowed-values/AllowedValues.js.map +1 -1
  50. package/dist/components/form/fields/radio-group/RadioGroup.d.ts.map +1 -1
  51. package/dist/components/form/fields/radio-group/RadioGroup.js +13 -7
  52. package/dist/components/form/fields/radio-group/RadioGroup.js.map +1 -1
  53. package/dist/components/form/index.d.ts +1 -0
  54. package/dist/components/form/index.d.ts.map +1 -1
  55. package/dist/components/form/index.js +1 -0
  56. package/dist/components/form/index.js.map +1 -1
  57. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts +3 -2
  58. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts.map +1 -1
  59. package/dist/components/supportTicket/InterfaceReferenceTags.js.map +1 -1
  60. package/dist/components/supportTicket/ReferencePicker.d.ts +2 -1
  61. package/dist/components/supportTicket/ReferencePicker.d.ts.map +1 -1
  62. package/dist/components/supportTicket/ReferencePicker.js +19 -13
  63. package/dist/components/supportTicket/ReferencePicker.js.map +1 -1
  64. package/dist/components/supportTicket/TicketHeader.d.ts.map +1 -1
  65. package/dist/components/supportTicket/TicketHeader.js +4 -1
  66. package/dist/components/supportTicket/TicketHeader.js.map +1 -1
  67. package/dist/components/supportTicket/TicketReplyBox.d.ts.map +1 -1
  68. package/dist/components/supportTicket/TicketReplyBox.js +29 -10
  69. package/dist/components/supportTicket/TicketReplyBox.js.map +1 -1
  70. package/dist/components/ticketThread/TicketThread.d.ts.map +1 -1
  71. package/dist/components/ticketThread/TicketThread.js +8 -16
  72. package/dist/components/ticketThread/TicketThread.js.map +1 -1
  73. package/package.json +1 -1
  74. package/src/components/form/engine/CompactRow.tsx +141 -22
  75. package/src/components/form/engine/CompactToolbar.tsx +85 -16
  76. package/src/components/form/engine/FormEngine.stories.tsx +25 -13
  77. package/src/components/form/engine/FormEngine.tsx +358 -77
  78. package/src/components/form/engine/FormEngineAppearance.stories.tsx +540 -0
  79. package/src/components/form/engine/FormEngineStickyHeaders.stories.tsx +5 -1
  80. package/src/components/form/engine/OptionFieldMessages.tsx +73 -2
  81. package/src/components/form/engine/_structuredData/SchemaDataView.tsx +17 -4
  82. package/src/components/form/engine/compactRowContext.ts +5 -0
  83. package/src/components/form/engine/compactRowStyles.ts +218 -38
  84. package/src/components/form/engine/compactToolbarContext.ts +3 -0
  85. package/src/components/form/engine/formAppearance.ts +78 -0
  86. package/src/components/form/engine/readFirst.ts +16 -3
  87. package/src/components/form/engine/rowFocus.ts +17 -6
  88. package/src/components/form/fields/ChoiceGroupHeading.tsx +38 -0
  89. package/src/components/form/fields/Field.tsx +6 -5
  90. package/src/components/form/fields/allowed-values/AllowedValues.tsx +7 -4
  91. package/src/components/form/fields/radio-group/RadioGroup.tsx +13 -7
  92. package/src/components/form/index.tsx +1 -0
  93. package/src/components/supportTicket/InterfaceReferenceTags.tsx +3 -2
  94. package/src/components/supportTicket/ReferencePicker.stories.tsx +0 -4
  95. package/src/components/supportTicket/ReferencePicker.tsx +22 -12
  96. package/src/components/supportTicket/TicketConversation.stories.tsx +0 -1
  97. package/src/components/supportTicket/TicketHeader.tsx +2 -1
  98. package/src/components/supportTicket/TicketReplyBox.stories.tsx +24 -21
  99. package/src/components/supportTicket/TicketReplyBox.tsx +27 -5
  100. package/src/components/ticketThread/TicketThread.stories.tsx +31 -2
  101. package/src/components/ticketThread/TicketThread.tsx +13 -28
@@ -6,6 +6,7 @@ import {
6
6
  ReqoreIcon,
7
7
  ReqoreMessage,
8
8
  ReqoreP,
9
+ ReqoreSpan,
9
10
  ReqoreTag,
10
11
  ReqoreTagGroup,
11
12
  ReqoreVerticalSpacer,
@@ -43,6 +44,7 @@ import { offersTypeChoices } from './typeChoices';
43
44
  import { optionRowActions, resolveOptionActions, TOptionActions } from './optionActions';
44
45
  import { createRendererOnlyUiTypeCheck, isRendererOnlyUiType } from './rendererTypes';
45
46
  import { revealRow } from './revealRow';
47
+ import { findRowFocusTarget } from './rowFocus';
46
48
  import { useEmittedValues } from '../fields/emittedValues';
47
49
  import { cloneDeep, findKey, flatten, forEach, isEqual, isPlainObject, last, uniq } from 'lodash';
48
50
  import map from 'lodash/map';
@@ -84,6 +86,7 @@ import { CompactRow } from './CompactRow';
84
86
  import { FormFieldsSkeleton } from './FormFieldsSkeleton';
85
87
  import { CompactRowContext, ICompactRowContext, TCodePreviewRenderer } from './compactRowContext';
86
88
  import {
89
+ CALM_SECTION_INSET,
87
90
  GROUP_INDENT,
88
91
  LABEL_AFFORDANCE_WIDTH,
89
92
  LABEL_COL_MAX,
@@ -96,6 +99,7 @@ import {
96
99
  StyledRequiredClusterHeader,
97
100
  StyledStatusBox,
98
101
  StyledStatusBoxGroupLabel,
102
+ StyledStatusDot,
99
103
  } from './compactRowStyles';
100
104
  import { CompactToolbar } from './CompactToolbar';
101
105
  import {
@@ -114,6 +118,14 @@ import {
114
118
  TMarkdownRenderer,
115
119
  } from '../../Description/markdownRendererContext';
116
120
  import { OptionsHelpDialog } from './OptionsHelpDialog';
121
+ import {
122
+ FormAppearanceProvider,
123
+ getCalmPanelHairline,
124
+ getCalmPanelSurface,
125
+ TFormEngineAppearance,
126
+ useFormAppearance,
127
+ useIsCalmForm,
128
+ } from './formAppearance';
117
129
  import { firstDeclaredType, isUntypedOptionType } from '../../../helpers/optionUiTypes';
118
130
  import {
119
131
  TReadFirstStatus,
@@ -197,6 +209,14 @@ export interface IFormValidityData {
197
209
  invalidFields: IFormFieldValidityData[];
198
210
  }
199
211
 
212
+ /** The two numbers the readiness line shows ("2 need attention · 1 to fill in"). */
213
+ export interface IFormAttention {
214
+ /** Fields in the first box: still to fill in, and faults. */
215
+ count: number;
216
+ /** How many of those are faults (amber) rather than only still to fill in. */
217
+ faults: number;
218
+ }
219
+
200
220
  /**
201
221
  * Resolve a field's `inherit_props` map against the current available
202
222
  * options (plus any values inherited from an outer FormEngine scope),
@@ -848,6 +868,21 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
848
868
  * value; clicking expands the real editor inline, Done collapses it.
849
869
  */
850
870
  compact?: boolean;
871
+ /**
872
+ * How the form LOOKS — never what it does. `'default'` keeps the established
873
+ * look; `'calm'` draws the status boxes as plain panels on the theme's panel
874
+ * surface with label/value rows split by hairlines, grey labels, an amber
875
+ * "Required · add a value" in place of red asterisks and messages, quiet
876
+ * counts, no dot on a healthy row, Yes/No as words, and a readiness meter
877
+ * whose done part is grey. See {@link TFormEngineAppearance}.
878
+ *
879
+ * Opt-in, so a consumer that does not pass it is unchanged. Inherited: a
880
+ * nested (`arg_schema`) sub-form and every field editor the form mounts
881
+ * follow the form's appearance, and a form with no `appearance` of its own
882
+ * follows a `FormAppearanceProvider` above it. Applies to compact mode; the
883
+ * classic collection layout only passes it on to its field editors.
884
+ */
885
+ appearance?: TFormEngineAppearance;
851
886
  /**
852
887
  * Compact mode only: drop the read-first wrap's horizontal gutter (the 12px
853
888
  * breathing room) so the form sits flush to its container's edges. For embeds
@@ -928,8 +963,32 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
928
963
  * `Optional` names a category rather than the things in it, and
929
964
  * `Optional settings` is what the reader is looking at. Only the labels
930
965
  * change; bucketing, icons and intents are untouched.
966
+ *
967
+ * `pending` names the first box while nothing in it is a fault yet — a new form's
968
+ * empty required fields, which are still to fill in (default `To fill in`).
969
+ */
970
+ compactBoxLabels?: Partial<Record<TFormEngineBoxKey | 'pending', string>>;
971
+ /**
972
+ * The host attempted a submit. Until then an empty required field the person has not
973
+ * been to yet is only "to fill in": a new form opens neutral rather than with every
974
+ * required field in amber, and a field turns amber once someone opens it and leaves
975
+ * it empty. Pass `true` after a failed submit to show every empty required field as
976
+ * needing attention. Invalid values are faults from the start either way.
977
+ */
978
+ revealRequired?: boolean;
979
+ /**
980
+ * Bump it to move the caret to the first field that needs attention: the answer to a
981
+ * submit that could not go is where to type. Each new value opens that field and puts
982
+ * the caret in it once, even though the host's submit button holds the caret (an open
983
+ * row otherwise never takes a caret held outside it). Pair it with `revealRequired`.
984
+ */
985
+ focusAttentionRequest?: number;
986
+ /**
987
+ * The readiness line's two numbers, reported whenever they change. A host that draws
988
+ * its own submit control draws it from these, so the control and the form can never
989
+ * disagree about what is left. `{ count: 0, faults: 0 }` is a form with nothing left.
931
990
  */
932
- compactBoxLabels?: Partial<Record<TFormEngineBoxKey, string>>;
991
+ onAttentionChange?: (attention: IFormAttention) => void;
933
992
  /**
934
993
  * Compact mode only: the three status-box headers stay pinned to the top of
935
994
  * whatever scrolls while their own box is in view. Default `true`.
@@ -1235,6 +1294,9 @@ const FormEngineImpl = ({
1235
1294
  forceDropdown,
1236
1295
  showTypeToggle = true,
1237
1296
  compact,
1297
+ // Read through the context `FormEngine` provides (resolved there against an
1298
+ // inherited appearance); destructured only to keep it out of `rest`.
1299
+ appearance: _appearance, // eslint-disable-line @typescript-eslint/no-unused-vars
1238
1300
  // Destructured only to keep it out of `rest` (and so off the DOM). The gutter
1239
1301
  // this prop documents was never implemented: it was passed to the wrap as
1240
1302
  // `$flush`, whose styled template had no interpolation slot, so it has always
@@ -1247,6 +1309,9 @@ const FormEngineImpl = ({
1247
1309
  compactPanelProps,
1248
1310
  compactCollapsedGroups: compactCollapsedGroupsProp,
1249
1311
  compactBoxLabels,
1312
+ revealRequired = false,
1313
+ focusAttentionRequest,
1314
+ onAttentionChange,
1250
1315
  compactStickyBoxHeaders = true,
1251
1316
  compactStickyOffset = 0,
1252
1317
  compactToolbar = true,
@@ -1318,6 +1383,7 @@ const FormEngineImpl = ({
1318
1383
  const [loading, setLoading] = useState<boolean>(!!(url || customUrl || operatorsUrl));
1319
1384
  const confirmAction = useReqoreProperty('confirmAction');
1320
1385
  const theme = useReqoreTheme();
1386
+ const calm = useIsCalmForm();
1321
1387
  const [focusedEditing, setFocusedEditing] = useState<string>();
1322
1388
  const [showFieldTypes, setShowFieldTypes] = useState<boolean>(false);
1323
1389
  // Global toggle (toolbar ⓘ): reveal the short-description info panel on every
@@ -1330,6 +1396,21 @@ const FormEngineImpl = ({
1330
1396
  const [showInvalidOptionsOnly, setShowInvalidOptionsOnly] = useState<boolean>(false);
1331
1397
  // Which options are expanded into their editor (several can be open at once).
1332
1398
  const [expandedOptions, setExpandedOptions] = useState<string[]>([]);
1399
+ // The fields the person has opened and left. An empty required field becomes a
1400
+ // fault only then (or after a submit attempt, `revealRequired`): a form nobody has
1401
+ // touched is not already wrong.
1402
+ const [visitedOptions, setVisitedOptions] = useState<string[]>([]);
1403
+ const lastExpandedOptions = useRef<string[]>([]);
1404
+ useEffect(() => {
1405
+ const left = lastExpandedOptions.current.filter((name) => !expandedOptions.includes(name));
1406
+ lastExpandedOptions.current = expandedOptions;
1407
+ if (left.length) {
1408
+ setVisitedOptions((prev) => {
1409
+ const added = left.filter((name) => !prev.includes(name));
1410
+ return added.length ? [...prev, ...added] : prev;
1411
+ });
1412
+ }
1413
+ }, [expandedOptions]);
1333
1414
  /** Status boxes whose "N more fields" control the reader has opened. Keyed by
1334
1415
  box so opening Optional does not also unfold Set. */
1335
1416
  const [expandedMoreBoxes, setExpandedMoreBoxes] = useState<TFormEngineBoxKey[]>([]);
@@ -2458,6 +2539,11 @@ const FormEngineImpl = ({
2458
2539
  (newValue, option, optionName) => {
2459
2540
  if (
2460
2541
  showInvalidOptionsOnly &&
2542
+ // An open row stays while the reader is in it, as its box does
2543
+ // (`stableBucketOf`): the first character typed into an empty required
2544
+ // field makes it valid, and hiding it then would pull the field out
2545
+ // from under the caret mid-word.
2546
+ !expandedOptions.includes(optionName) &&
2461
2547
  isOptionValid(
2462
2548
  optionName,
2463
2549
  getOptionFieldStorageType(
@@ -2480,6 +2566,7 @@ const FormEngineImpl = ({
2480
2566
  );
2481
2567
  }, [
2482
2568
  showInvalidOptionsOnly,
2569
+ expandedOptions,
2483
2570
  isRendererOnly,
2484
2571
  JSON.stringify(availableOptions),
2485
2572
  JSON.stringify(options),
@@ -2548,9 +2635,12 @@ const FormEngineImpl = ({
2548
2635
  covered,
2549
2636
  invalid,
2550
2637
  warned: msgIntent === 'warning',
2638
+ visited: revealRequired || visitedOptions.includes(name),
2551
2639
  });
2552
2640
  },
2553
2641
  [
2642
+ revealRequired,
2643
+ visitedOptions,
2554
2644
  isRendererOnly,
2555
2645
  JSON.stringify(options),
2556
2646
  JSON.stringify(availableOptions),
@@ -2583,6 +2673,24 @@ const FormEngineImpl = ({
2583
2673
  .length,
2584
2674
  [JSON.stringify(availableOptions), getOptionBucket]
2585
2675
  );
2676
+ // How many of those are faults rather than only still to fill in (`pending`). Zero
2677
+ // on a new form, so its first box reads "To fill in" without amber.
2678
+ const readFirstAttentionFaultCount = useMemo(
2679
+ () =>
2680
+ Object.keys(availableOptions || {}).filter(
2681
+ (name) => getOptionBucket(name) === 'attention' && getOptionStatus(name) !== 'pending'
2682
+ ).length,
2683
+ [JSON.stringify(availableOptions), getOptionBucket, getOptionStatus]
2684
+ );
2685
+ // The same two numbers, to a host that draws its own submit control from them.
2686
+ const onAttentionChangeRef = useRef(onAttentionChange);
2687
+ onAttentionChangeRef.current = onAttentionChange;
2688
+ useEffect(() => {
2689
+ onAttentionChangeRef.current?.({
2690
+ count: readFirstAttentionCount,
2691
+ faults: readFirstAttentionFaultCount,
2692
+ });
2693
+ }, [readFirstAttentionCount, readFirstAttentionFaultCount]);
2586
2694
 
2587
2695
  const getIntent = useCallback(
2588
2696
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -2708,6 +2816,39 @@ const FormEngineImpl = ({
2708
2816
  // field update or an in-place schema reload will NOT re-grab focus; only a
2709
2817
  // genuine remount (a fresh instance) auto-focuses again, which is the intended
2710
2818
  // on-mount behaviour.
2819
+ // What the "first field to fix" scans read: the opener on arrival below and the
2820
+ // caret move on a submit attempt (`focusAttentionRequest`) pick the same field.
2821
+ const getAttentionFieldMeta = useCallback(
2822
+ (fieldName: string) => {
2823
+ const schema = options?.[fieldName];
2824
+ if (!schema) {
2825
+ return undefined;
2826
+ }
2827
+ return {
2828
+ focusable:
2829
+ !readOnly &&
2830
+ !schema.disabled &&
2831
+ !(schema as { readonly?: boolean }).readonly &&
2832
+ !dependencyLockedNames.includes(fieldName),
2833
+ // Single source of truth: the same bucket the read-first status boxes
2834
+ // show, so a filled-but-invalid required row is a target too.
2835
+ needsAttention: getOptionBucket(fieldName) === 'attention',
2836
+ };
2837
+ },
2838
+ [options, readOnly, dependencyLockedNames, getOptionBucket]
2839
+ );
2840
+ // Opening a row by name: `expandMode: 'single'` replaces only what a click could
2841
+ // (see the note in the opener below), never a row the host named.
2842
+ const openOption = useCallback(
2843
+ (target: string) =>
2844
+ setExpandedOptions((prev) =>
2845
+ prev.includes(target) ? prev
2846
+ : expandMode === 'multi' ? [...prev, target]
2847
+ : [...prev.filter((name) => initialExpandedOptions?.includes(name)), target]
2848
+ ),
2849
+ [expandMode, initialExpandedOptions]
2850
+ );
2851
+
2711
2852
  const hasAutoFocusedRef = useRef(false);
2712
2853
  useEffect(() => {
2713
2854
  // Two decisions, one scan. Both open the row that needs attention;
@@ -2746,22 +2887,7 @@ const FormEngineImpl = ({
2746
2887
  return;
2747
2888
  }
2748
2889
 
2749
- const target = getFirstAttentionOptionName(orderedNames, (fieldName) => {
2750
- const schema = options[fieldName];
2751
- if (!schema) {
2752
- return undefined;
2753
- }
2754
- return {
2755
- focusable:
2756
- !readOnly &&
2757
- !schema.disabled &&
2758
- !(schema as { readonly?: boolean }).readonly &&
2759
- !dependencyLockedNames.includes(fieldName),
2760
- // Single source of truth: the same bucket the read-first status boxes
2761
- // show, so a filled-but-invalid required row is a target too.
2762
- needsAttention: getOptionBucket(fieldName) === 'attention',
2763
- };
2764
- });
2890
+ const target = getFirstAttentionOptionName(orderedNames, getAttentionFieldMeta);
2765
2891
 
2766
2892
  // Mark done even when nothing needs attention, so later value edits or an
2767
2893
  // in-place schema reload never re-scan or re-focus. Only a remount arms it
@@ -2769,17 +2895,13 @@ const FormEngineImpl = ({
2769
2895
  hasAutoFocusedRef.current = true;
2770
2896
 
2771
2897
  if (target) {
2772
- setExpandedOptions((prev) =>
2773
- prev.includes(target) ? prev
2774
- : expandMode === 'multi' ? [...prev, target]
2775
- // `expandMode: 'single'` is a rule about what a CLICK does: opening a
2776
- // row closes the one the reader last opened. It is not a licence to
2777
- // close a row the HOST named through `initialExpandedOptions`. That
2778
- // is an address — a statement about what the page is FOR, made before
2779
- // the reader did anything — and this scan is a convenience layered on
2780
- // top of it. Keep the named rows; replace only what a click could.
2781
- : [...prev.filter((name) => initialExpandedOptions?.includes(name)), target]
2782
- );
2898
+ // `expandMode: 'single'` is a rule about what a CLICK does: opening a row
2899
+ // closes the one the reader last opened. It is not a licence to close a row
2900
+ // the HOST named through `initialExpandedOptions`. That is an address — a
2901
+ // statement about what the page is FOR, made before the reader did anything
2902
+ // — and this scan is a convenience layered on top of it. `openOption` keeps
2903
+ // the named rows and replaces only what a click could.
2904
+ openOption(target);
2783
2905
  }
2784
2906
  }, [
2785
2907
  autoFocusFirstRequired,
@@ -2787,13 +2909,52 @@ const FormEngineImpl = ({
2787
2909
  compact,
2788
2910
  options,
2789
2911
  availableOptions,
2790
- readOnly,
2791
- dependencyLockedNames,
2792
- getOptionBucket,
2793
- expandMode,
2794
- initialExpandedOptions,
2912
+ getAttentionFieldMeta,
2913
+ openOption,
2795
2914
  ]);
2796
2915
 
2916
+ // A submit that could not go is answered with where to type: each new
2917
+ // `focusAttentionRequest` opens the first field that needs attention and puts the
2918
+ // caret in it. The host's submit button holds the caret at that moment, and an open
2919
+ // row never takes a caret held outside it (CompactRow), so the button lets go of it
2920
+ // here first; the person asked to submit, and this is the answer to that.
2921
+ const lastFocusRequest = useRef(focusAttentionRequest);
2922
+ useEffect(() => {
2923
+ if (focusAttentionRequest === lastFocusRequest.current) {
2924
+ return;
2925
+ }
2926
+ lastFocusRequest.current = focusAttentionRequest;
2927
+ if (!focusAttentionRequest || !compact || !options) {
2928
+ return;
2929
+ }
2930
+ const target = getFirstAttentionOptionName(
2931
+ Object.keys(availableOptions),
2932
+ getAttentionFieldMeta
2933
+ );
2934
+ if (!target) {
2935
+ return;
2936
+ }
2937
+ const wrap = compactWrapNodeRef.current;
2938
+ const active = document.activeElement as HTMLElement | null;
2939
+ if (active && active !== document.body && !wrap?.contains(active)) {
2940
+ active.blur();
2941
+ }
2942
+ if (!expandedOptions.includes(target)) {
2943
+ // The row's own open effect reveals it and takes the caret.
2944
+ openOption(target);
2945
+ return;
2946
+ }
2947
+ // Already open (the field the form opened on arrival): its open effect has run,
2948
+ // so the caret goes to its control here.
2949
+ const row = wrap?.querySelector<HTMLElement>(`[data-field="${target}"]`);
2950
+ if (row) {
2951
+ revealRow(row, 'auto');
2952
+ findRowFocusTarget(row.querySelector<HTMLElement>('.options-readfirst-row-editor'), {
2953
+ textEntryOnly: row.classList.contains('options-readfirst-card'),
2954
+ })?.focus({ preventScroll: true });
2955
+ }
2956
+ }, [focusAttentionRequest]);
2957
+
2797
2958
  // Read-first completion summary (how many shown options have a value set),
2798
2959
  // surfaced as a progress meter at the top of the compact form.
2799
2960
  const readFirstCompletion = useMemo(
@@ -3191,6 +3352,9 @@ const FormEngineImpl = ({
3191
3352
  option={{ type: resolvedType, ...other }}
3192
3353
  getType={getTypeForOption}
3193
3354
  isRendererOnly={isRendererOnly}
3355
+ // Open is not left: a field the form opened for typing (the first empty
3356
+ // one, on a new form) says what it needs without already being wrong.
3357
+ requirementPending={!(revealRequired || visitedOptions.includes(optionName))}
3194
3358
  />
3195
3359
  {operators && size(operators) && size(other.op) ?
3196
3360
  <>
@@ -3242,6 +3406,8 @@ const FormEngineImpl = ({
3242
3406
  getTypeForOption,
3243
3407
  inheritedScopes,
3244
3408
  inheritedFromParent,
3409
+ revealRequired,
3410
+ visitedOptions,
3245
3411
  ]
3246
3412
  );
3247
3413
 
@@ -3266,6 +3432,10 @@ const FormEngineImpl = ({
3266
3432
  // The "N more fields" row's dashed edge. Well above the divider tint: this
3267
3433
  // one is an affordance the reader has to notice, not a hairline between rows.
3268
3434
  const cMoreBorder = `${cText}4d`;
3435
+ // Calm: the section surface and its hairline, from the theme's panel (the
3436
+ // same surface and edge Reqore gives the section panels themselves).
3437
+ const cPanel = getCalmPanelSurface(theme);
3438
+ const cHairline = getCalmPanelHairline(theme);
3269
3439
 
3270
3440
  // The closure surface the extracted CompactRow reads through context. Refs and
3271
3441
  // setters are stable; the state/memo/handler fields change identity as they do
@@ -3284,6 +3454,8 @@ const FormEngineImpl = ({
3284
3454
  codePreviewRenderer,
3285
3455
  showAllDescriptions,
3286
3456
  expandedOptions,
3457
+ visitedOptions,
3458
+ revealRequired,
3287
3459
  highlightedOptions,
3288
3460
  flashedOptions,
3289
3461
  infoPanelOverrides,
@@ -3332,6 +3504,8 @@ const FormEngineImpl = ({
3332
3504
  codePreviewRenderer,
3333
3505
  showAllDescriptions,
3334
3506
  expandedOptions,
3507
+ visitedOptions,
3508
+ revealRequired,
3335
3509
  highlightedOptions,
3336
3510
  flashedOptions,
3337
3511
  infoPanelOverrides,
@@ -3383,6 +3557,7 @@ const FormEngineImpl = ({
3383
3557
  readOnly,
3384
3558
  invalidCount: size(validityData.invalidFields),
3385
3559
  attentionCount: readFirstAttentionCount,
3560
+ attentionFaultCount: readFirstAttentionFaultCount,
3386
3561
  completion: readFirstCompletion,
3387
3562
  showInvalidOnly: showInvalidOptionsOnly,
3388
3563
  onToggleInvalidOnly: handleToggleInvalidOnly,
@@ -3430,6 +3605,7 @@ const FormEngineImpl = ({
3430
3605
  onReadOnlyActivate,
3431
3606
  validityData,
3432
3607
  readFirstAttentionCount,
3608
+ readFirstAttentionFaultCount,
3433
3609
  readFirstCompletion,
3434
3610
  showInvalidOptionsOnly,
3435
3611
  handleToggleInvalidOnly,
@@ -3711,12 +3887,20 @@ const FormEngineImpl = ({
3711
3887
  intent?: 'warning' | 'success';
3712
3888
  icon: IReqoreIconName;
3713
3889
  }> = [
3714
- {
3715
- key: 'attention',
3716
- label: compactBoxLabels?.attention ?? 'Needs attention',
3717
- intent: 'warning',
3718
- icon: 'ErrorWarningLine',
3719
- },
3890
+ // The first box is amber only once something in it is a fault. On a new form it
3891
+ // holds the required fields still to fill in, which is not yet anything wrong.
3892
+ readFirstAttentionFaultCount ?
3893
+ {
3894
+ key: 'attention',
3895
+ label: compactBoxLabels?.attention ?? 'Needs attention',
3896
+ intent: 'warning',
3897
+ icon: 'ErrorWarningLine',
3898
+ }
3899
+ : {
3900
+ key: 'attention',
3901
+ label: compactBoxLabels?.pending ?? 'To fill in',
3902
+ icon: 'CheckboxBlankCircleLine',
3903
+ },
3720
3904
  { key: 'set', label: compactBoxLabels?.set ?? 'Set', intent: 'success', icon: 'CheckLine' },
3721
3905
  {
3722
3906
  key: 'optional',
@@ -3785,15 +3969,28 @@ const FormEngineImpl = ({
3785
3969
  // box (the Focus cluster). A met group needs no banner — the rail + the
3786
3970
  // "Covers"/"Covered by" chips already say which member satisfies it.
3787
3971
  if (requiredGroupsInfo.satisfiedBy[grp]) return railed;
3972
+ // Amber once someone has been to the group (or a submit was attempted); until
3973
+ // then it is still to fill in, in the quiet colour, like its members.
3974
+ const clusterColor =
3975
+ memberEntries.some((e) => getOptionStatus(e.name) !== 'pending') ? cWarning : cMuted;
3788
3976
  return (
3789
3977
  <StyledRequiredClusterBox
3790
3978
  key={grp}
3791
3979
  className='options-readfirst-required-cluster'
3792
- $border={`${cWarning}33`}
3793
- $tint={`${cWarning}0d`}
3980
+ $border={`${clusterColor}33`}
3981
+ $tint={`${clusterColor}0d`}
3982
+ $calm={calm}
3794
3983
  >
3795
- <StyledRequiredClusterHeader $color={cWarning}>
3796
- <ReqoreIcon icon='LinkM' size='11px' style={{ color: cWarning }} />
3984
+ <StyledRequiredClusterHeader
3985
+ className='options-readfirst-required-cluster-header'
3986
+ $color={clusterColor}
3987
+ $calm={calm}
3988
+ >
3989
+ <ReqoreIcon
3990
+ icon='LinkM'
3991
+ size={calm ? '13px' : '11px'}
3992
+ style={{ color: clusterColor }}
3993
+ />
3797
3994
  One of the below is required
3798
3995
  </StyledRequiredClusterHeader>
3799
3996
  {railed}
@@ -3821,20 +4018,31 @@ const FormEngineImpl = ({
3821
4018
  return (
3822
4019
  <React.Fragment key={groupName}>
3823
4020
  {showGroupSubLabel(groupName) ?
3824
- <StyledStatusBoxGroupLabel>
4021
+ <StyledStatusBoxGroupLabel
4022
+ className='options-readfirst-group-label'
4023
+ $calm={calm}
4024
+ $color={cKey}
4025
+ >
3825
4026
  {getOptionGroupLabel(groupName, groups)}
3826
4027
  </StyledStatusBoxGroupLabel>
3827
4028
  : null}
3828
4029
  {showGroupSubLabel(groupName) && groupConfig?.subtitle ?
3829
4030
  <ReqoreP
4031
+ className='options-readfirst-group-subtitle'
3830
4032
  size='small'
3831
4033
  effect={{ opacity: 0.6 }}
3832
- style={{
3833
- marginTop: 2,
3834
- marginBottom: 8,
3835
- marginLeft: GROUP_INDENT,
3836
- paddingRight: 10,
3837
- }}
4034
+ style={
4035
+ calm ?
4036
+ // Under the calm heading, at the rows' inset; the space below
4037
+ // it is the next row's own padding.
4038
+ { margin: 0, padding: `2px ${CALM_SECTION_INSET}px 4px` }
4039
+ : {
4040
+ marginTop: 2,
4041
+ marginBottom: 8,
4042
+ marginLeft: GROUP_INDENT,
4043
+ paddingRight: 10,
4044
+ }
4045
+ }
3838
4046
  >
3839
4047
  {groupConfig.subtitle}
3840
4048
  </ReqoreP>
@@ -3888,8 +4096,15 @@ const FormEngineImpl = ({
3888
4096
  )
3889
4097
  }
3890
4098
  flat
3891
- raised
4099
+ // Calm: no surface and no lift at all. A theme that gives
4100
+ // panels a surface of their own (Reqore's `theme.panel`) would
4101
+ // otherwise paint this wrapper as a card around the section
4102
+ // cards inside it. Transparent and un-raised, Reqore also drops
4103
+ // the wrapper's horizontal padding, so the readiness line, the
4104
+ // search and the sections share one left edge with the host.
4105
+ raised={!calm}
3892
4106
  minimal
4107
+ transparent={calm || undefined}
3893
4108
  // No panel background: the form sits transparently on whatever
3894
4109
  // hosts it (page, drawer, or — for an arg_schema field — the
3895
4110
  // parent's edit card) instead of stacking its own dark surface.
@@ -3967,8 +4182,13 @@ const FormEngineImpl = ({
3967
4182
  the reader's record withheld, and a page scrolls. */}
3968
4183
  {readOnly ?
3969
4184
  bucketCount('set') ?
3970
- <div className='options-readfirst-read' style={{ padding: '4px 4px 6px' }}>
4185
+ <div
4186
+ className='options-readfirst-read'
4187
+ style={{ padding: calm ? 0 : '4px 4px 6px' }}
4188
+ >
3971
4189
  <StyledGroupBody
4190
+ $calm={calm}
4191
+ $hairline={cHairline}
3972
4192
  $divider={cDivider}
3973
4193
  $hover={cHover}
3974
4194
  $focus={cWarning}
@@ -3976,7 +4196,7 @@ const FormEngineImpl = ({
3976
4196
  $rowBg={cRowBg}
3977
4197
  $lineColor={cGroupLine}
3978
4198
  $moreBorder={cMoreBorder}
3979
- className={compactNarrow ? 'readfirst-narrow' : undefined}
4199
+ className={`readfirst-read-list${compactNarrow ? ' readfirst-narrow' : ''}`}
3980
4200
  >
3981
4201
  {bucketGroups.set.map((groupName) => renderBoxGroup('set', groupName))}
3982
4202
  </StyledGroupBody>
@@ -4004,6 +4224,17 @@ const FormEngineImpl = ({
4004
4224
  const orderedRows: TRowEntry[] = groupsInBox.flatMap(
4005
4225
  (groupName) => buckets[box.key][groupName] || []
4006
4226
  );
4227
+ // The number in the box's header counts the rows whose value
4228
+ // belongs in it NOW. A field being edited keeps its box until it
4229
+ // is left (so it does not jump away under the caret), and it used
4230
+ // to keep its count too: a box read "To fill in 1" while its last
4231
+ // field already had a value and the host's submit read ready.
4232
+ const headerCount =
4233
+ readOnly ? count : (
4234
+ orderedRows.filter(
4235
+ (entry) => getOptionBucket(entry.name, entry.hidden) === box.key
4236
+ ).length
4237
+ );
4007
4238
  // Optional's own rule first: a box opened solely to reveal
4008
4239
  // preselected rows offers those, not an arbitrary first-N.
4009
4240
  const candidateRows =
@@ -4036,24 +4267,32 @@ const FormEngineImpl = ({
4036
4267
  );
4037
4268
  const moreOpen = Boolean(query) || expandedMoreBoxes.includes(box.key);
4038
4269
  const accent =
4039
- box.key === 'attention' ? cWarning
4270
+ box.key === 'attention' && readFirstAttentionFaultCount ? cWarning
4040
4271
  : box.key === 'set' ? cSuccess
4041
4272
  : cMuted;
4042
4273
  // The muted "Optional" box reads as a quieter, recessed
4043
4274
  // surface — a touch darker than the page rather than the
4044
4275
  // faint grey tint the accent would give.
4276
+ // Calm: every box is the same panel — no recessed one.
4045
4277
  const boxBg =
4046
- box.key === 'optional' ?
4278
+ box.key === 'optional' && !calm ?
4047
4279
  changeDarkness(getMainBackgroundColor(theme), 0.06)
4048
4280
  : undefined;
4049
4281
  return (
4050
4282
  <StyledStatusBox
4051
4283
  $accent={accent}
4052
4284
  $bg={boxBg}
4285
+ $calm={calm}
4053
4286
  // Only a box that PINS needs an opaque header (see
4054
4287
  // StyledStatusBox); passing it unconditionally would
4055
- // repaint every form's boxes for nothing.
4056
- $surface={stickyBoxHeaders ? getMainBackgroundColor(theme) : undefined}
4288
+ // repaint every form's boxes for nothing. A calm box
4289
+ // pins on its own panel surface.
4290
+ $surface={
4291
+ stickyBoxHeaders ?
4292
+ calm ? cPanel
4293
+ : getMainBackgroundColor(theme)
4294
+ : undefined
4295
+ }
4057
4296
  key={box.key}
4058
4297
  ref={(node: HTMLDivElement | null) => {
4059
4298
  statusBoxRefs.current[box.key] = node;
@@ -4063,7 +4302,11 @@ const FormEngineImpl = ({
4063
4302
  onCollapseChange={(collapsed) =>
4064
4303
  setOpenedStatusBox(collapsed ? undefined : box.key)
4065
4304
  }
4066
- flat
4305
+ // Calm: an ordinary panel — Reqore paints the theme's
4306
+ // panel surface and its hairline edge. The header stays
4307
+ // minimal (no surface or rule of its own); the first
4308
+ // row's hairline is the line under it.
4309
+ flat={!calm}
4067
4310
  minimal
4068
4311
  /* The header stays on screen while its own box is
4069
4312
  being read — see `compactStickyBoxHeaders`. Sticky
@@ -4101,29 +4344,57 @@ const FormEngineImpl = ({
4101
4344
  // author.
4102
4345
  isCollapsed={wouldCollapse && !hasPreselectedRows}
4103
4346
  label={
4104
- <StyledGroupHeader>
4105
- <ReqoreP effect={{ weight: 'bold' }} size='normal'>
4106
- {box.label}
4107
- </ReqoreP>
4108
- <ReqoreTag
4109
- size='small'
4110
- minimal
4111
- compact
4112
- intent={box.intent}
4113
- label={String(count)}
4114
- />
4115
- </StyledGroupHeader>
4347
+ calm ?
4348
+ // One status dot and a word — and only where a
4349
+ // person is needed. The count is a quiet number.
4350
+ <StyledGroupHeader $calm>
4351
+ {box.key === 'attention' && readFirstAttentionFaultCount ?
4352
+ <StyledStatusDot
4353
+ className='options-readfirst-group-dot'
4354
+ $color={cWarning}
4355
+ aria-hidden
4356
+ />
4357
+ : null}
4358
+ <ReqoreP effect={{ weight: 'bold' }} size='normal'>
4359
+ {box.label}
4360
+ </ReqoreP>
4361
+ {headerCount ?
4362
+ <ReqoreSpan
4363
+ className='options-readfirst-group-count'
4364
+ size='small'
4365
+ effect={{ opacity: 0.65 }}
4366
+ >
4367
+ {headerCount}
4368
+ </ReqoreSpan>
4369
+ : null}
4370
+ </StyledGroupHeader>
4371
+ : <StyledGroupHeader>
4372
+ <ReqoreP effect={{ weight: 'bold' }} size='normal'>
4373
+ {box.label}
4374
+ </ReqoreP>
4375
+ {headerCount ?
4376
+ <ReqoreTag
4377
+ size='small'
4378
+ minimal
4379
+ compact
4380
+ intent={box.intent}
4381
+ label={String(headerCount)}
4382
+ />
4383
+ : null}
4384
+ </StyledGroupHeader>
4116
4385
  }
4117
- icon={box.icon}
4386
+ icon={calm ? undefined : box.icon}
4118
4387
  className='options-readfirst-group'
4119
4388
  padded={false}
4120
- contentStyle={{ padding: '4px 4px 6px' }}
4389
+ contentStyle={{ padding: calm ? 0 : '4px 4px 6px' }}
4121
4390
  >
4122
4391
  {/* ONE group body per box: every field block (and the
4123
4392
  thin schema sub-labels) is a direct flex child, so the
4124
4393
  inter-field gap is identical everywhere — including
4125
4394
  across schema-group boundaries. */}
4126
4395
  <StyledGroupBody
4396
+ $calm={calm}
4397
+ $hairline={cHairline}
4127
4398
  $divider={cDivider}
4128
4399
  $hover={cHover}
4129
4400
  $focus={cWarning}
@@ -4560,10 +4831,20 @@ const FormEngineImpl = ({
4560
4831
  * a child of the row it belongs to. Threading a renderer to each of them by
4561
4832
  * hand is how one of them gets missed.
4562
4833
  */
4563
- export const FormEngine = (props: IFormEngineProps) => (
4564
- <MarkdownRendererContext.Provider value={props.markdownRenderer}>
4565
- <FormEngineImpl {...props} />
4566
- </MarkdownRendererContext.Provider>
4567
- );
4834
+ export const FormEngine = (props: IFormEngineProps) => {
4835
+ // The form's own `appearance` wins; without one it follows the form (or the
4836
+ // host's provider) it sits in, so a nested sub-form never changes look
4837
+ // halfway down its parent.
4838
+ const inherited = useFormAppearance();
4839
+ const appearance = props.appearance ?? inherited;
4840
+
4841
+ return (
4842
+ <MarkdownRendererContext.Provider value={props.markdownRenderer}>
4843
+ <FormAppearanceProvider value={appearance}>
4844
+ <FormEngineImpl {...props} />
4845
+ </FormAppearanceProvider>
4846
+ </MarkdownRendererContext.Provider>
4847
+ );
4848
+ };
4568
4849
 
4569
4850
  export default FormEngine;