Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
f1f589b
✨ [Palette] SettingsLayout λΉ„ν™œμ„±ν™” λ²„νŠΌ μ ‘κ·Όμ„± 툴팁 κ°œμ„ 
seonghobae Aug 23, 2026
fc84ac3
✨ [Palette] SettingsLayout λΉ„ν™œμ„±ν™” λ²„νŠΌ μ ‘κ·Όμ„± 툴팁 κ°œμ„ 
seonghobae Aug 23, 2026
3bb260f
✨ [Palette] SettingsLayout λΉ„ν™œμ„±ν™” λ²„νŠΌ μ ‘κ·Όμ„± 툴팁 κ°œμ„ 
seonghobae Aug 23, 2026
1bafde9
✨ [Palette] SettingsLayout λΉ„ν™œμ„±ν™” λ²„νŠΌ μ ‘κ·Όμ„± 툴팁 κ°œμ„ 
seonghobae Aug 23, 2026
f900abe
test(settings): cover accessible unavailable actions
seonghobae Aug 24, 2026
bdc95be
chore(settings): restore branch while repair remains open
seonghobae Aug 24, 2026
7f74bc2
fix(settings): expose async action readiness
seonghobae Aug 24, 2026
4f6c07d
fix(settings): make disabled action guidance accessible
seonghobae Aug 24, 2026
a2f9c6f
fix(settings): preserve enabled action guidance
seonghobae Aug 24, 2026
924b71c
fix(settings): keep enabled logout tooltip accessible
seonghobae Aug 24, 2026
89efb89
fix(a11y): attach action guidance to buttons
seonghobae Aug 24, 2026
39a95cd
✨ [Palette] SettingsLayout λΉ„ν™œμ„±ν™” λ²„νŠΌ μ ‘κ·Όμ„± 툴팁 및 focus 관리 κ°œμ„ 
seonghobae Aug 24, 2026
9023ea7
fix(a11y): distinguish session loading from logout unavailable
seonghobae Aug 24, 2026
db4fd6e
✨ [Palette] SettingsLayout OIDC λ‘œκ·Έμ•„μ›ƒ λ²„νŠΌ λΉ„ν™œμ„±ν™” μ ‘κ·Όμ„± κ°œμ„  및 μ„Έμ…˜ λ‘œλ”© μƒνƒœ 반영
seonghobae Aug 24, 2026
dfe7054
fix(a11y): preserve settings action state guidance
seonghobae Aug 24, 2026
a9d8f78
✨ [Palette] SettingsLayout OIDC λ‘œκ·Έμ•„μ›ƒ λ²„νŠΌ λΉ„ν™œμ„±ν™” μ ‘κ·Όμ„± κ°œμ„  및 μ„Έμ…˜ λ‘œλ”© μƒνƒœ 반영
seonghobae Aug 24, 2026
a89925e
fix(a11y): unify account-save unavailable reason for sr-only and titl…
seonghobae Aug 24, 2026
253cfb3
✨ [Palette] SettingsLayout OIDC λ‘œκ·Έμ•„μ›ƒ λ²„νŠΌ λΉ„ν™œμ„±ν™” μ ‘κ·Όμ„± κ°œμ„  및 μ„Έμ…˜ λ‘œλ”© μƒνƒœ 반영
seonghobae Aug 24, 2026
0606794
fix(frontend): prevent duplicate account saves
seonghobae Aug 24, 2026
88d6585
✨ [Palette] Reset to user-provided remediation for PR review
seonghobae Aug 24, 2026
ad96743
fix(settings): guard duplicate account saves
seonghobae Aug 24, 2026
379c89d
✨ [Palette] Reset to user-provided remediation for PR review
seonghobae Aug 24, 2026
b468e90
fix(settings): announce account load failures accurately
seonghobae Aug 24, 2026
3fac623
✨ [Palette] Reset to user-provided remediation for PR review
seonghobae Aug 24, 2026
439efbc
fix(settings): announce account load failures accurately
seonghobae Aug 24, 2026
cbee0e4
fix(settings): keep account retries available after save errors
seonghobae Aug 24, 2026
a8cd4df
✨ [Palette] Reset to user-provided remediation for PR review
seonghobae Aug 24, 2026
24950b2
fix(settings): keep account retries available after save errors
seonghobae Aug 24, 2026
637acdd
fix(settings): make OIDC setup guidance actionable
seonghobae Aug 24, 2026
fcd9e2e
✨ [Palette] Reset to user-provided remediation for PR review
seonghobae Aug 24, 2026
1420e8a
fix(settings): make OIDC setup guidance actionable
seonghobae Aug 24, 2026
806b3b0
✨ [Palette] Reset to user-provided remediation for PR review
seonghobae Aug 24, 2026
eef7e8d
✨ [Palette] Reset to user-provided remediation for PR review
seonghobae Aug 24, 2026
ba5fedf
fix(a11y): expose unavailable action guidance
seonghobae Aug 31, 2026
78b3135
Merge remote-tracking branch 'origin/develop' into codex/pr1453-conso…
seonghobae Aug 31, 2026
0b3307b
✨ [Palette] Reset to user-provided remediation for PR review
seonghobae Aug 31, 2026
8d92fa5
✨ [Palette] Reset to user-provided remediation for PR review
seonghobae Aug 31, 2026
81363f4
fix(frontend): localize unavailable control titles
seonghobae Aug 31, 2026
588bced
✨ [Palette] Reset to user-provided remediation for PR review
seonghobae Aug 31, 2026
e15c079
Revert "✨ [Palette] Reset to user-provided remediation for PR review"
seonghobae Aug 31, 2026
14b0217
✨ [Palette] Reset to user-provided remediation for PR review
seonghobae Aug 31, 2026
ba8980f
✨ [Palette] Reset to user-provided remediation for PR review
seonghobae Sep 1, 2026
a0ce13e
✨ [Palette] Reset to user-provided remediation for PR review
seonghobae Sep 1, 2026
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
2 changes: 1 addition & 1 deletion docs/product-technical-gap-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
**Baseline version:** 1.2
**Observed on:** 2026-08-26 (Asia/Seoul)
**Observed protected branch (current scan; row Base-SHA values remain historical):** `develop@e5e99b4e3bb081b92c602358878856536030e2ca`
**Observed product version:** `0.14.4`
**Observed product version:** `0.14.4`
**Canonical completion issue:** [#1428](https://github.com/ContextualWisdomLab/naruon/issues/1428)

**Inventory observation:** the 106-PR open surface below is a fresh live
Expand Down
32 changes: 16 additions & 16 deletions frontend/src/components/NetworkGraph.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ describe("NetworkGraph", () => {
expect(Network).not.toHaveBeenCalled();
});

it("describes the unavailable first-relationship action programmatically", async () => {
it("keeps the unavailable first-relationship reason visible and the guarded action focusable", async () => {
const fetchMock = vi.fn(() =>
Promise.resolve(
jsonResponse({
Expand All @@ -123,22 +123,22 @@ describe("NetworkGraph", () => {
await flushAsyncWork();

const mountedContainer = getMountedContainer();
const wrapper = mountedContainer.querySelector('span[tabindex="0"]');
const button = mountedContainer.querySelector('button[disabled]');
const descriptionId = wrapper?.getAttribute("aria-describedby");

expect(wrapper).toBeInstanceOf(HTMLSpanElement);
expect(wrapper?.className).toContain("cursor-not-allowed");
expect(wrapper?.getAttribute("title")).toBe("ν‘œμ‹œν•  관계 데이터가 μ—†μŠ΅λ‹ˆλ‹€.");
expect(wrapper?.className).toContain("focus-visible:ring-2");
expect(descriptionId).toBeTruthy();
expect(document.getElementById(descriptionId ?? "")?.textContent).toBe(
"ν‘œμ‹œν•  관계 데이터가 μ—†μŠ΅λ‹ˆλ‹€.",
);
const button = mountedContainer.querySelector('button[aria-disabled="true"]');
const descriptionId = button?.getAttribute("aria-describedby");

expect(button).toBeInstanceOf(HTMLButtonElement);
expect((button as HTMLButtonElement).disabled).toBe(true);
expect(button?.className).toContain("disabled:cursor-not-allowed");
expect(button?.className).toContain("pointer-events-none");
expect((button as HTMLButtonElement).disabled).toBe(false);
expect(button?.className).toContain("aria-disabled:cursor-not-allowed");
expect(button?.className).toContain("focus-visible:ring-2");
expect(descriptionId).toBeTruthy();
const reason = document.getElementById(descriptionId ?? "");
expect(reason?.className).not.toContain("sr-only");
expect(reason?.textContent).toBe("μ—°κ²°λœ 관계가 생기면 첫 관계λ₯Ό 확인할 수 μžˆμŠ΅λ‹ˆλ‹€.");

(button as HTMLButtonElement).focus();
expect(document.activeElement).toBe(button);
button?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
expect(mountedContainer.textContent).not.toContain("첫 관계λ₯Ό μ„ νƒν–ˆμŠ΅λ‹ˆλ‹€.");
});

it("announces graph loading failures as a polite alert", async () => {
Expand Down
37 changes: 16 additions & 21 deletions frontend/src/components/NetworkGraph.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { useEffect, useId, useMemo, useRef, useState, type MouseEvent } from 'react';
import { Network } from 'vis-network';

interface Node {
Expand Down Expand Up @@ -322,8 +322,11 @@ export default function NetworkGraph() {
networkRef.current?.fit?.({ nodes: [node.id], animation: false });
};

const handleSelectFirstRelationship = () => {
if (!firstEdge) return;
const handleSelectFirstRelationship = (event: MouseEvent<HTMLButtonElement>) => {
if (!firstEdge) {
event.preventDefault();
return;
}
selectRelationship(firstEdge, '첫 관계λ₯Ό μ„ νƒν–ˆμŠ΅λ‹ˆλ‹€.');
};

Expand Down Expand Up @@ -390,30 +393,22 @@ export default function NetworkGraph() {
</p>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<span
tabIndex={!firstEdge ? 0 : undefined}
aria-describedby={!firstEdge ? unavailableRelationshipDescriptionId : undefined}
title={!firstEdge ? "ν‘œμ‹œν•  관계 데이터가 μ—†μŠ΅λ‹ˆλ‹€." : undefined}
className={
!firstEdge
? "cursor-not-allowed rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
: undefined
}
>
{!firstEdge && (
<span id={unavailableRelationshipDescriptionId} className="sr-only">
ν‘œμ‹œν•  관계 데이터가 μ—†μŠ΅λ‹ˆλ‹€.
</span>
)}
<div className="flex max-w-xs flex-col items-start gap-2">
<button
type="button"
onClick={handleSelectFirstRelationship}
disabled={!firstEdge}
className={`rounded-md border border-primary/25 bg-background px-3 py-2 text-xs font-bold text-primary transition hover:bg-primary/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50 ${!firstEdge ? "pointer-events-none" : ""}`}
aria-disabled={!firstEdge}
aria-describedby={!firstEdge ? unavailableRelationshipDescriptionId : undefined}
className="rounded-md border border-primary/25 bg-background px-3 py-2 text-xs font-bold text-primary transition hover:bg-primary/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 aria-disabled:cursor-not-allowed aria-disabled:opacity-50"
>
첫 관계 보기
</button>
</span>
{!firstEdge && (
<p id={unavailableRelationshipDescriptionId} className="text-xs font-semibold leading-5 text-muted-foreground">
μ—°κ²°λœ 관계가 생기면 첫 관계λ₯Ό 확인할 수 μžˆμŠ΅λ‹ˆλ‹€.
</p>
)}
</div>
<button
type="button"
onClick={handleZoomGraph}
Expand Down
116 changes: 116 additions & 0 deletions frontend/src/components/SettingsLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,12 @@ function jsonResponse(body: unknown) {
describe("SettingsLayout", () => {
let root: Root | null = null;
let container: HTMLDivElement | null = null;
let failAccountConfigLoad = false;
let failAccountConfigSave = false;

beforeEach(() => {
failAccountConfigLoad = false;
failAccountConfigSave = false;
oidcMocks.getOidcBrowserConfig.mockReturnValue({
issuerUrl: "https://login.example.com/realms/naruon",
clientId: "naruon-web",
Expand Down Expand Up @@ -246,6 +250,9 @@ describe("SettingsLayout", () => {
]);
}
if (String(input) === "/api/accounts/config" && init?.method === "PUT") {
if (failAccountConfigSave) {
return Promise.reject(new Error("계정 μ„€μ • μ €μž₯ μ‹€νŒ¨"));
}
const body = JSON.parse(String(init.body));
return jsonResponse({
user_id: "default",
Expand All @@ -267,6 +274,9 @@ describe("SettingsLayout", () => {
});
}
if (String(input) === "/api/accounts/config") {
if (failAccountConfigLoad) {
return Promise.reject(new Error("계정 μ„€μ • 쑰회 μ‹€νŒ¨"));
}
return jsonResponse({
user_id: "default",
smtp_server: "smtp.example.com",
Expand Down Expand Up @@ -491,6 +501,54 @@ describe("SettingsLayout", () => {
});
});

it("announces session loading before enabling logout", async () => {
oidcMocks.getOidcBrowserConfig.mockReturnValue(null);
const defaultFetch = vi.mocked(fetch);
vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
if (String(input) === "/auth/session") {
return new Promise<Response>(() => undefined);
}
return defaultFetch(input, init);
}));

container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);

await act(async () => {
root?.render(<SettingsLayout />);
await Promise.resolve();
});

const developerTab = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "개발자",
);
await act(async () => {
developerTab?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});

const logoutButton = Array.from(container.querySelectorAll("button")).find((button) => button.textContent === "λ‘œκ·Έμ•„μ›ƒ");
const loginButton = Array.from(container.querySelectorAll("button")).find((button) => button.textContent === "OIDC 둜그인");
const loginReason = container.querySelector('#oidc-login-availability');
expect(loginButton?.getAttribute("aria-disabled")).toBe("true");
expect(loginReason?.className).not.toContain("sr-only");
expect(loginReason?.textContent).toBe("OIDC λ‘œκ·ΈμΈμ„ μ‚¬μš©ν•˜λ €λ©΄ κ΄€λ¦¬μžμ—κ²Œ OIDC 섀정을 μš”μ²­ν•˜μ„Έμš”.");
expect(logoutButton?.getAttribute("aria-disabled")).toBe("true");
expect(logoutButton?.getAttribute("aria-describedby")).toBe("oidc-logout-availability");
const logoutReason = container.querySelector('#oidc-logout-availability');
expect(logoutReason?.className).not.toContain("sr-only");
expect(logoutReason?.textContent).toBe("둜그인 μ„Έμ…˜μ„ ν™•μΈν•˜λŠ” μ€‘μž…λ‹ˆλ‹€. μž μ‹œλ§Œ κΈ°λ‹€λ € μ£Όμ„Έμš”.");
logoutButton?.focus();
expect(document.activeElement).toBe(logoutButton);

await act(async () => {
logoutButton?.click();
await Promise.resolve();
});
expect(oidcMocks.clearOidcSession).not.toHaveBeenCalled();
});

it("loads and saves source-backed mail account settings without public identity headers or secret replay", async () => {
container = document.createElement("div");
document.body.appendChild(container);
Expand Down Expand Up @@ -598,6 +656,64 @@ describe("SettingsLayout", () => {
expect(container.textContent).toContain("계정 섀정을 μ €μž₯ν–ˆμŠ΅λ‹ˆλ‹€");
});

it("announces an account configuration load error instead of a loading state", async () => {
failAccountConfigLoad = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);

await act(async () => {
root?.render(<SettingsLayout />);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});

const accountButton = Array.from(container.querySelectorAll("button")).find((button) => button.textContent === "μ—°κ²° 계정");
await act(async () => {
accountButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});

const saveButton = Array.from(container.querySelectorAll("button")).find((button) => button.textContent === "계정 μ„€μ • μ €μž₯");
expect(saveButton?.getAttribute("aria-describedby")).toBe("account-save-availability");
const accountReason = container.querySelector('#account-save-availability');
expect(accountReason?.className).not.toContain("sr-only");
expect(accountReason?.textContent).toBe("계정 섀정을 λΆˆλŸ¬μ˜€μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€. μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•˜κ±°λ‚˜ κ΄€λ¦¬μžμ—κ²Œ λ¬Έμ˜ν•˜μ„Έμš”.");
expect(container.textContent).not.toContain("계정 섀정을 λΆˆλŸ¬μ˜€λŠ” μ€‘μž…λ‹ˆλ‹€. μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•˜μ„Έμš”.");
});

it("keeps account saving available after a save failure", async () => {
failAccountConfigSave = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);

await act(async () => {
root?.render(<SettingsLayout />);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});

const accountButton = Array.from(container.querySelectorAll("button")).find((button) => button.textContent === "μ—°κ²° 계정");
await act(async () => {
accountButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
const accountForm = container.querySelector('form');
await act(async () => {
accountForm?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
await Promise.resolve();
await Promise.resolve();
});

const updatedSaveButton = Array.from(container.querySelectorAll("button")).find((button) => button.textContent === "계정 μ„€μ • μ €μž₯");
expect(container.textContent).toContain("API request failed");
expect(updatedSaveButton?.getAttribute("aria-disabled")).toBeNull();
expect(updatedSaveButton?.getAttribute("aria-describedby")).toBeNull();
});

it("loads and saves AI model registry entries without public identity headers or secret replay", async () => {
container = document.createElement("div");
document.body.appendChild(container);
Expand Down
Loading
Loading