@qoretechnologies/reqraft 0.10.2 → 0.10.4

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 (71) hide show
  1. package/design/COMPACT_ENGINE_REDESIGN.md +156 -0
  2. package/design/FORM_ENGINE_COMPACT_UX_PLAN.md +353 -0
  3. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  4. package/dist/components/form/engine/CompactRow.js +153 -94
  5. package/dist/components/form/engine/CompactRow.js.map +1 -1
  6. package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
  7. package/dist/components/form/engine/CompactToolbar.js +130 -94
  8. package/dist/components/form/engine/CompactToolbar.js.map +1 -1
  9. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  10. package/dist/components/form/engine/FormEngine.js +181 -45
  11. package/dist/components/form/engine/FormEngine.js.map +1 -1
  12. package/dist/components/form/engine/compactRowStyles.d.ts +6 -3
  13. package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
  14. package/dist/components/form/engine/compactRowStyles.js +70 -48
  15. package/dist/components/form/engine/compactRowStyles.js.map +1 -1
  16. package/dist/components/form/engine/compactToolbarContext.d.ts +1 -0
  17. package/dist/components/form/engine/compactToolbarContext.d.ts.map +1 -1
  18. package/dist/components/form/engine/compactToolbarContext.js.map +1 -1
  19. package/dist/components/form/engine/readFirst.d.ts +19 -0
  20. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  21. package/dist/components/form/engine/readFirst.js +22 -1
  22. package/dist/components/form/engine/readFirst.js.map +1 -1
  23. package/dist/components/form/engine/variants/VariantCalmTable.d.ts +6 -0
  24. package/dist/components/form/engine/variants/VariantCalmTable.d.ts.map +1 -0
  25. package/dist/components/form/engine/variants/VariantCalmTable.js +94 -0
  26. package/dist/components/form/engine/variants/VariantCalmTable.js.map +1 -0
  27. package/dist/components/form/engine/variants/VariantCards.d.ts +6 -0
  28. package/dist/components/form/engine/variants/VariantCards.d.ts.map +1 -0
  29. package/dist/components/form/engine/variants/VariantCards.js +80 -0
  30. package/dist/components/form/engine/variants/VariantCards.js.map +1 -0
  31. package/dist/components/form/engine/variants/VariantFocus.d.ts +7 -0
  32. package/dist/components/form/engine/variants/VariantFocus.d.ts.map +1 -0
  33. package/dist/components/form/engine/variants/VariantFocus.js +138 -0
  34. package/dist/components/form/engine/variants/VariantFocus.js.map +1 -0
  35. package/dist/components/form/engine/variants/VariantMinimal.d.ts +6 -0
  36. package/dist/components/form/engine/variants/VariantMinimal.d.ts.map +1 -0
  37. package/dist/components/form/engine/variants/VariantMinimal.js +73 -0
  38. package/dist/components/form/engine/variants/VariantMinimal.js.map +1 -0
  39. package/dist/components/form/engine/variants/focusDemo.d.ts +13 -0
  40. package/dist/components/form/engine/variants/focusDemo.d.ts.map +1 -0
  41. package/dist/components/form/engine/variants/focusDemo.js +139 -0
  42. package/dist/components/form/engine/variants/focusDemo.js.map +1 -0
  43. package/dist/components/form/engine/variants/variantModel.d.ts +70 -0
  44. package/dist/components/form/engine/variants/variantModel.d.ts.map +1 -0
  45. package/dist/components/form/engine/variants/variantModel.js +133 -0
  46. package/dist/components/form/engine/variants/variantModel.js.map +1 -0
  47. package/dist/components/form/engine/variants/variantParts.d.ts +79 -0
  48. package/dist/components/form/engine/variants/variantParts.d.ts.map +1 -0
  49. package/dist/components/form/engine/variants/variantParts.js +191 -0
  50. package/dist/components/form/engine/variants/variantParts.js.map +1 -0
  51. package/dist/components/form/fields/auto/AutoFormField.d.ts +3 -0
  52. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  53. package/dist/components/form/fields/auto/AutoFormField.js +2 -2
  54. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  55. package/package.json +1 -1
  56. package/src/components/form/engine/CompactRow.tsx +256 -234
  57. package/src/components/form/engine/CompactToolbar.tsx +108 -68
  58. package/src/components/form/engine/FormEngine.stories.tsx +127 -110
  59. package/src/components/form/engine/FormEngine.tsx +248 -67
  60. package/src/components/form/engine/compactRowStyles.ts +207 -134
  61. package/src/components/form/engine/compactToolbarContext.ts +1 -0
  62. package/src/components/form/engine/readFirst.ts +35 -0
  63. package/src/components/form/engine/variants/FormEngineVariants.stories.tsx +119 -0
  64. package/src/components/form/engine/variants/VariantCalmTable.tsx +242 -0
  65. package/src/components/form/engine/variants/VariantCards.tsx +212 -0
  66. package/src/components/form/engine/variants/VariantFocus.tsx +382 -0
  67. package/src/components/form/engine/variants/VariantMinimal.tsx +170 -0
  68. package/src/components/form/engine/variants/focusDemo.ts +145 -0
  69. package/src/components/form/engine/variants/variantModel.ts +216 -0
  70. package/src/components/form/engine/variants/variantParts.tsx +313 -0
  71. package/src/components/form/fields/auto/AutoFormField.tsx +5 -0
@@ -3,11 +3,8 @@ import {
3
3
  ReqoreControlGroup,
4
4
  ReqoreDropdown,
5
5
  ReqoreInput,
6
- ReqoreMessage,
7
- ReqoreProgress,
8
- ReqoreSpan,
9
- ReqoreTag,
10
6
  } from '@qoretechnologies/reqore';
7
+ import { useReqoreTheme } from '@qoretechnologies/reqore/dist/hooks/useTheme';
11
8
  import { IReqoreControlGroupProps } from '@qoretechnologies/reqore/dist/components/ControlGroup';
12
9
  import {
13
10
  IReqoreDropdownItem,
@@ -28,20 +25,73 @@ const SORT_MODES: { value: TCompactSort; label: string; tooltip: string }[] = [
28
25
  { value: 'invalid', label: 'Invalid first', tooltip: 'Fields needing attention first' },
29
26
  ];
30
27
 
31
- // Custom flex, not ReqoreControlGroup: the meter needs the middle bar to absorb
32
- // all width changes while the fixed-width labels never shrink/truncate and a
33
- // 12px gap that isn't on ReqoreControlGroup's gapSize scale (5 or 18). The
34
- // `flex: 1; min-width: 0` makes only the bar yield as the row narrows.
28
+ // "Focus" header: a summary line ({pct}% complete · {set}/{total} set ·
29
+ // {attention} need attention →) above a SEGMENTED metera green run (set) then
30
+ // an amber run (needs attention) then the empty remainder.
35
31
  const StyledCompletion = styled.div`
36
32
  display: flex;
37
- align-items: center;
38
- gap: 12px;
33
+ flex-flow: column;
34
+ gap: 8px;
39
35
  padding: 0 2px;
40
- & > .reqore-progress {
41
- flex: 1;
42
- min-width: 0;
36
+ `;
37
+ const StyledCompletionLine = styled.div`
38
+ display: flex;
39
+ align-items: baseline;
40
+ gap: 10px;
41
+ flex-wrap: wrap;
42
+ `;
43
+ const StyledMeter = styled.div<{ $set: number; $attention: number; $track: string; $set_c: string; $att_c: string }>`
44
+ position: relative;
45
+ height: 4px;
46
+ border-radius: 3px;
47
+ width: 100%;
48
+ overflow: hidden;
49
+ background: ${({ $track }) => $track};
50
+ /* No per-segment radius — the container's overflow:hidden + radius rounds only
51
+ the OUTER corners, so the green/amber runs meet flush (no notch). */
52
+ &::before,
53
+ &::after {
54
+ content: '';
55
+ position: absolute;
56
+ top: 0;
57
+ bottom: 0;
58
+ }
59
+ /* green run: 0 → set% */
60
+ &::before {
61
+ left: 0;
62
+ width: ${({ $set }) => $set}%;
63
+ background: ${({ $set_c }) => $set_c};
64
+ }
65
+ /* amber run: set% → set%+attention% */
66
+ &::after {
67
+ left: ${({ $set }) => $set}%;
68
+ width: ${({ $attention }) => $attention}%;
69
+ background: ${({ $att_c }) => $att_c};
43
70
  }
44
71
  `;
72
+ // One shared text size for the whole summary line, so "Draft", "1/6 set" and
73
+ // "N need attention" all read at the same scale (no chips, no size jumps).
74
+ const StyledSummary = styled.span<{ $color?: string }>`
75
+ font-size: 13px;
76
+ white-space: nowrap;
77
+ color: ${({ $color }) => $color || 'inherit'};
78
+ `;
79
+ const StyledAttentionLink = styled.span<{ $color: string }>`
80
+ color: ${({ $color }) => $color};
81
+ font-size: 13px;
82
+ cursor: pointer;
83
+ white-space: nowrap;
84
+ &:hover {
85
+ text-decoration: underline;
86
+ }
87
+ `;
88
+ // The percentage, pushed to the far right of the summary line.
89
+ const StyledPct = styled.span`
90
+ margin-left: auto;
91
+ font-weight: 700;
92
+ font-size: 17px;
93
+ white-space: nowrap;
94
+ `;
45
95
 
46
96
  /**
47
97
  * The compact form's sticky toolbar (completion meter + field filter + "Fields"
@@ -55,8 +105,8 @@ export const CompactToolbar = memo((reqoreProps: Partial<IReqoreControlGroupProp
55
105
  const {
56
106
  readOnly,
57
107
  invalidCount,
108
+ attentionCount,
58
109
  completion,
59
- showInvalidOnly,
60
110
  onToggleInvalidOnly,
61
111
  hasMultipleOptions,
62
112
  compactQuery,
@@ -78,51 +128,53 @@ export const CompactToolbar = memo((reqoreProps: Partial<IReqoreControlGroupProp
78
128
  onRevertAll,
79
129
  } = useContext(CompactToolbarContext);
80
130
 
131
+ const theme = useReqoreTheme();
132
+ const intents = (theme.intents || {}) as Record<string, string>;
133
+ const cSuccess = intents.success || '#4a7110';
134
+ const cWarning = intents.warning || '#d17c29';
135
+ const cText = (theme.text?.color as string) || '#e8e8e8';
136
+ const cTrack = `${cText}1f`;
137
+ const setPct = completion.total ? (completion.set / completion.total) * 100 : 0;
138
+ const attentionPct = completion.total ? (attentionCount / completion.total) * 100 : 0;
139
+
81
140
  return (
82
141
  <ReqoreControlGroup {...reqoreProps} vertical fluid fixed={false} gapSize='big'>
83
142
  {completion.total ?
84
143
  <StyledCompletion className='options-readfirst-completion'>
85
- {!readOnly ?
86
- invalidCount ?
87
- <ReqoreTag
144
+ <StyledCompletionLine>
145
+ {!readOnly ?
146
+ <StyledSummary
88
147
  className='options-readfirst-status'
89
- label='Draft'
90
- intent='warning'
91
- icon='EditLine'
92
- minimal
93
- flat
94
- size='tiny'
95
- compact
96
- fixed
97
- effect={{ uppercase: true, spaced: 1 }}
98
- />
99
- : <ReqoreTag
100
- className='options-readfirst-status'
101
- label='Ready'
102
- intent='success'
103
- icon='CheckLine'
104
- minimal
105
- flat
106
- size='tiny'
107
- compact
108
- fixed
109
- effect={{ uppercase: true, spaced: 1 }}
110
- />
111
-
112
- : null}
113
- <ReqoreSpan size='small' effect={{ opacity: 0.7, noWrap: true }}>
114
- {completion.set} / {completion.total} fields set
115
- </ReqoreSpan>
116
- <ReqoreProgress
148
+ $color={invalidCount ? cWarning : cSuccess}
149
+ style={{ fontWeight: 600 }}
150
+ >
151
+ {invalidCount ? 'Draft' : 'Ready'}
152
+ </StyledSummary>
153
+ : null}
154
+ <StyledSummary style={{ opacity: 0.5 }}>
155
+ {!readOnly ? '· ' : ''}
156
+ {completion.set}/{completion.total} set
157
+ {!readOnly && attentionCount ? ' ·' : ''}
158
+ </StyledSummary>
159
+ {!readOnly && attentionCount ?
160
+ <StyledAttentionLink
161
+ $color={cWarning}
162
+ className='options-readfirst-attention-link'
163
+ onClick={onToggleInvalidOnly}
164
+ >
165
+ {attentionCount} need attention →
166
+ </StyledAttentionLink>
167
+ : null}
168
+ <StyledPct>{completion.pct}%</StyledPct>
169
+ </StyledCompletionLine>
170
+ <StyledMeter
117
171
  className='options-readfirst-completion-bar'
118
- value={completion.pct}
119
- intent={completion.set === completion.total ? 'success' : 'info'}
120
- size='normal'
121
- flat
172
+ $set={setPct}
173
+ $attention={attentionPct}
174
+ $track={cTrack}
175
+ $set_c={cSuccess}
176
+ $att_c={cWarning}
122
177
  />
123
- <ReqoreSpan size='small' effect={{ opacity: 0.7, noWrap: true }}>
124
- {completion.pct}%
125
- </ReqoreSpan>
126
178
  </StyledCompletion>
127
179
  : null}
128
180
 
@@ -147,10 +199,10 @@ export const CompactToolbar = memo((reqoreProps: Partial<IReqoreControlGroupProp
147
199
  {!readOnly ?
148
200
  <ReqoreDropdown
149
201
  fixed
150
- minimal
202
+ flat
151
203
  filterable
152
204
  icon='Filter3Line'
153
- label='Fields'
205
+ tooltip='Fields'
154
206
  className='options-readfirst-fields'
155
207
  intent={requiredOnly ? 'info' : undefined}
156
208
  badge={requiredOnly ? 'Required only' : undefined}
@@ -238,7 +290,8 @@ export const CompactToolbar = memo((reqoreProps: Partial<IReqoreControlGroupProp
238
290
  : null}
239
291
  <ReqoreButton
240
292
  fixed
241
- minimal
293
+ flat
294
+ minimal={showAllDescriptions === true}
242
295
  icon={showAllDescriptions ? 'InformationFill' : 'InformationLine'}
243
296
  className='options-readfirst-descriptions'
244
297
  active={showAllDescriptions}
@@ -248,19 +301,6 @@ export const CompactToolbar = memo((reqoreProps: Partial<IReqoreControlGroupProp
248
301
  />
249
302
  </ReqoreControlGroup>
250
303
  : null}
251
- {invalidCount && !readOnly ?
252
- <ReqoreMessage
253
- intent={showInvalidOnly ? 'info' : 'danger'}
254
- opaque={false}
255
- size='small'
256
- className='options-readfirst-invalid-banner'
257
- onClick={onToggleInvalidOnly}
258
- >
259
- {showInvalidOnly ?
260
- 'Showing invalid fields only. Click here again to show all fields.'
261
- : `${invalidCount < 2 ? 'A field is not valid and requires' : `${invalidCount} fields are not valid and require`} attention. Click here to only show invalid fields.`}
262
- </ReqoreMessage>
263
- : null}
264
304
  </ReqoreControlGroup>
265
305
  : null}
266
306
  </ReqoreControlGroup>
@@ -963,7 +963,7 @@ const CompactSchema: Record<string, TCompactField> = {
963
963
  };
964
964
 
965
965
  // `description` is intentionally left empty so the required-but-unset state is
966
- // visible (a "Required not set" row and an invalid-field message).
966
+ // visible (a "— Required" row and an invalid-field message).
967
967
  const CompactValue: IOptions = {
968
968
  name: { type: 'string', value: 'order-fulfilment' },
969
969
  lang: { type: 'string', value: 'python' },
@@ -1022,10 +1022,16 @@ export const Compact: Story = {
1022
1022
  play: async () => {
1023
1023
  // Groups render with their display metadata; rows show formatted values.
1024
1024
  await _testsWaitForText('Identity and core settings');
1025
+ // Regression: `general` is a REAL consumer-defined group here (it's in
1026
+ // CompactGroups, and `description`/`tags` set `group: 'general'`), so its
1027
+ // sub-label MUST render. It must NOT be suppressed as the synthetic "no
1028
+ // group" catch-all — doing so visually merged its rows (e.g. Tags) into the
1029
+ // group above them.
1030
+ await _testsWaitForText('General');
1025
1031
  await _testsWaitForText('order-fulfilment');
1026
1032
  await _testsWaitForText('orders, batch');
1027
1033
  await _testsWaitForText('Yes');
1028
- await _testsWaitForText('Required not set');
1034
+ await _testsWaitForText('—');
1029
1035
  },
1030
1036
  };
1031
1037
 
@@ -1039,11 +1045,12 @@ export const CompactReadOnly: Story = {
1039
1045
  await _testsWaitForText('order-fulfilment');
1040
1046
  // Read-only hides the Draft/Ready badge (the meter itself stays)…
1041
1047
  await _testsWaitForTextToNotExist('Draft');
1042
- // …and rows open in view mode: Close instead of Done, then collapse back.
1048
+ // …and rows open in view mode: the card's done (✓/close) button collapses
1049
+ // back. The button is icon-only now, so assert it by class, not text.
1043
1050
  await _testsClickText('order-fulfilment');
1044
- await _testsWaitForText('Close');
1051
+ await waitFor(() => expect(document.querySelector('.options-readfirst-done')).toBeTruthy());
1045
1052
  await _testsClickButton({ selector: '.options-readfirst-done' });
1046
- await _testsWaitForTextToNotExist('Close');
1053
+ await waitFor(() => expect(document.querySelector('.options-readfirst-done')).toBeNull());
1047
1054
  },
1048
1055
  };
1049
1056
 
@@ -1057,8 +1064,8 @@ export const CompactEmpty: Story = {
1057
1064
  },
1058
1065
  play: async () => {
1059
1066
  // Both required fields read as unset; the four optional ones as "Not set".
1060
- await _testsWaitForTextsCount('Required not set', undefined, 2);
1061
- await _testsWaitForTextsCount('Not set', undefined, 4);
1067
+ // All six empty fields read as a calm dash (the red asterisk marks required).
1068
+ await _testsWaitForTextsCount('', undefined, 6);
1062
1069
  },
1063
1070
  };
1064
1071
 
@@ -1539,11 +1546,10 @@ export const CompactValidIdentifierRule: Story = {
1539
1546
  play: async () => {
1540
1547
  await _testsWaitForText('Variable name');
1541
1548
  await _testsWaitForText('1-bad-identifier');
1542
- // The rules-driven validation marks the form as needing attention.
1549
+ // The rules-driven validation marks the form as needing attention — the
1550
+ // dedicated "Needs attention" box (and the header link) signal it.
1543
1551
  await _testsWaitForText('Draft');
1544
- await _testsWaitForText(
1545
- 'A field is not valid and requires attention. Click here to only show invalid fields.'
1546
- );
1552
+ await _testsWaitForText('Needs attention');
1547
1553
  },
1548
1554
  };
1549
1555
 
@@ -1608,7 +1614,19 @@ export const CompactFocusedEditing: Story = {
1608
1614
  await waitFor(() => expect(document.querySelector('.options-readfirst-card')).toBeTruthy(), {
1609
1615
  timeout: 10000,
1610
1616
  });
1611
- await _testsClickButton({ selector: '.options-readfirst-fullscreen' });
1617
+ // Fullscreen now lives in the card's "More" (⋮) menu, before the Done ✓.
1618
+ await _testsClickButton({ selector: '.options-readfirst-more' });
1619
+ let fsItem: Element | undefined;
1620
+ await waitFor(
1621
+ () => {
1622
+ fsItem = Array.from(document.querySelectorAll('.reqore-menu-item')).find((element) =>
1623
+ element.textContent?.includes('Edit fullscreen')
1624
+ );
1625
+ expect(fsItem).toBeTruthy();
1626
+ },
1627
+ { timeout: 10000 }
1628
+ );
1629
+ await fireEvent.click(fsItem as Element);
1612
1630
  await waitFor(() => expect(document.querySelector('.reqore-modal')).toBeTruthy(), {
1613
1631
  timeout: 10000,
1614
1632
  });
@@ -1709,7 +1727,7 @@ export const CompactReadFirstEditing: Story = {
1709
1727
  await _testsWaitForText('Yes');
1710
1728
  await _testsWaitForText('Python');
1711
1729
  // The required-but-empty field shows its placeholder instead of an editor.
1712
- await _testsWaitForText('Required not set');
1730
+ await _testsWaitForText('—');
1713
1731
  // No field editor (textarea) is mounted while everything is collapsed.
1714
1732
  await expect(document.querySelectorAll('.reqore-textarea')).toHaveLength(0);
1715
1733
 
@@ -1798,14 +1816,31 @@ export const CompactFieldsMenu: Story = {
1798
1816
  await clickFieldsMenuItem('Default fields');
1799
1817
  await _testsWaitForTextToNotExist('Notes');
1800
1818
 
1801
- // The per-row delete affordance: re-add Notes, then remove it via its row's
1802
- // delete button → the confirm modal → Confirm.
1819
+ // The delete affordance now lives in the expanded editor's "More" (⋮) menu:
1820
+ // re-add Notes, open it, then Remove field via More → the confirm modal →
1821
+ // Confirm.
1803
1822
  await clickFieldsMenuItem('Select all');
1804
1823
  await _testsWaitForText('Notes');
1805
- await fireEvent.click(
1806
- document.querySelector('.readfirst-row[data-field="notes"] .readfirst-action') as HTMLElement
1824
+ await _testsClickText('Notes');
1825
+ await waitFor(
1826
+ () =>
1827
+ expect(
1828
+ document.querySelector('[data-field="notes"] .options-readfirst-more')
1829
+ ).toBeTruthy(),
1830
+ { timeout: 10000 }
1831
+ );
1832
+ await _testsClickButton({ selector: '[data-field="notes"] .options-readfirst-more' });
1833
+ let removeItem: Element | undefined;
1834
+ await waitFor(
1835
+ () => {
1836
+ removeItem = Array.from(document.querySelectorAll('.reqore-menu-item')).find((element) =>
1837
+ element.textContent?.includes('Remove field')
1838
+ );
1839
+ expect(removeItem).toBeTruthy();
1840
+ },
1841
+ { timeout: 10000 }
1807
1842
  );
1808
- await _testsWaitForText('Remove field');
1843
+ await fireEvent.click(removeItem as Element);
1809
1844
  await _testsClickButton({ label: 'Confirm' });
1810
1845
  await _testsWaitForTextToNotExist('Notes');
1811
1846
  },
@@ -1847,6 +1882,31 @@ export const CompactDescriptionsToggle: Story = {
1847
1882
  // One toggle reveals the short_desc on every field that has one.
1848
1883
  await _testsWaitForText('The server hostname or IP address');
1849
1884
  await _testsWaitForText('TCP port to connect on');
1885
+
1886
+ // Regression: opening a field for INLINE editing must keep its description
1887
+ // visible while the global toggle is on — it used to vanish because the
1888
+ // inline editor's label dropped the short_desc.
1889
+ await _testsClickText('Host');
1890
+ await waitFor(
1891
+ () =>
1892
+ expect(
1893
+ document.querySelector(
1894
+ '.readfirst-row-editing[data-field="host"] .options-readfirst-label-desc'
1895
+ )
1896
+ ).toBeTruthy(),
1897
+ { timeout: 10000 }
1898
+ );
1899
+ await _testsWaitForText('The server hostname or IP address');
1900
+ // Collapse back to the read row (Done) before toggling descriptions off.
1901
+ await _testsClickButton({ selector: '[data-field="host"] .options-readfirst-done' });
1902
+ await waitFor(
1903
+ () =>
1904
+ expect(
1905
+ document.querySelector('.readfirst-row-editing[data-field="host"]')
1906
+ ).toBeFalsy(),
1907
+ { timeout: 10000 }
1908
+ );
1909
+
1850
1910
  // Toggling off hides them again.
1851
1911
  await _testsClickButton({ selector: '.options-readfirst-descriptions' });
1852
1912
  await _testsWaitForTextToNotExist('The server hostname or IP address');
@@ -1874,7 +1934,9 @@ export const CompactSearchHidden: Story = {
1874
1934
  value: 'notes',
1875
1935
  });
1876
1936
  await _testsWaitForText('Notes');
1877
- await _testsWaitForText('Not in form — add');
1937
+ await waitFor(() =>
1938
+ expect(document.querySelector('.readfirst-row-hidden[data-field="notes"]')).toBeTruthy()
1939
+ );
1878
1940
 
1879
1941
  // Rows are keyboard-operable (role=button + Enter): focusing the hidden row
1880
1942
  // and pressing Enter adds the field and opens its inline editor.
@@ -2928,29 +2990,17 @@ export const CompactFieldTypes: Story = {
2928
2990
  // fresh nodes per click.
2929
2991
  await _testsWaitForText('This value fails validation upstream.');
2930
2992
  await _testsWaitForText('Deprecated — migrate before 2026-09.');
2931
- const catalogPanel = (field: string) =>
2932
- document.querySelector(
2933
- `.options-readfirst-info-row[data-field="${field}"] .options-readfirst-info-panel`
2934
- );
2935
- await expect(catalogPanel('infoMsgQuiet')).toBeNull();
2936
- await fireEvent.click(
2937
- document.querySelector(
2938
- '.readfirst-row[data-field="infoMsgQuiet"] .options-readfirst-info-slot .options-readfirst-info-toggle'
2939
- ) as HTMLElement
2940
- );
2993
+ // Dedicated schema messages render as panels, visible WITHOUT interaction
2994
+ // (the per-row ⓘ is gone — descriptions are revealed by the global toggle or
2995
+ // by expanding the field).
2941
2996
  await _testsWaitForText('Requests are signed automatically.');
2942
2997
  await _testsWaitForText('Connection verified.');
2943
- // A short_desc-only field has no value-side panel; the reveals the short_desc
2944
- // under the name. desc renders the ? help affordance.
2945
- await expect(catalogPanel('infoShortDesc')).toBeNull();
2946
- await fireEvent.click(
2947
- document.querySelector(
2948
- '.readfirst-row[data-field="infoShortDesc"] .options-readfirst-info-slot .options-readfirst-info-toggle'
2949
- ) as HTMLElement
2950
- );
2998
+ // The global descriptions toggle reveals each field's short_desc under its name.
2999
+ await fireEvent.click(document.querySelector('.options-readfirst-descriptions') as HTMLElement);
2951
3000
  await _testsWaitForText(
2952
3001
  'A one-line summary shown under the field name and in the hover title.'
2953
3002
  );
3003
+ // A field with a long desc still exposes the ? help affordance.
2954
3004
  await expect(
2955
3005
  document.querySelector('.readfirst-row[data-field="infoLongDesc"] .options-readfirst-help')
2956
3006
  ).toBeTruthy();
@@ -2988,7 +3038,10 @@ const _compactExpandAllRows = async () => {
2988
3038
  document.querySelectorAll<HTMLElement>(
2989
3039
  '.readfirst-row:not(.readfirst-row-editing):not(.readfirst-row-disabled):not(.readfirst-row-hidden)'
2990
3040
  )
2991
- );
3041
+ // An arg_schema field opens a NESTED compact sub-form (recursive compact);
3042
+ // its rows live inside the parent's edit card — don't count those as
3043
+ // top-level read rows to expand.
3044
+ ).filter((r) => !r.closest('.options-readfirst-card'));
2992
3045
  // Generous guard: the catalog has ~70 fields.
2993
3046
  for (let guard = 0; guard < 120; guard++) {
2994
3047
  const remaining = readRows();
@@ -3090,7 +3143,7 @@ export const CompactRequiredGroups: Story = {
3090
3143
  // contiguous members (byHost/byFile in Connection) cluster into a rail, which
3091
3144
  // carries the grouping in place of a chip; only the lone member (byUrl in
3092
3145
  // General) keeps a "One of" chip — so exactly one chip, not three.
3093
- await _testsWaitForTextsCount('Required not set', undefined, 3);
3146
+ await _testsWaitForTextsCount('—', undefined, 3);
3094
3147
  await _testsWaitForText('Draft');
3095
3148
  await _testsWaitForTextsCount('One of', undefined, 1);
3096
3149
 
@@ -3147,13 +3200,14 @@ export const CompactRequiredGroups: Story = {
3147
3200
  await _testsWaitForText('https://example.com');
3148
3201
 
3149
3202
  // One fulfilled member satisfies the group → the badge flips to Ready and the
3150
- // chips PERSIST but flip to their muted resolution: the filled member shows
3151
- // "Covers", the empty siblings "Covered by 'By URL'", and no "One of" remains.
3203
+ // Once satisfied: the filled member keeps a "Covers" chip; the empty siblings
3204
+ // show their "Covered by 'By URL'" note INLINE (not a chip), and no "One of"
3205
+ // remains. So exactly one required-group chip stays (the coverer's).
3152
3206
  await _testsWaitForText('Ready');
3153
3207
  await _testsWaitForTextsCount('Covered by “By URL”', undefined, 2);
3154
3208
  await _testsWaitForText('Covers');
3155
3209
  await _testsWaitForTextToNotExist('One of');
3156
- await expect(document.querySelectorAll('.options-readfirst-required-group')).toHaveLength(3);
3210
+ await expect(document.querySelectorAll('.options-readfirst-required-group')).toHaveLength(1);
3157
3211
  },
3158
3212
  };
3159
3213
 
@@ -3682,57 +3736,41 @@ export const CompactShowcase: Story = {
3682
3736
  ).toBeTruthy();
3683
3737
  await waitFor(() => {
3684
3738
  // The intent stripe rides the value surface's left border, fed by
3685
- // --readfirst-stripe on the field's BLOCK root. This field carries a
3686
- // message, so the block root is the info-row wrapper (not the inner row).
3739
+ // --readfirst-stripe on the field's BLOCK root. Schema messages now render
3740
+ // inside the value cell, so the block root is the row itself.
3687
3741
  const intentRow = document.querySelector(
3688
- '.options-readfirst-info-row[data-field="chromeIntent"]'
3742
+ '.readfirst-row[data-field="chromeIntent"]'
3689
3743
  ) as HTMLElement;
3690
3744
  expect(intentRow?.style?.getPropertyValue('--readfirst-stripe')).toBeTruthy();
3691
3745
  });
3692
3746
  await _testsWaitForText('••••••');
3693
3747
  await _testsWaitForText('This field also carries a warning message.');
3694
- // The required-group pair (authToken/authCertFile, contiguous) clusters into a
3695
- // connection rail a status node per member, not a per-row "One of" chip —
3696
- // alongside the info affordances.
3748
+ // The unmet auth one-of group (authToken/authCertFile) renders the
3749
+ // "One of the below is required" cluster box.
3697
3750
  await waitFor(() =>
3698
- expect(document.querySelectorAll('.options-readfirst-node').length).toBeGreaterThan(0)
3751
+ expect(document.querySelector('.options-readfirst-required-cluster')).toBeTruthy()
3699
3752
  );
3700
3753
 
3701
- // Toggling a panel adds/removes the info-row wrapper, REBUILDING the row's
3702
- // DOM re-query the toggle for every click and assert panel state on the
3703
- // wrapper element.
3704
- const infoToggle = (field: string) =>
3705
- document.querySelector(
3706
- `.readfirst-row[data-field="${field}"] .options-readfirst-info-slot .options-readfirst-info-toggle`
3707
- ) as HTMLElement;
3754
+ // Schema message panels render inside the value cell of the row itself,
3755
+ // directly beneath the value.
3708
3756
  const infoPanel = (field: string) =>
3709
3757
  document.querySelector(
3710
- `.options-readfirst-info-row[data-field="${field}"] .options-readfirst-info-panel`
3758
+ `.readfirst-row[data-field="${field}"] .options-readfirst-info-panel`
3711
3759
  );
3712
3760
 
3713
- // Tier-2-only fields (info messages, default-value notes) stay one line:
3714
- // panel closed, toggle in the fixed slot. Toggling open reveals the
3715
- // default-value note; toggling again hides it.
3716
- await expect(infoPanel('metaDefault')).toBeNull();
3717
- await expect(infoToggle('metaDefault')).toBeTruthy();
3718
- await fireEvent.click(infoToggle('metaDefault'));
3761
+ // Default-value notes and validation/dependency hints now render as a compact
3762
+ // INLINE reason (no ⓘ, no panel) visible without any interaction.
3719
3763
  await _testsWaitForText('Default: thirty — Falls back to 30 seconds when unset.');
3720
- await fireEvent.click(infoToggle('metaDefault'));
3721
- await waitFor(() => expect(infoPanel('metaDefault')).toBeNull());
3722
-
3723
- // Auto-open panels can be dismissed the same way (override sticks).
3764
+ // Dedicated schema messages stay prominent PANELS, also always visible.
3724
3765
  await expect(infoPanel('apiEndpoint')).toBeTruthy();
3725
- await fireEvent.click(infoToggle('apiEndpoint'));
3726
- await waitFor(() => expect(infoPanel('apiEndpoint')).toBeNull());
3727
- await fireEvent.click(infoToggle('apiEndpoint'));
3728
3766
  await _testsWaitForText('v1 endpoints are deprecated — migrate to /v2 before 2026-09.');
3729
3767
 
3730
- // short_desc renders UNDER the field name, gated by the same ⓘ: a message-free
3731
- // field keeps it hidden until toggled, then reveals it under the name.
3768
+ // short_desc renders UNDER the field name when the global descriptions toggle
3769
+ // is engaged (the per-row is gone).
3732
3770
  const labelDesc = (field: string) =>
3733
3771
  document.querySelector(`.readfirst-row[data-field="${field}"] .options-readfirst-label-desc`);
3734
3772
  await expect(labelDesc('chromeIcon')).toBeNull();
3735
- await fireEvent.click(infoToggle('chromeIcon'));
3773
+ await fireEvent.click(document.querySelector('.options-readfirst-descriptions') as HTMLElement);
3736
3774
  await waitFor(() => expect(labelDesc('chromeIcon')).toBeTruthy());
3737
3775
  },
3738
3776
  };
@@ -3817,38 +3855,14 @@ export const CompactRequiredGroupRails: Story = {
3817
3855
  },
3818
3856
  play: async () => {
3819
3857
  await _testsWaitForText('API key');
3820
- // Members are clustered with a status node each; the two groups give 6 nodes.
3858
+ // The unmet `target` group (email/slack/webhook/sms, none set) renders the
3859
+ // "One of the below is required" cluster box.
3821
3860
  await waitFor(() =>
3822
- expect(document.querySelectorAll('.options-readfirst-node').length).toBe(6)
3861
+ expect(document.querySelector('.options-readfirst-required-cluster')).toBeTruthy()
3823
3862
  );
3824
- // The set member's node is filled (satisfies its group); pending ones are
3825
- // hollow (transparent centre painted the form bg).
3826
- await waitFor(() => {
3827
- const apiNode = document.querySelector(
3828
- '.readfirst-row[data-field="apiKey"] .options-readfirst-node'
3829
- ) as HTMLElement;
3830
- const oauthNode = document.querySelector(
3831
- '.readfirst-row[data-field="oauthToken"] .options-readfirst-node'
3832
- ) as HTMLElement;
3833
- // email is in the `target` group, which has NO member set — still unmet.
3834
- const emailNode = document.querySelector(
3835
- '.readfirst-row[data-field="email"] .options-readfirst-node'
3836
- ) as HTMLElement;
3837
- // Fill marks the member carrying the value: apiKey filled (bg = its border
3838
- // colour), oauthToken hollow (bg = the form background).
3839
- expect(getComputedStyle(apiNode).backgroundColor).not.toBe(
3840
- getComputedStyle(oauthNode).backgroundColor
3841
- );
3842
- // Colour follows the GROUP, not the member. The credential group is satisfied
3843
- // by apiKey, so its empty alternative (oauthToken) reads the SAME colour as
3844
- // the filled node — not warning. The still-unmet target group's node (email)
3845
- // keeps the warning colour, so it differs.
3846
- const apiBorder = getComputedStyle(apiNode).borderTopColor;
3847
- const oauthBorder = getComputedStyle(oauthNode).borderTopColor;
3848
- const emailBorder = getComputedStyle(emailNode).borderTopColor;
3849
- expect(oauthBorder).toBe(apiBorder);
3850
- expect(oauthBorder).not.toBe(emailBorder);
3851
- });
3863
+ // The met `credential` group needs no box: apiKey satisfies it, so its empty
3864
+ // alternative oauthToken reads as covered by its sibling.
3865
+ await _testsWaitForText('Covered by “API key”');
3852
3866
  },
3853
3867
  };
3854
3868
 
@@ -3867,9 +3881,12 @@ export const CompactFieldSortWithinGroups: Story = {
3867
3881
  )
3868
3882
  ).map((row) => row.getAttribute('data-field'));
3869
3883
 
3870
- // Default = the schema's declared order.
3884
+ // Rows are bucketed into status boxes (Needs attention → Set → Optional);
3885
+ // within a box, schema order holds. The `target` group is unmet (attention),
3886
+ // the `credential` group is met by apiKey (set), so the attention members
3887
+ // come first, then the set members.
3871
3888
  await waitFor(() =>
3872
- expect(order()).toEqual(['apiKey', 'oauthToken', 'email', 'slack', 'webhook', 'sms'])
3889
+ expect(order()).toEqual(['email', 'slack', 'webhook', 'sms', 'apiKey', 'oauthToken'])
3873
3890
  );
3874
3891
 
3875
3892
  // Open the Fields menu, drill into the (collapsed) "Sort by" submenu, then
@@ -3887,13 +3904,13 @@ export const CompactFieldSortWithinGroups: Story = {
3887
3904
  await waitFor(() => expect(menuItem('Name A→Z')).toBeTruthy());
3888
3905
  await fireEvent.click(menuItem('Name A→Z') as HTMLElement);
3889
3906
 
3890
- // Sorted by display name inside each group; the two groups stay separate
3891
- // (Connection: apiKey/oauthToken | Notification: email/slack/sms/webhook).
3907
+ // Sorted by display name inside each group; the status boxes stay separate
3908
+ // (attention: email/slack/sms/webhook | set: apiKey/oauthToken).
3892
3909
  await waitFor(() =>
3893
- expect(order()).toEqual(['apiKey', 'oauthToken', 'email', 'slack', 'sms', 'webhook'])
3910
+ expect(order()).toEqual(['email', 'slack', 'sms', 'webhook', 'apiKey', 'oauthToken'])
3894
3911
  );
3895
- // The required-group clusters survive the re-sort: all 6 nodes + both rails.
3896
- await expect(document.querySelectorAll('.options-readfirst-node')).toHaveLength(6);
3912
+ // The required-group clusters survive the re-sort (both groups keep their
3913
+ // first-member marker, used to anchor the cluster box).
3897
3914
  await expect(document.querySelectorAll('.readfirst-cluster-first')).toHaveLength(2);
3898
3915
  },
3899
3916
  };