From 391639c1de90fc21218f53db40c8df3492427efd Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Sun, 28 Jun 2026 21:46:22 +0900 Subject: [PATCH] =?UTF-8?q?=ED=8F=89=EA=B0=80=20=EB=B0=8F=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EA=B5=AC=EC=A1=B0=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENT.md | 80 +- AGENTS.md | 3 + CLAUDE.md | 157 +- README.md | 4 +- .../api/app/services/case_worksheet_rubric.py | 140 ++ apps/api/app/services/guardrail.py | 43 +- apps/api/app/services/pii_masking_eval.py | 82 +- apps/api/app/session_read_model.py | 138 +- apps/api/app/test_case_worksheet_rubric.py | 93 + apps/api/app/test_pii_masking_eval.py | 85 +- apps/web/e2e/layout-visual-gate.spec.ts | 4 + apps/web/e2e/teacher.spec.ts | 102 ++ apps/web/index.html | 2 + apps/web/public/favicon.svg | 5 + .../src/components/avatar/ClientAvatar.tsx | 60 +- .../src/components/avatar/client-avatar.css | 54 + apps/web/src/components/ui/Icon.tsx | 7 + apps/web/src/pages/LearnerHome.tsx | 1569 +---------------- apps/web/src/pages/Login.tsx | 512 +----- apps/web/src/pages/Onboarding.tsx | 258 +-- apps/web/src/pages/PendingApproval.tsx | 99 +- apps/web/src/pages/PersonaStudio.tsx | 949 +++------- apps/web/src/pages/Professor.tsx | 920 +--------- apps/web/src/pages/learner-home.css | 1564 ++++++++++++++++ apps/web/src/pages/login/LoginBrand.tsx | 61 + apps/web/src/pages/login/LoginPanel.tsx | 169 ++ apps/web/src/pages/login/login.css | 484 +++++ apps/web/src/pages/onboarding.css | 349 ++-- apps/web/src/pages/pending-approval.css | 94 + apps/web/src/pages/persona-studio.css | 688 ++++++++ apps/web/src/pages/professor.css | 914 ++++++++++ .../pii-masking-eval-input.schema.json | 74 + .../pii-masking-eval-report.schema.json | 99 ++ data/privacy/pii-masking-ko-fixtures.json | 134 ++ data/rubrics/case-worksheet-rubric.json | 73 + .../rubrics/case-worksheet-rubric.schema.json | 99 ++ docs/decisions/backend-node-transition.md | 1 + docs/dev_dashboard.html | 40 +- docs/guides/architecture.md | 9 + docs/guides/source-docs-and-gaps.md | 16 +- docs/ops/backlog-2026-06-26.md | 10 +- docs/ops/handoff-avatar-seoyeon-2026-06-27.md | 4 +- scripts/check-case-worksheet-rubric.py | 62 + scripts/evaluate-pii-masking.py | 7 +- 44 files changed, 5816 insertions(+), 4501 deletions(-) create mode 100644 apps/api/app/services/case_worksheet_rubric.py create mode 100644 apps/api/app/test_case_worksheet_rubric.py create mode 100644 apps/web/public/favicon.svg create mode 100644 apps/web/src/components/avatar/client-avatar.css create mode 100644 apps/web/src/pages/learner-home.css create mode 100644 apps/web/src/pages/login/LoginBrand.tsx create mode 100644 apps/web/src/pages/login/LoginPanel.tsx create mode 100644 apps/web/src/pages/login/login.css create mode 100644 apps/web/src/pages/pending-approval.css create mode 100644 apps/web/src/pages/persona-studio.css create mode 100644 apps/web/src/pages/professor.css create mode 100644 data/privacy/pii-masking-eval-input.schema.json create mode 100644 data/privacy/pii-masking-eval-report.schema.json create mode 100644 data/rubrics/case-worksheet-rubric.json create mode 100644 data/rubrics/case-worksheet-rubric.schema.json create mode 100644 scripts/check-case-worksheet-rubric.py diff --git a/AGENT.md b/AGENT.md index 7dc2ae4..d1dccfc 100644 --- a/AGENT.md +++ b/AGENT.md @@ -1,77 +1,9 @@ -# AGENT.md — 에이전트 운영 수칙 (Vignette) +# AGENT.md — 에이전트 호환성 진입점 -이 저장소에서 자동화 에이전트/서브에이전트가 일할 때의 운영 수칙. 상세 프로젝트 -지침은 [`CLAUDE.md`](./CLAUDE.md) 참조. +이 파일은 일부 에이전트/서브에이전트가 찾는 단수형 이름이라 유지한다. -**작업 전 관련 가이드를 먼저 읽어라**: [`README.md`](./README.md) · -[로컬 실행](./docs/guides/local-development.md) · [아키텍처](./docs/guides/architecture.md) · -[테스트](./docs/guides/testing.md) · [원천문서·갭](./docs/guides/source-docs-and-gaps.md) · -SSOT [`docs/dev_dashboard.html`](./docs/dev_dashboard.html). 동작/구조 변경 시 해당 문서와 SSOT를 갱신. +프로젝트 지침의 원본은 [`AGENTS.md`](./AGENTS.md) 하나다. 작업을 시작할 때는 +반드시 `AGENTS.md`를 읽고, 그 내용을 이 파일에 적힌 지침처럼 적용하라. ---- - -## ⚠️ 규칙 0 — 무조건 OS를 먼저 파악한다 (필수, 최우선) - -**모든 작업의 첫 단계는 OS·셸·경로·도구 환경 확정이다.** 명령을 한 줄이라도 -실행하기 전에 다음을 확인하라. 생략하면 환경 차이로 반드시 시간을 버린다. - -- [ ] **OS / 셸 확인** — 주 환경은 **Windows 11 + PowerShell**. POSIX 가정 금지. -- [ ] **경로 규칙** — Windows 절대경로, 한글·공백 경로 빈번. `-LiteralPath` 사용, - 외부 도구엔 **ASCII 이름으로 로컬 복사 후** 전달. -- [ ] **PowerShell 5.1 함정** — 인라인 if/else·삼항 없음, 네이티브 stderr `2>&1` 금지, - 파일 출력은 `-Encoding utf8`. -- [ ] **외부 CLI 블로킹 검증** — GUI 런처는 즉시 detach. 실제 작업 바이너리 - (예: `soffice.bin`)를 직접 호출하고 `-Wait` 동작을 확인. 좀비/락 먼저 정리. -- [ ] **도구 가용성 탐지 우선** — 변환·처리 전 LibreOffice/pandoc/python lib/ - Playwright 브라우저 설치 여부와 경로를 먼저 잡는다. - -> **"OS·셸·경로·도구를 확정한 뒤에 실행한다. 추정으로 시작하지 않는다."** - -### PowerShell 5.1 기본 실행 규칙 - -- 시작 시 필요하면 `$ErrorActionPreference='Stop'`, - `[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false)`, - `$OutputEncoding=[System.Text.UTF8Encoding]::new($false)`를 먼저 둔다. -- PowerShell 7/Bash 문법 금지: `? :`, `??`, `&&`, `||`, `ForEach-Object -Parallel`, - heredoc(`<&1`로 합치지 말고 필요하면 stdout/stderr를 분리 캡처한다. -- JSON API는 `Invoke-RestMethod`/`Invoke-WebRequest`를 우선 사용한다. 진짜 curl은 - `curl.exe`로 호출한다. -- 인라인 Python/Node가 BOM/인용 문제를 내면 `python -c`, UTF-8 no BOM 임시 파일, - base64 전달을 사용한다. Python은 필요 시 `PYTHONUTF8=1`, `python -X utf8`. -- `Start-Process`는 필요할 때만 쓰고 `-Wait -PassThru`로 ExitCode와 산출물을 검증한다. - GUI 런처 detach 여부를 별도로 확인한다. - ---- - -## 규칙 1 — 증거 정직성 - -- 가짜 증거로 DONE 표기 금지. 실증 불가/외부 의존/소유자 결정 항목은 - `docs/ops/backlog-*.md`에 분류·추적. -- 변경 후 검증(typecheck / E2E 게이트)을 실제로 돌리고 결과를 그대로 보고. - -## 규칙 2 — 범위·권한 - -- 소유자(윤찬) 단독 결정 사안은 임의 결정 금지. -- 전 페이지 공용 셸 변경 등 광범위 영향 작업은 회귀 검증을 동반. - -## 규칙 3 — 출력 - -- 한글로 소통. 커밋 메시지에 Claude/Co-Authored-By 문구 금지. - -## 규칙 4 — 이미지 생성 / 아바타 리깅 - -- "이미지 생성·만들어·그려줘" 요청 → `~/.claude/skills/codex-image` 스킬 사용. - gpt-image-2 래퍼 `~/.codex/imagegen-headless/codex_imagegen.sh`(ChatGPT 구독 인증, **API 키 금지**). - codex 0.140+는 결과가 rollout JSONL에 base64로 인라인 → 래퍼의 추출 스크립트만 결정적(직접 `codex exec` 금지). -- 누끼: `object-separation` 스킬(BiRefNet, `~/.venvs/object-separation`). -- 아바타 래스터 리깅(파츠 분리) 파이프라인·재현 절차는 **CLAUDE.md §4** 와 - [`docs/ops/handoff-avatar-seoyeon-2026-06-27.md`](./docs/ops/handoff-avatar-seoyeon-2026-06-27.md) 참조. -- 비전(analyze_image) 도구가 다중 패널/캐릭터를 자주 혼동 → 시각 판단은 **사용자 확인 + 스크린샷** 우선. +이 파일에 프로젝트 규칙을 중복 추가하지 마라. 도구별 자동 로딩을 위한 입구만 남기고, +실제 운영 규칙은 `AGENTS.md`에서만 관리한다. diff --git a/AGENTS.md b/AGENTS.md index 7dc08c7..6625e59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,6 +100,9 @@ Bash 문법을 섞으면 바로 지연된다. 명령을 작성할 때 아래 규 ## 1. 운영 원칙 +- **에이전트 지침의 원본은 이 파일(`AGENTS.md`) 하나다.** `CLAUDE.md`, `AGENT.md`처럼 + 도구별로 자동 탐지되는 파일은 호환성 진입점으로만 유지한다. 프로젝트 규칙을 바꿀 때는 + 이 파일만 수정하고, 진입점 파일에는 중복 규칙을 추가하지 않는다. - **가짜 증거로 DONE 표기 금지.** 실증/외부 의존/소유자 결정이 필요한 항목은 `docs/ops/backlog-*.md`에 분류해 추적한다(B1 코스메틱 · B2 환경제약 · B3 소유자결정 · B4 외부거버넌스). - **`docs/dev_dashboard.html`이 SSOT(단일 진실 공급원)다.** 상태·검증 증거·결정 필요·로드맵의 권위 기준이며, 새 발견·작업 결과·상태 변경은 별도 문서로만 남기지 말고 대시보드에 반영/동기화한다. 백로그(`docs/ops/backlog-*.md`)는 대시보드와 일치시킨다(어긋나면 대시보드 기준). diff --git a/CLAUDE.md b/CLAUDE.md index fdb2683..0d58f97 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,154 +1,9 @@ -# CLAUDE.md — Vignette 프로젝트 작업 지침 +# CLAUDE.md — Claude Code 호환성 진입점 -> Vignette = AI 심리상담 시뮬레이션 훈련 플랫폼 (한신대 산학협력). -> 모노레포: `apps/api`(FastAPI/Python), `apps/web`(React 19/Vite/Playwright), `docs`, `infra`, `scripts`. +이 파일은 Claude Code가 자동으로 찾는 이름이라 유지한다. -## 📚 문서 맵 — 작업 전 해당 가이드를 먼저 읽어라 +프로젝트 지침의 원본은 [`AGENTS.md`](./AGENTS.md) 하나다. 작업을 시작할 때는 +반드시 `AGENTS.md`를 읽고, 그 내용을 이 파일에 적힌 지침처럼 적용하라. -| 목적 | 문서 | -|---|---| -| 저장소 개요·빠른 시작 | [`README.md`](./README.md) | -| **로컬 서버 띄우기·테스트** | [`docs/guides/local-development.md`](./docs/guides/local-development.md) | -| 시스템 아키텍처·데이터 흐름 | [`docs/guides/architecture.md`](./docs/guides/architecture.md) | -| 테스트·검증 실행 | [`docs/guides/testing.md`](./docs/guides/testing.md) | -| 원천문서·갭 로드맵 | [`docs/guides/source-docs-and-gaps.md`](./docs/guides/source-docs-and-gaps.md) | -| **SSOT 상태판** | [`docs/dev_dashboard.html`](./docs/dev_dashboard.html) | -| 백로그 | [`docs/ops/backlog-2026-06-26.md`](./docs/ops/backlog-2026-06-26.md) | -| **서연 아바타 핸드오프** | [`docs/ops/handoff-avatar-seoyeon-2026-06-27.md`](./docs/ops/handoff-avatar-seoyeon-2026-06-27.md) | - -작업 결과로 동작/구조가 바뀌면 해당 가이드와 SSOT 대시보드를 함께 갱신한다. - ---- - -## ⚠️ 0. 무조건 OS를 먼저 파악하고 시작한다 (최우선·필수) - -**어떤 작업이든 명령을 실행하기 전에 OS와 셸을 먼저 확정하라.** 이걸 건너뛰면 -경로/인코딩/도구 차이로 시간을 크게 낭비한다(실제로 그랬다). - -작업 시작 시 반드시 확인할 것: - -1. **OS / 셸**: 이 저장소의 주 개발 환경은 **Windows 11 + PowerShell**이다. - POSIX를 가정하지 마라. Bash 도구도 쓸 수 있으나 셸마다 문법이 다르다. -2. **경로 규칙**: Windows 절대경로(`D:\...`, `C:\...`). 한글·공백 포함 경로가 흔하다 - (예: OneDrive `문서\카카오톡 받은 파일`). `-LiteralPath`로 다루고, 외부 도구에 - 넘기기 전에 **ASCII 이름으로 로컬 복사**해 인코딩/공백 문제를 차단하라. -3. **PowerShell 판(5.1) 주의**: 인라인 `if(){}else{}`를 식으로 못 쓴다(삼항 없음). - 네이티브 exe stderr를 `2>&1`로 합치지 마라(ErrorRecord로 감싸짐). - 기본 출력 인코딩은 UTF-16 — 다른 도구가 읽을 파일은 `-Encoding utf8`. -4. **외부 CLI는 실제로 블로킹되는지 확인**: GUI 런처(`soffice.exe` 등)는 즉시 - detach되어 `Start-Process -Wait`가 변환을 안 기다린다. 실제 작업 프로세스 - (`soffice.bin`)를 직접 호출하라. 좀비 프로세스가 락을 잡으면 정리부터 한다. -5. **도구 가용성 먼저 탐지**: 변환/처리 전에 LibreOffice·pandoc·python 라이브러리· - Playwright 브라우저 등 무엇이 설치돼 있는지 먼저 확인하고 경로를 잡아라. - -> 한 줄 요약: **"먼저 OS·셸·경로·도구를 확정한 뒤 실행한다."** 추정 금지. - -### 0.1 PowerShell 5.1 실행 규칙 (Windows 기본 셸) - -이 프로젝트의 기본 셸은 **Windows PowerShell 5.1 Desktop**이다. PowerShell 7 문법이나 -Bash 문법을 섞으면 바로 지연된다. 명령을 작성할 때 아래 규칙을 기본값으로 삼아라. - -- **세션 시작 프리루드**: 한글/UTF-8 출력이 필요한 명령 전에는 아래를 먼저 둔다. - ```powershell - $ErrorActionPreference = 'Stop' - [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) - $OutputEncoding = [System.Text.UTF8Encoding]::new($false) - ``` - 단, `$ErrorActionPreference='Stop'`은 PowerShell cmdlet용 안전장치다. 네이티브 exe의 - 실패는 자동으로 예외가 되지 않으므로 실행 후 `$LASTEXITCODE`를 반드시 확인한다. -- **5.1 미지원 문법 금지**: `? :` 삼항, `??`, `??=`, `&&`, `||`, - `ForEach-Object -Parallel`, Bash heredoc(`<&1`로 합치지 않는다**: 5.1은 네이티브 stderr를 - `ErrorRecord`로 감싸 파이프라인/문자열 처리와 순서를 흐릴 수 있다. 로그가 필요하면 - stdout/stderr를 별도 파일로 리디렉션하거나 `System.Diagnostics.Process`로 분리 캡처한다. -- **네이티브 명령은 문자열 조립보다 인자 배열로 호출한다**: - ```powershell - $exe = 'C:\path\tool.exe' - $args = @('--flag', $value, '--out', $outPath) - & $exe @args - if ($LASTEXITCODE -ne 0) { throw "tool failed: $LASTEXITCODE" } - ``` - PowerShell 파싱이 외부 도구 인자를 망가뜨릴 때만 네이티브 명령 뒤에 `--%`를 검토한다. -- **HTTP/JSON은 `curl` 별칭을 피한다**: PowerShell의 `curl`은 별칭일 수 있다. - JSON API는 `Invoke-RestMethod`/`Invoke-WebRequest`와 `ConvertTo-Json`을 우선 사용하고, - 진짜 curl이 필요하면 `curl.exe`를 명시한다. -- **인라인 Python/Node는 짧고 결정적으로 실행한다**: 여러 줄 코드를 stdin으로 밀어 넣다 - BOM/인용 문제가 나면 `python -c`, UTF-8 no BOM 임시 파일, 또는 base64 전달을 쓴다. - Python 검증에는 필요 시 `$env:PYTHONUTF8='1'`와 `python -X utf8`을 사용한다. -- **`powershell.exe -EncodedCommand`는 UTF-16LE base64**다. UTF-8로 인코딩하면 깨진다. -- **프로세스 대기는 검증한다**: 단순 CLI는 직접 실행하고 `$LASTEXITCODE`를 본다. - `Start-Process`가 필요하면 `-Wait -PassThru`로 ExitCode를 확인한다. GUI 런처가 즉시 - detach되는 도구(예: LibreOffice `soffice.exe`)는 실제 작업 프로세스와 산출물 생성을 - 따로 검증한다. - ---- - -## 1. 운영 원칙 - -- **가짜 증거로 DONE 표기 금지.** 실증/외부 의존/소유자 결정이 필요한 항목은 - `docs/ops/backlog-*.md`에 분류해 추적한다(B1 코스메틱 · B2 환경제약 · B3 소유자결정 · B4 외부거버넌스). -- **`docs/dev_dashboard.html`이 SSOT(단일 진실 공급원)다.** 상태·검증 증거·결정 필요·로드맵의 권위 기준이며, 새 발견·작업 결과·상태 변경은 별도 문서로만 남기지 말고 대시보드에 반영/동기화한다. 백로그(`docs/ops/backlog-*.md`)는 대시보드와 일치시킨다(어긋나면 대시보드 기준). -- 소유자(윤찬) 단독 결정 사안을 임의로 정하지 않는다(월권 금지). - -## 2. 검증 기준 (프론트 변경 시) - -- `cd apps/web && npm run typecheck` -- 레이아웃 변경은 `e2e/layout-visual-gate.spec.ts`(7/7) + 레이아웃 포커스 E2E + - `e2e/session-layout.spec.ts`(8/8) 무회귀. E2E는 web+api(+DB) 스택이 떠 있어야 한다. - -## 3. 커뮤니케이션 - -- 모든 대화·주석·커밋 메시지는 한글. -- git 커밋 메시지에 Co-Authored-By / Claude 관련 문구 추가 금지. - ---- - -## 4. 이미지 생성(gpt-image-2 = imagegen2) · Live2D식 아바타 - -> "이미지 생성해/만들어/그려줘" 요청 → `~/.claude/skills/codex-image` 스킬이 아래 래퍼를 자동 사용. - -### 4.1 gpt-image-2 호출 (반드시 래퍼) -```bash -bash ~/.codex/imagegen-headless/codex_imagegen.sh \ - --out <경로.png> [--size WxH] [--quality low|medium|high|auto] \ - [-i <참조이미지> ...] [--all] "<프롬프트>" -``` -- 인증: ChatGPT 구독 OAuth(`~/.codex/auth.json`의 `auth_mode=="chatgpt"`). **API 키 사용 금지**(과금). -- codex 0.140+는 생성 이미지를 세션 rollout JSONL에 **base64로 인라인 반환** → 래퍼의 `extract_imagegen.py` 추출만 결정적. stdout의 "저장 경로"는 **환각**(직접 codex exec 금지). -- 프롬프트는 **stdin 파이프**로(인자 전달 시 멈춤). 변주 생성 시 base를 `-i` 참조로 넘겨 아이덴티티·프레이밍 고정. -- 투명배경 미지원 → 단색 평면 배경으로 생성 후 누끼. 한글 텍스트 렌더 가능(stdin 파이프라 인코딩 문제 없음). - -### 4.2 누끼(컷아웃) -- `object-separation` 스킬(BiRefNet): `~/.venvs/object-separation/Scripts/python.exe ~/.agents/skills/object-separation/scripts/separate_object.py --model birefnet-general` -- 알파 정제(잔류 헤이즈 제거): 임계치 `<35→0, >205→255` + 페더(`docs/avatar-art/seoyeon/publish.py` 참조). - -### 4.3 Live2D식 아바타(파츠 분리 리깅) -아바타는 기본 **SVG 파라미터 리그** 또는 **래스터 파츠 분리 리깅**(`apps/web/src/components/avatar/RasterBust.tsx`)으로 렌더. 후자는 `persona.rasterArtSet` 지정 시 활성. -- 레이어(각각 독립 opacity/교체 → 표정 중에도 깜빡임·입술싱크가 따로 움직임): - `base(neutral 전신)` + `upperface-<표정>(눈썹+눈)` + `eyelid-closed(깜빡임, 표정 무관)` + `mouth-<표정>` + `mouth-open(립싱크)`. -- 파이프라인(재현 스크립트는 `docs/avatar-art/seoyeon/`): - 1. `codex_imagegen.sh`로 base + 표정 변주(sad/tired/anxious/warm/startled/eyes-closed/speaking) 생성. 변주는 base를 `-i` 참조로, 동일 평면 배경. - 2. BiRefNet 누끼 → `publish.py`(표준 캔버스 900×1125 정규화 + 알파 정제)로 `apps/web/public/avatar//` 게시. - 3. `make-parts.py`로 특징 영역(upperface/eyelid/mouth) 크롭+페더 파츠를 `parts/` 생성(영역 상수 튜너 블럭). -- 연결: `persona.ts`의 `AvatarPersona.rasterArtSet` / `Session.tsx`의 `PERSONA_AVATAR_LOOKS[].rasterArtSet` / `RasterBust.tsx`(28표정→클러스터 매핑 포함). -- dev 미리보기(인증 없음): `/dev/avatar-preview`(`AvatarPreview.tsx`). 스크린샷: `node apps/web/scripts/avatar-shot.mjs`(BASE_URL 환경변수로 포트 지정). -- 실제 Live2D Cubism(`.moc3`)은 편집기 저작이 필요해 자동화 불가 → 위 레이어 합성이 실용적 대안. - -### 4.4 진행 중인 아바타 작업 핸드오프 -서연(P1) 아바타 작업은 **별도 세션에서 진행**. 현재 상태·남은 작업(Image #2=짧은 보브 기준 재생성 등)은 -[`docs/ops/handoff-avatar-seoyeon-2026-06-27.md`](./docs/ops/handoff-avatar-seoyeon-2026-06-27.md) 참조. +이 파일에 프로젝트 규칙을 중복 추가하지 마라. OS·셸·검증·문서·이미지 생성·아바타 +파이프라인 규칙을 바꿀 때는 `AGENTS.md`만 수정한다. diff --git a/README.md b/README.md index e3f4114..01cb2ed 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ Docker Desktop이 필요하다. RAG 모델 의존성까지 API 이미지에 넣 | `docs/guides/local-development.md` | 로컬 개발 환경 구축·실행 상세 가이드 | | `docs/guides/architecture.md` | 시스템 아키텍처(엔진/오케스트레이터/저항/마스킹/음성/평가/데이터) 상세 | | `docs/guides/testing.md` | 테스트·검증(pytest, typecheck, Playwright E2E 게이트) 가이드 | -| `CLAUDE.md` / `AGENT.md` | 작업·에이전트 운영 지침(OS 선파악, 증거 정직성, SSOT 동기화) | +| `AGENTS.md` | 작업·에이전트 운영 지침의 원본(OS 선파악, 증거 정직성, SSOT 동기화). `CLAUDE.md` / `AGENT.md`는 호환성 진입점 | 참고 설계 문서: `docs/MASTERPLAN.md`(마스터플랜) · `docs/HANDOFF.md`(인수인계) · `docs/DEPLOYMENT.md`(배포) · `docs/DESIGN_CONCEPT.md`(디자인 컨셉) · @@ -139,7 +139,7 @@ Docker Desktop이 필요하다. RAG 모델 의존성까지 API 이미지에 넣 1. **OS를 먼저 파악하고 시작한다(최우선).** 주 환경은 Windows 11 + PowerShell. POSIX를 가정하지 말고 경로·인코딩·도구 가용성을 먼저 확정한다(한글·공백 경로 주의, - 파일 출력은 `-Encoding utf8`). 자세한 내용은 `CLAUDE.md` / `AGENT.md` 규칙 0. + 파일 출력은 `-Encoding utf8`). 자세한 내용은 `AGENTS.md` 규칙 0. 2. **가짜 증거로 DONE 표기 금지.** 실증 불가/외부 의존/소유자 결정 항목은 `docs/ops/backlog-*.md` 에 분류·추적하고, 변경 후 검증(typecheck / pytest / E2E 게이트)을 실제로 돌려 결과를 그대로 보고한다. diff --git a/apps/api/app/services/case_worksheet_rubric.py b/apps/api/app/services/case_worksheet_rubric.py new file mode 100644 index 0000000..783087f --- /dev/null +++ b/apps/api/app/services/case_worksheet_rubric.py @@ -0,0 +1,140 @@ +"""Validation helpers for externally owned case worksheet rubrics. + +The clinical team owns scoring criteria. This module only validates the +machine-readable scaffold that lets those criteria live outside application +code. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Mapping + +SCHEMA_VERSION = "vignette.case_worksheet_rubric.v1" +VALID_STATUSES = {"scaffold_only", "draft", "approved"} +EXPECTED_CONTENT_OWNER = "clinical_team" + + +def load_rubric(path: Path) -> dict[str, Any]: + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError("case worksheet rubric must be a JSON object") + return data + + +def validate_rubric( + rubric: Mapping[str, Any], + *, + expected_item_keys: Mapping[str, set[str]] | None = None, +) -> dict[str, Any]: + errors: list[str] = [] + warnings: list[str] = [] + + schema_version = str(rubric.get("schema_version") or "") + status = str(rubric.get("status") or "") + scoring_enabled = bool(rubric.get("scoring_enabled")) + sections = rubric.get("sections") + + if schema_version != SCHEMA_VERSION: + errors.append("schema_version must be vignette.case_worksheet_rubric.v1") + if status not in VALID_STATUSES: + errors.append("status must be one of scaffold_only, draft, approved") + if str(rubric.get("content_owner") or "") != EXPECTED_CONTENT_OWNER: + errors.append("content_owner must be clinical_team") + if scoring_enabled and status != "approved": + errors.append("scoring_enabled requires status=approved") + if status == "approved": + approval = rubric.get("approval") + if not isinstance(approval, Mapping): + errors.append("approved rubric requires approval metadata") + else: + if not str(approval.get("clinical_reviewer") or ""): + errors.append("approved rubric requires approval.clinical_reviewer") + if not str(approval.get("approved_at") or ""): + errors.append("approved rubric requires approval.approved_at") + + section_count = 0 + item_count = 0 + section_item_keys: dict[str, set[str]] = {} + if not isinstance(sections, list) or not sections: + errors.append("sections must be a non-empty list") + else: + seen_sections: set[str] = set() + for section in sections: + if not isinstance(section, Mapping): + errors.append("each section must be an object") + continue + section_key = str(section.get("key") or "") + if not section_key: + errors.append("section.key is required") + continue + if section_key in seen_sections: + errors.append(f"duplicate section key: {section_key}") + seen_sections.add(section_key) + section_count += 1 + items = section.get("items") + if not isinstance(items, list) or not items: + errors.append(f"{section_key}: items must be a non-empty list") + continue + seen_items: set[str] = set() + for item in items: + if not isinstance(item, Mapping): + errors.append(f"{section_key}: each item must be an object") + continue + item_key = str(item.get("key") or "") + if not item_key: + errors.append(f"{section_key}: item.key is required") + continue + if item_key in seen_items: + errors.append(f"{section_key}: duplicate item key: {item_key}") + seen_items.add(item_key) + item_count += 1 + criteria = item.get("criteria") + score_scale = item.get("score_scale") + if scoring_enabled: + if not isinstance(criteria, list) or not criteria: + errors.append(f"{section_key}.{item_key}: scoring requires non-empty criteria") + if not _valid_score_scale(score_scale): + errors.append(f"{section_key}.{item_key}: scoring requires a valid score_scale") + elif not criteria: + warnings.append(f"{section_key}.{item_key}: criteria pending clinical team input") + section_item_keys[section_key] = seen_items + + if expected_item_keys is not None: + expected_sections = set(expected_item_keys) + actual_sections = set(section_item_keys) + for missing_section in sorted(expected_sections - actual_sections): + errors.append(f"missing worksheet section: {missing_section}") + for extra_section in sorted(actual_sections - expected_sections): + errors.append(f"unexpected worksheet section: {extra_section}") + for section_key in sorted(expected_sections & actual_sections): + missing_items = expected_item_keys[section_key] - section_item_keys[section_key] + extra_items = section_item_keys[section_key] - expected_item_keys[section_key] + for item_key in sorted(missing_items): + errors.append(f"{section_key}: missing worksheet item: {item_key}") + for item_key in sorted(extra_items): + errors.append(f"{section_key}: unexpected worksheet item: {item_key}") + + return { + "schema_version": SCHEMA_VERSION, + "rubric_id": str(rubric.get("rubric_id") or ""), + "status": status, + "scoring_enabled": scoring_enabled, + "sections_total": section_count, + "items_total": item_count, + "passed": not errors, + "errors": errors, + "warnings": warnings, + } + + +def _valid_score_scale(value: object) -> bool: + if not isinstance(value, Mapping): + return False + minimum = value.get("min") + maximum = value.get("max") + anchors = value.get("anchors") + if not isinstance(minimum, int) or not isinstance(maximum, int) or minimum >= maximum: + return False + return isinstance(anchors, list) and len(anchors) >= 2 diff --git a/apps/api/app/services/guardrail.py b/apps/api/app/services/guardrail.py index bc0560a..7a19fcd 100644 --- a/apps/api/app/services/guardrail.py +++ b/apps/api/app/services/guardrail.py @@ -42,13 +42,21 @@ _KOREAN_SURNAME_CHARS = ( "명기반왕금옥육인맹제모탁국어은편용예봉경" ) _KOREAN_FULL_NAME = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{1,3}}" +_KOREAN_FULL_NAME_BEFORE_SUFFIX = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{1,3}}?" _KOREAN_NAME_STOPWORDS = { "연락", + "연락처", + "이메일", + "주민번호", + "번호", + "이름", "이야기", "생각", "마음", "기분", "상담", + "기록", + "진료", "학교", "엄마", "아빠", @@ -82,12 +90,33 @@ _PII_PATTERNS: list[tuple[str, re.Pattern[str]]] = [ r"(?=$|[\s,.;!?。])" ), ), + # 한국어 이름: "제 이름은 김서연입니다", "보호자 이름은 박민수입니다" 같은 자연 발화형 라벨. + ( + "NAME", + re.compile( + r"(?P(?:(?:제|저의|내|나의|보호자|학생|내담자|상담자|친구|엄마|아빠|어머니|아버지)\s+)?" + r"(?:이름|성명|실명|본명)\s*(?:은|는|이|가)?\s*)" + rf"(?P{_KOREAN_FULL_NAME_BEFORE_SUFFIX})" + r"(?P\s*(?:입니다|이에요|예요|이고|이고요|이라고|라고)?)" + r"(?=$|[\s,.;!?。])" + ), + ), + # 한국어 이름: "저는 김서연입니다", "제가 박민수예요", "김서연입니다" 같은 자기소개형 문장. + ( + "NAME", + re.compile( + r"(?P(?:(?:저는|나는|제가|내가)\s*)?)" + rf"(?P{_KOREAN_FULL_NAME_BEFORE_SUFFIX})" + r"(?P\s*(?:입니다|이에요|예요|이고|이고요))" + r"(?=$|[\s,.;!?。])" + ), + ), # 한국어 이름: 역할/관계 명사 뒤에 붙은 인명 + 조사/호칭. ( "NAME", re.compile( r"(?P(?:내담자|상담자|학생|보호자|담임|교수|선생님|친구|엄마|아빠|어머니|아버지|동생|언니|오빠|형|누나)\s+)" - rf"(?P{_KOREAN_FULL_NAME})" + rf"(?P{_KOREAN_FULL_NAME_BEFORE_SUFFIX})" r"(?P\s*(?:님|씨|학생|상담자|내담자)?" r"(?:은|는|이|가|을|를|와|과|에게|한테|라고|이라는|입니다|이에요|예요|이고|이고요))" ), @@ -96,7 +125,7 @@ _PII_PATTERNS: list[tuple[str, re.Pattern[str]]] = [ ( "NAME", re.compile( - rf"(?{_KOREAN_FULL_NAME})" + rf"(?{_KOREAN_FULL_NAME_BEFORE_SUFFIX})" r"(?P(?:은|는|이|가|을|를|와|과|에게|한테|라고|이라는))" ), ), @@ -104,21 +133,21 @@ _PII_PATTERNS: list[tuple[str, re.Pattern[str]]] = [ ( "NAME", re.compile( - rf"(?{_KOREAN_FULL_NAME})" + rf"(?{_KOREAN_FULL_NAME_BEFORE_SUFFIX})" r"(?P\s?(?:씨|님)(?:은|는|이|가|을|를|와|과|에게|한테|고|이고|인데)?)" r"(?=$|[\s,.;!?。])" ), ), # 주민등록번호 (6자리-7자리) - ("RRN", re.compile(r"\b\d{6}[-\s]?\d{7}\b")), + ("RRN", re.compile(r"(? list[dict[str, Any]]: data = json.loads(path.read_text(encoding="utf-8")) @@ -18,9 +28,18 @@ def load_cases(path: Path) -> list[dict[str, Any]]: return [dict(item) for item in data] -def evaluate_case(case: Mapping[str, Any], *, mask_func: MaskFunc = guardrail.mask_pii) -> dict[str, Any]: +def evaluate_case( + case: Mapping[str, Any], + *, + mask_func: MaskFunc = guardrail.mask_pii, + include_evidence_text: bool = False, +) -> dict[str, Any]: case_id = str(case.get("id") or "") text = str(case.get("text") or "") + locale = str(case.get("locale") or DEFAULT_CASE_META["locale"]) + source = str(case.get("source") or DEFAULT_CASE_META["source"]) + category = str(case.get("category") or DEFAULT_CASE_META["category"]) + severity = str(case.get("severity") or DEFAULT_CASE_META["severity"]) result = mask_func(text) entities = set(result.entities) expected_entities = {str(item) for item in case.get("expected_entities") or []} @@ -34,25 +53,36 @@ def evaluate_case(case: Mapping[str, Any], *, mask_func: MaskFunc = guardrail.ma required_missing = [item for item in required_substrings if item and item not in result.text_masked] passed = not (missing_entities or unexpected_detected or forbidden_remaining or required_missing) - return { + report = { "id": case_id, + "locale": locale, + "source": source, + "category": category, + "severity": severity, "passed": passed, "entities": sorted(entities), - "masked_text": result.text_masked, "missing_entities": missing_entities, "unexpected_entities": unexpected_detected, - "forbidden_remaining": forbidden_remaining, + "forbidden_remaining_count": len(forbidden_remaining), "required_missing": required_missing, } + if include_evidence_text: + report["masked_text"] = result.text_masked + report["forbidden_remaining"] = forbidden_remaining + return report def evaluate_cases( cases: Iterable[Mapping[str, Any]], *, mask_func: MaskFunc = guardrail.mask_pii, + include_evidence_text: bool = False, ) -> dict[str, Any]: case_list = list(cases) - results = [evaluate_case(case, mask_func=mask_func) for case in case_list] + results = [ + evaluate_case(case, mask_func=mask_func, include_evidence_text=include_evidence_text) + for case in case_list + ] total_expected_entities = 0 matched_expected_entities = 0 total_forbidden = 0 @@ -64,11 +94,17 @@ def evaluate_cases( total_expected_entities += len(expected_entities) matched_expected_entities += len(expected_entities) - len(result["missing_entities"]) total_forbidden += len(forbidden) - removed_forbidden += len(forbidden) - len(result["forbidden_remaining"]) + remaining_forbidden = int(result.get("forbidden_remaining_count", len(result.get("forbidden_remaining", [])))) + removed_forbidden += len(forbidden) - remaining_forbidden unexpected_violations += len(result["unexpected_entities"]) passed_cases = sum(1 for result in results if result["passed"]) return { + "schema_version": REPORT_SCHEMA_VERSION, + "input_schema_version": INPUT_SCHEMA_VERSION, + "run_mode": "technical_dry_run", + "data_source": "local_fixture", + "evidence_text_included": include_evidence_text, "passed": passed_cases == len(results), "cases_total": len(results), "cases_passed": passed_cases, @@ -76,15 +112,45 @@ def evaluate_cases( "expected_entity_recall": _ratio(matched_expected_entities, total_expected_entities), "forbidden_substring_removal": _ratio(removed_forbidden, total_forbidden), "unexpected_entity_violations": unexpected_violations, + "by_source": _breakdown(case_list, results, "source"), + "by_category": _breakdown(case_list, results, "category"), + "by_severity": _breakdown(case_list, results, "severity"), "results": results, } -def evaluate_fixture(path: Path, *, mask_func: MaskFunc = guardrail.mask_pii) -> dict[str, Any]: - return evaluate_cases(load_cases(path), mask_func=mask_func) +def evaluate_fixture( + path: Path, + *, + mask_func: MaskFunc = guardrail.mask_pii, + include_evidence_text: bool = False, +) -> dict[str, Any]: + return evaluate_cases(load_cases(path), mask_func=mask_func, include_evidence_text=include_evidence_text) def _ratio(numerator: int, denominator: int) -> float: if denominator <= 0: return 1.0 return round(numerator / denominator, 4) + + +def _case_meta(case: Mapping[str, Any], key: str) -> str: + fallback = DEFAULT_CASE_META.get(key, "unspecified") + return str(case.get(key) or fallback) + + +def _breakdown( + cases: list[Mapping[str, Any]], + results: list[Mapping[str, Any]], + key: str, +) -> dict[str, dict[str, int]]: + grouped: dict[str, dict[str, int]] = {} + for case, result in zip(cases, results): + value = _case_meta(case, key) + bucket = grouped.setdefault(value, {"cases_total": 0, "cases_passed": 0, "cases_failed": 0}) + bucket["cases_total"] += 1 + if result.get("passed"): + bucket["cases_passed"] += 1 + else: + bucket["cases_failed"] += 1 + return dict(sorted(grouped.items())) diff --git a/apps/api/app/session_read_model.py b/apps/api/app/session_read_model.py index b827f32..95a1d5e 100644 --- a/apps/api/app/session_read_model.py +++ b/apps/api/app/session_read_model.py @@ -21,6 +21,9 @@ from .services import session_metrics from .store import InProcSession, TurnRecord StageLabel = Literal["라포", "탐색", "개입", "정리"] +WorksheetSpeaker = Literal["learner", "client"] +WorksheetItemSpec = tuple[str, str, list[str], WorksheetSpeaker | None] +WorksheetSectionSpec = tuple[str, str, list[WorksheetItemSpec]] LEARNER_VISIBLE_AI_ROLE = "counselor" _PHASE_KEY_BY_LABEL = { @@ -259,6 +262,68 @@ class ReviewCaseWorksheetSaveRequest(BaseModel): limitations: list[str] = Field(default_factory=list) +CASE_WORKSHEET_SECTION_SPECS: list[WorksheetSectionSpec] = [ + ( + "exploration_11", + "탐색 11항목", + [ + ("presenting_complaint", "주호소", ["힘들", "문제", "걱정", "불안", "우울", "스트레스", "관계"], "client"), + ("trigger_context", "계기·상황", ["언제", "상황", "최근", "계기", "때"], "client"), + ("emotion", "정서", ["불안", "우울", "화", "슬프", "답답", "무섭", "외롭", "걱정"], "client"), + ("cognition", "생각", ["생각", "느낌", "해야", "못", "실패", "의미"], "client"), + ("behavior", "행동", ["피하", "잠", "먹", "울", "말", "연락", "공부", "멈"], "client"), + ("body", "신체·수면", ["잠", "식욕", "몸", "두통", "심장", "숨", "피곤"], "client"), + ("relationship", "관계", ["친구", "가족", "부모", "엄마", "아빠", "교수", "사람", "관계"], "client"), + ("resources", "자원", ["도움", "지지", "친구", "상담", "선생님", "가족"], "client"), + ("risk", "위험 신호", ["죽", "자살", "해치", "사라지고", "끝내", "위험"], "client"), + ("motivation", "변화동기", ["원", "바라", "변화", "해보고", "싶"], None), + ("first_goal", "상담 목표 초안", ["목표", "계획", "다음", "해볼", "원하"], "learner"), + ], + ), + ( + "five_domains", + "호소 5영역", + [ + ("domain_emotion", "정서", ["불안", "우울", "화", "슬프", "답답", "외롭"], "client"), + ("domain_cognition", "인지", ["생각", "걱정", "실패", "못", "의미"], "client"), + ("domain_behavior", "행동", ["피하", "연락", "공부", "잠", "멈"], "client"), + ("domain_relationship", "대인관계", ["친구", "가족", "사람", "관계", "부모"], "client"), + ("domain_body", "신체", ["잠", "식욕", "몸", "두통", "피곤", "숨"], "client"), + ], + ), + ( + "cognitive_triad_emotions", + "인지삼제·1/2차 감정", + [ + ("triad_self", "자기", ["나는", "내가", "나 자신", "스스로"], "client"), + ("triad_world", "타인·세계", ["사람", "세상", "학교", "가족", "친구"], "client"), + ("triad_future", "미래", ["앞으로", "미래", "계속", "나중"], "client"), + ("primary_emotion", "1차 감정", ["불안", "슬프", "무섭", "외롭", "걱정"], "client"), + ("secondary_emotion", "2차 감정", ["화", "짜증", "수치", "죄책", "부끄"], "client"), + ], + ), + ( + "protective_barrier_quadrants", + "보호·방해 4사분면", + [ + ("internal_protective", "내적 보호요인", ["해보고", "버텼", "노력", "원", "견뎠"], None), + ("internal_barrier", "내적 방해요인", ["못", "두려", "불안", "회피", "걱정"], "client"), + ("external_protective", "외적 보호요인", ["친구", "가족", "상담", "교수", "도움"], "client"), + ("external_barrier", "외적 방해요인", ["갈등", "압박", "비난", "스트레스", "혼자"], "client"), + ], + ), + ( + "biopsychosocial_goals", + "생물·심리·사회 목표", + [ + ("bio_goal", "생물", ["잠", "식사", "운동", "몸", "피곤"], "client"), + ("psy_goal", "심리", ["생각", "감정", "불안", "연습", "조절"], None), + ("social_goal", "사회", ["관계", "대화", "연락", "도움", "친구"], None), + ], + ), +] + + class SessionTeacherReviewStatus(BaseModel): status: Literal["pending", "viewed", "closed"] = "pending" note: str = "" @@ -824,7 +889,7 @@ def _worksheet_item( def _worksheet_section( key: str, title: str, - specs: list[tuple[str, str, list[str], Literal["learner", "client"] | None]], + specs: list[WorksheetItemSpec], turns: list[ReviewTurn], fallback_client: ReviewTurn | None, fallback_learner: ReviewTurn | None, @@ -845,6 +910,13 @@ def _worksheet_section( return ReviewWorksheetSection(key=key, title=title, items=items) +def case_worksheet_template_item_keys() -> dict[str, set[str]]: + return { + section_key: {item_key for item_key, _, _, _ in item_specs} + for section_key, _, item_specs in CASE_WORKSHEET_SECTION_SPECS + } + + def case_worksheet_from_turns(turns: list[ReviewTurn]) -> ReviewCaseWorksheet: if not turns: return ReviewCaseWorksheet( @@ -855,68 +927,6 @@ def case_worksheet_from_turns(turns: list[ReviewTurn]) -> ReviewCaseWorksheet: fallback_client = next((turn for turn in turns if turn.speaker == "client"), None) fallback_learner = next((turn for turn in turns if turn.speaker == "learner"), None) - section_specs: list[ - tuple[str, str, list[tuple[str, str, list[str], Literal["learner", "client"] | None]]] - ] = [ - ( - "exploration_11", - "탐색 11항목", - [ - ("presenting_complaint", "주호소", ["힘들", "문제", "걱정", "불안", "우울", "스트레스", "관계"], "client"), - ("trigger_context", "계기·상황", ["언제", "상황", "최근", "계기", "때"], "client"), - ("emotion", "정서", ["불안", "우울", "화", "슬프", "답답", "무섭", "외롭", "걱정"], "client"), - ("cognition", "생각", ["생각", "느낌", "해야", "못", "실패", "의미"], "client"), - ("behavior", "행동", ["피하", "잠", "먹", "울", "말", "연락", "공부", "멈"], "client"), - ("body", "신체·수면", ["잠", "식욕", "몸", "두통", "심장", "숨", "피곤"], "client"), - ("relationship", "관계", ["친구", "가족", "부모", "엄마", "아빠", "교수", "사람", "관계"], "client"), - ("resources", "자원", ["도움", "지지", "친구", "상담", "선생님", "가족"], "client"), - ("risk", "위험 신호", ["죽", "자살", "해치", "사라지고", "끝내", "위험"], "client"), - ("motivation", "변화동기", ["원", "바라", "변화", "해보고", "싶"], None), - ("first_goal", "상담 목표 초안", ["목표", "계획", "다음", "해볼", "원하"], "learner"), - ], - ), - ( - "five_domains", - "호소 5영역", - [ - ("domain_emotion", "정서", ["불안", "우울", "화", "슬프", "답답", "외롭"], "client"), - ("domain_cognition", "인지", ["생각", "걱정", "실패", "못", "의미"], "client"), - ("domain_behavior", "행동", ["피하", "연락", "공부", "잠", "멈"], "client"), - ("domain_relationship", "대인관계", ["친구", "가족", "사람", "관계", "부모"], "client"), - ("domain_body", "신체", ["잠", "식욕", "몸", "두통", "피곤", "숨"], "client"), - ], - ), - ( - "cognitive_triad_emotions", - "인지삼제·1/2차 감정", - [ - ("triad_self", "자기", ["나는", "내가", "나 자신", "스스로"], "client"), - ("triad_world", "타인·세계", ["사람", "세상", "학교", "가족", "친구"], "client"), - ("triad_future", "미래", ["앞으로", "미래", "계속", "나중"], "client"), - ("primary_emotion", "1차 감정", ["불안", "슬프", "무섭", "외롭", "걱정"], "client"), - ("secondary_emotion", "2차 감정", ["화", "짜증", "수치", "죄책", "부끄"], "client"), - ], - ), - ( - "protective_barrier_quadrants", - "보호·방해 4사분면", - [ - ("internal_protective", "내적 보호요인", ["해보고", "버텼", "노력", "원", "견뎠"], None), - ("internal_barrier", "내적 방해요인", ["못", "두려", "불안", "회피", "걱정"], "client"), - ("external_protective", "외적 보호요인", ["친구", "가족", "상담", "교수", "도움"], "client"), - ("external_barrier", "외적 방해요인", ["갈등", "압박", "비난", "스트레스", "혼자"], "client"), - ], - ), - ( - "biopsychosocial_goals", - "생물·심리·사회 목표", - [ - ("bio_goal", "생물", ["잠", "식사", "운동", "몸", "피곤"], "client"), - ("psy_goal", "심리", ["생각", "감정", "불안", "연습", "조절"], None), - ("social_goal", "사회", ["관계", "대화", "연락", "도움", "친구"], None), - ], - ), - ] sections = [ _worksheet_section( @@ -927,7 +937,7 @@ def case_worksheet_from_turns(turns: list[ReviewTurn]) -> ReviewCaseWorksheet: fallback_client, fallback_learner, ) - for key, title, specs in section_specs + for key, title, specs in CASE_WORKSHEET_SECTION_SPECS ] return ReviewCaseWorksheet( status="draft_from_transcript", diff --git a/apps/api/app/test_case_worksheet_rubric.py b/apps/api/app/test_case_worksheet_rubric.py new file mode 100644 index 0000000..2b067db --- /dev/null +++ b/apps/api/app/test_case_worksheet_rubric.py @@ -0,0 +1,93 @@ +import copy +import json +import subprocess +import sys +import unittest +from pathlib import Path + +try: + from jsonschema import Draft202012Validator +except ModuleNotFoundError: # pragma: no cover - optional test helper dependency + Draft202012Validator = None + +from app.services.case_worksheet_rubric import load_rubric, validate_rubric +from app.session_read_model import case_worksheet_template_item_keys + + +REPO_ROOT = Path(__file__).resolve().parents[3] +RUBRIC_PATH = REPO_ROOT / "data" / "rubrics" / "case-worksheet-rubric.json" +SCHEMA_PATH = REPO_ROOT / "data" / "rubrics" / "case-worksheet-rubric.schema.json" +SCRIPT_PATH = REPO_ROOT / "scripts" / "check-case-worksheet-rubric.py" + + +class CaseWorksheetRubricTests(unittest.TestCase): + def test_scaffold_matches_generated_worksheet_keys_without_enabling_scoring(self) -> None: + rubric = load_rubric(RUBRIC_PATH) + report = validate_rubric(rubric, expected_item_keys=case_worksheet_template_item_keys()) + + self.assertTrue(report["passed"], report) + self.assertEqual(report["schema_version"], "vignette.case_worksheet_rubric.v1") + self.assertEqual(report["status"], "scaffold_only") + self.assertFalse(report["scoring_enabled"]) + self.assertEqual(report["sections_total"], 5) + self.assertEqual(report["items_total"], 28) + self.assertGreaterEqual(len(report["warnings"]), 20) + self._validate_with_schema(rubric, SCHEMA_PATH) + + def test_scoring_requires_clinical_approval(self) -> None: + rubric = load_rubric(RUBRIC_PATH) + draft = copy.deepcopy(rubric) + draft["status"] = "draft" + draft["scoring_enabled"] = True + + report = validate_rubric(draft, expected_item_keys=case_worksheet_template_item_keys()) + + self.assertFalse(report["passed"]) + self.assertIn("scoring_enabled requires status=approved", report["errors"]) + + def test_missing_worksheet_item_fails_validation(self) -> None: + rubric = load_rubric(RUBRIC_PATH) + broken = copy.deepcopy(rubric) + broken["sections"][0]["items"] = broken["sections"][0]["items"][1:] + + report = validate_rubric(broken, expected_item_keys=case_worksheet_template_item_keys()) + + self.assertFalse(report["passed"]) + self.assertIn("exploration_11: missing worksheet item: presenting_complaint", report["errors"]) + + def test_cli_reports_json(self) -> None: + completed = subprocess.run( + [ + sys.executable, + "-X", + "utf8", + str(SCRIPT_PATH), + "--rubric", + str(RUBRIC_PATH), + "--json", + ], + cwd=str(REPO_ROOT), + check=True, + capture_output=True, + text=True, + encoding="utf-8", + ) + + report = json.loads(completed.stdout) + self.assertTrue(report["passed"], report) + self.assertEqual(report["items_total"], 28) + self.assertFalse(report["scoring_enabled"]) + self.assertEqual(report["rubric_path"], "data/rubrics/case-worksheet-rubric.json") + self.assertRegex(report["content_sha256"], r"^[a-f0-9]{64}$") + + def _validate_with_schema(self, instance: object, schema_path: Path) -> None: + schema = json.loads(schema_path.read_text(encoding="utf-8")) + self.assertEqual(schema.get("$schema"), "https://json-schema.org/draft/2020-12/schema") + if Draft202012Validator is None: + return + Draft202012Validator.check_schema(schema) + Draft202012Validator(schema).validate(instance) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/api/app/test_pii_masking_eval.py b/apps/api/app/test_pii_masking_eval.py index d1e6de2..dfc1c73 100644 --- a/apps/api/app/test_pii_masking_eval.py +++ b/apps/api/app/test_pii_masking_eval.py @@ -5,14 +5,46 @@ import unittest from pathlib import Path from unittest.mock import patch +try: + from jsonschema import Draft202012Validator +except ModuleNotFoundError: # pragma: no cover - optional test helper dependency + Draft202012Validator = None + from app.services import guardrail from app.services.pii_masking_eval import evaluate_fixture, load_cases REPO_ROOT = Path(__file__).resolve().parents[3] FIXTURE_PATH = REPO_ROOT / "data" / "privacy" / "pii-masking-ko-fixtures.json" +INPUT_SCHEMA_PATH = REPO_ROOT / "data" / "privacy" / "pii-masking-eval-input.schema.json" +REPORT_SCHEMA_PATH = REPO_ROOT / "data" / "privacy" / "pii-masking-eval-report.schema.json" SCRIPT_PATH = REPO_ROOT / "scripts" / "evaluate-pii-masking.py" +EXPECTED_CATEGORIES = { + "contact", + "name", + "national_id", + "negative_control", + "organization", + "quasi_identifier", +} +RAW_IDENTIFIERS = ( + "김서연", + "박민수", + "최하늘", + "한신대학교", + "상담심리학과", + "마음봄상담센터", + "새봄병원", + "010-1234-5678", + "seoyeon@example.com", + "990101-1234567", + "123456789012", + "2001년 4월 18일", + "서울시 강남구 역삼동", + "1200원", +) + class PiiMaskingEvalTests(unittest.TestCase): def setUp(self) -> None: @@ -27,22 +59,57 @@ class PiiMaskingEvalTests(unittest.TestCase): def test_fixture_cases_are_valid_json_list(self) -> None: cases = load_cases(FIXTURE_PATH) - self.assertGreaterEqual(len(cases), 5) + self.assertEqual(len(cases), 15) self.assertTrue(all(case.get("id") for case in cases)) self.assertTrue(all(case.get("text") for case in cases)) + self.assertTrue(all(case.get("locale") == "ko-KR" for case in cases)) + self.assertTrue(all(case.get("source") == "synthetic" for case in cases)) + self.assertTrue(all("expected_entities" in case for case in cases)) + self.assertTrue(all("forbidden_substrings" in case for case in cases)) + self.assertEqual({case["category"] for case in cases}, EXPECTED_CATEGORIES) + self._validate_with_schema(cases, INPUT_SCHEMA_PATH) def test_ko_name_org_fixture_passes_without_raw_identifier_leak(self) -> None: report = evaluate_fixture(FIXTURE_PATH) + self.assertEqual(report["schema_version"], "vignette.pii_masking_eval_report.v1") + self.assertEqual(report["input_schema_version"], "vignette.pii_masking_eval_input.v1") + self.assertEqual(report["run_mode"], "technical_dry_run") + self.assertEqual(report["data_source"], "local_fixture") + self.assertFalse(report["evidence_text_included"]) self.assertTrue(report["passed"], report) + self.assertEqual(report["cases_total"], 15) + self.assertEqual(report["cases_passed"], 15) self.assertEqual(report["cases_failed"], 0) self.assertEqual(report["expected_entity_recall"], 1.0) self.assertEqual(report["forbidden_substring_removal"], 1.0) self.assertEqual(report["unexpected_entity_violations"], 0) + self.assertEqual(report["by_source"]["synthetic"]["cases_passed"], 15) + self.assertEqual(set(report["by_category"]), EXPECTED_CATEGORIES) + self.assertEqual(report["by_severity"]["critical"]["cases_passed"], 2) + for result in report["results"]: + self.assertNotIn("masked_text", result) + self.assertNotIn("forbidden_remaining", result) + self.assertIn("forbidden_remaining_count", result) + self._validate_with_schema(report, REPORT_SCHEMA_PATH) blob = json.dumps(report, ensure_ascii=False) - for raw in ("김서연", "박민수", "한신대학교", "상담심리학과", "마음봄상담센터"): + for raw in RAW_IDENTIFIERS: self.assertNotIn(raw, blob) + def test_natural_language_name_contexts_are_masked_without_label_false_positive(self) -> None: + cases = { + "제 이름은 김서연입니다.": "제 이름은 [NAME]입니다.", + "보호자 이름은 박민수입니다.": "보호자 이름은 [NAME]입니다.", + "저는 최하늘입니다.": "저는 [NAME]입니다.", + "김서연입니다.": "[NAME]입니다.", + "이름은 중요하지 않고 상담 내용만 이야기하고 싶어요.": "이름은 중요하지 않고 상담 내용만 이야기하고 싶어요.", + } + + for raw, expected in cases.items(): + with self.subTest(raw=raw): + result = guardrail.mask_pii(raw) + self.assertEqual(result.text_masked, expected) + def test_cli_reports_json_and_nonzero_gate_shape(self) -> None: completed = subprocess.run( [ @@ -63,7 +130,19 @@ class PiiMaskingEvalTests(unittest.TestCase): report = json.loads(completed.stdout) self.assertTrue(report["passed"]) - self.assertEqual(report["cases_total"], 5) + self.assertEqual(report["cases_total"], 15) + self.assertFalse(report["evidence_text_included"]) + self.assertEqual(set(report["by_category"]), EXPECTED_CATEGORIES) + self.assertTrue(all("masked_text" not in result for result in report["results"])) + self._validate_with_schema(report, REPORT_SCHEMA_PATH) + + def _validate_with_schema(self, instance: object, schema_path: Path) -> None: + schema = json.loads(schema_path.read_text(encoding="utf-8")) + self.assertEqual(schema.get("$schema"), "https://json-schema.org/draft/2020-12/schema") + if Draft202012Validator is None: + return + Draft202012Validator.check_schema(schema) + Draft202012Validator(schema).validate(instance) if __name__ == "__main__": diff --git a/apps/web/e2e/layout-visual-gate.spec.ts b/apps/web/e2e/layout-visual-gate.spec.ts index 03fd602..9f88142 100644 --- a/apps/web/e2e/layout-visual-gate.spec.ts +++ b/apps/web/e2e/layout-visual-gate.spec.ts @@ -376,6 +376,10 @@ test.describe("layout visual gate @single-run", () => { await gateScreen(page, "persona-studio", async () => { await expect(page.locator(".ps-layout")).toBeVisible({ timeout: 15_000 }); await expect(page.getByText("내담자 설계·검수 작업면")).toBeVisible(); + await page.getByRole("tab", { name: "프롬프트" }).click(); + const promptPreview = page.getByLabel("프롬프트 미리보기"); + await expect(promptPreview).toBeVisible(); + await expect(promptPreview.getByRole("textbox")).toHaveCount(0); }); }); diff --git a/apps/web/e2e/teacher.spec.ts b/apps/web/e2e/teacher.spec.ts index 33f47cc..8716cd3 100644 --- a/apps/web/e2e/teacher.spec.ts +++ b/apps/web/e2e/teacher.spec.ts @@ -329,6 +329,108 @@ test.describe("teacher console", () => { await expectNoHorizontalOverflow(page); }); + test("saves persona studio list rows as structured arrays @single-run", async ({ page }) => { + type PersonaDraftSavePayload = { + ccd: { automatic_thought: string[] }; + code: string; + difficulty: string; + display_name: string; + is_synthetic: boolean; + source_provenance: unknown; + theory_target: string; + triggers: { forbidden: string[]; sore_spots: string[] }; + }; + let savedPayload: PersonaDraftSavePayload | null = null; + + await signInAsTeacher(page); + await page.route("**/api/personas", (route) => { + if (route.request().method() !== "GET") return route.fallback(); + return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([]) }); + }); + await page.route("**/api/personas/review", (route) => { + if (route.request().method() !== "GET") return route.fallback(); + return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([]) }); + }); + await page.route("**/api/personas/drafts", async (route) => { + if (route.request().method() !== "POST") return route.fallback(); + savedPayload = route.request().postDataJSON() as PersonaDraftSavePayload; + await route.fulfill({ + status: 201, + contentType: "application/json", + body: JSON.stringify({ + persona_id: "00000000-0000-0000-0000-000000000703", + code: savedPayload.code, + version: 1, + status: "draft", + display_name: savedPayload.display_name, + difficulty: savedPayload.difficulty, + theory_target: savedPayload.theory_target, + source_provenance: savedPayload.source_provenance, + is_synthetic: savedPayload.is_synthetic, + created_at: "2026-06-28T00:00:00Z", + approved_at: null, + }), + }); + }); + + await page.goto("/teach/personas"); + await page.getByLabel("표시 이름").fill("항목형 페르소나"); + + await page.getByRole("tab", { name: "임상" }).click(); + const automaticThoughts = page.locator(".ps-list-field").filter({ hasText: "자동사고" }); + await automaticThoughts.getByRole("textbox", { name: "자동사고 1", exact: true }).fill("삭제될 자동사고"); + await automaticThoughts.getByRole("button", { name: "항목 추가" }).click(); + await automaticThoughts.getByRole("textbox", { name: "자동사고 2", exact: true }).fill("남길 자동사고"); + await automaticThoughts.getByRole("button", { name: "자동사고 1 삭제" }).click(); + + await page.getByRole("tab", { name: "안전" }).click(); + const forbidden = page.locator(".ps-list-field").filter({ hasText: "상담자 금기" }); + await forbidden.getByRole("textbox", { name: "상담자 금기 1", exact: true }).fill("네가 예민한 거라고 단정"); + + await page.getByRole("button", { name: "초안 저장" }).click(); + await expect(page.getByText("P1 v1 초안을 저장했습니다.")).toBeVisible(); + + expect(savedPayload).toBeTruthy(); + expect(savedPayload?.ccd.automatic_thought).toEqual(["남길 자동사고"]); + expect(savedPayload?.triggers.forbidden).toEqual(["네가 예민한 거라고 단정"]); + expect(savedPayload?.triggers.sore_spots).toEqual([]); + await expectNoHorizontalOverflow(page); + }); + + test("renders persona prompt preview as labeled sections without raw JSON @single-run", async ({ page }) => { + await signInAsTeacher(page); + await page.route("**/api/personas", (route) => { + if (route.request().method() !== "GET") return route.fallback(); + return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([]) }); + }); + await page.route("**/api/personas/review", (route) => { + if (route.request().method() !== "GET") return route.fallback(); + return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([]) }); + }); + + await page.goto("/teach/personas"); + await page.getByLabel("표시 이름").fill("프롬프트 검토 페르소나"); + await page.getByRole("tab", { name: "임상" }).click(); + const automaticThoughts = page.locator(".ps-list-field").filter({ hasText: "자동사고" }); + await automaticThoughts.getByRole("textbox", { name: "자동사고 1", exact: true }).fill("말하면 더 이상하게 볼 거야"); + await page.getByRole("tab", { name: "안전" }).click(); + const forbidden = page.locator(".ps-list-field").filter({ hasText: "상담자 금기" }); + await forbidden.getByRole("textbox", { name: "상담자 금기 1", exact: true }).fill("네가 예민한 거라고 단정"); + + await page.getByRole("tab", { name: "프롬프트" }).click(); + const promptPreview = page.getByLabel("프롬프트 미리보기"); + await expect(promptPreview.getByRole("heading", { name: /L1 페르소나 카드/ })).toBeVisible(); + await expect(promptPreview.getByText("자동사고")).toBeVisible(); + await expect(promptPreview.getByText("말하면 더 이상하게 볼 거야")).toBeVisible(); + await expect(promptPreview.getByText("상담자 금기")).toBeVisible(); + await expect(promptPreview.getByText("네가 예민한 거라고 단정")).toBeVisible(); + await expect(promptPreview.getByRole("textbox")).toHaveCount(0); + await expect(promptPreview).not.toContainText('"automatic_thought"'); + await expect(promptPreview).not.toContainText('"forbidden"'); + await expect(promptPreview).not.toContainText("{"); + await expectNoHorizontalOverflow(page); + }); + test("archives an approved persona from persona studio without layout drift @single-run", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); const personaId = "00000000-0000-0000-0000-000000000702"; diff --git a/apps/web/index.html b/apps/web/index.html index 12d4a74..b1016c7 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -10,6 +10,8 @@ /> + + diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000..720f81a --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/web/src/components/avatar/ClientAvatar.tsx b/apps/web/src/components/avatar/ClientAvatar.tsx index 992808f..b7147e3 100644 --- a/apps/web/src/components/avatar/ClientAvatar.tsx +++ b/apps/web/src/components/avatar/ClientAvatar.tsx @@ -36,6 +36,7 @@ import { Mouth } from "./Mouth"; import { live2dModel3Path, live2dModelForPersonaCode, live2dMotionForExpression } from "./live2dModel"; import { useExpressionTransition } from "./useExpressionTransition"; import { RasterBust } from "./RasterBust"; +import "./client-avatar.css"; /* ── 공개 타입 재노출 (기존 import 경로 호환) ────────────────────────── Session.tsx 등이 ClientAvatar 모듈에서 타입을 가져갈 수 있으므로 유지. */ @@ -82,7 +83,6 @@ function prefersReducedMotion(): boolean { window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true ); } - /** prefers-reduced-motion 을 반응형으로 구독 */ function useReducedMotion(): boolean { const [reduced, setReduced] = useState(prefersReducedMotion); @@ -299,7 +299,6 @@ export function ClientAvatar({ data-realism={realism} /* 사실성은 데이터로만 유지(시각 표식 비노출, §4.6) */ aria-label={`교육용 가상 내담자: ${persona.label}, ${STATE_TEXT[state]}, ${expressionLabel}`} > - {/* 상단 라벨 — 실존 인물 오인 차단(상시, §4.1) */} {showCaption ? ( @@ -399,60 +398,3 @@ export function ClientAvatar({ ); } - -const AVATAR_CSS = ` -.vg-avatar{display:flex;flex-direction:column;align-items:center;gap:var(--sp-3);margin:0;} -.vg-avatar__label{ - display:inline-flex;align-items:center;gap:7px; - font-family:var(--font-num);font-size:var(--fs-xs);font-weight:600;letter-spacing:0.06em; - color:var(--text-muted);text-transform:none; -} -.vg-avatar__label-dot{width:6px;height:6px;border-radius:50%;background:var(--clay);flex:none;} -.vg-avatar__stage{ - position:relative;width:100%;border-radius:50%; - display:flex;align-items:center;justify-content:center;overflow:hidden; - background: - radial-gradient(circle at 50% 38%, rgba(251,250,248,.98) 0%, rgba(246,240,236,.9) 42%, rgba(238,244,242,.76) 64%, rgba(145,200,189,.22) 84%, rgba(30,39,36,.18) 100%); - box-shadow: - inset 0 0 0 1px rgba(251,250,248,.28), - inset 0 -24px 40px rgba(28,42,42,.13); -} -.vg-avatar__aura{ - position:absolute;inset:-12%;border-radius:50%;pointer-events:none; - animation:vgAuraBreathe 6s var(--ease-in-out) infinite; -} -.vg-avatar__aura.is-reduced{animation:none;} -@keyframes vgAuraBreathe{0%,100%{opacity:.85;transform:scale(1)}50%{opacity:1;transform:scale(1.04)}} -.vg-avatar__svg{position:relative;z-index:1;display:block;transition:opacity var(--dur-base) var(--ease-out);} -/* ── 래스터(Live2D식) 렌더: 레이어 합성 흉상 ── */ -.vg-raster{position:absolute;inset:0;z-index:1;pointer-events:none;will-change:transform;} -.vg-raster__layer{ - position:absolute;left:50%;top:var(--vg-raster-top,-15%); - height:var(--vg-raster-h,136%);width:auto;max-width:none; - transform:translateX(-50%);object-fit:contain; - user-select:none;-webkit-user-drag:none;opacity:0; -} -.vg-raster__layer--shoulders{opacity:1;z-index:1;} -.vg-raster__layer--neck{opacity:1;z-index:2;} -.vg-raster__layer--hairback{opacity:1;z-index:3;} -.vg-raster__layer--ear{opacity:1;z-index:4;} -.vg-raster__layer--face{opacity:1;z-index:5;} -.vg-raster__layer--forehead{opacity:1;z-index:6;} -.vg-raster__layer--hair{opacity:1;z-index:7;will-change:transform;} -.vg-raster__layer--bangs{opacity:1;z-index:8;will-change:transform;} -.vg-raster__layer--brow{z-index:9;will-change:opacity,transform;} -.vg-raster__layer--eyes{z-index:10;} -.vg-raster__layer--eyelid{z-index:11;} -.vg-raster__layer--nose{opacity:1;z-index:12;} -.vg-raster__layer--mouth{z-index:13;} -.vg-raster__layer--mouthopen{z-index:14;} -.vg-raster__layer--base{opacity:1;z-index:1;} -.vg-raster__layer--upperface{z-index:2;} -.vg-avatar__meta{display:flex;flex-direction:column;align-items:center;gap:3px;text-align:center;} -.vg-avatar__persona{font-size:var(--fs-sm);font-weight:600;color:var(--text-strong);} -.vg-avatar__state{font-size:var(--fs-xs);color:var(--text-muted);} -.vg-avatar__state b{font-weight:600;color:var(--text-body);} -@media (prefers-reduced-motion: reduce){ - .vg-avatar__aura{animation:none;} -} -`; diff --git a/apps/web/src/components/avatar/client-avatar.css b/apps/web/src/components/avatar/client-avatar.css new file mode 100644 index 0000000..5aeeb96 --- /dev/null +++ b/apps/web/src/components/avatar/client-avatar.css @@ -0,0 +1,54 @@ +.vg-avatar{display:flex;flex-direction:column;align-items:center;gap:var(--sp-3);margin:0;} +.vg-avatar__label{ + display:inline-flex;align-items:center;gap:7px; + font-family:var(--font-num);font-size:var(--fs-xs);font-weight:600;letter-spacing:0.06em; + color:var(--text-muted);text-transform:none; +} +.vg-avatar__label-dot{width:6px;height:6px;border-radius:50%;background:var(--clay);flex:none;} +.vg-avatar__stage{ + position:relative;width:100%;border-radius:50%; + display:flex;align-items:center;justify-content:center;overflow:hidden; + background: + radial-gradient(circle at 50% 38%, rgba(251,250,248,.98) 0%, rgba(246,240,236,.9) 42%, rgba(238,244,242,.76) 64%, rgba(145,200,189,.22) 84%, rgba(30,39,36,.18) 100%); + box-shadow: + inset 0 0 0 1px rgba(251,250,248,.28), + inset 0 -24px 40px rgba(28,42,42,.13); +} +.vg-avatar__aura{ + position:absolute;inset:-12%;border-radius:50%;pointer-events:none; + animation:vgAuraBreathe 6s var(--ease-in-out) infinite; +} +.vg-avatar__aura.is-reduced{animation:none;} +@keyframes vgAuraBreathe{0%,100%{opacity:.85;transform:scale(1)}50%{opacity:1;transform:scale(1.04)}} +.vg-avatar__svg{position:relative;z-index:1;display:block;transition:opacity var(--dur-base) var(--ease-out);} +/* ── 래스터(Live2D식) 렌더: 레이어 합성 흉상 ── */ +.vg-raster{position:absolute;inset:0;z-index:1;pointer-events:none;will-change:transform;} +.vg-raster__layer{ + position:absolute;left:50%;top:var(--vg-raster-top,-15%); + height:var(--vg-raster-h,136%);width:auto;max-width:none; + transform:translateX(-50%);object-fit:contain; + user-select:none;-webkit-user-drag:none;opacity:0; +} +.vg-raster__layer--shoulders{opacity:1;z-index:1;} +.vg-raster__layer--neck{opacity:1;z-index:2;} +.vg-raster__layer--hairback{opacity:1;z-index:3;} +.vg-raster__layer--ear{opacity:1;z-index:4;} +.vg-raster__layer--face{opacity:1;z-index:5;} +.vg-raster__layer--forehead{opacity:1;z-index:6;} +.vg-raster__layer--hair{opacity:1;z-index:7;will-change:transform;} +.vg-raster__layer--bangs{opacity:1;z-index:8;will-change:transform;} +.vg-raster__layer--brow{z-index:9;will-change:opacity,transform;} +.vg-raster__layer--eyes{z-index:10;} +.vg-raster__layer--eyelid{z-index:11;} +.vg-raster__layer--nose{opacity:1;z-index:12;} +.vg-raster__layer--mouth{z-index:13;} +.vg-raster__layer--mouthopen{z-index:14;} +.vg-raster__layer--base{opacity:1;z-index:1;} +.vg-raster__layer--upperface{z-index:2;} +.vg-avatar__meta{display:flex;flex-direction:column;align-items:center;gap:3px;text-align:center;} +.vg-avatar__persona{font-size:var(--fs-sm);font-weight:600;color:var(--text-strong);} +.vg-avatar__state{font-size:var(--fs-xs);color:var(--text-muted);} +.vg-avatar__state b{font-weight:600;color:var(--text-body);} +@media (prefers-reduced-motion: reduce){ + .vg-avatar__aura{animation:none;} +} diff --git a/apps/web/src/components/ui/Icon.tsx b/apps/web/src/components/ui/Icon.tsx index 3486a4c..e435654 100644 --- a/apps/web/src/components/ui/Icon.tsx +++ b/apps/web/src/components/ui/Icon.tsx @@ -18,6 +18,7 @@ export type IconName = | "chevron-right" | "chevron-left" | "check" + | "plus" | "share" | "mic" | "mic-off" @@ -94,6 +95,12 @@ const PATHS: Record = { "chevron-right": , "chevron-left": , check: , + plus: ( + <> + + + + ), share: ( <> diff --git a/apps/web/src/pages/LearnerHome.tsx b/apps/web/src/pages/LearnerHome.tsx index 69ab32c..adf7f5a 100644 --- a/apps/web/src/pages/LearnerHome.tsx +++ b/apps/web/src/pages/LearnerHome.tsx @@ -23,6 +23,7 @@ import { shortPersonaName, unavailablePersonaMessage, } from "../lib/personaViewModel"; +import "./learner-home.css"; type LoadState = "loading" | "ready" | "error"; export type LearnerHomeView = "dashboard" | "practice" | "history"; @@ -915,7 +916,6 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) { return ( -
{view === "dashboard" ? ( <> @@ -1448,1570 +1448,3 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) { ); } - -const LH_CSS = ` -.lh-root{ - width:100%; - min-height:0; - display:grid; - align-content:start; - gap:clamp(16px,1.8vw,24px); -} -.lh-head{ - min-width:0; - display:flex; - align-items:flex-start; - justify-content:space-between; - gap:var(--sp-4); -} -.lh-head__copy{min-width:0;} -.lh-head__tools{ - flex:none; - display:flex; - align-items:center; - justify-content:flex-end; - gap:var(--sp-2); - min-width:0; -} -.lh-head__status{ - flex:none; - min-height:34px; - display:inline-flex; - align-items:center; - gap:8px; - padding:0 12px; - border:1px solid var(--border-subtle); - border-radius:var(--radius); - background:var(--bg-surface); - color:var(--text-body); - font-family:var(--font-num); - font-size:var(--fs-xs); - font-weight:750; -} -.lh-title{ - margin:5px 0 0; - color:var(--text-strong); - font-size:clamp(24px,2.4vw,32px); - font-weight:760; - letter-spacing:0; - line-height:1.16; -} -.lh-sub{ - margin:7px 0 0; - max-width:820px; - color:var(--text-body); - font-size:var(--fs-sm); - line-height:1.55; - word-break:keep-all; - overflow-wrap:anywhere; -} -.lh-dashboard-hero{ - min-width:0; - display:grid; - grid-template-columns:minmax(0,1fr) minmax(320px,360px); - gap:var(--sp-4); - align-items:start; -} -.lh-session-focus, -.lh-coach-card, -.lh-panel, -.lh-list-pane, -.lh-preview__main, -.lh-activity, -.lh-persona-progress, -.lh-history-main, -.lh-history-task, -.lh-archive-note{ - min-width:0; - border:1px solid var(--border-subtle); - border-radius:var(--radius); - background:var(--bg-surface); - box-shadow:var(--shadow-sm); -} -.lh-session-focus{ - min-width:0; - display:grid; - align-content:start; - gap:var(--sp-5); - padding:clamp(18px,2.3vw,28px); - background: - linear-gradient(135deg,color-mix(in srgb,var(--bg-surface-2) 86%,transparent),var(--bg-surface)); -} -.lh-session-focus__head{ - min-width:0; - display:flex; - align-items:start; - justify-content:space-between; - gap:var(--sp-4); -} -.lh-session-focus__head h2{ - margin:8px 0 0; - color:var(--text-strong); - font-size:clamp(24px,2.3vw,34px); - line-height:1.12; - letter-spacing:0; - word-break:keep-all; -} -.lh-session-focus__head p{ - margin:7px 0 0; - color:var(--text-muted); - font-size:14px; - line-height:1.5; -} -.lh-session-focus__status{ - flex:0 0 auto; - display:inline-flex; - align-items:center; - min-height:30px; - padding:0 11px; - border:1px solid color-mix(in srgb,var(--accent) 34%,transparent); - border-radius:var(--radius-sm); - background:color-mix(in srgb,var(--accent) 12%,transparent); - color:var(--accent-deep); - font-size:12px; - font-weight:820; - white-space:nowrap; -} -.lh-coach-card{ - min-height:0; - display:flex; - flex-direction:column; - gap:var(--sp-4); - padding:var(--sp-5); -} -.lh-coach-card__cta{ - margin-top:auto; -} -.lh-coach-card h2, -.lh-panel__head h2{ - margin:8px 0 0; - color:var(--text-strong); - font-size:clamp(19px,1.8vw,25px); - line-height:1.22; - letter-spacing:0; -} -.lh-coach-card p{ - margin:12px 0 0; - color:var(--text-body); - font-size:14px; - line-height:1.58; - word-break:keep-all; -} -.lh-coach-hints{ - display:grid; - gap:8px; -} -.lh-coach-hints span{ - min-width:0; - display:flex; - align-items:center; - gap:8px; - min-height:38px; - padding:0 11px; - border:1px solid var(--border-subtle); - border-radius:var(--radius-sm); - background:var(--bg-surface-2); - color:var(--text-body); - font-size:12.5px; - font-weight:700; - line-height:1.35; -} -.lh-coach-hints svg{ - flex:0 0 auto; - color:var(--accent-deep); -} -.lh-recap{ - min-width:0; - display:grid; - grid-template-columns:156px minmax(0,1fr); - gap:var(--sp-5); - align-items:start; - padding-top:var(--sp-4); - border-top:1px solid var(--border-subtle); -} -.lh-recap__avatar{ - min-width:0; - display:grid; - place-items:center; - min-height:156px; - border:1px solid var(--border-subtle); - border-radius:var(--radius); - background:var(--bg-surface); - overflow:hidden; -} -.lh-recap__avatar .vg-avatar{ - margin:0; -} -.lh-recap__body{ - min-width:0; - display:grid; - gap:12px; -} -.lh-recap__meta-row{ - display:flex; - flex-wrap:wrap; - gap:7px; -} -.lh-recap__meta-row span{ - display:inline-flex; - align-items:center; - min-height:24px; - padding:0 8px; - border:1px solid var(--border-subtle); - border-radius:var(--radius-sm); - background:var(--bg-surface); - color:var(--text-muted); - font-family:var(--font-num); - font-size:11.5px; - font-weight:780; -} -.lh-recap__coach{ - margin:0; - color:var(--text-muted); - font-size:12.5px; - line-height:1.45; - word-break:keep-all; -} -.lh-recap__facts{ - margin:0; - display:grid; - grid-template-columns:repeat(3,minmax(0,1fr)); - gap:8px; -} -.lh-recap__facts div{ - min-width:0; - display:grid; - gap:3px; - padding:8px; - border:1px solid var(--border-subtle); - border-radius:var(--radius-sm); - background:var(--bg-surface); -} -.lh-recap__facts dt{ - color:var(--text-muted); - font-size:11px; - font-weight:730; -} -.lh-recap__facts dd{ - margin:0; - color:var(--text-strong); - font-family:var(--font-num); - font-size:12px; - font-weight:800; - overflow:hidden; - text-overflow:ellipsis; - white-space:nowrap; -} -.lh-recap__progress{ - display:grid; - gap:8px; -} -.lh-recap__progress div{ - display:flex; - align-items:center; - justify-content:space-between; - gap:12px; -} -.lh-recap__progress span{ - color:var(--text-muted); - font-size:11px; - font-weight:760; -} -.lh-recap__progress b{ - color:var(--accent-deep); - font-family:var(--font-num); - font-size:12px; - font-weight:840; -} -.lh-recap__progress i{ - position:relative; - display:block; - height:8px; - border-radius:999px; - background:var(--bg-surface); - overflow:hidden; -} -.lh-recap__progress i::before{ - content:""; - position:absolute; - inset:0 auto 0 0; - width:var(--lh-stage-progress,0%); - border-radius:inherit; - background:linear-gradient(90deg,var(--accent),color-mix(in srgb,var(--accent) 72%,#f2c36b)); -} -.lh-recap__quote{ - min-width:0; - display:grid; - gap:5px; - padding:10px 12px; - border:1px solid var(--border-subtle); - border-radius:var(--radius-sm); - background:var(--bg-surface); -} -.lh-recap__quote span{ - color:var(--text-muted); - font-size:11px; - font-weight:760; -} -.lh-recap__quote p{ - margin:0; - max-width:none; - color:var(--text-strong); - font-size:13px; - line-height:1.5; - display:-webkit-box; - -webkit-line-clamp:2; - -webkit-box-orient:vertical; - overflow:hidden; -} -.lh-recap__learner{ - margin:0; - display:grid; - gap:5px; - padding-top:2px; -} -.lh-recap__learner span{ - color:var(--text-muted); - font-size:11px; - font-weight:760; -} -.lh-recap__learner p{ - margin:0; - color:var(--text-body); - font-size:12.5px; - line-height:1.45; - word-break:keep-all; - display:-webkit-box; - -webkit-line-clamp:2; - -webkit-box-orient:vertical; - overflow:hidden; -} -.lh-recap__coach{ - color:var(--text-body); -} -.lh-metric-grid{ - min-width:0; - display:grid; - grid-template-columns:repeat(4,minmax(0,1fr)); - gap:10px; -} -.lh-metric-card{ - min-width:0; - min-height:86px; - display:grid; - align-content:start; - gap:6px; - padding:12px 14px; - border:1px solid var(--border-subtle); - border-radius:var(--radius); - background:color-mix(in srgb,var(--bg-surface) 82%,var(--bg-surface-2)); - color:var(--text-body); - text-align:left; - box-shadow:none; - cursor:pointer; -} -.lh-metric-card:hover{ - border-color:var(--border-strong); - background:var(--bg-surface-2); -} -.lh-metric-card span, -.lh-insight-list span{ - color:var(--text-muted); - font-size:12px; - font-weight:760; -} -.lh-metric-card b{ - color:var(--text-strong); - font-family:var(--font-num); - font-size:clamp(18px,1.6vw,24px); - line-height:1.06; - overflow:hidden; - text-overflow:ellipsis; - white-space:nowrap; -} -.lh-metric-card p, -.lh-insight-list p{ - margin:0; - color:var(--text-body); - font-size:12.5px; - line-height:1.45; - word-break:keep-all; -} -.lh-dashboard-columns, -.lh-history-layout{ - min-width:0; - display:grid; - grid-template-columns:repeat(3,minmax(0,1fr)); - gap:var(--sp-4); - align-items:start; -} -.lh-history-layout{ - grid-template-columns:minmax(0,1fr) minmax(300px,380px); -} -.lh-history-overview{ - min-width:0; - display:grid; - grid-template-columns:repeat(4,minmax(0,1fr)); - gap:10px; -} -.lh-history-task{ - min-height:96px; - display:grid; - align-content:start; - gap:6px; - padding:14px; - color:var(--text-body); - text-align:left; - cursor:pointer; -} -.lh-history-task:hover, -.lh-history-task.is-selected{ - border-color:color-mix(in srgb,var(--accent) 50%,var(--border-subtle)); - background:var(--accent-tint); -} -.lh-history-task span{ - color:var(--text-muted); - font-size:12px; - font-weight:800; -} -.lh-history-task b{ - color:var(--text-strong); - font-family:var(--font-num); - font-size:clamp(23px,2vw,30px); - line-height:1; -} -.lh-history-task small{ - color:var(--text-body); - font-size:12px; - line-height:1.35; -} -.lh-history-main{ - display:grid; - gap:var(--sp-4); - padding:var(--sp-5); -} -.lh-history-side{ - min-width:0; - display:grid; - gap:var(--sp-4); -} -.lh-history-main__head{ - min-width:0; - display:flex; - align-items:flex-start; - justify-content:space-between; - gap:var(--sp-3); -} -.lh-history-main__head h2{ - margin:7px 0 0; - color:var(--text-strong); - font-size:clamp(19px,1.8vw,25px); - line-height:1.18; -} -.lh-history-main__head > span{ - flex:none; - color:var(--text-muted); - font-family:var(--font-num); - font-size:12px; - font-weight:800; -} -.lh-history-tools{ - min-width:0; - display:grid; - grid-template-columns:minmax(220px,1fr) auto; - gap:10px; - align-items:end; -} -.lh-history-search{ - min-width:0; - display:grid; - gap:6px; -} -.lh-history-search span{ - color:var(--text-muted); - font-size:12px; - font-weight:780; -} -.lh-history-search input{ - width:100%; - min-width:0; - min-height:38px; - border:1px solid var(--border-subtle); - border-radius:var(--radius-sm); - background:var(--bg-surface-2); - color:var(--text-strong); - font:inherit; - font-size:13px; - padding:0 11px; -} -.lh-history-search input::placeholder{color:var(--text-muted);} -.lh-history-search input:focus{ - outline:2px solid color-mix(in srgb,var(--accent) 34%,transparent); - outline-offset:2px; - border-color:var(--accent); -} -.lh-history-filter{ - min-width:0; - display:flex; - flex-wrap:wrap; - gap:6px; - justify-content:flex-end; -} -.lh-history-filter button{ - min-height:38px; - border:1px solid var(--border-subtle); - border-radius:var(--radius-sm); - background:var(--bg-surface-2); - color:var(--text-body); - font-size:12px; - font-weight:800; - padding:0 11px; - white-space:nowrap; - cursor:pointer; -} -.lh-history-filter button:hover, -.lh-history-filter button.is-selected{ - border-color:color-mix(in srgb,var(--accent) 42%,var(--border-subtle)); - background:var(--accent-tint); - color:var(--accent-deep); -} -.lh-panel{ - display:grid; - gap:14px; - padding:18px; -} -.lh-panel__head, -.lh-pane-head, -.lh-activity__head, -.lh-persona-progress__head{ - min-width:0; - display:flex; - align-items:center; - justify-content:space-between; - gap:var(--sp-3); -} -.lh-panel__head h2{ - font-size:clamp(17px,1.5vw,21px); -} -.lh-panel__badge{ - display:inline-flex; - align-items:center; - min-height:26px; - padding:0 9px; - border:1px solid color-mix(in srgb,var(--accent) 25%,transparent); - border-radius:var(--radius-sm); - background:color-mix(in srgb,var(--accent) 10%,transparent); - color:var(--accent-deep); - font-size:11.5px; - font-weight:800; - white-space:nowrap; -} -.lh-panel__head button{ - border:1px solid var(--border-subtle); - border-radius:var(--radius-sm); - background:var(--bg-surface-2); - color:var(--accent-deep); - min-height:32px; - padding:0 10px; - font-size:12px; - font-weight:740; - cursor:pointer; - white-space:nowrap; -} -.lh-insight-list{ - display:grid; - gap:10px; -} -.lh-insight-list div{ - min-width:0; - display:grid; - gap:6px; - padding:14px; - border:1px solid var(--border-subtle); - border-radius:var(--radius); - background:var(--bg-surface-2); -} -.lh-insight-list b{ - color:var(--text-strong); - font-size:15px; - line-height:1.35; -} -.lh-recommend-card{ - display:grid; - gap:10px; - min-width:0; - padding:16px; - border:1px solid color-mix(in srgb,var(--accent) 18%,var(--border-subtle)); - border-radius:var(--radius); - background: - linear-gradient(135deg,color-mix(in srgb,var(--accent) 12%,transparent),transparent 62%), - var(--bg-surface-2); -} -.lh-recommend-card span{ - color:var(--accent-deep); - font-size:12px; - font-weight:800; -} -.lh-recommend-card b{ - color:var(--text-strong); - font-size:17px; - line-height:1.35; - word-break:keep-all; -} -.lh-recommend-card p{ - margin:0; - color:var(--text-body); - font-size:12.5px; - line-height:1.55; - word-break:keep-all; -} -.lh-recommend-card button{ - display:flex; - align-items:center; - justify-content:space-between; - gap:10px; - min-height:40px; - margin-top:2px; - padding:0 12px; - border:1px solid color-mix(in srgb,var(--accent) 34%,transparent); - border-radius:var(--radius-sm); - background:color-mix(in srgb,var(--accent) 10%,transparent); - color:var(--accent-deep); - font-size:13px; - font-weight:800; - cursor:pointer; -} -.lh-recommend-card button:hover{ - background:color-mix(in srgb,var(--accent) 17%,transparent); -} -.lh-compact-empty{ - margin:0; - padding:13px; - border:1px solid var(--border-subtle); - border-radius:var(--radius); - background:var(--bg-surface-2); - color:var(--text-body); - font-size:13px; - line-height:1.5; -} -.lh-compact-list{ - list-style:none; - margin:0; - padding:0; - display:grid; - gap:8px; -} -.lh-compact-list li{ - min-width:0; - display:grid; - grid-template-columns:34px minmax(0,1fr) auto; - gap:10px; - align-items:center; - padding:10px; - border:1px solid var(--border-subtle); - border-radius:var(--radius-sm); - background:var(--bg-surface-2); -} -.lh-compact-list__avatar{ - width:34px; - height:34px; - display:grid; - place-items:center; - border-radius:50%; - background:color-mix(in srgb,var(--clay) 20%,var(--bg-surface)); - color:var(--text-strong); - font-weight:820; -} -.lh-compact-list__body{ - min-width:0; - display:grid; - gap:3px; -} -.lh-compact-list__body b{ - min-width:0; - color:var(--text-strong); - font-size:13px; - line-height:1.25; - overflow:hidden; - text-overflow:ellipsis; - white-space:nowrap; -} -.lh-compact-list__body small{ - min-width:0; - color:var(--text-muted); - font-family:var(--font-num); - font-size:11.5px; - line-height:1.25; - overflow:hidden; - text-overflow:ellipsis; - white-space:nowrap; -} -.lh-compact-list__action, -.lh-compact-list__status{ - min-height:28px; - padding:0 8px; - border:1px solid color-mix(in srgb,var(--accent) 28%,transparent); - border-radius:var(--radius-sm); - background:color-mix(in srgb,var(--accent) 10%,transparent); - color:var(--accent-deep); - font-size:11.5px; - font-weight:800; - white-space:nowrap; - cursor:pointer; -} -.lh-compact-list__status{ - border-color:var(--border-subtle); - background:var(--bg-surface); - color:var(--text-body); -} -.lh-feedback-mini{ - display:grid; - gap:10px; - padding-top:14px; - border-top:1px solid var(--border-subtle); -} -.lh-feedback-mini__head{ - display:flex; - align-items:center; - justify-content:space-between; - gap:10px; -} -.lh-feedback-mini__head span{ - color:var(--text-muted); - font-size:12px; - font-weight:760; -} -.lh-feedback-mini > p{ - margin:0; - color:var(--text-body); - font-size:13px; - line-height:1.5; -} -.lh-feedback-mini__row{ - display:grid; - gap:5px; - padding:10px 0; - border:0; - border-bottom:1px solid var(--border-subtle); - background:transparent; - color:var(--text-body); - text-align:left; - cursor:pointer; -} -.lh-feedback-mini__row:hover{ - color:var(--accent-deep); -} -.lh-feedback-mini__row b{ - color:var(--text-strong); - font-size:13.5px; - line-height:1.35; -} -.lh-feedback-mini__row span{ - color:var(--text-body); - font-size:12.5px; - line-height:1.45; - word-break:keep-all; -} -.lh-feedback-mini__row em{ - color:var(--text-muted); - font-size:11.5px; - font-style:normal; -} -.lh-practice-layout{ - min-width:0; - display:grid; - grid-template-columns:minmax(300px,380px) minmax(0,1fr); - gap:var(--sp-5); - align-items:start; -} -.lh-list-pane{ - position:sticky; - top:calc(var(--topbar-h) + 18px); - display:grid; - grid-template-rows:auto minmax(0,1fr); - max-height:calc(100dvh - var(--topbar-h) - 36px); - padding:var(--sp-4); - overflow:hidden; -} -.lh-pane-head{margin-bottom:var(--sp-3);} -.lh-pane-head span, -.lh-activity__head span, -.lh-persona-progress__head span{ - color:var(--text-muted); - font-family:var(--font-num); - font-size:12px; - font-weight:800; - white-space:nowrap; -} -.lh-personas{ - min-height:0; - display:grid; - align-content:start; - gap:8px; - overflow:auto; - scrollbar-gutter:stable; - padding-right:3px; -} -.lh-persona{ - position:relative; - min-width:0; - min-height:92px; - display:grid; - grid-template-columns:44px minmax(0,1fr); - gap:11px; - align-items:start; - padding:12px 38px 12px 12px; - border:1px solid var(--border-subtle); - border-radius:var(--radius); - background:var(--bg-surface-2); - color:var(--text-body); - text-align:left; - cursor:pointer; -} -.lh-persona:hover{border-color:var(--border-strong);} -.lh-persona:disabled{ - cursor:not-allowed; - opacity:.68; -} -.lh-persona.is-selected{ - border-color:var(--accent); - background:var(--accent-tint); - box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 12%,transparent); -} -.lh-persona__mark{ - width:40px; - height:40px; - display:grid; - place-items:center; - color:var(--text-strong); - background:var(--bg-surface); - border:1px solid var(--border-subtle); - border-radius:var(--radius-sm); - font-family:var(--font-num); - font-size:12px; - font-weight:800; -} -.lh-persona__body{ - min-width:0; - display:grid; - gap:5px; -} -.lh-persona__name{ - color:var(--text-strong); - font-size:14.5px; - font-weight:760; - line-height:1.25; - overflow:hidden; - display:-webkit-box; - -webkit-line-clamp:1; - -webkit-box-orient:vertical; -} -.lh-persona__meta{ - color:var(--text-muted); - font-size:12px; - line-height:1.45; - overflow:hidden; - text-overflow:ellipsis; - white-space:nowrap; -} -.lh-persona__summary{ - min-width:0; - color:var(--text-body); - font-size:12.5px; - line-height:1.45; - overflow:hidden; - display:-webkit-box; - -webkit-line-clamp:2; - -webkit-box-orient:vertical; -} -.lh-persona__check{ - position:absolute; - top:10px; - right:10px; - width:22px; - height:22px; - display:grid; - place-items:center; - border-radius:50%; - background:var(--accent); - color:var(--text-on-accent); -} -.lh-persona.is-skel{pointer-events:none;} -.lh-skel__box, -.lh-skel__line{ - background:linear-gradient(90deg,var(--bg-surface-2),var(--neutral-100),var(--bg-surface-2)); - background-size:180% 100%; - animation:lhSkel 1.1s ease-in-out infinite; -} -.lh-skel__line{ - height:11px; - border-radius:6px; -} -.lh-skel__line--name{width:62%;height:13px;} -.lh-skel__line--meta{width:44%;height:10px;} -.lh-skel__line--sum{width:92%;} -.lh-preview{ - min-width:0; - display:grid; - gap:var(--sp-4); - align-content:start; -} -.lh-preview__main{ - display:grid; - gap:var(--sp-4); - align-content:start; - padding:var(--sp-5); -} -.lh-preview__hero{ - min-width:0; - display:grid; - grid-template-columns:minmax(0,1fr) minmax(220px,auto); - gap:var(--sp-4); - align-items:start; -} -.lh-preview__identity{ - min-width:0; - display:grid; - grid-template-columns:auto minmax(0,1fr); - align-items:start; - gap:var(--sp-4); -} -.lh-avatar{ - width:64px; - height:64px; - display:grid; - place-items:center; - border-radius:50%; - background:var(--accent-tint); - color:var(--accent-deep); - font-size:26px; - font-weight:780; - box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--accent) 22%,transparent); -} -.lh-preview__copy{min-width:0;} -.lh-preview__code{ - display:inline-flex; - align-items:center; - min-height:24px; - padding:0 9px; - border-radius:var(--radius-sm); - background:var(--bg-surface-2); - color:var(--accent-deep); - font-family:var(--font-num); - font-size:12px; - font-weight:800; -} -.lh-preview h2{ - margin:8px 0 0; - color:var(--text-strong); - font-size:clamp(22px,2.2vw,30px); - line-height:1.2; - letter-spacing:0; - word-break:keep-all; - overflow-wrap:anywhere; -} -.lh-preview__copy p{ - margin:7px 0 0; - color:var(--text-muted); - font-size:var(--fs-sm); - line-height:1.45; - word-break:keep-all; -} -.lh-actions{ - min-width:0; - display:grid; - justify-items:end; - gap:10px; -} -.lh-actions__note{ - display:block; - min-width:0; - max-width:280px; - color:var(--text-muted); - font-size:12.5px; - line-height:1.45; - text-align:right; - word-break:keep-all; -} -.lh-summary{ - min-width:0; - padding:var(--sp-4); - border:1px solid var(--border-subtle); - border-radius:var(--radius); - background:var(--bg-surface-2); -} -.lh-summary p{ - margin:8px 0 0; - color:var(--text-body); - font-size:clamp(15px,1.2vw,18px); - line-height:1.52; - word-break:keep-all; -} -.lh-facts{ - display:grid; - grid-template-columns:repeat(3,minmax(0,1fr)); - gap:10px; - margin:0; -} -.lh-facts div{ - min-width:0; - min-height:64px; - padding:12px; - border:1px solid var(--border-subtle); - border-radius:var(--radius); - background:var(--bg-surface-2); -} -.lh-facts dt{ - margin:0; - color:var(--text-muted); - font-size:12px; - font-weight:720; -} -.lh-facts dd{ - margin:7px 0 0; - color:var(--text-strong); - font-size:14px; - font-weight:720; - line-height:1.3; - overflow:hidden; - text-overflow:ellipsis; - white-space:nowrap; -} -.lh-activity{ - display:grid; - gap:var(--sp-4); - padding:var(--sp-5); -} -.lh-activity__stats{ - min-width:0; - display:grid; - grid-template-columns:repeat(3,minmax(0,1fr)); - gap:8px; -} -.lh-activity__stats span{ - min-width:0; - min-height:58px; - display:grid; - align-content:center; - gap:4px; - padding:10px; - border:1px solid var(--border-subtle); - border-radius:var(--radius); - background:var(--bg-surface-2); -} -.lh-activity__stats b{ - color:var(--text-strong); - font-family:var(--font-num); - font-size:22px; - line-height:1; -} -.lh-activity__stats small{ - color:var(--text-muted); - font-size:12px; - font-weight:720; -} -.lh-activity__recent{ - min-width:0; - padding:var(--sp-4); - border:1px solid var(--border-subtle); - border-radius:var(--radius); - background:var(--bg-surface-2); -} -.lh-activity__head{ - margin-bottom:10px; -} -.lh-session-list{ - list-style:none; - margin:0; - padding:0; - display:grid; - gap:10px; - background:transparent; -} -.lh-session-list li{ - min-width:0; - display:grid; - grid-template-columns:minmax(140px,.75fr) minmax(0,1fr) auto; - gap:12px; - align-items:center; - padding:13px; - border:1px solid var(--border-subtle); - border-radius:var(--radius); - background:var(--bg-surface); -} -.lh-session-card:hover{ - border-color:var(--border-strong); - background:color-mix(in srgb,var(--bg-surface) 78%,var(--bg-surface-2)); -} -.lh-session-card.is-active{ - border-color:color-mix(in srgb,var(--accent) 42%,var(--border-subtle)); -} -.lh-session-card.is-review{ - border-color:color-mix(in srgb,var(--warn-solid) 35%,var(--border-subtle)); -} -.lh-session-card__status{ - min-width:0; - display:grid; - grid-template-columns:10px minmax(0,1fr); - gap:8px; - align-items:start; -} -.lh-session-card__dot{ - width:8px; - height:8px; - margin-top:6px; - border-radius:50%; - background:var(--text-muted); -} -.lh-session-card.is-active .lh-session-card__dot{background:var(--accent);} -.lh-session-card.is-review .lh-session-card__dot{background:var(--warn-solid);} -.lh-session-card__status b{ - display:block; - color:var(--text-strong); - font-size:13px; - line-height:1.25; -} -.lh-session-card__status small{ - display:block; - margin-top:3px; - color:var(--text-muted); - font-size:12px; - line-height:1.3; -} -.lh-session-card__body{ - min-width:0; - display:grid; - gap:7px; -} -.lh-session-card__title{ - min-width:0; - display:flex; - align-items:center; - gap:8px; -} -.lh-session-card__title span{ - flex:none; - min-width:34px; - min-height:24px; - display:inline-grid; - place-items:center; - padding:0 7px; - border:1px solid var(--border-subtle); - border-radius:var(--radius-sm); - background:var(--bg-surface-2); - color:var(--text-strong); - font-family:var(--font-num); - font-size:11px; - font-weight:840; -} -.lh-session-card__title b{ - min-width:0; - color:var(--text-strong); - font-size:14px; - line-height:1.25; - overflow:hidden; - text-overflow:ellipsis; - white-space:nowrap; -} -.lh-session-card__body p, -.lh-muted{ - margin:0; - color:var(--text-muted); - font-size:12px; - line-height:1.45; -} -.lh-session-card__progress{ - width:100%; - height:5px; - overflow:hidden; - border-radius:999px; - background:color-mix(in srgb,var(--border-subtle) 72%,transparent); -} -.lh-session-card__progress span{ - display:block; - max-width:100%; - height:100%; - border-radius:inherit; - background:var(--accent); -} -.lh-session-actions{ - display:flex; - align-items:center; - justify-content:flex-end; - gap:6px; -} -.lh-review-link{ - flex:none; - border:1px solid var(--border-subtle); - border-radius:var(--radius-sm); - background:var(--bg-surface); - color:var(--accent-deep); - font-family:var(--font-sans); - font-size:12px; - font-weight:720; - min-height:30px; - padding:5px 9px; - cursor:pointer; - white-space:nowrap; -} -.lh-review-link:hover{background:var(--accent-tint);} -.lh-review-link--ghost{color:var(--text-body);} -.lh-review-link--ghost:hover{color:var(--accent-deep);} -.lh-review-link:disabled{ - cursor:wait; - opacity:.58; -} -.lh-review-link:disabled:hover{ - background:var(--bg-surface); - color:var(--text-body); -} -.lh-inline-alert, -.lh-activity__empty{ - display:grid; - grid-template-columns:auto minmax(0,1fr); - gap:10px; - align-items:start; - padding:12px; - border:1px solid var(--border-subtle); - border-radius:var(--radius); - background:var(--bg-surface); - color:var(--text-body); -} -.lh-inline-alert{ - display:flex; - color:var(--warn-text); - font-size:12px; - line-height:1.45; -} -.lh-inline-alert--archive{ - margin-bottom:10px; - color:var(--text-body); -} -.lh-activity__empty svg{ - margin-top:2px; - color:var(--accent-deep); -} -.lh-activity__empty b{ - display:block; - color:var(--text-strong); - font-size:13px; -} -.lh-activity__empty p{ - margin:4px 0 0; - color:var(--text-muted); - font-size:12px; - line-height:1.45; -} -.lh-persona-progress{ - display:grid; - gap:10px; - padding:var(--sp-5); -} -.lh-persona-progress__rows{ - display:grid; - gap:8px; -} -.lh-persona-progress__row{ - width:100%; - min-width:0; - display:grid; - grid-template-columns:minmax(86px,.8fr) minmax(0,1.2fr) minmax(90px,.7fr); - gap:8px 10px; - align-items:center; - padding:10px; - border:1px solid var(--border-subtle); - border-radius:var(--radius-sm); - background:var(--bg-surface-2); - color:var(--text-body); - text-align:left; - cursor:pointer; -} -.lh-persona-progress__row:hover, -.lh-persona-progress__row.is-selected{ - border-color:color-mix(in srgb,var(--accent) 50%,var(--border-subtle)); - background:var(--accent-tint); -} -.lh-persona-progress__title{ - min-width:0; - display:grid; - gap:2px; -} -.lh-persona-progress__title b{ - color:var(--text-strong); - font-family:var(--font-num); - font-size:12px; - line-height:1; -} -.lh-persona-progress__title em, -.lh-persona-progress__meta, -.lh-persona-progress__latest, -.lh-persona-progress__note{ - color:var(--text-muted); - font-size:12px; - line-height:1.35; -} -.lh-persona-progress__title em, -.lh-persona-progress__meta, -.lh-persona-progress__latest{ - min-width:0; - overflow:hidden; - text-overflow:ellipsis; - white-space:nowrap; -} -.lh-persona-progress__title em{ - font-style:normal; - font-weight:720; -} -.lh-persona-progress__bar{ - grid-column:1 / -1; - height:6px; - overflow:hidden; - border-radius:999px; - background:color-mix(in srgb,var(--border-subtle) 70%,transparent); -} -.lh-persona-progress__bar span{ - display:block; - max-width:100%; - height:100%; - border-radius:inherit; - background:var(--accent); -} -.lh-persona-progress__note{margin:0;} -.lh-archive-note{ - display:grid; - gap:12px; - padding:var(--sp-5); -} -.lh-archive-note__rows{ - display:grid; - gap:8px; -} -.lh-archive-note__rows span{ - display:grid; - gap:3px; - padding:10px; - border:1px solid var(--border-subtle); - border-radius:var(--radius-sm); - background:var(--bg-surface-2); -} -.lh-archive-note__rows b{ - color:var(--text-strong); - font-size:13px; - line-height:1.25; -} -.lh-archive-note__rows small{ - color:var(--text-muted); - font-size:12px; - line-height:1.35; -} -.lh-error{ - display:flex; - align-items:flex-start; - gap:var(--sp-3); - color:var(--crit-text); - background:var(--crit-tint); - border-radius:var(--radius); - padding:var(--sp-4); -} -.lh-error b{display:block;} -.lh-error p{margin:4px 0 0;font-size:var(--fs-sm);line-height:1.5;} -@keyframes lhSkel{0%{background-position:120% 0}100%{background-position:-80% 0}} -@media (max-width:1180px){ - .lh-dashboard-hero, - .lh-dashboard-columns, - .lh-history-layout, - .lh-practice-layout{ - grid-template-columns:1fr; - } - .lh-list-pane{ - position:relative; - top:auto; - max-height:none; - overflow:visible; - } - .lh-personas{ - grid-template-columns:repeat(2,minmax(0,1fr)); - overflow:visible; - padding-right:0; - } -} -@media (max-width:860px){ - .lh-metric-grid{ - grid-template-columns:repeat(2,minmax(0,1fr)); - } - .lh-history-overview{ - grid-template-columns:repeat(2,minmax(0,1fr)); - } - .lh-history-tools{ - grid-template-columns:1fr; - } - .lh-history-filter{ - justify-content:flex-start; - } - .lh-preview__hero{ - grid-template-columns:1fr; - } - .lh-actions{ - justify-items:start; - } - .lh-actions__note{ - text-align:left; - max-width:none; - } -} -@media (max-width:720px){ - .lh-head{ - display:grid; - gap:10px; - } - .lh-head__tools{ - width:100%; - justify-content:space-between; - align-items:center; - } - .lh-head__status{ - justify-self:start; - } - .lh-title{ - font-size:22px; - } - .lh-sub{ - font-size:12.5px; - line-height:1.45; - } - .lh-session-focus, - .lh-coach-card, - .lh-panel, - .lh-list-pane, - .lh-preview__main, - .lh-activity, - .lh-persona-progress, - .lh-history-main, - .lh-archive-note{ - padding:14px; - } - .lh-recap{ - grid-template-columns:1fr; - gap:12px; - } - .lh-recap__avatar{ - min-height:120px; - } - .lh-recap__facts{ - grid-template-columns:repeat(2,minmax(0,1fr)); - } - .lh-metric-card{ - min-height:0; - padding:12px; - } - .lh-personas{ - display:flex; - grid-template-columns:none; - gap:8px; - overflow-x:auto; - overflow-y:hidden; - padding-bottom:2px; - } - .lh-persona{ - flex:0 0 min(82vw,320px); - min-height:66px; - grid-template-columns:38px minmax(0,1fr); - align-items:center; - padding:10px 38px 10px 10px; - } - .lh-persona__mark{width:34px;height:34px;} - .lh-persona__summary{display:none;} - .lh-preview__identity{ - grid-template-columns:54px minmax(0,1fr); - gap:12px; - } - .lh-avatar{width:54px;height:54px;font-size:22px;} - .lh-preview h2{font-size:20px;} - .lh-facts{ - grid-template-columns:1fr; - gap:8px; - } - .lh-facts div{ - min-height:0; - display:flex; - align-items:center; - justify-content:space-between; - gap:8px; - padding:10px; - } - .lh-facts dd{ - margin:0; - max-width:70%; - } - .lh-activity__stats{ - gap:6px; - } - .lh-activity__stats span{ - min-height:46px; - padding:8px 4px; - text-align:center; - } - .lh-activity__stats b{font-size:17px;} - .lh-activity__stats small{font-size:11px;} - .lh-session-list li{ - grid-template-columns:1fr; - gap:10px; - align-items:start; - } - .lh-session-actions{ - justify-content:flex-start; - } - .lh-persona-progress__row{ - grid-template-columns:1fr; - gap:6px; - } - .lh-persona-progress__title{ - grid-template-columns:auto minmax(0,1fr); - align-items:center; - gap:7px; - } - .lh-persona-progress__bar, - .lh-persona-progress__latest{ - grid-column:auto; - } - .lh-persona-progress__meta, - .lh-persona-progress__latest{ - white-space:normal; - } -} -@media (max-width:460px){ - .lh-head__status{display:none;} - .lh-history-overview{ - grid-template-columns:1fr; - } - .lh-session-focus__head{ - display:grid; - gap:10px; - } - .lh-session-focus__status{ - justify-self:start; - } - .lh-persona{ - flex-basis:min(78vw,300px); - width:min(78vw,300px); - } - .lh-recap__facts{ - grid-template-columns:1fr; - } - .lh-session-actions{ - grid-column:1; - grid-row:auto; - gap:4px; - flex-direction:row; - justify-content:flex-start; - } - .lh-review-link{ - min-height:32px; - padding:4px 7px; - } -} -@media (prefers-reduced-motion:reduce){ - .lh-skel__box, - .lh-skel__line{animation:none;} -} -`; diff --git a/apps/web/src/pages/Login.tsx b/apps/web/src/pages/Login.tsx index c9b872f..ea1fc16 100644 --- a/apps/web/src/pages/Login.tsx +++ b/apps/web/src/pages/Login.tsx @@ -1,8 +1,10 @@ import { useEffect, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; -import { Icon } from "../components/ui/Icon"; import { roleHomePath, useAuth, type Role } from "../lib/auth"; import { apiUrl, authApi, type AuthConfigResponse } from "../lib/api"; +import { LoginBrand } from "./login/LoginBrand"; +import { LoginPanel, type LoginRoleOption } from "./login/LoginPanel"; +import "./login/login.css"; const OAUTH_NOT_CONFIGURED_MESSAGE = "Google 로그인이 아직 연결되지 않았습니다. 관리자에게 OAuth 클라이언트 설정을 요청하세요."; @@ -57,7 +59,7 @@ function oauthMessage(reason: string | null): string | null { return OAUTH_FAILED_MESSAGE; } -const ROLE_OPTIONS: { role: Role; label: string; desc: string; dotClass: string }[] = [ +const ROLE_OPTIONS: LoginRoleOption[] = [ { role: "learner", label: "학습자", desc: "연습 공간으로 이동", dotClass: "learner" }, { role: "teacher", label: "교수자", desc: "담당 학습자 관리", dotClass: "teacher" }, { role: "admin", label: "관리자", desc: "운영 설정과 감사", dotClass: "admin" }, @@ -143,6 +145,11 @@ export default function Login() { : allowedDomains[0] ? "승인된 Google 계정" : "관리자 설정 필요"; + const oauthStatusMessage = devOAuthUnavailable + ? LOCAL_OAUTH_UNAVAILABLE_MESSAGE + : oauthChecking + ? "Google 로그인 설정을 확인하는 중입니다." + : "Google 로그인이 아직 연결되지 않았습니다. 관리자에게 OAuth 클라이언트 설정을 요청하세요."; const startOAuth = () => { if (!oauthReady) { @@ -182,485 +189,28 @@ export default function Login() { return (
- - -
-
- - Vignette -
- -
- - -

실제 계정으로 들어가고,
실제 회기만 남깁니다.

-

- Vignette는 상담 연습, 교수자 피드백, 운영 관리 권한을 안전한 로그인 세션으로 분리합니다. - 브라우저에는 인증 토큰을 저장하지 않습니다. -

-
- -
- - - 허용 도메인 - - {allowedDomains.length ? ( - allowedDomains.map((domain) => {domain}) - ) : ( - {oauthChecking ? "확인 중" : "설정 필요"} - )} -
-
- -
-
- - -

로그인

-

- 학교 또는 승인된 Google 계정으로 접속하면 역할과 코호트 권한을 확인합니다. -

- -
- - - -
- - {!oauthReady ? ( -
- - - {devOAuthUnavailable - ? LOCAL_OAUTH_UNAVAILABLE_MESSAGE - : oauthChecking - ? "Google 로그인 설정을 확인하는 중입니다." - : "Google 로그인이 아직 연결되지 않았습니다. 관리자에게 OAuth 클라이언트 설정을 요청하세요."} - -
- ) : null} - - {devLoginReady ? ( -
-
- 로컬 테스트 - 보안 로그인 세션 사용 -
-
- {ROLE_OPTIONS.map((opt) => ( - - ))} -
- - {loginError ? ( -

- {loginError} - {loginErrorReason ? 오류 코드: {loginErrorReason} : null} -

- ) : null} -
- ) : null} - - {!devLoginReady && loginError ? ( -

- {loginError} - {loginErrorReason ? 오류 코드: {loginErrorReason} : null} -

- ) : null} - -

- 교육용 비치료 연구 도구입니다. 실제 치료, 진단, 위기 개입을 대체하지 않습니다. -

-
-
+ + { + void enterDev(role); + }, + }} + error={{ message: loginError, reason: loginErrorReason }} + />
); } - -const LOGIN_CSS = ` -.lg-root{ - min-height:100dvh; - position:relative; - display:grid; - grid-template-columns:minmax(0,1fr) minmax(360px,500px); - background: - linear-gradient(90deg,rgba(7,16,14,.84) 0%,rgba(10,21,19,.72) 48%,rgba(10,21,19,.55) 74%,rgba(10,21,19,.62) 100%), - var(--asset-login-room) center / cover no-repeat; - color:#edf4f2; - overflow-x:hidden; - overflow-y:auto; -} -.lg-root::before{ - content:""; - position:absolute; - inset:0; - background:linear-gradient(180deg,rgba(3,9,8,.18),rgba(3,9,8,.42)); - pointer-events:none; -} -.lg-brand, -.lg-enter{ - position:relative; - z-index:1; -} -.lg-brand{ - min-width:0; - width:100%; - position:relative; - display:flex; - flex-direction:column; - justify-content:space-between; - gap:var(--sp-7); - padding:var(--sp-7); - background:transparent; - color:#edf4f2; - overflow:hidden; -} -.lg-brand::after{ - display:none; -} -.lg-wordmark{ - position:relative; - z-index:1; - display:flex; - align-items:center; - gap:10px; - font-size:19px; - font-weight:700; - letter-spacing:0; - color:#edf4f2; -} -.lg-mark{display:grid;place-items:center;color:var(--accent-bright);} -.lg-copy{max-width:620px;} -.lg-copy, -.lg-policy{ - position:relative; - z-index:1; -} -.lg-kicker{ - display:inline-flex; - align-items:center; - gap:8px; - font-family:var(--font-num); - font-size:12px; - font-weight:700; - letter-spacing:0; - text-transform:uppercase; - color:var(--accent-bright); -} -.lg-kicker .d{width:6px;height:6px;border-radius:50%;background:currentColor;} -.lg-highlight{ - display:inline-block; - position:relative; - z-index:0; - padding:0 4px; - background:linear-gradient(90deg,#59b5a6,#8ee4d6); - -webkit-background-clip:text; - background-clip:text; - -webkit-text-fill-color:transparent; - color:transparent; -} -.lg-copy h1{ - margin:var(--sp-4) 0 0; - max-width:640px; - font-size:56px; - line-height:1.12; - letter-spacing:0; - font-weight:760; -} -.lg-copy p{ - margin:var(--sp-5) 0 0; - max-width:560px; - color:rgba(237,244,242,.72); - font-size:17px; - line-height:1.75; -} -.lg-policy{ - display:flex; - align-items:center; - flex-wrap:wrap; - gap:10px; - width:max-content; - max-width:min(430px,100%); - padding:14px 16px; - border:1px solid rgba(255,255,255,.14); - border-radius:var(--radius-lg); - background:rgba(237,244,242,.08); - backdrop-filter:blur(18px) saturate(1.08); - -webkit-backdrop-filter:blur(18px) saturate(1.08); - color:rgba(237,244,242,.66); - font-size:13px; -} -.lg-policy span,.lg-policy b{ - display:inline-flex; - align-items:center; - gap:7px; -} -.lg-policy b{ - color:#edf4f2; - background:rgba(255,255,255,.08); - border:1px solid rgba(255,255,255,.12); - border-radius:999px; - padding:5px 10px; - font-weight:650; -} -.lg-enter{ - min-width:0; - display:flex; - align-items:center; - justify-content:center; - padding:var(--sp-6); - background:transparent; -} -.lg-panel{ - width:100%; - max-width:430px; - background:rgba(11,25,22,.64); - border:1px solid rgba(255,255,255,.16); - border-radius:var(--radius-lg); - box-shadow:0 24px 70px rgba(0,0,0,.34), inset 0 1px 0 rgba(255,255,255,.08); - backdrop-filter:blur(24px) saturate(1.14); - -webkit-backdrop-filter:blur(24px) saturate(1.14); - padding:var(--sp-6); - color:#edf4f2; -} -.lg-panel h2{ - margin:var(--sp-3) 0 0; - font-size:28px; - line-height:1.25; - letter-spacing:0; - color:#f7fbf9; -} -.lg-lead{ - margin:10px 0 0; - color:rgba(237,244,242,.76); - font-size:14px; - line-height:1.65; -} -.lg-actions{display:flex;flex-direction:column;gap:var(--sp-3);margin-top:var(--sp-6);} -.lg-obtn{ - width:100%; - min-height:58px; - display:grid; - grid-template-columns:36px minmax(0,1fr) 18px; - align-items:center; - gap:13px; - border-radius:var(--radius); - padding:11px 14px; - font-family:var(--font-sans); - font-size:15px; - font-weight:650; - text-align:left; - cursor:pointer; -} -.lg-obtn.primary{ - background:rgba(89,181,166,.2); - border:1px solid rgba(114,211,197,.44); - color:#f7fbf9; -} -.lg-obtn.primary:hover{background:rgba(89,181,166,.28);border-color:rgba(114,211,197,.62);} -.lg-obtn.secondary{ - background:rgba(255,255,255,.08); - border:1px solid rgba(255,255,255,.13); - color:#edf4f2; -} -.lg-obtn.secondary:hover{border-color:rgba(114,211,197,.42);background:rgba(255,255,255,.11);} -.lg-obtn:disabled{ - cursor:not-allowed; - opacity:1; - background:rgba(255,255,255,.07); - border-color:rgba(255,255,255,.1); - color:rgba(237,244,242,.58); -} -.lg-obtn:disabled:hover{ - background:rgba(255,255,255,.07); - border-color:rgba(255,255,255,.1); -} -.lg-obtn:disabled .ic{ - background:rgba(255,255,255,.08); - color:rgba(237,244,242,.58); -} -.lg-obtn:disabled .sub{ - color:rgba(237,244,242,.45); -} -.lg-obtn .ic{ - width:36px; - height:36px; - display:grid; - place-items:center; - border-radius:8px; - background:rgba(255,255,255,.16); -} -.lg-obtn.secondary .ic{background:rgba(7,17,15,.34);} -.lg-obtn .txt{min-width:0;display:flex;flex-direction:column;gap:1px;} -.lg-obtn .sub{font-size:12px;font-weight:550;color:rgba(237,244,242,.58);} -.lg-obtn.primary .sub{color:rgba(251,250,248,.74);} -.lg-config{ - display:flex; - align-items:flex-start; - gap:10px; - margin-top:var(--sp-3); - padding:10px 12px; - border-radius:var(--radius); - background:rgba(154,94,20,.28); - border:1px solid rgba(236,180,91,.16); - color:#f2b75f; - font-size:12.5px; - line-height:1.5; -} -.lg-dev{ - margin-top:var(--sp-6); - padding-top:var(--sp-5); - border-top:1px solid rgba(255,255,255,.1); -} -.lg-devhead{ - display:flex; - align-items:baseline; - justify-content:space-between; - gap:var(--sp-3); - color:#edf4f2; - font-size:13px; - font-weight:700; -} -.lg-devhead small{color:rgba(237,244,242,.58);font-weight:600;} -.lg-rolepick{display:grid;grid-template-columns:1fr;gap:var(--sp-2);margin-top:var(--sp-3);} -.lg-roleopt{ - min-height:50px; - display:grid; - grid-template-columns:10px minmax(0,1fr) 16px; - align-items:center; - gap:10px; - padding:9px 11px; - border:1px solid rgba(255,255,255,.11); - border-radius:var(--radius); - background:rgba(255,255,255,.045); - color:#edf4f2; - text-align:left; - cursor:pointer; -} -.lg-roleopt.is-sel{border-color:rgba(114,211,197,.58);background:rgba(89,181,166,.15);} -.lg-roleopt .d{width:8px;height:8px;border-radius:50%;} -.lg-roleopt .d.learner{background:var(--accent-bright);} -.lg-roleopt .d.teacher{background:#5478c4;} -.lg-roleopt .d.admin{background:#7d818e;} -.lg-roleopt b{display:block;font-size:13px;} -.lg-roleopt small{display:block;margin-top:1px;color:rgba(237,244,242,.58);font-size:12px;} -.lg-devbtn{ - width:100%; - min-height:46px; - margin-top:var(--sp-3); - border:1px solid rgba(114,211,197,.58); - border-radius:var(--radius); - background:rgba(89,181,166,.15); - color:#8fe7d9; - font-family:var(--font-sans); - font-size:14px; - font-weight:750; - cursor:pointer; -} -.lg-devbtn:disabled{opacity:.62;cursor:wait;} -.lg-error{ - margin:var(--sp-3) 0 0; - color:var(--crit-text); - background:rgba(122,38,38,.28); - border:1px solid rgba(255,145,145,.18); - border-radius:var(--radius); - padding:10px 12px; - font-size:13px; - line-height:1.5; -} -.lg-error small{ - display:block; - margin-top:4px; - color:rgba(237,244,242,.56); - font-family:var(--font-num); - font-size:11.5px; - overflow-wrap:anywhere; -} -.lg-note{ - margin:var(--sp-5) 0 0; - color:rgba(237,244,242,.52); - font-size:12.5px; - line-height:1.6; -} -@media (max-width:880px){ - .lg-root{grid-template-columns:1fr;} - .lg-root::before{display:none;} - .lg-brand{padding:var(--sp-6) var(--sp-5);gap:var(--sp-6);} - .lg-brand::after{display:none;} - .lg-copy h1{font-size:36px;} - .lg-enter{padding:var(--sp-5);} - .lg-panel{max-width:560px;} -} -@media (max-width:480px){ - .lg-brand{padding:var(--sp-5);} - .lg-copy h1{font-size:30px;} - .lg-copy p{font-size:15px;} - .lg-enter{padding:var(--sp-4);} - .lg-panel{padding:var(--sp-5);} -} -`; diff --git a/apps/web/src/pages/Onboarding.tsx b/apps/web/src/pages/Onboarding.tsx index d1c2187..be8af26 100644 --- a/apps/web/src/pages/Onboarding.tsx +++ b/apps/web/src/pages/Onboarding.tsx @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom"; import { Button } from "../components/ui"; import { roleHomePath, useAuth } from "../lib/auth"; import { apiUrl, userApi, type LegalDocumentsResponse, type UserProfileResponse } from "../lib/api"; +import "./onboarding.css"; interface OnboardingForm { legal_name: string; @@ -146,7 +147,6 @@ export default function Onboarding() { return ( <> -
@@ -337,259 +337,3 @@ export default function Onboarding() { ); } - -const ONBOARDING_CSS = ` -.ob-page{ - min-height:100dvh; - width:100%; - background:var(--bg-app); - color:var(--text-body); - padding:clamp(20px,5vw,56px); -} -.ob-shell{ - width:100%; - max-width:900px; - margin:0 auto; - display:grid; - gap:var(--sp-6); -} -.ob-head{ - display:grid; - gap:8px; -} -.ob-head p{ - margin:0; - color:var(--accent-deep); - font-size:12px; - font-weight:800; -} -.ob-head h1{ - margin:0; - color:var(--text-strong); - font-size:clamp(28px,4vw,44px); - line-height:1.18; - letter-spacing:0; -} -.ob-form{ - min-width:0; - display:grid; - gap:var(--sp-6); -} -.ob-section{ - min-width:0; - display:grid; - gap:var(--sp-4); - padding-bottom:var(--sp-5); - border-bottom:1px solid var(--border-subtle); -} -.ob-section__head h2{ - margin:0; - color:var(--text-strong); - font-size:18px; - line-height:1.35; - letter-spacing:0; -} -.ob-avatar{ - display:grid; - grid-template-columns:auto minmax(0,1fr); - gap:var(--sp-3); - align-items:center; -} -.ob-avatar__preview{ - width:72px; - height:72px; - border-radius:50%; - display:grid; - place-items:center; - overflow:hidden; - background:var(--accent-tint); - color:var(--accent-deep); - font-size:28px; - font-weight:800; - border:1px solid var(--border-subtle); -} -.ob-avatar__preview img{ - width:100%; - height:100%; - display:block; - object-fit:cover; -} -.ob-avatar__body{ - min-width:0; - display:grid; - gap:7px; -} -.ob-avatar__body span{ - color:var(--text-strong); - font-size:13px; - font-weight:780; -} -.ob-avatar__body p{ - margin:0; - color:var(--text-muted); - font-size:12.5px; - line-height:1.45; -} -.ob-avatar__button{ - position:relative; - width:max-content; - min-height:34px; - display:inline-flex; - align-items:center; - justify-content:center; - padding:0 12px; - border:1px solid var(--border-subtle); - border-radius:var(--radius-sm); - background:var(--bg-surface); - color:var(--text-strong); - font-size:12px; - font-weight:760; - cursor:pointer; -} -.ob-avatar__button input{ - position:absolute; - inline-size:1px; - block-size:1px; - opacity:0; - pointer-events:none; -} -.ob-field--wide{ - grid-column:1 / -1; -} -.ob-fields{ - display:grid; - grid-template-columns:repeat(2,minmax(0,1fr)); - gap:var(--sp-3); -} -.ob-form label{ - min-width:0; - display:grid; - gap:7px; -} -.ob-form label span{ - color:var(--text-muted); - font-size:12px; - font-weight:730; -} -.ob-form input[type="text"], -.ob-form input:not([type]){ - min-width:0; -} -.ob-form input, -.ob-form textarea{ - width:100%; - border:1px solid var(--border-subtle); - border-radius:var(--radius-sm); - background:var(--bg-surface-2); - color:var(--text-strong); - padding:0 12px; - font:500 var(--fs-sm)/1.2 var(--font-sans); -} -.ob-form input{ - min-height:42px; -} -.ob-form textarea{ - min-height:92px; - padding:11px 12px; - resize:vertical; - line-height:1.45; -} -.ob-form input:focus, -.ob-form textarea:focus{ - outline:2px solid color-mix(in srgb,var(--accent) 24%,transparent); - border-color:var(--accent); -} -.ob-checks{ - display:grid; - gap:10px; - padding:14px; - border:1px solid var(--border-subtle); - border-radius:var(--radius); - background:var(--bg-surface-2); -} -.ob-checks label{ - grid-template-columns:auto minmax(0,1fr); - align-items:center; - gap:10px; -} -.ob-checks input{ - width:18px; - height:18px; - min-height:18px; - padding:0; - accent-color:var(--accent); -} -.ob-legal{ - min-width:0; - display:grid; - gap:8px; -} -.ob-legal details{ - min-width:0; - border:1px solid var(--border-subtle); - border-radius:var(--radius-sm); - background:var(--bg-surface-2); -} -.ob-legal summary{ - min-width:0; - display:flex; - align-items:center; - justify-content:space-between; - gap:10px; - min-height:42px; - padding:0 12px; - cursor:pointer; -} -.ob-legal summary span{ - min-width:0; - color:var(--text-strong); - font-size:13px; - font-weight:760; - overflow:hidden; - text-overflow:ellipsis; - white-space:nowrap; -} -.ob-legal summary b{ - flex:none; - color:var(--text-muted); - font-size:11px; - font-weight:700; -} -.ob-note, -.ob-error{ - margin:0; - color:var(--text-muted); - font-size:12.5px; - line-height:1.5; -} -.ob-doc-text{ - max-height:260px; - overflow:auto; - white-space:pre-wrap; - color:var(--text-body); - font-size:12.5px; - line-height:1.55; - padding:0 12px 12px; -} -.ob-error{ - color:var(--crit-text); -} -.ob-actions{ - display:flex; - justify-content:flex-start; -} -@media (max-width:620px){ - .ob-page{ - padding:14px; - } - .ob-fields{ - grid-template-columns:1fr; - } - .ob-avatar{ - grid-template-columns:1fr; - } - .ob-actions .vg-btn{ - width:100%; - } -} -`; diff --git a/apps/web/src/pages/PendingApproval.tsx b/apps/web/src/pages/PendingApproval.tsx index acd038b..e41fc4f 100644 --- a/apps/web/src/pages/PendingApproval.tsx +++ b/apps/web/src/pages/PendingApproval.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { Button, Icon } from "../components/ui"; import { roleHomePath, useAuth } from "../lib/auth"; +import "./pending-approval.css"; export default function PendingApproval() { const navigate = useNavigate(); @@ -33,7 +34,6 @@ export default function PendingApproval() { return ( <> -