diff --git a/.github/workflows/hr-workspace-export-delivery-state.yml b/.github/workflows/hr-workspace-export-delivery-state.yml new file mode 100644 index 000000000..4562bd42c --- /dev/null +++ b/.github/workflows/hr-workspace-export-delivery-state.yml @@ -0,0 +1,55 @@ +name: HR Workspace Export Delivery State Quality + +on: + pull_request: + branches: + - develop + - feat/hr-workspace-protected-read-state + paths: + - "apps/hr-workspace/hr-export-delivery-state.js" + - "apps/hr-workspace/hr-export-delivery-state.css" + - "apps/hr-workspace/hr-export-delivery-state.stories.js" + - "tests/hr-workspace-export-delivery-state.test.mjs" + - "docs/traceability/hr-workspace-export-delivery-state.md" + - "docs/doctoring/hr-workspace-export-delivery-accessibility-references.md" + - ".github/workflows/hr-workspace-export-delivery-state.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: hr-workspace-export-delivery-state-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + export-delivery-state: + name: One-time export delivery interaction contract + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout exact candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - name: Prove exact candidate checkout + env: + ORGMETRA_EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: test "$(git rev-parse HEAD)" = "$ORGMETRA_EXPECTED_HEAD_SHA" + - name: Set up Node.js LTS + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "24" + check-latest: false + - name: Run one-time export interaction contract with exact coverage + run: >- + node --test --experimental-test-coverage + --test-coverage-lines=100 + --test-coverage-branches=100 + --test-coverage-functions=100 + tests/hr-workspace-export-delivery-state.test.mjs + - name: Require clean checkout + run: | + git diff --exit-code + test -z "$(git status --porcelain)" diff --git a/apps/hr-workspace/hr-export-delivery-state.css b/apps/hr-workspace/hr-export-delivery-state.css new file mode 100644 index 000000000..d9961b8c3 --- /dev/null +++ b/apps/hr-workspace/hr-export-delivery-state.css @@ -0,0 +1,69 @@ +.hr-export-delivery-state { + display: grid; + gap: var(--orgmetra-space-md); + max-width: 40rem; + padding: var(--orgmetra-space-lg); + border: 1px solid var(--orgmetra-border-subtle); + border-radius: var(--orgmetra-radius-md); + background: var(--orgmetra-surface-card); + color: var(--orgmetra-text-primary); +} + +.hr-export-status, +.hr-export-next-action { + display: grid; + gap: var(--orgmetra-space-xs); + margin: 0; +} + +.hr-export-status span, +.hr-export-next-action span { + color: var(--orgmetra-text-muted); +} + +.hr-export-delivery-state[data-interaction-state="high-risk-confirmation"] { + border-color: var(--orgmetra-action-review); +} + +.hr-export-delivery-state[data-interaction-state="permission-denied"], +.hr-export-delivery-state[data-interaction-state="error"] { + border-color: var(--orgmetra-danger); +} + +.hr-export-actions { + display: flex; + flex-wrap: wrap; + gap: var(--orgmetra-space-sm); +} + +.hr-export-confirm, +.hr-export-publish { + min-height: 44px; + padding: var(--orgmetra-space-sm) var(--orgmetra-space-md); + border: 0; + border-radius: var(--orgmetra-radius-sm); + font: inherit; + cursor: pointer; +} + +.hr-export-confirm { + background: var(--orgmetra-surface-page); + color: var(--orgmetra-text-primary); +} + +.hr-export-publish { + background: var(--orgmetra-action-review); + color: #fff; +} + +.hr-export-confirm:disabled, +.hr-export-publish:disabled { + cursor: not-allowed; + opacity: 0.62; +} + +.hr-export-confirm:focus-visible, +.hr-export-publish:focus-visible { + outline: 3px solid var(--orgmetra-focus-ring); + outline-offset: 3px; +} diff --git a/apps/hr-workspace/hr-export-delivery-state.js b/apps/hr-workspace/hr-export-delivery-state.js new file mode 100644 index 000000000..e6ae5c0d7 --- /dev/null +++ b/apps/hr-workspace/hr-export-delivery-state.js @@ -0,0 +1,101 @@ +const EXPORT_DELIVERY_STATES = Object.freeze({ + review: Object.freeze({ + ariaBusy: 'false', + ariaLive: 'polite', + role: 'status', + confirmDisabled: false, + publishDisabled: true, + interactionState: 'high-risk-confirmation', + label: 'Review one-time HR export', + message: 'Confirm the approved purpose, field scope, destination class, and reviewed evidence before any delivery attempt.', + nextAction: 'Confirm the reviewed scope to prepare one audited one-time delivery attempt.', + }), + ready: Object.freeze({ + ariaBusy: 'false', + ariaLive: 'polite', + role: 'status', + confirmDisabled: true, + publishDisabled: false, + interactionState: 'default', + label: 'Reviewed export scope confirmed', + message: 'UI confirmation is complete. The backend must still revalidate authorization and reviewed export evidence before delivery.', + nextAction: 'Start one audited one-time delivery attempt.', + }), + publishing: Object.freeze({ + ariaBusy: 'true', + ariaLive: 'polite', + role: 'status', + confirmDisabled: true, + publishDisabled: true, + interactionState: 'loading', + label: 'One-time HR export delivery in progress', + message: 'Orgmetra is waiting for the authoritative delivery result. A second delivery attempt is disabled.', + nextAction: 'Wait for the current one-time delivery attempt to finish.', + }), + delivered: Object.freeze({ + ariaBusy: 'false', + ariaLive: 'polite', + role: 'status', + confirmDisabled: true, + publishDisabled: true, + interactionState: 'read-only', + label: 'One-time HR export delivered', + message: 'Delivery evidence is read-only. The completed export must not be published again.', + nextAction: 'Review the immutable delivery receipt. Do not send the export again.', + }), + indeterminate: Object.freeze({ + ariaBusy: 'false', + ariaLive: 'assertive', + role: 'alert', + confirmDisabled: true, + publishDisabled: true, + interactionState: 'error', + label: 'Delivery outcome needs reconciliation', + message: 'The delivery may already have completed. Automatic republication is disabled while the existing delivery reference is reconciled.', + nextAction: 'Do not send again. Reconcile the existing delivery reference before any further action.', + }), + denied: Object.freeze({ + ariaBusy: 'false', + ariaLive: 'assertive', + role: 'alert', + confirmDisabled: true, + publishDisabled: true, + interactionState: 'permission-denied', + label: 'HR export delivery not authorized', + message: 'The reviewed purpose or authorization is not sufficient for this one-time export delivery.', + nextAction: 'Resolve purpose-bound authorization and start a new reviewed export only after approval.', + }), +}); + +function requireExactExportDeliveryState(state) { + if (typeof state !== 'string') { + throw new TypeError('HR export delivery state must be an exact built-in string'); + } + if (!Object.hasOwn(EXPORT_DELIVERY_STATES, state)) { + throw new TypeError(`unsupported HR export delivery state: ${state}`); + } + return EXPORT_DELIVERY_STATES[state]; +} + +/** + * Return value-minimized interaction semantics for one governed HR export delivery state. + * The model contains no HR payload values, credentials, identifiers, or delivery secrets. + * @param {string} state Governed export delivery state. + * @returns {Readonly} Immutable accessibility and next-action semantics. + */ +export function exportDeliveryViewModel(state) { + return requireExactExportDeliveryState(state); +} + +/** + * Render one Storybook proof for the one-time HR export delivery interaction. + * Only constant governed copy is emitted; caller-controlled HR values are not accepted. + * @param {string} state Governed export delivery state. + * @returns {string} Static HTML for the existing HR Workspace Storybook fixture. + */ +export function exportDeliveryStateMarkup(state) { + const model = requireExactExportDeliveryState(state); + const confirmDisabled = model.confirmDisabled ? ' disabled' : ''; + const publishDisabled = model.publishDisabled ? ' disabled' : ''; + return `
\n

${model.label}${model.message}

\n

Next action${model.nextAction}

\n
\n \n \n
\n
`; +} diff --git a/apps/hr-workspace/hr-export-delivery-state.stories.js b/apps/hr-workspace/hr-export-delivery-state.stories.js new file mode 100644 index 000000000..20fa3f76a --- /dev/null +++ b/apps/hr-workspace/hr-export-delivery-state.stories.js @@ -0,0 +1,13 @@ +import './hr-export-delivery-state.css'; +import { exportDeliveryStateMarkup } from './hr-export-delivery-state.js'; + +export default { + title: 'HR Workspace/One-time HR export delivery', +}; + +export const ReviewRequired = () => exportDeliveryStateMarkup('review'); +export const ConfirmedReady = () => exportDeliveryStateMarkup('ready'); +export const Publishing = () => exportDeliveryStateMarkup('publishing'); +export const DeliveredReadOnly = () => exportDeliveryStateMarkup('delivered'); +export const DeliveryIndeterminate = () => exportDeliveryStateMarkup('indeterminate'); +export const PermissionDenied = () => exportDeliveryStateMarkup('denied'); diff --git a/docs/doctoring/hr-workspace-export-delivery-accessibility-references.md b/docs/doctoring/hr-workspace-export-delivery-accessibility-references.md new file mode 100644 index 000000000..244b26e9f --- /dev/null +++ b/docs/doctoring/hr-workspace-export-delivery-accessibility-references.md @@ -0,0 +1,20 @@ +# HR Workspace one-time export delivery accessibility references + +Status: active-PR research evidence for the dependency-first HR Workspace export-delivery interaction slice. This note does not claim WCAG conformance, legal compliance, production deployment, or backend authorization. + +## Design evidence reviewed + +- Existing Figma `Orgmetra Baseline` file `xu1ZK1zmtFcDep95R8oE9O`, Storybook Inventory node `1:64`, reviewed read-only on 2026-08-27. The published inventory explicitly requires `default / hover / focus / disabled / loading / validation-error / read-only / high-risk-confirmation` states. This slice reuses that requirement and does not create new Figma geometry. +- Parent PR #130 owns shared protected-read loading/disabled/error/read-only/focus semantics and Orgmetra design-token usage. This child adds only one-time HR export-specific high-risk confirmation, delivered-receipt, and do-not-republish ambiguity behavior. + +## Standards implications + +WCAG 2.2 is a W3C Recommendation. The interaction proof is designed around keyboard-operable controls with visible `:focus-visible` treatment, clear labels/instructions, explicit error/denial next actions, and programmatically exposed status changes. The high-risk export flow deliberately keeps the delivery action disabled until a distinct confirmation state, and terminal delivery/indeterminate states keep republish disabled. + +WAI-ARIA 1.2 is a W3C Recommendation used here only for interaction semantics. Non-urgent progress/read-only changes use `role=status` with polite live announcements; authorization denial and ambiguous delivery outcomes use `role=alert` with assertive announcements. `aria-busy=true` is reserved for the in-progress delivery state. These semantics do not substitute for backend authorization or immutable audit evidence. + +## APA 7 references + +World Wide Web Consortium. (2024, December 12). *Web Content Accessibility Guidelines (WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/ + +World Wide Web Consortium. (2023, June 6). *Accessible Rich Internet Applications (WAI-ARIA) 1.2* (W3C Recommendation). https://www.w3.org/TR/wai-aria-1.2/ diff --git a/docs/traceability/hr-workspace-export-delivery-state.md b/docs/traceability/hr-workspace-export-delivery-state.md new file mode 100644 index 000000000..1ef1f2ff1 --- /dev/null +++ b/docs/traceability/hr-workspace-export-delivery-state.md @@ -0,0 +1,47 @@ +# HR Workspace one-time export delivery interaction traceability + +## Status + +Active PR only. This interaction evidence is not shipped default-branch product truth and is not an authorization or release artifact. + +Technical dependency chain: #53 → #130 → this child. Semantic export-owner dependencies remain #75 (governed export review) and #120 (audited one-time delivery). Their checks, reviews, backend authority, and persistence evidence do not transfer into this UI branch. + +## Buyer risk closed + +A one-time HR export is a sensitive, consequential data-egress operation. The UI must not make review, delivery, retry, or reconciliation look interchangeable. This slice makes the next safe action explicit without embedding protected HR values or credentials in component-state evidence. + +| State | Interaction evidence | Safe next action | +|---|---|---| +| `review` | Figma-required high-risk confirmation; delivery disabled | Confirm the reviewed purpose/scope/destination evidence | +| `ready` | UI confirmation complete; confirmation locked; delivery handoff enabled | Start one audited one-time delivery attempt; backend still revalidates authority | +| `publishing` | `aria-busy=true`; confirmation and delivery disabled | Wait; do not duplicate the send | +| `delivered` | Read-only terminal receipt state; delivery disabled | Review immutable receipt; do not republish | +| `indeterminate` | Assertive error state; delivery disabled | Do not send again; reconcile the existing delivery reference | +| `denied` | Assertive authorization-denied state; delivery disabled | Resolve purpose-bound authorization and begin a newly reviewed export only after approval | + +## Privacy and authority boundary + +`hr-export-delivery-state.js` accepts only the governed state name and emits constant copy. It does not accept HR values, Person/Employment identifiers, document content, destination addresses, credentials, tokens, compensation, ratings, or model output. Storybook evidence therefore cannot itself exfiltrate protected HR payloads. + +The `ready` UI state is not delivery authorization. The authoritative export service must still re-resolve the exact reviewed export packet, tenant/purpose/field scope, authorization freshness, destination class, immutable audit/outbox correlation, and one-time publication semantics defined by the export owner boundaries. An ambiguous external outcome is reconciliation-only; the UI deliberately has no republish action in `indeterminate`. + +## Accessibility / Product Design mapping + +Figma `Orgmetra Baseline` node `1:64` requires high-risk confirmation, loading, disabled, read-only, error, and focus states. Parent #130 owns the shared pattern. This child reuses existing Orgmetra spacing/surface/action/focus tokens and adds only workflow-specific semantics. Focusable controls use the existing `--orgmetra-focus-ring`; busy/error status is conveyed programmatically rather than by color alone. + +The dedicated Storybook states are `ReviewRequired`, `ConfirmedReady`, `Publishing`, `DeliveredReadOnly`, `DeliveryIndeterminate`, and `PermissionDenied`. + +## Verification + +`tests/hr-workspace-export-delivery-state.test.mjs` requires: + +- high-risk confirmation before delivery; +- exactly one enabled delivery handoff state; +- duplicate confirmation/delivery prevention during publishing; +- no republish action after delivered or indeterminate outcomes; +- explicit customer next actions for denial and ambiguity; +- no protected-value vocabulary in state evidence; +- exact built-in state input semantics; and +- Storybook/Figma correlation, tokenized focus treatment, and exact 100% line/branch/function coverage in the dedicated workflow. + +After the technical parent integrates, this child must be retargeted to fresh `develop`. Before representing the interaction as commercial product truth, also refetch the current #75/#120 export contracts and rerun every applicable browser/accessibility/Foundation/SAST/Security/Recovery/central workflow on the resulting exact head. Predecessor or parent evidence never transfers. diff --git a/tests/hr-workspace-export-delivery-state.test.mjs b/tests/hr-workspace-export-delivery-state.test.mjs new file mode 100644 index 000000000..cfd7abe44 --- /dev/null +++ b/tests/hr-workspace-export-delivery-state.test.mjs @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { + exportDeliveryStateMarkup, + exportDeliveryViewModel, +} from '../apps/hr-workspace/hr-export-delivery-state.js'; + +const story = readFileSync( + new URL('../apps/hr-workspace/hr-export-delivery-state.stories.js', import.meta.url), + 'utf8', +); +const css = readFileSync( + new URL('../apps/hr-workspace/hr-export-delivery-state.css', import.meta.url), + 'utf8', +); + +test('review state requires explicit high-risk confirmation before one-time delivery', () => { + const review = exportDeliveryViewModel('review'); + assert.equal(review.interactionState, 'high-risk-confirmation'); + assert.equal(review.ariaBusy, 'false'); + assert.equal(review.confirmDisabled, false); + assert.equal(review.publishDisabled, true); + assert.equal(review.role, 'status'); + assert.match(review.nextAction, /Confirm the reviewed scope/); + + const markup = exportDeliveryStateMarkup('review'); + assert.match(markup, /data-figma-node-id="1:64"/); + assert.match(markup, /data-interaction-state="high-risk-confirmation"/); + assert.match(markup, /class="hr-export-confirm"[^>]*>Confirm reviewed scope/); + assert.match(markup, /class="hr-export-publish"[^>]* disabled/); +}); + +test('confirmed state enables exactly one backend handoff while keeping confirmation locked', () => { + const ready = exportDeliveryViewModel('ready'); + assert.equal(ready.interactionState, 'default'); + assert.equal(ready.ariaBusy, 'false'); + assert.equal(ready.confirmDisabled, true); + assert.equal(ready.publishDisabled, false); + assert.match(ready.message, /backend must still revalidate authorization/i); + assert.equal(ready.nextAction, 'Start one audited one-time delivery attempt.'); + + const markup = exportDeliveryStateMarkup('ready'); + assert.match(markup, /class="hr-export-confirm"[^>]* disabled/); + assert.match(markup, /class="hr-export-publish"[^>]*>Start one-time delivery/); + assert.doesNotMatch(markup, /class="hr-export-publish"[^>]* disabled/); +}); + +test('publishing is perceivable and prevents duplicate confirmation or delivery', () => { + const publishing = exportDeliveryViewModel('publishing'); + assert.equal(publishing.ariaBusy, 'true'); + assert.equal(publishing.confirmDisabled, true); + assert.equal(publishing.publishDisabled, true); + assert.equal(publishing.role, 'status'); + assert.equal(publishing.nextAction, 'Wait for the current one-time delivery attempt to finish.'); + + const markup = exportDeliveryStateMarkup('publishing'); + assert.match(markup, /aria-busy="true"/); + assert.match(markup, /class="hr-export-confirm"[^>]* disabled/); + assert.match(markup, /class="hr-export-publish"[^>]* disabled/); +}); + +test('terminal delivery states never offer republish and always explain the next action', () => { + const delivered = exportDeliveryViewModel('delivered'); + assert.equal(delivered.interactionState, 'read-only'); + assert.equal(delivered.publishDisabled, true); + assert.equal(delivered.nextAction, 'Review the immutable delivery receipt. Do not send the export again.'); + + const indeterminate = exportDeliveryViewModel('indeterminate'); + assert.equal(indeterminate.role, 'alert'); + assert.equal(indeterminate.publishDisabled, true); + assert.equal(indeterminate.nextAction, 'Do not send again. Reconcile the existing delivery reference before any further action.'); + + const denied = exportDeliveryViewModel('denied'); + assert.equal(denied.role, 'alert'); + assert.equal(denied.publishDisabled, true); + assert.equal(denied.nextAction, 'Resolve purpose-bound authorization and start a new reviewed export only after approval.'); + + for (const state of ['delivered', 'indeterminate', 'denied']) { + const model = exportDeliveryViewModel(state); + const serialized = JSON.stringify(model); + assert.doesNotMatch(serialized, /employee|person|email|name|salary|compensation|token|credential|document_content/i); + const markup = exportDeliveryStateMarkup(state); + assert.match(markup, /Next action/); + assert.match(markup, /class="hr-export-publish"[^>]* disabled/); + } +}); + +test('unsupported or boxed state values fail closed before rendering', () => { + assert.throws(() => exportDeliveryViewModel('retrying'), /unsupported HR export delivery state/); + assert.throws(() => exportDeliveryViewModel(new String('review')), /exact built-in string/); +}); + +test('prototype-inherited names cannot masquerade as governed export states', () => { + for (const inheritedName of ['constructor', 'toString', '__proto__']) { + assert.throws( + () => exportDeliveryViewModel(inheritedName), + /unsupported HR export delivery state/, + `${inheritedName} must fail closed at the view-model boundary`, + ); + assert.throws( + () => exportDeliveryStateMarkup(inheritedName), + /unsupported HR export delivery state/, + `${inheritedName} must fail closed before markup is emitted`, + ); + } +}); + +test('Storybook and styling cover the Figma-required high-risk and terminal states', () => { + for (const storyName of ['ReviewRequired', 'ConfirmedReady', 'Publishing', 'DeliveredReadOnly', 'DeliveryIndeterminate', 'PermissionDenied']) { + assert.match(story, new RegExp(`export const ${storyName}`)); + } + assert.match(story, /import ['"]\.\/hr-export-delivery-state\.css['"]/); + assert.match(story, /exportDeliveryStateMarkup/); + assert.match(css, /data-interaction-state="high-risk-confirmation"/); + assert.match(css, /var\(--orgmetra-surface-page\)/); + assert.doesNotMatch(css, /var\(--orgmetra-surface-muted\)/); + assert.match(css, /var\(--orgmetra-focus-ring\)/); + assert.match(css, /:focus-visible/); +});