- 참조 코드 {sources.length}개 -
--
- {sources.map((s, idx) => (
-
-
-
-
{s.filePath}- - - ({s.startLine ?? '-'}-{s.endLine ?? '-'}) - -
- ))}
-
diff --git a/.github/ISSUE_TEMPLATE/system-change.yml b/.github/ISSUE_TEMPLATE/system-change.yml
new file mode 100644
index 0000000..a2b1dca
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/system-change.yml
@@ -0,0 +1,37 @@
+name: 시스템 변경 제안
+description: 새 토큰·컴포넌트·패턴이 필요하거나 기존 규칙을 바꾸자고 제안합니다. (docs/process/change-flow.md)
+title: '[System]: '
+labels: ['system-change']
+body:
+ - type: dropdown
+ id: kind
+ attributes:
+ label: 무엇을 바꾸나요
+ options:
+ - 토큰 (색·글자·모서리·모션)
+ - 컴포넌트 (새 ui 컴포넌트 · variant/size 추가)
+ - 패턴 (결정 트리)
+ - 문구 규칙 (톤·용어집)
+ validations:
+ required: true
+ - type: textarea
+ id: need
+ attributes:
+ label: 왜 필요한가요
+ description: 어떤 화면에서 무엇을 하려는데 막혔는지. 스크린샷이 있으면 좋아요.
+ validations:
+ required: true
+ - type: textarea
+ id: existing
+ attributes:
+ label: 기존 것으로는 왜 안 되나요
+ description: docs/registry.md · docs/patterns · docs/foundations.md 에서 검토한 것
+ validations:
+ required: true
+ - type: textarea
+ id: proposal
+ attributes:
+ label: 제안
+ description: 이름·값·쓸 때/피할 때. 다른 대안이 있었다면 함께.
+ validations:
+ required: true
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..7e60955
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,20 @@
+## 요약
+
+
+
+## 화면이 바뀌는 곳
+
+
+
+## UI 를 바꿨다면 (`docs/ai-review-checklist.md`)
+
+- [ ] `docs/registry.md` 의 기존 컴포넌트를 썼다 (새로 만들었다면 JSDoc + `*.meta.ts` + 스토리)
+- [ ] 상황이 패턴에 해당하면 `docs/patterns/README.md` 의 결정 트리를 따랐다
+- [ ] 문구는 `docs/copy.md` — 해요체, `…`, 용어집
+- [ ] 로딩·오류·빈 상태·성공을 확인했다
+- [ ] 스토리가 있다면 Storybook Accessibility 패널 위반 0
+- [ ] 새 토큰·컴포넌트·패턴이면 문서를 먼저 고쳤다 (change-flow(`docs/process/change-flow.md`))
+
+## 검증
+
+- [ ] CI 통과 (lint · typecheck · test · tokens:check · build)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..28e3aa0
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,59 @@
+name: CI
+
+# develop·main 으로 가는 PR 마다 검수 5종을 돌린다 (AGENTS.md "명령어", docs/process/change-flow.md).
+# 로컬 git hook 은 lint·typecheck·build 만 돌고, 건너뛸 수도 있어서 여기서 다시 막는다.
+on:
+ pull_request:
+ branches:
+ - develop
+ - main
+ workflow_dispatch:
+
+concurrency:
+ group: ci-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ verify:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ env:
+ # 검사용 빌드라 실제 API 주소가 필요 없다. index.html 의 %VITE_API_HOST% 치환만 채운다.
+ VITE_API_BASE_URL: http://localhost:3000
+ VITE_API_HOST: localhost:3000
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - name: Setup Yarn via Corepack
+ run: |
+ corepack enable
+ corepack prepare yarn@3.6.3 --activate
+
+ - name: Install dependencies
+ run: corepack yarn install --immutable
+
+ - name: Lint
+ run: corepack yarn lint
+
+ - name: Typecheck
+ run: corepack yarn typecheck
+
+ - name: Test
+ run: corepack yarn test
+
+ - name: Tokens check
+ run: corepack yarn tokens:check
+
+ # tokens:check 가 다시 만든 docs/registry.md·contrast-matrix.md 가 커밋본과 다르면
+ # meta·토큰을 고치고 생성 파일을 커밋하지 않은 것이다.
+ - name: Generated docs are committed
+ run: git diff --exit-code -- docs/registry.md docs/tokens/contrast-matrix.md
+
+ - name: Build
+ run: corepack yarn build
diff --git a/.gitignore b/.gitignore
index 1bdee94..98cc832 100644
--- a/.gitignore
+++ b/.gitignore
@@ -64,3 +64,4 @@ web-dist.tar.gz
#ai
/.omx
/.omc
+storybook-static
diff --git a/.prettierignore b/.prettierignore
index 9c6b791..0f99381 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -4,3 +4,6 @@ pnpm-lock.yaml
LICENSE.md
tsconfig.json
tsconfig.*.json
+# yarn tokens:check 가 생성 — 스크립트 출력 그대로 둔다
+docs/tokens/contrast-matrix.md
+docs/registry.md
diff --git a/.storybook/main.ts b/.storybook/main.ts
index a7be3e3..1d98529 100644
--- a/.storybook/main.ts
+++ b/.storybook/main.ts
@@ -6,7 +6,7 @@ import svgr from 'vite-plugin-svgr';
const config: StorybookConfig = {
stories: ['../src/renderer/src/components/**/*.stories.@(ts|tsx)'],
- addons: ['@storybook/addon-essentials', '@storybook/addon-interactions'],
+ addons: ['@storybook/addon-essentials', '@storybook/addon-interactions', '@storybook/addon-a11y'],
framework: {
name: '@storybook/react-vite',
options: {}
diff --git a/AGENTS.md b/AGENTS.md
index 42a495f..0417471 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -9,13 +9,15 @@ yarn dev # Electron 개발 모드 (main + preload + renderer HM
yarn web:dev # 렌더러만 브라우저에서 (src/renderer/vite.config.ts)
yarn lint # ESLint
yarn typecheck # tsc: node(main/preload) + web(renderer) 두 프로젝트 모두
+yarn test # vitest
yarn build # typecheck + electron-vite build
yarn web:build # 웹 정적 빌드 → src/renderer/dist
yarn storybook # 컴포넌트 샌드박스 (포트 6006)
+yarn tokens:check # 토큰·대비·패턴 규칙 검사 + docs/registry.md 생성
yarn swagger:local # OpenAPI → src/renderer/src/api/generated 재생성
```
-테스트 러너는 없다. 검증 수단은 `lint` + `typecheck` + Storybook.
+테스트는 `yarn test`(vitest, `*.test.ts(x)`). 검증 수단은 `lint` + `typecheck` + `test` + `build` + `tokens:check` + Storybook.
`yarn swagger:local`은 package.json에서 EC2 주소를 가리킨다. 로컬 백엔드(`../Qode-Server`)
스펙으로 생성하려면 스크립트의 `-p` URL을 `http://localhost:3000/docs/json`으로 바꿔서 실행한다.
@@ -82,6 +84,16 @@ vanilla-extract 플러그인이 세 곳(electron.vite, renderer vite, storybook)
아이콘은 SVGR(`?react`)로 불러 `components/icons/iconRegistry.ts`에 등록하고 `
` 를 렌더하므로 `` 래퍼는 벗겨서 중복을 방지한다. - -### CodeBlock (다크 테마) - -- 컨테이너: `rounded-md overflow-hidden border border-zinc-800 bg-zinc-900`. -- 헤더: `border-b border-zinc-800 bg-zinc-900 px-3 py-1 text-ui-10`. 좌: 언어 라벨 (`text-zinc-400 font-mono`). 우: `복사` 버튼 (`text-zinc-400 hover:text-zinc-100`). -- 본문: `prism-react-renderer` + `themes.oneDark`. `text-ui-14 leading-[1.55] p-3`. 배경은 컨테이너 색을 그대로 사용하도록 `style.background: 'transparent'` 로 덮어쓴다. -- 라인 번호: `w-6 text-right text-zinc-500 mr-3`. -- 폰트 크기는 본문(text-base = 13px) 보다 살짝 작은 text-ui-14 (~11.4px) 로 두어 코드 가독성과 카드 밀도 균형을 잡는다. 이보다 작으면 (text-ui-12) 코드가 판독이 어려워진다. - -### MessageSources (참조 소스 카드) - -- 카드: `mt-3 rounded-[8px] border border-line bg-surface`. -- 헤더: `flex w-full items-center justify-between px-3 py-1.5 text-ui-12 text-text-soft hover:bg-surface-muted`. 좌측 `•` 불릿 마커 + `참조한 소스 N개`. 셰브런은 사용하지 않는다. -- 본문 리스트: 상단 `border-t border-line-soft`. 각 항목 `flex items-center justify-between gap-3 px-3 py-1 text-ui-12 text-text-soft`. -- 항목 좌측: `min-w-0 flex-1 truncate` 파일 경로. -- 항목 우측: `shrink-0` `(start-end)` 형식. 실제 응답값만 표시하고 목데이터를 넣지 않는다. - ### 메시지 액션 (답변 하단) - 3개 고정: `복사`, `팀 공유`, `팀 채팅 생성`. 순서 유지. -- 버튼 스타일: `flex items-center gap-[2px] rounded-[4px] px-1 py-[2px] text-ui-10 font-medium text-text-soft hover:bg-surface-muted hover:text-text-base`. +- 버튼 스타일: `flex items-center gap-[2px] rounded-inline px-1 py-[2px] text-micro font-medium text-fg-muted hover:bg-surface-muted hover:text-fg-default`. - 비활성 상태는 `API_CAPABILITIES` 와 권한으로 결정하고, 비활성 시 `title`(툴팁) 로 이유를 안내한다. ### 채팅 제목 헤더 + 스크롤 페이드 -- `flex justify-center bg-surface px-6 py-3` 헤더. 하단 border 없음. 인라인 편집 input 은 `rounded-md border border-control-line`. +- `flex justify-center bg-surface px-6 py-3` 헤더. 하단 border 없음. 인라인 편집 input 은 `rounded-control border border-line-strong`. - 헤더 바로 아래 messages viewport 상단에 `pointer-events-none absolute inset-x-0 top-0 z-10 h-4 bg-gradient-to-b from-surface to-transparent` 오버레이. 스크롤 시 콘텐츠가 헤더 뒤로 부드럽게 페이드된다. -### InlineAlert / Toast - -- 오류 원인과 복구 행동을 함께 안내한다. 오렌지를 성공·실패 공통 상태색으로 사용하지 않는다. -- 재시도 버튼은 alert 안에 `Button size="sm" variant="secondary"` 로 붙인다. - -### UserAvatar (`AppShell.tsx` 내부) - -- 기본 variant: `border border-line bg-surface-muted font-medium text-text-soft`. -- brand variant: `bg-primary-soft font-semibold text-primary`. 사이드바 하단 프로필과 프로젝트 아바타 등 브랜드 강조 자리에 사용. -- `avatarUrl` 있으면 이미지가 우선, 로드 실패 시 이니셜로 fallback. URL 이 바뀌면 실패 플래그를 초기화한다. - ## SSE 스트리밍 렌더 라이프사이클 새 답변 UI 를 만들 때 참고할 상태 규칙: - 스트리밍 article 렌더는 `postPersonalMessage.isPending || streamError` 만 사용한다. Timestamp gate·useEffect 감지 조합은 지양한다. -- 스트리밍 카드에는 완료 카드와 같은 시각(테두리 없음, `bg-surface`, `rounded-[12px]`) 을 쓰고, 헤더 상태 라벨만 다르게 붙인다. +- 스트리밍 카드에는 완료 카드와 같은 시각(테두리 없음, `bg-surface`, `rounded-panel`) 을 쓰고, 헤더 상태 라벨만 다르게 붙인다. - 완료된 assistant 카드에서 서버 sources 가 비어있으면 `streamSources` 를 마지막 assistant 메시지에만 merge 한다. 중간 assistant 는 자신의 서버 sources 만 사용. - 서버가 body 없이 assistant 자리만 만든 경우 (`content trim === ''` && `sources.length === 0`) 그 카드는 `return null` 로 렌더하지 않는다. 스트리밍 카드가 실제 응답을 계속 보여준다. - AI 본문에 인라인으로 남긴 `(참고: `x.ts` 1-17)` / `- 파일 경로: x.ts` / `filepath (L101-116)` 등 참조 표기는 `extractInlineSources` 파서가 뽑아 소스 카드로 옮기고 본문에서 제거한다. @@ -271,14 +229,14 @@ - 목표: WCAG 2.1 AA. 이 문서는 준수 인증이 아니라 유지 목표다. - 일반 텍스트 대비 4.5:1 이상, 큰 글자·비텍스트 UI 대비 3:1 이상. - 알려진 대비값(sRGB): - - `#ff6900` / 흰색 = 2.89:1 → 작은 텍스트 금지. - - `#ff6900` / `#0e0e0f` = 6.68:1 → 오렌지 버튼 위 진한 글자 OK. - - `#b54700` / 흰색 = 5.43:1 → 밝은 면의 링크 OK. - - `#b54700` / 크림 = 5.05:1 → 브랜드 강조 텍스트 OK. -- 밝은 면의 작은 오렌지 텍스트/링크는 `accent-strong` 만 사용한다. + - `#ff6900` / 흰색 = 2.89:1 → 글자·선 모두 금지. 면(`primary`)으로만 쓴다. + - `#ff6900` / `#0e0e0f` = 6.68:1 → 오렌지 버튼 위 진한 글자(`fg-on-primary`) OK. + - `#e05e00` / 흰색 = 3.64:1 → 선택 테두리·입력 focus(`line-primary`) OK. + - `#b54700` / 흰색 = 5.43:1, 크림 = 5.05:1 → 오렌지 글자·링크(`fg-primary`) OK. +- 전체 조합표는 [`docs/tokens/contrast-matrix.md`](./docs/tokens/contrast-matrix.md). 새 fg×bg 조합을 쓰면 `scripts/tokens-check.ts` 의 REQUIRED 에 추가하고 `yarn tokens:check` 로 검증한다. - **키보드 포커스 통일 규칙** — 전역 `outline` 규칙은 없음. 요소별 focus 스타일만 사용한다. - - **입력창 (input, textarea)**: 보더 색만 변경. Default `border-control-line` → Focus `focus:border-primary`. 에러는 `border-danger` (focus 무시하고 error 우선). `outline-none` 는 브라우저 기본 outline 을 명시적으로 억제한다. Ring 은 얹지 않는다. - - **버튼, IconButton, MenuItem, 링크 등 인터랙션 요소**: `focus-visible:ring-2 focus-visible:ring-text-base focus-visible:ring-offset-2` 링 패턴. 키보드 focus 만 링이 보이고 마우스 클릭엔 안 보이도록 `focus-visible` 을 명시적으로 쓴다. + - **입력창 (input, textarea)**: 보더 색만 변경. Default `border-line-strong` → Focus `focus:border-line-primary`. 에러는 `border-danger` (focus 무시하고 error 우선). `outline-none` 는 브라우저 기본 outline 을 명시적으로 억제한다. Ring 은 얹지 않는다. + - **버튼, IconButton, MenuItem, 링크 등 인터랙션 요소**: `focus-visible:ring-2 focus-visible:ring-fg-default focus-visible:ring-offset-2` 링 패턴. 키보드 focus 만 링이 보이고 마우스 클릭엔 안 보이도록 `focus-visible` 을 명시적으로 쓴다. - **ChatComposer textarea (예외)**: 컨테이너 `focus-within:bg-primary-soft` 배경 전환으로 시각 포커스를 대체. textarea 자체 outline 은 style 인라인으로 완전히 제거. 이 예외를 다른 곳에 확장하지 않는다. - 위 세 규칙 밖의 새 컴포넌트는 기본적으로 입력창 패턴 또는 링 패턴 둘 중 하나를 골라 쓴다. 두 방식을 한 요소에 섞지 않는다. - 색상 외에 텍스트/형태로도 선택·오류·비활성을 구분한다. @@ -290,30 +248,32 @@ ## Responsive behavior — 화면 크기 대응 - 우선 대상은 Electron 과 데스크톱 웹. -- 사이드바 240px 고정. 메인 영역은 크림 캔버스 위에 `rounded-[16px]` 흰 카드로 부유한다. 대화 본문 최대 폭은 `max-w-[48rem]` (768px). +- 사이드바 240px 고정. 메인 영역은 크림 캔버스 위에 `rounded-shell` 흰 카드로 부유한다. 대화 본문 최대 폭은 `max-w-[48rem]` (768px). - 좁은 창의 사이드바 전환 방식은 후속 검증 항목. - 긴 제목은 `truncate`, 파일 경로는 소스 카드 내부에서 truncate. 코드 블록은 자체 가로 스크롤을 갖는다. - hover 로 드러나는 작업은 키보드 focus 로도 접근 가능하게 한다. ## Interaction states — 상태별 표현 -| 상태 | 표현과 행동 | -| ----------------------- | ------------------------------------------------------------------------------------- | -| 초기 로딩 | 해당 영역의 로딩 안내 (`text-ui-12 text-text-soft`), 전체 화면 교체 지양. | -| 프로젝트·대화 없음 | 현재 맥락과 실제 가능한 다음 행동 안내. | -| 답변 요청 직후 | 스트리밍 카드에 `찾아보는 중이에요...` + `LoadingDots`. | -| 답변 스트리밍 중 | 스트리밍 카드에서 토큰이 실시간으로 축적. 상태 라벨 `· 스트리밍 중`. | -| 답변 완료 | `isPending: false` 즉시 스트리밍 카드 hide. 완료 assistant 카드로 자연 전환. | -| 답변 실패 | 스트리밍 카드에 `text-danger` 메시지 + `Button variant="secondary" size="sm"` 재시도. | -| 동기화 대기·진행 | 사이드바 sync 버튼 비활성 + `동기화 진행 중` 인라인 텍스트. 실제 진행률만 사용. | -| 동기화 성공 | 서버 `lastSyncedAt` 을 상대 시간으로 표시 (`마지막 동기화: N분 전`). | -| 동기화 실패 | `지난 동기화 실패` 인라인 + 상단 `InlineAlert` + 재시도. | -| 조회·전송 실패 | 기존 내용을 보존하고 해당 요청의 복구 행동만 안내한다. | -| 권한 부족·미지원 | 실제 권한·`API_CAPABILITIES` 에 따른 숨김 또는 비활성. 비활성 시 이유 툴팁. | -| 느린 네트워크·연결 끊김 | 팀 채팅 상단에 spinner + `연결이 끊어졌습니다 / 재연결 중...` 배너. | +| 상태 | 표현과 행동 | +| ----------------------- | ---------------------------------------------------------------------------------------- | +| 초기 로딩 | 해당 영역의 로딩 안내 (`text-caption text-fg-muted`), 전체 화면 교체 지양. | +| 프로젝트·대화 없음 | 현재 맥락과 실제 가능한 다음 행동 안내. | +| 답변 요청 직후 | 스트리밍 카드에 `찾아보는 중이에요...` + `LoadingDots`. | +| 답변 스트리밍 중 | 스트리밍 카드에서 토큰이 실시간으로 축적. 상태 라벨 `· 스트리밍 중`. | +| 답변 완료 | `isPending: false` 즉시 스트리밍 카드 hide. 완료 assistant 카드로 자연 전환. | +| 답변 실패 | 스트리밍 카드에 `text-fg-danger` 메시지 + `Button variant="secondary" size="sm"` 재시도. | +| 동기화 대기·진행 | 사이드바 sync 버튼 비활성 + `동기화 진행 중` 인라인 텍스트. 실제 진행률만 사용. | +| 동기화 성공 | 서버 `lastSyncedAt` 을 상대 시간으로 표시 (`마지막 동기화: N분 전`). | +| 동기화 실패 | `지난 동기화 실패` 인라인 + 상단 `InlineAlert` + 재시도. | +| 조회·전송 실패 | 기존 내용을 보존하고 해당 요청의 복구 행동만 안내한다. | +| 권한 부족·미지원 | 실제 권한·`API_CAPABILITIES` 에 따른 숨김 또는 비활성. 비활성 시 이유 툴팁. | +| 느린 네트워크·연결 끊김 | 팀 채팅 상단에 spinner + `연결이 끊어졌습니다 / 재연결 중...` 배너. | ## Content voice — 문구 +상황별 톤·용어집·전후 예시는 [`docs/copy.md`](docs/copy.md) 가 기준이다(해요체, 말줄임표 `…`). + - 한국어 기본, 짧고 구체적인 문장. - 버튼은 실행 결과를 말한다: `동기화`, `다시 시도`, `새 채팅`, `이름 바꾸기`, `팀 공유`, `팀 채팅 생성`. - 같은 동작은 흐름 전체에서 같은 이름으로 부른다. @@ -327,36 +287,35 @@ - 렌더러는 Electron 전용 API 에 의존하지 않는다. 해시 라우팅, API·SSE·Socket 동작, 인증 정책을 보존한다. - API 생성 파일 (`src/renderer/src/api/generated/`) 은 직접 수정하지 않는다. - **React Compiler 규칙**: 새 컴포넌트에서 수동 `useMemo` / `useCallback` 을 남발하지 않는다. Compiler 가 자동 메모이제이션을 해주므로 필요한 경우에만 사용. -- 스타일 구현 후에는 `yarn typecheck` + 대상 파일 스코프 `yarn eslint` 를 실행한다. +- 스타일 구현 후에는 `yarn typecheck` + `yarn tokens:check` + 대상 파일 스코프 `yarn eslint` 를 실행한다. ## 새 컴포넌트 추가 절차 -1. 기존 컴포넌트로 표현 가능한지 먼저 확인한다. 가능하면 확장을 우선. +1. [`docs/registry.md`](docs/registry.md) 에서 기존 컴포넌트로 표현 가능한지 먼저 확인한다. 가능하면 확장을 우선. 2. 사용하는 색·크기·간격이 위 토큰과 일치하는지 검토한다. 어긋나면 이 문서에 새 규칙을 추가하고 코드를 맞춘다. 3. 접근성 체크: label, focus 표시, 색상 외 구분 수단, keyboard 조작 가능 여부. 4. 스트리밍/비동기 상태가 있는 UI 는 위 **SSE 스트리밍 렌더 라이프사이클** 규칙을 따른다. 5. 테스트 시 최소 4가지 상태를 봐야 한다: 기본 / hover · focus / disabled · loading / 오류. +6. `ui/` 에 추가했다면 JSDoc(✅ Use / ❌ Don't) + `Name.meta.ts` + 스토리를 함께 만들고 `yarn tokens:check` 로 registry 를 다시 생성한다. ## 시안·구현 검토 체크리스트 +공통 항목(토큰·재사용·상태·접근성·안티패턴)은 [`docs/ai-review-checklist.md`](docs/ai-review-checklist.md). 아래는 Qode 화면 전용 항목이다. + - [ ] 주요 과제(질문·답변)가 시각적으로 가장 분명하고 동일 정보가 중복되지 않는다. - [ ] 사이드바 섹션이 숨겨져 있고, sync 버튼이 w-full + 인라인 상태 텍스트로 표시된다. - [ ] `내 채팅` · `팀 채팅` 헤더는 `+` 버튼만 있고 접기 화살표·`+ 추가` 링크가 없다. - [ ] 사이드바 하단 프로필 행은 아바타·이름·`⋮` 만 노출한다. Border-t 가 좌우 여백을 두고 시작한다. -- [ ] 완료 답변·스트리밍 답변 모두 오렌지 테두리 원 + favicon 아바타 + `Qode AI` 라벨을 쓴다. -- [ ] 참조 소스 카드는 `•` 불릿 + `text-ui-12 text-text-soft` + 좌(경로) / 우(줄 번호) 양쪽 정렬. +- [ ] 완료 답변·스트리밍 답변 모두 오렌지 테두리 원 + Qode 심볼 아바타 + `Qode AI` 라벨을 쓴다. +- [ ] 참조 소스는 `SourceList` 로만 표시한다(같은 파일 묶음, 파일명 강조 + 흐린 폴더 + 오른쪽 줄 범위). - [ ] 답변 하단 액션은 `복사`·`팀 공유`·`팀 채팅 생성` 3개 순서 유지, capability 에 맞춘 비활성 + 툴팁. -- [ ] 인증 화면 버튼·입력창은 `rounded-md` (6px), 알약 없음. +- [ ] 인증 화면 버튼·입력창은 `rounded-control` (6px), 알약 없음. - [ ] ChatComposer 컨테이너 배경은 기본 `bg-line-soft`, focus-within 시 `bg-primary-soft`. 테두리·outline 이중 표시 없음. 전송 버튼은 textarea 옆. -- [ ] 메인 채팅 영역이 크림 캔버스 위에 `rounded-[16px]` 흰 카드로 부유한다. +- [ ] 메인 채팅 영역이 크림 캔버스 위에 `rounded-shell` 흰 카드로 부유한다. - [ ] 스트리밍 article 이 `postPersonalMessage.isPending` false 되는 즉시 사라진다. 완료 카드와 겹치는 순간이 없다. - [ ] AI 답변 카드에 테두리 없음 (완료·스트리밍 모두). -- [ ] 미구현 기능이나 가공한 운영 지표가 추가되지 않았다. -- [ ] 빈 화면·로딩·생성 중·실패·비활성 상태가 정의되어 있다. - [ ] 긴 한국어 제목·긴 답변·긴 파일 경로에서 레이아웃이 유지된다. - [ ] 글자 확대(`data-font-size='large'`) 와 키보드 조작에서 입력창·메뉴·동기화에 접근 가능하다. -- [ ] 기본·hover·focus 상태의 대비와 토큰 참조를 확인했다. -- [ ] 같은 요소의 재구현 없이 기존 컴포넌트를 재사용한다. ## Open questions diff --git a/SYSTEM.md b/SYSTEM.md new file mode 100644 index 0000000..d9aba39 --- /dev/null +++ b/SYSTEM.md @@ -0,0 +1,110 @@ +# Quire + +> **Every decision, remembered.** + +- 상태: **Draft** (2026-09-26) — Phase A(정체성 & 브랜딩) 결정 반영. 팀 리뷰 후 확정한다. +- 갱신일: 2026-09-26. (로고 확정) +- 관계: 이 문서는 Qode 디자인 시스템의 상위 정체성 문서다. 시각·화면 규칙은 [`DESIGN.md`](./DESIGN.md), 컴포넌트 규칙은 [`docs/registry.md`](./docs/registry.md), 실제 토큰 값은 `src/renderer/src/assets/main.css`가 기준이다. + +--- + +## 1. 정의 + +**Quire는 Qode를 만드는 사람과 AI가 같은 결정을 두 번 내리지 않도록, 결정과 그 이유를 기록해 두는 디자인 시스템이다.** + +토큰·컴포넌트·패턴을 모아 두는 데서 그치지 않는다. 각 요소가 왜 그렇게 정해졌는지, 언제 쓰고 언제 피하는지를 함께 남긴다. 독자는 사람과 AI 둘 다다. + +## 2. 코어 밸류 + +우선순위 순서다. 두 밸류가 부딪히면 앞의 것을 따른다. + +### ① Context — 맥락 + +결정에는 이유를, AI에게는 맥락을 남긴다. 관련된 정보는 가까이 둔다. + +- 시스템에서: 토큰은 값이 아니라 의미로, 컴포넌트는 이름이 아니라 "언제 쓰고 언제 피하는지"로 기술한다. 패턴에는 결정 트리와 실제 결정 기록을 붙인다. +- 판단 질문: _6개월 뒤의 팀원이나 AI가 이걸 보고 왜 이렇게 됐는지 알 수 있는가?_ + +### ② Trust — 신뢰 + +실제 상태와 근거만 표현하고 과장하지 않는다. 오류가 나도 다음에 무엇을 해야 하는지 알 수 있어야 한다. + +- 시스템에서: 상태 표시·참조 소스 표기·복구 안내가 필수 요소다. 임의 통계, 장식용 배지, 미구현 기능을 암시하는 컨트롤은 만들지 않는다. +- 판단 질문: _화면에 보이는 것이 지금 실제로 사실인가?_ + +### ③ Density — 밀도 + +개발 도구답게 정보량은 넉넉하게, 그러면서도 차분하고 읽기 편하게. + +- 시스템에서: 작업 화면에 랜딩 페이지식 대형 여백을 쓰지 않는다. 여백보다 리듬으로 정리하고, 강조는 중요도에 비례해 아낀다. +- 판단 질문: _이 화면은 한눈에 더 많이 보여주면서도 시끄럽지 않은가?_ + +> **Calm은 어디로 갔나** — 후보였던 Calm(차분함)은 독립 밸류로 두지 않고 Density 안에 넣었다. Quire에서 차분함은 목표라기보다, 밀도를 높일 때 지켜야 하는 조건이다. Qode 앱의 "차분하고 신뢰감 있는" 브랜드 톤은 그대로 유지된다. + +## 3. 이름 & 유래 + +**Quire** /ˈkwaɪər/ — 종이를 접어 묶은 한 첩(약 24장). 책을 제본할 때 쓰는 기본 단위다. + +- 문서화, 그리고 코드와 문서를 뒤적이며 오가는 일에 가장 가까운 단어라서 골랐다. +- 낱장(토큰·컴포넌트)을 묶어 하나의 기록(시스템)을 만든다는 서사가 Context 밸류와 맞닿는다. +- Q로 시작해 Qode와 첫 글자를 공유한다. +- 비교했던 후보: Quilt(조각보), Qube(블록), Compass(나침반). 최상위 밸류가 Compose(조립)가 아니라 Context(맥락)로 정해지면서 기록의 은유를 택했다. + +## 4. Qode와의 거리 + +> 원칙: _Qode에서 출발하되 Qode에 갇히지 않는다._ + +| 항목 | 결정 | 내용 | +| --------- | --------------------------------- | -------------------------------------------------------------------------------------- | +| 컬러 계승 | **오렌지만 유지** | `#ff6900`만 물려받는다. 서브 컬러는 도입하지 않는다. | +| 톤 거리 | **살짝 벌림** | 앱은 따뜻한 작업 공간, 시스템 문서는 조금 더 전문가향(구조적·기술적). | +| 로고 관계 | **독립 심볼, 오렌지만 공유** | Qode 로고를 변형하지 않은 별도 심볼. 이어짐은 오렌지 `#ff6900` 한 조각으로만. §5 참고. | +| 태그라인 | **"Every decision, remembered."** | Context 서사. Quire(기록의 묶음)라는 이름과 짝을 이룬다. | + +## 5. 시각 정체성 방향 (컨셉만) + +실제 값은 Phase B(파운데이션 재편)에서 확정한다. 여기서는 방향만 정한다. + +- **시그니처 컬러**: `#ff6900` 단일 액센트. 강조는 오렌지 하나로만 한다. +- **문서 바탕**: 웜 그레이 모노톤. 앱의 크림(`#faf6f1`)에서 채도를 뺀 뉴트럴 계열로, 부모 브랜드와 이어지면서도 더 차분하고 기술적으로 보이게 한다. (참고 후보: 웜 그레이 `#7a7368`) +- **타이포**: Pretendard Variable을 계승한다. +- **로고**: 확정 (2026-09-26) — 도브테일(Dovetail) 심볼 + `Quire` 워드마크. + +  + + | 심볼 | 설명 | + | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | + || 육각형을 이루는 검정 두 조각이 도브테일 이음처럼 맞물리고, 그 사이를 오렌지 쐐기 하나가 잇는다. 낱장을 묶어 한 첩을 만드는 Quire 의 서사, 그리고 Qode 에서 물려받은 유일한 색(`#ff6900`)을 한 형태에 담는다. | + - 구성: 검정(`#171717` 근처, 원본 측정) 두 조각 + 오렌지 `#ff6900` 하나. 다른 색을 더하지 않는다. + - 바탕: 크림·밝은 배경 위. 어두운 배경용 반전판은 필요해지면 만든다. + - 파일: `docs/assets/quire/quire-lockup.png`(1200×600), `quire-symbol.png`(512×512) — 문서용 PNG. 벡터(SVG) 원본은 아직 없다. + +## 6. 이후 로드맵 + +| Phase | 이름 | 요약 | 상태 | +| ----- | -------------------- | ---------------------------------------------------------------------------------------------- | -------------- | +| **A** | 정체성 & 브랜딩 | 이 문서 | 완료 | +| B | 파운데이션 재편 | 토큰 3-tier(raw → semantic → component), 색상 역할 분리, 접근성 매트릭스, 타이포·radius 명시화 | 완료 (#53) | +| C | 컴포넌트 정비 | 인벤토리·상태 판정, API 컨벤션, "언제 쓰고 언제 피하나" 명세 | 완료 (#54~#56) | +| D | 패턴 & 의사결정 기록 | 확인·피드백·에러 등 반복 패턴의 결정 트리와 Do/Don't | 완료 (#57) | +| E | AI 컨텍스트 | `DESIGN.md` 스키마 표준화, 컴포넌트 registry, AI 검수 체크리스트 | 완료 (#58) | +| F | 화면 밖 경험 | 모션 토큰, 카피 톤, loading/error/empty/success 표준 | 완료 (#59) | +| G | 운영 | 변경 프로세스, Storybook addon, SoT 우선순위 규칙 | 완료 | + +Phase B~G의 결정도 Context 밸류에 따라 결정 사항과 이유를 함께 남긴다. + +## 7. 관련 문서 + +| 문서 | 담당 | 관계 | +| -------------------------------------------------------------- | --------------------- | --------------------------------------------------------------- | +| `SYSTEM.md` | 정체성·철학·로드맵 | 최상위. 다른 문서와 철학이 어긋나면 여기를 먼저 검토한다. | +| [`DESIGN.md`](./DESIGN.md) | 시각·화면 규칙 | 브랜드·시각 언어·화면 구성·접근성 기준서. | +| [`docs/foundations.md`](./docs/foundations.md) | 토큰 의미 | 토큰 이름과 쓰임. | +| [`docs/registry.md`](./docs/registry.md) | 컴포넌트 규칙 | `ui/*.meta.ts` 에서 생성. 쓸 때·피할 때·옵션·토큰. | +| [`docs/process/`](./docs/process/change-flow.md) | 운영 | 변경 흐름·문서 우선순위·상태 헤더 규칙. | +| [`docs/copy.md`](./docs/copy.md) | 화면 문구 | 해요체·상황별 톤·용어집. 🤖 일부 자동 검사. | +| [`docs/patterns/`](./docs/patterns/README.md) | 패턴 결정 | 확인·에러·피드백·빈 상태·로딩. | +| [`docs/ai-review-checklist.md`](./docs/ai-review-checklist.md) | 검수 | 머지 전 체크리스트. 🤖 항목은 `yarn tokens:check` 가 자동 검사. | +| `src/renderer/src/assets/main.css` | 토큰 실제 값 | 값의 기준. | +| [`AGENTS.md`](./AGENTS.md) | AI 에이전트 작업 안내 | 코드 구조·명령어. UI 작업 전 읽을 Quire 문서를 안내한다. | +| [`README.md`](./README.md) | 프로젝트 소개 | — | diff --git a/docs/ai-review-checklist.md b/docs/ai-review-checklist.md new file mode 100644 index 0000000..17a4433 --- /dev/null +++ b/docs/ai-review-checklist.md @@ -0,0 +1,76 @@ +# AI 검수 체크리스트 + +- 상태: **In sync** (2026-09-26) + +> UI 를 만들거나 고친 뒤, 사람이든 AI 든 머지 전에 이 순서로 확인한다. +> 🤖 = `yarn tokens:check` 가 자동으로 잡는 항목. 나머지는 눈으로 본다. + +## 0. 시작 전에 읽었나 + +- [ ] [`docs/registry.md`](./registry.md) 에서 쓸 컴포넌트를 찾았다 — "피할 때"에 걸리면 → 로 안내된 것을 쓴다 +- [ ] 상황이 패턴에 해당하면 [`docs/patterns/`](./patterns/README.md) 의 결정 트리를 따랐다 +- [ ] 새 색·크기·모션이 필요하면 [`docs/foundations.md`](./foundations.md) 의 토큰부터 찾았다 +- [ ] 문구는 [`docs/copy.md`](./copy.md) 의 톤·용어집을 따랐다 + +## 1. 토큰 + +- [ ] 🤖 Tailwind 기본 팔레트(`bg-blue-500`)·`white`/`black`·임의 색(`bg-[#..]`)이 없다 +- [ ] 🤖 `'#ff6900'`·`rgb(...)` 같은 하드코딩 색이 없다 +- [ ] 🤖 글자 크기는 `text-micro`~`text-display`, 모서리는 `rounded-inline/control/card/panel/shell`, 그림자는 `shadow-overlay` 만 +- [ ] 글자색은 쓰임에 맞는 `fg-*` — 본문 `fg-default`, 보조 `fg-subtle`, 흐린 안내 `fg-muted` +- [ ] 새 fg × bg 조합이면 `scripts/tokens-check.ts` 의 REQUIRED 에 추가해 대비를 검증했다 + +## 2. 컴포넌트 재사용 + +- [ ] 같은 역할의 컴포넌트가 registry 에 있으면 그것을 썼다(버튼·입력·모달·아바타·아이콘 버튼을 손으로 만들지 않았다) +- [ ] 비슷하지만 조금 다르면 새로 만들기 전에 기존 것에 `variant`/`size`/`tone` 을 더하는 쪽을 검토했다 +- [ ] 새 ui 컴포넌트를 만들었다면 JSDoc(✅/❌) + `*.meta.ts` + 스토리를 함께 추가했다 (🤖 meta·JSDoc 누락) + +## 3. 상태 — 로딩 / 에러 / 빈 상태 / 성공 + +- [ ] 🤖 버튼 처리 중은 `Button isLoading` — 라벨을 "~ 중..." 으로 바꾸지 않는다 +- [ ] 🤖 스피너는 `Spinner`/`StateMessage` 로만(손 `animate-spin` 금지) +- [ ] 영역 첫 로딩은 `StateMessage kind="loading"` — 무엇을 불러오는지 글자로 +- [ ] 비어 있으면 `StateMessage kind="empty"` — 채울 수 있으면 다음 행동 한 줄 (그 자리에 컨트롤이 실제로 있을 때만) +- [ ] 실패는 시작한 자리에 — 모달은 모달 안 `InlineAlert`, 영역은 `InlineAlert` + 다시 시도, 제자리 동작만 토스트 +- [ ] 성공은 화면에 보이면 조용히, 안 보이거나 보던 화면이 사라지면 토스트 "~했어요" + +## 4. 확인 + +- [ ] 🤖 `window.confirm`·`alert`·`prompt` 가 없다 +- [ ] 되돌릴 수 없는 동작만 `ConfirmDialog` 로 묻는다 — 제목은 질문형, 버튼은 결과 동사("삭제", "나가기"), "확인" 금지 +- [ ] 모달 안에서는 모달을 겹치지 않고 그 줄 안에서 확인한다 + +## 5. 접근성 + +- [ ] 아이콘만 있는 버튼에 `aria-label` (IconButton 은 타입이 강제) +- [ ] 인터랙션 요소에 focus 표시가 보인다 +- [ ] 입력에는 보이는 label — placeholder 로 대신하지 않는다 +- [ ] 로딩·결과 안내는 `role="status"`, 오류만 `role="alert"` +- [ ] 키보드만으로 열고·이동하고·닫을 수 있다(메뉴·모달) +- [ ] 스토리가 있다면 Storybook **Accessibility** 패널 위반 0 (스크롤 영역은 `tabIndex={0}`, 잠긴 영역은 `inert`) + +## 6. 레이아웃·밀도 + +- [ ] 작업 화면에 랜딩 페이지식 큰 여백·큰 제목을 쓰지 않았다 +- [ ] 긴 한국어 제목·긴 파일 경로·좁은 폭(모바일)에서 깨지지 않는다 — 넘칠 곳에 `min-w-0`·`truncate`·`break-keep` +- [ ] 글자 확대(`data-font-size="large"`)에서도 겹치지 않는다 + +## 7. 안티패턴 + +- [ ] 알약형(`rounded-full`) 버튼·입력이 없다 +- [ ] 장식용 그림자·그라디언트가 없다 +- [ ] 미구현 기능을 동작하는 것처럼 보이게 하거나 가짜 수치를 넣지 않았다 — 서버가 없으면 `API_CAPABILITIES` 로 끄고 안내 문구 +- [ ] 렌더러 코드가 Electron API(`window.electron`)에 의존하지 않는다(웹 배포가 깨진다) + +## 8. 문구 — [`docs/copy.md`](./copy.md) + +- [ ] 🤖 해요체 — `~습니다`·`~입니다` 없음(개발자용 `throw new Error` 제외) +- [ ] 🤖 말줄임표는 `…` 한 글자, 🤖 용어집(팀 채팅·저장소·방장 넘기기) +- [ ] 실패는 무엇을 못 했는지 + 다음 행동, 로딩은 "~를 불러오는 중…", 성공은 "~했어요" +- [ ] 버튼은 결과 동사, 같은 동작은 메뉴·모달 제목·버튼에서 같은 이름 + +## 9. 확인 방법 + +- [ ] `yarn lint` · `yarn typecheck` · `yarn test` · `yarn build` · `yarn tokens:check` +- [ ] 바뀐 화면을 실제로 열어 기본·hover/focus·로딩·오류·빈 상태를 봤다 diff --git a/docs/assets/quire/quire-lockup.png b/docs/assets/quire/quire-lockup.png new file mode 100644 index 0000000..a2e3ec3 Binary files /dev/null and b/docs/assets/quire/quire-lockup.png differ diff --git a/docs/assets/quire/quire-symbol.png b/docs/assets/quire/quire-symbol.png new file mode 100644 index 0000000..a3b8cb5 Binary files /dev/null and b/docs/assets/quire/quire-symbol.png differ diff --git a/docs/components.md b/docs/components.md new file mode 100644 index 0000000..b24bb7d --- /dev/null +++ b/docs/components.md @@ -0,0 +1,136 @@ +# Quire Components — 컴포넌트 인벤토리 & API 컨벤션 + +- 상태: **In sync** (2026-09-26) — Phase C 1차(2026-09-25). 판정은 기록만 하고, 실행한 것은 §3에 표시한다. +- 상위 문서: [`SYSTEM.md`](../SYSTEM.md) · 토큰: [`docs/foundations.md`](./foundations.md) · 시각 규칙: [`DESIGN.md`](../DESIGN.md) · 컴포넌트 registry: [`docs/registry.md`](./registry.md) +- 집계 기준: `src/renderer/src`(generated·stories 제외) import 검색, 2026-09-25. + +--- + +## 0. 결정 요약 + +| # | 항목 | 결정 | 이유 | +| ---- | -------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------- | +| C1 | 이번 실행 범위 | SourceList 추출, `cn()` 도입. 나머지는 판정만 기록 | 사용자가 불편을 직접 겪은 곳(참조 코드 목록)과 모든 컴포넌트에 걸리는 기반부터. | +| C2-1 | Props 이름 | `variant` / `size` / `tone` 세 개로 고정 | 이미 Button·IconButton·InlineAlert·Toast 가 이 형태. 규칙만 명문화한다. | +| C2-2 | className 병합 | `cn()` (clsx + tailwind-merge, Quire 토큰 등록) | 문자열 이어붙이기는 `p-3 p-4` 같은 충돌을 못 거른다. | +| C2-3 | asChild | 보류 | 라우터 링크형 버튼이 거의 없다. 필요해지면 도입. | +| C2-4 | ref | React 19 — `ref` 는 일반 prop. `forwardRef` 쓰지 않는다 | 로드맵의 "forwardRef 강제" 항목은 React 19에서 의미가 없어졌다. | +| C3 | Do/Don't 위치 | 컴포넌트 파일 상단 JSDoc (5줄 이내) | IDE hover 와 AI 가 파일을 열 때 함께 읽힌다. 상세 registry 는 Phase E. | + +--- + +## 1. API 컨벤션 + +### Props + +| prop | 뜻 | 값 | +| ----------- | ------------------------ | ------------------------------------------------------------ | +| `variant` | 모양 | 컴포넌트별 (`primary` / `secondary` / `ghost` / `danger` 등) | +| `size` | 크기 | `sm` / `md` (필요할 때만 `lg`, 이유를 JSDoc 에 적는다) | +| `tone` | 의미 색 | `neutral` / `info` / `danger` / `success` 중 필요한 것 | +| `className` | 바깥에서 덧붙이는 클래스 | 항상 `cn(기본, 조건부..., className)` 순서로 맨 뒤에 합친다 | + +- 새 이름(`intent`, `color`, `kind` 등)을 만들지 않는다. +- 불리언 prop 은 상태만: `disabled`, `loading`, `defaultExpanded`. 모양을 불리언으로 늘리지 않는다(`isBig` ✗ → `size`). + +### className + +```tsx +import { cn } from '../../lib/cn'; + +; +``` + +- `[...].join(' ')`, 템플릿 문자열, `className ?? ''` 는 쓰지 않는다. +- `main.css` 에 크기·radius·shadow 토큰을 추가하면 `lib/cn.ts` 에도 등록한다. 빠뜨리면 `yarn tokens:check` 가 실패한다. + +### 문서화 + +- 파일 상단 JSDoc: 한 줄 정의 + `✅ Use` + `❌ Don't` (5줄 이내). 예: `components/ui/SourceList.tsx`. +- 새 `ui/` 컴포넌트는 스토리 필수. 상태(기본 / 긴 내용 / 비어 있음 / 제한된 공간)를 스토리로 남긴다. +- 옆에 `Name.meta.ts`(`componentMeta.ts` 스키마): 쓸 때·피할 때·관련·패턴. `yarn tokens:check` 가 이를 모아 [`docs/registry.md`](./registry.md) 를 만든다(Phase E). 컴포넌트별 규칙은 DESIGN.md 가 아니라 여기에 둔다. + +--- + +## 2. 인벤토리 (`components/ui/`, 19개) + +2차(2026-09-26)에서 모든 컴포넌트 상단에 사용 규칙 JSDoc 을 달고, 스토리를 전부 채웠다. 판정은 실제 화면(Storybook + 앱 스크린샷)을 보며 컴포넌트별로 정했다. + +| 컴포넌트 | 사용처 | 스토리 | 판정 → 처리 | +| --------------- | ------ | ------ | --------------------------------------------------------------------- | +| Button | 20 | ✅ | Keep — 컨벤션 기준 | +| OverlayModal | 13 | ✅ | Improve → 폭 8가지를 `size` sm·md·lg·xl 로 통일 | +| InlineAlert | 12 | ✅ | Improve → 안내·성공은 `role=status`, 오류만 `alert` | +| Icon | 6 | ✅ | Keep — 숫자 크기 1곳을 `sm` 으로 | +| SourceList | 4 | ✅ | Keep (1차 신규) | +| MarkdownAnswer | 4 | ✅ | Keep — 스토리 추가 | +| ChatItemMenu | 3 | ✅ | Improve → ⋯ 버튼을 IconButton 으로 내장, `triggerSize`. 스토리 추가 | +| Link | 3 | ✅ | Improve → hover 밑줄 변화, 포커스 링 추가 | +| TextField | 2 | ✅ | Improve → `size` sm(40px, 모달)·md(44px, 인증) | +| Toast | 2 | ✅ | Improve → `shadow-overlay`. 스토리 추가 | +| ToastProvider | 1 | — | Keep — 앱 루트 한 번(기반 컴포넌트) | +| IconButton | 1 | ✅ | Improve → `variant` ghost·outline × `size` sm·md 로 재정리 | +| ChatComposer | 1 | ✅ | Improve → 진행 상태 `status` 줄(placeholder 대신) | +| CodeBlock | 1 | ✅ | Keep — 스토리 추가 | +| DrawerHeader | 1 | ✅ | Keep — 안 쓰는 `logo` prop 제거 | +| Logo | 3 | ✅ | Improve → `variant` lockup·mark·wordmark × `size`, 손 조립 3곳 흡수 | +| ProjectSwitcher | 1 | ✅ | Improve → 키보드(menu 패턴: ↑↓·Home·End·Esc) + 테스트 | +| SuggestionCard | 1 | ✅ | Improve → 눌러도 동작 없던 버튼을 예시 목록으로, 빈 화면 주 버튼 정리 | + +### 추출·흡수 (3차 완료) + +| 대상 | 처리 | +| ------------------------ | --------------------------------------------------- | +| AI·사람 아바타 10곳 | `ui/Avatar` 로 추출(kind·size·tone·ring·text) | +| 모달·설정 폼 입력창 8개 | `TextField size="sm"` 으로 흡수 | +| AppShell 아이콘 버튼 6개 | `IconButton` 으로 흡수(⋮·☰ 아이콘 레지스트리 추가) | +| CreateChatModal | 삭제 | + +### Phase D 패턴에서 추가 + +| 컴포넌트 | 패턴 | 처리 | +| ------------- | ------------------------------------------- | ------------------------------------------------------------- | +| ConfirmDialog | [확인](./patterns/confirm.md) | `window.confirm` 4곳·확인 모달 2벌 흡수, 간단·강조 두 단계 | +| Spinner | [빈 상태·로딩](./patterns/empty-loading.md) | 손으로 만든 스피너 6곳 흡수, `size` sm·md·lg × `tone` | +| StateMessage | [빈 상태·로딩](./patterns/empty-loading.md) | 영역 로딩·빈 상태 문장 ~20곳 흡수, 빈 상태 다음 행동 `action` | + +---------- | --------------------------------------------------------------------- | ----------------------------------------------------------------- | +| AI 아바타 | ProjectDetailPage ×2, DigestSourceViewBody (Qode 심볼 원형) | 크기(size-6 / size-7)만 다름 — `Logo variant="mark"` 로 흡수 가능 | +| UserAvatar | AppShell 내부, ProjectDetailPage `Avatar`, DigestSharedCard 이니셜 원 | 이니셜 원 3벌 | + +### Deprecate + +| 대상 | 근거 | 처리 | +| --------------- | ------------------------ | --------------------- | +| CreateChatModal | 사용처 0 (스토리만 존재) | 삭제 후보 — 다음 차수 | + +--- + +## 3. 실행 기록 + +### 1차 (PR #54) + +- [x] `lib/cn.ts` — clsx + tailwind-merge, Quire 토큰 등록, 테스트. `tokens:check` 에 동기화 검사. +- [x] `ui/` 15개 컴포넌트 className 조립을 `cn()` 으로 통일. +- [x] `ui/SourceList` — 같은 파일 묶음, 4곳 교체, 테스트·스토리. 본문 참조 표기를 SourceList 로 옮겨 중복 제거. + +### 2차 — 사용 규칙 (4묶음, 화면 보며 결정) + +- [x] 1묶음(버튼·입력): IconButton 재정리, Link hover, TextField size, ChatComposer status. +- [x] 2묶음(알림·오버레이): OverlayModal size, InlineAlert role, Toast 그림자, ChatItemMenu 트리거. +- [x] 3묶음(콘텐츠): 스토리 보충, SuggestionCard 예시화 + 빈 화면 주 버튼, Icon 숫자 크기 정리. +- [x] 4묶음(레이아웃): Logo 조합, ProjectSwitcher 키보드, DrawerHeader 정리. +- [x] 사이드바 로고·AI 아바타 이미지를 `favicon.ico` 대신 번들 PNG 로. +- [x] 18개 전부 상단 JSDoc(Use / Don't · variant · size). + +### 3차 — 흡수 + +- [x] CreateChatModal 삭제. +- [x] 모달·설정 폼 입력창 8개 → `TextField size="sm"`. 이 과정에서 TextField 오류 테두리 대비(1.61 → 5.18:1) 수정. +- [x] 원형 아바타 10곳 → `ui/Avatar`. 사진 실패 시 이니셜 전환. +- [x] AppShell 아이콘 버튼 6개 → `IconButton`. md 아이콘 16 → 20px. + +### 남은 것 (Phase C 밖) + +- 읽기 전용 + 복사 버튼 입력(Git 주소·초대 링크), 검색창, 인라인 이름 편집은 성격이 달라 흡수하지 않음 — 반복되면 Phase D 패턴으로. +- 숨겨진 섹션 영역의 버튼 2개는 화면에 없어 손대지 않음. diff --git a/docs/copy.md b/docs/copy.md new file mode 100644 index 0000000..417dd59 --- /dev/null +++ b/docs/copy.md @@ -0,0 +1,53 @@ +# Quire Copy — 화면 문구 카탈로그 + +- 상태: **In sync** (2026-09-26) — Phase F. 화면 문구와 이 문서가 일치하고, 🤖 항목은 CI 가 검사한다. + +> 화면에 보이는 한국어 문구의 기준. 🤖 표시는 `yarn tokens:check` 가 자동으로 잡는다. +> 상위: [`SYSTEM.md`](../SYSTEM.md) · 상황별 결정: [`docs/patterns/`](./patterns/README.md) · 컴포넌트: [`docs/registry.md`](./registry.md) + +## 1. 기본 + +- **해요체로 통일한다.** 🤖 `~습니다`·`~합니다`·`~입니다`·`~됩니다` 는 쓰지 않는다. + - 예외: 개발자만 보는 메시지(`throw new Error(...)`), AI 가 쓴 답변 본문, 서버가 내려준 원문. +- 짧고 구체적으로. 한 문장에 한 가지. +- 말줄임표는 **`…` 한 글자**. 🤖 마침표 3개(`...`) 금지. 입력창 placeholder 에는 붙이지 않는다. +- `~해주세요` 는 붙여 쓴다(지금 표기 유지). `~해 보세요` 처럼 권유는 띄어 쓴다. +- 느낌표·이모지·과장("성공!", "완료! 🎉")은 쓰지 않는다. + +## 2. 상황별 톤 + +| 상황 | 형태 | 좋음 | 피함 | +| ------------ | -------------------------------------- | ---------------------------------------------------------------------- | ---------------------------------- | +| 성공(토스트) | 담담한 과거형 **"~했어요"** | `복사했어요` · `‘z’ 채팅을 삭제했어요` | `복사되었습니다` · `복사 완료!` | +| 실패 | **무엇을 못 했는지 + 다음 행동** | `채팅 목록을 불러오지 못했어요.` + [다시 시도] | `오류가 발생했습니다.` | +| 시스템 오류 | 쉬운 말로, 원인이 있으면 원인 | `답변이 끝나기 전에 연결이 끊겼어요.` | `스트리밍 연결이 종료되었습니다.` | +| 확인(질문) | 대상을 밝힌 질문 + 결과 동사 버튼 | `‘z’ 채팅방을 삭제할까요?` [삭제] | `정말 삭제하시겠습니까?` [확인] | +| 확인(설명) | 무엇이 사라지고 누구에게 영향이 가나 | `채팅방과 모든 대화가 삭제되고, 참여자 전원이 더 이상 볼 수 없어요.` | `접근할 수 없게 됩니다.` | +| 로딩 | **"~를 불러오는 중…"** — 대상을 넣는다 | `멤버를 불러오는 중…` · `다시 연결하는 중…` | `로딩 중` · `불러오는 중입니다...` | +| 빈 상태 | 무엇이 없는지 (+ 다음 행동) | `아직 채팅이 없어요.` / `+ 를 눌러 코드에 질문해 보세요.` | `데이터가 없습니다.` | +| 입력 검증 | 무엇이 틀렸는지 / 어떻게 고치나 | `이메일 형식이 올바르지 않아요.` · `이미 쓰고 있는 채팅방 이름이에요.` | `유효하지 않은 입력입니다.` | +| 비활성 이유 | 왜 안 되는지 | `아직 준비 중인 기능이에요.` · `팀 채팅은 아직 읽기만 할 수 있어요.` | `권한이 없습니다.` | + +- 실패 문구의 "못 했어요"와 "할 수 없어요"를 구분한다: 시도했는데 실패 → `불러오지 못했어요`, 원래 안 되는 것 → `해제할 수 없어요`. +- 오류 문구를 새로 만들기 전에 `api/errorMessages.ts`(`friendlyErrorMessage`)·`lib/sync-errors.ts`·`lib/response-errors.ts` 에 이미 있는지 본다. + +## 3. 용어집 + +| 쓴다 | 쓰지 않는다 | 뜻 | +| --------------- | ------------------- | -------------------------------------------------------------------------------------------------------- | +| **팀 채팅** | 🤖 팀채팅 | 여러 사람이 함께 쓰는 채팅방 | +| **내 채팅** | — | 나와 AI 만의 채팅 목록(사이드바 제목). 새 채팅 기본 이름 `새 개인 채팅` 은 서버에 저장되는 값이라 그대로 | +| **저장소** | 🤖 레포지토리, 레포 | GitHub 저장소 (GitHub 한국어 UI 표기) | +| **방장 넘기기** | 🤖 양도 | 팀 채팅방 방장을 다른 참여자에게 | +| **멤버** | — | **프로젝트**에 속한 사람 | +| **참여자** | — | **팀 채팅방**에 들어와 있는 사람 — 멤버와 섞어 쓰지 않는다 | +| **동기화** | 싱크 | GitHub 저장소 코드를 가져와 분석하는 것 | +| **참조 코드** | 출처 | AI 답변이 근거로 든 코드 위치 (`SourceList`). `연결된 소스`(프로젝트에 연결한 저장소)와는 다른 말 | +| **공유 카드** | — | 개인 채팅 답변을 팀 채팅에 올린 카드 | + +- 버튼은 실행 결과를 말한다: `동기화` · `다시 시도` · `새 채팅` · `이름 바꾸기` · `팀 공유` · `나가기` · `삭제`. "확인" 금지. +- 같은 동작은 흐름 전체에서 같은 이름으로 부른다(메뉴 항목 = 모달 제목 = 버튼). + +## 4. 결정 기록 + +- 2026-09-26 (Phase F): 화면 문자열 591개 중 습니다체 88 · 해요체 146 이 섞여 있던 것을 해요체로 통일. 말줄임표 `...` 16곳을 `…` 로. 팀채팅→팀 채팅, 레포지토리→저장소, 양도→방장 넘기기. 멤버/참여자는 뜻이 달라 유지. `해주세요` 띄어쓰기는 바꿀 이득이 없어 유지. 서버가 내려주는 오류 원문은 이번 범위 밖. diff --git a/docs/foundations.md b/docs/foundations.md new file mode 100644 index 0000000..fc1d068 --- /dev/null +++ b/docs/foundations.md @@ -0,0 +1,343 @@ +# Quire Foundations — 파운데이션 결정 기록 + +- 상태: **In sync** (2026-09-26) — Phase B 결정과 구현(`refactor/design-tokens`)이 이 문서와 일치한다. 토큰을 바꿀 때는 이 문서를 먼저 갱신하고 `yarn tokens:check`로 검증한다. +- 갱신일: 2026-09-25. +- 상위 문서: [`SYSTEM.md`](../SYSTEM.md). 실제 값의 기준은 구현 후 `src/renderer/src/assets/main.css`. +- 표기: 사용 횟수는 2026-09-25 기준 `src/renderer/src`(generated·stories 제외) grep 결과. + +--- + +## 0. 결정 요약 + +| # | 항목 | 결정 | 이유 | +| ---- | ------------ | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| B1-1 | 토큰 계층 | raw → semantic 2층. component 층은 보류 | 처음부터 다 채우면 죽은 토큰이 생긴다. 반복 조합이 나타나면 그때 승격한다. | +| B1-2 | Raw 규모 | 중간 스케일, 예측 가능한 번호 + 필요한 단계만 | 없는 shade도 이름을 예측할 수 있어야 확장이 자연스럽다. | +| B1-3 | 회색 | 현재 값 유지, `gray` 한 스케일로 이름만 붙임 | 화면 변화 없이 zinc/slate/임의 회색을 하나로 모은다. | +| B2 | 네이밍 | 전경 `fg-*`, 배경은 장소 이름, 선 `line-*` | `text-text-base` 충돌 해소. 교체 범위 최소. | +| B2 | 마이그레이션 | 한 번에 일괄 치환, alias 없음 | 기계적 rename이다. 옛 이름이 남으면 어느 쪽이 맞는지 헷갈린다. | +| B3 | 오렌지 글자 | 글자용은 `fg-primary` = `#b54700`. `#ff6900`은 면·아이콘 전용 | `#ff6900` 글자는 흰/크림 위 2.7~2.9:1로 AA 실패. | +| B3 | 상태 색 | danger + success만 | 실제 쓰는 두 가지. warning/info는 필요할 때 추가. | +| B3-1 | 대비 기준 | WCAG 2.1 AA만 | 표준 확정, 도구 많음. APCA는 확정 후 재검토. | +| B3-2 | 검증 | `yarn tokens:check` 스크립트로 매트릭스 생성 + 의도한 조합이 기준 미달이면 실패 | 토큰이 바뀔 때마다 사람이 표를 갱신하면 어긋난다. | +| B4-1 | 타이포 | 두 스케일(`xs~5xl`, `ui-*`) + 임의 px → 역할 이름 7단계 | 이름이 용도를 말해야 사람·AI가 고른다. `ui-16`=13px처럼 이름이 값을 속이지 않게. | +| B4-1 | 최소 크기 | 10px (base 13 기준) | 현재 8.1px 텍스트 24곳. 밀도는 높이되 읽을 수 있어야 한다(Density). | +| B4-2 | 묶음 | 크기 + 줄간격. 굵기는 문서 규칙 | 같은 크기에 다른 굵기가 필요한 경우(버튼 등)가 많다. | +| B5-1 | Spacing | Tailwind 기본 4px 그리드 유지 | 이미 관례로 잘 동작. spacing은 맥락 의존적이라 시맨틱화 이득이 적다. | +| B5-2 | Radius | 역할 이름 5단계, 값은 그대로 | 이미 정착된 값에 이름만 붙인다. | +| B5-3 | Elevation | `shadow-overlay` 하나만 | 떠 있는 요소(메뉴·드롭다운) 4곳뿐. 그 외 그림자는 금지. | + +--- + +## 1. 구조 + +```css +/* Tier 1 · Raw — 값. @theme 밖에 두어 유틸리티가 생성되지 않는다. */ +:root { + --palette-orange-500: #ff6900; +} + +/* Tier 2 · Semantic — 역할. 컴포넌트는 이 층만 쓴다. */ +@theme { + --color-*: initial; + --color-primary: var(--palette-orange-500); +} +``` + +- Raw는 `:root`의 `--palette-
- `, semantic은 `@theme`의 `--color-<역할>`. Tailwind v4는 `@theme`에 있는 semantic 이름으로만 유틸리티(`bg-primary`, `text-fg-default`)를 만든다. raw는 유틸리티가 없어 컴포넌트에서 쓸 수 없다. +- 아래 표의 `gray-50` 같은 raw 이름은 `--palette-gray-50`을 줄여 쓴 것이다. +- 컴포넌트 코드에 raw 이름(`text-orange-500`, `bg-gray-50`)이 보이면 규칙 위반이다. +- Tailwind 기본 팔레트·폰트 크기·radius·그림자는 `@theme`에서 `--color-*: initial` 등으로 지운다. `bg-zinc-800` 같은 직접 사용은 스타일이 아예 생성되지 않는다. +- 지워진 클래스는 에러 없이 조용히 무시되므로, `tokens:check`가 소스에서 raw 팔레트·기본 크기 클래스를 찾아 실패시킨다(§4). + +--- + +## 2. Raw 팔레트 + +번호는 밝을수록 작다. 비어 있는 번호는 필요할 때 채운다. + +### gray + +| 토큰 | 값 | 흡수한 기존 값 | +| ---------- | --------- | ----------------------------------------------------- | +| `gray-0` | `#ffffff` | surface | +| `gray-25` | `#fbfaf8` | sidebar | +| `gray-50` | `#faf6f1` | app-bg, surface-muted | +| `gray-100` | `#efefef` | message, line-soft, zinc-100(`#f4f4f5`) | +| `gray-150` | `#e4e4e7` | zinc-200, slate-200(`#e2e8f0`) | +| `gray-200` | `#dfddd8` | line | +| `gray-400` | `#9f9fa9` | zinc-400, zinc-500(`#71717b`) | +| `gray-600` | `#61646b` | text-soft, control-line | +| `gray-700` | `#52545a` | text-subtle, slate-600(`#45556c`) | +| `gray-850` | `#27272a` | zinc-800, zinc-700(`#3f3f47`) | +| `gray-900` | `#18181b` | zinc-900(Tailwind 기본) | +| `gray-950` | `#0e0e0f` | text-base, fill-icon(`#222222`), slate-900(`#0f172b`) | + +괄호 안 값은 가장 가까운 단계로 흡수되며 미세하게 바뀐다(§8). + +### orange / red / green + +| 토큰 | 값 | 비고 | +| ------------ | --------- | ------------------------------- | +| `orange-100` | `#fff0e5` | primary-soft | +| `orange-500` | `#ff6900` | 브랜드 시그니처 | +| `orange-600` | `#e05e00` | primary-strong | +| `orange-700` | `#b54700` | accent-strong | +| `red-50` | `#fff3f3` | danger-bg. Tailwind red-50 흡수 | +| `red-200` | `#f1b5b5` | danger-line | +| `red-700` | `#c62828` | danger. Tailwind red-600 흡수 | +| `green-50` | `#ecfdf5` | 신규 (emerald-50) | +| `green-200` | `#a4f4cf` | 신규 (emerald-200·300) | +| `green-700` | `#007a55` | 신규 (emerald-600·700) | + +### 기타 + +| 토큰 | 값 | 비고 | +| ------- | --------- | ----------------- | +| `black` | `#000000` | 스크림 전용 | +| `white` | `#ffffff` | 어두운 면 위 글자 | + +**삭제**: `slate-*`, `zinc-*`, `green-400`(사용 0), `message`(사용 0), `accent`(= primary 중복). + +--- + +## 3. Semantic 색 + +### 배경 (장소 이름) + +| 새 이름 | raw | 기존 이름 | 사용 | 용도 | +| ---------------- | ---------- | ---------------------- | ---- | ----------------------------------------------- | +| `canvas` | gray-50 | `app-bg` | 3 | 앱 바탕 | +| `surface` | gray-0 | `surface` | 137 | 카드·패널·입력 | +| `surface-muted` | gray-50 | `surface-muted` | 58 | 표 헤더, 인라인 코드, 옅은 구획 | +| `sidebar` | gray-25 | `sidebar` | 1 | 사이드바 | +| `primary` | orange-500 | `primary`, `accent` | — | 주 액션 면 | +| `primary-strong` | orange-600 | `primary-strong` | 3 | 주 액션 hover | +| `primary-soft` | orange-100 | `primary-soft` | 21 | 선택·강조 면 | +| `danger` | red-700 | `danger` | 11 | 위험 액션 면, 강한 오류 경계 | +| `danger-soft` | red-50 | `danger-bg`, `red-50` | 8+3 | 오류 면, 위험 메뉴 hover | +| `success-soft` | green-50 | `emerald-50` | 3 | 성공 면 | +| `code` | gray-900 | `zinc-900` | 2 | 코드 블록 | +| `code-raised` | gray-850 | `zinc-800` | 2 | 코드 블록 hover, 툴팁 | +| `inverse` | gray-850 | `zinc-800`(AppShell) | 1 | 어두운 버튼 | +| `scrim` | black | `black/40`, `black/25` | 2 | 모달 뒤 덮개. `bg-scrim/40`처럼 불투명도로 조절 | + +### 전경 `fg-*` (텍스트·아이콘) + +| 새 이름 | raw | 기존 이름 | 용도 | +| --------------- | ---------- | ----------------------------------------- | ----------------------------- | +| `fg-default` | gray-950 | `text-base`, `fill-icon` | 본문, 기본 아이콘 | +| `fg-subtle` | gray-700 | `text-subtle` | 보조 본문 | +| `fg-muted` | gray-600 | `text-soft`, `zinc-500`(밝은 면) | 메타·설명·플레이스홀더 | +| `fg-primary` | orange-700 | `text-primary`, `accent`, `accent-strong` | 오렌지 글자·링크 | +| `fg-on-primary` | gray-950 | `primary-foreground` | 오렌지 면 위 글자 | +| `fg-danger` | red-700 | `danger`, `red-600` | 오류 글자 | +| `fg-success` | green-700 | `emerald-600·700` | 성공 글자 | +| `fg-code` | gray-100 | `zinc-100` | 코드 블록 위 강조 글자 | +| `fg-code-muted` | gray-400 | `zinc-400·500` | 코드 블록 위 보조 글자·줄번호 | +| `fg-on-dark` | white | `white` | 어두운 면 위 글자 | + +**`#ff6900` 글자 금지**: `text-primary`(18곳)와 `text-accent`(6곳)는 모두 `text-fg-primary`(`#b54700`)로 바뀐다. `#ff6900`은 면·아이콘·포커스 링에만 쓴다. + +### 선 `line-*` + +| 새 이름 | raw | 기존 이름 | 용도 | +| -------------- | ---------- | -------------------------------- | --------------------------- | +| `line` | gray-200 | `line` | 기본 구분선·카드 경계(장식) | +| `line-soft` | gray-100 | `line-soft` | 더 옅은 구분 | +| `line-strong` | gray-600 | `control-line` | 입력·컨트롤 경계(3:1 필요) | +| `line-primary` | orange-600 | `border-primary`, `ring-primary` | 선택 테두리·포커스 링 | +| `line-danger` | red-200 | `danger-line` | 오류 경계 | +| `line-success` | green-200 | `emerald-200·300` | 성공 경계 | +| `line-code` | gray-850 | `zinc-800` | 코드 블록 경계 | + +### 한 토큰이 여러 면에 쓰이던 경우 + +`danger`·`primary`는 면·글자·선에 두루 쓰였다. 유틸리티 접두어로 기계적으로 나눈다. + +| 기존 | 사용 | 새 이름 | 비고 | +| -------------------------------- | ---- | ------------------------------------------ | ---------------------------------------------------- | +| `text-danger` | 33 | `text-fg-danger` | | +| `bg-danger` | 11 | `bg-danger` | 면용 `danger`(red-700) 유지. 위험 버튼 등 | +| `border-danger`, `ring-danger` | 20 | `border-danger` | 강한 오류 경계는 `danger`, 옅은 경계는 `line-danger` | +| `text-primary`, `text-accent` | 22 | `text-fg-primary` | 색이 진해짐 | +| `bg-primary` | 28 | `bg-primary` | 유지 | +| `border-primary`, `ring-primary` | 25 | `border-line-primary`, `ring-line-primary` | `#ff6900`(2.89:1) → `#e05e00`(3.64:1) | + +--- + +## 4. 접근성 + +**기준**: WCAG 2.1 AA — 본문 4.5:1, 큰 텍스트(18.66px bold / 24px+)·비텍스트 UI 3:1. + +### 의도한 조합 (tokens:check 허용 목록 초안) + +| 전경 | 배경 | 대비 | 기준 | 판정 | +| ----------------------------------- | ----------------------------------------- | --------- | ---- | ----------------- | +| fg-default | canvas / surface / sidebar / primary-soft | 17.3~19.3 | 4.5 | ✅ | +| fg-subtle | canvas / surface / primary-soft | 6.8~7.6 | 4.5 | ✅ | +| fg-muted | canvas / surface / primary-soft | 5.3~5.9 | 4.5 | ✅ | +| fg-primary | canvas / surface / primary-soft | 4.9~5.4 | 4.5 | ✅ | +| fg-on-primary | primary | 6.68 | 4.5 | ✅ | +| fg-danger | surface / danger-soft | 5.2~5.6 | 4.5 | ✅ | +| fg-success | surface / canvas / success-soft | 5.0~5.4 | 4.5 | ✅ | +| fg-code | code-raised | 13.6 | 4.5 | ✅ | +| fg-code-muted | code / code-raised | 5.7~6.8 | 4.5 | ✅ | +| line-strong | surface | 5.93 | 3 | ✅ | +| line-primary(선택 테두리·포커스 링) | surface / canvas | 3.38~3.64 | 3 | ✅ | +| line | surface | 1.36 | — | 장식용, 판정 제외 | + +### tokens:check 동작 + +1. `main.css`의 `@theme`에서 semantic 색을 읽어 raw 참조를 값으로 풀어낸다. +2. 모든 `fg-*` × 배경 조합의 대비를 계산해 `docs/tokens/contrast-matrix.md`를 생성한다. +3. 위 허용 목록의 조합이 기준 미달이면 종료 코드 1로 실패한다. +4. 허용 목록에 없는 조합은 매트릭스에만 기록하고 실패시키지 않는다. +5. `src/renderer/src`에서 토큰이 아닌 클래스(`*-zinc-*`, `text-xs`, `rounded-md`, `text-[11px]` 등)를 찾으면 실패한다. + +--- + +## 5. 타이포 + +### 스케일 + +작은 단계는 base에서 px을 빼고, 큰 단계는 base에 배율을 곱한다. "크게 보기"(base 15px)에서도 전 단계가 함께 커진다. + +| 토큰 | 식 | 기본(13) | 크게(15) | 줄간격 | 용도 | +| -------------- | ----------- | -------- | -------- | ------ | -------------------------- | +| `text-micro` | base − 3px | 10px | 12px | 1.4 | 배지·카운트·코드 블록 헤더 | +| `text-caption` | base − 2px | 11px | 13px | 1.4 | 메타·타임스탬프·도움말 | +| `text-label` | base − 1px | 12px | 14px | 1.45 | 버튼·메뉴·탭·보조 텍스트 | +| `text-body` | base | 13px | 15px | 1.5 | 본문, 입력 | +| `text-title` | base × 1.25 | 16.25px | 18.75px | 1.4 | 모달·섹션 제목 | +| `text-heading` | base × 1.5 | 19.5px | 22.5px | 1.3 | 페이지 제목 | +| `text-display` | base × 2 | 26px | 30px | 1.2 | 인증·빈 화면 대형 | + +`xs~5xl`, `ui-*`는 삭제한다. + +### 기존 → 새 이름 + +| 기존 | 기존 크기(13 기준) | 사용 | 새 이름 | 변화 | +| ----------------------------------- | ------------------ | ------ | --------------- | ---------------------------------- | +| `ui-10`, `[10px]` | 8.1px / 10px | 24 + 1 | micro | 8.1 → 10 ▲ | +| `ui-11`, `[11px]` | 8.9px / 11px | 7 + 5 | micro / caption | 8.9 → 10 ▲, 11 유지 | +| `xs`, `ui-12` | 9.75px | 99 | caption | 9.75 → 11 ▲ | +| `ui-13` | 10.6px | 1 | caption | ▲ | +| `sm`, `ui-14` | 11.4px | 60 | label | 11.4 → 12 ▲ | +| `ui-15` | 12.2px | 1 | label | ≈ | +| `base`, `ui-16`, `[13px]`, `[14px]` | 13px / 14px | 약 20 | body | 없음 / 14 → 13 ▼ (드로어 제목 1곳) | +| `lg`, `[16px]` | 14.6px / 16px | 1 + 3 | title | ▲ 소폭 | +| `xl`, `ui-20`, `[18px]` | 16.25px / 18px | 5 + 3 | title | 없음 / ▼ 소폭 | +| `2xl`, `ui-22`, `ui-24`\* | 19.5px | 3 | heading | 없음 | +| `ui-32`, `3xl` | 26px | 2 | display | 없음 | +| `ui-44`, `5xl` | 35.75px / 39px | 2 | display | ▼ 26px | + +\* `text-ui-24`(AuthFrame.tsx:74)는 테마에 정의되지 않아 지금 스타일이 적용되지 않는 상태다. 이번에 heading으로 바로잡는다. + +`[16px]`·`[18px]` 일부는 아이콘 크기 조절용일 수 있다. 구현 시 한 곳씩 확인한다. + +### 굵기 규칙 + +| 굵기 | 용도 | +| ------------------- | ---------------------------------------- | +| 400 `font-normal` | 본문, 설명 | +| 500 `font-medium` | 버튼·라벨·선택 상태·답변 첫 문단 | +| 600 `font-semibold` | 제목(title 이상), 표 헤더, 마크다운 강조 | +| 700 `font-bold` | 쓰지 않는다 (현재 3곳 → 600) | + +--- + +## 6. Radius + +값은 그대로 두고 이름만 붙인다. 역할과 맞지 않는 사용처(예: 코드 블록이 6px)는 Phase C 컴포넌트 정비에서 재판정한다. + +| 토큰 | 값 | 기존 | 사용 | 용도 | +| ----------------- | ---- | ------------------------------------------------- | ---- | ----------------------------------------------- | +| `rounded-inline` | 4px | `rounded`, `rounded-[4px]` | 15 | 인라인 코드·배지·메시지 액션 | +| `rounded-control` | 6px | `rounded-md`, `rounded-[6px]` | 65 | 버튼·입력·메뉴 아이템 | +| `rounded-card` | 8px | `rounded-lg`, `rounded-[8px]` | 19 | 카드·팝오버 | +| `rounded-panel` | 12px | `rounded-xl`, `rounded-[10px]`, `rounded-[12px]` | 23 | 모달·말풍선·큰 컨테이너 (10px 흡수) | +| `rounded-shell` | 16px | `rounded-2xl`, `rounded-[16px]`, `rounded-[20px]` | 9 | 메인 채팅 카드·인증 폼·바텀시트 (20px 흡수) | +| `rounded-full` | — | `rounded-full` | 31 | 아바타·원형 인디케이터 (Tailwind 정적 유틸리티) | + +방향 변형도 같이 바뀐다: `rounded-t` → `rounded-t-inline`, `rounded-t-2xl` → `rounded-t-shell`. + +> 처음 집계(2026-09-25 초안)는 임의값 `rounded-[..]` 44곳을 놓쳤다. 구현 중 발견해 `shell`(16px)을 6번째 단계로 추가했다. + +--- + +## 7. Spacing · Elevation + +**Spacing** — Tailwind 기본 4px 그리드 유지. 권장값: 4 · 8 · 12 · 16 · 24 (`1 · 2 · 3 · 4 · 6`). 그 외 값은 이유가 있을 때만. + +**Elevation** — 토큰 하나. + +```css +--shadow-overlay: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); +``` + +- 쓰는 곳: 기판 위에 떠서 다른 콘텐츠를 가리는 요소(드롭다운·컨텍스트 메뉴·팝오버). 현재 `shadow-lg` 4곳(ChatItemMenu, AppShell ×3)을 교체한다. +- 그 외 그림자는 쓰지 않는다. 새 그림자가 필요하면 이 문서에 먼저 등록한다. + +--- + +## 8. 화면이 바뀌는 곳 + +일괄 치환은 대부분 이름만 바뀌지만, 아래는 실제로 보이는 모습이 달라진다. + +| 변화 | 범위 | 원인 | +| ---------------------------------------------------------------------------- | ------------ | -------------------------------- | +| 오렌지 글자가 진해짐 (`#ff6900` → `#b54700`) | 약 24곳 | B3 오렌지 글자 규칙 | +| 작은 글자가 커짐 (8.1→10, 9.75→11, 11.4→12px) | 약 190곳 | B4 최소 10px | +| 위험 메뉴 빨강이 살짝 어두워짐 (Tailwind red-600 → `#c62828`) | 3곳 | danger 통합 | +| 성공 표시 초록 미세 변화 (emerald → green 토큰) | 5곳 | success 신설 | +| 코드 블록 줄번호가 밝아짐 (zinc-500 → gray-400) | 1곳 | 3.67:1 → 6.75:1 | +| 아이콘 색 `#222222` → `#0e0e0f` | 3곳 | fill-icon 흡수 | +| 인증 화면 부제 크기 적용됨 | 1곳 | `ui-24` 미정의 버그 수정 | +| 대형 제목(`ui-44`, `5xl`) 26px로 축소 | 2곳 | display 단계 통합 | +| 오렌지 테두리·포커스 링이 살짝 진해짐 (`#ff6900` → `#e05e00`) | 25곳 | 비텍스트 3:1 | +| 입력창 focus 테두리가 살짝 진해짐 (`#ff6900` → `#e05e00`) | TextField 등 | 비텍스트 3:1 | +| 링크 hover 색 변화 없어짐 (`#e05e00` hover → `fg-primary` 유지, 밑줄은 상시) | Link 1곳 | hover 색 3.64:1로 글자 기준 미달 | +| 10px radius 3곳 → 12px, 20px 1곳 → 16px | 4곳 | radius 역할 흡수 | +| 드로어 제목 14px → 13px | 1곳 | `[14px]` → body | +| 토큰 줄간격이 명시됨 (`leading-*` 없는 곳) | 다수 | 크기+줄간격 묶음 | + +--- + +## 9. 구현 기록 + +브랜치 `refactor/design-tokens`(develop 기준), 성격별 커밋. + +1. 색 — raw/semantic 재편, 색 유틸리티 407곳 치환. +2. 타이포 — 역할 스케일 도입, 244곳 치환. `max-sm:text-[16px]`(ChatComposer, iOS 자동확대 방지)은 예외로 유지. +3. Radius·그림자 — 135곳 치환. +4. `yarn tokens:check` — `scripts/tokens-check.ts`, 매트릭스 `docs/tokens/contrast-matrix.md` 생성. 의도한 조합은 스크립트의 `REQUIRED`, 의도적 예외는 `ALLOW`에 이유와 함께 둔다. +5. 문서 — `DESIGN.md` 토큰 표기 갱신, 이 문서 In sync. + +### 확정된 추가 결정 (2026-09-25) + +- 오렌지 테두리·포커스 링은 `line-primary`(`#e05e00`)로 분리한다. 면은 `#ff6900` 유지. +- Tailwind 기본값을 차단하고 tokens:check에 raw 클래스 검사를 넣는다. +- 대형 제목 2곳은 display(26px)로 통합한다. 7단계 유지. +- Radius에 `shell`(16px)을 추가한다. 10px → panel, 20px → shell로 흡수. +- raw 팔레트는 `--palette-*`로 `@theme` 밖에 둔다. + +### 남은 일 + +- [ ] Storybook·실제 화면에서 §8 변화 육안 확인. +- [ ] `tokens:check`를 pre-commit 또는 CI에 넣을지 결정 (Phase G). + +## 10. 모션 (Phase F, 2026-09-26) + +Qode 는 작업 도구라 움직임을 적게 쓴다. 움직임은 **상태가 바뀌었다는 신호**일 때만 — 장식용 반복 애니메이션은 두지 않는다. + +| 토큰 | 값 | 쓰는 곳 | +| -------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------ | +| `--motion-fast` (기본값) | 150ms · `ease-standard` | hover 색·셰브런 회전 등 작은 전환. `transition-*` 만 쓰면 자동으로 붙는다 | +| `duration-base` | 200ms (`--motion-base`) | 화면에 나타나고 움직이는 것 — 토스트 등장, 모바일 드로어. `ease-enter` 와 함께 | +| `ease-standard` / `ease-enter` / `ease-exit` | cubic-bezier | 기본 / 들어옴 / 나감 | + +- 숫자 `duration-300`·임의 `duration-[..]`·Tailwind 기본 `ease-in-out` 은 쓰지 않는다 (`yarn tokens:check` 가 잡는다). +- 반복 애니메이션은 기다림 표시만: `Spinner`, 진행 바·스트리밍 커서의 `animate-pulse`, `LoadingDots`. +- **동작 줄이기**(`prefers-reduced-motion`): `main.css` 전역 규칙이 CSS 전환·애니메이션을 0.01ms 로 줄인다. CSS 가 닿지 않는 JS 타이머 애니메이션은 `useReducedMotion()` 으로 멈춘다(`LoadingDots` 는 "…" 고정). +- 모달(`OverlayModal`)은 등장 애니메이션 없이 바로 뜬다 — 흐름을 멈추는 화면이라 기다리게 하지 않는다. diff --git a/docs/patterns/README.md b/docs/patterns/README.md new file mode 100644 index 0000000..6df50d6 --- /dev/null +++ b/docs/patterns/README.md @@ -0,0 +1,17 @@ +# Quire Patterns — 반복 UX 패턴과 결정 기록 + +- 상태: **In sync** (2026-09-26) — Phase D. 패턴마다 결정 트리 → 문서 → 어긋난 코드 수정 → 전/후 확인 순서로 진행한다. +- 상위 문서: [`SYSTEM.md`](../../SYSTEM.md) · 컴포넌트: [`docs/components.md`](../components.md) · 토큰: [`docs/foundations.md`](../foundations.md) + +컴포넌트보다 한 층 위의 "이럴 땐 무엇을 쓰나"를 정리한다. 새 화면을 만들 때 같은 결정을 다시 하지 않는 것이 목적이다. + +| 패턴 | 한 줄 요약 | 상태 | +| ---------------------------------- | ---------------------------------------------------------------- | ---- | +| [확인](./confirm.md) | 되돌릴 수 없는 액션만 묻는다. `ConfirmDialog`, 간단·강조 두 단계 | 완료 | +| [에러](./error.md) | 시작한 자리에 띄운다. 모달은 인라인, 제자리 액션만 토스트 | 완료 | +| [피드백](./feedback.md) | 결과가 보이면 조용히, 안 보이거나 보던 화면이 사라지면 토스트 | 완료 | +| [빈 상태·로딩](./empty-loading.md) | 무엇을 기다리는지·무엇이 없는지 말한다. `StateMessage`·`Spinner` | 완료 | + +## 문서 형식 + +각 패턴은 같은 순서로 쓴다: 언제 마주치나 → 결정 트리 → 옵션별 세부 → Qode 에서의 실제 결정 기록. diff --git a/docs/patterns/confirm.md b/docs/patterns/confirm.md new file mode 100644 index 0000000..38fe025 --- /dev/null +++ b/docs/patterns/confirm.md @@ -0,0 +1,67 @@ +# 패턴: 확인 + +> 되돌릴 수 없는 액션 앞에서만 묻는다. 묻는다면 앱의 `ConfirmDialog` 로, 결과를 말하는 동사 버튼으로. + +## 언제 이 패턴을 마주치나 + +- 채팅·팀 채팅·프로젝트를 삭제할 때 +- 채팅방이나 프로젝트에서 나갈 때 +- 멤버를 제거하거나 방장을 넘길 때 +- 이름 바꾸기·설정 변경처럼 되돌릴 수 있는 동작 (→ 묻지 않는다) + +## 결정 트리 + +``` +이 동작을 되돌릴 수 있나? +├─ 예 (이름 바꾸기·설정 변경·선택 해제) ──────────── 확인 없이 바로 실행 +└─ 아니오 + ├─ 나에게만 영향 + 잃는 게 적음 ───────────────── ConfirmDialog (간단) + └─ 다른 사람에게도 영향 or 잃는 게 큼 ─────────── ConfirmDialog emphasis (강조) +``` + +## 옵션별 세부 + +### 간단 — ` ` + +- 쓰는 곳: 개인 채팅 삭제, 섹션 삭제, 팀 채팅 나가기, 공유 취소 +- 모양: 질문형 제목 + 한 줄 설명 + [취소 secondary] [동사 danger] + +### 강조 — ` ` + +- 쓰는 곳: 팀 채팅 삭제, 프로젝트 삭제, 멤버 제거, 프로젝트 나가기 +- 모양: 질문형 제목 + 빨간 경고 "이 작업은 되돌릴 수 없어요" 안에 결과 설명 + [취소] [동사 danger] + +### 모달 안에서 — 줄 안 확인 + +- 이미 열린 모달 안의 목록에서 되돌릴 수 없는 동작(예: 팀 채팅 멤버 내보내기)은 `ConfirmDialog` 를 겹치지 않는다(모달 위 모달 금지). +- 그 줄이 `내보낼까요? [취소] [내보내기]` 로 바뀐다. 포커스는 `취소` 에 먼저 둔다(실수 방지). +- 실패는 모달 위쪽 InlineAlert 로, 성공하면 줄이 원래대로. +- 참고 구현: `components/feature/teamChat/TeamChatMembersModal.tsx` — `confirmKickId` 상태, `autoFocus` 취소, `isLoading` 확인 버튼, `kick.isError` InlineAlert, 닫을 때 `reset()`. + +### 공통 규칙 + +- **제목**: 질문형으로 대상을 밝힌다 — `‘z’ 채팅방을 삭제할까요?` +- **설명**: 무엇이 사라지고 누구에게 영향이 가는지. 코드·서버 동작으로 확인할 수 있는 사실만 쓴다(Trust). +- **버튼**: 결과를 말하는 동사(삭제·나가기·제거). "확인"·"예" 금지. 위험 버튼은 오른쪽 끝 `danger`. +- **처리 중**: 버튼에 로딩, 취소·닫기 막기(중간 상태로 닫히지 않게). +- **실패**: 모달을 닫지 않고 `error` 로 안에 보여준다 — [에러 패턴](./error.md). +- **금지**: `window.confirm` — OS 창이라 앱 디자인·키보드 규칙을 벗어나고 Electron·웹에서 모양이 다르다. +- **이름 입력 확인**(GitHub 식)은 두지 않는다 — 삭제 권한이 OWNER 로 제한돼 있고 강조형 모달로 충분. + +## 접근성 + +- `OverlayModal` 이 포커스 가두기·Esc·포커스 복귀를 맡는다. +- 강조형 경고는 `role="alert"`(InlineAlert danger) 라 열리는 즉시 읽힌다. + +## Do / Don't + +| Do | Don't | +| ------------------------------------------ | ---------------------------------------- | +| `‘z’ 채팅방을 삭제할까요?` + [취소] [삭제] | `정말 삭제하시겠습니까?` + [취소] [확인] | +| 이름 바꾸기는 바로 저장 | 이름 바꾸기에도 "변경할까요?" 확인 | +| `ConfirmDialog` | `window.confirm(...)` | + +## Qode 에서의 실제 결정 기록 + +- 2026-09-26: 확인 방식이 `window.confirm` 4곳과 앱 모달 2곳으로 섞여 있던 것을 `ConfirmDialog` 하나로 통일. 모양은 가벼움에 따라 간단·강조 두 단계로 결정(전부 강조형은 가벼운 삭제까지 무겁게 보이고, 전부 간단형은 프로젝트 삭제의 무게를 못 전함). 프로젝트 삭제의 이름 입력 확인은 두지 않기로 결정. +- 2026-09-26: 팀 채팅 멤버 내보내기가 확인 없이 실행되던 것을 발견. 모달 안이라 ConfirmDialog 대신 줄 안 확인으로 결정. diff --git a/docs/patterns/empty-loading.md b/docs/patterns/empty-loading.md new file mode 100644 index 0000000..b419e8e --- /dev/null +++ b/docs/patterns/empty-loading.md @@ -0,0 +1,69 @@ +# 패턴: 빈 상태·로딩 + +> 기다리게 하면 무엇을 기다리는지, 비어 있으면 무엇이 없는지 말한다. 사용자가 채울 수 있으면 다음 행동을 한 줄 덧붙인다. + +## 언제 이 패턴을 마주치나 + +- 버튼을 눌러 서버 응답을 기다릴 때(저장·추가·다시 시도) +- 목록·모달·화면 영역을 처음 불러올 때 +- 불러왔는데 항목이 하나도 없을 때(채팅·멤버·검색 결과) + +## 결정 트리 + +``` +무엇을 기다리나? +├─ 내가 누른 버튼의 처리 ─────────── Button isLoading — 라벨은 그대로, 스피너만 +├─ 영역(목록·모달·패널)의 첫 로딩 ── StateMessage kind="loading" — 스피너 + "~를 불러오는 중…" +├─ 화면 전체의 첫 로딩 ──────────── Spinner lg brand + 글자 (프로젝트 화면·로그인 직후) +└─ AI 답변 ─────────────────────── LoadingDots / ChatComposer status (별도, 이 패턴 밖) + +불러왔는데 비어 있나? +├─ 사용자가 채울 수 있음 ─────────── StateMessage kind="empty" + action — 다음 행동 한 줄 +└─ 할 수 있는 게 없음 ───────────── StateMessage kind="empty" — 무엇이 없는지만 + +불러오다 실패했나? ──────────────── 에러 패턴(error.md) — InlineAlert + 다시 시도 +``` + +## 옵션별 세부 + +### Button isLoading + +- 라벨을 "추가 중..."으로 바꾸지 않는다. 버튼 폭이 흔들리고, 스피너와 뜻이 겹친다. +- `disabled` 로 막고 끝내지 않는다 — 막힌 이유(처리 중)가 안 보인다. `isLoading` 이 막기 + 스피너 + `aria-busy` 를 함께 한다. +- 적용: 섹션·폴더 추가/저장, 초대 링크 다시 만들기, 프로젝트·프로젝트 목록 다시 시도 + +### StateMessage kind="loading" + +- `role="status"` 로 스크린리더에 알린다. 모양은 `text-caption` · `text-fg-muted` + Spinner sm. +- 가운데 정렬 목록(모달 안 리스트)은 `align="center"`. +- 적용: 섹션 목록, 프로젝트 멤버, 팀 채팅 참여자·방장 넘기기 후보, 멤버 선택 목록(`ProjectMemberPickList loadingMessage`), 저장소 목록, 다이제스트 대상·원본, 초대 정보, 메시지 목록 + +### StateMessage kind="empty" + +- 무엇이 없는지 먼저, 다음 행동은 한 줄 아래 흐리게. +- 다음 행동은 **그 자리에 실제로 있는 컨트롤**을 가리킨다. 버튼이 없으면(권한·기능 꺼짐) 안내도 없다. +- 적용: 내 채팅·팀 채팅 목록(다음 행동 있음), 프로젝트 목록, 멤버 선택·참여자·방장 후보·공유된 대화(무엇이 없는지만) + +### Spinner + +- sm 12 문장 안 · md 14 버튼 · lg 32 화면. `tone="brand"` 는 화면 로딩(회색 원 + 오렌지 호), 나머지는 글자색을 따른다. +- 혼자 두지 않는다. 항상 무엇을 기다리는지 글자와 함께. + +## 문구 + +- 로딩: **"~를 불러오는 중…"** — 대상을 넣는다. "로딩 중" 같은 대상 없는 문구 금지. +- 빈 상태: **"~가 없어요."** + 다음 행동 **"+ 를 눌러 ~해 보세요."** +- 어미·말줄임표·용어는 [`docs/copy.md`](../copy.md) 를 따른다(해요체, `…`). + +## Do / Don't + +| Do | Don't | +| ------------------------------------------------- | --------------------------------------- | +| 스피너 + `추가` | 라벨을 `추가 중...` 으로 바꾸기 | +| `isLoading` 으로 막기 | `disabled` 만 걸고 처리 중 표시 없음 | +| 빈 채팅 목록에 "+ 를 눌러 코드에 질문해 보세요." | + 버튼이 없는데 "+ 를 눌러" 안내 | +| 로딩을 `loadingMessage` 로 따로 | 로딩을 `emptyMessage` 문자열로 흉내내기 | + +## Qode 에서의 실제 결정 기록 + +- 2026-09-26: 영역 로딩이 곳마다 크기·색·정렬이 달랐고(text-label/caption, fg-subtle/muted) 스피너가 없던 것을 `StateMessage` 로 통일. 손으로 만든 스피너 6곳을 `Spinner` 로, 버튼 라벨 바꿔치기 4곳과 raw 다시 시도 버튼 2곳을 `Button isLoading` 으로 정리. 빈 채팅 목록에 다음 행동 한 줄 추가(팀 채팅은 작성 가능할 때만). 문구 어미는 Phase F 로 미룸. diff --git a/docs/patterns/error.md b/docs/patterns/error.md new file mode 100644 index 0000000..64634a9 --- /dev/null +++ b/docs/patterns/error.md @@ -0,0 +1,63 @@ +# 패턴: 에러 + +> 오류는 그 동작이 시작된 자리에 보여준다. 토스트는 보여줄 자리가 없는 제자리·백그라운드 동작에만. + +## 언제 이 패턴을 마주치나 + +- 모달·폼에서 저장·만들기·보내기가 실패할 때 +- 확인 모달에서 삭제·나가기·제거가 실패할 때 +- 목록·대화처럼 화면 영역 전체가 불러와지지 않을 때 +- 복사·인라인 이름 변경·동기화·메시지 전송처럼 제자리에서 끝나는 동작이 실패할 때 + +## 결정 트리 + +``` +오류가 난 동작이 어디서 시작됐나? +├─ 열려 있는 모달·폼 안 ───────────────────── 그 모달 안에 표시, 모달 유지 (토스트 X) +│ 입력값 문제 → 입력칸 아래(TextField error) +│ 요청 실패 → 모달 안 InlineAlert +├─ 확인 모달의 확인 버튼 ─────────────────── ConfirmDialog error, 닫지 않음 → 바로 다시 시도 +├─ 화면 안 영역 전체가 못 뜸 ──────────────── 그 영역에 InlineAlert + 다시 시도 버튼 +└─ 제자리·백그라운드 동작 ─────────────────── 토스트 (danger) +``` + +## 옵션별 세부 + +### 모달 안 — `TextField error` / `InlineAlert tone="danger"` + +- 쓰는 곳: 채팅방 이름 바꾸기, 새 팀 채팅, 방장 넘기기, 팀 공유, 새 프로젝트(GitHub 연결·만들기), 섹션·폴더 모달, 멤버 내보내기 +- 요청 상태는 React Query mutation 의 `error`/`isError` 를 그대로 쓴다. 모달을 닫을 때 `mutation.reset()` 으로 지워 다시 열었을 때 남지 않게 한다. + +### 확인 모달 안 — `ConfirmDialog error` + +- 쓰는 곳: 프로젝트·섹션·개인 채팅 삭제, 멤버 제거·프로젝트 나가기, 팀 채팅 삭제·나가기, 공유 취소 +- 실패해도 모달을 닫지 않는다. 확인 버튼이 곧 "다시 시도". + +### 화면 영역 — `InlineAlert` + 다시 시도 + +- 쓰는 곳: 채팅 목록·참여자 목록·저장소 목록·동기화 상태 조회 실패 + +### 토스트 — `toast.error` + +- 쓰는 곳: 복사 실패, 사이드바·헤더 인라인 이름 변경, 동기화 요청, 메시지 전송, 첫 메시지로 채팅 자동 생성, 참여자 불러오기 +- 사용자 잘못이 아닌 상태 알림(예: "해당 프로젝트가 삭제되었습니다.")은 오류가 아니라 `toast.info`. + +## 공통 규칙 + +- **원인 + 다음 행동**: `friendlyErrorMessage(error, context)` 로 사람 말로. 서버 메시지·상태 코드를 그대로 노출하지 않는다. +- **한 곳에만**: 같은 오류를 모달 안과 토스트에 동시에 띄우지 않는다. +- **남겨 둔다**: 사용자가 읽고 고쳐야 하는 오류는 사라지는 토스트에 두지 않는다. +- **스크린리더**: 오류만 `role="alert"`(InlineAlert danger·Toast danger). + +## Do / Don't + +| Do | Don't | +| ------------------------------------------------------ | ----------------------------------- | +| 이름 바꾸기 실패 → 입력칸 아래 오류, 모달 유지 | 입력칸 아래 오류 + 같은 내용 토스트 | +| 삭제 실패 → 확인 모달 안 오류, [삭제] 로 다시 시도 | 확인 모달을 닫고 토스트 | +| 복사 실패 → 토스트 "텍스트를 직접 선택해 복사해주세요" | 복사 실패를 모달로 띄움 | + +## Qode 에서의 실제 결정 기록 + +- 2026-09-26: 오류 토스트 28곳을 분류해 정리. 모달 안 중복 토스트 3곳 제거, 토스트만 있던 모달 7곳은 모달 안 InlineAlert 로, 확인 모달 실패 5곳 + 공유 취소는 모달 유지 + 안에 표시, "프로젝트 삭제됨"은 info 로. 제자리·백그라운드 11곳은 토스트 유지. 확인 모달 실패도 토스트로 둘지 검토했으나 바로 다시 시도할 수 있게 모달 안 표시로 결정. +- 같은 정리 중 확인 없이 실행되던 **공유 취소**에 확인(간단)을 추가. diff --git a/docs/patterns/feedback.md b/docs/patterns/feedback.md new file mode 100644 index 0000000..1045c9a --- /dev/null +++ b/docs/patterns/feedback.md @@ -0,0 +1,52 @@ +# 패턴: 피드백 + +> 성공한 결과가 화면에 바로 보이면 조용히. 안 보이거나, 보던 화면이 사라지면 토스트로 한 줄. + +## 언제 이 패턴을 마주치나 + +- 복사·공유·초대처럼 결과가 지금 화면 밖에서 일어날 때 +- 이름 바꾸기·삭제·만들기처럼 결과가 목록에 바로 반영될 때 +- 지금 보던 채팅·프로젝트를 지우거나 나가서 화면이 바뀔 때 + +## 결정 트리 + +``` +성공한 결과가 지금 화면에서 바로 보이나? +├─ 예 (목록에서 사라짐·이름이 바뀜·새 항목으로 이동) ─── 알리지 않음 — 화면 변화가 곧 피드백 +├─ 아니오 (클립보드·다른 채팅방·다른 사람에게 감) ────── 성공 토스트 +└─ 보던 화면이 사라짐 (보던 채팅·프로젝트 삭제·나가기) ── 성공 토스트 — 왜 화면이 바뀌었는지 + +모달이 계속 열려 있는 채로 끝나면 ─────────────────── 모달 안 InlineAlert tone="success" +``` + +## 옵션별 세부 + +### 알리지 않음 + +- 이름 바꾸기(사이드바·헤더·모달), 새 팀 채팅(그 채팅으로 이동), 멤버 내보내기(목록에서 사라짐), 공유 취소(카드가 사라짐), 사이드바에서 **다른** 채팅 삭제 + +### 성공 토스트 — `toast.success` + +- 복사, 팀 채팅에 공유, 멤버 초대 +- **보던** 채팅·프로젝트를 삭제·나가기·방장 넘기고 나가기 — `activeChatId`/`selectedProjectId` 와 같을 때만 + +### 모달 안 success + +- 초대 링크 다시 만들기, 새 프로젝트의 초기 동기화 진행 + +## 문구 + +- 담담한 과거형 **"~했어요"**: `복사했어요` · `팀 채팅에 공유했어요` · `‘z’ 채팅을 삭제했어요` +- 대상이 있으면 이름을 넣는다. 느낌표·이모지·"성공!" 금지. + +## Do / Don't + +| Do | Don't | +| ------------------------------------------- | ----------------------------------------- | +| 멤버를 내보내면 목록에서 사라지는 것으로 끝 | 목록에서 사라지는데 "내보냈어요" 토스트도 | +| 보던 채팅을 지우면 "‘z’ 채팅을 삭제했어요" | 화면이 바뀌는데 아무 설명 없음 | +| `복사했어요` | `복사 완료! 🎉` · `복사되었습니다` | + +## Qode 에서의 실제 결정 기록 + +- 2026-09-26: 같은 "삭제"인데 팀 채팅만 토스트, 개인 채팅·프로젝트는 무응답이던 것을 트리로 정리. 보던 것을 지울 때만 알리기로 하고 개인 채팅·프로젝트 삭제에 토스트 추가, 팀 채팅 삭제·나가기는 보던 채팅일 때만. 목록에 바로 보이는 멤버 내보내기·공유 취소 토스트는 제거. 복사 토스트 문구를 "~했어요"로 통일. diff --git a/docs/process/change-flow.md b/docs/process/change-flow.md new file mode 100644 index 0000000..fc81a81 --- /dev/null +++ b/docs/process/change-flow.md @@ -0,0 +1,28 @@ +# 디자인 시스템 변경 흐름 + +- 상태: **In sync** (2026-09-26) +- 상위: [`SYSTEM.md`](../../SYSTEM.md) · 문서 우선순위: [`sot-priority.md`](./sot-priority.md) + +토큰·컴포넌트·패턴·문구를 바꾸는 일은 한 화면이 아니라 앱 전체에 번진다. 아래 순서를 지키면 "왜 이렇게 됐지?"를 나중에 다시 묻지 않아도 된다. + +## 언제 이 흐름을 타나 + +| 바꾸는 것 | 흐름 | 예 | +| ---------------------------------------------------- | --------------- | -------------------------------- | +| 새 토큰(색·크기·모션), 토큰 값 변경 | **제안부터** | 새 상태색, 글자 크기 추가 | +| 새 `ui/` 컴포넌트, 기존 컴포넌트의 variant·size 추가 | **제안부터** | `EmptyState`, `Button size="lg"` | +| 새 패턴 또는 패턴 결정 트리 변경 | **제안부터** | 확인 단계 추가 기준 변경 | +| 기존 규칙대로 화면 만들기·고치기 | PR 체크리스트만 | 새 모달에 `StateMessage` 쓰기 | +| 문구 한두 줄 | PR 체크리스트만 | 오류 문구 다듬기 | + +## 순서 + +1. **제안** — GitHub 이슈 "시스템 변경" 템플릿(라벨 `system-change`). 무엇이 필요한지, 기존 것으로 왜 안 되는지, 대안. +2. **결정** — 이슈에서 한 명 이상 동의. 급하면 PR 리뷰에서 함께 해도 된다. 결정과 이유는 해당 문서의 **결정 기록** 절에 날짜와 함께 남긴다(Phase A~F 방식). +3. **문서 먼저** — 코드보다 문서를 먼저 고친다: 토큰 → `docs/foundations.md`, 컴포넌트 → JSDoc + `Name.meta.ts`, 패턴 → `docs/patterns/*.md`, 문구 → `docs/copy.md`. +4. **코드** — `main.css`·컴포넌트·화면을 맞춘다. 새 `ui/` 컴포넌트는 스토리를 함께. +5. **검증** — PR 에서 CI(검수 5종)가 돈다. 화면이 바뀌면 전/후 스크린샷, 스토리는 Storybook **Accessibility** 패널에서 위반 0. + +## 자동으로 지켜지는 것 + +`yarn tokens:check` 가 CI 에서 매 PR 마다 돈다 — 토큰 밖 클래스·대비·모션, 컴포넌트 meta 누락, 패턴 위반(confirm/alert·손 스피너·라벨 바꿔치기·하드코딩 색), 문구(습니다체·`...`·폐기 용어). 생성 파일(`docs/registry.md`, `docs/tokens/contrast-matrix.md`)이 커밋본과 다르면 CI 가 실패한다 — 로컬에서 `yarn tokens:check` 후 함께 커밋한다. diff --git a/docs/process/sot-priority.md b/docs/process/sot-priority.md new file mode 100644 index 0000000..45efe9e --- /dev/null +++ b/docs/process/sot-priority.md @@ -0,0 +1,42 @@ +# 문서 우선순위 (Source of Truth) + +- 상태: **In sync** (2026-09-26) +- 상위: [`SYSTEM.md`](../../SYSTEM.md) · 변경 흐름: [`change-flow.md`](./change-flow.md) + +## 누가 무엇의 기준인가 + +| 기준 | 담당 | +| ------------------------------------------------------------------- | ------------------------------------------ | +| [`SYSTEM.md`](../../SYSTEM.md) | 정체성·원칙·로드맵. 가장 위 | +| `src/renderer/src/assets/main.css` | 토큰 **값** | +| [`docs/foundations.md`](../foundations.md) | 토큰의 **의미**와 쓰임 | +| 컴포넌트 코드 | 컴포넌트가 실제로 하는 일 | +| `ui/*.meta.ts` + JSDoc → [`docs/registry.md`](../registry.md)(생성) | 컴포넌트를 언제 쓰고 피하나 | +| [`docs/patterns/`](../patterns/README.md) | 상황별 결정(확인·에러·피드백·빈 상태·로딩) | +| [`docs/copy.md`](../copy.md) | 화면 문구 | +| [`DESIGN.md`](../../DESIGN.md) | 브랜드·시각 언어·화면 구성 | + +## 어긋나면 누가 이기나 + +| 상황 | 이기는 쪽 | 할 일 | +| ----------------------------- | --------- | ------------------------------------------------------- | +| 토큰 값 vs 하드코딩 | 토큰 | 하드코딩 제거 (🤖 tokens:check) | +| meta·JSDoc vs 컴포넌트 코드 | 코드 | 의도된 변경이면 meta·JSDoc 갱신, 아니면 코드 수정 | +| DESIGN.md·foundations vs 구현 | 구현 | 의도된 변경이면 문서 갱신 + 결정 기록, 아니면 코드 수정 | +| 패턴·문구 문서 vs 새 화면 | 문서 | 화면을 맞춘다. 예외가 맞다면 문서 결정 기록에 추가 | +| 하위 문서 vs SYSTEM.md 원칙 | SYSTEM.md | SYSTEM.md 를 먼저 검토하고 하위 문서를 정렬 | + +"구현이 이긴다"는 **지금 코드가 사실**이라는 뜻이지, 문서를 무시해도 된다는 뜻이 아니다 — 발견한 사람이 같은 PR 에서 문서를 맞춘다. + +## 문서 상태 헤더 + +사람이 쓰는 기준 문서는 맨 위에 상태를 적는다. + +``` +- 상태: **In sync** (YYYY-MM-DD) — 코드와 맞음. 날짜는 마지막으로 맞춘 날 +- 상태: **Draft** (YYYY-MM-DD) — 결정 중, 코드와 다를 수 있음 +- 상태: **Out of sync** (YYYY-MM-DD) — 어긋난 걸 알고 있음. 이유와 이슈 링크를 함께 +``` + +- 문서를 고치는 PR 에서 날짜를 갱신한다. +- 생성 파일(`docs/registry.md`, `docs/tokens/contrast-matrix.md`)은 헤더 대신 "생성 파일" 안내를 둔다 — CI 가 동기화를 검사한다. diff --git a/docs/registry.md b/docs/registry.md new file mode 100644 index 0000000..03a2099 --- /dev/null +++ b/docs/registry.md @@ -0,0 +1,491 @@ +# Quire Component Registry + +> `yarn tokens:check` 가 `components/ui/*.meta.ts`, 컴포넌트 JSDoc, className 에서 생성한다. 직접 수정하지 않는다. +> 고칠 때는 해당 `*.meta.ts`(쓸 때·피할 때·관련·패턴) 나 컴포넌트 JSDoc(옵션)을 고치고 다시 생성한다. + +UI 를 만들거나 고칠 때: 아래 표에서 맞는 컴포넌트를 먼저 찾는다 → "피할 때"에 걸리면 → 로 안내된 컴포넌트를 쓴다 → 없으면 새로 만들기 전에 가장 가까운 것을 확장한다. +상황별 결정(확인·에러·피드백·빈 상태·로딩)은 [`docs/patterns/`](./patterns/README.md), 토큰 의미는 [`docs/foundations.md`](./foundations.md). + +## 한눈에 + +| 분류 | 컴포넌트 | 한 줄 | 패턴 | +| --- | --- | --- | --- | +| 액션 | [Button](#button) | 제출·저장·실행·삭제처럼 결과가 있는 동작 | [confirm](./patterns/confirm.md) · [error](./patterns/error.md) · [empty-loading](./patterns/empty-loading.md) | +| 액션 | [IconButton](#iconbutton) | 글자 없이 아이콘만 있는 버튼 | — | +| 액션 | [Link](#link) | 앱 안 화면 이동(해시 라우터) | — | +| 입력 | [ChatComposer](#chatcomposer) | 채팅 메시지 입력과 전송 | — | +| 입력 | [TextField](#textfield) | 라벨·도움말·오류가 붙는 한 줄 입력 | [error](./patterns/error.md) | +| 오버레이 | [ChatItemMenu](#chatitemmenu) | ⋯ 버튼으로 여는 항목별 작업 메뉴 | [confirm](./patterns/confirm.md) | +| 오버레이 | [ConfirmDialog](#confirmdialog) | 되돌릴 수 없는 액션 직전의 확인 | [confirm](./patterns/confirm.md) · [error](./patterns/error.md) | +| 오버레이 | [OverlayModal](#overlaymodal) | 흐름을 멈추고 집중이 필요한 작업 | [confirm](./patterns/confirm.md) · [error](./patterns/error.md) · [feedback](./patterns/feedback.md) | +| 상태 알림 | [InlineAlert](#inlinealert) | 화면 안에 머무는 상태 안내 | [error](./patterns/error.md) · [feedback](./patterns/feedback.md) | +| 상태 알림 | [Spinner](#spinner) | 기다리는 중임을 알리는 원형 표시 | [empty-loading](./patterns/empty-loading.md) | +| 상태 알림 | [StateMessage](#statemessage) | 영역이 비었거나 불러오는 중일 때의 안내 | [empty-loading](./patterns/empty-loading.md) | +| 상태 알림 | [Toast](#toast) | 잠깐 떴다 사라지는 결과 알림(오른쪽 위) | [error](./patterns/error.md) · [feedback](./patterns/feedback.md) | +| 상태 알림 | [ToastProvider](#toastprovider) | 토스트 상태와 오른쪽 위 쌓기 영역 | [feedback](./patterns/feedback.md) | +| 표시 | [Avatar](#avatar) | 사람(사진·이니셜) 또는 Qode AI 를 나타내는 원 | — | +| 표시 | [CodeBlock](#codeblock) | 줄번호·복사 버튼이 있는 다크 코드 블록 | — | +| 표시 | [Icon](#icon) | 레지스트리에 등록된 SVG 아이콘 | — | +| 표시 | [Logo](#logo) | Qode 브랜드 표기(심볼·글자·조합) | — | +| 표시 | [MarkdownAnswer](#markdownanswer) | AI 답변·요약 마크다운 렌더 | — | +| 표시 | [SourceList](#sourcelist) | AI 답변이 참조한 코드 위치 목록(같은 파일은 한 줄) | — | +| 표시 | [SuggestionCard](#suggestioncard) | 빈 화면에서 물어볼 수 있는 질문 예시 | [empty-loading](./patterns/empty-loading.md) | +| 내비게이션 | [DrawerHeader](#drawerheader) | 사이드바 머리: 브랜드 + 프로젝트 선택 + 설정 | — | +| 내비게이션 | [ProjectSwitcher](#projectswitcher) | 현재 프로젝트 표시와 전환 | [error](./patterns/error.md) · [empty-loading](./patterns/empty-loading.md) | + +## 액션 + +### Button + +`components/ui/Button.tsx` · stable + +제출·저장·실행·삭제처럼 결과가 있는 동작 + +**쓸 때** +- 폼 제출, 모달 주 액션 — 화면당 primary 하나 +- 처리 중엔 isLoading(라벨은 그대로, 스피너만) +- 되돌릴 수 없는 삭제는 variant="danger" + +**피할 때** +- 페이지 이동 → Link +- 아이콘만 → IconButton +- 처리 중 라벨을 "~ 중..." 으로 바꾸기 → isLoading + +**옵션** +- `variant` primary 화면당 하나(주 액션) · secondary 보조·취소 · ghost 목록 안 가벼운 액션 · danger 되돌릴 수 없는 삭제 +- `size` md 기본(폼·모달 하단) · sm 카드·알림 안 보조 버튼 + +**관련** [IconButton](#iconbutton) · [Link](#link) · [Spinner](#spinner) · **패턴** [confirm](./patterns/confirm.md) · [error](./patterns/error.md) · [empty-loading](./patterns/empty-loading.md) + +**토큰** 색 `danger` `fg-default` `fg-on-dark` `fg-on-primary` `fg-subtle` `line` `line-primary` `primary` `surface` `surface-muted` · 글자 `caption` `label` · 모서리 `control` + +### IconButton + +`components/ui/IconButton.tsx` · stable + +글자 없이 아이콘만 있는 버튼 + +**쓸 때** +- 설정·닫기·더보기처럼 아이콘만으로 뜻이 통하는 동작 +- aria-label 필수(타입으로 강제) + +**피할 때** +- 뜻이 모호함 → Button +- 페이지 이동 + +**옵션** +- `variant` ghost 기본(목록·카드 안) · outline 헤더처럼 단독으로 놓일 때 +- `size` md 36px(아이콘 20) 기본 · sm 24px(아이콘 16) 목록 줄 안의 ⋯·+ + +**관련** [Button](#button) · [Icon](#icon) · [ChatItemMenu](#chatitemmenu) · **패턴** — + +**토큰** 색 `fg-default` `line` `surface` `surface-muted` · 모서리 `control` `inline` + +### Link + +`components/ui/Link.tsx` · stable + +앱 안 화면 이동(해시 라우터) + +**쓸 때** +- 문장 안이나 폼 아래의 이동 링크 + +**피할 때** +- 실행·저장 같은 동작 → Button +- 외부 URL → +- 버튼처럼 꾸미기 + +**관련** [Button](#button) · **패턴** — + +**토큰** 색 `fg-default` `fg-primary` · 글자 `label` · 모서리 `inline` + +## 입력 + +### ChatComposer + +`components/ui/ChatComposer.tsx` · stable + +채팅 메시지 입력과 전송 + +**쓸 때** +- AI 채팅·팀 채팅 하단 입력 +- 진행 상태는 status 로 입력창 위에 +- 전송 불가 이유는 sendDisabledReason +- 전송 버튼은 입력창 옆(아래가 아님) + +**피할 때** +- 폼 입력 → TextField +- placeholder 에 상태 넣기 +- 테두리·outline 추가(focus 는 배경 전환으로만) + +**관련** [TextField](#textfield) · [Spinner](#spinner) · **패턴** — + +**토큰** 색 `fg-danger` `fg-default` `fg-muted` `fg-on-primary` `line` `line-soft` `primary` `primary-soft` `primary-strong` · 글자 `caption` `label` · 모서리 `control` `panel` + +### TextField + +`components/ui/TextField.tsx` · stable + +라벨·도움말·오류가 붙는 한 줄 입력 + +**쓸 때** +- 폼 입력(이메일·비밀번호·이름) +- 오류는 error prop 으로 입력 바로 아래(원인 + 다음 행동) +- 인증 화면 size="md", 모달·설정 size="sm" + +**피할 때** +- 여러 줄 → textarea +- 채팅 입력 → ChatComposer +- label 을 placeholder 로 대신하기 + +**옵션** +- `size` md 인증 화면(44px) · sm 모달·설정 안(40px) + +**관련** [ChatComposer](#chatcomposer) · [InlineAlert](#inlinealert) · **패턴** [error](./patterns/error.md) + +**토큰** 색 `danger` `danger-soft` `fg-danger` `fg-default` `fg-muted` `fg-subtle` `line-primary` `line-strong` `surface` `surface-muted` · 글자 `caption` `label` · 모서리 `card` `control` + +## 오버레이 + +### ChatItemMenu + +`components/ui/ChatItemMenu.tsx` · stable + +⋯ 버튼으로 여는 항목별 작업 메뉴 + +**쓸 때** +- 목록 항목(채팅·카드) 하나에 딸린 작업 2~5개 +- 위험 작업은 맨 아래, 비활성은 이유를 label 에 + +**피할 때** +- 화면 전체 작업 → 헤더의 Button +- 작업이 1개 → IconButton + +**옵션** +- `triggerSize` sm 목록 줄 안(기본) · md 헤더 + +**관련** [IconButton](#iconbutton) · [ConfirmDialog](#confirmdialog) · **패턴** [confirm](./patterns/confirm.md) + +**토큰** 색 `danger-soft` `fg-danger` `fg-default` `fg-muted` `line` `surface` `surface-muted` · 글자 `caption` `label` · 모서리 `card` `control` `shell` · 그림자 `overlay` + +### ConfirmDialog + +`components/ui/ConfirmDialog.tsx` · stable + +되돌릴 수 없는 액션 직전의 확인 + +**쓸 때** +- 삭제·나가기·제거·소유권 이전 직전 +- 나에게만 영향이면 기본, 다른 사람에게 영향·큰 손실이면 emphasis +- 실패하면 닫지 말고 error 로 모달 안에 + +**피할 때** +- window.confirm +- 되돌릴 수 있는 동작(이름 바꾸기 등) +- 버튼 라벨 "확인" — 결과 동사를 쓴다 +- 모달 안에서 또 띄우기 → 그 줄 안에서 확인 + +**관련** [OverlayModal](#overlaymodal) · [Button](#button) · [InlineAlert](#inlinealert) · **패턴** [confirm](./patterns/confirm.md) · [error](./patterns/error.md) + +**토큰** 색 `fg-default` · 글자 `label` + +### OverlayModal + +`components/ui/OverlayModal.tsx` · stable + +흐름을 멈추고 집중이 필요한 작업 + +**쓸 때** +- 입력 몇 개로 끝나는 만들기·이름 바꾸기 +- footer 오른쪽 끝 [취소 secondary][주 액션 primary] +- 폭은 size 로만(sm 440 · md 520 · lg 640 · xl 720) + +**피할 때** +- 결과 알림 → Toast +- 화면 안 오류 → InlineAlert +- 모달 위에 모달 +- 되돌릴 수 없는 확인 → ConfirmDialog + +**옵션** +- `footer` 오른쪽 끝에 [취소 secondary] [주 액션 primary] 순서. +- `size` sm 440 확인·짧은 입력 · md 520 기본 폼 · lg 640 여러 단계 폼 · xl 720 긴 답변·코드 보기 + +**관련** [ConfirmDialog](#confirmdialog) · [InlineAlert](#inlinealert) · [Button](#button) · **패턴** [confirm](./patterns/confirm.md) · [error](./patterns/error.md) · [feedback](./patterns/feedback.md) + +**토큰** 색 `fg-default` `fg-subtle` `line` `line-strong` `scrim` `surface` `surface-muted` · 글자 `caption` `label` `title` · 모서리 `control` `shell` + +## 상태 알림 + +### InlineAlert + +`components/ui/InlineAlert.tsx` · stable + +화면 안에 머무는 상태 안내 + +**쓸 때** +- 모달·폼·영역 안 오류와 복구 방법(원인 + 다음 행동) +- 재시도는 안에 Button size="sm" variant="secondary" +- 모달이 열린 채 끝나는 성공은 tone="success" + +**피할 때** +- 잠깐 알리고 사라져도 되는 결과 → Toast +- 로딩·빈 상태 → StateMessage +- 오렌지(primary)를 성공·실패 상태색으로 쓰기 — tone 을 쓴다 + +**옵션** +- `tone` danger 오류(원인+다음 행동) · info 안내 · success 오래 남겨야 할 완료 + +**관련** [Toast](#toast) · [StateMessage](#statemessage) · [Button](#button) · **패턴** [error](./patterns/error.md) · [feedback](./patterns/feedback.md) + +**토큰** 색 `danger-soft` `fg-danger` `fg-default` `fg-success` `line-danger` `line-soft` `line-success` `success-soft` `surface-muted` · 글자 `label` · 모서리 `panel` + +### Spinner + +`components/ui/Spinner.tsx` · stable + +기다리는 중임을 알리는 원형 표시 + +**쓸 때** +- Button isLoading·StateMessage 안에서(보통 직접 안 씀) +- 화면 전체 첫 로딩은 size="lg" tone="brand" + 글자 + +**피할 때** +- 혼자 두기 — 무엇을 기다리는지 글자와 함께 +- animate-spin 으로 손수 만들기 + +**옵션** +- `size` sm 12 문장 안 · md 14 버튼 · lg 32 화면 로딩 + +**관련** [StateMessage](#statemessage) · [Button](#button) · **패턴** [empty-loading](./patterns/empty-loading.md) + +**토큰** 색 `line` `line-primary` + +### StateMessage + +`components/ui/StateMessage.tsx` · stable + +영역이 비었거나 불러오는 중일 때의 안내 + +**쓸 때** +- 목록·모달·패널 영역의 첫 로딩 — "~를 불러오는 중…" +- 빈 상태 — 무엇이 없는지, 채울 수 있으면 action 으로 다음 행동 + +**피할 때** +- 버튼 처리 중 → Button isLoading +- 실패 → InlineAlert +- AI 답변 대기 → LoadingDots + +**관련** [Spinner](#spinner) · [InlineAlert](#inlinealert) · [SuggestionCard](#suggestioncard) · **패턴** [empty-loading](./patterns/empty-loading.md) + +**토큰** 색 `fg-muted` `fg-subtle` · 글자 `caption` + +### Toast + +`components/ui/Toast.tsx` · stable + +잠깐 떴다 사라지는 결과 알림(오른쪽 위) + +**쓸 때** +- 결과가 화면에 안 보이는 성공(복사·공유) — "~했어요" +- 보던 화면이 사라진 이유(보던 채팅 삭제) +- 제자리·백그라운드 동작의 실패 + +**피할 때** +- 모달 안 오류 → InlineAlert +- 결과가 화면에 바로 보이는 성공 — 알리지 않는다 +- 한 동작에 토스트 여러 개 + +**옵션** +- `tone` success 완료(3초) · info 안내(4초) · danger 실패(원인+다음 행동, 5초) + +**관련** [InlineAlert](#inlinealert) · [ToastProvider](#toastprovider) · **패턴** [error](./patterns/error.md) · [feedback](./patterns/feedback.md) + +**토큰** 색 `danger-soft` `fg-danger` `fg-default` `fg-muted` `fg-subtle` `fg-success` `line` `line-danger` `line-success` `success-soft` `surface` · 글자 `label` `title` · 모서리 `inline` `panel` · 그림자 `overlay` + +### ToastProvider + +`components/ui/ToastProvider.tsx` · stable + +토스트 상태와 오른쪽 위 쌓기 영역 + +**쓸 때** +- 앱 루트에서 한 번만 감싼다. 띄우기는 useToast() + +**피할 때** +- 화면·모달마다 다시 감싸기(스토리·테스트는 예외) + +**관련** [Toast](#toast) · **패턴** [feedback](./patterns/feedback.md) + +## 표시 + +### Avatar + +`components/ui/Avatar.tsx` · stable + +사람(사진·이니셜) 또는 Qode AI 를 나타내는 원 + +**쓸 때** +- 메시지·참여자·프로필 옆 — 이름이 옆에 글자로 있을 때 +- 사진을 못 불러오면 이니셜로 자동 전환 +- 겹쳐 쌓을 땐 ring, 남은 인원은 text="+N" + +**피할 때** +- 원형 숫자·단계 표시 +- 이름 없이 아바타만 단독으로 + +**옵션** +- `size` sm 24 메시지 · md 28 목록·헤더 · lg 32 참여자 목록 · xl 40 프로필 창 +- `tone` brand 나·팀 강조 · neutral 기본 + +**관련** — · **패턴** — + +**토큰** 색 `fg-muted` `fg-primary` `line` `line-primary` `primary-soft` `surface` `surface-muted` · 글자 `body` `caption` + +### CodeBlock + +`components/ui/CodeBlock.tsx` · stable + +줄번호·복사 버튼이 있는 다크 코드 블록 + +**쓸 때** +- 여러 줄 코드 — 보통 MarkdownAnswer 가 코드 펜스를 바꿔 줌 +- 긴 줄은 블록 안 가로 스크롤(부모에 min-w-0) +- 코드 글자는 text-label — 더 작게(text-caption) 하면 읽기 어렵다 + +**피할 때** +- 한 줄 안의 짧은 코드 → 인라인 + +**관련** [MarkdownAnswer](#markdownanswer) · [SourceList](#sourcelist) · **패턴** — + +**토큰** 색 `code` `code-raised` `fg-code` `fg-code-muted` `line-code` `line-primary` · 글자 `label` `micro` · 모서리 `control` `inline` + +### Icon + +`components/ui/Icon.tsx` · stable + +레지스트리에 등록된 SVG 아이콘 + +**쓸 때** +- 글자 옆 보조 아이콘(decorative 기본) +- 아이콘만으로 뜻을 전하면 decorative={false} + aria-label + +**피할 때** +- 클릭 가능한 아이콘 → IconButton +- SVG 직접 import — icons/raw 에 넣고 iconRegistry 에 등록 +- 숫자 크기 + +**옵션** +- `size` sm 16 글자 옆(가장 많이) · md 24 · lg 28 강조 · 숫자는 피한다 + +**관련** [IconButton](#iconbutton) · **패턴** — + +**토큰** 색 `fg-default` + +### Logo + +`components/ui/Logo.tsx` · stable + +Qode 브랜드 표기(심볼·글자·조합) + +**쓸 때** +- 앱 헤더·인증 화면의 브랜드 자리 + +**피할 때** +- 심볼과 글자를 따로로 조립 +- 텍스트로 대신 쓰기 + +**옵션** +- `variant` lockup 기본 · mark 심볼만 · wordmark 글자만 +- `size` sm 사이드바 · md 인증 화면 + +**관련** [Avatar](#avatar) · [DrawerHeader](#drawerheader) · **패턴** — + +### MarkdownAnswer + +`components/ui/MarkdownAnswer.tsx` · stable + +AI 답변·요약 마크다운 렌더 + +**쓸 때** +- AI 가 만든 본문 — 코드 펜스는 CodeBlock, 링크는 새 탭 + +**피할 때** +- 사용자가 쓴 메시지·일반 UI 문구 +- 본문에 참조 표기 남기기 → cleanAnswerSources 후 SourceList + +**관련** [CodeBlock](#codeblock) · [SourceList](#sourcelist) · **패턴** — + +**토큰** 색 `fg-default` · 글자 `body` + +### SourceList + +`components/ui/SourceList.tsx` · stable + +AI 답변이 참조한 코드 위치 목록(같은 파일은 한 줄) + +**쓸 때** +- 답변·요약 카드 아래 참조 소스 표기 — 참조 목록은 이것 하나만(메인 채팅·원본 대화·팀 공유 카드·공유 미리보기) +- 파일 순서는 서버가 준 관련도 순, 줄 범위는 오른쪽 끝에 오름차순 +- 높이가 제한된 모달에서만 maxHeight + +**피할 때** +- 코드 본문 미리보기 → CodeBlock +- 클릭 이동이 필요한 파일 탐색 목록 +- 응답에 없는 소스·줄 번호 채워 넣기 +- 겹치는 줄 범위를 임의로 합치기 — 완전히 같은 범위만 합친다 +- 화면마다 참조 목록을 따로 만들기 + +**관련** [CodeBlock](#codeblock) · [MarkdownAnswer](#markdownanswer) · **패턴** — + +**토큰** 색 `fg-default` `fg-muted` `fg-subtle` `line` `line-primary` `line-soft` `surface` `surface-muted` · 글자 `caption` · 모서리 `card` + +### SuggestionCard + +`components/ui/SuggestionCard.tsx` · stable + +빈 화면에서 물어볼 수 있는 질문 예시 + +**쓸 때** +- 첫 방문·빈 채팅에서 무엇을 할 수 있는지 보여줄 때(
안) + +**피할 때** +- 누를 수 있는 것처럼 꾸미기 — 실제 동작은 옆의 Button + +**관련** [StateMessage](#statemessage) · [Button](#button) · **패턴** [empty-loading](./patterns/empty-loading.md) + +**토큰** 색 `fg-subtle` `surface-muted` · 글자 `label` · 모서리 `panel` + +## 내비게이션 + +### DrawerHeader + +`components/ui/DrawerHeader.tsx` · stable + +사이드바 머리: 브랜드 + 프로젝트 선택 + 설정 + +**쓸 때** +- AppShell 사이드바 맨 위 한 곳 + +**피할 때** +- 다른 화면의 헤더로 재사용 + +**관련** [ProjectSwitcher](#projectswitcher) · [Logo](#logo) · **패턴** — + +### ProjectSwitcher + +`components/ui/ProjectSwitcher.tsx` · stable + +현재 프로젝트 표시와 전환 + +**쓸 때** +- 사이드바 상단 한 곳, 목록 맨 아래 "+ 새 프로젝트" + +**피할 때** +- 프로젝트 선택 UI 새로 만들기 — 이것이나 목록 데이터를 재사용 + +**옵션** +- `키보드` Enter/Space/↓ 열기 · ↑↓ Home End 이동 · Enter 선택 · Esc 닫기 + +**관련** [DrawerHeader](#drawerheader) · **패턴** [error](./patterns/error.md) · [empty-loading](./patterns/empty-loading.md) + +**토큰** 색 `fg-default` `fg-muted` `fg-subtle` `line` `surface` `surface-muted` · 글자 `body` `caption` `label` `title` · 모서리 `control` `panel` diff --git a/docs/tokens/contrast-matrix.md b/docs/tokens/contrast-matrix.md new file mode 100644 index 0000000..500e298 --- /dev/null +++ b/docs/tokens/contrast-matrix.md @@ -0,0 +1,23 @@ +# Contrast matrix + +> `yarn tokens:check` 가 생성한다. 직접 수정하지 않는다. + +- 굵게 = 의도한 조합(`scripts/tokens-check.ts` 의 REQUIRED). ✅ 기준 충족 / ❌ 미달. +- 기준: 텍스트 4.5:1, 비텍스트 UI(`line-*`, `danger` 경계) 3:1 (WCAG 2.1 AA). +- 작은 숫자 = 의도하지 않은 조합 중 4.5:1 미만. 이 조합을 쓰려면 먼저 REQUIRED 에 추가해 검증한다. + +| 전경 \ 배경 | `canvas` | `surface` | `surface-muted` | `sidebar` | `primary` | `primary-strong` | `primary-soft` | `danger` | `danger-soft` | `success-soft` | `code` | `code-raised` | `inverse` | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| `fg-default` `#0e0e0f` | **17.93** ✅ | **19.29** ✅ | **17.93** ✅ | **18.50** ✅ | 6.68 | 5.30 | **17.32** ✅ | 3.43 | **17.79** ✅ | **18.32** ✅ | 1.09 | 1.30 | 1.30 | +| `fg-subtle` `#52545a` | **7.03** ✅ | **7.57** ✅ | **7.03** ✅ | **7.26** ✅ | 2.62 | 2.08 | **6.79** ✅ | 1.35 | 6.98 | 7.19 | 2.34 | 1.97 | 1.97 | +| `fg-muted` `#61646b` | **5.51** ✅ | **5.93** ✅ | **5.51** ✅ | **5.68** ✅ | 2.05 | 1.63 | **5.32** ✅ | 1.05 | 5.47 | 5.63 | 2.99 | 2.51 | 2.51 | +| `fg-primary` `#b54700` | **5.05** ✅ | **5.43** ✅ | **5.05** ✅ | 5.21 | 1.88 | 1.49 | **4.88** ✅ | 1.04 | 5.01 | 5.16 | 3.26 | 2.74 | 2.74 | +| `fg-on-primary` `#0e0e0f` | 17.93 | 19.29 | 17.93 | 18.50 | **6.68** ✅ | 5.30 | 17.32 | 3.43 | 17.79 | 18.32 | 1.09 | 1.30 | 1.30 | +| `fg-danger` `#c62828` | **5.23** ✅ | **5.62** ✅ | 5.23 | 5.39 | 1.95 | 1.54 | 5.05 | 1.00 | **5.18** ✅ | 5.34 | 3.15 | 2.65 | 2.65 | +| `fg-success` `#007a55` | **4.99** ✅ | **5.36** ✅ | 4.99 | 5.14 | 1.86 | 1.47 | 4.81 | 1.05 | 4.95 | **5.09** ✅ | 3.30 | 2.78 | 2.78 | +| `fg-code` `#efefef` | 1.07 | 1.15 | 1.07 | 1.10 | 2.51 | 3.16 | 1.03 | 4.89 | 1.06 | 1.09 | **15.41** ✅ | **12.95** ✅ | 12.95 | +| `fg-code-muted` `#9f9fa9` | 2.44 | 2.62 | 2.44 | 2.51 | 1.10 | 1.39 | 2.35 | 2.14 | 2.42 | 2.49 | **6.75** ✅ | **5.68** ✅ | 5.68 | +| `fg-on-dark` `#ffffff` | 1.08 | 1.00 | 1.08 | 1.04 | 2.89 | 3.64 | 1.11 | **5.62** ✅ | 1.08 | 1.05 | 17.72 | 14.89 | **14.89** ✅ | +| `line-strong` `#61646b` | **5.51** ✅ | **5.93** ✅ | 5.51 | 5.68 | 2.05 | 1.63 | 5.32 | 1.05 | 5.47 | 5.63 | 2.99 | 2.51 | 2.51 | +| `line-primary` `#e05e00` | **3.38** ✅ | **3.64** ✅ | 3.38 | 3.49 | 1.26 | 1.00 | 3.27 | 1.54 | 3.36 | 3.45 | 4.87 | 4.09 | 4.09 | +| `danger` `#c62828` | **5.23** ✅ | **5.62** ✅ | 5.23 | 5.39 | 1.95 | 1.54 | 5.05 | 1.00 | **5.18** ✅ | 5.34 | 3.15 | 2.65 | 2.65 | diff --git a/eslint.config.mjs b/eslint.config.mjs index 419e9d2..5f7a352 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -6,7 +6,9 @@ import eslintPluginReactHooks from 'eslint-plugin-react-hooks'; import eslintPluginReactRefresh from 'eslint-plugin-react-refresh'; export default defineConfig( - { ignores: ['**/node_modules', '**/dist', '**/out'] }, + { + ignores: ['**/node_modules', '**/dist', '**/out', '**/.vite', 'storybook-static', 'artifacts'] + }, tseslint.configs.recommended, eslintPluginReact.configs.flat.recommended, eslintPluginReact.configs.flat['jsx-runtime'], diff --git a/package.json b/package.json index d47c4da..319502d 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", "typecheck": "npm run typecheck:node && npm run typecheck:web", + "tokens:check": "tsx scripts/tokens-check.ts", "swagger:local": "swagger-typescript-api generate -p http://localhost:3000/docs/json -r -o ./src/renderer/src/api/generated --modular -d --extract-request-body --extract-response-body --extract-response-error --axios --clean-output", "start": "electron-vite preview", "dev": "electron-vite dev", @@ -39,17 +40,20 @@ "@vanilla-extract/css": "^1.18.0", "@vanilla-extract/vite-plugin": "^5.1.4", "axios": "^1.13.5", + "clsx": "^2.1.1", "pretendard": "^1.3.9", "prism-react-renderer": "^2.4.1", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "socket.io-client": "^4.8.3", + "tailwind-merge": "^3.7.0", "zustand": "^5.0.11" }, "devDependencies": { "@electron-toolkit/eslint-config-prettier": "^3.0.0", "@electron-toolkit/eslint-config-ts": "^3.1.0", "@electron-toolkit/tsconfig": "^2.0.0", + "@storybook/addon-a11y": "^8.6.14", "@storybook/addon-essentials": "^8.6.14", "@storybook/addon-interactions": "^8.6.14", "@storybook/react-vite": "^8.6.14", diff --git a/scripts/registry.ts b/scripts/registry.ts new file mode 100644 index 0000000..b634f45 --- /dev/null +++ b/scripts/registry.ts @@ -0,0 +1,187 @@ +/** + * Quire 컴포넌트 registry 생성 (docs/components.md · Phase E) + * + * components/ui/*.meta.ts + 컴포넌트 JSDoc 의 옵션 줄 + className 에서 뽑은 토큰을 + * docs/registry.md 한 파일로 합친다. AI·사람이 "무엇을 쓸까"를 한 곳에서 찾게 하는 것이 목적. + * + * 검사: ui 컴포넌트마다 meta 가 있는지, 이름이 파일과 같은지, JSDoc ✅/❌ 가 있는지, 패턴 문서가 있는지. + * tokens-check.ts 가 불러서 실행한다. + */ +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import type { ComponentMeta } from '../src/renderer/src/components/ui/componentMeta'; + +const CATEGORY_ORDER: Array<[ComponentMeta['category'], string]> = [ + ['action', '액션'], + ['input', '입력'], + ['overlay', '오버레이'], + ['feedback', '상태 알림'], + ['display', '표시'], + ['navigation', '내비게이션'] +]; + +// JSDoc 에서 옵션 설명으로 옮길 줄 — "size: …", "variant: …" 와 그 이어지는 줄 +const OPTION_LINE = /^\s*\*\s+([A-Za-z가-힣]+):\s+(.+)$/; +const CONTINUATION = /^\s*\*\s{2,}(\S.*)$/; + +const extractOptions = (src: string): string[] => { + const doc = src.match(/\/\*\*[\s\S]*?✅[\s\S]*?\*\//)?.[0] ?? ''; + const out: string[] = []; + let current: string | null = null; + for (const line of doc.split('\n')) { + const opt = line.match(OPTION_LINE); + if (opt && !/^(Use|Don't)$/.test(opt[1])) { + if (current) out.push(current); + current = `\`${opt[1]}\` ${opt[2].trim()}`; + continue; + } + const cont = line.match(CONTINUATION); + if (current && cont && !/^[✅❌]/.test(cont[1])) { + current += ` ${cont[1].trim()}`; + continue; + } + if (current) out.push(current); + current = null; + } + if (current) out.push(current); + return out; +}; + +const extractTokens = ( + src: string, + vars: Set
+): { color: string[]; text: string[]; radius: string[]; shadow: string[] } => { + const pick = (prefix: string): Set => + new Set( + [...vars].filter((v) => v.startsWith(`${prefix}-`)).map((v) => v.slice(prefix.length + 1)) + ); + const colors = pick('color'); + const sizes = pick('text'); + const radii = pick('radius'); + const shadows = pick('shadow'); + const found = { + color: new Set (), + text: new Set (), + radius: new Set (), + shadow: new Set () + }; + const util = + /(?): string[] => [...s].sort(); + return { + color: sort(found.color), + text: sort(found.text), + radius: sort(found.radius), + shadow: sort(found.shadow) + }; +}; + +export const buildRegistry = (root: string): { markdown: string; failures: string[] } => { + const uiDir = join(root, 'src/renderer/src/components/ui'); + const patternsDir = join(root, 'docs/patterns'); + const css = readFileSync(join(root, 'src/renderer/src/assets/main.css'), 'utf8'); + const vars = new Set([...css.matchAll(/--([\w-]+):/g)].map((m) => m[1])); + const failures: string[] = []; + + // 컴포넌트 파일 = 대문자로 시작하는 .tsx (스토리·테스트 제외) + const components = readdirSync(uiDir) + .filter((f) => /^[A-Z]\w*\.tsx$/.test(f)) + .map((f) => f.replace(/\.tsx$/, '')); + + const entries: Array<{ meta: ComponentMeta; src: string }> = []; + for (const name of components) { + const src = readFileSync(join(uiDir, `${name}.tsx`), 'utf8'); + const metaPath = join(uiDir, `${name}.meta.ts`); + if (!/\/\*\*[\s\S]*?✅[\s\S]*?❌[\s\S]*?\*\//.test(src)) + failures.push(`registry: ui/${name}.tsx 상단 JSDoc 에 ✅ Use / ❌ Don't 가 없음`); + if (!existsSync(metaPath)) { + failures.push(`registry: ui/${name}.meta.ts 가 없음 — componentMeta.ts 스키마로 추가한다`); + continue; + } + // eslint-disable-next-line @typescript-eslint/no-require-imports + const meta = require(metaPath).default as ComponentMeta; + if (meta.name !== name) failures.push(`registry: ui/${name}.meta.ts 의 name 이 '${meta.name}'`); + for (const p of meta.patterns) + if (!existsSync(join(patternsDir, `${p}.md`))) + failures.push(`registry: ${name} 의 패턴 문서 docs/patterns/${p}.md 가 없음`); + entries.push({ meta, src }); + } + for (const f of readdirSync(uiDir).filter((f) => f.endsWith('.meta.ts'))) { + if (!components.includes(f.replace(/\.meta\.ts$/, ''))) + failures.push(`registry: ui/${f} 에 해당하는 컴포넌트가 없음`); + } + + const byCategory = CATEGORY_ORDER.map(([key, label]) => ({ + label, + items: entries + .filter((e) => e.meta.category === key) + .sort((a, b) => a.meta.name.localeCompare(b.meta.name)) + })).filter((c) => c.items.length); + + const patternLinks = (m: ComponentMeta): string => + m.patterns.map((p) => `[${p}](./patterns/${p}.md)`).join(' · ') || '—'; + + const lines: string[] = [ + '# Quire Component Registry', + '', + '> `yarn tokens:check` 가 `components/ui/*.meta.ts`, 컴포넌트 JSDoc, className 에서 생성한다. 직접 수정하지 않는다.', + '> 고칠 때는 해당 `*.meta.ts`(쓸 때·피할 때·관련·패턴) 나 컴포넌트 JSDoc(옵션)을 고치고 다시 생성한다.', + '', + 'UI 를 만들거나 고칠 때: 아래 표에서 맞는 컴포넌트를 먼저 찾는다 → "피할 때"에 걸리면 → 로 안내된 컴포넌트를 쓴다 → 없으면 새로 만들기 전에 가장 가까운 것을 확장한다.', + '상황별 결정(확인·에러·피드백·빈 상태·로딩)은 [`docs/patterns/`](./patterns/README.md), 토큰 의미는 [`docs/foundations.md`](./foundations.md).', + '', + '## 한눈에', + '', + '| 분류 | 컴포넌트 | 한 줄 | 패턴 |', + '| --- | --- | --- | --- |' + ]; + for (const c of byCategory) + for (const { meta } of c.items) + lines.push( + `| ${c.label} | [${meta.name}](#${meta.name.toLowerCase()}) | ${meta.summary} | ${patternLinks(meta)} |` + ); + + for (const c of byCategory) { + lines.push('', `## ${c.label}`); + for (const { meta, src } of c.items) { + const t = extractTokens(src, vars); + const opts = extractOptions(src); + lines.push( + '', + `### ${meta.name}`, + '', + `\`components/ui/${meta.name}.tsx\` · ${meta.status}`, + '', + meta.summary, + '', + '**쓸 때**', + ...meta.whenToUse.map((x) => `- ${x}`), + '', + '**피할 때**', + ...meta.whenNotToUse.map((x) => `- ${x}`) + ); + if (opts.length) lines.push('', '**옵션**', ...opts.map((x) => `- ${x}`)); + const tokenParts = [ + t.color.length && `색 ${t.color.map((x) => `\`${x}\``).join(' ')}`, + t.text.length && `글자 ${t.text.map((x) => `\`${x}\``).join(' ')}`, + t.radius.length && `모서리 ${t.radius.map((x) => `\`${x}\``).join(' ')}`, + t.shadow.length && `그림자 ${t.shadow.map((x) => `\`${x}\``).join(' ')}` + ].filter(Boolean); + lines.push( + '', + `**관련** ${meta.related.map((r) => `[${r}](#${r.toLowerCase()})`).join(' · ') || '—'} · **패턴** ${patternLinks(meta)}` + ); + if (tokenParts.length) lines.push('', `**토큰** ${tokenParts.join(' · ')}`); + } + } + lines.push(''); + return { markdown: lines.join('\n'), failures }; +}; diff --git a/scripts/tokens-check.ts b/scripts/tokens-check.ts new file mode 100644 index 0000000..f18610f --- /dev/null +++ b/scripts/tokens-check.ts @@ -0,0 +1,352 @@ +/** + * Quire 토큰 검사 (docs/foundations.md §4) + * + * 1. main.css 의 semantic 색을 값으로 풀어 fg × bg 대비 매트릭스를 docs/tokens/contrast-matrix.md 로 생성한다. + * 2. REQUIRED 에 적힌 의도한 조합이 WCAG AA 기준 미달이면 실패한다. + * 3. 렌더러 소스에서 토큰이 아닌 클래스(raw 팔레트·기본 크기·기본 radius·임의값)를 찾으면 실패한다. + * 3b. 패턴 규칙 위반(브라우저 confirm/alert, 손 스피너, 버튼 라벨 바꿔치기, 하드코딩 색)을 찾으면 실패한다. + * 3c. 화면 문구 규칙(해요체, 말줄임표 …, 용어집) 위반을 찾으면 실패한다 (docs/copy.md). + * 4. cn.ts 토큰 목록이 main.css 와 같은지 본다. + * 5. 컴포넌트 registry(docs/registry.md)를 생성하고 meta 누락을 검사한다 (scripts/registry.ts). + * + * 실행: yarn tokens:check + */ +import { readFileSync, writeFileSync, readdirSync, statSync, mkdirSync } from 'node:fs'; +import { join, relative, dirname } from 'node:path'; +import { buildRegistry } from './registry'; + +const ROOT = join(__dirname, '..'); +const CSS_PATH = join(ROOT, 'src/renderer/src/assets/main.css'); +const SRC_DIR = join(ROOT, 'src/renderer/src'); +const CN_PATH = join(ROOT, 'src/renderer/src/lib/cn.ts'); +const MATRIX_PATH = join(ROOT, 'docs/tokens/contrast-matrix.md'); +const REGISTRY_PATH = join(ROOT, 'docs/registry.md'); + +const TEXT = 4.5; +const UI = 3; + +/** 실제로 함께 쓰는 조합. 새 조합을 쓰기 시작하면 여기에 추가한다. */ +const REQUIRED: Array<{ fg: string; bg: string[]; min: number }> = [ + { + fg: 'fg-default', + bg: [ + 'canvas', + 'surface', + 'surface-muted', + 'sidebar', + 'primary-soft', + 'danger-soft', + 'success-soft' + ], + min: TEXT + }, + { + fg: 'fg-subtle', + bg: ['canvas', 'surface', 'surface-muted', 'sidebar', 'primary-soft'], + min: TEXT + }, + { + fg: 'fg-muted', + bg: ['canvas', 'surface', 'surface-muted', 'sidebar', 'primary-soft'], + min: TEXT + }, + { fg: 'fg-primary', bg: ['canvas', 'surface', 'surface-muted', 'primary-soft'], min: TEXT }, + { fg: 'fg-on-primary', bg: ['primary'], min: TEXT }, + { fg: 'fg-danger', bg: ['canvas', 'surface', 'danger-soft'], min: TEXT }, + { fg: 'fg-success', bg: ['canvas', 'surface', 'success-soft'], min: TEXT }, + { fg: 'fg-code', bg: ['code', 'code-raised'], min: TEXT }, + { fg: 'fg-code-muted', bg: ['code', 'code-raised'], min: TEXT }, + { fg: 'fg-on-dark', bg: ['danger', 'inverse'], min: TEXT }, + // 비텍스트 UI: 입력 경계, 선택 테두리·포커스 링, 오류 경계 + { fg: 'line-strong', bg: ['canvas', 'surface'], min: UI }, + { fg: 'line-primary', bg: ['canvas', 'surface'], min: UI }, + { fg: 'danger', bg: ['canvas', 'surface', 'danger-soft'], min: UI } +]; + +/** 매트릭스의 배경 열. scrim·scrollbar 는 글자가 올라가지 않아 제외한다. */ +const BACKGROUNDS = [ + 'canvas', + 'surface', + 'surface-muted', + 'sidebar', + 'primary', + 'primary-strong', + 'primary-soft', + 'danger', + 'danger-soft', + 'success-soft', + 'code', + 'code-raised', + 'inverse' +]; + +/** 소스에서 금지하는 클래스. 선행 경계는 공백·따옴표·변형 콜론 등. */ +const PALETTE = + 'slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose'; +const COLOR_UTIL = + 'bg|text|border(?:-[trblxyse])?|ring(?:-offset)?|outline|divide|fill|stroke|from|via|to|decoration|caret|accent|shadow|placeholder'; +const FORBIDDEN: Array<{ re: RegExp; why: string }> = [ + { + re: new RegExp(`(?:${COLOR_UTIL})-(?:${PALETTE})-\\d+`), + why: 'Tailwind 기본 팔레트 — semantic 토큰을 쓴다' + }, + { + re: new RegExp(`(?:${COLOR_UTIL})-(?:white|black)(?![-\\w])`), + why: 'white/black — fg-on-dark, scrim 등 semantic 토큰을 쓴다' + }, + { + re: new RegExp(`(?:${COLOR_UTIL})-\\[(?:#|rgb|hsl|oklch|var)`), + why: '임의 색 값 — 토큰을 추가한다' + }, + { + re: /text-(?:xs|sm|base|lg|[2-9]?xl|ui-\d+)(?![-\w])/, + why: '폐기된 폰트 크기 — text-micro~display 를 쓴다' + }, + { re: /text-\[\d+(?:\.\d+)?(?:px|rem|em)\]/, why: '임의 폰트 크기 — 역할 스케일을 쓴다' }, + { + re: /rounded(?:-(?:[trblse]|tl|tr|bl|br|ss|se|es|ee))?(?:-(?:xs|sm|md|lg|[2-4]?xl))?(?![-\w[])/, + why: '기본 radius — rounded-inline/control/card/panel/shell 을 쓴다' + }, + { re: /rounded(?:-[a-z]+)?-\[/, why: '임의 radius — 역할 토큰을 쓴다' }, + { + re: /shadow(?:-(?:2xs|xs|sm|md|lg|xl|2xl))?(?![-\w[])|shadow-\[/, + why: '그림자는 shadow-overlay 하나만' + }, + { re: /font-bold(?![-\w])/, why: '굵기 규칙 — 제목도 semibold(600)까지' }, + { + re: /duration-(?:\d+|\[[^\]]*\])(?![-\w])/, + why: '숫자 duration — 작은 전환은 기본값(fast), 나타나고 움직이는 것은 duration-base' + }, + { + re: /ease-(?:in|out|in-out|linear|\[[^\]]*\])(?![-\w])/, + why: '기본 easing — ease-standard / ease-enter / ease-exit 을 쓴다' + }, + { + re: /(?:text-text-(?:base|subtle|soft)|app-bg|control-line|danger-bg|danger-line|primary-foreground|accent-strong|fill-icon)(?![-\w])/, + why: '폐기된 토큰 이름' + } +]; +const BOUNDARY = /(?:^|[\s"'`:!([{,])-?$/; + +/** 의도적 예외. 파일(렌더러 src 기준 상대경로) + 클래스 + 이유. */ +const ALLOW: Array<{ file: string; token: string; why: string }> = [ + { + file: 'components/ui/ChatComposer.tsx', + token: 'text-[16px]', + why: 'iOS Safari 입력 자동확대 방지(16px 고정)' + } +]; + +// ── CSS 파싱 ──────────────────────────────────────────────────────────── +const css = readFileSync(CSS_PATH, 'utf8'); +const vars = new Map (); +for (const m of css.matchAll(/--([\w-]+):\s*([^;]+);/g)) vars.set(m[1], m[2].trim()); + +const resolve = (name: string, depth = 0): string => { + const raw = vars.get(name); + if (!raw || depth > 10) throw new Error(`토큰을 찾을 수 없음: --${name}`); + const ref = raw.match(/^var\(--([\w-]+)\)$/); + return ref ? resolve(ref[1], depth + 1) : raw; +}; +const color = (token: string): string => { + const v = resolve(`color-${token}`); + if (!/^#[0-9a-f]{6}$/i.test(v)) throw new Error(`hex 가 아닌 색: ${token} = ${v}`); + return v; +}; + +const luminance = (hex: string): number => { + const [r, g, b] = [1, 3, 5].map((i) => { + const c = parseInt(hex.slice(i, i + 2), 16) / 255; + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +}; +const contrast = (a: string, b: string): number => { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +}; + +// ── 1·2. 대비 ─────────────────────────────────────────────────────────── +const failures: string[] = []; +const required = new Map (); +for (const { fg, bg, min } of REQUIRED) { + for (const b of bg) { + required.set(`${fg}|${b}`, min); + const ratio = contrast(color(fg), color(b)); + if (ratio < min) failures.push(`대비 미달: ${fg} on ${b} = ${ratio.toFixed(2)} (기준 ${min})`); + } +} + +const fgTokens = [...vars.keys()] + .filter((k) => /^color-fg-/.test(k)) + .map((k) => k.replace(/^color-/, '')) + .concat(['line-strong', 'line-primary', 'danger']); +const header = `| 전경 \\ 배경 | ${BACKGROUNDS.map((b) => `\`${b}\``).join(' | ')} |`; +const rows = fgTokens.map((fg) => { + const cells = BACKGROUNDS.map((b) => { + const ratio = contrast(color(fg), color(b)); + const min = required.get(`${fg}|${b}`); + const text = ratio.toFixed(2); + if (min === undefined) return ratio >= TEXT ? text : `${text}`; + return ratio >= min ? `**${text}** ✅` : `**${text}** ❌`; + }); + return `| \`${fg}\` \`${color(fg)}\` | ${cells.join(' | ')} |`; +}); +const matrix = [ + '# Contrast matrix', + '', + '> `yarn tokens:check` 가 생성한다. 직접 수정하지 않는다.', + '', + '- 굵게 = 의도한 조합(`scripts/tokens-check.ts` 의 REQUIRED). ✅ 기준 충족 / ❌ 미달.', + `- 기준: 텍스트 ${TEXT}:1, 비텍스트 UI(\`line-*\`, \`danger\` 경계) ${UI}:1 (WCAG 2.1 AA).`, + `- 작은 숫자 = 의도하지 않은 조합 중 ${TEXT}:1 미만. 이 조합을 쓰려면 먼저 REQUIRED 에 추가해 검증한다.`, + '', + header, + `|---|${BACKGROUNDS.map(() => '---').join('|')}|`, + ...rows, + '' +].join('\n'); +mkdirSync(dirname(MATRIX_PATH), { recursive: true }); +writeFileSync(MATRIX_PATH, matrix); + +// ── 3. 소스 검사 ──────────────────────────────────────────────────────── +const walk = (dir: string, out: string[] = []): string[] => { + for (const name of readdirSync(dir)) { + const p = join(dir, name); + if (name === 'generated' || name === 'node_modules') continue; + if (statSync(p).isDirectory()) walk(p, out); + else if (/\.(tsx?|mdx)$/.test(name)) out.push(p); + } + return out; +}; + +for (const file of walk(SRC_DIR)) { + const rel = relative(SRC_DIR, file); + // cn.ts 는 토큰 이름 목록(설정)이지 className 이 아니다 — §4 동기화 검사가 따로 본다 + if (rel === 'lib/cn.ts') continue; + readFileSync(file, 'utf8') + .split('\n') + .forEach((line, i) => { + for (const { re, why } of FORBIDDEN) { + const global = new RegExp(re.source, 'g'); + for (const m of line.matchAll(global)) { + if (!BOUNDARY.test(line.slice(0, m.index))) continue; + if (ALLOW.some((a) => a.file === rel && a.token === m[0])) continue; + failures.push(`${rel}:${i + 1} ${m[0]} — ${why}`); + } + } + }); +} + +// ── 3b. 패턴 규칙 (docs/patterns · docs/ai-review-checklist.md) ────────── +// className 이 아니라 코드 모양을 본다. 대신 쓸 것을 메시지에 적는다. +const PATTERN_RULES: Array<{ re: RegExp; why: string; except?: string[] }> = [ + { + re: /(? { + if (/^\s*(\/\/|\*)/.test(line)) return; // 주석 + for (const { re, why, except } of PATTERN_RULES) { + if (except?.includes(rel)) continue; + const m = line.match(re); + if (m) failures.push(`${rel}:${i + 1} ${m[0]} — ${why}`); + } + }); +} + +// ── 3c. 화면 문구 (docs/copy.md) ───────────────────────────────────────── +// 주석·스토리·테스트·개발자용 throw 는 보지 않는다. 스토리의 AI 답변 예시는 AI 가 쓴 글이라 제외. +const COPY_RULES: Array<{ re: RegExp; why: string }> = [ + { re: /[가-힣](?:니다|니까|십시오)(?![가-힣])/, why: '습니다체 — 해요체로 쓴다' }, + { re: /[가-힣)]\s?\.\.\./, why: '마침표 3개 — 말줄임표 "…" 한 글자' }, + { re: /팀채팅/, why: '용어 — "팀 채팅"' }, + { re: /레포지토리/, why: '용어 — "저장소"' }, + { re: /양도/, why: '용어 — "방장 넘기기"' } +]; +for (const file of walk(SRC_DIR)) { + const rel = relative(SRC_DIR, file); + if (!/\.tsx?$/.test(rel) || /\.(test|stories)\.tsx?$/.test(rel) || rel.endsWith('.meta.ts')) + continue; + let inBlock = false; + readFileSync(file, 'utf8') + .split('\n') + .forEach((raw, i) => { + // 블록 주석·JSDoc·줄 주석을 걷어낸 코드만 본다 + let line = raw; + if (inBlock) { + const end = line.indexOf('*/'); + if (end < 0) return; + line = line.slice(end + 2); + inBlock = false; + } + line = line.replace(/\/\*.*?\*\//g, ''); + const open = line.indexOf('/*'); + if (open >= 0) { + line = line.slice(0, open); + inBlock = true; + } + line = line.replace(/(^|\s)\/\/.*$/, '$1').replace(/\{\s*\/\*.*$/, ''); + if (/throw new Error\(/.test(line)) return; + for (const { re, why } of COPY_RULES) { + const m = line.match(re); + if (m) failures.push(`${rel}:${i + 1} ${m[0]} — ${why} (docs/copy.md)`); + } + }); +} + +// ── 4. cn() 토큰 등록 동기화 ──────────────────────────────────────────── +// tailwind-merge 는 모르는 크기·radius 토큰을 색으로 오인해 지운다. main.css 와 cn.ts 목록이 같아야 한다. +const cnSrc = readFileSync(CN_PATH, 'utf8'); +const themeKeys = (prefix: string): string[] => + [...vars.keys()] + .filter((k) => k.startsWith(`${prefix}-`) && !k.includes('--')) + .map((k) => k.slice(prefix.length + 1)); +for (const [cssPrefix, cnKey] of [ + ['text', 'text'], + ['radius', 'radius'], + ['shadow', 'shadow'], + ['ease', 'ease'] +] as const) { + const m = cnSrc.match(new RegExp(`${cnKey}: \\[([^\\]]*)\\]`)); + const registered = m ? [...m[1].matchAll(/'([\w-]+)'/g)].map((x) => x[1]) : []; + const defined = themeKeys(cssPrefix); + const missing = defined.filter((t) => !registered.includes(t)); + const stale = registered.filter((t) => !defined.includes(t)); + if (missing.length) failures.push(`cn.ts ${cnKey} 에 미등록 토큰: ${missing.join(', ')}`); + if (stale.length) failures.push(`cn.ts ${cnKey} 에 없는 토큰: ${stale.join(', ')}`); +} + +// ── 5. 컴포넌트 registry ───────────────────────────────────────────────── +const registry = buildRegistry(ROOT); +writeFileSync(REGISTRY_PATH, registry.markdown); +failures.push(...registry.failures); + +// ── 결과 ─────────────────────────────────────────────────────────────── +console.log(`contrast matrix → ${relative(ROOT, MATRIX_PATH)}`); +console.log(`registry → ${relative(ROOT, REGISTRY_PATH)}`); +if (failures.length) { + console.error(`\n✖ tokens:check ${failures.length}건\n`); + for (const f of failures) console.error(` ${f}`); + process.exit(1); +} +console.log('✔ tokens:check 통과'); diff --git a/src/renderer/index.html b/src/renderer/index.html index bd4102f..8b795dd 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -32,7 +32,6 @@ - diff --git a/src/renderer/public/icon-192.png b/src/renderer/public/icon-192.png deleted file mode 100644 index d1b5aa2..0000000 Binary files a/src/renderer/public/icon-192.png and /dev/null differ diff --git a/src/renderer/public/icon-512.png b/src/renderer/public/icon-512.png deleted file mode 100644 index cf9e8b2..0000000 Binary files a/src/renderer/public/icon-512.png and /dev/null differ diff --git a/src/renderer/public/site.webmanifest b/src/renderer/public/site.webmanifest index f83e98c..c8e9298 100644 --- a/src/renderer/public/site.webmanifest +++ b/src/renderer/public/site.webmanifest @@ -7,16 +7,5 @@ "display": "standalone", "background_color": "#ffffff", "theme_color": "#27272A", - "icons": [ - { - "src": "/icon-192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "/icon-512.png", - "sizes": "512x512", - "type": "image/png" - } - ] + "icons": [] } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index bf44d92..ada0895 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -4,7 +4,6 @@ import { useGetAuthMe } from './api/auth/useAuthAPI'; import { useGetProjectChats, type ProjectChatItem } from './api/auth/useChatsAPI'; import { useGetProject, useGetProjects } from './api/auth/useProjectsAPI'; import { useGetProjectSections } from './api/auth/useSectionsAPI'; -import { authTransitionStorage } from './api/authTransitionStorage'; import { handleApiError } from './api/axios'; import type { ProjectsListData } from './api/generated/data-contracts'; import { QUERY_KEY } from './api/queryKeys'; @@ -38,7 +37,6 @@ const App = (): React.JSX.Element => { const me = useGetAuthMe({ enabled: !isAuthRoute }); const [createProjectModalOpen, setCreateProjectModalOpen] = useState(false); const projects = useGetProjects({ search: '', enabled: Boolean(token) }); - const loginTransitionUserName = authTransitionStorage.getLoginTransitionUserName(); const toast = useToast(); const queryClient = useQueryClient(); @@ -165,13 +163,6 @@ const App = (): React.JSX.Element => { } }, [token, isInviteRoute, projects.isSuccess, projects.data]); - useEffect(() => { - if (!loginTransitionUserName) return; - if (!token || me.isSuccess || me.isError) { - authTransitionStorage.clearLoginTransitionUserName(); - } - }, [loginTransitionUserName, token, me.isSuccess, me.isError]); - // 삭제된 프로젝트로 들어오면(404): 알림, 목록 캐시에서 제거, 기본 프로젝트로 fallback const handledDeletedProjectIdRef = useRef (null); useEffect(() => { @@ -184,7 +175,7 @@ const App = (): React.JSX.Element => { handledDeletedProjectIdRef.current = selectedProjectId; - toast.error('해당 프로젝트가 삭제되었습니다.'); + toast.info('이 프로젝트는 삭제됐어요.'); queryClient.setQueriesData ({ queryKey: ['projects'] }, (old) => { if (!old) return old; return { ...old, data: old.data.filter((p) => p.id !== selectedProjectId) }; @@ -213,26 +204,12 @@ const App = (): React.JSX.Element => { if (!token) { return ( - +Redirecting...); } - if (me.isLoading && loginTransitionUserName) { - return ( --- ); - } - if (me.isError) { return (--- 어서오세요, {loginTransitionUserName}님! -
-잠시만요, 준비하고 있어요...
- -@@ -248,7 +225,7 @@ const App = (): React.JSX.Element => { if (location.path === '/' || location.path === '') { navigate('/projects', { replace: true }); return ( -+Redirecting...); @@ -290,10 +267,7 @@ const App = (): React.JSX.Element => { ) : null} {matchPath(location.path, '/projects').matched ? ( -setCreateProjectModalOpen(true)} - /> + setCreateProjectModalOpen(true)} /> ) : null} {projectMatch.matched ? ( { /> ) : null} {!matchPath(location.path, '/projects').matched && !projectMatch.matched ? ( - -) : null} + {startGithubOauth.isError ? ( +Not Found
-{location.path}
++) : null}Not Found
+{location.path}
undefined); diff --git a/src/renderer/src/api/auth/useDigestAPI.ts b/src/renderer/src/api/auth/useDigestAPI.ts index 1d00c9e..0da34f6 100644 --- a/src/renderer/src/api/auth/useDigestAPI.ts +++ b/src/renderer/src/api/auth/useDigestAPI.ts @@ -124,7 +124,7 @@ export const useDigestPreview = (params: { chatId: string }): UseDigestPreviewRe ? err.message : err instanceof Error ? err.message - : '요약 생성 중 오류가 발생했습니다.'; + : '요약을 만드는 중 문제가 생겼어요.'; const code = err instanceof SseServerError ? err.code : undefined; setState((prev) => ({ diff --git a/src/renderer/src/api/auth/useTeamChatSocket.ts b/src/renderer/src/api/auth/useTeamChatSocket.ts index 1c94191..40cd935 100644 --- a/src/renderer/src/api/auth/useTeamChatSocket.ts +++ b/src/renderer/src/api/auth/useTeamChatSocket.ts @@ -59,7 +59,7 @@ export const useTeamChatSocket = ( const handleError = (payload: { message: string }): void => { setIsSending(false); - setSendError(payload.message ?? '메시지 전송에 실패했습니다.'); + setSendError(payload.message ?? '메시지를 보내지 못했어요.'); }; socket.on('team:message:receive', handleMessage); diff --git a/src/renderer/src/api/authTransitionStorage.ts b/src/renderer/src/api/authTransitionStorage.ts deleted file mode 100644 index 43c8a8b..0000000 --- a/src/renderer/src/api/authTransitionStorage.ts +++ /dev/null @@ -1,25 +0,0 @@ -const LOGIN_TRANSITION_USER_NAME_KEY = 'loginTransitionUserName'; - -export const authTransitionStorage = { - getLoginTransitionUserName(): string | null { - try { - return sessionStorage.getItem(LOGIN_TRANSITION_USER_NAME_KEY); - } catch { - return null; - } - }, - setLoginTransitionUserName(name: string): void { - try { - sessionStorage.setItem(LOGIN_TRANSITION_USER_NAME_KEY, name); - } catch { - // noop - } - }, - clearLoginTransitionUserName(): void { - try { - sessionStorage.removeItem(LOGIN_TRANSITION_USER_NAME_KEY); - } catch { - // noop - } - } -}; diff --git a/src/renderer/src/api/capabilities.ts b/src/renderer/src/api/capabilities.ts index 50899ef..0fde2ba 100644 --- a/src/renderer/src/api/capabilities.ts +++ b/src/renderer/src/api/capabilities.ts @@ -3,4 +3,4 @@ export const API_CAPABILITIES = { messageShareEnabled: false } as const; -export const TEAM_CHAT_READONLY_TOOLTIP = '현재는 지원하지 않는 기능이며, 추후 구현 예정입니다.'; +export const TEAM_CHAT_READONLY_TOOLTIP = '아직 준비 중인 기능이에요.'; diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index f481471..da9d777 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -2,74 +2,132 @@ @source "../**/*.{ts,tsx}"; +/* ─── Tier 1 · Raw palette ─────────────────────────────────────────────── + 값만 가진다. @theme 밖에 두어 유틸리티(bg-gray-50 등)가 생성되지 않게 한다. + 컴포넌트는 raw 를 직접 쓰지 않고 아래 semantic 토큰만 쓴다. (docs/foundations.md §2) */ +:root { + --palette-gray-0: #ffffff; + --palette-gray-25: #fbfaf8; + --palette-gray-50: #faf6f1; + --palette-gray-100: #efefef; + --palette-gray-150: #e4e4e7; + --palette-gray-200: #dfddd8; + --palette-gray-400: #9f9fa9; + --palette-gray-600: #61646b; + --palette-gray-700: #52545a; + --palette-gray-850: #27272a; + --palette-gray-900: #18181b; + --palette-gray-950: #0e0e0f; + + --palette-orange-100: #fff0e5; + --palette-orange-500: #ff6900; + --palette-orange-600: #e05e00; + --palette-orange-700: #b54700; + + --palette-red-50: #fff3f3; + --palette-red-200: #f1b5b5; + --palette-red-700: #c62828; + + --palette-green-50: #ecfdf5; + --palette-green-200: #a4f4cf; + --palette-green-700: #007a55; + + --palette-black: #000000; + --palette-white: #ffffff; + + --motion-fast: 150ms; + --motion-base: 200ms; +} + +/* ─── Tier 2 · Semantic ────────────────────────────────────────────────── + Tailwind 기본 팔레트는 지우고 역할 토큰만 유틸리티로 노출한다. (docs/foundations.md §3) */ @theme { - /* Zinc neutral palette */ - --color-zinc-50: #fafafa; - --color-zinc-100: #f4f4f5; - --color-zinc-200: #e4e4e7; - --color-zinc-400: #9f9fa9; - --color-zinc-500: #71717b; - --color-zinc-700: #3f3f47; - --color-zinc-800: #27272a; - - /* Slate palette */ - --color-slate-200: #e2e8f0; - --color-slate-600: #45556c; - --color-slate-900: #0f172b; - - /* Accent */ - --color-green-400: #05df72; - --color-fill-icon: #222222; - - /* Semantic tokens */ - --color-app-bg: #faf6f1; - --color-surface: #ffffff; - --color-sidebar: #fbfaf8; - --color-message: #efefef; - --color-surface-muted: #faf6f1; - --color-line: #dfddd8; - --color-line-soft: #efefef; - --color-control-line: #61646b; - - --color-text-base: #0e0e0f; - --color-text-subtle: #52545a; - --color-text-soft: #61646b; - - --color-primary: #ff6900; - --color-primary-strong: #e05e00; - --color-primary-foreground: #0e0e0f; - --color-primary-soft: #fff0e5; - - --color-accent: #ff6900; - --color-accent-strong: #b54700; - - --color-danger: #c62828; - --color-danger-bg: #fff3f3; - --color-danger-line: #f1b5b5; - - /* Typography tokens */ - --text-xs: calc(var(--font-size-base) * 0.75); - --text-sm: calc(var(--font-size-base) * 0.875); - --text-base: calc(var(--font-size-base) * 1); - --text-lg: calc(var(--font-size-base) * 1.125); - --text-xl: calc(var(--font-size-base) * 1.25); - --text-2xl: calc(var(--font-size-base) * 1.5); - --text-3xl: calc(var(--font-size-base) * 1.875); - --text-4xl: calc(var(--font-size-base) * 2.25); - --text-5xl: calc(var(--font-size-base) * 3); - - --text-ui-10: calc(var(--font-size-base) * 0.625); - --text-ui-11: calc(var(--font-size-base) * 0.6875); - --text-ui-12: calc(var(--font-size-base) * 0.75); - --text-ui-13: calc(var(--font-size-base) * 0.8125); - --text-ui-14: calc(var(--font-size-base) * 0.875); - --text-ui-15: calc(var(--font-size-base) * 0.9375); - --text-ui-16: calc(var(--font-size-base) * 1); - --text-ui-20: calc(var(--font-size-base) * 1.25); - --text-ui-22: calc(var(--font-size-base) * 1.375); - --text-ui-32: calc(var(--font-size-base) * 2); - --text-ui-42: calc(var(--font-size-base) * 2.625); - --text-ui-44: calc(var(--font-size-base) * 2.75); + --color-*: initial; + + /* 배경 — 장소 이름 */ + --color-canvas: var(--palette-gray-50); + --color-surface: var(--palette-gray-0); + --color-surface-muted: var(--palette-gray-50); + --color-sidebar: var(--palette-gray-25); + --color-primary: var(--palette-orange-500); + --color-primary-strong: var(--palette-orange-600); + --color-primary-soft: var(--palette-orange-100); + --color-danger: var(--palette-red-700); + --color-danger-soft: var(--palette-red-50); + --color-success-soft: var(--palette-green-50); + --color-code: var(--palette-gray-900); + --color-code-raised: var(--palette-gray-850); + --color-inverse: var(--palette-gray-850); + --color-scrim: var(--palette-black); + + /* 전경 — 텍스트·아이콘 */ + --color-fg-default: var(--palette-gray-950); + --color-fg-subtle: var(--palette-gray-700); + --color-fg-muted: var(--palette-gray-600); + --color-fg-primary: var(--palette-orange-700); + --color-fg-on-primary: var(--palette-gray-950); + --color-fg-danger: var(--palette-red-700); + --color-fg-success: var(--palette-green-700); + --color-fg-code: var(--palette-gray-100); + --color-fg-code-muted: var(--palette-gray-400); + --color-fg-on-dark: var(--palette-white); + + /* 선 */ + --color-line: var(--palette-gray-200); + --color-line-soft: var(--palette-gray-100); + --color-line-strong: var(--palette-gray-600); + --color-line-primary: var(--palette-orange-600); + --color-line-danger: var(--palette-red-200); + --color-line-success: var(--palette-green-200); + --color-line-code: var(--palette-gray-850); + + /* 스크롤바 */ + --color-scrollbar-track: var(--palette-gray-150); + --color-scrollbar-thumb: var(--palette-gray-400); + + /* 타이포 — 역할 이름 7단계. 크기 + 줄간격을 함께 정한다. (docs/foundations.md §5) + 작은 단계는 base 에서 px 을 빼고, 큰 단계는 배율을 곱한다. 모두 "크게 보기"를 따라간다. */ + --text-*: initial; + --text-micro: calc(var(--font-size-base) - 3px); + --text-micro--line-height: 1.4; + --text-caption: calc(var(--font-size-base) - 2px); + --text-caption--line-height: 1.4; + --text-label: calc(var(--font-size-base) - 1px); + --text-label--line-height: 1.45; + --text-body: var(--font-size-base); + --text-body--line-height: 1.5; + --text-title: calc(var(--font-size-base) * 1.25); + --text-title--line-height: 1.4; + --text-heading: calc(var(--font-size-base) * 1.5); + --text-heading--line-height: 1.3; + --text-display: calc(var(--font-size-base) * 2); + --text-display--line-height: 1.2; + + /* Radius — 역할 이름. (docs/foundations.md §6) rounded-full / rounded-none 은 Tailwind 정적 유틸리티 */ + --radius-*: initial; + --radius-inline: 4px; + --radius-control: 6px; + --radius-card: 8px; + --radius-panel: 12px; + --radius-shell: 16px; + + /* Elevation — 떠서 다른 콘텐츠를 가리는 요소 전용 하나만. 새 그림자는 문서에 먼저 등록한다. */ + --shadow-*: initial; + --shadow-overlay: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + + /* 모션 (docs/foundations.md §10) + hover 색·셰브런 회전 같은 작은 전환은 transition-* 만 쓰면 기본값(fast)이 붙는다. + 화면에 나타나고 움직이는 것(토스트·드로어)은 duration-base + ease-enter. */ + --ease-*: initial; + --ease-standard: cubic-bezier(0.2, 0, 0, 1); + --ease-enter: cubic-bezier(0, 0, 0.2, 1); + --ease-exit: cubic-bezier(0.4, 0, 1, 1); + --default-transition-duration: var(--motion-fast); + --default-transition-timing-function: var(--ease-standard); +} + +@utility duration-base { + transition-duration: var(--motion-base); } @layer base { @@ -101,8 +159,8 @@ font-family: 'Pretendard Variable', 'Pretendard', 'Noto Sans KR', 'Apple SD Gothic Neo', 'Segoe UI', sans-serif; - color: var(--color-text-base); - background-color: var(--color-app-bg); + color: var(--color-fg-default); + background-color: var(--color-canvas); background-image: none; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; @@ -132,7 +190,7 @@ /* Global scrollbar: 6px thumb + 2px inset on both sides, transparent track, radius 8px */ * { scrollbar-width: thin; - scrollbar-color: var(--color-zinc-200) transparent; + scrollbar-color: var(--color-scrollbar-track) transparent; } *::-webkit-scrollbar { @@ -147,7 +205,7 @@ } *::-webkit-scrollbar-thumb { - background-color: var(--color-zinc-400); + background-color: var(--color-scrollbar-thumb); border: 2px solid transparent; background-clip: content-box; border-radius: 8px; @@ -201,7 +259,7 @@ font-style: italic; } .markdown-answer a { - color: var(--color-accent-strong); + color: var(--color-fg-primary); text-decoration: underline; text-underline-offset: 2px; } @@ -223,7 +281,7 @@ margin: 0.5em 0; padding: 0.4em 0.8em; border-left: 3px solid var(--color-line); - color: var(--color-text-subtle); + color: var(--color-fg-subtle); } .markdown-answer hr { margin: 0.8em 0; diff --git a/src/renderer/src/assets/overflow-icon.png b/src/renderer/src/assets/overflow-icon.png deleted file mode 100644 index 5fb0173..0000000 Binary files a/src/renderer/src/assets/overflow-icon.png and /dev/null differ diff --git a/src/renderer/src/components/feature/CreateChatModal.stories.tsx b/src/renderer/src/components/feature/CreateChatModal.stories.tsx deleted file mode 100644 index 8548473..0000000 --- a/src/renderer/src/components/feature/CreateChatModal.stories.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React, { useState } from 'react'; -import type { Meta, StoryObj } from '@storybook/react-vite'; -import { Button } from '../ui/Button'; -import { CreateChatModal } from './CreateChatModal'; - -const meta = { - title: 'Components/Feature/CreateChatModal', - component: CreateChatModal, - tags: ['autodocs'], - parameters: { - layout: 'fullscreen' - }, - args: { - open: true, - type: 'personal', - isSubmitting: false, - onClose: () => undefined, - onSubmit: () => undefined - }, - argTypes: { - type: { - options: ['personal', 'team'], - control: { type: 'inline-radio' } - }, - onClose: { action: 'close', control: false }, - onSubmit: { action: 'submit', control: false } - } -} satisfies Meta ; - -export default meta; -type Story = StoryObj ; - -export const Default: Story = {}; - -export const Team: Story = { - args: { - type: 'team' - } -}; - -const InteractiveExample = (): React.JSX.Element => { - const [open, setOpen] = useState(false); - - return ( - - -- ); -}; - -export const Interactive: Story = { - render: () =>setOpen(false)} - onSubmit={() => setOpen(false)} - /> - -}; diff --git a/src/renderer/src/components/feature/CreateChatModal.tsx b/src/renderer/src/components/feature/CreateChatModal.tsx deleted file mode 100644 index 5911a2e..0000000 --- a/src/renderer/src/components/feature/CreateChatModal.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { useMemo, useState } from 'react'; -import { Button } from '../ui/Button'; -import { OverlayModal } from '../ui/OverlayModal'; - -type Props = { - open: boolean; - type: 'personal' | 'team' | null; - onClose: () => void; - onSubmit: (input: { type: 'personal' | 'team'; name: string }) => void; - isSubmitting?: boolean; -}; - -export const CreateChatModal = ({ - open, - type, - onClose, - onSubmit, - isSubmitting -}: Props): React.JSX.Element | null => { - const [name, setName] = useState(''); - const [touched, setTouched] = useState(false); - - const title = type === 'team' ? '팀 채팅 생성' : '개인 채팅 생성'; - const placeholder = type === 'team' ? '새 팀 채팅' : '새 개인 채팅'; - - const nameError = useMemo(() => { - if (!touched) return ''; - if (!name.trim()) return '채팅 이름을 입력해주세요.'; - return ''; - }, [name, touched]); - - if (!type) return null; - - const resetAndClose = (): void => { - setName(''); - setTouched(false); - onClose(); - }; - - return ( - - - - ); -}; diff --git a/src/renderer/src/components/feature/CreateProjectModal.tsx b/src/renderer/src/components/feature/CreateProjectModal.tsx index db00677..862de54 100644 --- a/src/renderer/src/components/feature/CreateProjectModal.tsx +++ b/src/renderer/src/components/feature/CreateProjectModal.tsx @@ -8,7 +8,6 @@ import { useGetProjectSyncStatus, usePostProjects } from '../../api/auth/useProj import { handleApiError } from '../../api/axios'; import { friendlyErrorMessage } from '../../api/errorMessages'; import type { GithubOauthDeviceStartCreateData } from '../../api/generated/data-contracts'; -import { useToast } from '../../hooks/useToast'; import { clearCachedOauthFlow, readCachedOauthFlow, @@ -18,6 +17,8 @@ import { navigate } from '../../lib/hashRouter'; import { Button } from '../ui/Button'; import { InlineAlert } from '../ui/InlineAlert'; import { OverlayModal } from '../ui/OverlayModal'; +import { TextField } from '../ui/TextField'; +import { StateMessage } from '../ui/StateMessage'; type Props = { open: boolean; @@ -30,7 +31,6 @@ const PROJECT_NAME_MAX = 50; const PROJECT_DESCRIPTION_MAX = 200; export const CreateProjectModal = ({ open, onClose }: Props): React.JSX.Element | null => { - const toast = useToast(); const create = usePostProjects(); const startGithubOauth = usePostGithubOauthDeviceStart(); @@ -81,14 +81,14 @@ export const CreateProjectModal = ({ open, onClose }: Props): React.JSX.Element // 서버 createProjectBodySchema 와 같은 한도다(명세 A-3). maxLength 가 51자째를 // 막아주지만 2자 미만은 입력 단계에서 막을 수 없어 문구로 알린다. if (name.trim().length < PROJECT_NAME_MIN) - return `프로젝트 이름은 ${PROJECT_NAME_MIN}자 이상이어야 합니다.`; + return `프로젝트 이름은 ${PROJECT_NAME_MIN}자 이상이어야 해요.`; return ''; }, [name, touched.name]); const repoError = useMemo(() => { if (!touched.repo) return ''; if (!activeOauthFlow) return 'GitHub 인증을 시작해주세요.'; - if (!isAuthorized) return 'GitHub 인증이 완료되어야 합니다.'; + if (!isAuthorized) return 'GitHub 인증을 먼저 완료해주세요.'; if (!selectedRepo) return '연결할 저장소를 선택해주세요.'; return ''; }, [activeOauthFlow, isAuthorized, selectedRepo, touched.repo]); @@ -168,8 +168,8 @@ export const CreateProjectModal = ({ open, onClose }: Props): React.JSX.Element const stepTone = (ready: boolean): string => ready - ? 'border-emerald-300 bg-emerald-50 text-emerald-700' - : 'border-line bg-surface-muted text-text-soft'; + ? 'border-line-success bg-success-soft text-fg-success' + : 'border-line bg-surface-muted text-fg-muted'; const closeAndReset = (): void => { setName(''); @@ -180,6 +180,8 @@ export const CreateProjectModal = ({ open, onClose }: Props): React.JSX.Element setCreatedProjectId(''); autoHandledFlowRef.current = null; startedFlowRef.current = null; + startGithubOauth.reset(); + create.reset(); onClose(); }; @@ -190,20 +192,12 @@ export const CreateProjectModal = ({ open, onClose }: Props): React.JSX.Element setSelectedRepoFullName(''); saveCachedOauthFlow(data); startedFlowRef.current = data.data.flowId; - }, - onError: (error) => { - toast.error(friendlyErrorMessage(error, 'github.connect')); } }); }; return ( -+ ++ ) : null} + + {create.isError ? ( ++ {friendlyErrorMessage(startGithubOauth.error, 'github.connect').description} + +++ ) : null} + {createdProjectId ? (+ {friendlyErrorMessage(create.error, 'project.create').description} + +- 프로젝트가 생성되었습니다. ID: {createdProjectId} + 프로젝트를 만들었어요. ID: {createdProjectId} {syncStatus.data?.data.latestJob?.errorMessage ? ` (${syncStatus.data.data.latestJob.errorMessage})` : ''} diff --git a/src/renderer/src/components/feature/digest/DigestSharedCard.tsx b/src/renderer/src/components/feature/digest/DigestSharedCard.tsx index 601b523..317dd63 100644 --- a/src/renderer/src/components/feature/digest/DigestSharedCard.tsx +++ b/src/renderer/src/components/feature/digest/DigestSharedCard.tsx @@ -3,6 +3,9 @@ import { Button } from '../../ui/Button'; import { ChatItemMenu, type ChatItemMenuAction } from '../../ui/ChatItemMenu'; import { Icon } from '../../ui/Icon'; import { MarkdownAnswer } from '../../ui/MarkdownAnswer'; +import { SourceList } from '../../ui/SourceList'; +import { extractReferenceLines, mergeSources } from '../../../lib/inlineSources'; +import { Avatar } from '../../ui/Avatar'; // 팀채팅에 도착한 개인채팅 답변 요약 공유 카드. // 일반 팀채팅 메시지(회색/유저 말풍선)와 시각적으로 구분해 카드 형태로 렌더한다. @@ -14,7 +17,6 @@ type Props = { content: string; sources: SourceItem[]; senderName: string; - senderInitial?: string; createdAt: string; isMe: boolean; hasSourceLink?: boolean; @@ -34,7 +36,6 @@ export const DigestSharedCard = ({ content, sources, senderName, - senderInitial, createdAt, isMe, hasSourceLink, @@ -43,7 +44,6 @@ export const DigestSharedCard = ({ isDeleting }: Props): React.JSX.Element => { const time = formatTime(createdAt); - const initial = (senderInitial ?? senderName.charAt(0) ?? '?').toUpperCase(); const menuActions: ChatItemMenuAction[] = isMe ? [ @@ -58,56 +58,35 @@ export const DigestSharedCard = ({ ] : []; + // 요약 본문의 '근거 코드' 목록은 SourceList 와 중복이라 걷어낸다. 문장 속 참조는 둔다. + const body = extractReferenceLines(content); + return ( -- {initial} -+ -- ++-팀 공유 - {senderName} - {time ? · {time} : null} + {senderName} + {time ? · {time} : null} {menuActions.length > 0 ? ( ) : null}- + + - {sources.length > 0 ? ( - - - ) : null} +- 참조 코드 {sources.length}개 -
-- {sources.map((s, idx) => ( -
-- - -
- ))} -{s.filePath}- - - ({s.startLine ?? '-'}-{s.endLine ?? '-'}) - -