유스케이스 TDD 16테마 스펙과 접근성 제품 결함 수정

- 지원 티켓 작성 UI 신설(설정)·관리자 해결 노트 입력 신설(Admin): 서버 계약은 있었으나
  웹 진입점이 없던 2결함
- a11y: outline 채널 포커스 링(ui/shell css), 세션바 44px 터치 타깃, 청록 하드코딩
  그라디언트를 테마 토큰으로 교체, 설정 라벨/헤딩/대비·리뷰 44px·모바일 오버플로 수정
- uc-*.spec.ts 16테마 239 시나리오 신규(수집 1109 tests/61 files), 기존 스펙 5종 계약 드리프트 교정
- breakpoint-sweep: widthsFor 솎아내기가 실기기 대표 폭(360/390/1024)을 탈락시키는 테스트 결함 수정
  — keep 시드를 전체 DEVICE_WIDTHS로, 3연폭 예외는 솎아내기 발생 여부 기준으로
- 검증: tsc PASS, 병렬 게이트 1040 passed(데스크톱 밀도 충돌 1건 해소 후 focused 68/68 GREEN),
  직렬 게이트 52/5/4 삼각화 — breakpoint(테스트 결함)·kb(낡은 dev API 재기동)·voice(일시적) 해소,
  교사 재평가 2건은 엔진 구독 한도(resets 3pm)로 skip 후 재검증 대기
- 문서/SSOT: HANDOFF·TODO·대시보드·testing 가이드 동기화, 증거 usecase-tdd-2026-08-18.json,
  SSOT 체커 PASS(59)
This commit is contained in:
Yun Chan 2026-08-18 11:47:58 +09:00
parent 4771b97c3a
commit cf899b7f15
37 changed files with 11989 additions and 67 deletions

View file

@ -240,6 +240,12 @@
color: var(--text-strong);
text-decoration: none;
}
/* 포커스 가시성 보태니컬 is-active 규칙이 box-shadow 점유해 전역
:focus-visible 링이 사라지므로 outline 채널로 링을 보장한다(WCAG 2.4.7). */
.vg-nav__item:focus-visible {
outline: 3px solid var(--focus-ring);
outline-offset: 2px;
}
.vg-nav__item .vg-nav__ic {
flex: none;
color: var(--text-muted);

View file

@ -148,6 +148,12 @@
border-color: color-mix(in srgb, var(--accent) 48%, var(--glass-border));
background: var(--glass-specular), var(--glass-surface-strong);
}
/* 포커스 가시성 자체 box-shadow(글래스 인셋) 전역 :focus-visible (box-shadow)
덮으므로, 아무도 쓰지 않는 outline 채널로 링을 그린다(WCAG 2.4.7). */
.vg-surface.vg-surface--interactive:focus-visible {
outline: 3px solid var(--focus-ring);
outline-offset: 2px;
}
.vg-surface.vg-surface--flat {
box-shadow: none;
}

View file

@ -682,6 +682,8 @@ export type AvatarUploadResponse = ApiSchema<"AvatarUploadResponse">;
export type VoicePresetResponse = ApiSchema<"VoicePresetResponse">;
export type UserSupportTicketListItem = ApiSchema<"UserSupportTicketListItem">;
export type UserSupportTicketsResponse = ApiSchema<"UserSupportTicketsResponse">;
export type UserSupportTicketCreateRequest = ApiSchema<"UserSupportTicketRequest">;
export type UserSupportTicketCreateResponse = ApiSchema<"UserSupportTicketResponse">;
export type UserPrepostMeasureRequest = ApiSchema<"UserPrepostMeasureRequest">;
export type UserPrepostMeasureItem = ApiSchema<"UserPrepostMeasureItem">;
export type UserPrepostMeasuresResponse = ApiSchema<"UserPrepostMeasuresResponse">;
@ -712,6 +714,8 @@ export const userApi = {
apiFetch<UserPreferencesResponse>("/users/me/preferences", { method: "PATCH", body }),
voicePresets: () => api.get<VoicePresetResponse[]>("/users/me/voice-presets"),
supportTickets: () => api.get<UserSupportTicketsResponse>("/users/support-tickets"),
createSupportTicket: (body: UserSupportTicketCreateRequest) =>
api.post<UserSupportTicketCreateResponse>("/users/support-tickets", body),
prepostMeasures: (pilotId = "phase3-pilot-draft") =>
api.get<UserPrepostMeasuresResponse>(
`/users/me/prepost-measures?pilot_id=${encodeURIComponent(pilotId)}`,

View file

@ -406,6 +406,7 @@ interface TicketActionsProps {
onUpdateStatus: (
ticket: AdminSupportTicket,
status: AdminSupportTicket["status"],
resolutionNote?: string,
) => Promise<void>;
onUpdateParent: (
ticket: AdminSupportTicket,
@ -421,6 +422,8 @@ function TicketActions({
onUpdateParent,
}: TicketActionsProps) {
const [pending, setPending] = useState(false);
// 해결 노트 — 학습자 설정 화면(resolution_note)에 그대로 보이는 처리 메모.
const [resolutionNote, setResolutionNote] = useState("");
const pendingRef = useRef(false);
const run = (action: () => Promise<void>) => {
@ -438,6 +441,16 @@ function TicketActions({
return (
<div className="vgops-ticket__actions" aria-busy={pending}>
<textarea
className="vgops-ticket__note-input"
rows={2}
value={resolutionNote}
aria-label="해결 노트"
placeholder="해결 시 학습자에게 보일 처리 메모"
maxLength={2000}
disabled={pending}
onChange={(event) => setResolutionNote(event.target.value)}
/>
{canLinkDuplicate ? (
<Button
size="sm"
@ -469,7 +482,11 @@ function TicketActions({
<Button
size="sm"
variant="primary"
onClick={() => run(() => onUpdateStatus(ticket, "resolved"))}
onClick={() =>
run(() =>
onUpdateStatus(ticket, "resolved", resolutionNote.trim() || undefined),
)
}
disabled={pending}
>
@ -1073,9 +1090,13 @@ export default function Admin({ section = "overview" }: AdminProps) {
const updateTicketStatus = async (
ticket: AdminSupportTicket,
status: AdminSupportTicket["status"],
resolutionNote?: string,
) => {
try {
const updated = await adminApi.updateTicket(ticket.ticket_id, { status });
const updated = await adminApi.updateTicket(ticket.ticket_id, {
status,
...(resolutionNote ? { resolution_note: resolutionNote } : {}),
});
setTicketsError(null);
setTickets((current) =>
current

View file

@ -1260,7 +1260,8 @@ export default function SessionReview() {
{data.client.initial}
</span>
<div>
<div className="sr-head__name">{data.client.name}</div>
{/* 페이지 유일 h1 — 스크린리더 헤딩 구조의 시작점(WCAG 1.3.1/2.4.6) */}
<h1 className="sr-head__name">{data.client.name}</h1>
{/* D3: 날짜·소요시간만 가운뎃점 1개로 묶고, 페르소나 메타는 아래 줄 칩으로 분리 */}
<div className="sr-head__meta">
<span className="sr-mono">{data.date}</span>

View file

@ -235,6 +235,12 @@ export default function Settings() {
const [preferencesReady, setPreferencesReady] = useState(false);
const [supportTicketsReady, setSupportTicketsReady] = useState(false);
const [supportTicketsError, setSupportTicketsError] = useState<string | null>(null);
// 지원 요청 작성 — 접수 목록을 보는 자리에서 새 티켓을 접수한다(POST /users/support-tickets).
const [supportComposeOpen, setSupportComposeOpen] = useState(false);
const [supportSubject, setSupportSubject] = useState("");
const [supportBody, setSupportBody] = useState("");
const [supportSubmitting, setSupportSubmitting] = useState(false);
const [supportComposeError, setSupportComposeError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [savedKey, setSavedKey] = useState<string | null>(null);
@ -264,6 +270,39 @@ export default function Settings() {
savedTimer.current = window.setTimeout(() => setSavedKey(null), 2400);
};
const submitSupportTicket = async () => {
const subject = supportSubject.trim();
const body = supportBody.trim();
if (!subject || !body || supportSubmitting) return;
setSupportSubmitting(true);
setSupportComposeError(null);
try {
await userApi.createSupportTicket({
subject,
body,
category: "other",
priority: "normal",
source_path: "/settings",
});
// 접수 직후 목록을 재조회해 서버 원장의 상태를 그대로 보여준다.
const nextTickets = await userApi.supportTickets().catch(() => null);
if (nextTickets) {
setSupportTickets(nextTickets);
setSupportTicketsError(null);
}
setSupportComposeOpen(false);
setSupportSubject("");
setSupportBody("");
flashSaved("support");
} catch (err) {
setSupportComposeError(
err instanceof Error ? err.message : "지원 요청을 접수하지 못했습니다.",
);
} finally {
setSupportSubmitting(false);
}
};
const updateEngineConfig = useCallback((patch: Partial<AdminEngineConfigResponse>) => {
if (!engineConfigRef.current) return;
engineConfigRef.current = { ...engineConfigRef.current, ...patch };
@ -841,13 +880,75 @@ export default function Settings() {
.
</div>
</div>
{supportTickets ? (
<Badge tone={supportTickets.durable ? "accent" : "neutral"}>
{supportTickets.tickets.length}
</Badge>
) : null}
<div className="vg-set__support-head-ops">
{supportTickets ? (
<Badge tone={supportTickets.durable ? "accent" : "neutral"}>
{supportTickets.tickets.length}
</Badge>
) : null}
<Button
size="sm"
variant="secondary"
aria-expanded={supportComposeOpen}
onClick={() => setSupportComposeOpen((open) => !open)}
>
</Button>
</div>
</div>
{supportComposeOpen ? (
<div className={surfaceClassName("vg-set__support-compose", { variant: "inset", flat: true })}>
<Field>
<Input
value={supportSubject}
aria-label="요청 제목"
placeholder="어떤 문제인지 한 줄로 요약해 주세요"
maxLength={200}
disabled={supportSubmitting}
onChange={(event) => setSupportSubject(event.target.value)}
/>
</Field>
<Field>
<textarea
className="vg-input vg-set__support-textarea"
rows={4}
value={supportBody}
aria-label="요청 내용"
placeholder="언제, 어떤 화면에서, 무엇이 발생했는지 적어 주세요."
maxLength={4000}
disabled={supportSubmitting}
onChange={(event) => setSupportBody(event.target.value)}
/>
</Field>
{supportComposeError ? (
<div className="vg-set__state vg-set__state--warn" role="alert">
<span className="vg-set__state-dot" aria-hidden="true" />
<span>{supportComposeError}</span>
</div>
) : null}
<div className="vg-set__support-compose-foot">
<span> .</span>
<Button
size="sm"
disabled={
supportSubmitting || !supportSubject.trim() || !supportBody.trim()
}
onClick={() => void submitSupportTicket()}
>
{supportSubmitting ? "접수 중" : "요청 전송"}
</Button>
</div>
</div>
) : null}
{savedKey === "support" ? (
<div className="vg-set__state" role="status">
<span className="vg-set__state-dot" aria-hidden="true" />
<span> .</span>
</div>
) : null}
{supportTicketsError ? (
<div className="vg-set__state vg-set__state--warn" role="alert">
<span className="vg-set__state-dot" aria-hidden="true" />

View file

@ -1457,6 +1457,26 @@
max-width: 190px;
}
/* 해결 노트 — 해결 처리와 함께 학습자에게 보일 처리 메모를 남긴다. */
.vgops-ticket__note-input {
flex: 1 1 100%;
min-width: 0;
min-height: 40px;
resize: vertical;
border: 1px solid var(--hair);
border-radius: var(--radius-sm);
background: var(--bg-surface);
color: var(--text-body);
padding: 6px 9px;
font: inherit;
font-size: var(--fs-xs);
line-height: 1.45;
}
.vgops-ticket__note-input::placeholder {
color: var(--text-muted);
}
.vgops-ticket__actions .vg-btn {
white-space: nowrap;
}

View file

@ -534,6 +534,7 @@
}
.sr-actions .vg-btn {
width: 100%;
min-height: 44px; /* 터치 타깃 44×44 — 전 폭 공통(WCAG 2.5.5) */
}
.sr-share-note {
min-width: 0;
@ -845,6 +846,8 @@
}
.sr-chip-toggle {
flex: none;
/* 데스크톱은 밀도 계약(34px, session-review.spec 분기) 지킨다
터치 44px은 아래 좁은 미디어쿼리가 소유한다. */
min-height: 30px;
padding: 4px 12px;
border: 1px solid var(--border-subtle);

View file

@ -125,7 +125,7 @@
min-width: 0;
}
.sx-sessionbar button {
min-height: 32px;
min-height: 44px; /* 터치 타깃 44×44 (WCAG 2.5.5 / 플랫폼 HIG) */
padding: 7px 10px;
border: 1px solid var(--glass-inset-border);
border-radius: var(--radius-sm);
@ -5113,7 +5113,7 @@
box-shadow: var(--glass-edge-shadow), var(--glass-shadow);
}
.sx-page--active .sx-sessionbar button {
min-height: 42px;
min-height: 44px; /* 데스크톱에서도 44px 터치 계약 유지(WCAG 2.5.5) */
padding: 0 16px;
border-color: rgba(159, 142, 111, 0.23);
border-radius: 14px;
@ -5495,7 +5495,9 @@
min-width: 134px;
min-height: 56px;
border-radius: 12px;
background: linear-gradient(145deg, #3d947d, #267762);
/* 하드코딩 청록 그라디언트(#3d947d#267762) 다크 테마의
--text-on-accent(#0f1a17) 3.3:1로 4.5:1 미달 테마 토큰으로 교체. */
background: linear-gradient(145deg, var(--accent), var(--accent-deep));
box-shadow: 0 8px 18px rgba(43, 119, 97, 0.2);
font-size: 15px;
}

View file

@ -1542,6 +1542,34 @@ body[data-page="settings"] .vg-set__ticket-note b {
body[data-page="settings"] .vg-set__ticket-note--empty {
color: var(--text-muted);
}
/* 지원 요청 작성 — 목록 위에서 새 티켓을 접수하는 인라인 폼. */
body[data-page="settings"] .vg-set__support-head-ops {
display: flex;
align-items: flex-start;
gap: 8px;
}
body[data-page="settings"] .vg-set__support-compose {
display: grid;
gap: 10px;
margin-bottom: var(--sp-4);
padding: 12px;
border-radius: var(--radius);
}
body[data-page="settings"] .vg-set__support-textarea {
resize: vertical;
line-height: 1.55;
}
body[data-page="settings"] .vg-set__support-compose-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-4);
}
body[data-page="settings"] .vg-set__support-compose-foot > span {
color: var(--text-muted);
font-size: var(--fs-xs);
line-height: 1.45;
}
body[data-page="settings"] .vg-set__foot {
margin-top: 12px;
padding-top: 12px;