diff --git a/.github/workflows/hr-workspace-performance-goal-review-state.yml b/.github/workflows/hr-workspace-performance-goal-review-state.yml new file mode 100644 index 000000000..2d96fe7be --- /dev/null +++ b/.github/workflows/hr-workspace-performance-goal-review-state.yml @@ -0,0 +1,55 @@ +name: HR Workspace Performance Goal Review State Quality + +on: + pull_request: + branches: + - develop + - feat/hr-workspace-protected-read-state + paths: + - "apps/hr-workspace/performance-goal-review-state.js" + - "apps/hr-workspace/performance-goal-review-state.css" + - "apps/hr-workspace/performance-goal-review-state.stories.js" + - "tests/hr-workspace-performance-goal-review-state.test.mjs" + - "docs/traceability/hr-workspace-performance-goal-review-state.md" + - "docs/doctoring/hr-workspace-performance-goal-accessibility-references.md" + - ".github/workflows/hr-workspace-performance-goal-review-state.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: hr-workspace-performance-goal-review-state-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + performance-goal-review-state: + name: Performance goal 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 performance-goal 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-performance-goal-review-state.test.mjs + - name: Require clean checkout + run: | + git diff --exit-code + test -z "$(git status --porcelain)" diff --git a/apps/hr-workspace/performance-goal-review-state.css b/apps/hr-workspace/performance-goal-review-state.css new file mode 100644 index 000000000..4e9d719f2 --- /dev/null +++ b/apps/hr-workspace/performance-goal-review-state.css @@ -0,0 +1,62 @@ +.performance-goal-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); +} + +.performance-goal-review-status, +.performance-goal-review-next-action { + display: grid; + gap: var(--orgmetra-space-xs); + margin: 0; +} + +.performance-goal-review-status span, +.performance-goal-review-next-action span { + color: var(--orgmetra-text-muted); +} + +.performance-goal-review-state[data-interaction-state="high-risk-confirmation"] { + border-color: var(--orgmetra-action-review); +} + +.performance-goal-review-state[data-interaction-state="read-only"] { + border-color: var(--orgmetra-border-subtle); +} + +.performance-goal-review-state[data-interaction-state="permission-denied"], +.performance-goal-review-state[data-interaction-state="validation-error"], +.performance-goal-review-state[data-interaction-state="error"] { + border-color: var(--orgmetra-danger); +} + +.performance-goal-review-state[aria-busy="true"] { + cursor: progress; +} + +.performance-goal-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; +} + +.performance-goal-review-submit:disabled { + cursor: not-allowed; + opacity: 0.62; +} + +.performance-goal-review-submit:focus-visible { + outline: 3px solid var(--orgmetra-focus-ring); + outline-offset: 3px; +} diff --git a/apps/hr-workspace/performance-goal-review-state.js b/apps/hr-workspace/performance-goal-review-state.js new file mode 100644 index 000000000..21ab04992 --- /dev/null +++ b/apps/hr-workspace/performance-goal-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 goal-plan evidence', + label: 'Review performance-goal plan evidence', + message: 'Load fresh governed performance-goal plan evidence before asking a human reviewer to confirm the plan.', + nextAction: 'Load the current governed goal-plan evidence for this authorized HR task.', + }), + loading: Object.freeze({ + ariaBusy: 'true', ariaLive: 'polite', role: 'status', submitDisabled: true, + interactionState: 'loading', actionLabel: 'Loading goal-plan evidence', + label: 'Loading current goal-plan evidence', + message: 'Orgmetra is resolving fresh Employment, Job, performance-cycle, goal-set, measurement, cadence, actor, and chronology evidence.', + nextAction: 'Wait for the current governed goal-plan evidence load to finish.', + }), + review: Object.freeze({ + ariaBusy: 'false', ariaLive: 'polite', role: 'status', submitDisabled: false, + interactionState: 'high-risk-confirmation', actionLabel: 'Record human review', + label: 'Human review required before goal-plan activation', + message: 'A human reviewer may confirm the governed plan evidence. This review does not activate the plan and does not authorize a performance rating, compensation action, or employment decision.', + nextAction: 'Confirm only the reviewed evidence; activation remains a separately governed operation.', + }), + recording: Object.freeze({ + ariaBusy: 'true', ariaLive: 'polite', role: 'status', submitDisabled: true, + interactionState: 'loading', actionLabel: 'Recording review evidence', + label: 'Recording goal-plan review evidence', + message: 'Orgmetra is recording the human-review evidence. Duplicate confirmation is disabled while this operation is in flight.', + nextAction: 'Wait for the immutable review evidence to finish recording before continuing.', + }), + recorded: Object.freeze({ + ariaBusy: 'false', ariaLive: 'polite', role: 'status', submitDisabled: true, + interactionState: 'read-only', actionLabel: 'Review evidence recorded', + label: 'Goal-plan review evidence recorded', + message: 'This is read-only review evidence. Recording it does not activate the plan and does not authorize performance rating, compensation, or an employment decision.', + nextAction: 'If activation is required, continue through the separately governed activation boundary using fresh authoritative evidence.', + }), + denied: Object.freeze({ + ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', submitDisabled: false, + interactionState: 'permission-denied', actionLabel: 'Review access', + label: 'Goal-plan review access denied', + message: 'The current purpose or actor authority does not permit this performance-goal review task.', + nextAction: 'Check the purpose and access authority before loading or reviewing goal-plan evidence again.', + }), + stale: Object.freeze({ + ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', submitDisabled: false, + interactionState: 'validation-error', actionLabel: 'Reload evidence', + label: 'Goal-plan evidence is stale', + message: 'The authoritative goal-plan scope changed before the review evidence could be relied on.', + nextAction: 'Reload authoritative Employment, Job, performance-cycle, goal-set, measurement, cadence, actor, and chronology evidence before reviewing again.', + }), + activationBlocked: Object.freeze({ + ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', submitDisabled: false, + interactionState: 'validation-error', actionLabel: 'Review activation scope', + label: 'Goal-plan activation is blocked', + message: 'The recorded review cannot proceed to activation while authoritative scope or chronology is inconsistent.', + nextAction: 'Resolve Employment, Job, performance cycle, goal-set, measurement, cadence, actor, and chronology evidence through the governed owners before requesting activation.', + }), + error: Object.freeze({ + ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', submitDisabled: false, + interactionState: 'error', actionLabel: 'Retry governed load', + label: 'Performance-goal review unavailable', + message: 'The governed performance-goal review service did not return usable authoritative evidence.', + nextAction: 'Do not activate or infer a performance outcome from cached or partial evidence; verify the service and authorization before retrying.', + }), +}); + +function requireExactState(value) { + if (typeof value !== 'string') { + throw new TypeError('performance-goal 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 performance-goal review state: ${value}`); + return model; +} + +/** Return immutable accessibility semantics for one governed performance-goal review state. */ +export function performanceGoalReviewViewModel(state) { + return requireExactState(state); +} + +/** Render static Storybook evidence without accepting caller-controlled HR values or identifiers. */ +export function performanceGoalReviewStateMarkup(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/performance-goal-review-state.stories.js b/apps/hr-workspace/performance-goal-review-state.stories.js new file mode 100644 index 000000000..8ac2a5bb9 --- /dev/null +++ b/apps/hr-workspace/performance-goal-review-state.stories.js @@ -0,0 +1,26 @@ +import { performanceGoalReviewStateMarkup } from './performance-goal-review-state.js'; +import './performance-goal-review-state.css'; + +export default { + title: 'HR Workspace/Performance Goal Review States', + parameters: { + design: { + type: 'figma', + url: 'Orgmetra Baseline — Storybook Inventory node 1:64', + }, + }, +}; + +function story(state) { + return () => performanceGoalReviewStateMarkup(state); +} + +export const Idle = story('idle'); +export const Loading = story('loading'); +export const HumanReview = story('review'); +export const Recording = story('recording'); +export const RecordedReadOnly = story('recorded'); +export const PermissionDenied = story('denied'); +export const StaleEvidence = story('stale'); +export const ActivationBlocked = story('activationBlocked'); +export const Error = story('error'); diff --git a/docs/doctoring/hr-workspace-performance-goal-accessibility-references.md b/docs/doctoring/hr-workspace-performance-goal-accessibility-references.md new file mode 100644 index 000000000..5c3ee79fd --- /dev/null +++ b/docs/doctoring/hr-workspace-performance-goal-accessibility-references.md @@ -0,0 +1,17 @@ +# HR Workspace performance-goal review accessibility references + +Status: active-PR evidence for the dependency-first HR Workspace performance-goal review interaction. This document does not claim protected-main integration or release authorization. + +## Primary standards + +World Wide Web Consortium. (2023). *Web Content Accessibility Guidelines (WCAG) 2.2*. https://www.w3.org/TR/WCAG22/ + +World Wide Web Consortium. (2023). *Accessible Rich Internet Applications (WAI-ARIA) 1.2*. https://www.w3.org/TR/wai-aria-1.2/ + +## Applied contract + +- In-flight load/record operations expose `aria-busy="true"` and disable duplicate action submission. +- Denied, stale, blocked, and error states use assertive alert semantics and always explain the next safe action. +- Interactive controls preserve the existing Orgmetra `:focus-visible` token and a minimum 44px target height. +- Human review is visually and semantically separated from authoritative activation. A recorded review is read-only evidence and cannot be interpreted as a performance rating, compensation action, or employment decision. +- Storybook evidence remains correlated to Figma `Orgmetra Baseline`, Storybook Inventory node `1:64`, whose required states are default / hover / focus / disabled / loading / validation-error / read-only / high-risk-confirmation. diff --git a/docs/traceability/hr-workspace-performance-goal-review-state.md b/docs/traceability/hr-workspace-performance-goal-review-state.md new file mode 100644 index 000000000..4ba91bd28 --- /dev/null +++ b/docs/traceability/hr-workspace-performance-goal-review-state.md @@ -0,0 +1,19 @@ +# HR Workspace performance-goal review state traceability + +Status: active stacked PR only; not protected-main truth and not release authorization. + +| Need / risk | Owner boundary | Executable evidence | +| --- | --- | --- | +| Shared protected-read interaction semantics | #130 | Existing HR Workspace design tokens, focus treatment, loading/read-only/error patterns | +| Human-reviewed goal-plan governance evidence | #92 | Separate backend authority; this UI does not import or duplicate the unmerged implementation | +| Authoritative goal-plan activation | #121 | Separate backend authority; this UI never activates a plan | +| Durable goal-plan persistence | #125 | Separate persistence boundary; this UI stores no HR truth | +| Figma / Storybook interaction-state inventory | Figma `Orgmetra Baseline`, node `1:64` | Storybook stories and `data-figma-node-id="1:64"` correlation | +| Prevent review → activation/rating/compensation/employment-decision confusion | This PR | `review` and `recorded` state regressions explicitly deny those authorities | +| Prevent stale or inconsistent scope from becoming consequential action | This PR | `stale` and `activationBlocked` states require fresh authoritative evidence and governed owner resolution | +| Minimize UI-state evidence | This PR | View-model key allowlist excludes identifiers, goal text/value, ratings, compensation, assessments, credentials, prompts, and model output | +| Accessibility and actionable failure recovery | This PR | WCAG 2.2 / WAI-ARIA 1.2 doctoring, Storybook state inventory, exact 100% line/branch/function focused gate | + +## Dependency-first integration + +Base this slice on #130. Process #53 → #130 first. After the parent actually integrates, retarget/revalidate this child against fresh `develop`, reconcile intervening HR Workspace and performance-goal changes, and rerun every applicable browser/accessibility/Foundation/Recovery/SAST/Security and central required workflow on one exact resulting head. Parent/backend checks and reviews never transfer. diff --git a/tests/hr-workspace-performance-goal-review-state.test.mjs b/tests/hr-workspace-performance-goal-review-state.test.mjs new file mode 100644 index 000000000..c12ca3cdf --- /dev/null +++ b/tests/hr-workspace-performance-goal-review-state.test.mjs @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { + performanceGoalReviewStateMarkup, + performanceGoalReviewViewModel, +} from '../apps/hr-workspace/performance-goal-review-state.js'; + +const story = readFileSync( + new URL('../apps/hr-workspace/performance-goal-review-state.stories.js', import.meta.url), + 'utf8', +); +const css = readFileSync( + new URL('../apps/hr-workspace/performance-goal-review-state.css', import.meta.url), + 'utf8', +); +const workflow = readFileSync( + new URL('../.github/workflows/hr-workspace-performance-goal-review-state.yml', import.meta.url), + 'utf8', +); + +const expectedStates = { + idle: ['false', 'status', false, 'default', 'Review performance-goal plan evidence'], + loading: ['true', 'status', true, 'loading', 'Loading current goal-plan evidence'], + review: ['false', 'status', false, 'high-risk-confirmation', 'Human review required before goal-plan activation'], + recording: ['true', 'status', true, 'loading', 'Recording goal-plan review evidence'], + recorded: ['false', 'status', true, 'read-only', 'Goal-plan review evidence recorded'], + denied: ['false', 'alert', false, 'permission-denied', 'Goal-plan review access denied'], + stale: ['false', 'alert', false, 'validation-error', 'Goal-plan evidence is stale'], + activationBlocked: ['false', 'alert', false, 'validation-error', 'Goal-plan activation is blocked'], + error: ['false', 'alert', false, 'error', 'Performance-goal review unavailable'], +}; + +const allowedViewModelKeys = [ + 'actionLabel', + 'ariaBusy', + 'ariaLive', + 'interactionState', + 'label', + 'message', + 'nextAction', + 'role', + 'submitDisabled', +]; + +test('performance-goal review states are bounded, actionable, and privacy-minimized', () => { + for (const [state, [ariaBusy, role, submitDisabled, interactionState, label]] of Object.entries(expectedStates)) { + const model = performanceGoalReviewViewModel(state); + assert.equal(model.ariaBusy, ariaBusy); + assert.equal(model.role, role); + assert.equal(model.submitDisabled, submitDisabled); + assert.equal(model.interactionState, interactionState); + assert.equal(model.label, label); + assert.equal(model.ariaLive, role === 'alert' ? 'assertive' : 'polite'); + assert.match(model.nextAction, /\.$/); + assert.deepEqual(Object.keys(model).sort(), allowedViewModelKeys); + + for (const forbiddenKey of [ + 'personRecordId', 'employmentRecordId', 'jobRecordId', 'performanceCycleId', + 'goalText', 'goalValue', 'ratingValue', 'assessmentScore', 'compensationValue', + 'candidateRecordId', 'workerName', 'email', 'phone', 'credential', 'token', + 'prompt', 'modelOutput', + ]) { + assert.equal(Object.hasOwn(model, forbiddenKey), false); + } + + const markup = performanceGoalReviewStateMarkup(state); + assert.match(markup, /data-figma-node-id="1:64"/); + assert.match(markup, new RegExp(`data-interaction-state="${interactionState}"`)); + assert.match(markup, new RegExp(`aria-busy="${ariaBusy}"`)); + assert.match(markup, /Next action/); + if (submitDisabled) assert.match(markup, /]* disabled/); + else assert.doesNotMatch(markup, /]* disabled/); + } +}); + +test('human review does not activate, rate, compensate, or decide employment', () => { + const review = performanceGoalReviewViewModel('review'); + assert.match(review.message, /human review/i); + assert.match(review.message, /does not activate/i); + assert.match(review.message, /performance rating/i); + assert.match(review.message, /compensation/i); + assert.match(review.message, /employment decision/i); + + const recorded = performanceGoalReviewViewModel('recorded'); + assert.match(recorded.message, /read-only review evidence/i); + assert.match(recorded.message, /does not activate/i); + assert.match(recorded.nextAction, /separately governed activation boundary/i); +}); + +test('stale and blocked states demand fresh authoritative evidence instead of inference', () => { + assert.match(performanceGoalReviewViewModel('stale').nextAction, /Reload authoritative/i); + assert.match(performanceGoalReviewViewModel('activationBlocked').nextAction, /Employment, Job, performance cycle, goal-set, measurement, cadence, actor, and chronology/i); + assert.match(performanceGoalReviewViewModel('denied').nextAction, /purpose and access authority/i); + assert.match(performanceGoalReviewViewModel('error').nextAction, /Do not activate or infer/i); +}); + +test('unsupported runtime input fails closed before rendering', () => { + assert.throws(() => performanceGoalReviewViewModel('activated'), /unsupported performance-goal review state/); + for (const inheritedState of ['constructor', 'toString', '__proto__']) { + assert.throws( + () => performanceGoalReviewViewModel(inheritedState), + /unsupported performance-goal review state/, + ); + assert.throws( + () => performanceGoalReviewStateMarkup(inheritedState), + /unsupported performance-goal review state/, + ); + } + assert.throws(() => performanceGoalReviewViewModel(new String('review')), /exact built-in string/); + assert.throws(() => performanceGoalReviewStateMarkup(Symbol('recorded')), /exact built-in string/); +}); + +test('Storybook and CSS cover governed performance-goal review accessibility states', () => { + for (const storyName of [ + 'Idle', 'Loading', 'HumanReview', 'Recording', 'RecordedReadOnly', + 'PermissionDenied', 'StaleEvidence', 'ActivationBlocked', 'Error', + ]) { + assert.match(story, new RegExp(`export const ${storyName}`)); + } + assert.match(story, /performanceGoalReviewStateMarkup/); + assert.match(css, /var\(--orgmetra-focus-ring\)/); + assert.match(css, /:focus-visible/); + assert.match(css, /\[aria-busy="true"\]/); + assert.match(css, /high-risk-confirmation/); + assert.match(css, /read-only/); + assert.match(css, /min-height:\s*44px/); +}); + +test('the dedicated contract reruns on protected develop after parent integration', () => { + assert.match(workflow, /branches:\n\s+- develop\n\s+- feat\/hr-workspace-protected-read-state/); +});