diff --git a/.github/workflows/hr-workspace-position-lifecycle-review-state.yml b/.github/workflows/hr-workspace-position-lifecycle-review-state.yml new file mode 100644 index 000000000..f0bfcf5a2 --- /dev/null +++ b/.github/workflows/hr-workspace-position-lifecycle-review-state.yml @@ -0,0 +1,55 @@ +name: HR Workspace Position Lifecycle Review State Quality + +on: + pull_request: + branches: + - develop + - feat/hr-workspace-protected-read-state + paths: + - "apps/hr-workspace/position-lifecycle-review-state.js" + - "apps/hr-workspace/position-lifecycle-review-state.css" + - "apps/hr-workspace/position-lifecycle-review-state.stories.js" + - "tests/hr-workspace-position-lifecycle-review-state.test.mjs" + - "docs/traceability/hr-workspace-position-lifecycle-review-state.md" + - "docs/doctoring/hr-workspace-position-lifecycle-review-accessibility-references.md" + - ".github/workflows/hr-workspace-position-lifecycle-review-state.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: hr-workspace-position-lifecycle-review-state-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + position-lifecycle-review-state: + name: Position lifecycle review state 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 Position lifecycle review accessibility 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-position-lifecycle-review-state.test.mjs + - name: Require clean checkout + run: | + git diff --exit-code + test -z "$(git status --porcelain)" diff --git a/apps/hr-workspace/position-lifecycle-review-state.css b/apps/hr-workspace/position-lifecycle-review-state.css new file mode 100644 index 000000000..14ad995a7 --- /dev/null +++ b/apps/hr-workspace/position-lifecycle-review-state.css @@ -0,0 +1,58 @@ +.position-lifecycle-review-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); +} + +.position-lifecycle-review-status, +.position-lifecycle-review-next-action { + display: grid; + gap: var(--orgmetra-space-xs); + margin: 0; +} + +.position-lifecycle-review-status span, +.position-lifecycle-review-next-action span { + color: var(--orgmetra-text-muted); +} + +.position-lifecycle-review-state[data-interaction-state="high-risk-confirmation"] { + border-color: var(--orgmetra-action-review); +} + +.position-lifecycle-review-state[data-interaction-state="permission-denied"], +.position-lifecycle-review-state[data-interaction-state="validation-error"], +.position-lifecycle-review-state[data-interaction-state="error"] { + border-color: var(--orgmetra-danger); +} + +.position-lifecycle-review-state[aria-busy="true"] { + cursor: progress; +} + +.position-lifecycle-review-submit { + justify-self: start; + min-height: 44px; + padding: var(--orgmetra-space-sm) var(--orgmetra-space-md); + border: 0; + border-radius: var(--orgmetra-radius-sm); + background: var(--orgmetra-action-review); + color: #fff; + font: inherit; + cursor: pointer; +} + +.position-lifecycle-review-submit:disabled { + cursor: wait; + opacity: 0.62; +} + +.position-lifecycle-review-submit:focus-visible { + outline: 3px solid var(--orgmetra-focus-ring); + outline-offset: 3px; +} diff --git a/apps/hr-workspace/position-lifecycle-review-state.js b/apps/hr-workspace/position-lifecycle-review-state.js new file mode 100644 index 000000000..37e6529e2 --- /dev/null +++ b/apps/hr-workspace/position-lifecycle-review-state.js @@ -0,0 +1,86 @@ +const STATE_MODELS = Object.freeze({ + idle: Object.freeze({ + ariaBusy: 'false', ariaLive: 'polite', role: 'status', submitDisabled: false, + interactionState: 'default', actionLabel: 'Load Position evidence', + label: 'Review Position lifecycle evidence', + message: 'Load the fresh Position and Assignment evidence before making a human lifecycle review.', + nextAction: 'Load the current governed Position and staffing evidence for one lifecycle review.', + }), + loading: Object.freeze({ + ariaBusy: 'true', ariaLive: 'polite', role: 'status', submitDisabled: true, + interactionState: 'loading', actionLabel: 'Loading Position evidence', + label: 'Loading governed Position evidence', + message: 'Orgmetra is waiting for fresh Position and Assignment evidence. No cached lifecycle truth is used.', + nextAction: 'Wait for the current evidence load to finish.', + }), + review: Object.freeze({ + ariaBusy: 'false', ariaLive: 'polite', role: 'status', submitDisabled: false, + interactionState: 'high-risk-confirmation', actionLabel: 'Confirm lifecycle review', + label: 'Position lifecycle change requires human confirmation', + message: 'The evidence is read-only input for human review. Confirming this review does not apply, freeze, close, abolish, or reactivate a Position.', + nextAction: 'Confirm the reviewed lifecycle evidence before recording the human review.', + }), + recording: Object.freeze({ + ariaBusy: 'true', ariaLive: 'polite', role: 'status', submitDisabled: true, + interactionState: 'loading', actionLabel: 'Recording human review', + label: 'Recording human Position lifecycle review', + message: 'Orgmetra is recording the human review evidence. Duplicate submission is disabled.', + nextAction: 'Wait for immutable review evidence to be recorded.', + }), + recorded: Object.freeze({ + ariaBusy: 'false', ariaLive: 'polite', role: 'status', submitDisabled: true, + interactionState: 'read-only', actionLabel: 'Review recorded', + label: 'Human Position lifecycle review recorded', + message: 'The recorded review is evidence only and does not apply a Position lifecycle mutation.', + nextAction: 'Return to the Position lifecycle queue. Authoritative application is separate and must re-resolve fresh Position and Assignment truth.', + }), + denied: Object.freeze({ + ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', submitDisabled: false, + interactionState: 'permission-denied', actionLabel: 'Review access', + label: 'Position lifecycle review access denied', + message: 'The current purpose or reviewer authority does not permit this Position lifecycle review.', + nextAction: 'Check the access purpose and reviewer authority before trying again.', + }), + stale: Object.freeze({ + ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', submitDisabled: false, + interactionState: 'validation-error', actionLabel: 'Reload evidence', + label: 'Position lifecycle evidence is stale', + message: 'The Position or Assignment evidence changed before review recording.', + nextAction: 'Reload fresh authoritative Position and Assignment evidence before reviewing again.', + }), + blocked: Object.freeze({ + ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', submitDisabled: false, + interactionState: 'validation-error', actionLabel: 'Reload staffing evidence', + label: 'Position lifecycle change is blocked by staffing state', + message: 'The current staffing evidence conflicts with the proposed lifecycle change. This screen cannot override that safety boundary.', + nextAction: 'Reload authoritative staffing evidence and resolve the staffing conflict before reviewing again.', + }), + error: Object.freeze({ + ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', submitDisabled: false, + interactionState: 'error', actionLabel: 'Retry governed load', + label: 'Position lifecycle review unavailable', + message: 'The governed evidence or immutable review service did not return a usable result. No cached review is accepted.', + nextAction: 'Verify the service and authorization before retrying the governed evidence load.', + }), +}); + +function requireExactState(value) { + if (typeof value !== 'string') { + throw new TypeError('Position lifecycle review state must be an exact built-in string'); + } + const model = Object.hasOwn(STATE_MODELS, value) ? STATE_MODELS[value] : undefined; + if (!model) throw new TypeError(`unsupported Position lifecycle review state: ${value}`); + return model; +} + +/** Return immutable, value-minimized accessibility semantics for one Position lifecycle review state. */ +export function positionLifecycleReviewViewModel(state) { + return requireExactState(state); +} + +/** Render one static Storybook proof without accepting caller-controlled HR values. */ +export function positionLifecycleReviewStateMarkup(state) { + const model = requireExactState(state); + const disabled = model.submitDisabled ? ' disabled' : ''; + return `
\n

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

\n

Next action${model.nextAction}

\n \n
`; +} diff --git a/apps/hr-workspace/position-lifecycle-review-state.stories.js b/apps/hr-workspace/position-lifecycle-review-state.stories.js new file mode 100644 index 000000000..ba3185f8f --- /dev/null +++ b/apps/hr-workspace/position-lifecycle-review-state.stories.js @@ -0,0 +1,26 @@ +import { positionLifecycleReviewStateMarkup } from './position-lifecycle-review-state.js'; +import './position-lifecycle-review-state.css'; + +export default { + title: 'HR Workspace/Position Lifecycle Review States', + parameters: { + design: { + type: 'figma', + url: 'Orgmetra Baseline — Storybook Inventory node 1:64', + }, + }, +}; + +function story(state) { + return () => positionLifecycleReviewStateMarkup(state); +} + +export const Idle = story('idle'); +export const Loading = story('loading'); +export const HighRiskHumanReview = story('review'); +export const Recording = story('recording'); +export const RecordedReadOnly = story('recorded'); +export const PermissionDenied = story('denied'); +export const StaleEvidence = story('stale'); +export const StaffingBlocked = story('blocked'); +export const Error = story('error'); diff --git a/docs/doctoring/hr-workspace-position-lifecycle-review-accessibility-references.md b/docs/doctoring/hr-workspace-position-lifecycle-review-accessibility-references.md new file mode 100644 index 000000000..ffbc10ca6 --- /dev/null +++ b/docs/doctoring/hr-workspace-position-lifecycle-review-accessibility-references.md @@ -0,0 +1,20 @@ +# HR Workspace Position lifecycle review accessibility references + +Checked against the current W3C Recommendation baselines used by the existing HR Workspace interaction stack. These references support interaction semantics only; they do not establish Position mutation, staffing, or employment-decision authority. + +## Design implications + +- WAI-ARIA 1.2 defines `status` as advisory live-region information and `alert` as assertive information that does not itself require focus. Orgmetra therefore uses `status` for ordinary load/review/recording progress and `alert` for denial, stale evidence, staffing conflicts, and failures. +- WAI-ARIA 1.2 defines `aria-busy` for content that is being updated. Orgmetra sets it while loading authoritative evidence or recording one human review, and the action is disabled to prevent a duplicate interaction. +- WCAG 2.2 remains the normative WCAG Recommendation baseline used here. The interaction preserves visible keyboard focus, a 44px minimum action target in the existing design-token system, actionable non-color-only state copy, and explicit next actions. +- The Figma `Orgmetra Baseline` Storybook Inventory node `1:64` explicitly requires `default / hover / focus / disabled / loading / validation-error / read-only / high-risk-confirmation` states. This slice implements those semantics for a Position lifecycle review without inventing a new geometry or application authority. + +## APA 7 references + +World Wide Web Consortium. (2023). *Accessible Rich Internet Applications (WAI-ARIA) 1.2* (W3C Recommendation). https://www.w3.org/TR/wai-aria-1.2/ + +World Wide Web Consortium. (2023). *Web Content Accessibility Guidelines (WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/ + +## Product boundary + +High-risk confirmation in this Storybook evidence records the need for accountable human review; it does not execute a Position lifecycle mutation. PR #111 remains the review-evidence owner and PR #112 remains the authoritative application owner. Before consequential application, fresh bitemporal Position/Assignment evidence and staffing safety must be re-established by the authoritative Orgmetra boundary. diff --git a/docs/traceability/hr-workspace-position-lifecycle-review-state.md b/docs/traceability/hr-workspace-position-lifecycle-review-state.md new file mode 100644 index 000000000..e5ce3aacd --- /dev/null +++ b/docs/traceability/hr-workspace-position-lifecycle-review-state.md @@ -0,0 +1,35 @@ +# HR Workspace Position lifecycle review state traceability + +## Truth boundary + +- This file is **active-PR evidence only** until the dependency stack integrates and is revalidated against fresh `develop`. +- Parent UI contract: PR #130 owns shared protected-read accessibility semantics and Figma/Storybook correlation. +- Position lifecycle governance owner: PR #111 owns the human-reviewed `PositionLifecycleChangeReviewPacket`; this UI does not recreate that evidence contract. +- Authoritative application owner: dependency-first PR #112 owns the later Position lifecycle mutation boundary. This UI does not write Position/Assignment truth and does not authorize an application. + +## Buyer-visible requirement → evidence + +| Requirement | Active-PR evidence | Safety meaning | +| --- | --- | --- | +| Load fresh Position and Assignment evidence before review | `loading` → `review` state model and Storybook stories | Cached or locally invented lifecycle truth is not presented as sufficient evidence. | +| Show a consequential review as explicit human confirmation | `review` uses `high-risk-confirmation` and a `Confirm lifecycle review` action | Confirmation records human review only; it does not apply, freeze, close, abolish, or reactivate a Position. | +| Prevent duplicate review recording | `recording` is busy and disables the action | One in-flight review recording is visible and duplicate submission is blocked in the interaction model. | +| Recorded review remains evidence only | `recorded` is read-only and disables the action | A completed review is not mutation authority. | +| Stale authoritative evidence fails closed | `stale` is an assertive validation alert | The reviewer must reload fresh authoritative Position and Assignment evidence before another review. | +| Staffing conflict fails closed | `blocked` is an assertive validation alert | The UI cannot override a staffing-safety boundary; fresh authoritative staffing evidence must be re-resolved. | +| Denied reviewer/purpose scope fails closed | `denied` is an assertive permission alert | No local or cached downgrade path is offered. | +| Service failure names a safe next action | `error` rejects cached review and instructs service/authorization verification | Customer-facing recovery copy does not fabricate evidence. | +| Figma/Storybook state inventory stays inspectable | every rendered state carries `data-figma-node-id="1:64"`; Storybook exposes nine named states | Reviewers can inspect default, loading, high-risk-confirmation, read-only, permission-denied, validation-error, error and focus-related evidence. | +| Owned interaction logic has exact coverage | focused workflow requires Node 24 100% line/branch/function coverage | Missing owned branches are non-passing. | + +## Data minimization + +The view model contains only constant governed copy and interaction semantics. It accepts no Person or candidate identity, worker name/contact data, compensation or salary values, ratings, assessment results, credentials or bearer tokens, prompts, model output, free-form HR notes, or Position/Assignment payload values. + +## Consequential-decision boundary + +A human confirmation in this UI is not a Position mutation and is not an employment decision. Before any authoritative change, the application boundary must freshly re-resolve same-tenant bitemporal Position and Assignment truth, the reviewed lifecycle evidence, staffing safety, actor separation/authority, effective/business time, system-recorded time, and immutable audit/outbox evidence. UI state never substitutes for those checks. + +## Verification and stack discipline + +The dedicated workflow runs the Position lifecycle interaction contract directly on Node 24, checks out the exact candidate head, requires exact 100% line/branch/function coverage, and proves a clean checkout. Parent #130, PR #111 and PR #112 checks/reviews never transfer to this child. After #130 actually integrates, retarget this child to fresh `develop`, reconcile any UI/design changes, and rerun every applicable browser/accessibility/Foundation/Recovery/SAST/Security and central required workflow on one resulting exact head. diff --git a/tests/hr-workspace-position-lifecycle-review-state.test.mjs b/tests/hr-workspace-position-lifecycle-review-state.test.mjs new file mode 100644 index 000000000..713616084 --- /dev/null +++ b/tests/hr-workspace-position-lifecycle-review-state.test.mjs @@ -0,0 +1,165 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { + positionLifecycleReviewStateMarkup, + positionLifecycleReviewViewModel, +} from '../apps/hr-workspace/position-lifecycle-review-state.js'; + +const story = readFileSync( + new URL('../apps/hr-workspace/position-lifecycle-review-state.stories.js', import.meta.url), + 'utf8', +); +const css = readFileSync( + new URL('../apps/hr-workspace/position-lifecycle-review-state.css', import.meta.url), + 'utf8', +); +const traceability = readFileSync( + new URL('../docs/traceability/hr-workspace-position-lifecycle-review-state.md', import.meta.url), + 'utf8', +); +const doctoring = readFileSync( + new URL('../docs/doctoring/hr-workspace-position-lifecycle-review-accessibility-references.md', import.meta.url), + 'utf8', +); +const workflow = readFileSync( + new URL('../.github/workflows/hr-workspace-position-lifecycle-review-state.yml', import.meta.url), + 'utf8', +); + +const states = [ + 'idle', + 'loading', + 'review', + 'recording', + 'recorded', + 'denied', + 'stale', + 'blocked', + 'error', +]; + +test('Position lifecycle review requires a visible high-risk human confirmation state', () => { + const review = positionLifecycleReviewViewModel('review'); + assert.equal(review.ariaBusy, 'false'); + assert.equal(review.role, 'status'); + assert.equal(review.submitDisabled, false); + assert.equal(review.interactionState, 'high-risk-confirmation'); + assert.match(review.message, /does not apply/i); + assert.match(review.nextAction, /confirm/i); + + const markup = positionLifecycleReviewStateMarkup('review'); + assert.match(markup, /data-figma-node-id="1:64"/); + assert.match(markup, /data-interaction-state="high-risk-confirmation"/); + assert.match(markup, />Confirm lifecycle review { + for (const state of ['loading', 'recording']) { + const model = positionLifecycleReviewViewModel(state); + assert.equal(model.ariaBusy, 'true'); + assert.equal(model.submitDisabled, true); + assert.equal(model.role, 'status'); + assert.match(positionLifecycleReviewStateMarkup(state), /]* disabled/); + } +}); + +test('recorded review remains read-only evidence and cannot imply lifecycle mutation', () => { + const recorded = positionLifecycleReviewViewModel('recorded'); + assert.equal(recorded.interactionState, 'read-only'); + assert.equal(recorded.submitDisabled, true); + assert.match(recorded.message, /evidence only/i); + assert.match(recorded.message, /does not apply/i); +}); + +test('denied stale blocked and error states fail closed with concrete next actions', () => { + for (const state of ['denied', 'stale', 'blocked', 'error']) { + const model = positionLifecycleReviewViewModel(state); + assert.equal(model.role, 'alert'); + assert.equal(model.ariaLive, 'assertive'); + assert.equal(model.submitDisabled, false); + assert.ok(model.nextAction.length > 20); + } + assert.equal(positionLifecycleReviewViewModel('denied').interactionState, 'permission-denied'); + assert.equal(positionLifecycleReviewViewModel('stale').interactionState, 'validation-error'); + assert.equal(positionLifecycleReviewViewModel('blocked').interactionState, 'validation-error'); + assert.equal(positionLifecycleReviewViewModel('error').interactionState, 'error'); + assert.match(positionLifecycleReviewViewModel('blocked').message, /staffing/i); +}); + +test('idle and every governed state render only constant value-minimized interaction evidence', () => { + const idle = positionLifecycleReviewViewModel('idle'); + assert.equal(idle.interactionState, 'default'); + assert.equal(idle.submitDisabled, false); + + const allowedKeys = [ + 'actionLabel', + 'ariaBusy', + 'ariaLive', + 'interactionState', + 'label', + 'message', + 'nextAction', + 'role', + 'submitDisabled', + ].sort(); + for (const state of states) { + const model = positionLifecycleReviewViewModel(state); + assert.deepEqual(Object.keys(model).sort(), allowedKeys); + assert.doesNotMatch( + JSON.stringify(model), + /person_record_id|candidate_record_id|display_name|email|phone|salary|compensation_amount|rating_value|assessment_score|credential|bearer|token|prompt|model_output/i, + ); + assert.match(positionLifecycleReviewStateMarkup(state), /data-figma-node-id="1:64"/); + } +}); + +test('unsupported or non-string states are rejected before rendering', () => { + assert.throws(() => positionLifecycleReviewViewModel({}), /exact built-in string/); + assert.throws(() => positionLifecycleReviewViewModel('approved'), /unsupported Position lifecycle review state/); + for (const inheritedState of ['constructor', 'toString', '__proto__']) { + assert.throws( + () => positionLifecycleReviewViewModel(inheritedState), + /unsupported Position lifecycle review state/, + ); + assert.throws( + () => positionLifecycleReviewStateMarkup(inheritedState), + /unsupported Position lifecycle review state/, + ); + } + assert.throws(() => positionLifecycleReviewStateMarkup(3), /exact built-in string/); +}); + +test('Storybook and styling preserve the existing Figma accessibility inventory contract', () => { + assert.match(story, /HR Workspace\/Position Lifecycle Review States/); + for (const exportName of [ + 'Idle', 'Loading', 'HighRiskHumanReview', 'Recording', 'RecordedReadOnly', + 'PermissionDenied', 'StaleEvidence', 'StaffingBlocked', 'Error', + ]) { + assert.match(story, new RegExp(`export const ${exportName}`)); + } + assert.match(story, /Storybook Inventory node 1:64/); + assert.match(css, /min-height:\s*44px/); + assert.match(css, /:focus-visible/); + assert.match(css, /var\(--orgmetra-focus-ring\)/); + assert.match(css, /data-interaction-state="high-risk-confirmation"/); +}); + +test('traceability separates review evidence from authoritative Position application', () => { + assert.match(traceability, /PR #111/); + assert.match(traceability, /PR #112/); + assert.match(traceability, /active-PR evidence only/i); + assert.match(traceability, /does not authorize/i); + assert.match(traceability, /fresh authoritative/i); + assert.match(doctoring, /WAI-ARIA 1\.2/); + assert.match(doctoring, /WCAG 2\.2/); +}); + +test('focused workflow runs on the real stacked base and enforces exact coverage', () => { + assert.match(workflow, /develop/); + assert.match(workflow, /feat\/hr-workspace-protected-read-state/); + assert.match(workflow, /--test-coverage-lines=100/); + assert.match(workflow, /--test-coverage-branches=100/); + assert.match(workflow, /--test-coverage-functions=100/); + assert.match(workflow, /Require clean checkout/); +});