diff --git a/.github/workflows/hr-workspace-employment-absence-state.yml b/.github/workflows/hr-workspace-employment-absence-state.yml new file mode 100644 index 000000000..5de8e2d92 --- /dev/null +++ b/.github/workflows/hr-workspace-employment-absence-state.yml @@ -0,0 +1,55 @@ +name: HR Workspace Employment Absence State Quality + +on: + pull_request: + branches: + - develop + - feat/hr-workspace-protected-read-state + paths: + - "apps/hr-workspace/employment-absence-state.js" + - "apps/hr-workspace/employment-absence-state.css" + - "apps/hr-workspace/employment-absence-state.stories.js" + - "tests/hr-workspace-employment-absence-state.test.mjs" + - "docs/traceability/hr-workspace-employment-absence-state.md" + - "docs/doctoring/hr-workspace-employment-absence-accessibility-references.md" + - ".github/workflows/hr-workspace-employment-absence-state.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: hr-workspace-employment-absence-state-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + employment-absence-state: + name: Employment absence 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 Employment absence 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-employment-absence-state.test.mjs + - name: Require clean checkout + run: | + git diff --exit-code + test -z "$(git status --porcelain)" diff --git a/apps/hr-workspace/employment-absence-state.css b/apps/hr-workspace/employment-absence-state.css new file mode 100644 index 000000000..3549dde80 --- /dev/null +++ b/apps/hr-workspace/employment-absence-state.css @@ -0,0 +1,58 @@ +.employment-absence-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); +} + +.employment-absence-status, +.employment-absence-next-action { + display: grid; + gap: var(--orgmetra-space-xs); + margin: 0; +} + +.employment-absence-status span, +.employment-absence-next-action span { + color: var(--orgmetra-text-muted); +} + +.employment-absence-state[data-interaction-state="read-only"] { + border-color: var(--orgmetra-border-subtle); +} + +.employment-absence-state[data-interaction-state="permission-denied"], +.employment-absence-state[data-interaction-state="validation-error"], +.employment-absence-state[data-interaction-state="error"] { + border-color: var(--orgmetra-danger); +} + +.employment-absence-state[aria-busy="true"] { + cursor: progress; +} + +.employment-absence-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; +} + +.employment-absence-submit:disabled { + cursor: not-allowed; + opacity: 0.62; +} + +.employment-absence-submit:focus-visible { + outline: 3px solid var(--orgmetra-focus-ring); + outline-offset: 3px; +} diff --git a/apps/hr-workspace/employment-absence-state.js b/apps/hr-workspace/employment-absence-state.js new file mode 100644 index 000000000..1c333df6a --- /dev/null +++ b/apps/hr-workspace/employment-absence-state.js @@ -0,0 +1,79 @@ +const STATE_MODELS = Object.freeze({ + idle: Object.freeze({ + ariaBusy: 'false', ariaLive: 'polite', role: 'status', submitDisabled: false, + interactionState: 'default', actionLabel: 'Load absence evidence', + label: 'Review Employment absence evidence', + message: 'Load fresh reason-free Employment absence evidence for the requested business date and system-knowledge cutoff.', + nextAction: 'Load the current governed Employment and absence evidence for this authorized HR task.', + }), + loading: Object.freeze({ + ariaBusy: 'true', ariaLive: 'polite', role: 'status', submitDisabled: true, + interactionState: 'loading', actionLabel: 'Loading absence evidence', + label: 'Loading current absence evidence', + message: 'Orgmetra is resolving fresh Employment and reason-free absence evidence. Cached or partial absence evidence is not accepted.', + nextAction: 'Wait for the current Employment absence evidence load to finish.', + }), + absent: Object.freeze({ + ariaBusy: 'false', ariaLive: 'polite', role: 'status', submitDisabled: true, + interactionState: 'read-only', actionLabel: 'Absence evidence loaded', + label: 'Employment is absent at this coordinate', + message: 'This is reason-free operational evidence that the Employment is absent at the reviewed business/system coordinate. It does not disclose why and does not authorize leave, scheduling, compensation, or an employment decision.', + nextAction: 'If a consequential action is required, continue only through the separately governed leave, work-capacity, scheduling, or Employment boundary.', + }), + notAbsent: Object.freeze({ + ariaBusy: 'false', ariaLive: 'polite', role: 'status', submitDisabled: true, + interactionState: 'read-only', actionLabel: 'Absence evidence loaded', + label: 'Employment is not absent at this coordinate', + message: 'This is reason-free operational evidence that no confirmed absence is visible at the reviewed business/system coordinate. It does not prove attendance, availability, or fitness for work.', + nextAction: 'Continue only with the authorized HR task and resolve any separate attendance, scheduling, leave, or fitness evidence through its governed owner boundary.', + }), + denied: Object.freeze({ + ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', submitDisabled: false, + interactionState: 'permission-denied', actionLabel: 'Review access', + label: 'Absence evidence access denied', + message: 'The current purpose or actor authority does not permit access to Employment absence evidence.', + nextAction: 'Check the purpose and access authority before trying to load absence evidence again.', + }), + stale: Object.freeze({ + ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', submitDisabled: false, + interactionState: 'validation-error', actionLabel: 'Reload evidence', + label: 'Absence evidence is stale', + message: 'The Employment or absence truth changed before this view could be relied on.', + nextAction: 'Reload authoritative Employment and absence evidence at a fresh business/system coordinate before continuing.', + }), + blocked: Object.freeze({ + ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', submitDisabled: false, + interactionState: 'validation-error', actionLabel: 'Review authoritative scope', + label: 'Absence evidence is blocked by authoritative scope', + message: 'The absence view cannot be trusted while tenant, Employment, Person binding, status, or visible-version evidence is inconsistent.', + nextAction: 'Resolve the tenant, Employment, Person binding, status, and visible-version evidence before loading absence truth again.', + }), + error: Object.freeze({ + ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', submitDisabled: false, + interactionState: 'error', actionLabel: 'Retry governed load', + label: 'Employment absence evidence unavailable', + message: 'The governed Employment absence service did not return usable authoritative evidence.', + nextAction: 'Do not infer absence from cached or partial evidence; verify the service and authorization before retrying.', + }), +}); + +function requireExactState(value) { + if (typeof value !== 'string') { + throw new TypeError('Employment absence 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 Employment absence state: ${value}`); + return model; +} + +/** Return immutable, reason-free accessibility semantics for one Employment absence evidence state. */ +export function employmentAbsenceViewModel(state) { + return requireExactState(state); +} + +/** Render one static Storybook proof without accepting caller-controlled worker, absence-reason, or HR values. */ +export function employmentAbsenceStateMarkup(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/employment-absence-state.stories.js b/apps/hr-workspace/employment-absence-state.stories.js new file mode 100644 index 000000000..aafcadb0b --- /dev/null +++ b/apps/hr-workspace/employment-absence-state.stories.js @@ -0,0 +1,25 @@ +import { employmentAbsenceStateMarkup } from './employment-absence-state.js'; +import './employment-absence-state.css'; + +export default { + title: 'HR Workspace/Employment Absence States', + parameters: { + design: { + type: 'figma', + url: 'Orgmetra Baseline — Storybook Inventory node 1:64', + }, + }, +}; + +function story(state) { + return () => employmentAbsenceStateMarkup(state); +} + +export const Idle = story('idle'); +export const Loading = story('loading'); +export const AbsentReadOnly = story('absent'); +export const NotAbsentReadOnly = story('notAbsent'); +export const PermissionDenied = story('denied'); +export const StaleEvidence = story('stale'); +export const AuthoritativeScopeBlocked = story('blocked'); +export const Error = story('error'); diff --git a/docs/doctoring/hr-workspace-employment-absence-accessibility-references.md b/docs/doctoring/hr-workspace-employment-absence-accessibility-references.md new file mode 100644 index 000000000..3f7458f4e --- /dev/null +++ b/docs/doctoring/hr-workspace-employment-absence-accessibility-references.md @@ -0,0 +1,23 @@ +# HR Workspace Employment absence accessibility references + +Status: **active stacked PR research/doctoring**. These references support the interaction semantics; they are not a claim of product certification, protected-`develop` availability, or compliance attestation. + +## Applied decisions + +- Use native button semantics and expose `aria-busy`, status/live-region, and alert semantics without turning the evidence surface into a custom application widget. +- Keep reason-free absence truth explicitly read-only. The UI may communicate that an Employment is absent or not absent at one reviewed business/system coordinate, but it must not disclose or infer medical, family, statutory, disciplinary, benefit, or free-form reasons. +- Do not infer attendance, availability, fitness for work, leave entitlement, scheduling, compensation, or employment-decision authority from absence truth. +- Disable duplicate submission while authoritative Employment/absence evidence is loading, and make loaded evidence read-only. +- Treat stale truth and tenant/Employment/Person/status/version inconsistency as fail-closed states with an explicit safe next action. +- Preserve a visible keyboard focus indicator and a minimum 44 CSS-pixel action height. +- Keep Storybook proof data value-minimized: no Person/Employment/Assignment identifiers, worker names/contact data, absence/leave reasons, medical/family/disciplinary data, compensation/benefit/rating/assessment values, credentials/tokens, prompts, or model output. + +## Primary final standards + +World Wide Web Consortium. (2023, October 5). *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/ + +## Current-status note + +WCAG 2.2 and WAI-ARIA 1.2 remain completed W3C Recommendations used as the normative accessibility baseline for this slice. Editorial or draft successor work does not replace those final standards here unless a future Orgmetra decision explicitly updates the baseline. This branch claims neither WCAG/ARIA conformance certification nor broader product accessibility certification. diff --git a/docs/traceability/hr-workspace-employment-absence-state.md b/docs/traceability/hr-workspace-employment-absence-state.md new file mode 100644 index 000000000..8579ee6dd --- /dev/null +++ b/docs/traceability/hr-workspace-employment-absence-state.md @@ -0,0 +1,35 @@ +# HR Workspace Employment absence state traceability + +Status: **active stacked PR**. This document records the presentation/interaction contract owned by this branch. It does not claim protected-`develop` availability, accessibility certification, leave approval, or authority to mutate Employment truth. + +## Ownership boundary + +- Parent presentation contract: #130 `feat/hr-workspace-protected-read-state@b3b30058a79174000919d566fbbb1fdad80c62bf`. +- HR Workspace product parent: #53. +- Reason-free bitemporal Employment absence truth: #113. This branch does not import or duplicate that unmerged HRIS-kernel implementation. +- Durable reason-free absence persistence: #114. This branch does not write its PostgreSQL relations or inherit its focused evidence. +- Employment leave review remains separately owned by #47. Absence truth must not be translated into leave reason, entitlement, schedule, benefit, compensation, fitness, or employment-decision authority. +- Figma correlation: `Orgmetra Baseline`, Storybook Inventory node `1:64`, freshly read on 2026-08-28. The node requires `default / hover / focus / disabled / loading / validation-error / read-only / high-risk-confirmation` as the shared inventory. This read-only absence slice uses the applicable default/loading/validation/read-only/focus states and deliberately does not invent a consequential confirmation action. + +## Interaction contract + +- `idle`: explain what fresh reason-free Employment absence evidence will be loaded and why cached evidence is insufficient. +- `loading`: mark the surface busy and disable duplicate requests while current business/system evidence is resolved. +- `absent`: read-only reason-free operational truth that the Employment is absent at the reviewed coordinate; no reason disclosure or consequential authority. +- `notAbsent`: read-only truth that no confirmed absence is visible at the reviewed coordinate; it must not be interpreted as attendance, availability, or fitness for work. +- `denied`: fail closed on purpose/authority denial and direct the user to correct authorization. +- `stale`: require a fresh authoritative Employment/absence read rather than relying on prior evidence. +- `blocked`: surface authoritative tenant/Employment/Person/status/version inconsistency and require that inconsistency to be resolved first. +- `error`: prohibit inference from cached/partial evidence and explain the safe retry prerequisite. + +The interaction view model is constant and value-minimized. It carries no Person/Employment/Assignment identifier, worker name/contact data, absence or leave reason, medical/family/statutory/disciplinary/benefit fact, compensation/rating/assessment value, credential/token, prompt, or model output. + +## Evidence and accessibility + +The focused workflow executes the exact candidate under Node.js 24 and requires 100% line, branch, and function coverage for this interaction contract. Storybook reuses existing Orgmetra design/focus tokens and the existing Figma inventory rather than introducing a parallel design system. WCAG 2.2 and WAI-ARIA 1.2 primary final references are recorded under `docs/doctoring/hr-workspace-employment-absence-accessibility-references.md`. + +## Dependency-first integration + +Keep this PR Draft and process #53 -> #130 first. The focused child gate is stack-local evidence only; #53/#130/#113/#114/#47 checks or reviews never transfer. After #130 actually integrates, retarget/revalidate this child against fresh `develop`, reconcile intervening HR Workspace/absence contracts, and rerun every applicable browser/accessibility/Foundation/Recovery/SAST/Security and central required workflow on one resulting exact head. + +Do not self-approve, use routine administrator bypass, race another lifecycle writer, infer a reason or consequential HR authority from the read-only absence state, transfer predecessor evidence, or mutate a dedicated-writer dependency. diff --git a/tests/hr-workspace-employment-absence-state.test.mjs b/tests/hr-workspace-employment-absence-state.test.mjs new file mode 100644 index 000000000..d76e8e21d --- /dev/null +++ b/tests/hr-workspace-employment-absence-state.test.mjs @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { + employmentAbsenceStateMarkup, + employmentAbsenceViewModel, +} from '../apps/hr-workspace/employment-absence-state.js'; + +const story = readFileSync( + new URL('../apps/hr-workspace/employment-absence-state.stories.js', import.meta.url), + 'utf8', +); +const css = readFileSync( + new URL('../apps/hr-workspace/employment-absence-state.css', import.meta.url), + 'utf8', +); +const workflow = readFileSync( + new URL('../.github/workflows/hr-workspace-employment-absence-state.yml', import.meta.url), + 'utf8', +); + +const expectedStates = { + idle: ['false', 'status', false, 'default', 'Review Employment absence evidence'], + loading: ['true', 'status', true, 'loading', 'Loading current absence evidence'], + absent: ['false', 'status', true, 'read-only', 'Employment is absent at this coordinate'], + notAbsent: ['false', 'status', true, 'read-only', 'Employment is not absent at this coordinate'], + denied: ['false', 'alert', false, 'permission-denied', 'Absence evidence access denied'], + stale: ['false', 'alert', false, 'validation-error', 'Absence evidence is stale'], + blocked: ['false', 'alert', false, 'validation-error', 'Absence evidence is blocked by authoritative scope'], + error: ['false', 'alert', false, 'error', 'Employment absence evidence unavailable'], +}; + +const allowedViewModelKeys = [ + 'actionLabel', + 'ariaBusy', + 'ariaLive', + 'interactionState', + 'label', + 'message', + 'nextAction', + 'role', + 'submitDisabled', +]; + +test('Employment absence states are bounded, actionable, and reason-free', () => { + for (const [state, [ariaBusy, role, submitDisabled, interactionState, label]] of Object.entries(expectedStates)) { + const model = employmentAbsenceViewModel(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', + 'assignmentRecordId', + 'workerName', + 'email', + 'phone', + 'absenceReason', + 'leaveReason', + 'medicalCondition', + 'familyReason', + 'disciplinaryReason', + 'benefitValue', + 'compensationValue', + 'ratingValue', + 'assessmentScore', + 'credential', + 'token', + 'prompt', + 'modelOutput', + ]) { + assert.equal(Object.hasOwn(model, forbiddenKey), false); + } + + const markup = employmentAbsenceStateMarkup(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('read-only absence truth never exposes a reason or grants consequential authority', () => { + const absent = employmentAbsenceViewModel('absent'); + assert.match(absent.message, /reason-free operational evidence/i); + assert.match(absent.message, /does not disclose why/i); + assert.match(absent.message, /does not authorize leave, scheduling, compensation, or an employment decision/i); + assert.match(absent.nextAction, /separately governed/i); + + const notAbsent = employmentAbsenceViewModel('notAbsent'); + assert.match(notAbsent.message, /reason-free operational evidence/i); + assert.match(notAbsent.message, /does not prove attendance, availability, or fitness for work/i); + assert.match(notAbsent.nextAction, /authorized HR task/i); +}); + +test('denial, stale evidence, scope conflict, and failure explain the next safe action', () => { + assert.match(employmentAbsenceViewModel('denied').nextAction, /purpose and access authority/i); + assert.match(employmentAbsenceViewModel('stale').nextAction, /Reload authoritative Employment and absence evidence/i); + assert.match(employmentAbsenceViewModel('blocked').nextAction, /tenant, Employment, Person binding, status, and visible-version evidence/i); + assert.match(employmentAbsenceViewModel('error').nextAction, /Do not infer absence from cached or partial evidence/i); +}); + +test('unsupported runtime input fails closed before rendering', () => { + assert.throws(() => employmentAbsenceViewModel('approved'), /unsupported Employment absence state/); + for (const inheritedState of ['constructor', 'toString', '__proto__']) { + assert.throws( + () => employmentAbsenceViewModel(inheritedState), + /unsupported Employment absence state/, + ); + assert.throws( + () => employmentAbsenceStateMarkup(inheritedState), + /unsupported Employment absence state/, + ); + } + assert.throws(() => employmentAbsenceViewModel(new String('absent')), /exact built-in string/); + assert.throws(() => employmentAbsenceStateMarkup(Symbol('notAbsent')), /exact built-in string/); +}); + +test('Storybook and CSS cover read-only Employment absence accessibility states', () => { + for (const storyName of [ + 'Idle', + 'Loading', + 'AbsentReadOnly', + 'NotAbsentReadOnly', + 'PermissionDenied', + 'StaleEvidence', + 'AuthoritativeScopeBlocked', + 'Error', + ]) { + assert.match(story, new RegExp(`export const ${storyName}`)); + } + assert.match(story, /employmentAbsenceStateMarkup/); + assert.match(css, /var\(--orgmetra-focus-ring\)/); + assert.match(css, /:focus-visible/); + assert.match(css, /\[aria-busy="true"\]/); + 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/); +});