From 5dab65a7c5fc8baab41cf4834b36038d6b08d416 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 3 Aug 2026 02:12:58 +0000 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20[UX=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0]=20=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94=EB=90=9C=20?= =?UTF-8?q?=EB=A1=9C=EB=94=A9=20=EB=B2=84=ED=8A=BC=EC=97=90=20aria-busy=20?= =?UTF-8?q?=EC=86=8D=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .jules/palette.md | 3 +++ frontend/src/components/ProjectsLayout.tsx | 1 + 2 files changed, 4 insertions(+) diff --git a/.jules/palette.md b/.jules/palette.md index bdb0a4bbd..5209e81d0 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -80,3 +80,6 @@ ## 2025-05-19 - Dynamic ARIA labels and robust disabled states for sidebar actions **Learning:** Hardcoded ARIA labels in mockups (like "출시 회의 일정 삭제") are often left intact during implementation, leading to incorrect screen reader announcements when different items are selected. In addition, action buttons that depend on selection state often lack correct visual and functional disabled states. **Action:** When implementing detail views or sidebars, always replace hardcoded mockup ARIA labels with dynamic data (e.g. `${event.title} 삭제`), and ensure action buttons are explicitly disabled (both functionally via `disabled` and visually via `opacity-50 cursor-not-allowed`) when their prerequisites (like a selected item or specific properties like location) are unmet. +## 2024-05-14 - Add aria-busy to loading buttons +**Learning:** Assistive technologies rely on `aria-busy` to understand when a form submission or button action is in a loading state, even if the button is visually disabled. Just setting `disabled={isLoading}` is not enough. +**Action:** Always ensure that `aria-busy={isLoading}` is set alongside `disabled={isLoading}` for action buttons that handle asynchronous tasks to communicate the specific reason for being disabled. diff --git a/frontend/src/components/ProjectsLayout.tsx b/frontend/src/components/ProjectsLayout.tsx index 20f1f72ee..2f750ffd9 100644 --- a/frontend/src/components/ProjectsLayout.tsx +++ b/frontend/src/components/ProjectsLayout.tsx @@ -681,6 +681,7 @@ export function ProjectsLayout() { type="button" onClick={handleConfirmCandidate} disabled={confirmSubmitting || candidateConfirmed} + aria-busy={confirmSubmitting} className="rounded-md bg-primary px-3 py-1.5 text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:bg-secondary disabled:text-muted-foreground" > {candidateConfirmed ? '프로젝트 후보 확정됨' : confirmSubmitting ? '확정 저장 중' : '프로젝트 후보 확정'} From fba4437df495ee9ea491780c7e17508d1d3da19a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 11:23:31 +0900 Subject: [PATCH 2/6] chore(ci): dispatch stable current-head review evidence --- .../pr-1219-direct-review-dispatch.yml | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 .github/workflows/pr-1219-direct-review-dispatch.yml diff --git a/.github/workflows/pr-1219-direct-review-dispatch.yml b/.github/workflows/pr-1219-direct-review-dispatch.yml new file mode 100644 index 000000000..211263d76 --- /dev/null +++ b/.github/workflows/pr-1219-direct-review-dispatch.yml @@ -0,0 +1,149 @@ +name: Dispatch stable Naruon review evidence + +on: + pull_request: + branches: + - develop + types: [opened, reopened, synchronize] + +permissions: + contents: read + +concurrency: + group: dispatch-stable-naruon-review-evidence + cancel-in-progress: true + +jobs: + dispatch: + if: github.event.pull_request.head.ref == 'ux-improvement-disabled-buttons-13634237694443312021' + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + steps: + - name: Harden the runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout dispatcher branch + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + with: + ref: ux-improvement-disabled-buttons-13634237694443312021 + fetch-depth: 0 + + - name: Remove one-shot dispatcher before binding review metadata + shell: bash + run: | + set -euo pipefail + rm -- .github/workflows/pr-1219-direct-review-dispatch.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add .github/workflows/pr-1219-direct-review-dispatch.yml + git commit -m "chore(ci): remove one-shot review evidence dispatcher" + git push origin HEAD:ux-improvement-disabled-buttons-13634237694443312021 + + - name: Exchange OpenCode app token + id: app-token + shell: bash + env: + OIDC_AUDIENCE: opencode-github-action + OPENCODE_API_BASE_URL: https://api.opencode.ai + run: | + set -euo pipefail + request_url="${ACTIONS_ID_TOKEN_REQUEST_URL}" + separator='&' + case "$request_url" in + *\?*) ;; + *) separator='?' ;; + esac + oidc_response="$( + curl -fsS \ + -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ + "${request_url}${separator}audience=${OIDC_AUDIENCE}" + )" + oidc_token="$(jq -r '.value // empty' <<<"$oidc_response")" + test -n "$oidc_token" + token_response="$( + curl -fsS \ + -X POST \ + -H "Authorization: Bearer ${oidc_token}" \ + "${OPENCODE_API_BASE_URL}/exchange_github_app_token" + )" + app_token="$(jq -r '.token // empty' <<<"$token_response")" + test -n "$app_token" + echo "::add-mask::$app_token" + echo "token=$app_token" >>"$GITHUB_OUTPUT" + + - name: Dispatch exact-head Strix and OpenCode evidence + shell: bash + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -euo pipefail + for pr_number in 1192 1194 1195 1203 1206 1214 1219; do + pr_json="$( + curl -fsS \ + -H "Authorization: Bearer ${APP_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/ContextualWisdomLab/naruon/pulls/${pr_number}" + )" + state="$(jq -r '.state // empty' <<<"$pr_json")" + base_repo="$(jq -r '.base.repo.full_name // empty' <<<"$pr_json")" + head_repo="$(jq -r '.head.repo.full_name // empty' <<<"$pr_json")" + if [ "$state" != open ] || [ "$base_repo" != ContextualWisdomLab/naruon ] || [ "$head_repo" != ContextualWisdomLab/naruon ]; then + echo "Skipping non-open or cross-repository PR #${pr_number}." + continue + fi + base_ref="$(jq -r '.base.ref' <<<"$pr_json")" + base_sha="$(jq -r '.base.sha' <<<"$pr_json")" + head_ref="$(jq -r '.head.ref' <<<"$pr_json")" + head_sha="$(jq -r '.head.sha' <<<"$pr_json")" + + strix_payload="$( + jq -cn \ + --argjson pr_number "$pr_number" \ + --arg base_ref "$base_ref" \ + --arg base_sha "$base_sha" \ + --arg head_sha "$head_sha" \ + '{ + event_type: "strix-scan", + client_payload: { + target_repository: "ContextualWisdomLab/naruon", + pr_number: $pr_number, + pr_base_ref: $base_ref, + pr_base_sha: $base_sha, + pr_head_sha: $head_sha + } + }' + )" + opencode_payload="$( + jq -cn \ + --argjson pr_number "$pr_number" \ + --arg base_ref "$base_ref" \ + --arg base_sha "$base_sha" \ + --arg head_ref "$head_ref" \ + --arg head_sha "$head_sha" \ + '{ + event_type: "opencode-review", + client_payload: { + target_repository: "ContextualWisdomLab/naruon", + pr_number: $pr_number, + pr_base_ref: $base_ref, + pr_base_sha: $base_sha, + pr_head_ref: $head_ref, + pr_head_sha: $head_sha + } + }' + )" + for payload in "$strix_payload" "$opencode_payload"; do + curl -fsS \ + -X POST \ + -H "Authorization: Bearer ${APP_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + https://api.github.com/repos/ContextualWisdomLab/.github/dispatches \ + -d "$payload" + done + done From 640e8534f7052a6144149484680006d266b5dc3e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 11:30:33 +0900 Subject: [PATCH 3/6] chore(ci): remove repo-local review evidence dispatcher --- .../pr-1219-direct-review-dispatch.yml | 149 ------------------ 1 file changed, 149 deletions(-) delete mode 100644 .github/workflows/pr-1219-direct-review-dispatch.yml diff --git a/.github/workflows/pr-1219-direct-review-dispatch.yml b/.github/workflows/pr-1219-direct-review-dispatch.yml deleted file mode 100644 index 211263d76..000000000 --- a/.github/workflows/pr-1219-direct-review-dispatch.yml +++ /dev/null @@ -1,149 +0,0 @@ -name: Dispatch stable Naruon review evidence - -on: - pull_request: - branches: - - develop - types: [opened, reopened, synchronize] - -permissions: - contents: read - -concurrency: - group: dispatch-stable-naruon-review-evidence - cancel-in-progress: true - -jobs: - dispatch: - if: github.event.pull_request.head.ref == 'ux-improvement-disabled-buttons-13634237694443312021' - runs-on: ubuntu-latest - permissions: - contents: write - id-token: write - steps: - - name: Harden the runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Checkout dispatcher branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 - with: - ref: ux-improvement-disabled-buttons-13634237694443312021 - fetch-depth: 0 - - - name: Remove one-shot dispatcher before binding review metadata - shell: bash - run: | - set -euo pipefail - rm -- .github/workflows/pr-1219-direct-review-dispatch.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add .github/workflows/pr-1219-direct-review-dispatch.yml - git commit -m "chore(ci): remove one-shot review evidence dispatcher" - git push origin HEAD:ux-improvement-disabled-buttons-13634237694443312021 - - - name: Exchange OpenCode app token - id: app-token - shell: bash - env: - OIDC_AUDIENCE: opencode-github-action - OPENCODE_API_BASE_URL: https://api.opencode.ai - run: | - set -euo pipefail - request_url="${ACTIONS_ID_TOKEN_REQUEST_URL}" - separator='&' - case "$request_url" in - *\?*) ;; - *) separator='?' ;; - esac - oidc_response="$( - curl -fsS \ - -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ - "${request_url}${separator}audience=${OIDC_AUDIENCE}" - )" - oidc_token="$(jq -r '.value // empty' <<<"$oidc_response")" - test -n "$oidc_token" - token_response="$( - curl -fsS \ - -X POST \ - -H "Authorization: Bearer ${oidc_token}" \ - "${OPENCODE_API_BASE_URL}/exchange_github_app_token" - )" - app_token="$(jq -r '.token // empty' <<<"$token_response")" - test -n "$app_token" - echo "::add-mask::$app_token" - echo "token=$app_token" >>"$GITHUB_OUTPUT" - - - name: Dispatch exact-head Strix and OpenCode evidence - shell: bash - env: - APP_TOKEN: ${{ steps.app-token.outputs.token }} - run: | - set -euo pipefail - for pr_number in 1192 1194 1195 1203 1206 1214 1219; do - pr_json="$( - curl -fsS \ - -H "Authorization: Bearer ${APP_TOKEN}" \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "https://api.github.com/repos/ContextualWisdomLab/naruon/pulls/${pr_number}" - )" - state="$(jq -r '.state // empty' <<<"$pr_json")" - base_repo="$(jq -r '.base.repo.full_name // empty' <<<"$pr_json")" - head_repo="$(jq -r '.head.repo.full_name // empty' <<<"$pr_json")" - if [ "$state" != open ] || [ "$base_repo" != ContextualWisdomLab/naruon ] || [ "$head_repo" != ContextualWisdomLab/naruon ]; then - echo "Skipping non-open or cross-repository PR #${pr_number}." - continue - fi - base_ref="$(jq -r '.base.ref' <<<"$pr_json")" - base_sha="$(jq -r '.base.sha' <<<"$pr_json")" - head_ref="$(jq -r '.head.ref' <<<"$pr_json")" - head_sha="$(jq -r '.head.sha' <<<"$pr_json")" - - strix_payload="$( - jq -cn \ - --argjson pr_number "$pr_number" \ - --arg base_ref "$base_ref" \ - --arg base_sha "$base_sha" \ - --arg head_sha "$head_sha" \ - '{ - event_type: "strix-scan", - client_payload: { - target_repository: "ContextualWisdomLab/naruon", - pr_number: $pr_number, - pr_base_ref: $base_ref, - pr_base_sha: $base_sha, - pr_head_sha: $head_sha - } - }' - )" - opencode_payload="$( - jq -cn \ - --argjson pr_number "$pr_number" \ - --arg base_ref "$base_ref" \ - --arg base_sha "$base_sha" \ - --arg head_ref "$head_ref" \ - --arg head_sha "$head_sha" \ - '{ - event_type: "opencode-review", - client_payload: { - target_repository: "ContextualWisdomLab/naruon", - pr_number: $pr_number, - pr_base_ref: $base_ref, - pr_base_sha: $base_sha, - pr_head_ref: $head_ref, - pr_head_sha: $head_sha - } - }' - )" - for payload in "$strix_payload" "$opencode_payload"; do - curl -fsS \ - -X POST \ - -H "Authorization: Bearer ${APP_TOKEN}" \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - https://api.github.com/repos/ContextualWisdomLab/.github/dispatches \ - -d "$payload" - done - done From 15f79be31a60a468637c579ebdd54dc2981263ee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 11:32:45 +0900 Subject: [PATCH 4/6] test(accessibility): cover busy state for project confirmation --- .../ProjectsLayout.accessibility.test.tsx | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 frontend/src/components/ProjectsLayout.accessibility.test.tsx diff --git a/frontend/src/components/ProjectsLayout.accessibility.test.tsx b/frontend/src/components/ProjectsLayout.accessibility.test.tsx new file mode 100644 index 000000000..8f26b6675 --- /dev/null +++ b/frontend/src/components/ProjectsLayout.accessibility.test.tsx @@ -0,0 +1,132 @@ +/* @vitest-environment jsdom */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const apiClientMock = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn(), + getServerSessionClaims: vi.fn(), +})); + +vi.mock("@/lib/api-client", () => ({ apiClient: apiClientMock })); + +vi.mock("lucide-react", () => ({ + CalendarDays: () => , + CheckCircle2: () => , + Clock: () => , + FileText: () => , + FolderOpen: () => , + GitBranch: () => , + ListChecks: () => , + Network: () => , + Search: () => , + User: () => , +})); + +import { ProjectsLayout } from "./ProjectsLayout"; + +const candidate = { + candidate_uid: "project_candidate:alpha", + project_uid: "project_candidate:alpha", + title: "Project: Alpha Checkout", + status_code: "needs_review", + score: 0.87, + object_count: 1, + requirement_count: 1, + issue_count: 0, + milestone_count: 0, + deliverable_count: 0, + participant_count: 0, + source_segment_count: 1, + representative_object_uids: [], + citation_bundle: [], + updated_at: "2026-08-03T00:00:00Z", +}; + +async function flushAsyncWork() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe("ProjectsLayout accessibility", () => { + let root: Root | null = null; + let container: HTMLDivElement | null = null; + + afterEach(() => { + if (root) act(() => root?.unmount()); + root = null; + container?.remove(); + container = null; + vi.clearAllMocks(); + }); + + it("announces candidate confirmation as busy while the request is pending", async () => { + let resolveConfirmation: ((value: typeof candidate) => void) | undefined; + const pendingConfirmation = new Promise((resolve) => { + resolveConfirmation = resolve; + }); + + apiClientMock.get.mockImplementation((path: string) => { + if (path === "/api/webdav/folders") return Promise.resolve([]); + if (path === "/api/tasks") return Promise.resolve([]); + if (path === "/api/projects/candidates") { + return Promise.resolve({ candidates: [candidate] }); + } + if (path === "/api/projects/project_candidate%3Aalpha/traceability") { + return Promise.resolve({ + project_uid: candidate.project_uid, + candidate, + objects: [], + edges: [], + }); + } + return Promise.reject(new Error(`Unexpected GET path: ${path}`)); + }); + apiClientMock.getServerSessionClaims.mockResolvedValue({ + userId: "alice", + organizationId: "org-acme", + workspaceId: "workspace-org-acme", + }); + apiClientMock.post.mockReturnValue(pendingConfirmation); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + await flushAsyncWork(); + await flushAsyncWork(); + + const confirmButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("프로젝트 후보 확정"), + ); + expect(confirmButton).toBeDefined(); + expect(confirmButton?.getAttribute("aria-busy")).toBe("false"); + + await act(async () => { + confirmButton?.click(); + await Promise.resolve(); + }); + + expect(confirmButton?.disabled).toBe(true); + expect(confirmButton?.getAttribute("aria-busy")).toBe("true"); + expect(confirmButton?.textContent).toContain("확정 저장 중"); + expect(apiClientMock.post).toHaveBeenCalledWith( + "/api/projects/candidates/project_candidate%3Aalpha/confirm", + {}, + ); + + await act(async () => { + resolveConfirmation?.({ ...candidate, status_code: "confirmed" }); + await pendingConfirmation; + }); + + expect(confirmButton?.getAttribute("aria-busy")).toBe("false"); + expect(confirmButton?.textContent).toContain("프로젝트 후보 확정됨"); + }); +}); From 82ef7a904a5f43df41b7e87f9888dcd64a0e649e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 12:08:14 +0900 Subject: [PATCH 5/6] chore(scope): restore shared Palette learnings --- .jules/palette.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/.jules/palette.md b/.jules/palette.md index 5209e81d0..bdb0a4bbd 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -80,6 +80,3 @@ ## 2025-05-19 - Dynamic ARIA labels and robust disabled states for sidebar actions **Learning:** Hardcoded ARIA labels in mockups (like "출시 회의 일정 삭제") are often left intact during implementation, leading to incorrect screen reader announcements when different items are selected. In addition, action buttons that depend on selection state often lack correct visual and functional disabled states. **Action:** When implementing detail views or sidebars, always replace hardcoded mockup ARIA labels with dynamic data (e.g. `${event.title} 삭제`), and ensure action buttons are explicitly disabled (both functionally via `disabled` and visually via `opacity-50 cursor-not-allowed`) when their prerequisites (like a selected item or specific properties like location) are unmet. -## 2024-05-14 - Add aria-busy to loading buttons -**Learning:** Assistive technologies rely on `aria-busy` to understand when a form submission or button action is in a loading state, even if the button is visually disabled. Just setting `disabled={isLoading}` is not enough. -**Action:** Always ensure that `aria-busy={isLoading}` is set alongside `disabled={isLoading}` for action buttons that handle asynchronous tasks to communicate the specific reason for being disabled. From 53ca2afac7a7311f895674e2d7a988ac1853d200 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 09:29:02 +0900 Subject: [PATCH 6/6] test(accessibility): assert confirmation disabled lifecycle --- frontend/src/components/ProjectsLayout.accessibility.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/ProjectsLayout.accessibility.test.tsx b/frontend/src/components/ProjectsLayout.accessibility.test.tsx index 8f26b6675..9bb58aebe 100644 --- a/frontend/src/components/ProjectsLayout.accessibility.test.tsx +++ b/frontend/src/components/ProjectsLayout.accessibility.test.tsx @@ -106,6 +106,7 @@ describe("ProjectsLayout accessibility", () => { (button) => button.textContent?.includes("프로젝트 후보 확정"), ); expect(confirmButton).toBeDefined(); + expect(confirmButton?.disabled).toBe(false); expect(confirmButton?.getAttribute("aria-busy")).toBe("false"); await act(async () => { @@ -126,7 +127,8 @@ describe("ProjectsLayout accessibility", () => { await pendingConfirmation; }); + expect(confirmButton?.disabled).toBe(true); expect(confirmButton?.getAttribute("aria-busy")).toBe("false"); expect(confirmButton?.textContent).toContain("프로젝트 후보 확정됨"); }); -}); +}); \ No newline at end of file