유스케이스 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:
parent
4771b97c3a
commit
cf899b7f15
37 changed files with 11989 additions and 67 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue