Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .github/workflows/hr-workspace-candidate-evidence-timeline.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: HR Workspace Candidate Evidence Timeline Quality

on:
pull_request:
branches:
- develop
- feat/hr-workspace-protected-read-state
paths:
- "apps/hr-workspace/candidate-evidence-timeline.js"
- "apps/hr-workspace/candidate-evidence-timeline.css"
- "apps/hr-workspace/candidate-evidence-timeline.stories.js"
- "tests/hr-workspace-candidate-evidence-timeline.test.mjs"
- "docs/traceability/hr-workspace-candidate-evidence-timeline.md"
- "docs/doctoring/hr-workspace-candidate-evidence-accessibility-references.md"
- ".github/workflows/hr-workspace-candidate-evidence-timeline.yml"
workflow_dispatch:

permissions:
contents: read

concurrency:
group: hr-workspace-candidate-evidence-timeline-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
candidate-evidence-timeline:
name: Candidate evidence timeline 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 candidate-evidence 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-candidate-evidence-timeline.test.mjs
- name: Require clean checkout
run: |
git diff --exit-code
test -z "$(git status --porcelain)"
62 changes: 62 additions & 0 deletions apps/hr-workspace/candidate-evidence-timeline.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
.candidate-evidence-timeline {
display: grid;
gap: var(--orgmetra-space-md);
max-width: 42rem;
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);
}

.candidate-evidence-status,
.candidate-evidence-next-action {
display: grid;
gap: var(--orgmetra-space-xs);
margin: 0;
}

.candidate-evidence-status span,
.candidate-evidence-next-action span {
color: var(--orgmetra-text-muted);
}

.candidate-evidence-timeline[data-interaction-state="read-only"] {
border-color: var(--orgmetra-border-subtle);
}

.candidate-evidence-timeline[data-interaction-state="permission-denied"],
.candidate-evidence-timeline[data-interaction-state="validation-error"],
.candidate-evidence-timeline[data-interaction-state="error"] {
border-color: var(--orgmetra-danger);
}

.candidate-evidence-timeline[aria-busy="true"] {
cursor: progress;
}

.candidate-evidence-action {
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;
}

.candidate-evidence-action:hover:not(:disabled) {
opacity: 0.88;
}

.candidate-evidence-action:disabled {
cursor: not-allowed;
opacity: 0.62;
}

.candidate-evidence-action:focus-visible {
outline: 3px solid var(--orgmetra-focus-ring);
outline-offset: 3px;
}
80 changes: 80 additions & 0 deletions apps/hr-workspace/candidate-evidence-timeline.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
const STATE_MODELS = Object.freeze({
idle: Object.freeze({
ariaBusy: 'false', ariaLive: 'polite', role: 'status', actionDisabled: false,
interactionState: 'default', actionLabel: 'Load candidate evidence',
label: 'Review candidate evidence timeline',
message: 'Load fresh purpose-authorized governed candidate evidence for the current recruiting scope.',
nextAction: 'Load the current governed evidence before relying on this recruiting view.',
}),
loading: Object.freeze({
ariaBusy: 'true', ariaLive: 'polite', role: 'status', actionDisabled: true,
interactionState: 'loading', actionLabel: 'Loading candidate evidence',
label: 'Loading candidate evidence',
message: 'Orgmetra is resolving the authorized evidence references and visible timeline entries for this purpose-bound request.',
nextAction: 'Wait for the governed candidate-evidence read to finish.',
}),
ready: Object.freeze({
ariaBusy: 'false', ariaLive: 'polite', role: 'status', actionDisabled: true,
interactionState: 'read-only', actionLabel: 'Candidate evidence loaded',
label: 'Candidate evidence timeline ready',
message: 'This is read-only governed candidate evidence. It does not evaluate, rank, reject, advance, or authorize an employment decision.',
nextAction: 'Open only the separately authorized evidence reference needed for accountable human review.',
}),
empty: Object.freeze({
ariaBusy: 'false', ariaLive: 'polite', role: 'status', actionDisabled: false,
interactionState: 'read-only', actionLabel: 'Reload candidate evidence',
label: 'No candidate evidence is visible here',
message: 'No governed candidate evidence is visible in this authorized scope. This is not evidence that no governed candidate evidence exists elsewhere.',
nextAction: 'Check the authorized recruiting scope and reload if another permitted evidence view is required.',
}),
denied: Object.freeze({
ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', actionDisabled: false,
interactionState: 'permission-denied', actionLabel: 'Review access',
label: 'Candidate evidence access denied',
message: 'The current actor or HR purpose does not permit this governed candidate-evidence read.',
nextAction: 'Check the HR purpose and access authority before requesting candidate evidence again.',
}),
stale: Object.freeze({
ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', actionDisabled: false,
interactionState: 'validation-error', actionLabel: 'Reload candidate evidence',
label: 'Candidate evidence is stale',
message: 'The governed evidence set or recruiting scope changed before this timeline could be relied on.',
nextAction: 'Reload the current governed candidate evidence before continuing human review.',
}),
scopeBlocked: Object.freeze({
ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', actionDisabled: false,
interactionState: 'validation-error', actionLabel: 'Narrow requested evidence',
label: 'Candidate evidence fields are not authorized',
message: 'One or more requested evidence fields fall outside the current purpose-bound authorization.',
nextAction: 'Narrow the requested evidence fields to the authorized set or obtain the required HR access before retrying.',
}),
error: Object.freeze({
ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', actionDisabled: false,
interactionState: 'error', actionLabel: 'Retry governed read',
label: 'Candidate evidence unavailable',
message: 'The governed candidate-evidence read did not return usable authoritative evidence.',
nextAction: 'Do not infer candidate status from cached or partial data; verify the governed evidence service and authorization before retrying.',
}),
});

function requireExactState(value) {
if (typeof value !== 'string') {
throw new TypeError('candidate-evidence timeline state must be an exact built-in string');
}
if (!Object.hasOwn(STATE_MODELS, value)) {
throw new TypeError(`unsupported candidate-evidence timeline state: ${value}`);
}
return STATE_MODELS[value];
}

/** Return immutable accessibility semantics for one purpose-bound candidate-evidence timeline state. */
export function candidateEvidenceTimelineViewModel(state) {
return requireExactState(state);
}

/** Render static Storybook evidence without accepting caller-controlled candidate values or identifiers. */
export function candidateEvidenceTimelineMarkup(state) {
const model = requireExactState(state);
const disabled = model.actionDisabled ? ' disabled' : '';
return `<section class="candidate-evidence-timeline" data-figma-node-id="1:64" data-interaction-state="${model.interactionState}" aria-busy="${model.ariaBusy}">\n <p class="candidate-evidence-status" role="${model.role}" aria-live="${model.ariaLive}"><strong>${model.label}</strong><span>${model.message}</span></p>\n <p class="candidate-evidence-next-action"><strong>Next action</strong><span>${model.nextAction}</span></p>\n <button class="candidate-evidence-action" type="button"${disabled}>${model.actionLabel}</button>\n</section>`;
}
25 changes: 25 additions & 0 deletions apps/hr-workspace/candidate-evidence-timeline.stories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { candidateEvidenceTimelineMarkup } from './candidate-evidence-timeline.js';
import './candidate-evidence-timeline.css';

export default {
title: 'HR Workspace/Candidate Evidence Timeline States',
parameters: {
design: {
type: 'figma',
url: 'Orgmetra Baseline — Storybook Inventory node 1:64',
},
},
};

function story(state) {
return () => candidateEvidenceTimelineMarkup(state);
}

export const Idle = story('idle');
export const Loading = story('loading');
export const ReadyReadOnly = story('ready');
export const EmptyReadOnly = story('empty');
export const PermissionDenied = story('denied');
export const StaleEvidence = story('stale');
export const EvidenceScopeBlocked = story('scopeBlocked');
export const Error = story('error');
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Candidate evidence timeline accessibility references

Reviewed: 2026-08-28 (Asia/Seoul).

This note records primary accessibility design inputs for the active Candidate Evidence timeline interaction slice. It is engineering evidence, not a claim of WCAG certification or conformance for a deployed product.

## Primary standards

World Wide Web Consortium. (2023, October 5). *Web Content Accessibility Guidelines (WCAG) 2.2*. https://www.w3.org/TR/WCAG22/

World Wide Web Consortium. (2023, June 6). *Accessible Rich Internet Applications (WAI-ARIA) 1.2*. https://www.w3.org/TR/wai-aria-1.2/

## Design consequences

- Loading is exposed through `aria-busy` and disables the repeated action while the governed read is in flight.
- Denied, stale, scope-blocked, and error states use assertive alert semantics and provide a concrete next action rather than silently widening access or relying on cached evidence.
- Read-only states remain visibly non-authorizing. They do not turn candidate evidence into ranking, rejection, progression, or employment-decision authority.
- The interactive action retains a visible `:focus-visible` treatment and a 44-pixel minimum target height using existing Orgmetra design tokens.
- The Figma `Orgmetra Baseline` Storybook Inventory node `1:64` was freshly read on 2026-08-28 and continues to require default, hover, focus, disabled, loading, validation-error, read-only, and high-risk-confirmation states. This slice uses that inventory as design correlation rather than creating parallel geometry.

The active PR must still be revalidated against fresh integrated parents and browser/accessibility evidence before its UI can be described as shipped.
35 changes: 35 additions & 0 deletions docs/traceability/hr-workspace-candidate-evidence-timeline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# HR Workspace Candidate Evidence timeline traceability

Status: **active PR evidence only**. This document does not change protected-main shipped truth.

## Ownership boundary

- Protected `develop` already ships governed Candidate Evidence intake through merged PR #41. That backend packet is reference-only, purpose-bound, value-minimized, and not authorized for an employment decision.
- Parent PR #130 owns the shared HR Workspace protected-read interaction semantics and Figma/Storybook accessibility contract.
- This dependency-first child owns only the Candidate Evidence timeline presentation/interaction state model. It does not duplicate candidate intake, identity, requisition/Job authority, candidate lifecycle, selection decision, or evidence persistence.
- Dedicated-writer CWL repositories remain read-only dependencies and are not mutated by this slice.

## Buyer-visible contract

The Recruiting Workspace wireframe names a Candidate Evidence timeline. This slice provides bounded executable states for that surface:

`idle / loading / ready / empty / denied / stale / scopeBlocked / error`.

`ready` is read-only governed evidence. It explicitly does not evaluate, rank, reject, advance, or authorize an employment decision. `empty` means only that no evidence is visible inside the currently authorized scope. Denial, staleness, scope mismatch, and transport failure fail closed with a concrete next action.

The generic state payload intentionally contains no candidate/Person identifiers, names/contact data, requisition/Job identifiers, raw evidence or resume content, assessment/match values, ratings, compensation, credentials/tokens, prompts, or model output. Unsupported runtime values and prototype-inherited names such as `constructor`, `toString`, and `__proto__` are rejected through exact string plus own-key membership checks.

## Design and accessibility evidence

Fresh Figma `Orgmetra Baseline` Storybook Inventory node `1:64` was read on 2026-08-28 and still requires default, hover, focus, disabled, loading, validation-error, read-only, and high-risk-confirmation behavior. The implementation uses existing Orgmetra tokens and Storybook rather than parallel geometry. WCAG 2.2 and WAI-ARIA 1.2 primary references are recorded in `docs/doctoring/hr-workspace-candidate-evidence-accessibility-references.md`.

## Verification contract

`.github/workflows/hr-workspace-candidate-evidence-timeline.yml` must:

1. check out and prove the exact candidate SHA;
2. use the reviewed Node 24 toolchain;
3. execute the focused interaction/privacy/fail-closed regression with exact 100% line, branch, and function coverage; and
4. finish with a clean checkout.

Focused child GREEN is stack-local only. After #53 and #130 integrate, this child must be retargeted/reconciled against fresh `develop` and all applicable browser/accessibility/Foundation/Recovery/SAST/Security and central required workflows must execute again on one resulting exact head. Parent or predecessor checks/reviews never transfer.
Loading
Loading