@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
@@ -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' },
@@ -998,6 +998,32 @@ const clickFieldsMenuItem = async (text: string) => {
998
998
  await fireEvent.click(item as Element);
999
999
  };
1000
1000
 
1001
+ // The Optional status box now holds every not-yet-added field AND any added-but-
1002
+ // empty optional field, so it starts COLLAPSED (and ReqorePanel unmounts collapsed
1003
+ // content). Tests that assert on / interact with optional fields call this to open
1004
+ // it. The whole panel title bar toggles the collapse.
1005
+ const _expandOptionalBox = async () => {
1006
+ const findBox = () =>
1007
+ Array.from(document.querySelectorAll('.options-readfirst-group')).find((panel) =>
1008
+ panel.querySelector('.reqore-panel-title')?.textContent?.includes('Optional')
1009
+ );
1010
+ let box: Element | undefined;
1011
+ await waitFor(
1012
+ () => {
1013
+ box = findBox();
1014
+ expect(box).toBeTruthy();
1015
+ },
1016
+ { timeout: 10000 }
1017
+ );
1018
+ // No `.reqore-panel-content` ⇒ the box is collapsed (content unmounted) — open it.
1019
+ if (box && !box.querySelector('.reqore-panel-content')) {
1020
+ await fireEvent.click(box.querySelector('.reqore-panel-title') as HTMLElement);
1021
+ await waitFor(() => expect(box!.querySelector('.reqore-panel-content')).toBeTruthy(), {
1022
+ timeout: 10000,
1023
+ });
1024
+ }
1025
+ };
1026
+
1001
1027
  // CompactSchema plus one optional (non-preselected) field, to exercise the
1002
1028
  // "Fields" menu add / select-all / reset actions.
1003
1029
  const CompactFieldsMenuSchema: Record<string, TCompactField> = {
@@ -1022,10 +1048,16 @@ export const Compact: Story = {
1022
1048
  play: async () => {
1023
1049
  // Groups render with their display metadata; rows show formatted values.
1024
1050
  await _testsWaitForText('Identity and core settings');
1051
+ // Regression: `general` is a REAL consumer-defined group here (it's in
1052
+ // CompactGroups, and `description`/`tags` set `group: 'general'`), so its
1053
+ // sub-label MUST render. It must NOT be suppressed as the synthetic "no
1054
+ // group" catch-all — doing so visually merged its rows (e.g. Tags) into the
1055
+ // group above them.
1056
+ await _testsWaitForText('General');
1025
1057
  await _testsWaitForText('order-fulfilment');
1026
1058
  await _testsWaitForText('orders, batch');
1027
1059
  await _testsWaitForText('Yes');
1028
- await _testsWaitForText('Required not set');
1060
+ await _testsWaitForText('—');
1029
1061
  },
1030
1062
  };
1031
1063
 
@@ -1039,11 +1071,12 @@ export const CompactReadOnly: Story = {
1039
1071
  await _testsWaitForText('order-fulfilment');
1040
1072
  // Read-only hides the Draft/Ready badge (the meter itself stays)…
1041
1073
  await _testsWaitForTextToNotExist('Draft');
1042
- // …and rows open in view mode: Close instead of Done, then collapse back.
1074
+ // …and rows open in view mode: the card's done (✓/close) button collapses
1075
+ // back. The button is icon-only now, so assert it by class, not text.
1043
1076
  await _testsClickText('order-fulfilment');
1044
- await _testsWaitForText('Close');
1077
+ await waitFor(() => expect(document.querySelector('.options-readfirst-done')).toBeTruthy());
1045
1078
  await _testsClickButton({ selector: '.options-readfirst-done' });
1046
- await _testsWaitForTextToNotExist('Close');
1079
+ await waitFor(() => expect(document.querySelector('.options-readfirst-done')).toBeNull());
1047
1080
  },
1048
1081
  };
1049
1082
 
@@ -1056,9 +1089,12 @@ export const CompactEmpty: Story = {
1056
1089
  groups: CompactGroups,
1057
1090
  },
1058
1091
  play: async () => {
1092
+ // The four empty OPTIONAL fields live in the (collapsed) Optional box — open
1093
+ // it so all six empty fields are on screen.
1094
+ await _expandOptionalBox();
1059
1095
  // 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);
1096
+ // All six empty fields read as a calm dash (the red asterisk marks required).
1097
+ await _testsWaitForTextsCount('', undefined, 6);
1062
1098
  },
1063
1099
  };
1064
1100
 
@@ -1076,6 +1112,9 @@ export const CompactBasic: Story = {
1076
1112
  // Unresolved required/invalid fields → the header shows the Draft badge
1077
1113
  // (the IDE restyled-hero convention).
1078
1114
  await _testsWaitForText('Draft');
1115
+ // Several asserted/clicked fields (Disabled option, …) are empty optionals in
1116
+ // the collapsed Optional box — open it so they're on screen.
1117
+ await _expandOptionalBox();
1079
1118
  // Values resolve in read-first rows: a template shows its display name (from
1080
1119
  // the supplied templates list), colour as hex, hash as a field-count summary.
1081
1120
  await _testsWaitForText('Test (local)');
@@ -1539,11 +1578,10 @@ export const CompactValidIdentifierRule: Story = {
1539
1578
  play: async () => {
1540
1579
  await _testsWaitForText('Variable name');
1541
1580
  await _testsWaitForText('1-bad-identifier');
1542
- // The rules-driven validation marks the form as needing attention.
1581
+ // The rules-driven validation marks the form as needing attention — the
1582
+ // dedicated "Needs attention" box (and the header link) signal it.
1543
1583
  await _testsWaitForText('Draft');
1544
- await _testsWaitForText(
1545
- 'A field is not valid and requires attention. Click here to only show invalid fields.'
1546
- );
1584
+ await _testsWaitForText('Needs attention');
1547
1585
  },
1548
1586
  };
1549
1587
 
@@ -1608,7 +1646,19 @@ export const CompactFocusedEditing: Story = {
1608
1646
  await waitFor(() => expect(document.querySelector('.options-readfirst-card')).toBeTruthy(), {
1609
1647
  timeout: 10000,
1610
1648
  });
1611
- await _testsClickButton({ selector: '.options-readfirst-fullscreen' });
1649
+ // Fullscreen now lives in the card's "More" (⋮) menu, before the Done ✓.
1650
+ await _testsClickButton({ selector: '.options-readfirst-more' });
1651
+ let fsItem: Element | undefined;
1652
+ await waitFor(
1653
+ () => {
1654
+ fsItem = Array.from(document.querySelectorAll('.reqore-menu-item')).find((element) =>
1655
+ element.textContent?.includes('Edit fullscreen')
1656
+ );
1657
+ expect(fsItem).toBeTruthy();
1658
+ },
1659
+ { timeout: 10000 }
1660
+ );
1661
+ await fireEvent.click(fsItem as Element);
1612
1662
  await waitFor(() => expect(document.querySelector('.reqore-modal')).toBeTruthy(), {
1613
1663
  timeout: 10000,
1614
1664
  });
@@ -1684,6 +1734,9 @@ export const CompactSortOrder: Story = {
1684
1734
  value: {} as IOptions,
1685
1735
  },
1686
1736
  play: async () => {
1737
+ // All three fields are empty + optional, so they sit in the (collapsed)
1738
+ // Optional box — open it to read their order.
1739
+ await _expandOptionalBox();
1687
1740
  await _testsWaitForText('First');
1688
1741
  const order = Array.from(document.querySelectorAll('.readfirst-row[data-field]')).map(
1689
1742
  (element) => element.getAttribute('data-field')
@@ -1709,7 +1762,7 @@ export const CompactReadFirstEditing: Story = {
1709
1762
  await _testsWaitForText('Yes');
1710
1763
  await _testsWaitForText('Python');
1711
1764
  // The required-but-empty field shows its placeholder instead of an editor.
1712
- await _testsWaitForText('Required not set');
1765
+ await _testsWaitForText('—');
1713
1766
  // No field editor (textarea) is mounted while everything is collapsed.
1714
1767
  await expect(document.querySelectorAll('.reqore-textarea')).toHaveLength(0);
1715
1768
 
@@ -1739,6 +1792,61 @@ export const CompactReadFirstEditing: Story = {
1739
1792
  },
1740
1793
  };
1741
1794
 
1795
+ // Following a field across panels: filling an empty optional field moves it from
1796
+ // the Optional box to Set, and the engine scrolls to + flashes its new row so it's
1797
+ // easy to keep track of. The flash is the observable signal that the panel-change
1798
+ // locate fired (the scroll itself, scrollIntoView, isn't assertable in the runner).
1799
+ export const CompactPanelChangeScroll: Story = {
1800
+ parameters: { chromatic: { disable: true } },
1801
+ args: {
1802
+ compact: true,
1803
+ minColumnWidth: '300px',
1804
+ options: {
1805
+ req: {
1806
+ type: 'string',
1807
+ ui_type: 'string',
1808
+ display_name: 'Req',
1809
+ required: true,
1810
+ preselected: true,
1811
+ },
1812
+ opt: { type: 'string', ui_type: 'string', display_name: 'Opt', preselected: true },
1813
+ } as IOptionsSchema,
1814
+ value: {} as IOptions,
1815
+ },
1816
+ play: async () => {
1817
+ await _testsWaitForText('Req');
1818
+ // 'opt' is an empty optional → the (collapsed) Optional box. Open it, fill the
1819
+ // field, collapse — it jumps to Set.
1820
+ await _expandOptionalBox();
1821
+ await _testsClickText('Opt');
1822
+ await _testsChangeStringField({
1823
+ selector: '.options-readfirst-inline .reqore-textarea',
1824
+ value: 'hello',
1825
+ });
1826
+ await sleep(300);
1827
+ await _testsClickButton({ selector: '.options-readfirst-done' });
1828
+ // It now lives in the Set box…
1829
+ await waitFor(
1830
+ () =>
1831
+ expect(
1832
+ document
1833
+ .querySelector('.readfirst-row[data-field="opt"]')
1834
+ ?.closest('.options-readfirst-group')
1835
+ ?.querySelector('.reqore-panel-title')?.textContent
1836
+ ).toContain('Set'),
1837
+ { timeout: 5000 }
1838
+ );
1839
+ // …and flashed, signalling the engine located/scrolled to its new panel.
1840
+ await waitFor(
1841
+ () =>
1842
+ expect(
1843
+ document.querySelector('.readfirst-row[data-field="opt"]')?.className
1844
+ ).toContain('readfirst-row-flash'),
1845
+ { timeout: 4000 }
1846
+ );
1847
+ },
1848
+ };
1849
+
1742
1850
  export const CompactRequiredOnlyAndSearch: Story = {
1743
1851
  parameters: { chromatic: { disable: true } },
1744
1852
  args: {
@@ -1787,27 +1895,61 @@ export const CompactFieldsMenu: Story = {
1787
1895
  },
1788
1896
  play: async () => {
1789
1897
  await _testsWaitForText('Tags');
1790
- // 'Notes' is optional and unset, so it is not listed as a row yet.
1898
+ // 'Notes' is optional + unset it lives (collapsed) in the Optional box, so
1899
+ // it isn't in the DOM yet.
1791
1900
  await _testsWaitForTextToNotExist('Notes');
1792
1901
 
1793
- // "Select all" adds every optional field Notes now appears as a row.
1902
+ // "Select all" adds every optional field. Reveal the (collapsed) Optional box
1903
+ // — Notes is now an ADDED row (the normal variant, not the hidden/addable one).
1794
1904
  await clickFieldsMenuItem('Select all');
1905
+ await _expandOptionalBox();
1795
1906
  await _testsWaitForText('Notes');
1907
+ await waitFor(() =>
1908
+ expect(
1909
+ document.querySelector('.readfirst-row[data-field="notes"]:not(.readfirst-row-hidden)')
1910
+ ).toBeTruthy()
1911
+ );
1796
1912
 
1797
- // "Default fields" drops the user-added optional fields — Notes is removed.
1913
+ // "Default fields" drops the user-added optional fields — Notes reverts to a
1914
+ // HIDDEN (addable) row in the still-open Optional box (it's always browsable
1915
+ // now, just not added).
1798
1916
  await clickFieldsMenuItem('Default fields');
1799
- await _testsWaitForTextToNotExist('Notes');
1917
+ await waitFor(() =>
1918
+ expect(document.querySelector('.readfirst-row-hidden[data-field="notes"]')).toBeTruthy()
1919
+ );
1800
1920
 
1801
- // The per-row delete affordance: re-add Notes, then remove it via its row's
1802
- // delete button → the confirm modal → Confirm.
1921
+ // The delete affordance now lives in the expanded editor's "More" (⋮) menu:
1922
+ // re-add Notes, open it, then Remove field via More → the confirm modal →
1923
+ // Confirm.
1803
1924
  await clickFieldsMenuItem('Select all');
1804
1925
  await _testsWaitForText('Notes');
1805
- await fireEvent.click(
1806
- document.querySelector('.readfirst-row[data-field="notes"] .readfirst-action') as HTMLElement
1926
+ await _testsClickText('Notes');
1927
+ // Only Notes is expanded, so the single More () menu in the DOM is its own.
1928
+ // (ReqoreDropdown's trigger isn't a DOM descendant of the row, so don't scope
1929
+ // the selector to [data-field].)
1930
+ await waitFor(
1931
+ () => expect(document.querySelector('.options-readfirst-more')).toBeTruthy(),
1932
+ { timeout: 10000 }
1933
+ );
1934
+ await _testsClickButton({ selector: '.options-readfirst-more' });
1935
+ let removeItem: Element | undefined;
1936
+ await waitFor(
1937
+ () => {
1938
+ removeItem = Array.from(document.querySelectorAll('.reqore-menu-item')).find((element) =>
1939
+ element.textContent?.includes('Remove field')
1940
+ );
1941
+ expect(removeItem).toBeTruthy();
1942
+ },
1943
+ { timeout: 10000 }
1807
1944
  );
1808
- await _testsWaitForText('Remove field');
1945
+ await fireEvent.click(removeItem as Element);
1809
1946
  await _testsClickButton({ label: 'Confirm' });
1810
- await _testsWaitForTextToNotExist('Notes');
1947
+ // Removing the field reverts it to a HIDDEN (addable) row in the still-open
1948
+ // Optional box (it's always browsable now, just no longer added) — and its
1949
+ // editor closes.
1950
+ await waitFor(() =>
1951
+ expect(document.querySelector('.readfirst-row-hidden[data-field="notes"]')).toBeTruthy()
1952
+ );
1811
1953
  },
1812
1954
  };
1813
1955
 
@@ -1847,6 +1989,31 @@ export const CompactDescriptionsToggle: Story = {
1847
1989
  // One toggle reveals the short_desc on every field that has one.
1848
1990
  await _testsWaitForText('The server hostname or IP address');
1849
1991
  await _testsWaitForText('TCP port to connect on');
1992
+
1993
+ // Regression: opening a field for INLINE editing must keep its description
1994
+ // visible while the global toggle is on — it used to vanish because the
1995
+ // inline editor's label dropped the short_desc.
1996
+ await _testsClickText('Host');
1997
+ await waitFor(
1998
+ () =>
1999
+ expect(
2000
+ document.querySelector(
2001
+ '.readfirst-row-editing[data-field="host"] .options-readfirst-label-desc'
2002
+ )
2003
+ ).toBeTruthy(),
2004
+ { timeout: 10000 }
2005
+ );
2006
+ await _testsWaitForText('The server hostname or IP address');
2007
+ // Collapse back to the read row (Done) before toggling descriptions off.
2008
+ await _testsClickButton({ selector: '[data-field="host"] .options-readfirst-done' });
2009
+ await waitFor(
2010
+ () =>
2011
+ expect(
2012
+ document.querySelector('.readfirst-row-editing[data-field="host"]')
2013
+ ).toBeFalsy(),
2014
+ { timeout: 10000 }
2015
+ );
2016
+
1850
2017
  // Toggling off hides them again.
1851
2018
  await _testsClickButton({ selector: '.options-readfirst-descriptions' });
1852
2019
  await _testsWaitForTextToNotExist('The server hostname or IP address');
@@ -1874,7 +2041,9 @@ export const CompactSearchHidden: Story = {
1874
2041
  value: 'notes',
1875
2042
  });
1876
2043
  await _testsWaitForText('Notes');
1877
- await _testsWaitForText('Not in form — add');
2044
+ await waitFor(() =>
2045
+ expect(document.querySelector('.readfirst-row-hidden[data-field="notes"]')).toBeTruthy()
2046
+ );
1878
2047
 
1879
2048
  // Rows are keyboard-operable (role=button + Enter): focusing the hidden row
1880
2049
  // and pressing Enter adds the field and opens its inline editor.
@@ -2928,29 +3097,17 @@ export const CompactFieldTypes: Story = {
2928
3097
  // fresh nodes per click.
2929
3098
  await _testsWaitForText('This value fails validation upstream.');
2930
3099
  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
- );
3100
+ // Dedicated schema messages render as panels, visible WITHOUT interaction
3101
+ // (the per-row ⓘ is gone — descriptions are revealed by the global toggle or
3102
+ // by expanding the field).
2941
3103
  await _testsWaitForText('Requests are signed automatically.');
2942
3104
  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
- );
3105
+ // The global descriptions toggle reveals each field's short_desc under its name.
3106
+ await fireEvent.click(document.querySelector('.options-readfirst-descriptions') as HTMLElement);
2951
3107
  await _testsWaitForText(
2952
3108
  'A one-line summary shown under the field name and in the hover title.'
2953
3109
  );
3110
+ // A field with a long desc still exposes the ? help affordance.
2954
3111
  await expect(
2955
3112
  document.querySelector('.readfirst-row[data-field="infoLongDesc"] .options-readfirst-help')
2956
3113
  ).toBeTruthy();
@@ -2988,7 +3145,10 @@ const _compactExpandAllRows = async () => {
2988
3145
  document.querySelectorAll<HTMLElement>(
2989
3146
  '.readfirst-row:not(.readfirst-row-editing):not(.readfirst-row-disabled):not(.readfirst-row-hidden)'
2990
3147
  )
2991
- );
3148
+ // An arg_schema field opens a NESTED compact sub-form (recursive compact);
3149
+ // its rows live inside the parent's edit card — don't count those as
3150
+ // top-level read rows to expand.
3151
+ ).filter((r) => !r.closest('.options-readfirst-card'));
2992
3152
  // Generous guard: the catalog has ~70 fields.
2993
3153
  for (let guard = 0; guard < 120; guard++) {
2994
3154
  const remaining = readRows();
@@ -3090,7 +3250,7 @@ export const CompactRequiredGroups: Story = {
3090
3250
  // contiguous members (byHost/byFile in Connection) cluster into a rail, which
3091
3251
  // carries the grouping in place of a chip; only the lone member (byUrl in
3092
3252
  // General) keeps a "One of" chip — so exactly one chip, not three.
3093
- await _testsWaitForTextsCount('Required not set', undefined, 3);
3253
+ await _testsWaitForTextsCount('—', undefined, 3);
3094
3254
  await _testsWaitForText('Draft');
3095
3255
  await _testsWaitForTextsCount('One of', undefined, 1);
3096
3256
 
@@ -3147,13 +3307,14 @@ export const CompactRequiredGroups: Story = {
3147
3307
  await _testsWaitForText('https://example.com');
3148
3308
 
3149
3309
  // 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.
3310
+ // Once satisfied: the filled member keeps a "Covers" chip; the empty siblings
3311
+ // show their "Covered by 'By URL'" note INLINE (not a chip), and no "One of"
3312
+ // remains. So exactly one required-group chip stays (the coverer's).
3152
3313
  await _testsWaitForText('Ready');
3153
3314
  await _testsWaitForTextsCount('Covered by “By URL”', undefined, 2);
3154
3315
  await _testsWaitForText('Covers');
3155
3316
  await _testsWaitForTextToNotExist('One of');
3156
- await expect(document.querySelectorAll('.options-readfirst-required-group')).toHaveLength(3);
3317
+ await expect(document.querySelectorAll('.options-readfirst-required-group')).toHaveLength(1);
3157
3318
  },
3158
3319
  };
3159
3320
 
@@ -3682,57 +3843,41 @@ export const CompactShowcase: Story = {
3682
3843
  ).toBeTruthy();
3683
3844
  await waitFor(() => {
3684
3845
  // 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).
3846
+ // --readfirst-stripe on the field's BLOCK root. Schema messages now render
3847
+ // inside the value cell, so the block root is the row itself.
3687
3848
  const intentRow = document.querySelector(
3688
- '.options-readfirst-info-row[data-field="chromeIntent"]'
3849
+ '.readfirst-row[data-field="chromeIntent"]'
3689
3850
  ) as HTMLElement;
3690
3851
  expect(intentRow?.style?.getPropertyValue('--readfirst-stripe')).toBeTruthy();
3691
3852
  });
3692
3853
  await _testsWaitForText('••••••');
3693
3854
  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.
3855
+ // The unmet auth one-of group (authToken/authCertFile) renders the
3856
+ // "One of the below is required" cluster box.
3697
3857
  await waitFor(() =>
3698
- expect(document.querySelectorAll('.options-readfirst-node').length).toBeGreaterThan(0)
3858
+ expect(document.querySelector('.options-readfirst-required-cluster')).toBeTruthy()
3699
3859
  );
3700
3860
 
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;
3861
+ // Schema message panels render inside the value cell of the row itself,
3862
+ // directly beneath the value.
3708
3863
  const infoPanel = (field: string) =>
3709
3864
  document.querySelector(
3710
- `.options-readfirst-info-row[data-field="${field}"] .options-readfirst-info-panel`
3865
+ `.readfirst-row[data-field="${field}"] .options-readfirst-info-panel`
3711
3866
  );
3712
3867
 
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'));
3868
+ // Default-value notes and validation/dependency hints now render as a compact
3869
+ // INLINE reason (no ⓘ, no panel) visible without any interaction.
3719
3870
  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).
3871
+ // Dedicated schema messages stay prominent PANELS, also always visible.
3724
3872
  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
3873
  await _testsWaitForText('v1 endpoints are deprecated — migrate to /v2 before 2026-09.');
3729
3874
 
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.
3875
+ // short_desc renders UNDER the field name when the global descriptions toggle
3876
+ // is engaged (the per-row is gone).
3732
3877
  const labelDesc = (field: string) =>
3733
3878
  document.querySelector(`.readfirst-row[data-field="${field}"] .options-readfirst-label-desc`);
3734
3879
  await expect(labelDesc('chromeIcon')).toBeNull();
3735
- await fireEvent.click(infoToggle('chromeIcon'));
3880
+ await fireEvent.click(document.querySelector('.options-readfirst-descriptions') as HTMLElement);
3736
3881
  await waitFor(() => expect(labelDesc('chromeIcon')).toBeTruthy());
3737
3882
  },
3738
3883
  };
@@ -3817,38 +3962,14 @@ export const CompactRequiredGroupRails: Story = {
3817
3962
  },
3818
3963
  play: async () => {
3819
3964
  await _testsWaitForText('API key');
3820
- // Members are clustered with a status node each; the two groups give 6 nodes.
3965
+ // The unmet `target` group (email/slack/webhook/sms, none set) renders the
3966
+ // "One of the below is required" cluster box.
3821
3967
  await waitFor(() =>
3822
- expect(document.querySelectorAll('.options-readfirst-node').length).toBe(6)
3968
+ expect(document.querySelector('.options-readfirst-required-cluster')).toBeTruthy()
3823
3969
  );
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
- });
3970
+ // The met `credential` group needs no box: apiKey satisfies it, so its empty
3971
+ // alternative oauthToken reads as covered by its sibling.
3972
+ await _testsWaitForText('Covered by “API key”');
3852
3973
  },
3853
3974
  };
3854
3975
 
@@ -3867,9 +3988,12 @@ export const CompactFieldSortWithinGroups: Story = {
3867
3988
  )
3868
3989
  ).map((row) => row.getAttribute('data-field'));
3869
3990
 
3870
- // Default = the schema's declared order.
3991
+ // Rows are bucketed into status boxes (Needs attention → Set → Optional);
3992
+ // within a box, schema order holds. The `target` group is unmet (attention),
3993
+ // the `credential` group is met by apiKey (set), so the attention members
3994
+ // come first, then the set members.
3871
3995
  await waitFor(() =>
3872
- expect(order()).toEqual(['apiKey', 'oauthToken', 'email', 'slack', 'webhook', 'sms'])
3996
+ expect(order()).toEqual(['email', 'slack', 'webhook', 'sms', 'apiKey', 'oauthToken'])
3873
3997
  );
3874
3998
 
3875
3999
  // Open the Fields menu, drill into the (collapsed) "Sort by" submenu, then
@@ -3887,13 +4011,13 @@ export const CompactFieldSortWithinGroups: Story = {
3887
4011
  await waitFor(() => expect(menuItem('Name A→Z')).toBeTruthy());
3888
4012
  await fireEvent.click(menuItem('Name A→Z') as HTMLElement);
3889
4013
 
3890
- // Sorted by display name inside each group; the two groups stay separate
3891
- // (Connection: apiKey/oauthToken | Notification: email/slack/sms/webhook).
4014
+ // Sorted by display name inside each group; the status boxes stay separate
4015
+ // (attention: email/slack/sms/webhook | set: apiKey/oauthToken).
3892
4016
  await waitFor(() =>
3893
- expect(order()).toEqual(['apiKey', 'oauthToken', 'email', 'slack', 'sms', 'webhook'])
4017
+ expect(order()).toEqual(['email', 'slack', 'sms', 'webhook', 'apiKey', 'oauthToken'])
3894
4018
  );
3895
- // The required-group clusters survive the re-sort: all 6 nodes + both rails.
3896
- await expect(document.querySelectorAll('.options-readfirst-node')).toHaveLength(6);
4019
+ // The required-group clusters survive the re-sort (both groups keep their
4020
+ // first-member marker, used to anchor the cluster box).
3897
4021
  await expect(document.querySelectorAll('.readfirst-cluster-first')).toHaveLength(2);
3898
4022
  },
3899
4023
  };