From a3cb1b180c6177d440674d7c3903409e46d60b43 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 00:05:41 -0700 Subject: [PATCH 1/9] test(ui): require governed candidate evidence timeline states --- ...space-candidate-evidence-timeline.test.mjs | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 tests/hr-workspace-candidate-evidence-timeline.test.mjs diff --git a/tests/hr-workspace-candidate-evidence-timeline.test.mjs b/tests/hr-workspace-candidate-evidence-timeline.test.mjs new file mode 100644 index 000000000..9e2798f10 --- /dev/null +++ b/tests/hr-workspace-candidate-evidence-timeline.test.mjs @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + candidateEvidenceTimelineMarkup, + candidateEvidenceTimelineViewModel, +} from '../apps/hr-workspace/candidate-evidence-timeline.js'; + +const STATES = Object.freeze([ + 'idle', + 'loading', + 'ready', + 'empty', + 'denied', + 'stale', + 'scopeBlocked', + 'error', +]); + +const ALLOWED_KEYS = Object.freeze([ + 'actionDisabled', + 'actionLabel', + 'ariaBusy', + 'ariaLive', + 'interactionState', + 'label', + 'message', + 'nextAction', + 'role', +]); + +const FORBIDDEN_VALUE_KEYS = Object.freeze([ + 'candidateId', + 'candidateName', + 'candidateEmail', + 'personId', + 'requisitionId', + 'jobId', + 'evidenceText', + 'resumeText', + 'assessmentScore', + 'matchScore', + 'rating', + 'compensation', + 'credential', + 'token', + 'prompt', + 'modelOutput', +]); + +test('candidate evidence timeline exposes only the governed bounded states', () => { + for (const state of STATES) { + const model = candidateEvidenceTimelineViewModel(state); + assert.deepEqual(Object.keys(model).sort(), [...ALLOWED_KEYS].sort()); + assert.equal(typeof model.nextAction, 'string'); + assert.ok(model.nextAction.length > 0); + } + + assert.equal(candidateEvidenceTimelineViewModel('loading').ariaBusy, 'true'); + assert.equal(candidateEvidenceTimelineViewModel('loading').actionDisabled, true); + assert.equal(candidateEvidenceTimelineViewModel('ready').interactionState, 'read-only'); + assert.equal(candidateEvidenceTimelineViewModel('ready').actionDisabled, true); + assert.equal(candidateEvidenceTimelineViewModel('denied').role, 'alert'); +}); + +test('candidate evidence timeline evidence is value-minimized and non-authorizing', () => { + for (const state of STATES) { + const model = candidateEvidenceTimelineViewModel(state); + for (const forbiddenKey of FORBIDDEN_VALUE_KEYS) { + assert.equal(Object.hasOwn(model, forbiddenKey), false); + } + } + + const ready = candidateEvidenceTimelineViewModel('ready'); + assert.match(ready.message, /read-only/i); + assert.match(ready.message, /does not evaluate, rank, reject, advance, or authorize an employment decision/i); + assert.match(candidateEvidenceTimelineViewModel('empty').message, /not evidence that no governed candidate evidence exists/i); + assert.match(candidateEvidenceTimelineViewModel('error').nextAction, /do not infer candidate status/i); +}); + +test('candidate evidence timeline renders Figma-correlated accessible state evidence', () => { + const loading = candidateEvidenceTimelineMarkup('loading'); + assert.match(loading, /data-figma-node-id="1:64"/); + assert.match(loading, /aria-busy="true"/); + assert.match(loading, /disabled/); + + const ready = candidateEvidenceTimelineMarkup('ready'); + assert.match(ready, /data-interaction-state="read-only"/); + assert.match(ready, /role="status"/); + assert.match(ready, /Next action/); + + const denied = candidateEvidenceTimelineMarkup('denied'); + assert.match(denied, /role="alert"/); + assert.match(denied, /aria-live="assertive"/); +}); + +test('candidate evidence timeline rejects non-string and prototype-inherited state names', () => { + for (const invalid of [null, 1, {}, [], new String('ready')]) { + assert.throws(() => candidateEvidenceTimelineViewModel(invalid), TypeError); + assert.throws(() => candidateEvidenceTimelineMarkup(invalid), TypeError); + } + + for (const inheritedName of ['constructor', 'toString', '__proto__']) { + assert.throws(() => candidateEvidenceTimelineViewModel(inheritedName), TypeError); + assert.throws(() => candidateEvidenceTimelineMarkup(inheritedName), TypeError); + } + + assert.throws(() => candidateEvidenceTimelineViewModel('unknown'), TypeError); +}); + +test('candidate evidence timeline gives a concrete fail-closed next action', () => { + const expectations = { + denied: /check the HR purpose and access authority/i, + stale: /reload the current governed candidate evidence/i, + scopeBlocked: /narrow the requested evidence fields/i, + error: /verify the governed evidence service and authorization/i, + }; + + for (const [state, pattern] of Object.entries(expectations)) { + const model = candidateEvidenceTimelineViewModel(state); + assert.equal(model.actionDisabled, false); + assert.match(model.nextAction, pattern); + } +}); From b7f99064740e0a8ad705991a3c6a42fefa074aa4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 00:05:55 -0700 Subject: [PATCH 2/9] ci(ui): require exact candidate evidence timeline coverage --- ...-workspace-candidate-evidence-timeline.yml | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .github/workflows/hr-workspace-candidate-evidence-timeline.yml diff --git a/.github/workflows/hr-workspace-candidate-evidence-timeline.yml b/.github/workflows/hr-workspace-candidate-evidence-timeline.yml new file mode 100644 index 000000000..729ebc498 --- /dev/null +++ b/.github/workflows/hr-workspace-candidate-evidence-timeline.yml @@ -0,0 +1,54 @@ +name: HR Workspace Candidate Evidence Timeline Quality + +on: + pull_request: + branches: + - 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)" From 8d5499214424cac58d06a883b58edc7b8e94a401 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 00:06:34 -0700 Subject: [PATCH 3/9] feat(ui): add governed candidate evidence timeline states --- .../candidate-evidence-timeline.js | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 apps/hr-workspace/candidate-evidence-timeline.js diff --git a/apps/hr-workspace/candidate-evidence-timeline.js b/apps/hr-workspace/candidate-evidence-timeline.js new file mode 100644 index 000000000..b473ebad9 --- /dev/null +++ b/apps/hr-workspace/candidate-evidence-timeline.js @@ -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 `
\n

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

\n

Next action${model.nextAction}

\n \n
`; +} From a198291dceaf7baa4cd2a8b4777dd8f87091380b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 00:06:44 -0700 Subject: [PATCH 4/9] feat(ui): style candidate evidence timeline states --- .../candidate-evidence-timeline.css | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 apps/hr-workspace/candidate-evidence-timeline.css diff --git a/apps/hr-workspace/candidate-evidence-timeline.css b/apps/hr-workspace/candidate-evidence-timeline.css new file mode 100644 index 000000000..1f17dfd73 --- /dev/null +++ b/apps/hr-workspace/candidate-evidence-timeline.css @@ -0,0 +1,58 @@ +.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:disabled { + cursor: not-allowed; + opacity: 0.62; +} + +.candidate-evidence-action:focus-visible { + outline: 3px solid var(--orgmetra-focus-ring); + outline-offset: 3px; +} From a4563480d9471b8c018f377fa1d2f204988246ed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 00:06:51 -0700 Subject: [PATCH 5/9] feat(ui): add candidate evidence timeline Storybook states --- .../candidate-evidence-timeline.stories.js | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 apps/hr-workspace/candidate-evidence-timeline.stories.js diff --git a/apps/hr-workspace/candidate-evidence-timeline.stories.js b/apps/hr-workspace/candidate-evidence-timeline.stories.js new file mode 100644 index 000000000..c0b17f29c --- /dev/null +++ b/apps/hr-workspace/candidate-evidence-timeline.stories.js @@ -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'); From 38a6fbe008102134d98ad3c8b5af88e1ec02f606 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 00:07:01 -0700 Subject: [PATCH 6/9] docs(ui): record candidate evidence accessibility references --- ...idate-evidence-accessibility-references.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 docs/doctoring/hr-workspace-candidate-evidence-accessibility-references.md diff --git a/docs/doctoring/hr-workspace-candidate-evidence-accessibility-references.md b/docs/doctoring/hr-workspace-candidate-evidence-accessibility-references.md new file mode 100644 index 000000000..410a68e66 --- /dev/null +++ b/docs/doctoring/hr-workspace-candidate-evidence-accessibility-references.md @@ -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. From ceb20db5f871a4f85adc336c793fd4387b3886df Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 00:07:15 -0700 Subject: [PATCH 7/9] docs(ui): trace candidate evidence timeline ownership --- ...r-workspace-candidate-evidence-timeline.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 docs/traceability/hr-workspace-candidate-evidence-timeline.md diff --git a/docs/traceability/hr-workspace-candidate-evidence-timeline.md b/docs/traceability/hr-workspace-candidate-evidence-timeline.md new file mode 100644 index 000000000..d1ff71781 --- /dev/null +++ b/docs/traceability/hr-workspace-candidate-evidence-timeline.md @@ -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. From 4cafd78f86c466c7600f946ff237f898fa6f5e0b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 03:16:15 +0900 Subject: [PATCH 8/9] ci: rerun candidate evidence checks on develop --- .../hr-workspace-candidate-evidence-timeline.yml | 1 + .../hr-workspace-candidate-evidence-timeline.test.mjs | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/.github/workflows/hr-workspace-candidate-evidence-timeline.yml b/.github/workflows/hr-workspace-candidate-evidence-timeline.yml index 729ebc498..c3fbfed8f 100644 --- a/.github/workflows/hr-workspace-candidate-evidence-timeline.yml +++ b/.github/workflows/hr-workspace-candidate-evidence-timeline.yml @@ -3,6 +3,7 @@ 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" diff --git a/tests/hr-workspace-candidate-evidence-timeline.test.mjs b/tests/hr-workspace-candidate-evidence-timeline.test.mjs index 9e2798f10..56d72a8fb 100644 --- a/tests/hr-workspace-candidate-evidence-timeline.test.mjs +++ b/tests/hr-workspace-candidate-evidence-timeline.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import test from 'node:test'; import { @@ -6,6 +7,11 @@ import { candidateEvidenceTimelineViewModel, } from '../apps/hr-workspace/candidate-evidence-timeline.js'; +const workflow = readFileSync( + new URL('../.github/workflows/hr-workspace-candidate-evidence-timeline.yml', import.meta.url), + 'utf8', +); + const STATES = Object.freeze([ 'idle', 'loading', @@ -122,3 +128,7 @@ test('candidate evidence timeline gives a concrete fail-closed next action', () assert.match(model.nextAction, pattern); } }); + +test('the dedicated contract reruns after retargeting to protected develop', () => { + assert.match(workflow, /branches:\n\s+- develop\n\s+- feat\/hr-workspace-protected-read-state/); +}); From 5fd7683ed8cd4bfbe9b217b64e22c0368ae6f486 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 10:15:15 +0900 Subject: [PATCH 9/9] fix(ui): add candidate evidence hover state --- apps/hr-workspace/candidate-evidence-timeline.css | 4 ++++ tests/hr-workspace-candidate-evidence-timeline.test.mjs | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/apps/hr-workspace/candidate-evidence-timeline.css b/apps/hr-workspace/candidate-evidence-timeline.css index 1f17dfd73..7edcae3f1 100644 --- a/apps/hr-workspace/candidate-evidence-timeline.css +++ b/apps/hr-workspace/candidate-evidence-timeline.css @@ -47,6 +47,10 @@ cursor: pointer; } +.candidate-evidence-action:hover:not(:disabled) { + opacity: 0.88; +} + .candidate-evidence-action:disabled { cursor: not-allowed; opacity: 0.62; diff --git a/tests/hr-workspace-candidate-evidence-timeline.test.mjs b/tests/hr-workspace-candidate-evidence-timeline.test.mjs index 56d72a8fb..e4f55dde1 100644 --- a/tests/hr-workspace-candidate-evidence-timeline.test.mjs +++ b/tests/hr-workspace-candidate-evidence-timeline.test.mjs @@ -11,6 +11,10 @@ const workflow = readFileSync( new URL('../.github/workflows/hr-workspace-candidate-evidence-timeline.yml', import.meta.url), 'utf8', ); +const css = readFileSync( + new URL('../apps/hr-workspace/candidate-evidence-timeline.css', import.meta.url), + 'utf8', +); const STATES = Object.freeze([ 'idle', @@ -85,6 +89,8 @@ test('candidate evidence timeline evidence is value-minimized and non-authorizin }); test('candidate evidence timeline renders Figma-correlated accessible state evidence', () => { + assert.match(css, /:hover:not\(:disabled\)/); + assert.match(css, /:focus-visible/); const loading = candidateEvidenceTimelineMarkup('loading'); assert.match(loading, /data-figma-node-id="1:64"/); assert.match(loading, /aria-busy="true"/);