From 933201dc50316d73cc022228fd4a7162fbebea3b Mon Sep 17 00:00:00 2001 From: Victor Wiebe Date: Fri, 7 Aug 2026 10:58:16 -0400 Subject: [PATCH] Improve editor progressive disclosure --- CODEX.md | 3 + ROADMAP.md | 2 +- TESTING.md | 10 ++ .../ActionInspector.module.css | 95 ++++++++++++ .../ActionInspector/ActionInspector.test.tsx | 58 ++++++++ .../ActionInspector/ActionInspector.tsx | 137 ++++++++++++------ .../ActionInspector/RestActionEditor.tsx | 14 +- .../ActionInspector/SecretManager.test.tsx | 17 ++- .../ActionInspector/SecretManager.tsx | 17 ++- 9 files changed, 298 insertions(+), 55 deletions(-) diff --git a/CODEX.md b/CODEX.md index 2d6cf4f..650b9aa 100644 --- a/CODEX.md +++ b/CODEX.md @@ -326,3 +326,6 @@ Date: 2026-07-20 - Standard Compose is restored healthy with empty history/secrets/configuration after automated cleanup. No commit or push occurred. - On 2026-08-07, the user reported all six manual workflows pass. Stored-history and backend-log marker scans passed, the expected redaction and `SECRET_NOT_FOUND` evidence was present, and cleanup restored healthy standard Compose with empty executions, secrets, secret-key configuration, and internal-origin configuration. - The Actions & Bindings information-density feedback is tracked in `SLICE6a.md` as a separate UX follow-up. The user granted explicit final Slice 6 sign-off on 2026-08-07 with the exact wording: “I grant explicit final Slice 6 sign-off.” Slice 6 is complete. +- Slice 6a implementation and automation are complete through the manual gate: accessible collapsible sections/cards, compact record summaries, grouped REST editing, explicit secret creation, visible collapsed issue counts, and mounted-state preservation are implemented. Focused coverage passes at 18 tests; full frontend coverage passes at 25 suites / 520 tests; TypeScript and production build pass. `Slice 6a Density Manual` (`#22`) is prepared for `SLICE6a_MANUAL_TEST.md`. +- The user reported all Slice 6a manual UX tests pass and that the changes look good. Disposable project `#22` was deleted; backend health, frontend HTTP 200, empty secrets, and empty history pass. +- The user granted explicit final Slice 6a sign-off on 2026-08-07 with the exact wording: “I grant explicit final Slice 6a sign-off.” Slice 6a is complete. diff --git a/ROADMAP.md b/ROADMAP.md index 34f590e..877f2f2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,7 +23,7 @@ The slice files remain the detailed implementation plans. When an older list con | [Slice 4](SLICE4.md) | Proxy security and observability | Complete | None | | [Slice 5](SLICE5.md) | Validation and error handling | Complete | None | | [Slice 6](SLICE6.md) | Testing and release validation | Complete | None | -| [Slice 6a](SLICE6a.md) | Editor information density and progressive disclosure | Planned | Collapsible sections/cards and a less overwhelming REST-action editing experience | +| [Slice 6a](SLICE6a.md) | Editor information density and progressive disclosure | Complete | None | | [Slice 7](SLICE7.md) | MVP scope decision | Complete | Cross-document reconciliation is carried by Slices 6 and 8 | | [Slice 8](SLICE8.md) | Documentation and release packaging | Partial | Unified roadmap complete; broader documentation ownership, reconciliation, guides, and release packaging remain | diff --git a/TESTING.md b/TESTING.md index 33d7f01..0ef32d3 100644 --- a/TESTING.md +++ b/TESTING.md @@ -73,6 +73,16 @@ Automation and human acceptance remain distinct. `SLICE6_MANUAL_TEST.md`, cleanu The user granted explicit final Slice 5 sign-off on 2026-08-04 with the wording: “Confirm slice5 signoff.” Slice 5 is complete. +## Slice 6a Progressive Disclosure Gate — 2026-08-07 + +Actions & Bindings now uses accessible collapsible major sections and independently collapsible action, binding, variable, and secret cards. REST editing is grouped into Identity, Endpoint & authentication, Request parameters, and Request body; the denser request groups start closed. Secret creation is explicit rather than exposing its full form whenever the section opens. Section hiding preserves mounted local editor state, and collapsed issue badges lead to expanded diagnostics. + +Focused ActionInspector/SecretManager coverage passes at 18 tests. Full frontend coverage passes at 25 suites / 520 tests, standalone TypeScript passes, and the production build succeeds. Standard Compose was rebuilt successfully. A disposable high-density project, `Slice 6a Density Manual` (`#22`), is prepared for the proportional procedure in `SLICE6a_MANUAL_TEST.md`. Manual acceptance, cleanup, and explicit sign-off remain pending. + +The user subsequently reported all five manual UX tests pass and that the changes look good. Cleanup deleted project `#22`, and final standard-stack checks returned backend health `ok`, frontend HTTP 200, empty secrets, and empty execution history. + +The user granted explicit final Slice 6a sign-off on 2026-08-07 with the exact wording: “I grant explicit final Slice 6a sign-off.” Slice 6a is complete. + ## Slice 4 Manual Gate The five workflows in `SLICE4.md` passed on 2026-08-02 after focused remediation and repetition. Initial testing passed approved destinations, controls/recovery, and execution history; mixed-answer/rebinding DNS checks were initially unavailable, and private destination URLs in backend failure logs failed redaction. The logger was reduced to safe structured metadata. A disposable authoritative DNS fixture then proved mixed public/loopback answers fail closed and rebinding cannot bypass pinned validated addresses. The focused logging retest found zero private literals or raw transport errors. IPv6 literal classification was also corrected and retested. All disposable secrets, history, fixtures, networks, keys, and exceptions were removed. This is manual evidence separate from automation. diff --git a/frontend/src/components/ActionInspector/ActionInspector.module.css b/frontend/src/components/ActionInspector/ActionInspector.module.css index 64cae48..07623c5 100644 --- a/frontend/src/components/ActionInspector/ActionInspector.module.css +++ b/frontend/src/components/ActionInspector/ActionInspector.module.css @@ -47,6 +47,48 @@ border-bottom: 1px solid #e5e7eb; } +.sectionToggle, +.cardToggle { + appearance: none; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.sectionToggle { + display: flex; + align-items: center; + gap: 9px; + min-width: 0; + padding: 2px 0; +} + +.sectionToggle > span:last-child { + display: flex; + flex-direction: column; +} + +.sectionToggle:focus-visible, +.cardToggle:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 3px; + border-radius: 4px; +} + +.sectionBody { + min-width: 0; +} + +.chevron { + color: #57606a; + font-size: 14px; + line-height: 1; + flex-shrink: 0; +} + .sectionTitle { font-size: 13px; font-weight: 600; @@ -89,6 +131,48 @@ gap: 8px; } +.cardToggle { + display: flex; + flex-direction: column; + gap: 7px; + width: 100%; + padding: 0; +} + +.toggleHeadingRow, +.toggleStatus { + display: flex; + align-items: center; +} + +.toggleHeadingRow { + width: 100%; + justify-content: space-between; + gap: 12px; +} + +.toggleStatus { + gap: 8px; +} + +.issueBadge { + padding: 2px 7px; + border: 1px solid #fde68a; + border-radius: 999px; + background: #fffbeb; + color: #92400e; + font-size: 10px; + font-weight: 600; +} + +.cardDetails { + display: flex; + flex-direction: column; + gap: 10px; + padding-top: 10px; + border-top: 1px solid #e5e7eb; +} + .recordList { display: flex; flex-direction: column; @@ -263,6 +347,17 @@ gap: 8px; } +.bindingCompactFlow { + display: block; + overflow: hidden; + color: #57606a; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; +} + .bindingCardHeader { display: flex; align-items: center; diff --git a/frontend/src/components/ActionInspector/ActionInspector.test.tsx b/frontend/src/components/ActionInspector/ActionInspector.test.tsx index aa3b968..85ac474 100644 --- a/frontend/src/components/ActionInspector/ActionInspector.test.tsx +++ b/frontend/src/components/ActionInspector/ActionInspector.test.tsx @@ -200,9 +200,59 @@ describe('Actions & Bindings visual REST action authoring', () => { 'Routes successful action responses into components or runtime variables.', ); expect(container.textContent).toContain('REST action'); + click(container.querySelector('button[aria-label="Expand Response Bindings section"]')); expect(container.textContent).toContain('Response binding'); }); + test('uses keyboard-accessible collapsed summaries and preserves local edits across section toggles', () => { + act(() => { + root.render(); + }); + + const actionsSection = container.querySelector('button[aria-label="Collapse REST Actions section"]'); + const actionToggle = container.querySelector('button[aria-label="Expand REST action Lookup"]'); + const bindingsSection = container.querySelector('button[aria-label="Expand Response Bindings section"]'); + expect(actionsSection?.tagName).toBe('BUTTON'); + expect(actionsSection?.getAttribute('aria-expanded')).toBe('true'); + expect(actionToggle?.getAttribute('aria-expanded')).toBe('false'); + expect(bindingsSection?.getAttribute('aria-expanded')).toBe('false'); + expect(container.querySelector('button[aria-label="Edit REST action Lookup"]')).toBeNull(); + + actionToggle?.focus(); + expect(document.activeElement).toBe(actionToggle); + click(actionToggle); + click(container.querySelector('button[aria-label="Edit REST action Lookup"]')); + + const groups = Array.from(container.querySelectorAll('[data-testid="rest-action-editor-action_lookup"] details')); + expect(groups.map((group) => [group.querySelector('summary')?.textContent, group.open])).toEqual([ + ['Identity', true], + ['Endpoint & authentication', true], + ['Request parameters', false], + ['Request body', false], + ]); + + const nameInput = container.querySelector('[data-testid="action-name"]'); + setControlValue(nameInput, ''); + expect(nameInput?.value).toBe(''); + expect(currentDocument(container).project.actions[0].name).toBe('Lookup'); + + click(container.querySelector('button[aria-label="Collapse REST Actions section"]')); + click(container.querySelector('button[aria-label="Expand REST Actions section"]')); + expect(container.querySelector('[data-testid="action-name"]')?.value).toBe(''); + expect(currentDocument(container).project.actions[0].name).toBe('Lookup'); + }); + + test('keeps issue status visible on a collapsed card and reveals the diagnostic on expansion', () => { + act(() => { + root.render(); + }); + const toggle = container.querySelector('button[aria-label="Expand REST action Lookup"]'); + expect(toggle?.textContent).toContain('1 issue'); + expect(container.textContent).not.toContain('Action "Lookup" is not triggered by any component or page event.'); + click(toggle); + expect(container.textContent).toContain('Action "Lookup" is not triggered by any component or page event.'); + }); + test('suggests canonical request references and common header names while preserving custom headers', () => { act(() => { root.render( @@ -213,6 +263,7 @@ describe('Actions & Bindings visual REST action authoring', () => { , ); }); + click(container.querySelector('button[aria-label="Expand REST action Lookup"]')); click(container.querySelector('button[aria-label="Edit REST action Lookup"]')); setControlValue( @@ -371,6 +422,7 @@ describe('Actions & Bindings visual REST action authoring', () => { click(container.querySelector('[data-testid="add-response-binding"]')); setControlValue(container.querySelector('select[aria-label="Binding target"]'), 'variables.capturedId'); const confirm = jest.spyOn(window, 'confirm').mockReturnValue(false); + click(container.querySelector('button[aria-label="Expand variable capturedId"]')); click(container.querySelector('button[aria-label="Delete variable capturedId"]')); expect(confirm).toHaveBeenCalledWith(expect.stringContaining('binding "binding_response_1"')); expect(currentDocument(container).project.variables.capturedId).toBeDefined(); @@ -394,6 +446,8 @@ describe('Actions & Bindings visual REST action authoring', () => { act(() => { root.render(); }); + click(container.querySelector('button[aria-label="Expand Response Bindings section"]')); + click(container.querySelector('button[aria-label="Expand binding binding_legacy"]')); click(container.querySelector('button[aria-label="Edit binding binding_legacy"]')); const trigger = container.querySelector('select[aria-label="Binding trigger"]'); expect(Array.from(trigger?.options ?? []).map((option) => option.value)).toEqual(['onSuccess', 'onClick']); @@ -421,6 +475,10 @@ describe('Actions & Bindings visual REST action authoring', () => { root.render(); }); + click(container.querySelector('button[aria-label="Expand REST action Lookup"]')); + click(container.querySelector('button[aria-label="Expand Response Bindings section"]')); + click(container.querySelector('button[aria-label="Expand binding binding_lookup"]')); + expect(container.textContent).toContain( 'Action "Lookup" is not triggered by any component or page event.', ); diff --git a/frontend/src/components/ActionInspector/ActionInspector.tsx b/frontend/src/components/ActionInspector/ActionInspector.tsx index d3a639f..9183449 100644 --- a/frontend/src/components/ActionInspector/ActionInspector.tsx +++ b/frontend/src/components/ActionInspector/ActionInspector.tsx @@ -594,6 +594,8 @@ type ActionCardProps = { allComponents: CanvasComponent[]; isEditing: boolean; actionsLocked: boolean; + expanded: boolean; + onToggle: () => void; onEdit: () => void; onDuplicate: () => void; onDelete: () => void; @@ -605,6 +607,8 @@ function ActionCard({ allComponents, isEditing, actionsLocked, + expanded, + onToggle, onEdit, onDuplicate, onDelete, @@ -651,28 +655,27 @@ function ActionCard({ return (
-
REST action
- {/* ── Header: method + name + id ── */} -
- - {action.method} +
- - {/* ── URL ── */} -
{action.url}
- - {/* ── Auth + description ── */} -
- - {AUTH_LABELS[action.authenticationType] ?? action.authenticationType} + + {action.method} + {action.name} + {action.id} - {action.description && ( - {action.description} - )} -
+ {action.url} + + {AUTH_LABELS[action.authenticationType] ?? action.authenticationType} + + + + {expanded &&
+ {action.description && {action.description}} {/* ── Template references (Step 16.5) ── */} {hasTemplates && ( @@ -780,6 +783,7 @@ function ActionCard({
)}
+ } ); } @@ -791,6 +795,8 @@ type BindingCardProps = { allComponents: CanvasComponent[]; allActions: RestAction[]; diags: Diagnostic[]; + expanded: boolean; + onToggle: () => void; onEdit: () => void; onDelete: () => void; }; @@ -800,6 +806,8 @@ function BindingCard({ allComponents, allActions, diags, + expanded, + onToggle, onEdit, onDelete, }: BindingCardProps): React.ReactElement { @@ -822,12 +830,19 @@ function BindingCard({ return (
-
{sourceActionId ? 'Response binding' : 'Component binding'}
- {/* ── Header: id + trigger ── */} -
- {binding.id} - {trigger} -
+ + + {expanded &&
{/* ── Source / Target flow ── */}
@@ -883,6 +898,7 @@ function BindingCard({
+
}
); } @@ -903,6 +919,18 @@ function ActionInspector(): React.ReactElement { const [editingVariableName, setEditingVariableName] = useState(null); const [addingVariable, setAddingVariable] = useState(false); const [secrets, setSecrets] = useState([]); + const [openSections, setOpenSections] = useState(() => new Set(['actions'])); + const [openActions, setOpenActions] = useState>(() => new Set()); + const [openBindings, setOpenBindings] = useState>(() => new Set()); + const [openVariables, setOpenVariables] = useState>(() => new Set()); + + const toggleOpen = useCallback((setter: React.Dispatch>>, key: string) => { + setter((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); else next.add(key); + return next; + }); + }, []); const refreshSecrets = useCallback(async () => { try { setSecrets(await listSecrets()); } catch { setSecrets([]); } @@ -932,6 +960,7 @@ function ActionInspector(): React.ReactElement { ); setDoc((current) => appendRestAction(current, action)); setEditingActionIndex(actions.length); + setOpenActions((current) => new Set(current).add(`${action.id}-${actions.length}`)); }, [actions, doc, setDoc]); const handleUpdateAction = useCallback( @@ -950,6 +979,7 @@ function ActionInspector(): React.ReactElement { ); setDoc((current) => appendRestAction(current, duplicate)); setEditingActionIndex(actions.length); + setOpenActions((current) => new Set(current).add(`${duplicate.id}-${actions.length}`)); }, [actions, doc, setDoc], ); @@ -978,6 +1008,7 @@ function ActionInspector(): React.ReactElement { const binding = createResponseBinding(bindings, actions[0], targetOptions[0].value); setDoc((current) => ({ ...current, project: { ...current.project, bindings: [...current.project.bindings, binding] } })); setEditingBindingIndex(bindings.length); + setOpenBindings((current) => new Set(current).add(`${binding.id}-${bindings.length}`)); }, [actions, bindings, setDoc, targetOptions]); const handleDeleteVariable = useCallback((name: string) => { @@ -1036,17 +1067,17 @@ function ActionInspector(): React.ReactElement { {/* ══ Actions section ══════════════════════════════════════════ */}
-
-
REST Actions
-
Requests sent through the backend proxy.
-
+
{actions.length}
+
{/* ══ Bindings section ═════════════════════════════════════════ */}
-
-
Response Bindings
-
Routes successful action responses into components or runtime variables.
-
+
{bindings.length} - +
+ )} +
+ Identity +
+
+
+
+ Endpoint & authentication +
)} +
+
-
+
Request parameters
-
+
Request body
diff --git a/frontend/src/components/ActionInspector/SecretManager.test.tsx b/frontend/src/components/ActionInspector/SecretManager.test.tsx index 7246c0a..280543d 100644 --- a/frontend/src/components/ActionInspector/SecretManager.test.tsx +++ b/frontend/src/components/ActionInspector/SecretManager.test.tsx @@ -18,14 +18,27 @@ describe('SecretManager', () => { beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); }); afterEach(() => { act(() => root.unmount()); container.remove(); jest.resetAllMocks(); }); + test('starts with compact section and card summaries that expose expansion state', async () => { + await act(async () => { root.render(); }); + const section = container.querySelector('button[aria-label="Expand Secrets section"]'); + expect(section?.getAttribute('aria-expanded')).toBe('false'); + await act(async () => { section!.click(); }); + const card = container.querySelector('button[aria-label="Expand secret Production token"]'); + expect(card?.getAttribute('aria-expanded')).toBe('false'); + expect(container.textContent).not.toContain('Replace value'); + await act(async () => { card!.click(); }); + expect(container.textContent).toContain('Replace value'); + }); + test('submits a masked credential and refreshes metadata without displaying its value', async () => { mockedApi.createSecret.mockResolvedValue(metadata); const changed = jest.fn().mockResolvedValue(undefined); await act(async () => { root.render(); }); + await act(async () => { Array.from(container.querySelectorAll('button')).find((button) => button.textContent === '+ Add secret')!.click(); }); const inputs = container.querySelectorAll('input'); setInput(inputs[0], 'Production token'); setInput(inputs[1], 'stored-token-value'); expect(inputs[1].type).toBe('password'); - await act(async () => { container.querySelector('button')!.click(); }); + await act(async () => { Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Add secret')!.click(); }); expect(mockedApi.createSecret).toHaveBeenCalledWith('Production token', 'bearerToken', { token: 'stored-token-value' }); expect(changed).toHaveBeenCalled(); expect(container.textContent).not.toContain('stored-token-value'); @@ -33,6 +46,8 @@ describe('SecretManager', () => { test('blocks deletion when an unsaved action references the secret', async () => { await act(async () => { root.render(); }); + await act(async () => { container.querySelector('button[aria-label="Expand Secrets section"]')!.click(); }); + await act(async () => { container.querySelector('button[aria-label="Expand secret Production token"]')!.click(); }); const deleteButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Delete')!; await act(async () => { deleteButton.click(); }); expect(mockedApi.deleteSecret).not.toHaveBeenCalled(); diff --git a/frontend/src/components/ActionInspector/SecretManager.tsx b/frontend/src/components/ActionInspector/SecretManager.tsx index f52955b..251eb33 100644 --- a/frontend/src/components/ActionInspector/SecretManager.tsx +++ b/frontend/src/components/ActionInspector/SecretManager.tsx @@ -11,11 +11,14 @@ const types: Array<{ value: CredentialType; label: string }> = [ ]; export default function SecretManager({ secrets, actions, onChanged }: Props): React.ReactElement { + const [sectionOpen, setSectionOpen] = useState(false); + const [openSecrets, setOpenSecrets] = useState>(() => new Set()); + const [adding, setAdding] = useState(false); const [editing, setEditing] = useState(null); const [name, setName] = useState(''); const [type, setType] = useState('bearerToken'); const [first, setFirst] = useState(''); const [second, setSecond] = useState(''); const [error, setError] = useState(''); const [busy, setBusy] = useState(false); - const reset = () => { setEditing(null); setName(''); setFirst(''); setSecond(''); setError(''); }; + const reset = () => { setEditing(null); setAdding(false); setName(''); setFirst(''); setSecond(''); setError(''); }; const value = (): SecretValue => type === 'basicAuth' ? { username: first, password: second } : type === 'bearerToken' ? { token: first } : { parameterName: first, value: second }; const save = async () => { setBusy(true); setError(''); @@ -28,13 +31,17 @@ export default function SecretManager({ secrets, actions, onChanged }: Props): R if (!window.confirm(`Delete secret “${secret.name}”? This cannot be undone.`)) return; try { await api.deleteSecret(secret.id); await onChanged(); setError(''); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } }; + const toggleSecret = (id: string) => setOpenSecrets((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; }); return
-
Secrets
Encrypted server-side credentials. Stored values are never displayed after submission.
{secrets.length}
- {secrets.map((secret) =>
{secret.name} {types.find((item) => item.value === secret.authenticationType)?.label}
{secret.id}
)} -
+
{secrets.length}
+
; }