diff --git a/package-lock.json b/package-lock.json index 458c817..1206d49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,15 @@ "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.8", + "@tiptap/core": "3.32.0", + "@tiptap/extension-highlight": "3.32.0", + "@tiptap/extension-list": "3.32.0", + "@tiptap/extension-subscript": "3.32.0", + "@tiptap/extension-superscript": "3.32.0", + "@tiptap/extension-table": "3.32.0", + "@tiptap/extension-text-align": "3.32.0", + "@tiptap/extension-text-style": "3.32.0", + "@tiptap/pm": "3.32.0", "@tiptap/react": "^3.32.0", "@tiptap/starter-kit": "^3.32.0", "@types/node": "^22.20.5", @@ -2188,6 +2197,20 @@ "@tiptap/core": "3.32.0" } }, + "node_modules/@tiptap/extension-highlight": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.32.0.tgz", + "integrity": "sha512-6q75QUC3iT7+TlMLhBSJASlpyO/f+Y22tB68hcdbg5hFW8fl8zv8np/oRzxoeAnis+4FZw9D5WfhOdwu1C+MWw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0" + } + }, "node_modules/@tiptap/extension-horizontal-rule": { "version": "3.32.0", "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.32.0.tgz", @@ -2320,6 +2343,51 @@ "@tiptap/core": "3.32.0" } }, + "node_modules/@tiptap/extension-subscript": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-subscript/-/extension-subscript-3.32.0.tgz", + "integrity": "sha512-f1NeIyTB3AkQTyC9xM8QVFDPsGekwJqHhcMfd5TW/sDv6BV39yT2zAjRjEICAEx298cJt8AnNflxvFs5A3mTfQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0", + "@tiptap/pm": "3.32.0" + } + }, + "node_modules/@tiptap/extension-superscript": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-superscript/-/extension-superscript-3.32.0.tgz", + "integrity": "sha512-4Xv6yTVEnn9rPa6eZ18q4wqw/pGTkw3aNI5aaCrder8XH5uuO5HfL42CPhRKWWFaer/WPwEL1lkUlskgbx+lLg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0", + "@tiptap/pm": "3.32.0" + } + }, + "node_modules/@tiptap/extension-table": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.32.0.tgz", + "integrity": "sha512-uOqCzWzxXvMccapZo+1CDxp3JGegxqNl11Lj8XiiWLxwmg81B/bKwMJW7+YlIdffgGNYJETk5YwL8T0IVBD2QQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0", + "@tiptap/pm": "3.32.0" + } + }, "node_modules/@tiptap/extension-text": { "version": "3.32.0", "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.32.0.tgz", @@ -2334,6 +2402,34 @@ "@tiptap/core": "3.32.0" } }, + "node_modules/@tiptap/extension-text-align": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.32.0.tgz", + "integrity": "sha512-DdV/V86ds9VtQ1MkbovgI28xwSvOW03I8SexsaKljGqSN/HovBXEjlefSnbK4tlWmQLJPH9eNsxRcpHJMftYCA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0" + } + }, + "node_modules/@tiptap/extension-text-style": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.32.0.tgz", + "integrity": "sha512-+U8FkrNAU45+fHgo4DYfndS/WuhhyttZ+zTyObF1W4VJ8wDiAYqXkl71nQcmMJyyK47yeNUY9CfjUomIaEHhpA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0" + } + }, "node_modules/@tiptap/extension-underline": { "version": "3.32.0", "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.32.0.tgz", diff --git a/package.json b/package.json index 8dd1ef0..674061c 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "site:preview": "vite preview --config site/vite.config.ts", "site:check": "tsc -p site/tsconfig.json", "site:test": "vitest run --config site/vitest.config.ts", - "site:icons": "node site/scripts/generate-icons.mjs" + "site:icons": "node site/scripts/generate-icons.mjs", + "site:sizes": "node site/scripts/measure-samples.mjs" }, "devDependencies": { "@astryxdesign/core": "^0.5.2", @@ -31,6 +32,15 @@ "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.8", + "@tiptap/core": "3.32.0", + "@tiptap/extension-highlight": "3.32.0", + "@tiptap/extension-list": "3.32.0", + "@tiptap/extension-subscript": "3.32.0", + "@tiptap/extension-superscript": "3.32.0", + "@tiptap/extension-table": "3.32.0", + "@tiptap/extension-text-align": "3.32.0", + "@tiptap/extension-text-style": "3.32.0", + "@tiptap/pm": "3.32.0", "@tiptap/react": "^3.32.0", "@tiptap/starter-kit": "^3.32.0", "@types/node": "^22.20.5", diff --git a/plugins/webcraft/dist/claude/plugins/webcraft/.claude-plugin/plugin.json b/plugins/webcraft/dist/claude/plugins/webcraft/.claude-plugin/plugin.json index 12dade8..2a02081 100644 --- a/plugins/webcraft/dist/claude/plugins/webcraft/.claude-plugin/plugin.json +++ b/plugins/webcraft/dist/claude/plugins/webcraft/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "webcraft", - "version": "0.9.1", + "version": "0.10.0", "description": "웹 플랫폼 개발 지침(프론트·디자인 시스템·백엔드·API·DB·아키텍처)과 프로젝트 연결", "author": { "name": "dev-goraebap" diff --git a/plugins/webcraft/dist/claude/plugins/webcraft/package.json b/plugins/webcraft/dist/claude/plugins/webcraft/package.json index 492e626..e1cbf31 100644 --- a/plugins/webcraft/dist/claude/plugins/webcraft/package.json +++ b/plugins/webcraft/dist/claude/plugins/webcraft/package.json @@ -1,6 +1,6 @@ { "name": "webcraft", - "version": "0.9.1", + "version": "0.10.0", "private": true, "type": "module" } diff --git a/plugins/webcraft/dist/claude/plugins/webcraft/skills/architecture/references/product-plugins.md b/plugins/webcraft/dist/claude/plugins/webcraft/skills/architecture/references/product-plugins.md index 388bea1..f416749 100644 --- a/plugins/webcraft/dist/claude/plugins/webcraft/skills/architecture/references/product-plugins.md +++ b/plugins/webcraft/dist/claude/plugins/webcraft/skills/architecture/references/product-plugins.md @@ -18,6 +18,14 @@ arc42: "8.1" 주소·인증·설정 입력 방식은 각 에이전트의 현재 지원을 확인합니다. 한 에이전트의 변수 치환이나 비밀값 저장 방식이 다른 에이전트에서도 작동한다고 가정하지 않습니다. 필요한 설정만 온보딩에서 확인하고 토큰은 지원되는 로컬 비밀 저장 수단으로 받습니다. 설치 명령이 성공한 뒤에는 실제 연결과 사용자 조회까지 별도로 확인합니다. +## 개선 피드백 받기 + +사용자에게 MCP를 제공하는 제품은 피드백 접수처(예: 이슈 등록 도구, 접수 양식)와 제출 방식을 정하고, 사용자용 스킬에 그 경로를 적습니다. Webcraft를 쓰지 않는 사용자의 에이전트는 Webcraft 지침을 읽지 않습니다. 판단 기준은 [도구의 부족을 발견했을 때](../../goraebap-mode/references/tool-improvements.md)를 따르되, 사용자용 스킬에는 짧은 요약과 그 제품의 접수처·인증 방식만 둡니다. + +MCP 서버를 만드는 제품은 서버 안내(instructions)에도 같은 짧은 요약을 둘 수 있습니다. 플러그인이 없는 에이전트에도 원칙이 닿게 하기 위한 선택지입니다. 스킬과 서버 안내에 긴 절차를 각각 복제하지 않습니다. + +Webcraft가 모든 MCP 서버를 관리한다고 가정하지 않습니다. + ## 변경과 검증 MCP 도구가 바뀌면 서버 계약과 사용자용 설명을 함께 맞춥니다. 스킬에 없는 도구나 아직 제공하지 않는 기능을 약속하지 않습니다. 패키지 버전과 서버의 호환 범위, 이름이 바뀐 경우 기존 사용자의 전환 방법을 기록합니다. diff --git a/plugins/webcraft/dist/claude/plugins/webcraft/skills/backend/SKILL.md b/plugins/webcraft/dist/claude/plugins/webcraft/skills/backend/SKILL.md index 87d7a2b..53ee8f8 100644 --- a/plugins/webcraft/dist/claude/plugins/webcraft/skills/backend/SKILL.md +++ b/plugins/webcraft/dist/claude/plugins/webcraft/skills/backend/SKILL.md @@ -15,6 +15,7 @@ arc42: "8.2, 5, 2" - [조회 포트](references/queries.md): 화면 조회의 소유권·결과 타입·조립 경로를 정할 때 읽습니다. - [모듈 간 조회](references/cross-module-read.md): 다른 모듈의 표시값·검색·정렬이 필요할 때 읽습니다. - [도메인 오류와 서비스 오류](references/errors.md): 규칙 위반·부재·권한·충돌을 처리할 때 읽습니다. +- [사용자가 올린 파일과 고른 주소](references/untrusted-content.md): 올린 HTML을 화면 안에 보이거나, 사용자가 준 주소를 서버가 읽을 때 읽습니다. - [코드 규칙](references/code-style.md): Java 파일·DTO·의존성을 작성할 때 읽습니다. 완료 시 프로젝트의 정적 검사·테스트와 변경한 유스케이스의 성공·실패·권한을 검증합니다. HTTP 계약이 바뀌면 명세·생성 타입·호출자도 함께 맞춥니다. diff --git a/plugins/webcraft/dist/claude/plugins/webcraft/skills/backend/references/untrusted-content.md b/plugins/webcraft/dist/claude/plugins/webcraft/skills/backend/references/untrusted-content.md new file mode 100644 index 0000000..e55780a --- /dev/null +++ b/plugins/webcraft/dist/claude/plugins/webcraft/skills/backend/references/untrusted-content.md @@ -0,0 +1,51 @@ +--- +arc42: "8.2" +--- + +# 사용자가 올린 파일과 고른 주소 + +사용자가 올린 HTML을 본문 안에 미리 보이거나, 사용자가 고른 웹 주소를 서버가 읽어(링크 카드 등) 보여 줄 때 적용합니다. 둘 다 남의 내용을 우리 출처나 우리 서버 망에서 실행·호출하게 되는 일이라 따로 경계를 둡니다. + +## 올린 HTML을 틀 안에 보이기 + +저장소의 서명 주소(presigned URL)나 `/api/**` 응답으로는 화면 안 틀(`iframe`)에 보일 수 없습니다. API 응답에는 `X-Frame-Options: DENY`가 붙고, 저장소 주소는 응답 헤더를 우리가 정할 수 없기 때문입니다. 그래서 앱이 전용 주소로 직접 내보냅니다. 예: `GET /api/embeds/{exp}.{sig}/{key}`. `{key}`는 저장 키를 URL-safe Base64로 쓴 것, `{exp}`는 만료 시각(초), `{sig}`는 서명입니다. + +| 항목 | 기준 | +| --- | --- | +| 서명 | 서버 비밀키로 HMAC-SHA256을 계산하고, 서명 대상에 용도 문자열(예: `html-embed`)·만료 시각·키를 함께 넣습니다. 용도 문자열이 있어 다른 서명과 섞여 쓰이지 않습니다. 비교는 시간이 일정한 비교(`MessageDigest.isEqual`)로 합니다 | +| 만료 | 1시간. 만료 시각을 10분 단위로 올려 가까운 시각에 만든 주소가 같아지게 하여 브라우저가 캐시할 수 있게 합니다. 본문을 다시 읽으면 새 주소를 받습니다 | +| 권한 | 서명한 주소가 곧 권한입니다(서명 주소와 같음). 권한 확인은 기존 파일 열기 주소(예: 모듈의 `…/files/{id}`)가 하고, HTML을 제자리에서 열 때만 전용 주소로 돌려보냅니다. 내려받기는 기존 경로를 씁니다 | +| 응답 | `Content-Type: text/html;charset=UTF-8`, `Cache-Control: private, max-age=300`. 서명이 틀리거나 만료됐거나 객체가 없으면 404입니다. 이 주소는 브라우저가 돌려보내기로만 오므로 OpenAPI 문서에서 뺍니다 | + +보안 체인(Spring Security)은 그 주소만의 `SecurityFilterChain`을 다른 체인보다 앞(`@Order(0)`, `securityMatcher("/api/embeds/**")`)에 둡니다. + +- GET·HEAD만 허용하고 나머지는 `denyAll`입니다. +- 세션 없음(`STATELESS`), CSRF·폼 로그인·HTTP Basic·로그아웃·요청 캐시를 끕니다. +- `X-Frame-Options`를 끕니다. +- `Content-Security-Policy: sandbox allow-scripts allow-modals allow-forms allow-popups; frame-ancestors 'self'`, `Referrer-Policy: no-referrer`를 붙입니다. + +응답 문서는 출처가 없는 상태(sandbox, `allow-same-origin` 없음)로 실행되어 이 앱의 쿠키·저장소·API에 닿지 못하고, 우리 화면 안 틀에서만 열립니다. 주소를 새 탭으로 바로 열어도 같은 정책이 붙습니다. + +화면 쪽 틀도 같은 격리를 겁니다: `sandbox="allow-scripts allow-modals allow-forms allow-popups"`, `referrerPolicy="no-referrer"`. 크게 보기 창의 "새 탭에서 열기"는 서버 주소일 때만 둡니다. 저장 전의 `blob:` 주소는 새 탭에서 앱의 출처로 열리기 때문입니다. + +서명·만료·변조된 서명·다른 키·잘못된 형식을 단위 테스트로 확인합니다. + +## 사용자가 고른 주소를 서버가 읽기 + +사용자가 고른 주소로 서버가 안쪽 망(사내 서비스, 클라우드 메타데이터)을 부르지 않게 합니다(SSRF 막기). + +받는 주소는 HTTPS, 포트 443(생략 포함)만입니다. 사용자 정보(`user:pass@`)가 든 주소, 2000자 넘는 주소는 거부합니다. + +호스트의 모든 IP가 공개 주소일 때만 부릅니다. 막는 대역은 미지정(`0.0.0.0/8`, `::`), 루프백, 링크 로컬, 사설(site local), 멀티캐스트, CGNAT 공유 대역 `100.64.0.0/10`, 벤치마크 `198.18.0.0/15`, 예약 `240.0.0.0/4`, IPv6 고유 로컬 `fc00::/7`입니다. IPv4를 품은 IPv6(IPv4-mapped `::ffff:a.b.c.d`, IPv4-compatible)와 NAT64 `64:ff9b::/96`은 안의 IPv4로 다시 판정합니다. + +리다이렉트는 자동으로 따르지 않습니다(`HttpClient.Redirect.NEVER`). `Location`을 직접 풀어 다음 주소에도 같은 검사를 하고 최대 3번까지 따릅니다. + +연결·응답은 각 5초이고, 상태 200이고 `Content-Type`이 HTML일 때만 읽습니다. 본문은 앞 512KB만 읽습니다(메타데이터는 `head`에 있음). 쿠키·토큰 등 우리 쪽 인증을 보내지 않습니다. + +검사할 때 이름을 해석하고 연결할 때 다시 해석하면, 그 사이에 DNS 답이 바뀌는 공격(DNS rebinding)은 위 검사로 막지 못합니다. 검사한 IP로 연결을 고정하거나, 안쪽 망으로 나가지 못하는 출구 프록시·방화벽으로 막습니다. 둘 다 못 하면 그 한계를 프로젝트 기록에 남깁니다. + +결과는 제목·설명·대표 사진·사이트 이름을 `og:*`, `twitter:*`, `description`, `` 순으로 읽습니다. 제목은 200자, 설명은 300자에서 자릅니다. 대표 사진 주소는 페이지 주소 기준으로 풀어 HTTPS일 때만 받습니다(HTTP·`data:`는 사진 없음). 대표 사진은 읽는 사람의 브라우저가 그 사이트에서 직접 받으며, 화면은 `referrerPolicy="no-referrer"`로 받습니다. + +결과는 6시간 기억합니다(최대 1000개, 오래 안 쓴 것부터 버림). 읽지 못한 주소는 오류 대신 주소(와 호스트)만 있는 카드로 돌려줘 본문이 죽은 링크로 깨지지 않게 합니다. 외부 사이트를 기다리는 동안 DB 트랜잭션을 잡지 않습니다(트랜잭션 서비스가 아닌 컴포넌트로 둠). + +공개·사설·루프백·CGNAT·IPv4-mapped·NAT64·포트·스킴 판정, 리다이렉트 횟수, 메타 읽기·자르기·엔티티 풀기를 단위 테스트로 확인합니다. diff --git a/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/SKILL.md b/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/SKILL.md index b9a7690..c9b43b4 100644 --- a/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/SKILL.md +++ b/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/SKILL.md @@ -32,6 +32,7 @@ arc42: "8.5, 2" | 대기·빈 상태·실패·전환 | [데이터 상태](references/data-states.md) | | 표면 고르기·누르는 대상·키보드 | [입력과 상호작용](references/interaction-patterns.md) | | UI 문구·본문 편집·검증 | [문구·본문과 검증](references/content-and-review.md) | +| 본문 인쇄·PDF 저장 | [인쇄와 PDF](references/print.md) | | 지침·코드·가이드 견본의 연결 | [패턴 카탈로그](references/pattern-catalog.md) | | 프로젝트 지정이 없는 기본 화면 | [레퍼런스 사용과 갱신](references/reference-kit.md) | diff --git a/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/notes.md b/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/notes.md index 47bbeb7..0038c56 100644 --- a/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/notes.md +++ b/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/notes.md @@ -63,3 +63,19 @@ arc42: "9" - 결정(사용자): 가이드를 Astro에서 키트와 같은 Vite + React + TanStack Router + MDX SPA로 옮겼습니다. Astro 때문에 둔 우회(셸 유지, 테마 스타일 붙잡기, 넘겨받을 때까지 감추기, 본문 옮겨 끼우기)가 사라지고, 전환은 라우터 대기 + 180ms 덮개 + 160ms 페이드로 키트 규칙 그대로 구현했습니다. 첫 화면은 키트의 bootCover·BootCover를 씁니다. - 사이트는 플러그인 지침 전체를 보여 줍니다. 지침 문서는 스킬 Markdown을 원본 그대로 빌드합니다(사본 없음). 문서별 정적 HTML(SSG)은 아직 없습니다. 공개할 때 프리렌더를 붙입니다. + +## 2026-11-10 · 사내 업무 앱의 리치 에디터와 화면 규칙 + +- 증상: 키트의 본문 칸은 자리를 보이는 Tiptap 예시뿐이라, 표·블록 메뉴·미디어처럼 실제 업무 본문에 필요한 동작을 지침과 견본으로 확인할 수 없었습니다. +- 수정: 사내 업무 앱에서 다듬은 리치 에디터(표·블록 메뉴·선택 서식 도구·링크 입력·올리는 칸·미디어·파일 카드·도표·HTML 임베드·링크 카드)를 키트 본문 칸으로 옮겼습니다. 키트 리비전 8입니다. +- 기존 규칙을 바꾼 것(이미 Webcraft를 따르는 프로젝트에 영향): 대화상자 안 여백을 데스크톱 40/24/12px로 바꿨습니다(전에는 1rem·0.5rem). 본문 편집을 상세 안 전환에서 전용 수정 화면으로 옮겼습니다. 너비를 조절하는 옆 목록의 기본 너비를 최대값으로 정했습니다(키트 사이드바 380px, 받은함 목록 560px). 본문 칸의 Tiptap 예시를 자리 표시에서 참고 구현으로 바꿨고, 본문의 1단계 제목을 쓰지 않게 했습니다. +- 원인(옮기며 겪은 함정): + 1. `@scope (…) { }` 안의 규칙은 같은 우선순위의 바깥 규칙을 이깁니다. 덮어쓰는 규칙은 같은 scope 안에 두거나 우선순위를 올립니다. + 2. 조상에 `transform`이 있으면 `position: fixed` 메뉴의 기준이 그 조상이 되어 엉뚱한 곳에 뜹니다. 손잡이 정렬에 transform 대신 margin을 씁니다. + 3. 표 열 너비 손잡이가 셀 밖으로 2px 나가 있으면 표를 감싼 칸에 스크롤바가 생겼다 사라지며 행이 흔들립니다. 손잡이는 셀 안에 두고 감싸는 칸은 `overflow-y: hidden`으로 둡니다. + 4. NodeView의 바깥 감싸개(`.react-renderer`)가 실제 블록이므로, 사진 나란히 놓기 같은 배치 규칙은 감싸개에 `:has()`로 겁니다. +- 키트 규칙 때문에 원본과 달라진 점: 색을 hex 대신 테마 변수로 저장합니다. Stone 테마의 색 토큰을 그대로 쓰면 색끼리 구분되지 않아 테마 색의 색상(hue)으로 값을 만들었습니다. 갈색은 테마에 없어 주황 색상을 낮은 채도로 씁니다. 메뉴·도구는 Astryx 부품(`Button`, `HStack`/`VStack`, `Kbd`, `Selector` 등)으로 그리고, 휴대폰 도구는 44px입니다. +- 원본에서 고친 결함: 포인터로 연 하위 메뉴가 누르면 닫히던 것, 블록 메뉴가 열린 채 Alt+↑/↓가 먹지 않던 것, 편집 전환만으로 "고친 내용 있음"이 되던 것. +- 키트에 넣지 않은 것: 토글·열 배치·수식·콜아웃·코드 강조, 댓글·버전 기록, 글꼴 설정 화면, 인쇄 화면. +- 확인: `npm run site:check`, `npm run site:test`(28개 파일, 349개 테스트 통과), `npm run site:build`, `npm test`가 통과했습니다. 가이드 사이트에서 1440 라이트·다크, 390 휴대폰으로 견본을 봤습니다. +- 범위: 실제 서버(올리기, 링크 미리보기, HTML 격리 응답), 터치 기기, 화면 낭독기, 2560 화면은 확인하지 않았습니다. diff --git a/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/app-layout.md b/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/app-layout.md index 9775105..0ba5d76 100644 --- a/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/app-layout.md +++ b/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/app-layout.md @@ -19,6 +19,8 @@ arc42: "8.6, 5" 열 때 340px, 220~380px로 끌어 조절합니다(큰 화면 배율만큼 함께 커집니다). 다른 제품·지침의 기본(250~320px)보다 넓은 것은 메뉴 이름과 하위 항목을 말줄임 없이 보이기 위한 선택입니다. 좁은 노트북에서 미리보기 패널과 함께 열 때 목록이 좁아지면 사용자가 사이드바를 줄이거나 접습니다. 머리 줄은 페이지 머리 줄과 같은 52px에 로고(홈 링크)·상자 버튼(전역 검색 등)·접기를 둡니다. 접으면 머리를 세로로 쌓고 접기 버튼은 바닥으로 옮기며, 사라진 묶음 이름 대신 선으로 묶음을 나눕니다. 접고 펼친 뒤 초점은 같은 버튼에 남습니다. +사용자가 끌어 너비를 바꿀 수 있는 옆 목록(사이드바, 받은함 목록, 문서의 페이지·목차 목록)은 처음에 최대 너비로 엽니다. 사용자가 줄인 너비를 저장하는 제품은 그 값을 우선합니다. 옆 목록의 이름이 처음부터 잘리지 않게 하고, 좁히는 것은 사용자가 고르게 하기 위해서입니다. + 메뉴 묶음은 이름으로 나누고 첫 묶음은 이름을 숨길 수 있습니다. 펼친 하위 항목도 상위 항목끼리와 같은 2px(0.125rem) 간격으로 띄웁니다(Astryx SideNav 기본은 하위 묶음 안 간격이 0입니다). 펼친 묶음 끝에는 `--spacing-2`(8px)를 더해 다음 상위 항목과 [경계를 보이고](spacing.md)(항목 사이 2px와 합쳐 10px), 접은 묶음에서는 이 간격이 함께 사라집니다. 하위 메뉴 묶음(`openGroups`)은 메뉴가 짧은 업무 앱에서는 모두 펼치는 `'all'`(기본)을, 묶음이 많은 문서 사이트에서는 현재 위치가 든 묶음만 펼치는 `'current'`를 씁니다. `'current'`에서는 다른 묶음으로 옮겨 가면 그 묶음도 펼치고, 사용자가 직접 연 묶음은 그대로 둡니다. 현재 항목은 주소의 가장 긴 접두사로 정하며 검색 조건으로 나뉜 메뉴는 조건까지 비교합니다. 선택 항목은 Bold Duotone입니다. 메뉴 글자는 보조 글자색으로 두고 포인터가 올라온 항목과 현재 항목만 본문 글자색으로 올립니다. 메뉴가 길어져도 무겁지 않고 현재 위치가 먼저 읽힙니다. 하단 탭과 더보기 시트도 같은 규칙입니다. 내 계정·관리처럼 따로 묶인 공간은 주 메뉴를 대체하고 위에 "← 공간 이름"을 둡니다. ## 휴대폰 diff --git a/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/composed-components.md b/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/composed-components.md index cd85c1c..d79cbb2 100644 --- a/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/composed-components.md +++ b/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/composed-components.md @@ -17,9 +17,11 @@ arc42: "8.8" | 행 순서 바꾸기 | 드래그 손잡이·`Alt`+방향키 이동·`Esc` 취소·위치 음성 안내 | [reorder](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/reorder) | | 파일 올리기 칸 | 고르기·끌어 놓기·형식과 크기 확인 | [upload](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/upload) | | 상태 화면 | 빈 목록·결과 없음·없는 대상·실패의 그림과 문구 | [state](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/state), [기준](data-states.md) | -| 본문 칸 | 종이 위의 리치 본문 | [editor](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/editor) | +| 본문 칸 | 종이 위의 리치 본문: 블록 메뉴·선택 서식 도구·표·미디어 | [editor](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/editor) | | 그룹 머리 | 그룹 이름·개수·접기, 스크롤에 붙는 띠 | [list-group](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/list-group) | +구현이 없는 조합을 메뉴에 빈 항목으로 만들지 않습니다. 제품 고유 조합(아이콘 표시 고르기, 전역 검색 팔레트, 프로필 패널)은 프로젝트가 소유합니다. + ## 곁에 여는 내용 버튼 곁에 여는 것은 데스크톱에서 팝오버, 휴대폰에서 언제나 바텀시트입니다. 휴대폰에 팝오버를 두지 않습니다. 내용은 고른 뒤 닫을 수 있도록 `close`를 받습니다. `AdaptivePopover`에 `isOpen`을 주면 바깥에서 열림을 제어하고 `onOpenChange`에서 변경을 받아 외부 상태에 반영합니다. 외부 제어 없이 최초에 열어 둘 때는 `defaultOpen`을 쓰며, `trigger`를 대신 클릭하는 우회는 쓰지 않습니다. 시트의 손잡이는 위 24px에 겹치므로 내용은 그 아래에서 시작하고 좌우 12px, 아래 20px을 씁니다. 이 여백은 한 부품(SheetBody)에 두어 시트마다 다시 쓰지 않습니다. 내용이 짧으면 시트가 화면 대부분을 차지하지 않게 하고(hug), 검색·긴 목록은 키보드를 고려해 높게(tall) 엽니다. @@ -50,6 +52,53 @@ arc42: "8.8" ## 본문 칸 -제목·본문 양식의 본문은 프로젝트가 채택한 리치에디터를 테두리와 서식 도구 막대 없이 종이처럼 둡니다. 서식은 에디터의 단축키·입력 규칙·선택 도구로 합니다. 앱 화면의 본문 토큰(13px, 15px `--text-large-size`)은 목록·도구가 빽빽한 화면용으로 남기고, 긴 글을 읽는 본문 칸과 상세 본문 글자는 데스크톱·휴대폰 모두 16px, 줄 높이 1.7이며 제목은 `--text-heading-2-size`입니다. 테마가 문단에 글자 크기와 줄 높이를 직접 주므로 문단·목록·인용이 크기와 줄 높이를 함께 상속하게 합니다. 쓰는 칸과 그 글을 읽는 상세 본문은 같은 크기로 둡니다. 키트의 Tiptap 예시는 자리를 보이기 위한 것이며 특정 에디터를 기본 의존성으로 복사하지 않습니다. +제목·본문 양식의 본문은 프로젝트가 채택한 리치에디터를 테두리와 서식 도구 막대 없이 종이처럼 둡니다. 서식은 에디터의 단축키·입력 규칙·선택 도구로 합니다. 앱 화면의 본문 토큰(13px, 15px `--text-large-size`)은 목록·도구가 빽빽한 화면용으로 남기고, 긴 글을 읽는 본문 칸과 상세 본문 글자는 데스크톱·휴대폰 모두 16px, 줄 높이 1.7이며 제목은 `--text-heading-2-size`입니다. 테마가 문단에 글자 크기와 줄 높이를 직접 주므로 문단·목록·인용이 크기와 줄 높이를 함께 상속하게 합니다. 쓰는 칸과 그 글을 읽는 상세 본문은 같은 크기로 둡니다. 키트의 본문 칸은 Tiptap 3(`@tiptap/*` 3.32, 표는 prosemirror-tables) 위에 만든 참고 구현이며, 쓰는 칸은 `RichDraft`, 읽는 본문은 `RichBody`로 씁니다. 프로젝트가 다른 에디터를 채택했으면 그 에디터로 같은 동작을 만듭니다. 키트 코드는 채택할 때만 프로젝트 소유 코드로 옮깁니다. -구현이 없는 조합을 메뉴에 빈 항목으로 만들지 않습니다. 제품 고유 조합(아이콘 표시 고르기, 전역 검색 팔레트, 프로필 패널)은 프로젝트가 소유합니다. +호스트는 올리기 함수(`upload`), 링크 미리보기 함수(`readPreview`), 링크로 고를 문서 목록(`links`), 앱 안 이동(`onNavigate`)을 줍니다. 키트에는 서버가 없어 견본은 가짜 함수를 씁니다. + +### 제목 + +화면은 늘 대상의 시스템 제목(제목 칸)을 보이므로 본문에서는 1단계 제목을 쓰지 않습니다. 제목이 둘이 되지 않게 하려는 것입니다. 본문 제목은 2·3·4단계이며 `# `를 입력해도 제목이 되지 않습니다. 예전 본문에 1단계 제목이 있으면 열 때 2단계로 내립니다. 인쇄·PDF의 표지와 절 제목도 시스템 제목을 씁니다([인쇄와 PDF](print.md)). + +### 블록 손잡이와 블록 메뉴 + +편집 중 블록에 포인터를 올리면 글줄 왼쪽(약 35px)에 손잡이가 뜹니다. 손잡이를 끌면 블록을 옮기고, 누르면 블록 메뉴가 열립니다. 목록 안의 글 위에서는 그 목록의 손잡이가 보입니다. 메뉴는 다음 묶음으로 나눕니다. + +- 색(글자색·배경색 하위 메뉴), 종류 바꾸기(하위 메뉴), 서식 지우기 +- 복제(Ctrl/⌘+D), 복사(Ctrl/⌘+C), 블록 링크 복사 +- 위로·아래로 옮기기(Alt+↑/↓) +- 지우기(Delete) + +메뉴를 열지 않고 블록을 고른 채 같은 단축키를 눌러도 동작합니다. 블록 링크는 지금 주소에 `#block-<id>`를 붙인 주소이며, 그 주소로 열면 그 블록으로 스크롤합니다. + +### 선택 서식 도구와 링크 + +글을 고르면 서식 도구 막대가 뜹니다. 순서는 블록 종류, 굵게·기울임·밑줄·취소선·코드, 링크, 색, 더 보기(위·아래 첨자, 정렬, 내어쓰기·들여쓰기, 서식 지우기)입니다. 막대는 창이 아니라 본문 칸의 좌우를 기준으로 위치를 잡습니다. 그래서 글줄 첫머리를 골라도 옆 메뉴 밑에 가리지 않습니다. 위 공간이 모자라면 아래로 뜹니다. + +링크는 대화상자 대신 서식 도구 아래의 입력 칸에서 겁니다. 주소를 넣거나(`example.com`이면 `https://`를 붙입니다) 호스트가 준 문서 목록(`links`)에서 이름으로 찾아 고릅니다. 고른 문서는 앱 안 주소로 걸립니다. 읽는 화면에서 앱 안 주소로 가는 링크는 새 탭 대신 그 화면으로 이동합니다(`RichBody`의 `onNavigate`). + +### 표 + +표는 모서리를 둥글리지 않고 바깥 양옆 세로선 없이 그리며, 머리 행에 옅은 바탕을 깝니다. 포인터가 있는 칸(없으면 커서가 있는 칸)의 행 왼쪽과 열 위에 손잡이가 뜹니다. 손잡이를 누르면 그 행·열의 메뉴가 열립니다. 메뉴에는 위·아래(열은 왼·오른쪽)에 넣기, 옮기기, 머리 행·열 켜기와 끄기, 지우기가 있습니다. 표 왼쪽 위 모서리의 단추는 표 전체의 메뉴(셀 병합·나누기·머리글·표 삭제)를 엽니다. 표 끝의 `+` 막대로 행·열을 더하고, 열 경계를 끌어 너비를 바꿉니다. 명령 하나가 실행 취소 한 단계입니다. + +읽기로 열었다가 편집으로 바꾸는 본문에서도 열 너비를 끌 수 있어야 합니다. 그래서 표 확장은 너비 조절을 늘 등록하고, 실행 취소 때 남은 열 너비를 지웁니다(`ResizableTable`, `SyncedTableView`). + +### 올리는 칸과 미디어 + +`/` 목록에서 파일이나 HTML 임베드를 고르면 파일 고르기를 바로 열지 않고 그 자리에 점선 칸을 띄웁니다. 칸을 누르면 파일 고르기가 열리고, 파일을 끌어 놓을 수도 있습니다. ✕나 Esc로 닫습니다. 칸은 문서에 저장되지 않는 화면 표시(decoration)라 그냥 닫아도 본문에 흔적이 남지 않습니다. HTML 칸은 `.html` 파일만 고릅니다. + +미디어 블록은 머리 띠·상자 틀·사진 아래 이름 줄 없이 그립니다. 포인터를 올리거나 초점이 들어오면 오른쪽 위에 도구(크게 보기·너비·내려받기)가 뜹니다. 사진은 원본 비율로 자리를 먼저 잡고 대표색으로 칠한 뒤 나타납니다. 너비를 25%나 50%로 줄인 사진끼리는 한 줄에 나란히 놓입니다. 파일은 확장자별 아이콘 글자와 색(PDF·워드·엑셀·발표 자료·압축·텍스트·코드·HTML 등)을 가진 카드로 보입니다. + +도표(Mermaid)는 칸 폭을 채우되 원래 크기의 3배까지, 높이 36rem 안에서 그립니다. 누르면 크게 보기 창으로 열리고, 원문 편집 칸은 테마 색을 따릅니다. Mermaid는 CSS 변수를 읽지 못하므로 도표 색표는 테마 파일의 값(`diagramColors`)으로 줍니다. + +HTML 임베드는 올린 HTML을 본문 안의 격리된 틀로 미리 봅니다. 틀은 `sandbox="allow-scripts allow-modals allow-forms allow-popups"`와 `referrerPolicy="no-referrer"`를 쓰고 `allow-same-origin`을 주지 않습니다. 크게 보기는 창을 거의 채우는 `<dialog>`에 같은 격리 틀을 두며, 닫기 단추와 Esc로 닫습니다. 새 탭 열기는 서버 주소일 때만 둡니다. 저장 전의 `blob:` 주소는 새 탭에서 앱의 출처로 열리기 때문입니다. 서버 쪽 규칙은 [사용자가 올린 파일과 고른 주소](../../backend/references/untrusted-content.md)를 따릅니다. + +### 링크 카드 + +링크 카드는 `/` 목록의 "링크 카드"로 넣는 웹 주소 블록입니다. 제목·설명·대표 사진을 카드로 보이고, 사진은 오른쪽에서 카드 높이를 채웁니다. 사진이 없거나 받지 못하면 같은 자리를 테두리로 나누고, 테마 강조색을 옅게(12%) 깐 바탕에 그림 아이콘과 "대표 이미지가 없습니다"를 둡니다. 읽는 화면에서는 카드 전체가 새 탭 링크입니다. 편집 화면에서는 카드를 누르면 블록이 선택되고, 열기와 주소 고치기는 위에 뜨는 도구로 합니다. 주소를 넣는 칸은 올리는 칸과 같은 점선 칸입니다. 미리보기는 서버가 읽어 주며 같은 backend 문서를 따릅니다. + +### 메뉴와 색 + +떠 있는 메뉴(블록 메뉴·선택 서식 도구·`/` 목록·표 메뉴)는 그림자를 줄이고 1px 테두리로 구분합니다. 본문 속 인라인 코드는 바탕 없이 고정폭 글자에 옅은 밑줄로 보입니다. + +글자색·배경색은 테마 색에서 만든 값(CSS 변수)으로 저장해 라이트·다크를 따릅니다. 다른 곳에서 가져온 본문에 직접 지정한 색(hex)은 그대로 보이지만 테마를 따르지 않습니다. diff --git a/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/content-and-review.md b/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/content-and-review.md index 7f9910d..37c16f9 100644 --- a/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/content-and-review.md +++ b/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/content-and-review.md @@ -23,9 +23,11 @@ arc42: "8.7" ## 읽기·편집 -본문은 읽기에서 시작하고 명시적인 편집 전환을 제공합니다. 편집기는 프로젝트 공통 UI를 통해 연결하고 저장 형식·첨부 수명주기·초안·버전 충돌을 제품 계약과 맞춥니다. 특정 편집기 소스를 기본 의존성으로 복사하지 않습니다. +본문은 읽기에서 시작하고 명시적인 편집 전환을 제공합니다. 상세의 편집 단추는 늘 그 대상의 수정 화면(자기 주소, 예: `…/edit`)을 엽니다. 상세 안에서 본문을 그 자리에서 편집 상태로 바꾸지 않습니다. 편집기는 프로젝트 공통 UI를 통해 연결하고 저장 형식·첨부 수명주기·초안·버전 충돌을 제품 계약과 맞춥니다. 특정 편집기 소스를 기본 의존성으로 복사하지 않습니다. -명시 저장을 선택한 제품은 저장·Ctrl/Command+S·편집 완료를 같은 저장 경로로 연결합니다. 저장 중·완료·실패를 구분하고 충돌 시 초안을 보존합니다. 프로젝트가 자동 저장을 정했으면 그 정책을 우선합니다. 읽기/편집 전환 때문에 불필요하게 편집기를 다시 만들어 Undo를 잃지 않습니다. +수정 화면에는 제목과 본문만 두고 속성 열과 활동(이력·댓글)은 두지 않습니다. 데스크톱은 머리 줄에 저장 상태 표시와 저장·취소·완료를 이 순서로 둡니다. 휴대폰은 바닥 줄에 저장·완료를 두고 머리 줄의 뒤로(←)가 취소입니다. 키트 부품은 `BodyEditPage`([코드](https://github.com/dev-goraebap/dev.goraebap/blob/main/site/src/shared/ui/patterns/compose/BodyEditPage.tsx))입니다. Ctrl/Command+S는 저장, 완료는 저장한 뒤 상세로 돌아가기, 취소는 저장하지 않고 상세로 돌아가기입니다. 고친 내용이 있으면 취소 전에 "저장하지 않은 내용이 있습니다"로 묻고 "버리고 나가기"와 "계속 작성"을 고르게 합니다. 고친 것이 없으면 묻지 않고 돌아갑니다. 읽는 화면과 고치는 화면을 나누면 상세의 속성·활동과 편집 상태가 섞이지 않고, 편집 중인 화면도 주소가 있어 새로고침·뒤로가기의 결과가 분명해집니다. + +명시 저장을 선택한 제품은 저장·Ctrl/Command+S·완료를 같은 저장 경로로 연결합니다. 저장 중·완료·실패를 구분하고 충돌 시 초안을 보존합니다. 프로젝트가 자동 저장을 정했으면 그 정책을 우선합니다. 저장할 때 불필요하게 편집기를 다시 만들어 Undo를 잃지 않습니다. 변경 기록과 댓글을 한 활동 영역에서 읽을 수 있게 하되 위치 댓글·인용 댓글·답글 깊이는 제품이 정합니다. 레퍼런스에 있던 활동 UI를 모든 제품의 필수 기능으로 추가하지 않습니다. diff --git a/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/dialog.md b/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/dialog.md index 9fe2e7c..44333c9 100644 --- a/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/dialog.md +++ b/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/dialog.md @@ -9,12 +9,14 @@ arc42: "8.6" | 항목 | 기준 | | --- | --- | | 위치 | 위에서 12dvh, 아래도 같은 여유. 최대 높이는 `100dvh - 2 × 12dvh` | -| 안 여백 | 좌우·바깥 1rem, 머리 아래·본문 위 0.5rem | +| 안 여백 | 데스크톱(64rem 이상)은 머리·본문·바닥의 좌우 40px(`--spacing-10`), 위아래 24px(`--spacing-6`), 머리 아래와 본문 위 12px(`--spacing-3`). 휴대폰(64rem 미만)은 좌우·바깥 1rem(16px), 머리 아래·본문 위 0.5rem. 모든 대화상자에 같은 값을 씁니다 | | 바닥 구분선 | 기본은 없습니다. 긴 본문과 바닥이 붙어 보일 때만 선택합니다 | | 첫 초점 | 제목(`tabIndex=-1`). 입력이 주인 양식은 첫 입력, 확인 창은 취소 | | 닫기 | 닫기 X·Esc·배경 누르기. 닫으면 초점이 연 요소로 돌아갑니다 | | 휴대폰 | 짧은 확인은 같은 틀, 양식은 전체 화면이나 페이지. 전체 화면은 안전 영역을 피합니다 | +안 여백을 한 값으로 정해 대화상자마다 여백이 달라지지 않게 합니다. 대화상자 안에서 리치 본문을 고칠 때 글줄 왼쪽 약 35px에 뜨는 블록 손잡이가 대화상자 테두리 밖으로 나가지 않는 것도 데스크톱 좌우 40px의 이유입니다. + 위치를 지정한 Dialog에 공통 margin을 더하면 top과 겹쳐 아래로 밀립니다. 위치와 남은 높이를 함께 정하고 짧은 화면에서도 닫기와 마지막 내용에 닿는지 확인합니다. 너비와 최소 크기는 쓰임별로 정하고 준비·실패·완료 상태에서 같은 기준을 씁니다. 비어 있는 로딩 틀의 크기를 내용의 고유 너비로 삼아 아주 작게 열지 않습니다. 긴 상세의 최소 높이는 작은 화면의 최대 높이를 넘지 않게 제한합니다. 짧은 확인 창과 본문이 있는 상세를 이유 없이 같은 크기로 강제하지 않습니다. diff --git a/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/layout-patterns.md b/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/layout-patterns.md index 0b81958..6c0b232 100644 --- a/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/layout-patterns.md +++ b/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/layout-patterns.md @@ -39,7 +39,7 @@ arc42: "8.6" 본문이 있는 모든 상세는 속성 열을 같은 순서로 둡니다. 본문에 제목이 있으면 맨 위에 "목차" 묶음, 그 아래 대상별 속성, 맨 끝에 "정보" 묶음(작성자 아바타·작성일·수정일과 고친 사람·버전 수)을 두고, 모르는 값은 줄째 뺍니다. 목차와 버전 줄도 다른 속성과 같은 속성 행 부품으로 그려 아이콘·글자 자리를 맞춥니다. 누르면 목차는 테두리 있는 팝오버, 버전은 버전 기록 대화상자를 엽니다. 휴대폰은 목차를 오른쪽 아래 떠 있는 둥근 버튼과 바텀시트로 둡니다. 편집기 가장자리에 목차 표시를 붙이지 않습니다. 상세의 더 보기(…) 메뉴는 페이지 머리 줄 한 곳에 둡니다. -본문 편집 도구는 페이지 머리 줄의 공통 자리에 둡니다. 읽기에서 시작하고 명시적인 편집 전환을 둡니다. 데스크톱 머리 줄의 편집 전환·편집 마침·버전 기록은 필터·정렬과 같은 둥근 아이콘 도구(`variant="tool"`, 아이콘만, 툴팁에 이름)로 두며, "편집" 같은 글자 버튼을 머리 줄에 두지 않습니다. 저장은 상태가 바뀌는 동작이라 글자를 유지하고, 휴대폰은 바닥 줄의 저장·완료를 그대로 둡니다. 버전 기록 목록은 표준 목록 행(아이콘·시각·설명, 선택 표시)으로 그리고, 주소를 가진 대화상자(라우트 마스킹 `…/history`, 새로 고침하면 다시 열림)로 엽니다. +본문 편집 도구는 페이지 머리 줄의 공통 자리에 둡니다. 상세에서는 본문을 그 자리에서 편집하지 않고, 편집 단추가 수정 화면을 엽니다. 상세 머리 줄의 편집(수정 화면 열기)·버전 기록은 필터·정렬과 같은 둥근 아이콘 도구(`variant="tool"`, 아이콘만, 툴팁에 이름)로 두며, "편집" 같은 글자 단추를 두지 않습니다. 저장·완료는 수정 화면의 머리 줄(휴대폰은 바닥 줄)에 있으며 [읽기·편집](content-and-review.md#읽기편집) 규칙을 따릅니다. 버전 기록 목록은 표준 목록 행(아이콘·시각·설명, 선택 표시)으로 그리고, 주소를 가진 대화상자(라우트 마스킹 `…/history`, 새로 고침하면 다시 열림)로 엽니다. 목차는 지금 읽는 제목을 선택해 보여 줍니다. 지금 읽는 제목은 기준선(스크롤 컨테이너 위쪽 + 그 제목의 `scroll-margin-top`)을 마지막으로 지난 제목입니다. 페이지 끝의 짧은 절은 끝까지 스크롤해도 기준선에 닿지 못해 이 규칙만으로는 선택되지 않고, "바닥이면 마지막 제목"을 덧붙이면 그 사이의 제목을 건너뜁니다. 흔히 보고되는 scrollspy 결함이므로 아래 두 규칙을 더합니다. 목차는 디자인 시스템의 outline 부품을 제어 모드(`activeId`, `onActiveIdChange`)로 쓰고, 선택 규칙만 작은 훅에 둡니다. tocbot 같은 라이브러리는 목차를 직접 그려 디자인 시스템 부품과 겹치므로 쓰지 않습니다. @@ -55,4 +55,4 @@ arc42: "8.6" ## 옆 패널 -주소가 필요 없는 짧은 조회(기록 한 줄의 사실)는 목록 옆 패널([WithPanel](https://github.com/dev-goraebap/dev.goraebap/blob/main/site/src/shared/ui/layouts/side-panel/WithPanel.tsx))로 엽니다. 데스크톱은 오른쪽 26rem 패널, 휴대폰은 바텀시트입니다. 편집이나 긴 내용은 자기 주소를 가진 상세로 엽니다. 목록과 상세를 나란히 두는 받은함은 목록 폭을 조절할 수 있게 하고 휴대폰에서는 목록만 보인 뒤 항목을 페이지로 엽니다. +주소가 필요 없는 짧은 조회(기록 한 줄의 사실)는 목록 옆 패널([WithPanel](https://github.com/dev-goraebap/dev.goraebap/blob/main/site/src/shared/ui/layouts/side-panel/WithPanel.tsx))로 엽니다. 데스크톱은 오른쪽 26rem 패널, 휴대폰은 바텀시트입니다. 편집이나 긴 내용은 자기 주소를 가진 상세로 엽니다. 목록과 상세를 나란히 두는 받은함은 목록 폭을 조절할 수 있게 하되 처음에는 최대 너비로 열고([사이드바](app-layout.md#사이드바)와 같은 규칙) 휴대폰에서는 목록만 보인 뒤 항목을 페이지로 엽니다. diff --git a/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/print.md b/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/print.md new file mode 100644 index 0000000..9a6fd00 --- /dev/null +++ b/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/print.md @@ -0,0 +1,45 @@ +--- +arc42: "8.7" +--- + +# 인쇄와 PDF + +문서·본문을 브라우저 인쇄로 PDF로 남기는 화면을 만들 때 읽습니다. + +## 제목과 덧붙임 + +인쇄 화면의 제목(표지와 각 절 제목)은 시스템이 가진 제목을 씁니다. 본문 안의 제목을 표지로 끌어 쓰지 않습니다. 표지·카드에 날짜·주소·키 같은 덧붙임을 넣지 않습니다. + +## 쪽 크기와 브라우저 머리글·바닥글 + +쪽 크기와 여백은 `@page`로 정합니다(예: `@page { size: A4; margin: 22mm 20mm 24mm; }`). 화면에서는 A4 폭의 읽기 열을 종이처럼 가운데 두고, 인쇄에서는 쪽 여백 안을 채웁니다. + +브라우저가 쪽 가장자리에 넣는 머리글·바닥글(날짜, 탭 제목, 주소)은 `@page`의 여백 상자로 지웁니다. Chrome은 글쓴이가 여백 상자를 둔 쪽(위·아래)에서만 자기 머리글·바닥글을 빼므로, 위에는 빈 상자를, 오른쪽 아래에는 쪽 번호를 둡니다. 그러면 인쇄 창의 "머리글과 바닥글"이 켜져 있어도 아무것도 붙지 않습니다. 첫 쪽(표지)은 쪽 번호도 비우되, 빈 상자는 남겨 브라우저 바닥글을 계속 막습니다. + +```css +@page { + size: A4; + margin: 22mm 20mm 24mm; + @top-center { content: ""; } + @bottom-right { content: counter(page) " / " counter(pages); } +} + +@page :first { + @bottom-right { content: ""; } +} +``` + +## 링크 + +앱 안으로 가는 링크(같은 출처의 주소)는 인쇄본에서 링크를 빼고 글자만 남깁니다. 종이와 PDF에서는 앱 안 주소를 열 수 없기 때문입니다. 바깥 주소(`http(s)`의 다른 출처, `mailto:`)는 남깁니다. + +## 쪽 나눔과 도표 크기 + +- 도표·사진·코드 블록(`figure`, `pre`)은 쪽 사이에서 자르지 않습니다(`break-inside: avoid`). +- 표는 쪽을 넘겨 이어 가되 행(`tr`)은 자르지 않습니다. 긴 표를 통째로 다음 쪽으로 밀면 앞쪽이 비기 때문입니다. +- 제목(`h1`~`h3`) 바로 뒤에서 쪽을 나누지 않습니다(`break-after: avoid`). +- 쪽보다 큰 도표는 비율을 지킨 채 쪽 높이의 약 2/3(`max-block-size: 160mm`, A4 기준) 안으로 줄입니다. + +## 화면 전용 요소 + +화면 전용 도구(복사·편집·크게 보기·내려받기 단추)는 인쇄에서 감춥니다. 편집기가 본문 끝에 늘 두는 빈 문단과 마지막 여백도 지웁니다. 쪽 끝에 걸치면 빈 쪽 하나가 더 생기기 때문입니다. diff --git a/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/typography.md b/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/typography.md index ae8d7cf..6af231f 100644 --- a/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/typography.md +++ b/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/typography.md @@ -19,6 +19,10 @@ arc42: "8.5" 한국어 업무 화면의 기본은 Pretendard Variable, 코드·고정폭은 JetBrains Mono입니다. 프로젝트가 글꼴 파일을 직접 제공하고 시스템 글꼴은 로딩 중 대체로만 씁니다. 날짜·수·금액처럼 위아래로 비교하는 값은 `hasTabularNumbers`(고정폭 숫자)로 자릿수를 맞추고 끝에 정렬합니다. +글꼴 고르기는 화면 설정(내 계정의 표시 설정) 한 곳에 둡니다. 상세 화면마다 글꼴 고르기를 두지 않습니다. 선택지는 고딕(기본, Pretendard)과 명조 두 가지입니다. 명조를 고르면 "본문만"(리치 본문·Markdown 본문을 읽고 쓰는 칸)과 "전체"(온 화면) 가운데 적용 범위를 고르며, 기본은 "본문만"입니다. "전체"는 `html`에 `data-font="serif"`를 붙이고 테마의 본문·제목 글꼴 변수(`--font-family-body`, `--font-family-heading`)를 명조로 바꿉니다. "본문만"이면 본문 칸이 스스로 명조로 씁니다. 이 설정은 브라우저에 저장하는 화면 설정이며 서버에 두지 않습니다. + +앱 화면의 보통 굵기는 450입니다. 명조 글꼴 파일의 굵기 구간을 일반 선언(500부터 SemiBold)대로 두면 온 화면이 굵게 보입니다. 앱용 이름의 `@font-face`를 따로 두어 굵기 구간을 다시 나눕니다(예: 100–499 Regular, 500–649 SemiBold, 650–900 Bold). 글꼴 파일은 쓰일 때만 받습니다(`font-display: swap`). + ## 줄과 넘침 목록의 제목·이름·경로는 한 줄에서 말줄임하고(`maxLines={1}`), 전체 값은 접근 가능한 이름이나 title로 남깁니다. 상세의 제목·본문·주소는 줄을 넘겨 가로로 넘치지 않게 합니다(`overflow-wrap: anywhere`, 부모의 `min-inline-size: 0`). 편집기의 한글 조합·커서·공백은 실제 입력으로 확인합니다. diff --git a/plugins/webcraft/dist/claude/plugins/webcraft/skills/goraebap-mode/SKILL.md b/plugins/webcraft/dist/claude/plugins/webcraft/skills/goraebap-mode/SKILL.md index 759c5ef..0251dc5 100644 --- a/plugins/webcraft/dist/claude/plugins/webcraft/skills/goraebap-mode/SKILL.md +++ b/plugins/webcraft/dist/claude/plugins/webcraft/skills/goraebap-mode/SKILL.md @@ -47,6 +47,7 @@ arc42: "none" - 데이터 소유권·입출력·실패 상태를 정한 뒤 구현합니다. 추측한 재사용을 위해 계층을 늘리지 않습니다. - 역할마다 기본값을 두되(글쓰기·에셋 역할), 도구와 모델은 사용자 설정과 실행 환경을 따르고 없으면 세션 모델을 씁니다. 다중 에이전트, 자동 커밋·병합을 전제하지 않습니다. - [검증](references/verification.md)에 따라 바뀐 부분을 확인합니다. 수행한 검증과 남은 제한을 구분해 답합니다. +- 작업 중 MCP·플러그인 도구의 기능 부족이나 반복 호출을 실제 스키마로 확인하면 우회만 설명하고 끝내지 않고 [도구의 부족을 발견했을 때](references/tool-improvements.md)를 따릅니다. - 커밋·푸시·게시·배포는 사용자가 허용한 범위에서만 합니다. 스킬 자체가 틀렸으면 [지침 개선](playbooks/evolve.md)으로 원인을 고칩니다. diff --git a/plugins/webcraft/dist/claude/plugins/webcraft/skills/goraebap-mode/references/tool-improvements.md b/plugins/webcraft/dist/claude/plugins/webcraft/skills/goraebap-mode/references/tool-improvements.md new file mode 100644 index 0000000..bbb9e17 --- /dev/null +++ b/plugins/webcraft/dist/claude/plugins/webcraft/skills/goraebap-mode/references/tool-improvements.md @@ -0,0 +1,47 @@ +--- +arc42: "none" +--- + +# 도구의 부족을 발견했을 때 + +작업 중 MCP·플러그인 도구의 기능 부족이나 사용 불편을 확인했을 때, 우회 방법만 설명하고 끝내지 않고 개선을 제안하는 기준입니다. Webcraft 지침 자체의 문제는 이 문서가 아니라 [지침 제안](../../manage-workflow/references/proposals.md)을 따릅니다. + +## 개선 후보 + +다음 경우를 개선 후보로 봅니다. + +- 필요한 도구가 없거나, 화면에서 되는 기능을 도구로 할 수 없을 때 +- 여러 대상을 위해 같은 호출을 반복해야 할 때 +- 도구의 이름·설명·인자·응답이 모호해 오해나 재조회를 만들 때 + +사소한 취향 차이나 확인하지 않은 불편은 제안하지 않습니다. + +## 제안하기 전에 확인한다 + +현재 연결의 실제 도구 목록과 인자 스키마를 읽습니다. 기존 도구의 배열 인자 등으로 이미 처리할 수 있으면 그 기능을 쓰고 개선안을 만들지 않습니다. + +인증 만료·권한 부족·일시적 연결 오류는 기능 누락과 구분합니다. 권한 우회나 접근 확대를 해결책으로 제안하지 않습니다. + +## 제안에 담을 것 + +하려던 작업, 확인한 제약이나 반복 단계, 추가·변경하면 나아질 동작을 짧게 씁니다. 반복 횟수와 비용은 실제로 확인한 만큼만 적습니다. + +## 누구에게 제안하는가 + +그 도구를 제공하는 제품의 개발 저장소에서 일하는 중이면 구현 범위와 기대 결과를 제안하고 직접 고칠지 묻습니다. 사용자가 이미 같은 범위를 승인했으면 다시 묻지 않습니다. 동의하면 저장소 지침대로 구현·검증하고, 커밋·푸시·배포는 허용된 범위만 합니다. + +일반 사용자가 도구로 업무를 하는 중이면 개발 환경이나 Git 접근을 요구하지 않습니다. 개선 피드백을 올릴지 묻고, 제품이 정한 접수 절차로 연결합니다. 프로젝트 이름이나 서비스를 쓴다는 사실만으로 제품 개발자라고 판단하지 않습니다. + +## 원래 작업은 계속한다 + +권한 있는 기존 방법으로 원래 작업을 끝낼 수 있으면 계속합니다. 사용자가 금지한 수단(예: 다른 브라우저)으로 바꾸거나 인증을 우회하지 않습니다. 개선 제안에 대한 동의를 원래 작업의 조건으로 만들지 않습니다. 사용자가 거절했거나 이미 처리 중인 같은 문제는 다시 제안하지 않습니다. + +## 피드백 제출의 경계 + +접수처와 제출 도구는 그 제품의 플러그인이나 프로젝트 정책이 정합니다. 모든 도구 개선을 Webcraft 저장소로 보내지 않고, 사용자의 업무 프로젝트에 피드백 이슈를 자동으로 만들지 않습니다. + +피드백 초안에는 하려던 작업, 확인한 제약, 도구·인자의 종류, 기대한 동작과 개선안, 플러그인 판과 에이전트 종류를 씁니다. 업무 데이터·실제 식별자·개인정보·토큰·서명 주소는 재현용 예시로 바꿉니다. 제출 전에 사용자가 내용과 대상을 검토하게 합니다. + +현재 권한과 제출 도구로 등록할 수 있을 때만 직접 등록합니다. 수단이 없으면 초안과 공식 제출 경로를 주고 아직 접수되지 않았다고 알립니다. 로컬에 파일로 남길 수 있으면 그 파일로 전달해도 됩니다. 실제 등록 응답이 없으면 등록했다고 말하지 않고, 다시 시도하기 전에 같은 피드백이 이미 접수됐는지 확인합니다. + +승인받은 범위만 수행합니다. 공개 이슈 제출만 승인했으면 제출만 하고, 구현·검증만 승인했으면 그것만 하고 배포로 넓히지 않습니다. diff --git a/plugins/webcraft/dist/codex/plugins/webcraft/.codex-plugin/plugin.json b/plugins/webcraft/dist/codex/plugins/webcraft/.codex-plugin/plugin.json index 7b25de9..c24867f 100644 --- a/plugins/webcraft/dist/codex/plugins/webcraft/.codex-plugin/plugin.json +++ b/plugins/webcraft/dist/codex/plugins/webcraft/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "webcraft", - "version": "0.9.1", + "version": "0.10.0", "description": "웹 플랫폼 개발 지침(프론트·디자인 시스템·백엔드·API·DB·아키텍처)과 프로젝트 연결", "author": { "name": "dev-goraebap" diff --git a/plugins/webcraft/dist/codex/plugins/webcraft/package.json b/plugins/webcraft/dist/codex/plugins/webcraft/package.json index 492e626..e1cbf31 100644 --- a/plugins/webcraft/dist/codex/plugins/webcraft/package.json +++ b/plugins/webcraft/dist/codex/plugins/webcraft/package.json @@ -1,6 +1,6 @@ { "name": "webcraft", - "version": "0.9.1", + "version": "0.10.0", "private": true, "type": "module" } diff --git a/plugins/webcraft/dist/codex/plugins/webcraft/skills/architecture/references/product-plugins.md b/plugins/webcraft/dist/codex/plugins/webcraft/skills/architecture/references/product-plugins.md index 388bea1..f416749 100644 --- a/plugins/webcraft/dist/codex/plugins/webcraft/skills/architecture/references/product-plugins.md +++ b/plugins/webcraft/dist/codex/plugins/webcraft/skills/architecture/references/product-plugins.md @@ -18,6 +18,14 @@ arc42: "8.1" 주소·인증·설정 입력 방식은 각 에이전트의 현재 지원을 확인합니다. 한 에이전트의 변수 치환이나 비밀값 저장 방식이 다른 에이전트에서도 작동한다고 가정하지 않습니다. 필요한 설정만 온보딩에서 확인하고 토큰은 지원되는 로컬 비밀 저장 수단으로 받습니다. 설치 명령이 성공한 뒤에는 실제 연결과 사용자 조회까지 별도로 확인합니다. +## 개선 피드백 받기 + +사용자에게 MCP를 제공하는 제품은 피드백 접수처(예: 이슈 등록 도구, 접수 양식)와 제출 방식을 정하고, 사용자용 스킬에 그 경로를 적습니다. Webcraft를 쓰지 않는 사용자의 에이전트는 Webcraft 지침을 읽지 않습니다. 판단 기준은 [도구의 부족을 발견했을 때](../../goraebap-mode/references/tool-improvements.md)를 따르되, 사용자용 스킬에는 짧은 요약과 그 제품의 접수처·인증 방식만 둡니다. + +MCP 서버를 만드는 제품은 서버 안내(instructions)에도 같은 짧은 요약을 둘 수 있습니다. 플러그인이 없는 에이전트에도 원칙이 닿게 하기 위한 선택지입니다. 스킬과 서버 안내에 긴 절차를 각각 복제하지 않습니다. + +Webcraft가 모든 MCP 서버를 관리한다고 가정하지 않습니다. + ## 변경과 검증 MCP 도구가 바뀌면 서버 계약과 사용자용 설명을 함께 맞춥니다. 스킬에 없는 도구나 아직 제공하지 않는 기능을 약속하지 않습니다. 패키지 버전과 서버의 호환 범위, 이름이 바뀐 경우 기존 사용자의 전환 방법을 기록합니다. diff --git a/plugins/webcraft/dist/codex/plugins/webcraft/skills/backend/SKILL.md b/plugins/webcraft/dist/codex/plugins/webcraft/skills/backend/SKILL.md index d3a1396..c615dc7 100644 --- a/plugins/webcraft/dist/codex/plugins/webcraft/skills/backend/SKILL.md +++ b/plugins/webcraft/dist/codex/plugins/webcraft/skills/backend/SKILL.md @@ -14,6 +14,7 @@ arc42: "8.2, 5, 2" - [조회 포트](references/queries.md): 화면 조회의 소유권·결과 타입·조립 경로를 정할 때 읽습니다. - [모듈 간 조회](references/cross-module-read.md): 다른 모듈의 표시값·검색·정렬이 필요할 때 읽습니다. - [도메인 오류와 서비스 오류](references/errors.md): 규칙 위반·부재·권한·충돌을 처리할 때 읽습니다. +- [사용자가 올린 파일과 고른 주소](references/untrusted-content.md): 올린 HTML을 화면 안에 보이거나, 사용자가 준 주소를 서버가 읽을 때 읽습니다. - [코드 규칙](references/code-style.md): Java 파일·DTO·의존성을 작성할 때 읽습니다. 완료 시 프로젝트의 정적 검사·테스트와 변경한 유스케이스의 성공·실패·권한을 검증합니다. HTTP 계약이 바뀌면 명세·생성 타입·호출자도 함께 맞춥니다. diff --git a/plugins/webcraft/dist/codex/plugins/webcraft/skills/backend/references/untrusted-content.md b/plugins/webcraft/dist/codex/plugins/webcraft/skills/backend/references/untrusted-content.md new file mode 100644 index 0000000..e55780a --- /dev/null +++ b/plugins/webcraft/dist/codex/plugins/webcraft/skills/backend/references/untrusted-content.md @@ -0,0 +1,51 @@ +--- +arc42: "8.2" +--- + +# 사용자가 올린 파일과 고른 주소 + +사용자가 올린 HTML을 본문 안에 미리 보이거나, 사용자가 고른 웹 주소를 서버가 읽어(링크 카드 등) 보여 줄 때 적용합니다. 둘 다 남의 내용을 우리 출처나 우리 서버 망에서 실행·호출하게 되는 일이라 따로 경계를 둡니다. + +## 올린 HTML을 틀 안에 보이기 + +저장소의 서명 주소(presigned URL)나 `/api/**` 응답으로는 화면 안 틀(`iframe`)에 보일 수 없습니다. API 응답에는 `X-Frame-Options: DENY`가 붙고, 저장소 주소는 응답 헤더를 우리가 정할 수 없기 때문입니다. 그래서 앱이 전용 주소로 직접 내보냅니다. 예: `GET /api/embeds/{exp}.{sig}/{key}`. `{key}`는 저장 키를 URL-safe Base64로 쓴 것, `{exp}`는 만료 시각(초), `{sig}`는 서명입니다. + +| 항목 | 기준 | +| --- | --- | +| 서명 | 서버 비밀키로 HMAC-SHA256을 계산하고, 서명 대상에 용도 문자열(예: `html-embed`)·만료 시각·키를 함께 넣습니다. 용도 문자열이 있어 다른 서명과 섞여 쓰이지 않습니다. 비교는 시간이 일정한 비교(`MessageDigest.isEqual`)로 합니다 | +| 만료 | 1시간. 만료 시각을 10분 단위로 올려 가까운 시각에 만든 주소가 같아지게 하여 브라우저가 캐시할 수 있게 합니다. 본문을 다시 읽으면 새 주소를 받습니다 | +| 권한 | 서명한 주소가 곧 권한입니다(서명 주소와 같음). 권한 확인은 기존 파일 열기 주소(예: 모듈의 `…/files/{id}`)가 하고, HTML을 제자리에서 열 때만 전용 주소로 돌려보냅니다. 내려받기는 기존 경로를 씁니다 | +| 응답 | `Content-Type: text/html;charset=UTF-8`, `Cache-Control: private, max-age=300`. 서명이 틀리거나 만료됐거나 객체가 없으면 404입니다. 이 주소는 브라우저가 돌려보내기로만 오므로 OpenAPI 문서에서 뺍니다 | + +보안 체인(Spring Security)은 그 주소만의 `SecurityFilterChain`을 다른 체인보다 앞(`@Order(0)`, `securityMatcher("/api/embeds/**")`)에 둡니다. + +- GET·HEAD만 허용하고 나머지는 `denyAll`입니다. +- 세션 없음(`STATELESS`), CSRF·폼 로그인·HTTP Basic·로그아웃·요청 캐시를 끕니다. +- `X-Frame-Options`를 끕니다. +- `Content-Security-Policy: sandbox allow-scripts allow-modals allow-forms allow-popups; frame-ancestors 'self'`, `Referrer-Policy: no-referrer`를 붙입니다. + +응답 문서는 출처가 없는 상태(sandbox, `allow-same-origin` 없음)로 실행되어 이 앱의 쿠키·저장소·API에 닿지 못하고, 우리 화면 안 틀에서만 열립니다. 주소를 새 탭으로 바로 열어도 같은 정책이 붙습니다. + +화면 쪽 틀도 같은 격리를 겁니다: `sandbox="allow-scripts allow-modals allow-forms allow-popups"`, `referrerPolicy="no-referrer"`. 크게 보기 창의 "새 탭에서 열기"는 서버 주소일 때만 둡니다. 저장 전의 `blob:` 주소는 새 탭에서 앱의 출처로 열리기 때문입니다. + +서명·만료·변조된 서명·다른 키·잘못된 형식을 단위 테스트로 확인합니다. + +## 사용자가 고른 주소를 서버가 읽기 + +사용자가 고른 주소로 서버가 안쪽 망(사내 서비스, 클라우드 메타데이터)을 부르지 않게 합니다(SSRF 막기). + +받는 주소는 HTTPS, 포트 443(생략 포함)만입니다. 사용자 정보(`user:pass@`)가 든 주소, 2000자 넘는 주소는 거부합니다. + +호스트의 모든 IP가 공개 주소일 때만 부릅니다. 막는 대역은 미지정(`0.0.0.0/8`, `::`), 루프백, 링크 로컬, 사설(site local), 멀티캐스트, CGNAT 공유 대역 `100.64.0.0/10`, 벤치마크 `198.18.0.0/15`, 예약 `240.0.0.0/4`, IPv6 고유 로컬 `fc00::/7`입니다. IPv4를 품은 IPv6(IPv4-mapped `::ffff:a.b.c.d`, IPv4-compatible)와 NAT64 `64:ff9b::/96`은 안의 IPv4로 다시 판정합니다. + +리다이렉트는 자동으로 따르지 않습니다(`HttpClient.Redirect.NEVER`). `Location`을 직접 풀어 다음 주소에도 같은 검사를 하고 최대 3번까지 따릅니다. + +연결·응답은 각 5초이고, 상태 200이고 `Content-Type`이 HTML일 때만 읽습니다. 본문은 앞 512KB만 읽습니다(메타데이터는 `head`에 있음). 쿠키·토큰 등 우리 쪽 인증을 보내지 않습니다. + +검사할 때 이름을 해석하고 연결할 때 다시 해석하면, 그 사이에 DNS 답이 바뀌는 공격(DNS rebinding)은 위 검사로 막지 못합니다. 검사한 IP로 연결을 고정하거나, 안쪽 망으로 나가지 못하는 출구 프록시·방화벽으로 막습니다. 둘 다 못 하면 그 한계를 프로젝트 기록에 남깁니다. + +결과는 제목·설명·대표 사진·사이트 이름을 `og:*`, `twitter:*`, `description`, `<title>` 순으로 읽습니다. 제목은 200자, 설명은 300자에서 자릅니다. 대표 사진 주소는 페이지 주소 기준으로 풀어 HTTPS일 때만 받습니다(HTTP·`data:`는 사진 없음). 대표 사진은 읽는 사람의 브라우저가 그 사이트에서 직접 받으며, 화면은 `referrerPolicy="no-referrer"`로 받습니다. + +결과는 6시간 기억합니다(최대 1000개, 오래 안 쓴 것부터 버림). 읽지 못한 주소는 오류 대신 주소(와 호스트)만 있는 카드로 돌려줘 본문이 죽은 링크로 깨지지 않게 합니다. 외부 사이트를 기다리는 동안 DB 트랜잭션을 잡지 않습니다(트랜잭션 서비스가 아닌 컴포넌트로 둠). + +공개·사설·루프백·CGNAT·IPv4-mapped·NAT64·포트·스킴 판정, 리다이렉트 횟수, 메타 읽기·자르기·엔티티 풀기를 단위 테스트로 확인합니다. diff --git a/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/SKILL.md b/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/SKILL.md index e60ca68..ad228a8 100644 --- a/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/SKILL.md +++ b/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/SKILL.md @@ -31,6 +31,7 @@ arc42: "8.5, 2" | 대기·빈 상태·실패·전환 | [데이터 상태](references/data-states.md) | | 표면 고르기·누르는 대상·키보드 | [입력과 상호작용](references/interaction-patterns.md) | | UI 문구·본문 편집·검증 | [문구·본문과 검증](references/content-and-review.md) | +| 본문 인쇄·PDF 저장 | [인쇄와 PDF](references/print.md) | | 지침·코드·가이드 견본의 연결 | [패턴 카탈로그](references/pattern-catalog.md) | | 프로젝트 지정이 없는 기본 화면 | [레퍼런스 사용과 갱신](references/reference-kit.md) | diff --git a/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/notes.md b/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/notes.md index 47bbeb7..0038c56 100644 --- a/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/notes.md +++ b/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/notes.md @@ -63,3 +63,19 @@ arc42: "9" - 결정(사용자): 가이드를 Astro에서 키트와 같은 Vite + React + TanStack Router + MDX SPA로 옮겼습니다. Astro 때문에 둔 우회(셸 유지, 테마 스타일 붙잡기, 넘겨받을 때까지 감추기, 본문 옮겨 끼우기)가 사라지고, 전환은 라우터 대기 + 180ms 덮개 + 160ms 페이드로 키트 규칙 그대로 구현했습니다. 첫 화면은 키트의 bootCover·BootCover를 씁니다. - 사이트는 플러그인 지침 전체를 보여 줍니다. 지침 문서는 스킬 Markdown을 원본 그대로 빌드합니다(사본 없음). 문서별 정적 HTML(SSG)은 아직 없습니다. 공개할 때 프리렌더를 붙입니다. + +## 2026-11-10 · 사내 업무 앱의 리치 에디터와 화면 규칙 + +- 증상: 키트의 본문 칸은 자리를 보이는 Tiptap 예시뿐이라, 표·블록 메뉴·미디어처럼 실제 업무 본문에 필요한 동작을 지침과 견본으로 확인할 수 없었습니다. +- 수정: 사내 업무 앱에서 다듬은 리치 에디터(표·블록 메뉴·선택 서식 도구·링크 입력·올리는 칸·미디어·파일 카드·도표·HTML 임베드·링크 카드)를 키트 본문 칸으로 옮겼습니다. 키트 리비전 8입니다. +- 기존 규칙을 바꾼 것(이미 Webcraft를 따르는 프로젝트에 영향): 대화상자 안 여백을 데스크톱 40/24/12px로 바꿨습니다(전에는 1rem·0.5rem). 본문 편집을 상세 안 전환에서 전용 수정 화면으로 옮겼습니다. 너비를 조절하는 옆 목록의 기본 너비를 최대값으로 정했습니다(키트 사이드바 380px, 받은함 목록 560px). 본문 칸의 Tiptap 예시를 자리 표시에서 참고 구현으로 바꿨고, 본문의 1단계 제목을 쓰지 않게 했습니다. +- 원인(옮기며 겪은 함정): + 1. `@scope (…) { }` 안의 규칙은 같은 우선순위의 바깥 규칙을 이깁니다. 덮어쓰는 규칙은 같은 scope 안에 두거나 우선순위를 올립니다. + 2. 조상에 `transform`이 있으면 `position: fixed` 메뉴의 기준이 그 조상이 되어 엉뚱한 곳에 뜹니다. 손잡이 정렬에 transform 대신 margin을 씁니다. + 3. 표 열 너비 손잡이가 셀 밖으로 2px 나가 있으면 표를 감싼 칸에 스크롤바가 생겼다 사라지며 행이 흔들립니다. 손잡이는 셀 안에 두고 감싸는 칸은 `overflow-y: hidden`으로 둡니다. + 4. NodeView의 바깥 감싸개(`.react-renderer`)가 실제 블록이므로, 사진 나란히 놓기 같은 배치 규칙은 감싸개에 `:has()`로 겁니다. +- 키트 규칙 때문에 원본과 달라진 점: 색을 hex 대신 테마 변수로 저장합니다. Stone 테마의 색 토큰을 그대로 쓰면 색끼리 구분되지 않아 테마 색의 색상(hue)으로 값을 만들었습니다. 갈색은 테마에 없어 주황 색상을 낮은 채도로 씁니다. 메뉴·도구는 Astryx 부품(`Button`, `HStack`/`VStack`, `Kbd`, `Selector` 등)으로 그리고, 휴대폰 도구는 44px입니다. +- 원본에서 고친 결함: 포인터로 연 하위 메뉴가 누르면 닫히던 것, 블록 메뉴가 열린 채 Alt+↑/↓가 먹지 않던 것, 편집 전환만으로 "고친 내용 있음"이 되던 것. +- 키트에 넣지 않은 것: 토글·열 배치·수식·콜아웃·코드 강조, 댓글·버전 기록, 글꼴 설정 화면, 인쇄 화면. +- 확인: `npm run site:check`, `npm run site:test`(28개 파일, 349개 테스트 통과), `npm run site:build`, `npm test`가 통과했습니다. 가이드 사이트에서 1440 라이트·다크, 390 휴대폰으로 견본을 봤습니다. +- 범위: 실제 서버(올리기, 링크 미리보기, HTML 격리 응답), 터치 기기, 화면 낭독기, 2560 화면은 확인하지 않았습니다. diff --git a/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/app-layout.md b/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/app-layout.md index 9775105..0ba5d76 100644 --- a/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/app-layout.md +++ b/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/app-layout.md @@ -19,6 +19,8 @@ arc42: "8.6, 5" 열 때 340px, 220~380px로 끌어 조절합니다(큰 화면 배율만큼 함께 커집니다). 다른 제품·지침의 기본(250~320px)보다 넓은 것은 메뉴 이름과 하위 항목을 말줄임 없이 보이기 위한 선택입니다. 좁은 노트북에서 미리보기 패널과 함께 열 때 목록이 좁아지면 사용자가 사이드바를 줄이거나 접습니다. 머리 줄은 페이지 머리 줄과 같은 52px에 로고(홈 링크)·상자 버튼(전역 검색 등)·접기를 둡니다. 접으면 머리를 세로로 쌓고 접기 버튼은 바닥으로 옮기며, 사라진 묶음 이름 대신 선으로 묶음을 나눕니다. 접고 펼친 뒤 초점은 같은 버튼에 남습니다. +사용자가 끌어 너비를 바꿀 수 있는 옆 목록(사이드바, 받은함 목록, 문서의 페이지·목차 목록)은 처음에 최대 너비로 엽니다. 사용자가 줄인 너비를 저장하는 제품은 그 값을 우선합니다. 옆 목록의 이름이 처음부터 잘리지 않게 하고, 좁히는 것은 사용자가 고르게 하기 위해서입니다. + 메뉴 묶음은 이름으로 나누고 첫 묶음은 이름을 숨길 수 있습니다. 펼친 하위 항목도 상위 항목끼리와 같은 2px(0.125rem) 간격으로 띄웁니다(Astryx SideNav 기본은 하위 묶음 안 간격이 0입니다). 펼친 묶음 끝에는 `--spacing-2`(8px)를 더해 다음 상위 항목과 [경계를 보이고](spacing.md)(항목 사이 2px와 합쳐 10px), 접은 묶음에서는 이 간격이 함께 사라집니다. 하위 메뉴 묶음(`openGroups`)은 메뉴가 짧은 업무 앱에서는 모두 펼치는 `'all'`(기본)을, 묶음이 많은 문서 사이트에서는 현재 위치가 든 묶음만 펼치는 `'current'`를 씁니다. `'current'`에서는 다른 묶음으로 옮겨 가면 그 묶음도 펼치고, 사용자가 직접 연 묶음은 그대로 둡니다. 현재 항목은 주소의 가장 긴 접두사로 정하며 검색 조건으로 나뉜 메뉴는 조건까지 비교합니다. 선택 항목은 Bold Duotone입니다. 메뉴 글자는 보조 글자색으로 두고 포인터가 올라온 항목과 현재 항목만 본문 글자색으로 올립니다. 메뉴가 길어져도 무겁지 않고 현재 위치가 먼저 읽힙니다. 하단 탭과 더보기 시트도 같은 규칙입니다. 내 계정·관리처럼 따로 묶인 공간은 주 메뉴를 대체하고 위에 "← 공간 이름"을 둡니다. ## 휴대폰 diff --git a/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/composed-components.md b/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/composed-components.md index cd85c1c..d79cbb2 100644 --- a/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/composed-components.md +++ b/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/composed-components.md @@ -17,9 +17,11 @@ arc42: "8.8" | 행 순서 바꾸기 | 드래그 손잡이·`Alt`+방향키 이동·`Esc` 취소·위치 음성 안내 | [reorder](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/reorder) | | 파일 올리기 칸 | 고르기·끌어 놓기·형식과 크기 확인 | [upload](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/upload) | | 상태 화면 | 빈 목록·결과 없음·없는 대상·실패의 그림과 문구 | [state](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/state), [기준](data-states.md) | -| 본문 칸 | 종이 위의 리치 본문 | [editor](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/editor) | +| 본문 칸 | 종이 위의 리치 본문: 블록 메뉴·선택 서식 도구·표·미디어 | [editor](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/editor) | | 그룹 머리 | 그룹 이름·개수·접기, 스크롤에 붙는 띠 | [list-group](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/list-group) | +구현이 없는 조합을 메뉴에 빈 항목으로 만들지 않습니다. 제품 고유 조합(아이콘 표시 고르기, 전역 검색 팔레트, 프로필 패널)은 프로젝트가 소유합니다. + ## 곁에 여는 내용 버튼 곁에 여는 것은 데스크톱에서 팝오버, 휴대폰에서 언제나 바텀시트입니다. 휴대폰에 팝오버를 두지 않습니다. 내용은 고른 뒤 닫을 수 있도록 `close`를 받습니다. `AdaptivePopover`에 `isOpen`을 주면 바깥에서 열림을 제어하고 `onOpenChange`에서 변경을 받아 외부 상태에 반영합니다. 외부 제어 없이 최초에 열어 둘 때는 `defaultOpen`을 쓰며, `trigger`를 대신 클릭하는 우회는 쓰지 않습니다. 시트의 손잡이는 위 24px에 겹치므로 내용은 그 아래에서 시작하고 좌우 12px, 아래 20px을 씁니다. 이 여백은 한 부품(SheetBody)에 두어 시트마다 다시 쓰지 않습니다. 내용이 짧으면 시트가 화면 대부분을 차지하지 않게 하고(hug), 검색·긴 목록은 키보드를 고려해 높게(tall) 엽니다. @@ -50,6 +52,53 @@ arc42: "8.8" ## 본문 칸 -제목·본문 양식의 본문은 프로젝트가 채택한 리치에디터를 테두리와 서식 도구 막대 없이 종이처럼 둡니다. 서식은 에디터의 단축키·입력 규칙·선택 도구로 합니다. 앱 화면의 본문 토큰(13px, 15px `--text-large-size`)은 목록·도구가 빽빽한 화면용으로 남기고, 긴 글을 읽는 본문 칸과 상세 본문 글자는 데스크톱·휴대폰 모두 16px, 줄 높이 1.7이며 제목은 `--text-heading-2-size`입니다. 테마가 문단에 글자 크기와 줄 높이를 직접 주므로 문단·목록·인용이 크기와 줄 높이를 함께 상속하게 합니다. 쓰는 칸과 그 글을 읽는 상세 본문은 같은 크기로 둡니다. 키트의 Tiptap 예시는 자리를 보이기 위한 것이며 특정 에디터를 기본 의존성으로 복사하지 않습니다. +제목·본문 양식의 본문은 프로젝트가 채택한 리치에디터를 테두리와 서식 도구 막대 없이 종이처럼 둡니다. 서식은 에디터의 단축키·입력 규칙·선택 도구로 합니다. 앱 화면의 본문 토큰(13px, 15px `--text-large-size`)은 목록·도구가 빽빽한 화면용으로 남기고, 긴 글을 읽는 본문 칸과 상세 본문 글자는 데스크톱·휴대폰 모두 16px, 줄 높이 1.7이며 제목은 `--text-heading-2-size`입니다. 테마가 문단에 글자 크기와 줄 높이를 직접 주므로 문단·목록·인용이 크기와 줄 높이를 함께 상속하게 합니다. 쓰는 칸과 그 글을 읽는 상세 본문은 같은 크기로 둡니다. 키트의 본문 칸은 Tiptap 3(`@tiptap/*` 3.32, 표는 prosemirror-tables) 위에 만든 참고 구현이며, 쓰는 칸은 `RichDraft`, 읽는 본문은 `RichBody`로 씁니다. 프로젝트가 다른 에디터를 채택했으면 그 에디터로 같은 동작을 만듭니다. 키트 코드는 채택할 때만 프로젝트 소유 코드로 옮깁니다. -구현이 없는 조합을 메뉴에 빈 항목으로 만들지 않습니다. 제품 고유 조합(아이콘 표시 고르기, 전역 검색 팔레트, 프로필 패널)은 프로젝트가 소유합니다. +호스트는 올리기 함수(`upload`), 링크 미리보기 함수(`readPreview`), 링크로 고를 문서 목록(`links`), 앱 안 이동(`onNavigate`)을 줍니다. 키트에는 서버가 없어 견본은 가짜 함수를 씁니다. + +### 제목 + +화면은 늘 대상의 시스템 제목(제목 칸)을 보이므로 본문에서는 1단계 제목을 쓰지 않습니다. 제목이 둘이 되지 않게 하려는 것입니다. 본문 제목은 2·3·4단계이며 `# `를 입력해도 제목이 되지 않습니다. 예전 본문에 1단계 제목이 있으면 열 때 2단계로 내립니다. 인쇄·PDF의 표지와 절 제목도 시스템 제목을 씁니다([인쇄와 PDF](print.md)). + +### 블록 손잡이와 블록 메뉴 + +편집 중 블록에 포인터를 올리면 글줄 왼쪽(약 35px)에 손잡이가 뜹니다. 손잡이를 끌면 블록을 옮기고, 누르면 블록 메뉴가 열립니다. 목록 안의 글 위에서는 그 목록의 손잡이가 보입니다. 메뉴는 다음 묶음으로 나눕니다. + +- 색(글자색·배경색 하위 메뉴), 종류 바꾸기(하위 메뉴), 서식 지우기 +- 복제(Ctrl/⌘+D), 복사(Ctrl/⌘+C), 블록 링크 복사 +- 위로·아래로 옮기기(Alt+↑/↓) +- 지우기(Delete) + +메뉴를 열지 않고 블록을 고른 채 같은 단축키를 눌러도 동작합니다. 블록 링크는 지금 주소에 `#block-<id>`를 붙인 주소이며, 그 주소로 열면 그 블록으로 스크롤합니다. + +### 선택 서식 도구와 링크 + +글을 고르면 서식 도구 막대가 뜹니다. 순서는 블록 종류, 굵게·기울임·밑줄·취소선·코드, 링크, 색, 더 보기(위·아래 첨자, 정렬, 내어쓰기·들여쓰기, 서식 지우기)입니다. 막대는 창이 아니라 본문 칸의 좌우를 기준으로 위치를 잡습니다. 그래서 글줄 첫머리를 골라도 옆 메뉴 밑에 가리지 않습니다. 위 공간이 모자라면 아래로 뜹니다. + +링크는 대화상자 대신 서식 도구 아래의 입력 칸에서 겁니다. 주소를 넣거나(`example.com`이면 `https://`를 붙입니다) 호스트가 준 문서 목록(`links`)에서 이름으로 찾아 고릅니다. 고른 문서는 앱 안 주소로 걸립니다. 읽는 화면에서 앱 안 주소로 가는 링크는 새 탭 대신 그 화면으로 이동합니다(`RichBody`의 `onNavigate`). + +### 표 + +표는 모서리를 둥글리지 않고 바깥 양옆 세로선 없이 그리며, 머리 행에 옅은 바탕을 깝니다. 포인터가 있는 칸(없으면 커서가 있는 칸)의 행 왼쪽과 열 위에 손잡이가 뜹니다. 손잡이를 누르면 그 행·열의 메뉴가 열립니다. 메뉴에는 위·아래(열은 왼·오른쪽)에 넣기, 옮기기, 머리 행·열 켜기와 끄기, 지우기가 있습니다. 표 왼쪽 위 모서리의 단추는 표 전체의 메뉴(셀 병합·나누기·머리글·표 삭제)를 엽니다. 표 끝의 `+` 막대로 행·열을 더하고, 열 경계를 끌어 너비를 바꿉니다. 명령 하나가 실행 취소 한 단계입니다. + +읽기로 열었다가 편집으로 바꾸는 본문에서도 열 너비를 끌 수 있어야 합니다. 그래서 표 확장은 너비 조절을 늘 등록하고, 실행 취소 때 남은 열 너비를 지웁니다(`ResizableTable`, `SyncedTableView`). + +### 올리는 칸과 미디어 + +`/` 목록에서 파일이나 HTML 임베드를 고르면 파일 고르기를 바로 열지 않고 그 자리에 점선 칸을 띄웁니다. 칸을 누르면 파일 고르기가 열리고, 파일을 끌어 놓을 수도 있습니다. ✕나 Esc로 닫습니다. 칸은 문서에 저장되지 않는 화면 표시(decoration)라 그냥 닫아도 본문에 흔적이 남지 않습니다. HTML 칸은 `.html` 파일만 고릅니다. + +미디어 블록은 머리 띠·상자 틀·사진 아래 이름 줄 없이 그립니다. 포인터를 올리거나 초점이 들어오면 오른쪽 위에 도구(크게 보기·너비·내려받기)가 뜹니다. 사진은 원본 비율로 자리를 먼저 잡고 대표색으로 칠한 뒤 나타납니다. 너비를 25%나 50%로 줄인 사진끼리는 한 줄에 나란히 놓입니다. 파일은 확장자별 아이콘 글자와 색(PDF·워드·엑셀·발표 자료·압축·텍스트·코드·HTML 등)을 가진 카드로 보입니다. + +도표(Mermaid)는 칸 폭을 채우되 원래 크기의 3배까지, 높이 36rem 안에서 그립니다. 누르면 크게 보기 창으로 열리고, 원문 편집 칸은 테마 색을 따릅니다. Mermaid는 CSS 변수를 읽지 못하므로 도표 색표는 테마 파일의 값(`diagramColors`)으로 줍니다. + +HTML 임베드는 올린 HTML을 본문 안의 격리된 틀로 미리 봅니다. 틀은 `sandbox="allow-scripts allow-modals allow-forms allow-popups"`와 `referrerPolicy="no-referrer"`를 쓰고 `allow-same-origin`을 주지 않습니다. 크게 보기는 창을 거의 채우는 `<dialog>`에 같은 격리 틀을 두며, 닫기 단추와 Esc로 닫습니다. 새 탭 열기는 서버 주소일 때만 둡니다. 저장 전의 `blob:` 주소는 새 탭에서 앱의 출처로 열리기 때문입니다. 서버 쪽 규칙은 [사용자가 올린 파일과 고른 주소](../../backend/references/untrusted-content.md)를 따릅니다. + +### 링크 카드 + +링크 카드는 `/` 목록의 "링크 카드"로 넣는 웹 주소 블록입니다. 제목·설명·대표 사진을 카드로 보이고, 사진은 오른쪽에서 카드 높이를 채웁니다. 사진이 없거나 받지 못하면 같은 자리를 테두리로 나누고, 테마 강조색을 옅게(12%) 깐 바탕에 그림 아이콘과 "대표 이미지가 없습니다"를 둡니다. 읽는 화면에서는 카드 전체가 새 탭 링크입니다. 편집 화면에서는 카드를 누르면 블록이 선택되고, 열기와 주소 고치기는 위에 뜨는 도구로 합니다. 주소를 넣는 칸은 올리는 칸과 같은 점선 칸입니다. 미리보기는 서버가 읽어 주며 같은 backend 문서를 따릅니다. + +### 메뉴와 색 + +떠 있는 메뉴(블록 메뉴·선택 서식 도구·`/` 목록·표 메뉴)는 그림자를 줄이고 1px 테두리로 구분합니다. 본문 속 인라인 코드는 바탕 없이 고정폭 글자에 옅은 밑줄로 보입니다. + +글자색·배경색은 테마 색에서 만든 값(CSS 변수)으로 저장해 라이트·다크를 따릅니다. 다른 곳에서 가져온 본문에 직접 지정한 색(hex)은 그대로 보이지만 테마를 따르지 않습니다. diff --git a/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/content-and-review.md b/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/content-and-review.md index 7f9910d..37c16f9 100644 --- a/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/content-and-review.md +++ b/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/content-and-review.md @@ -23,9 +23,11 @@ arc42: "8.7" ## 읽기·편집 -본문은 읽기에서 시작하고 명시적인 편집 전환을 제공합니다. 편집기는 프로젝트 공통 UI를 통해 연결하고 저장 형식·첨부 수명주기·초안·버전 충돌을 제품 계약과 맞춥니다. 특정 편집기 소스를 기본 의존성으로 복사하지 않습니다. +본문은 읽기에서 시작하고 명시적인 편집 전환을 제공합니다. 상세의 편집 단추는 늘 그 대상의 수정 화면(자기 주소, 예: `…/edit`)을 엽니다. 상세 안에서 본문을 그 자리에서 편집 상태로 바꾸지 않습니다. 편집기는 프로젝트 공통 UI를 통해 연결하고 저장 형식·첨부 수명주기·초안·버전 충돌을 제품 계약과 맞춥니다. 특정 편집기 소스를 기본 의존성으로 복사하지 않습니다. -명시 저장을 선택한 제품은 저장·Ctrl/Command+S·편집 완료를 같은 저장 경로로 연결합니다. 저장 중·완료·실패를 구분하고 충돌 시 초안을 보존합니다. 프로젝트가 자동 저장을 정했으면 그 정책을 우선합니다. 읽기/편집 전환 때문에 불필요하게 편집기를 다시 만들어 Undo를 잃지 않습니다. +수정 화면에는 제목과 본문만 두고 속성 열과 활동(이력·댓글)은 두지 않습니다. 데스크톱은 머리 줄에 저장 상태 표시와 저장·취소·완료를 이 순서로 둡니다. 휴대폰은 바닥 줄에 저장·완료를 두고 머리 줄의 뒤로(←)가 취소입니다. 키트 부품은 `BodyEditPage`([코드](https://github.com/dev-goraebap/dev.goraebap/blob/main/site/src/shared/ui/patterns/compose/BodyEditPage.tsx))입니다. Ctrl/Command+S는 저장, 완료는 저장한 뒤 상세로 돌아가기, 취소는 저장하지 않고 상세로 돌아가기입니다. 고친 내용이 있으면 취소 전에 "저장하지 않은 내용이 있습니다"로 묻고 "버리고 나가기"와 "계속 작성"을 고르게 합니다. 고친 것이 없으면 묻지 않고 돌아갑니다. 읽는 화면과 고치는 화면을 나누면 상세의 속성·활동과 편집 상태가 섞이지 않고, 편집 중인 화면도 주소가 있어 새로고침·뒤로가기의 결과가 분명해집니다. + +명시 저장을 선택한 제품은 저장·Ctrl/Command+S·완료를 같은 저장 경로로 연결합니다. 저장 중·완료·실패를 구분하고 충돌 시 초안을 보존합니다. 프로젝트가 자동 저장을 정했으면 그 정책을 우선합니다. 저장할 때 불필요하게 편집기를 다시 만들어 Undo를 잃지 않습니다. 변경 기록과 댓글을 한 활동 영역에서 읽을 수 있게 하되 위치 댓글·인용 댓글·답글 깊이는 제품이 정합니다. 레퍼런스에 있던 활동 UI를 모든 제품의 필수 기능으로 추가하지 않습니다. diff --git a/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/dialog.md b/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/dialog.md index 9fe2e7c..44333c9 100644 --- a/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/dialog.md +++ b/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/dialog.md @@ -9,12 +9,14 @@ arc42: "8.6" | 항목 | 기준 | | --- | --- | | 위치 | 위에서 12dvh, 아래도 같은 여유. 최대 높이는 `100dvh - 2 × 12dvh` | -| 안 여백 | 좌우·바깥 1rem, 머리 아래·본문 위 0.5rem | +| 안 여백 | 데스크톱(64rem 이상)은 머리·본문·바닥의 좌우 40px(`--spacing-10`), 위아래 24px(`--spacing-6`), 머리 아래와 본문 위 12px(`--spacing-3`). 휴대폰(64rem 미만)은 좌우·바깥 1rem(16px), 머리 아래·본문 위 0.5rem. 모든 대화상자에 같은 값을 씁니다 | | 바닥 구분선 | 기본은 없습니다. 긴 본문과 바닥이 붙어 보일 때만 선택합니다 | | 첫 초점 | 제목(`tabIndex=-1`). 입력이 주인 양식은 첫 입력, 확인 창은 취소 | | 닫기 | 닫기 X·Esc·배경 누르기. 닫으면 초점이 연 요소로 돌아갑니다 | | 휴대폰 | 짧은 확인은 같은 틀, 양식은 전체 화면이나 페이지. 전체 화면은 안전 영역을 피합니다 | +안 여백을 한 값으로 정해 대화상자마다 여백이 달라지지 않게 합니다. 대화상자 안에서 리치 본문을 고칠 때 글줄 왼쪽 약 35px에 뜨는 블록 손잡이가 대화상자 테두리 밖으로 나가지 않는 것도 데스크톱 좌우 40px의 이유입니다. + 위치를 지정한 Dialog에 공통 margin을 더하면 top과 겹쳐 아래로 밀립니다. 위치와 남은 높이를 함께 정하고 짧은 화면에서도 닫기와 마지막 내용에 닿는지 확인합니다. 너비와 최소 크기는 쓰임별로 정하고 준비·실패·완료 상태에서 같은 기준을 씁니다. 비어 있는 로딩 틀의 크기를 내용의 고유 너비로 삼아 아주 작게 열지 않습니다. 긴 상세의 최소 높이는 작은 화면의 최대 높이를 넘지 않게 제한합니다. 짧은 확인 창과 본문이 있는 상세를 이유 없이 같은 크기로 강제하지 않습니다. diff --git a/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/layout-patterns.md b/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/layout-patterns.md index 0b81958..6c0b232 100644 --- a/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/layout-patterns.md +++ b/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/layout-patterns.md @@ -39,7 +39,7 @@ arc42: "8.6" 본문이 있는 모든 상세는 속성 열을 같은 순서로 둡니다. 본문에 제목이 있으면 맨 위에 "목차" 묶음, 그 아래 대상별 속성, 맨 끝에 "정보" 묶음(작성자 아바타·작성일·수정일과 고친 사람·버전 수)을 두고, 모르는 값은 줄째 뺍니다. 목차와 버전 줄도 다른 속성과 같은 속성 행 부품으로 그려 아이콘·글자 자리를 맞춥니다. 누르면 목차는 테두리 있는 팝오버, 버전은 버전 기록 대화상자를 엽니다. 휴대폰은 목차를 오른쪽 아래 떠 있는 둥근 버튼과 바텀시트로 둡니다. 편집기 가장자리에 목차 표시를 붙이지 않습니다. 상세의 더 보기(…) 메뉴는 페이지 머리 줄 한 곳에 둡니다. -본문 편집 도구는 페이지 머리 줄의 공통 자리에 둡니다. 읽기에서 시작하고 명시적인 편집 전환을 둡니다. 데스크톱 머리 줄의 편집 전환·편집 마침·버전 기록은 필터·정렬과 같은 둥근 아이콘 도구(`variant="tool"`, 아이콘만, 툴팁에 이름)로 두며, "편집" 같은 글자 버튼을 머리 줄에 두지 않습니다. 저장은 상태가 바뀌는 동작이라 글자를 유지하고, 휴대폰은 바닥 줄의 저장·완료를 그대로 둡니다. 버전 기록 목록은 표준 목록 행(아이콘·시각·설명, 선택 표시)으로 그리고, 주소를 가진 대화상자(라우트 마스킹 `…/history`, 새로 고침하면 다시 열림)로 엽니다. +본문 편집 도구는 페이지 머리 줄의 공통 자리에 둡니다. 상세에서는 본문을 그 자리에서 편집하지 않고, 편집 단추가 수정 화면을 엽니다. 상세 머리 줄의 편집(수정 화면 열기)·버전 기록은 필터·정렬과 같은 둥근 아이콘 도구(`variant="tool"`, 아이콘만, 툴팁에 이름)로 두며, "편집" 같은 글자 단추를 두지 않습니다. 저장·완료는 수정 화면의 머리 줄(휴대폰은 바닥 줄)에 있으며 [읽기·편집](content-and-review.md#읽기편집) 규칙을 따릅니다. 버전 기록 목록은 표준 목록 행(아이콘·시각·설명, 선택 표시)으로 그리고, 주소를 가진 대화상자(라우트 마스킹 `…/history`, 새로 고침하면 다시 열림)로 엽니다. 목차는 지금 읽는 제목을 선택해 보여 줍니다. 지금 읽는 제목은 기준선(스크롤 컨테이너 위쪽 + 그 제목의 `scroll-margin-top`)을 마지막으로 지난 제목입니다. 페이지 끝의 짧은 절은 끝까지 스크롤해도 기준선에 닿지 못해 이 규칙만으로는 선택되지 않고, "바닥이면 마지막 제목"을 덧붙이면 그 사이의 제목을 건너뜁니다. 흔히 보고되는 scrollspy 결함이므로 아래 두 규칙을 더합니다. 목차는 디자인 시스템의 outline 부품을 제어 모드(`activeId`, `onActiveIdChange`)로 쓰고, 선택 규칙만 작은 훅에 둡니다. tocbot 같은 라이브러리는 목차를 직접 그려 디자인 시스템 부품과 겹치므로 쓰지 않습니다. @@ -55,4 +55,4 @@ arc42: "8.6" ## 옆 패널 -주소가 필요 없는 짧은 조회(기록 한 줄의 사실)는 목록 옆 패널([WithPanel](https://github.com/dev-goraebap/dev.goraebap/blob/main/site/src/shared/ui/layouts/side-panel/WithPanel.tsx))로 엽니다. 데스크톱은 오른쪽 26rem 패널, 휴대폰은 바텀시트입니다. 편집이나 긴 내용은 자기 주소를 가진 상세로 엽니다. 목록과 상세를 나란히 두는 받은함은 목록 폭을 조절할 수 있게 하고 휴대폰에서는 목록만 보인 뒤 항목을 페이지로 엽니다. +주소가 필요 없는 짧은 조회(기록 한 줄의 사실)는 목록 옆 패널([WithPanel](https://github.com/dev-goraebap/dev.goraebap/blob/main/site/src/shared/ui/layouts/side-panel/WithPanel.tsx))로 엽니다. 데스크톱은 오른쪽 26rem 패널, 휴대폰은 바텀시트입니다. 편집이나 긴 내용은 자기 주소를 가진 상세로 엽니다. 목록과 상세를 나란히 두는 받은함은 목록 폭을 조절할 수 있게 하되 처음에는 최대 너비로 열고([사이드바](app-layout.md#사이드바)와 같은 규칙) 휴대폰에서는 목록만 보인 뒤 항목을 페이지로 엽니다. diff --git a/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/print.md b/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/print.md new file mode 100644 index 0000000..9a6fd00 --- /dev/null +++ b/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/print.md @@ -0,0 +1,45 @@ +--- +arc42: "8.7" +--- + +# 인쇄와 PDF + +문서·본문을 브라우저 인쇄로 PDF로 남기는 화면을 만들 때 읽습니다. + +## 제목과 덧붙임 + +인쇄 화면의 제목(표지와 각 절 제목)은 시스템이 가진 제목을 씁니다. 본문 안의 제목을 표지로 끌어 쓰지 않습니다. 표지·카드에 날짜·주소·키 같은 덧붙임을 넣지 않습니다. + +## 쪽 크기와 브라우저 머리글·바닥글 + +쪽 크기와 여백은 `@page`로 정합니다(예: `@page { size: A4; margin: 22mm 20mm 24mm; }`). 화면에서는 A4 폭의 읽기 열을 종이처럼 가운데 두고, 인쇄에서는 쪽 여백 안을 채웁니다. + +브라우저가 쪽 가장자리에 넣는 머리글·바닥글(날짜, 탭 제목, 주소)은 `@page`의 여백 상자로 지웁니다. Chrome은 글쓴이가 여백 상자를 둔 쪽(위·아래)에서만 자기 머리글·바닥글을 빼므로, 위에는 빈 상자를, 오른쪽 아래에는 쪽 번호를 둡니다. 그러면 인쇄 창의 "머리글과 바닥글"이 켜져 있어도 아무것도 붙지 않습니다. 첫 쪽(표지)은 쪽 번호도 비우되, 빈 상자는 남겨 브라우저 바닥글을 계속 막습니다. + +```css +@page { + size: A4; + margin: 22mm 20mm 24mm; + @top-center { content: ""; } + @bottom-right { content: counter(page) " / " counter(pages); } +} + +@page :first { + @bottom-right { content: ""; } +} +``` + +## 링크 + +앱 안으로 가는 링크(같은 출처의 주소)는 인쇄본에서 링크를 빼고 글자만 남깁니다. 종이와 PDF에서는 앱 안 주소를 열 수 없기 때문입니다. 바깥 주소(`http(s)`의 다른 출처, `mailto:`)는 남깁니다. + +## 쪽 나눔과 도표 크기 + +- 도표·사진·코드 블록(`figure`, `pre`)은 쪽 사이에서 자르지 않습니다(`break-inside: avoid`). +- 표는 쪽을 넘겨 이어 가되 행(`tr`)은 자르지 않습니다. 긴 표를 통째로 다음 쪽으로 밀면 앞쪽이 비기 때문입니다. +- 제목(`h1`~`h3`) 바로 뒤에서 쪽을 나누지 않습니다(`break-after: avoid`). +- 쪽보다 큰 도표는 비율을 지킨 채 쪽 높이의 약 2/3(`max-block-size: 160mm`, A4 기준) 안으로 줄입니다. + +## 화면 전용 요소 + +화면 전용 도구(복사·편집·크게 보기·내려받기 단추)는 인쇄에서 감춥니다. 편집기가 본문 끝에 늘 두는 빈 문단과 마지막 여백도 지웁니다. 쪽 끝에 걸치면 빈 쪽 하나가 더 생기기 때문입니다. diff --git a/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/typography.md b/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/typography.md index ae8d7cf..6af231f 100644 --- a/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/typography.md +++ b/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/typography.md @@ -19,6 +19,10 @@ arc42: "8.5" 한국어 업무 화면의 기본은 Pretendard Variable, 코드·고정폭은 JetBrains Mono입니다. 프로젝트가 글꼴 파일을 직접 제공하고 시스템 글꼴은 로딩 중 대체로만 씁니다. 날짜·수·금액처럼 위아래로 비교하는 값은 `hasTabularNumbers`(고정폭 숫자)로 자릿수를 맞추고 끝에 정렬합니다. +글꼴 고르기는 화면 설정(내 계정의 표시 설정) 한 곳에 둡니다. 상세 화면마다 글꼴 고르기를 두지 않습니다. 선택지는 고딕(기본, Pretendard)과 명조 두 가지입니다. 명조를 고르면 "본문만"(리치 본문·Markdown 본문을 읽고 쓰는 칸)과 "전체"(온 화면) 가운데 적용 범위를 고르며, 기본은 "본문만"입니다. "전체"는 `html`에 `data-font="serif"`를 붙이고 테마의 본문·제목 글꼴 변수(`--font-family-body`, `--font-family-heading`)를 명조로 바꿉니다. "본문만"이면 본문 칸이 스스로 명조로 씁니다. 이 설정은 브라우저에 저장하는 화면 설정이며 서버에 두지 않습니다. + +앱 화면의 보통 굵기는 450입니다. 명조 글꼴 파일의 굵기 구간을 일반 선언(500부터 SemiBold)대로 두면 온 화면이 굵게 보입니다. 앱용 이름의 `@font-face`를 따로 두어 굵기 구간을 다시 나눕니다(예: 100–499 Regular, 500–649 SemiBold, 650–900 Bold). 글꼴 파일은 쓰일 때만 받습니다(`font-display: swap`). + ## 줄과 넘침 목록의 제목·이름·경로는 한 줄에서 말줄임하고(`maxLines={1}`), 전체 값은 접근 가능한 이름이나 title로 남깁니다. 상세의 제목·본문·주소는 줄을 넘겨 가로로 넘치지 않게 합니다(`overflow-wrap: anywhere`, 부모의 `min-inline-size: 0`). 편집기의 한글 조합·커서·공백은 실제 입력으로 확인합니다. diff --git a/plugins/webcraft/dist/codex/plugins/webcraft/skills/goraebap-mode/SKILL.md b/plugins/webcraft/dist/codex/plugins/webcraft/skills/goraebap-mode/SKILL.md index 759c5ef..0251dc5 100644 --- a/plugins/webcraft/dist/codex/plugins/webcraft/skills/goraebap-mode/SKILL.md +++ b/plugins/webcraft/dist/codex/plugins/webcraft/skills/goraebap-mode/SKILL.md @@ -47,6 +47,7 @@ arc42: "none" - 데이터 소유권·입출력·실패 상태를 정한 뒤 구현합니다. 추측한 재사용을 위해 계층을 늘리지 않습니다. - 역할마다 기본값을 두되(글쓰기·에셋 역할), 도구와 모델은 사용자 설정과 실행 환경을 따르고 없으면 세션 모델을 씁니다. 다중 에이전트, 자동 커밋·병합을 전제하지 않습니다. - [검증](references/verification.md)에 따라 바뀐 부분을 확인합니다. 수행한 검증과 남은 제한을 구분해 답합니다. +- 작업 중 MCP·플러그인 도구의 기능 부족이나 반복 호출을 실제 스키마로 확인하면 우회만 설명하고 끝내지 않고 [도구의 부족을 발견했을 때](references/tool-improvements.md)를 따릅니다. - 커밋·푸시·게시·배포는 사용자가 허용한 범위에서만 합니다. 스킬 자체가 틀렸으면 [지침 개선](playbooks/evolve.md)으로 원인을 고칩니다. diff --git a/plugins/webcraft/dist/codex/plugins/webcraft/skills/goraebap-mode/references/tool-improvements.md b/plugins/webcraft/dist/codex/plugins/webcraft/skills/goraebap-mode/references/tool-improvements.md new file mode 100644 index 0000000..bbb9e17 --- /dev/null +++ b/plugins/webcraft/dist/codex/plugins/webcraft/skills/goraebap-mode/references/tool-improvements.md @@ -0,0 +1,47 @@ +--- +arc42: "none" +--- + +# 도구의 부족을 발견했을 때 + +작업 중 MCP·플러그인 도구의 기능 부족이나 사용 불편을 확인했을 때, 우회 방법만 설명하고 끝내지 않고 개선을 제안하는 기준입니다. Webcraft 지침 자체의 문제는 이 문서가 아니라 [지침 제안](../../manage-workflow/references/proposals.md)을 따릅니다. + +## 개선 후보 + +다음 경우를 개선 후보로 봅니다. + +- 필요한 도구가 없거나, 화면에서 되는 기능을 도구로 할 수 없을 때 +- 여러 대상을 위해 같은 호출을 반복해야 할 때 +- 도구의 이름·설명·인자·응답이 모호해 오해나 재조회를 만들 때 + +사소한 취향 차이나 확인하지 않은 불편은 제안하지 않습니다. + +## 제안하기 전에 확인한다 + +현재 연결의 실제 도구 목록과 인자 스키마를 읽습니다. 기존 도구의 배열 인자 등으로 이미 처리할 수 있으면 그 기능을 쓰고 개선안을 만들지 않습니다. + +인증 만료·권한 부족·일시적 연결 오류는 기능 누락과 구분합니다. 권한 우회나 접근 확대를 해결책으로 제안하지 않습니다. + +## 제안에 담을 것 + +하려던 작업, 확인한 제약이나 반복 단계, 추가·변경하면 나아질 동작을 짧게 씁니다. 반복 횟수와 비용은 실제로 확인한 만큼만 적습니다. + +## 누구에게 제안하는가 + +그 도구를 제공하는 제품의 개발 저장소에서 일하는 중이면 구현 범위와 기대 결과를 제안하고 직접 고칠지 묻습니다. 사용자가 이미 같은 범위를 승인했으면 다시 묻지 않습니다. 동의하면 저장소 지침대로 구현·검증하고, 커밋·푸시·배포는 허용된 범위만 합니다. + +일반 사용자가 도구로 업무를 하는 중이면 개발 환경이나 Git 접근을 요구하지 않습니다. 개선 피드백을 올릴지 묻고, 제품이 정한 접수 절차로 연결합니다. 프로젝트 이름이나 서비스를 쓴다는 사실만으로 제품 개발자라고 판단하지 않습니다. + +## 원래 작업은 계속한다 + +권한 있는 기존 방법으로 원래 작업을 끝낼 수 있으면 계속합니다. 사용자가 금지한 수단(예: 다른 브라우저)으로 바꾸거나 인증을 우회하지 않습니다. 개선 제안에 대한 동의를 원래 작업의 조건으로 만들지 않습니다. 사용자가 거절했거나 이미 처리 중인 같은 문제는 다시 제안하지 않습니다. + +## 피드백 제출의 경계 + +접수처와 제출 도구는 그 제품의 플러그인이나 프로젝트 정책이 정합니다. 모든 도구 개선을 Webcraft 저장소로 보내지 않고, 사용자의 업무 프로젝트에 피드백 이슈를 자동으로 만들지 않습니다. + +피드백 초안에는 하려던 작업, 확인한 제약, 도구·인자의 종류, 기대한 동작과 개선안, 플러그인 판과 에이전트 종류를 씁니다. 업무 데이터·실제 식별자·개인정보·토큰·서명 주소는 재현용 예시로 바꿉니다. 제출 전에 사용자가 내용과 대상을 검토하게 합니다. + +현재 권한과 제출 도구로 등록할 수 있을 때만 직접 등록합니다. 수단이 없으면 초안과 공식 제출 경로를 주고 아직 접수되지 않았다고 알립니다. 로컬에 파일로 남길 수 있으면 그 파일로 전달해도 됩니다. 실제 등록 응답이 없으면 등록했다고 말하지 않고, 다시 시도하기 전에 같은 피드백이 이미 접수됐는지 확인합니다. + +승인받은 범위만 수행합니다. 공개 이슈 제출만 승인했으면 제출만 하고, 구현·검증만 승인했으면 그것만 하고 배포로 넓히지 않습니다. diff --git a/plugins/webcraft/package.json b/plugins/webcraft/package.json index 7918c90..d41b717 100644 --- a/plugins/webcraft/package.json +++ b/plugins/webcraft/package.json @@ -1,6 +1,6 @@ { "name": "webcraft", - "version": "0.9.1", + "version": "0.10.0", "private": true, "type": "module", "engines": { diff --git a/plugins/webcraft/skills/architecture/references/product-plugins.md b/plugins/webcraft/skills/architecture/references/product-plugins.md index 388bea1..f416749 100644 --- a/plugins/webcraft/skills/architecture/references/product-plugins.md +++ b/plugins/webcraft/skills/architecture/references/product-plugins.md @@ -18,6 +18,14 @@ arc42: "8.1" 주소·인증·설정 입력 방식은 각 에이전트의 현재 지원을 확인합니다. 한 에이전트의 변수 치환이나 비밀값 저장 방식이 다른 에이전트에서도 작동한다고 가정하지 않습니다. 필요한 설정만 온보딩에서 확인하고 토큰은 지원되는 로컬 비밀 저장 수단으로 받습니다. 설치 명령이 성공한 뒤에는 실제 연결과 사용자 조회까지 별도로 확인합니다. +## 개선 피드백 받기 + +사용자에게 MCP를 제공하는 제품은 피드백 접수처(예: 이슈 등록 도구, 접수 양식)와 제출 방식을 정하고, 사용자용 스킬에 그 경로를 적습니다. Webcraft를 쓰지 않는 사용자의 에이전트는 Webcraft 지침을 읽지 않습니다. 판단 기준은 [도구의 부족을 발견했을 때](../../goraebap-mode/references/tool-improvements.md)를 따르되, 사용자용 스킬에는 짧은 요약과 그 제품의 접수처·인증 방식만 둡니다. + +MCP 서버를 만드는 제품은 서버 안내(instructions)에도 같은 짧은 요약을 둘 수 있습니다. 플러그인이 없는 에이전트에도 원칙이 닿게 하기 위한 선택지입니다. 스킬과 서버 안내에 긴 절차를 각각 복제하지 않습니다. + +Webcraft가 모든 MCP 서버를 관리한다고 가정하지 않습니다. + ## 변경과 검증 MCP 도구가 바뀌면 서버 계약과 사용자용 설명을 함께 맞춥니다. 스킬에 없는 도구나 아직 제공하지 않는 기능을 약속하지 않습니다. 패키지 버전과 서버의 호환 범위, 이름이 바뀐 경우 기존 사용자의 전환 방법을 기록합니다. diff --git a/plugins/webcraft/skills/backend/SKILL.md b/plugins/webcraft/skills/backend/SKILL.md index d3a1396..c615dc7 100644 --- a/plugins/webcraft/skills/backend/SKILL.md +++ b/plugins/webcraft/skills/backend/SKILL.md @@ -14,6 +14,7 @@ arc42: "8.2, 5, 2" - [조회 포트](references/queries.md): 화면 조회의 소유권·결과 타입·조립 경로를 정할 때 읽습니다. - [모듈 간 조회](references/cross-module-read.md): 다른 모듈의 표시값·검색·정렬이 필요할 때 읽습니다. - [도메인 오류와 서비스 오류](references/errors.md): 규칙 위반·부재·권한·충돌을 처리할 때 읽습니다. +- [사용자가 올린 파일과 고른 주소](references/untrusted-content.md): 올린 HTML을 화면 안에 보이거나, 사용자가 준 주소를 서버가 읽을 때 읽습니다. - [코드 규칙](references/code-style.md): Java 파일·DTO·의존성을 작성할 때 읽습니다. 완료 시 프로젝트의 정적 검사·테스트와 변경한 유스케이스의 성공·실패·권한을 검증합니다. HTTP 계약이 바뀌면 명세·생성 타입·호출자도 함께 맞춥니다. diff --git a/plugins/webcraft/skills/backend/references/untrusted-content.md b/plugins/webcraft/skills/backend/references/untrusted-content.md new file mode 100644 index 0000000..e55780a --- /dev/null +++ b/plugins/webcraft/skills/backend/references/untrusted-content.md @@ -0,0 +1,51 @@ +--- +arc42: "8.2" +--- + +# 사용자가 올린 파일과 고른 주소 + +사용자가 올린 HTML을 본문 안에 미리 보이거나, 사용자가 고른 웹 주소를 서버가 읽어(링크 카드 등) 보여 줄 때 적용합니다. 둘 다 남의 내용을 우리 출처나 우리 서버 망에서 실행·호출하게 되는 일이라 따로 경계를 둡니다. + +## 올린 HTML을 틀 안에 보이기 + +저장소의 서명 주소(presigned URL)나 `/api/**` 응답으로는 화면 안 틀(`iframe`)에 보일 수 없습니다. API 응답에는 `X-Frame-Options: DENY`가 붙고, 저장소 주소는 응답 헤더를 우리가 정할 수 없기 때문입니다. 그래서 앱이 전용 주소로 직접 내보냅니다. 예: `GET /api/embeds/{exp}.{sig}/{key}`. `{key}`는 저장 키를 URL-safe Base64로 쓴 것, `{exp}`는 만료 시각(초), `{sig}`는 서명입니다. + +| 항목 | 기준 | +| --- | --- | +| 서명 | 서버 비밀키로 HMAC-SHA256을 계산하고, 서명 대상에 용도 문자열(예: `html-embed`)·만료 시각·키를 함께 넣습니다. 용도 문자열이 있어 다른 서명과 섞여 쓰이지 않습니다. 비교는 시간이 일정한 비교(`MessageDigest.isEqual`)로 합니다 | +| 만료 | 1시간. 만료 시각을 10분 단위로 올려 가까운 시각에 만든 주소가 같아지게 하여 브라우저가 캐시할 수 있게 합니다. 본문을 다시 읽으면 새 주소를 받습니다 | +| 권한 | 서명한 주소가 곧 권한입니다(서명 주소와 같음). 권한 확인은 기존 파일 열기 주소(예: 모듈의 `…/files/{id}`)가 하고, HTML을 제자리에서 열 때만 전용 주소로 돌려보냅니다. 내려받기는 기존 경로를 씁니다 | +| 응답 | `Content-Type: text/html;charset=UTF-8`, `Cache-Control: private, max-age=300`. 서명이 틀리거나 만료됐거나 객체가 없으면 404입니다. 이 주소는 브라우저가 돌려보내기로만 오므로 OpenAPI 문서에서 뺍니다 | + +보안 체인(Spring Security)은 그 주소만의 `SecurityFilterChain`을 다른 체인보다 앞(`@Order(0)`, `securityMatcher("/api/embeds/**")`)에 둡니다. + +- GET·HEAD만 허용하고 나머지는 `denyAll`입니다. +- 세션 없음(`STATELESS`), CSRF·폼 로그인·HTTP Basic·로그아웃·요청 캐시를 끕니다. +- `X-Frame-Options`를 끕니다. +- `Content-Security-Policy: sandbox allow-scripts allow-modals allow-forms allow-popups; frame-ancestors 'self'`, `Referrer-Policy: no-referrer`를 붙입니다. + +응답 문서는 출처가 없는 상태(sandbox, `allow-same-origin` 없음)로 실행되어 이 앱의 쿠키·저장소·API에 닿지 못하고, 우리 화면 안 틀에서만 열립니다. 주소를 새 탭으로 바로 열어도 같은 정책이 붙습니다. + +화면 쪽 틀도 같은 격리를 겁니다: `sandbox="allow-scripts allow-modals allow-forms allow-popups"`, `referrerPolicy="no-referrer"`. 크게 보기 창의 "새 탭에서 열기"는 서버 주소일 때만 둡니다. 저장 전의 `blob:` 주소는 새 탭에서 앱의 출처로 열리기 때문입니다. + +서명·만료·변조된 서명·다른 키·잘못된 형식을 단위 테스트로 확인합니다. + +## 사용자가 고른 주소를 서버가 읽기 + +사용자가 고른 주소로 서버가 안쪽 망(사내 서비스, 클라우드 메타데이터)을 부르지 않게 합니다(SSRF 막기). + +받는 주소는 HTTPS, 포트 443(생략 포함)만입니다. 사용자 정보(`user:pass@`)가 든 주소, 2000자 넘는 주소는 거부합니다. + +호스트의 모든 IP가 공개 주소일 때만 부릅니다. 막는 대역은 미지정(`0.0.0.0/8`, `::`), 루프백, 링크 로컬, 사설(site local), 멀티캐스트, CGNAT 공유 대역 `100.64.0.0/10`, 벤치마크 `198.18.0.0/15`, 예약 `240.0.0.0/4`, IPv6 고유 로컬 `fc00::/7`입니다. IPv4를 품은 IPv6(IPv4-mapped `::ffff:a.b.c.d`, IPv4-compatible)와 NAT64 `64:ff9b::/96`은 안의 IPv4로 다시 판정합니다. + +리다이렉트는 자동으로 따르지 않습니다(`HttpClient.Redirect.NEVER`). `Location`을 직접 풀어 다음 주소에도 같은 검사를 하고 최대 3번까지 따릅니다. + +연결·응답은 각 5초이고, 상태 200이고 `Content-Type`이 HTML일 때만 읽습니다. 본문은 앞 512KB만 읽습니다(메타데이터는 `head`에 있음). 쿠키·토큰 등 우리 쪽 인증을 보내지 않습니다. + +검사할 때 이름을 해석하고 연결할 때 다시 해석하면, 그 사이에 DNS 답이 바뀌는 공격(DNS rebinding)은 위 검사로 막지 못합니다. 검사한 IP로 연결을 고정하거나, 안쪽 망으로 나가지 못하는 출구 프록시·방화벽으로 막습니다. 둘 다 못 하면 그 한계를 프로젝트 기록에 남깁니다. + +결과는 제목·설명·대표 사진·사이트 이름을 `og:*`, `twitter:*`, `description`, `<title>` 순으로 읽습니다. 제목은 200자, 설명은 300자에서 자릅니다. 대표 사진 주소는 페이지 주소 기준으로 풀어 HTTPS일 때만 받습니다(HTTP·`data:`는 사진 없음). 대표 사진은 읽는 사람의 브라우저가 그 사이트에서 직접 받으며, 화면은 `referrerPolicy="no-referrer"`로 받습니다. + +결과는 6시간 기억합니다(최대 1000개, 오래 안 쓴 것부터 버림). 읽지 못한 주소는 오류 대신 주소(와 호스트)만 있는 카드로 돌려줘 본문이 죽은 링크로 깨지지 않게 합니다. 외부 사이트를 기다리는 동안 DB 트랜잭션을 잡지 않습니다(트랜잭션 서비스가 아닌 컴포넌트로 둠). + +공개·사설·루프백·CGNAT·IPv4-mapped·NAT64·포트·스킴 판정, 리다이렉트 횟수, 메타 읽기·자르기·엔티티 풀기를 단위 테스트로 확인합니다. diff --git a/plugins/webcraft/skills/design-system/SKILL.md b/plugins/webcraft/skills/design-system/SKILL.md index e60ca68..ad228a8 100644 --- a/plugins/webcraft/skills/design-system/SKILL.md +++ b/plugins/webcraft/skills/design-system/SKILL.md @@ -31,6 +31,7 @@ arc42: "8.5, 2" | 대기·빈 상태·실패·전환 | [데이터 상태](references/data-states.md) | | 표면 고르기·누르는 대상·키보드 | [입력과 상호작용](references/interaction-patterns.md) | | UI 문구·본문 편집·검증 | [문구·본문과 검증](references/content-and-review.md) | +| 본문 인쇄·PDF 저장 | [인쇄와 PDF](references/print.md) | | 지침·코드·가이드 견본의 연결 | [패턴 카탈로그](references/pattern-catalog.md) | | 프로젝트 지정이 없는 기본 화면 | [레퍼런스 사용과 갱신](references/reference-kit.md) | diff --git a/plugins/webcraft/skills/design-system/notes.md b/plugins/webcraft/skills/design-system/notes.md index 47bbeb7..0038c56 100644 --- a/plugins/webcraft/skills/design-system/notes.md +++ b/plugins/webcraft/skills/design-system/notes.md @@ -63,3 +63,19 @@ arc42: "9" - 결정(사용자): 가이드를 Astro에서 키트와 같은 Vite + React + TanStack Router + MDX SPA로 옮겼습니다. Astro 때문에 둔 우회(셸 유지, 테마 스타일 붙잡기, 넘겨받을 때까지 감추기, 본문 옮겨 끼우기)가 사라지고, 전환은 라우터 대기 + 180ms 덮개 + 160ms 페이드로 키트 규칙 그대로 구현했습니다. 첫 화면은 키트의 bootCover·BootCover를 씁니다. - 사이트는 플러그인 지침 전체를 보여 줍니다. 지침 문서는 스킬 Markdown을 원본 그대로 빌드합니다(사본 없음). 문서별 정적 HTML(SSG)은 아직 없습니다. 공개할 때 프리렌더를 붙입니다. + +## 2026-11-10 · 사내 업무 앱의 리치 에디터와 화면 규칙 + +- 증상: 키트의 본문 칸은 자리를 보이는 Tiptap 예시뿐이라, 표·블록 메뉴·미디어처럼 실제 업무 본문에 필요한 동작을 지침과 견본으로 확인할 수 없었습니다. +- 수정: 사내 업무 앱에서 다듬은 리치 에디터(표·블록 메뉴·선택 서식 도구·링크 입력·올리는 칸·미디어·파일 카드·도표·HTML 임베드·링크 카드)를 키트 본문 칸으로 옮겼습니다. 키트 리비전 8입니다. +- 기존 규칙을 바꾼 것(이미 Webcraft를 따르는 프로젝트에 영향): 대화상자 안 여백을 데스크톱 40/24/12px로 바꿨습니다(전에는 1rem·0.5rem). 본문 편집을 상세 안 전환에서 전용 수정 화면으로 옮겼습니다. 너비를 조절하는 옆 목록의 기본 너비를 최대값으로 정했습니다(키트 사이드바 380px, 받은함 목록 560px). 본문 칸의 Tiptap 예시를 자리 표시에서 참고 구현으로 바꿨고, 본문의 1단계 제목을 쓰지 않게 했습니다. +- 원인(옮기며 겪은 함정): + 1. `@scope (…) { }` 안의 규칙은 같은 우선순위의 바깥 규칙을 이깁니다. 덮어쓰는 규칙은 같은 scope 안에 두거나 우선순위를 올립니다. + 2. 조상에 `transform`이 있으면 `position: fixed` 메뉴의 기준이 그 조상이 되어 엉뚱한 곳에 뜹니다. 손잡이 정렬에 transform 대신 margin을 씁니다. + 3. 표 열 너비 손잡이가 셀 밖으로 2px 나가 있으면 표를 감싼 칸에 스크롤바가 생겼다 사라지며 행이 흔들립니다. 손잡이는 셀 안에 두고 감싸는 칸은 `overflow-y: hidden`으로 둡니다. + 4. NodeView의 바깥 감싸개(`.react-renderer`)가 실제 블록이므로, 사진 나란히 놓기 같은 배치 규칙은 감싸개에 `:has()`로 겁니다. +- 키트 규칙 때문에 원본과 달라진 점: 색을 hex 대신 테마 변수로 저장합니다. Stone 테마의 색 토큰을 그대로 쓰면 색끼리 구분되지 않아 테마 색의 색상(hue)으로 값을 만들었습니다. 갈색은 테마에 없어 주황 색상을 낮은 채도로 씁니다. 메뉴·도구는 Astryx 부품(`Button`, `HStack`/`VStack`, `Kbd`, `Selector` 등)으로 그리고, 휴대폰 도구는 44px입니다. +- 원본에서 고친 결함: 포인터로 연 하위 메뉴가 누르면 닫히던 것, 블록 메뉴가 열린 채 Alt+↑/↓가 먹지 않던 것, 편집 전환만으로 "고친 내용 있음"이 되던 것. +- 키트에 넣지 않은 것: 토글·열 배치·수식·콜아웃·코드 강조, 댓글·버전 기록, 글꼴 설정 화면, 인쇄 화면. +- 확인: `npm run site:check`, `npm run site:test`(28개 파일, 349개 테스트 통과), `npm run site:build`, `npm test`가 통과했습니다. 가이드 사이트에서 1440 라이트·다크, 390 휴대폰으로 견본을 봤습니다. +- 범위: 실제 서버(올리기, 링크 미리보기, HTML 격리 응답), 터치 기기, 화면 낭독기, 2560 화면은 확인하지 않았습니다. diff --git a/plugins/webcraft/skills/design-system/references/app-layout.md b/plugins/webcraft/skills/design-system/references/app-layout.md index 9775105..0ba5d76 100644 --- a/plugins/webcraft/skills/design-system/references/app-layout.md +++ b/plugins/webcraft/skills/design-system/references/app-layout.md @@ -19,6 +19,8 @@ arc42: "8.6, 5" 열 때 340px, 220~380px로 끌어 조절합니다(큰 화면 배율만큼 함께 커집니다). 다른 제품·지침의 기본(250~320px)보다 넓은 것은 메뉴 이름과 하위 항목을 말줄임 없이 보이기 위한 선택입니다. 좁은 노트북에서 미리보기 패널과 함께 열 때 목록이 좁아지면 사용자가 사이드바를 줄이거나 접습니다. 머리 줄은 페이지 머리 줄과 같은 52px에 로고(홈 링크)·상자 버튼(전역 검색 등)·접기를 둡니다. 접으면 머리를 세로로 쌓고 접기 버튼은 바닥으로 옮기며, 사라진 묶음 이름 대신 선으로 묶음을 나눕니다. 접고 펼친 뒤 초점은 같은 버튼에 남습니다. +사용자가 끌어 너비를 바꿀 수 있는 옆 목록(사이드바, 받은함 목록, 문서의 페이지·목차 목록)은 처음에 최대 너비로 엽니다. 사용자가 줄인 너비를 저장하는 제품은 그 값을 우선합니다. 옆 목록의 이름이 처음부터 잘리지 않게 하고, 좁히는 것은 사용자가 고르게 하기 위해서입니다. + 메뉴 묶음은 이름으로 나누고 첫 묶음은 이름을 숨길 수 있습니다. 펼친 하위 항목도 상위 항목끼리와 같은 2px(0.125rem) 간격으로 띄웁니다(Astryx SideNav 기본은 하위 묶음 안 간격이 0입니다). 펼친 묶음 끝에는 `--spacing-2`(8px)를 더해 다음 상위 항목과 [경계를 보이고](spacing.md)(항목 사이 2px와 합쳐 10px), 접은 묶음에서는 이 간격이 함께 사라집니다. 하위 메뉴 묶음(`openGroups`)은 메뉴가 짧은 업무 앱에서는 모두 펼치는 `'all'`(기본)을, 묶음이 많은 문서 사이트에서는 현재 위치가 든 묶음만 펼치는 `'current'`를 씁니다. `'current'`에서는 다른 묶음으로 옮겨 가면 그 묶음도 펼치고, 사용자가 직접 연 묶음은 그대로 둡니다. 현재 항목은 주소의 가장 긴 접두사로 정하며 검색 조건으로 나뉜 메뉴는 조건까지 비교합니다. 선택 항목은 Bold Duotone입니다. 메뉴 글자는 보조 글자색으로 두고 포인터가 올라온 항목과 현재 항목만 본문 글자색으로 올립니다. 메뉴가 길어져도 무겁지 않고 현재 위치가 먼저 읽힙니다. 하단 탭과 더보기 시트도 같은 규칙입니다. 내 계정·관리처럼 따로 묶인 공간은 주 메뉴를 대체하고 위에 "← 공간 이름"을 둡니다. ## 휴대폰 diff --git a/plugins/webcraft/skills/design-system/references/composed-components.md b/plugins/webcraft/skills/design-system/references/composed-components.md index cd85c1c..d79cbb2 100644 --- a/plugins/webcraft/skills/design-system/references/composed-components.md +++ b/plugins/webcraft/skills/design-system/references/composed-components.md @@ -17,9 +17,11 @@ arc42: "8.8" | 행 순서 바꾸기 | 드래그 손잡이·`Alt`+방향키 이동·`Esc` 취소·위치 음성 안내 | [reorder](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/reorder) | | 파일 올리기 칸 | 고르기·끌어 놓기·형식과 크기 확인 | [upload](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/upload) | | 상태 화면 | 빈 목록·결과 없음·없는 대상·실패의 그림과 문구 | [state](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/state), [기준](data-states.md) | -| 본문 칸 | 종이 위의 리치 본문 | [editor](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/editor) | +| 본문 칸 | 종이 위의 리치 본문: 블록 메뉴·선택 서식 도구·표·미디어 | [editor](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/editor) | | 그룹 머리 | 그룹 이름·개수·접기, 스크롤에 붙는 띠 | [list-group](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/list-group) | +구현이 없는 조합을 메뉴에 빈 항목으로 만들지 않습니다. 제품 고유 조합(아이콘 표시 고르기, 전역 검색 팔레트, 프로필 패널)은 프로젝트가 소유합니다. + ## 곁에 여는 내용 버튼 곁에 여는 것은 데스크톱에서 팝오버, 휴대폰에서 언제나 바텀시트입니다. 휴대폰에 팝오버를 두지 않습니다. 내용은 고른 뒤 닫을 수 있도록 `close`를 받습니다. `AdaptivePopover`에 `isOpen`을 주면 바깥에서 열림을 제어하고 `onOpenChange`에서 변경을 받아 외부 상태에 반영합니다. 외부 제어 없이 최초에 열어 둘 때는 `defaultOpen`을 쓰며, `trigger`를 대신 클릭하는 우회는 쓰지 않습니다. 시트의 손잡이는 위 24px에 겹치므로 내용은 그 아래에서 시작하고 좌우 12px, 아래 20px을 씁니다. 이 여백은 한 부품(SheetBody)에 두어 시트마다 다시 쓰지 않습니다. 내용이 짧으면 시트가 화면 대부분을 차지하지 않게 하고(hug), 검색·긴 목록은 키보드를 고려해 높게(tall) 엽니다. @@ -50,6 +52,53 @@ arc42: "8.8" ## 본문 칸 -제목·본문 양식의 본문은 프로젝트가 채택한 리치에디터를 테두리와 서식 도구 막대 없이 종이처럼 둡니다. 서식은 에디터의 단축키·입력 규칙·선택 도구로 합니다. 앱 화면의 본문 토큰(13px, 15px `--text-large-size`)은 목록·도구가 빽빽한 화면용으로 남기고, 긴 글을 읽는 본문 칸과 상세 본문 글자는 데스크톱·휴대폰 모두 16px, 줄 높이 1.7이며 제목은 `--text-heading-2-size`입니다. 테마가 문단에 글자 크기와 줄 높이를 직접 주므로 문단·목록·인용이 크기와 줄 높이를 함께 상속하게 합니다. 쓰는 칸과 그 글을 읽는 상세 본문은 같은 크기로 둡니다. 키트의 Tiptap 예시는 자리를 보이기 위한 것이며 특정 에디터를 기본 의존성으로 복사하지 않습니다. +제목·본문 양식의 본문은 프로젝트가 채택한 리치에디터를 테두리와 서식 도구 막대 없이 종이처럼 둡니다. 서식은 에디터의 단축키·입력 규칙·선택 도구로 합니다. 앱 화면의 본문 토큰(13px, 15px `--text-large-size`)은 목록·도구가 빽빽한 화면용으로 남기고, 긴 글을 읽는 본문 칸과 상세 본문 글자는 데스크톱·휴대폰 모두 16px, 줄 높이 1.7이며 제목은 `--text-heading-2-size`입니다. 테마가 문단에 글자 크기와 줄 높이를 직접 주므로 문단·목록·인용이 크기와 줄 높이를 함께 상속하게 합니다. 쓰는 칸과 그 글을 읽는 상세 본문은 같은 크기로 둡니다. 키트의 본문 칸은 Tiptap 3(`@tiptap/*` 3.32, 표는 prosemirror-tables) 위에 만든 참고 구현이며, 쓰는 칸은 `RichDraft`, 읽는 본문은 `RichBody`로 씁니다. 프로젝트가 다른 에디터를 채택했으면 그 에디터로 같은 동작을 만듭니다. 키트 코드는 채택할 때만 프로젝트 소유 코드로 옮깁니다. -구현이 없는 조합을 메뉴에 빈 항목으로 만들지 않습니다. 제품 고유 조합(아이콘 표시 고르기, 전역 검색 팔레트, 프로필 패널)은 프로젝트가 소유합니다. +호스트는 올리기 함수(`upload`), 링크 미리보기 함수(`readPreview`), 링크로 고를 문서 목록(`links`), 앱 안 이동(`onNavigate`)을 줍니다. 키트에는 서버가 없어 견본은 가짜 함수를 씁니다. + +### 제목 + +화면은 늘 대상의 시스템 제목(제목 칸)을 보이므로 본문에서는 1단계 제목을 쓰지 않습니다. 제목이 둘이 되지 않게 하려는 것입니다. 본문 제목은 2·3·4단계이며 `# `를 입력해도 제목이 되지 않습니다. 예전 본문에 1단계 제목이 있으면 열 때 2단계로 내립니다. 인쇄·PDF의 표지와 절 제목도 시스템 제목을 씁니다([인쇄와 PDF](print.md)). + +### 블록 손잡이와 블록 메뉴 + +편집 중 블록에 포인터를 올리면 글줄 왼쪽(약 35px)에 손잡이가 뜹니다. 손잡이를 끌면 블록을 옮기고, 누르면 블록 메뉴가 열립니다. 목록 안의 글 위에서는 그 목록의 손잡이가 보입니다. 메뉴는 다음 묶음으로 나눕니다. + +- 색(글자색·배경색 하위 메뉴), 종류 바꾸기(하위 메뉴), 서식 지우기 +- 복제(Ctrl/⌘+D), 복사(Ctrl/⌘+C), 블록 링크 복사 +- 위로·아래로 옮기기(Alt+↑/↓) +- 지우기(Delete) + +메뉴를 열지 않고 블록을 고른 채 같은 단축키를 눌러도 동작합니다. 블록 링크는 지금 주소에 `#block-<id>`를 붙인 주소이며, 그 주소로 열면 그 블록으로 스크롤합니다. + +### 선택 서식 도구와 링크 + +글을 고르면 서식 도구 막대가 뜹니다. 순서는 블록 종류, 굵게·기울임·밑줄·취소선·코드, 링크, 색, 더 보기(위·아래 첨자, 정렬, 내어쓰기·들여쓰기, 서식 지우기)입니다. 막대는 창이 아니라 본문 칸의 좌우를 기준으로 위치를 잡습니다. 그래서 글줄 첫머리를 골라도 옆 메뉴 밑에 가리지 않습니다. 위 공간이 모자라면 아래로 뜹니다. + +링크는 대화상자 대신 서식 도구 아래의 입력 칸에서 겁니다. 주소를 넣거나(`example.com`이면 `https://`를 붙입니다) 호스트가 준 문서 목록(`links`)에서 이름으로 찾아 고릅니다. 고른 문서는 앱 안 주소로 걸립니다. 읽는 화면에서 앱 안 주소로 가는 링크는 새 탭 대신 그 화면으로 이동합니다(`RichBody`의 `onNavigate`). + +### 표 + +표는 모서리를 둥글리지 않고 바깥 양옆 세로선 없이 그리며, 머리 행에 옅은 바탕을 깝니다. 포인터가 있는 칸(없으면 커서가 있는 칸)의 행 왼쪽과 열 위에 손잡이가 뜹니다. 손잡이를 누르면 그 행·열의 메뉴가 열립니다. 메뉴에는 위·아래(열은 왼·오른쪽)에 넣기, 옮기기, 머리 행·열 켜기와 끄기, 지우기가 있습니다. 표 왼쪽 위 모서리의 단추는 표 전체의 메뉴(셀 병합·나누기·머리글·표 삭제)를 엽니다. 표 끝의 `+` 막대로 행·열을 더하고, 열 경계를 끌어 너비를 바꿉니다. 명령 하나가 실행 취소 한 단계입니다. + +읽기로 열었다가 편집으로 바꾸는 본문에서도 열 너비를 끌 수 있어야 합니다. 그래서 표 확장은 너비 조절을 늘 등록하고, 실행 취소 때 남은 열 너비를 지웁니다(`ResizableTable`, `SyncedTableView`). + +### 올리는 칸과 미디어 + +`/` 목록에서 파일이나 HTML 임베드를 고르면 파일 고르기를 바로 열지 않고 그 자리에 점선 칸을 띄웁니다. 칸을 누르면 파일 고르기가 열리고, 파일을 끌어 놓을 수도 있습니다. ✕나 Esc로 닫습니다. 칸은 문서에 저장되지 않는 화면 표시(decoration)라 그냥 닫아도 본문에 흔적이 남지 않습니다. HTML 칸은 `.html` 파일만 고릅니다. + +미디어 블록은 머리 띠·상자 틀·사진 아래 이름 줄 없이 그립니다. 포인터를 올리거나 초점이 들어오면 오른쪽 위에 도구(크게 보기·너비·내려받기)가 뜹니다. 사진은 원본 비율로 자리를 먼저 잡고 대표색으로 칠한 뒤 나타납니다. 너비를 25%나 50%로 줄인 사진끼리는 한 줄에 나란히 놓입니다. 파일은 확장자별 아이콘 글자와 색(PDF·워드·엑셀·발표 자료·압축·텍스트·코드·HTML 등)을 가진 카드로 보입니다. + +도표(Mermaid)는 칸 폭을 채우되 원래 크기의 3배까지, 높이 36rem 안에서 그립니다. 누르면 크게 보기 창으로 열리고, 원문 편집 칸은 테마 색을 따릅니다. Mermaid는 CSS 변수를 읽지 못하므로 도표 색표는 테마 파일의 값(`diagramColors`)으로 줍니다. + +HTML 임베드는 올린 HTML을 본문 안의 격리된 틀로 미리 봅니다. 틀은 `sandbox="allow-scripts allow-modals allow-forms allow-popups"`와 `referrerPolicy="no-referrer"`를 쓰고 `allow-same-origin`을 주지 않습니다. 크게 보기는 창을 거의 채우는 `<dialog>`에 같은 격리 틀을 두며, 닫기 단추와 Esc로 닫습니다. 새 탭 열기는 서버 주소일 때만 둡니다. 저장 전의 `blob:` 주소는 새 탭에서 앱의 출처로 열리기 때문입니다. 서버 쪽 규칙은 [사용자가 올린 파일과 고른 주소](../../backend/references/untrusted-content.md)를 따릅니다. + +### 링크 카드 + +링크 카드는 `/` 목록의 "링크 카드"로 넣는 웹 주소 블록입니다. 제목·설명·대표 사진을 카드로 보이고, 사진은 오른쪽에서 카드 높이를 채웁니다. 사진이 없거나 받지 못하면 같은 자리를 테두리로 나누고, 테마 강조색을 옅게(12%) 깐 바탕에 그림 아이콘과 "대표 이미지가 없습니다"를 둡니다. 읽는 화면에서는 카드 전체가 새 탭 링크입니다. 편집 화면에서는 카드를 누르면 블록이 선택되고, 열기와 주소 고치기는 위에 뜨는 도구로 합니다. 주소를 넣는 칸은 올리는 칸과 같은 점선 칸입니다. 미리보기는 서버가 읽어 주며 같은 backend 문서를 따릅니다. + +### 메뉴와 색 + +떠 있는 메뉴(블록 메뉴·선택 서식 도구·`/` 목록·표 메뉴)는 그림자를 줄이고 1px 테두리로 구분합니다. 본문 속 인라인 코드는 바탕 없이 고정폭 글자에 옅은 밑줄로 보입니다. + +글자색·배경색은 테마 색에서 만든 값(CSS 변수)으로 저장해 라이트·다크를 따릅니다. 다른 곳에서 가져온 본문에 직접 지정한 색(hex)은 그대로 보이지만 테마를 따르지 않습니다. diff --git a/plugins/webcraft/skills/design-system/references/content-and-review.md b/plugins/webcraft/skills/design-system/references/content-and-review.md index 7f9910d..37c16f9 100644 --- a/plugins/webcraft/skills/design-system/references/content-and-review.md +++ b/plugins/webcraft/skills/design-system/references/content-and-review.md @@ -23,9 +23,11 @@ arc42: "8.7" ## 읽기·편집 -본문은 읽기에서 시작하고 명시적인 편집 전환을 제공합니다. 편집기는 프로젝트 공통 UI를 통해 연결하고 저장 형식·첨부 수명주기·초안·버전 충돌을 제품 계약과 맞춥니다. 특정 편집기 소스를 기본 의존성으로 복사하지 않습니다. +본문은 읽기에서 시작하고 명시적인 편집 전환을 제공합니다. 상세의 편집 단추는 늘 그 대상의 수정 화면(자기 주소, 예: `…/edit`)을 엽니다. 상세 안에서 본문을 그 자리에서 편집 상태로 바꾸지 않습니다. 편집기는 프로젝트 공통 UI를 통해 연결하고 저장 형식·첨부 수명주기·초안·버전 충돌을 제품 계약과 맞춥니다. 특정 편집기 소스를 기본 의존성으로 복사하지 않습니다. -명시 저장을 선택한 제품은 저장·Ctrl/Command+S·편집 완료를 같은 저장 경로로 연결합니다. 저장 중·완료·실패를 구분하고 충돌 시 초안을 보존합니다. 프로젝트가 자동 저장을 정했으면 그 정책을 우선합니다. 읽기/편집 전환 때문에 불필요하게 편집기를 다시 만들어 Undo를 잃지 않습니다. +수정 화면에는 제목과 본문만 두고 속성 열과 활동(이력·댓글)은 두지 않습니다. 데스크톱은 머리 줄에 저장 상태 표시와 저장·취소·완료를 이 순서로 둡니다. 휴대폰은 바닥 줄에 저장·완료를 두고 머리 줄의 뒤로(←)가 취소입니다. 키트 부품은 `BodyEditPage`([코드](https://github.com/dev-goraebap/dev.goraebap/blob/main/site/src/shared/ui/patterns/compose/BodyEditPage.tsx))입니다. Ctrl/Command+S는 저장, 완료는 저장한 뒤 상세로 돌아가기, 취소는 저장하지 않고 상세로 돌아가기입니다. 고친 내용이 있으면 취소 전에 "저장하지 않은 내용이 있습니다"로 묻고 "버리고 나가기"와 "계속 작성"을 고르게 합니다. 고친 것이 없으면 묻지 않고 돌아갑니다. 읽는 화면과 고치는 화면을 나누면 상세의 속성·활동과 편집 상태가 섞이지 않고, 편집 중인 화면도 주소가 있어 새로고침·뒤로가기의 결과가 분명해집니다. + +명시 저장을 선택한 제품은 저장·Ctrl/Command+S·완료를 같은 저장 경로로 연결합니다. 저장 중·완료·실패를 구분하고 충돌 시 초안을 보존합니다. 프로젝트가 자동 저장을 정했으면 그 정책을 우선합니다. 저장할 때 불필요하게 편집기를 다시 만들어 Undo를 잃지 않습니다. 변경 기록과 댓글을 한 활동 영역에서 읽을 수 있게 하되 위치 댓글·인용 댓글·답글 깊이는 제품이 정합니다. 레퍼런스에 있던 활동 UI를 모든 제품의 필수 기능으로 추가하지 않습니다. diff --git a/plugins/webcraft/skills/design-system/references/dialog.md b/plugins/webcraft/skills/design-system/references/dialog.md index 9fe2e7c..44333c9 100644 --- a/plugins/webcraft/skills/design-system/references/dialog.md +++ b/plugins/webcraft/skills/design-system/references/dialog.md @@ -9,12 +9,14 @@ arc42: "8.6" | 항목 | 기준 | | --- | --- | | 위치 | 위에서 12dvh, 아래도 같은 여유. 최대 높이는 `100dvh - 2 × 12dvh` | -| 안 여백 | 좌우·바깥 1rem, 머리 아래·본문 위 0.5rem | +| 안 여백 | 데스크톱(64rem 이상)은 머리·본문·바닥의 좌우 40px(`--spacing-10`), 위아래 24px(`--spacing-6`), 머리 아래와 본문 위 12px(`--spacing-3`). 휴대폰(64rem 미만)은 좌우·바깥 1rem(16px), 머리 아래·본문 위 0.5rem. 모든 대화상자에 같은 값을 씁니다 | | 바닥 구분선 | 기본은 없습니다. 긴 본문과 바닥이 붙어 보일 때만 선택합니다 | | 첫 초점 | 제목(`tabIndex=-1`). 입력이 주인 양식은 첫 입력, 확인 창은 취소 | | 닫기 | 닫기 X·Esc·배경 누르기. 닫으면 초점이 연 요소로 돌아갑니다 | | 휴대폰 | 짧은 확인은 같은 틀, 양식은 전체 화면이나 페이지. 전체 화면은 안전 영역을 피합니다 | +안 여백을 한 값으로 정해 대화상자마다 여백이 달라지지 않게 합니다. 대화상자 안에서 리치 본문을 고칠 때 글줄 왼쪽 약 35px에 뜨는 블록 손잡이가 대화상자 테두리 밖으로 나가지 않는 것도 데스크톱 좌우 40px의 이유입니다. + 위치를 지정한 Dialog에 공통 margin을 더하면 top과 겹쳐 아래로 밀립니다. 위치와 남은 높이를 함께 정하고 짧은 화면에서도 닫기와 마지막 내용에 닿는지 확인합니다. 너비와 최소 크기는 쓰임별로 정하고 준비·실패·완료 상태에서 같은 기준을 씁니다. 비어 있는 로딩 틀의 크기를 내용의 고유 너비로 삼아 아주 작게 열지 않습니다. 긴 상세의 최소 높이는 작은 화면의 최대 높이를 넘지 않게 제한합니다. 짧은 확인 창과 본문이 있는 상세를 이유 없이 같은 크기로 강제하지 않습니다. diff --git a/plugins/webcraft/skills/design-system/references/layout-patterns.md b/plugins/webcraft/skills/design-system/references/layout-patterns.md index 0b81958..6c0b232 100644 --- a/plugins/webcraft/skills/design-system/references/layout-patterns.md +++ b/plugins/webcraft/skills/design-system/references/layout-patterns.md @@ -39,7 +39,7 @@ arc42: "8.6" 본문이 있는 모든 상세는 속성 열을 같은 순서로 둡니다. 본문에 제목이 있으면 맨 위에 "목차" 묶음, 그 아래 대상별 속성, 맨 끝에 "정보" 묶음(작성자 아바타·작성일·수정일과 고친 사람·버전 수)을 두고, 모르는 값은 줄째 뺍니다. 목차와 버전 줄도 다른 속성과 같은 속성 행 부품으로 그려 아이콘·글자 자리를 맞춥니다. 누르면 목차는 테두리 있는 팝오버, 버전은 버전 기록 대화상자를 엽니다. 휴대폰은 목차를 오른쪽 아래 떠 있는 둥근 버튼과 바텀시트로 둡니다. 편집기 가장자리에 목차 표시를 붙이지 않습니다. 상세의 더 보기(…) 메뉴는 페이지 머리 줄 한 곳에 둡니다. -본문 편집 도구는 페이지 머리 줄의 공통 자리에 둡니다. 읽기에서 시작하고 명시적인 편집 전환을 둡니다. 데스크톱 머리 줄의 편집 전환·편집 마침·버전 기록은 필터·정렬과 같은 둥근 아이콘 도구(`variant="tool"`, 아이콘만, 툴팁에 이름)로 두며, "편집" 같은 글자 버튼을 머리 줄에 두지 않습니다. 저장은 상태가 바뀌는 동작이라 글자를 유지하고, 휴대폰은 바닥 줄의 저장·완료를 그대로 둡니다. 버전 기록 목록은 표준 목록 행(아이콘·시각·설명, 선택 표시)으로 그리고, 주소를 가진 대화상자(라우트 마스킹 `…/history`, 새로 고침하면 다시 열림)로 엽니다. +본문 편집 도구는 페이지 머리 줄의 공통 자리에 둡니다. 상세에서는 본문을 그 자리에서 편집하지 않고, 편집 단추가 수정 화면을 엽니다. 상세 머리 줄의 편집(수정 화면 열기)·버전 기록은 필터·정렬과 같은 둥근 아이콘 도구(`variant="tool"`, 아이콘만, 툴팁에 이름)로 두며, "편집" 같은 글자 단추를 두지 않습니다. 저장·완료는 수정 화면의 머리 줄(휴대폰은 바닥 줄)에 있으며 [읽기·편집](content-and-review.md#읽기편집) 규칙을 따릅니다. 버전 기록 목록은 표준 목록 행(아이콘·시각·설명, 선택 표시)으로 그리고, 주소를 가진 대화상자(라우트 마스킹 `…/history`, 새로 고침하면 다시 열림)로 엽니다. 목차는 지금 읽는 제목을 선택해 보여 줍니다. 지금 읽는 제목은 기준선(스크롤 컨테이너 위쪽 + 그 제목의 `scroll-margin-top`)을 마지막으로 지난 제목입니다. 페이지 끝의 짧은 절은 끝까지 스크롤해도 기준선에 닿지 못해 이 규칙만으로는 선택되지 않고, "바닥이면 마지막 제목"을 덧붙이면 그 사이의 제목을 건너뜁니다. 흔히 보고되는 scrollspy 결함이므로 아래 두 규칙을 더합니다. 목차는 디자인 시스템의 outline 부품을 제어 모드(`activeId`, `onActiveIdChange`)로 쓰고, 선택 규칙만 작은 훅에 둡니다. tocbot 같은 라이브러리는 목차를 직접 그려 디자인 시스템 부품과 겹치므로 쓰지 않습니다. @@ -55,4 +55,4 @@ arc42: "8.6" ## 옆 패널 -주소가 필요 없는 짧은 조회(기록 한 줄의 사실)는 목록 옆 패널([WithPanel](https://github.com/dev-goraebap/dev.goraebap/blob/main/site/src/shared/ui/layouts/side-panel/WithPanel.tsx))로 엽니다. 데스크톱은 오른쪽 26rem 패널, 휴대폰은 바텀시트입니다. 편집이나 긴 내용은 자기 주소를 가진 상세로 엽니다. 목록과 상세를 나란히 두는 받은함은 목록 폭을 조절할 수 있게 하고 휴대폰에서는 목록만 보인 뒤 항목을 페이지로 엽니다. +주소가 필요 없는 짧은 조회(기록 한 줄의 사실)는 목록 옆 패널([WithPanel](https://github.com/dev-goraebap/dev.goraebap/blob/main/site/src/shared/ui/layouts/side-panel/WithPanel.tsx))로 엽니다. 데스크톱은 오른쪽 26rem 패널, 휴대폰은 바텀시트입니다. 편집이나 긴 내용은 자기 주소를 가진 상세로 엽니다. 목록과 상세를 나란히 두는 받은함은 목록 폭을 조절할 수 있게 하되 처음에는 최대 너비로 열고([사이드바](app-layout.md#사이드바)와 같은 규칙) 휴대폰에서는 목록만 보인 뒤 항목을 페이지로 엽니다. diff --git a/plugins/webcraft/skills/design-system/references/print.md b/plugins/webcraft/skills/design-system/references/print.md new file mode 100644 index 0000000..9a6fd00 --- /dev/null +++ b/plugins/webcraft/skills/design-system/references/print.md @@ -0,0 +1,45 @@ +--- +arc42: "8.7" +--- + +# 인쇄와 PDF + +문서·본문을 브라우저 인쇄로 PDF로 남기는 화면을 만들 때 읽습니다. + +## 제목과 덧붙임 + +인쇄 화면의 제목(표지와 각 절 제목)은 시스템이 가진 제목을 씁니다. 본문 안의 제목을 표지로 끌어 쓰지 않습니다. 표지·카드에 날짜·주소·키 같은 덧붙임을 넣지 않습니다. + +## 쪽 크기와 브라우저 머리글·바닥글 + +쪽 크기와 여백은 `@page`로 정합니다(예: `@page { size: A4; margin: 22mm 20mm 24mm; }`). 화면에서는 A4 폭의 읽기 열을 종이처럼 가운데 두고, 인쇄에서는 쪽 여백 안을 채웁니다. + +브라우저가 쪽 가장자리에 넣는 머리글·바닥글(날짜, 탭 제목, 주소)은 `@page`의 여백 상자로 지웁니다. Chrome은 글쓴이가 여백 상자를 둔 쪽(위·아래)에서만 자기 머리글·바닥글을 빼므로, 위에는 빈 상자를, 오른쪽 아래에는 쪽 번호를 둡니다. 그러면 인쇄 창의 "머리글과 바닥글"이 켜져 있어도 아무것도 붙지 않습니다. 첫 쪽(표지)은 쪽 번호도 비우되, 빈 상자는 남겨 브라우저 바닥글을 계속 막습니다. + +```css +@page { + size: A4; + margin: 22mm 20mm 24mm; + @top-center { content: ""; } + @bottom-right { content: counter(page) " / " counter(pages); } +} + +@page :first { + @bottom-right { content: ""; } +} +``` + +## 링크 + +앱 안으로 가는 링크(같은 출처의 주소)는 인쇄본에서 링크를 빼고 글자만 남깁니다. 종이와 PDF에서는 앱 안 주소를 열 수 없기 때문입니다. 바깥 주소(`http(s)`의 다른 출처, `mailto:`)는 남깁니다. + +## 쪽 나눔과 도표 크기 + +- 도표·사진·코드 블록(`figure`, `pre`)은 쪽 사이에서 자르지 않습니다(`break-inside: avoid`). +- 표는 쪽을 넘겨 이어 가되 행(`tr`)은 자르지 않습니다. 긴 표를 통째로 다음 쪽으로 밀면 앞쪽이 비기 때문입니다. +- 제목(`h1`~`h3`) 바로 뒤에서 쪽을 나누지 않습니다(`break-after: avoid`). +- 쪽보다 큰 도표는 비율을 지킨 채 쪽 높이의 약 2/3(`max-block-size: 160mm`, A4 기준) 안으로 줄입니다. + +## 화면 전용 요소 + +화면 전용 도구(복사·편집·크게 보기·내려받기 단추)는 인쇄에서 감춥니다. 편집기가 본문 끝에 늘 두는 빈 문단과 마지막 여백도 지웁니다. 쪽 끝에 걸치면 빈 쪽 하나가 더 생기기 때문입니다. diff --git a/plugins/webcraft/skills/design-system/references/typography.md b/plugins/webcraft/skills/design-system/references/typography.md index ae8d7cf..6af231f 100644 --- a/plugins/webcraft/skills/design-system/references/typography.md +++ b/plugins/webcraft/skills/design-system/references/typography.md @@ -19,6 +19,10 @@ arc42: "8.5" 한국어 업무 화면의 기본은 Pretendard Variable, 코드·고정폭은 JetBrains Mono입니다. 프로젝트가 글꼴 파일을 직접 제공하고 시스템 글꼴은 로딩 중 대체로만 씁니다. 날짜·수·금액처럼 위아래로 비교하는 값은 `hasTabularNumbers`(고정폭 숫자)로 자릿수를 맞추고 끝에 정렬합니다. +글꼴 고르기는 화면 설정(내 계정의 표시 설정) 한 곳에 둡니다. 상세 화면마다 글꼴 고르기를 두지 않습니다. 선택지는 고딕(기본, Pretendard)과 명조 두 가지입니다. 명조를 고르면 "본문만"(리치 본문·Markdown 본문을 읽고 쓰는 칸)과 "전체"(온 화면) 가운데 적용 범위를 고르며, 기본은 "본문만"입니다. "전체"는 `html`에 `data-font="serif"`를 붙이고 테마의 본문·제목 글꼴 변수(`--font-family-body`, `--font-family-heading`)를 명조로 바꿉니다. "본문만"이면 본문 칸이 스스로 명조로 씁니다. 이 설정은 브라우저에 저장하는 화면 설정이며 서버에 두지 않습니다. + +앱 화면의 보통 굵기는 450입니다. 명조 글꼴 파일의 굵기 구간을 일반 선언(500부터 SemiBold)대로 두면 온 화면이 굵게 보입니다. 앱용 이름의 `@font-face`를 따로 두어 굵기 구간을 다시 나눕니다(예: 100–499 Regular, 500–649 SemiBold, 650–900 Bold). 글꼴 파일은 쓰일 때만 받습니다(`font-display: swap`). + ## 줄과 넘침 목록의 제목·이름·경로는 한 줄에서 말줄임하고(`maxLines={1}`), 전체 값은 접근 가능한 이름이나 title로 남깁니다. 상세의 제목·본문·주소는 줄을 넘겨 가로로 넘치지 않게 합니다(`overflow-wrap: anywhere`, 부모의 `min-inline-size: 0`). 편집기의 한글 조합·커서·공백은 실제 입력으로 확인합니다. diff --git a/plugins/webcraft/skills/goraebap-mode/SKILL.md b/plugins/webcraft/skills/goraebap-mode/SKILL.md index 759c5ef..0251dc5 100644 --- a/plugins/webcraft/skills/goraebap-mode/SKILL.md +++ b/plugins/webcraft/skills/goraebap-mode/SKILL.md @@ -47,6 +47,7 @@ arc42: "none" - 데이터 소유권·입출력·실패 상태를 정한 뒤 구현합니다. 추측한 재사용을 위해 계층을 늘리지 않습니다. - 역할마다 기본값을 두되(글쓰기·에셋 역할), 도구와 모델은 사용자 설정과 실행 환경을 따르고 없으면 세션 모델을 씁니다. 다중 에이전트, 자동 커밋·병합을 전제하지 않습니다. - [검증](references/verification.md)에 따라 바뀐 부분을 확인합니다. 수행한 검증과 남은 제한을 구분해 답합니다. +- 작업 중 MCP·플러그인 도구의 기능 부족이나 반복 호출을 실제 스키마로 확인하면 우회만 설명하고 끝내지 않고 [도구의 부족을 발견했을 때](references/tool-improvements.md)를 따릅니다. - 커밋·푸시·게시·배포는 사용자가 허용한 범위에서만 합니다. 스킬 자체가 틀렸으면 [지침 개선](playbooks/evolve.md)으로 원인을 고칩니다. diff --git a/plugins/webcraft/skills/goraebap-mode/references/tool-improvements.md b/plugins/webcraft/skills/goraebap-mode/references/tool-improvements.md new file mode 100644 index 0000000..bbb9e17 --- /dev/null +++ b/plugins/webcraft/skills/goraebap-mode/references/tool-improvements.md @@ -0,0 +1,47 @@ +--- +arc42: "none" +--- + +# 도구의 부족을 발견했을 때 + +작업 중 MCP·플러그인 도구의 기능 부족이나 사용 불편을 확인했을 때, 우회 방법만 설명하고 끝내지 않고 개선을 제안하는 기준입니다. Webcraft 지침 자체의 문제는 이 문서가 아니라 [지침 제안](../../manage-workflow/references/proposals.md)을 따릅니다. + +## 개선 후보 + +다음 경우를 개선 후보로 봅니다. + +- 필요한 도구가 없거나, 화면에서 되는 기능을 도구로 할 수 없을 때 +- 여러 대상을 위해 같은 호출을 반복해야 할 때 +- 도구의 이름·설명·인자·응답이 모호해 오해나 재조회를 만들 때 + +사소한 취향 차이나 확인하지 않은 불편은 제안하지 않습니다. + +## 제안하기 전에 확인한다 + +현재 연결의 실제 도구 목록과 인자 스키마를 읽습니다. 기존 도구의 배열 인자 등으로 이미 처리할 수 있으면 그 기능을 쓰고 개선안을 만들지 않습니다. + +인증 만료·권한 부족·일시적 연결 오류는 기능 누락과 구분합니다. 권한 우회나 접근 확대를 해결책으로 제안하지 않습니다. + +## 제안에 담을 것 + +하려던 작업, 확인한 제약이나 반복 단계, 추가·변경하면 나아질 동작을 짧게 씁니다. 반복 횟수와 비용은 실제로 확인한 만큼만 적습니다. + +## 누구에게 제안하는가 + +그 도구를 제공하는 제품의 개발 저장소에서 일하는 중이면 구현 범위와 기대 결과를 제안하고 직접 고칠지 묻습니다. 사용자가 이미 같은 범위를 승인했으면 다시 묻지 않습니다. 동의하면 저장소 지침대로 구현·검증하고, 커밋·푸시·배포는 허용된 범위만 합니다. + +일반 사용자가 도구로 업무를 하는 중이면 개발 환경이나 Git 접근을 요구하지 않습니다. 개선 피드백을 올릴지 묻고, 제품이 정한 접수 절차로 연결합니다. 프로젝트 이름이나 서비스를 쓴다는 사실만으로 제품 개발자라고 판단하지 않습니다. + +## 원래 작업은 계속한다 + +권한 있는 기존 방법으로 원래 작업을 끝낼 수 있으면 계속합니다. 사용자가 금지한 수단(예: 다른 브라우저)으로 바꾸거나 인증을 우회하지 않습니다. 개선 제안에 대한 동의를 원래 작업의 조건으로 만들지 않습니다. 사용자가 거절했거나 이미 처리 중인 같은 문제는 다시 제안하지 않습니다. + +## 피드백 제출의 경계 + +접수처와 제출 도구는 그 제품의 플러그인이나 프로젝트 정책이 정합니다. 모든 도구 개선을 Webcraft 저장소로 보내지 않고, 사용자의 업무 프로젝트에 피드백 이슈를 자동으로 만들지 않습니다. + +피드백 초안에는 하려던 작업, 확인한 제약, 도구·인자의 종류, 기대한 동작과 개선안, 플러그인 판과 에이전트 종류를 씁니다. 업무 데이터·실제 식별자·개인정보·토큰·서명 주소는 재현용 예시로 바꿉니다. 제출 전에 사용자가 내용과 대상을 검토하게 합니다. + +현재 권한과 제출 도구로 등록할 수 있을 때만 직접 등록합니다. 수단이 없으면 초안과 공식 제출 경로를 주고 아직 접수되지 않았다고 알립니다. 로컬에 파일로 남길 수 있으면 그 파일로 전달해도 됩니다. 실제 등록 응답이 없으면 등록했다고 말하지 않고, 다시 시도하기 전에 같은 피드백이 이미 접수됐는지 확인합니다. + +승인받은 범위만 수행합니다. 공개 이슈 제출만 승인했으면 제출만 하고, 구현·검증만 승인했으면 그것만 하고 배포로 넓히지 않습니다. diff --git a/site/content-plugin.ts b/site/content-plugin.ts index 09eb918..44cda7e 100644 --- a/site/content-plugin.ts +++ b/site/content-plugin.ts @@ -24,6 +24,9 @@ export type GuideDoc = { hasTitle: boolean; /** 아키텍처 정의서(arc42)에서 이 문서가 맡는 절. 첫 값이 메뉴에서 놓일 절이다. 해당 없으면 비운다. */ arc42?: string[]; + /** 본문이 띄우는 견본 id와 도표(Mermaid) 원문. 문서로 옮기기 전에 미리 준비해 빈 자리로 그렸다가 채우지 않게 한다. */ + samples?: string[]; + diagrams?: string[]; kind?: 'post'; date?: string; updated?: string; @@ -78,6 +81,13 @@ const walk = (dir: string): string[] => readdirSync(dir).flatMap(name => { const posix = (file: string) => file.split(path.sep).join('/'); const repoPath = (file: string) => posix(path.relative(path.resolve(here, '..'), file)); +/** 본문에서 미리 준비할 것: <Sample id="…">의 id와 ```mermaid 코드 블록의 원문. */ +const assetsOf = (text: string) => { + const samples = [...new Set([...text.matchAll(/<Sample\s+id="([^"]+)"/g)].map(match => match[1]))]; + const diagrams = [...text.matchAll(/^```mermaid\r?\n([\s\S]*?)\r?\n```/gm)].map(match => match[1].replace(/\r\n/g, '\n')); + return { ...(samples.length ? { samples } : {}), ...(diagrams.length ? { diagrams } : {}) }; +}; + /** 지침 Markdown 파일의 사이트 주소. SKILL.md는 지침 첫 장, 나머지는 하위 경로. */ export function skillRoute(file: string) { const relative = posix(path.relative(skillsRoot, file)).replace(/\.md$/, ''); @@ -106,7 +116,7 @@ function skillDocs(): GuideDoc[] { const meta = frontmatter(text); return { path: skillRoute(file), title: /^# (.+)$/m.exec(text)?.[1].trim() ?? relative, - description: isEntry ? meta.description : undefined, arc42: meta.arc42, + description: isEntry ? meta.description : undefined, arc42: meta.arc42, ...assetsOf(text), group: meta.arc42 ? sectionPath(meta.arc42[0]) : `/webcraft/rules/${skill}/`, order: isEntry ? 0 : index >= 0 ? index + 1 : 500, source: repoPath(file), hasTitle: true, }; @@ -117,9 +127,10 @@ function skillDocs(): GuideDoc[] { function contentDocs(): GuideDoc[] { return walk(contentRoot).filter(file => /\.mdx?$/.test(file) && !posix(path.relative(contentRoot, file)).startsWith('blog/')).map(file => { const relative = posix(path.relative(contentRoot, file)).replace(/\.mdx?$/, '').replace(/(^|\/)index$/, ''); - const meta = frontmatter(readFileSync(file, 'utf8')); + const text = readFileSync(file, 'utf8'); + const meta = frontmatter(text); const group = contentGroups.find(item => relative === item.dir || relative.startsWith(`${item.dir}/`)); - return { path: relative ? `/webcraft/${relative}/` : '/webcraft/', title: meta.title ?? relative, description: meta.description, arc42: meta.arc42, + return { path: relative ? `/webcraft/${relative}/` : '/webcraft/', title: meta.title ?? relative, description: meta.description, arc42: meta.arc42, ...assetsOf(text), group: meta.arc42 ? sectionPath(meta.arc42[0]) : group ? `/webcraft/${group.dir}/` : '/webcraft/', order: meta.order, source: repoPath(file), hasTitle: false }; }).sort((a, b) => a.order - b.order || a.title.localeCompare(b.title)); } diff --git a/site/content/catalog/composites/editor.mdx b/site/content/catalog/composites/editor.mdx new file mode 100644 index 0000000..036d680 --- /dev/null +++ b/site/content/catalog/composites/editor.mdx @@ -0,0 +1,40 @@ +--- +title: "리치 에디터" +sidebar: + order: 5 +--- + +import source0 from '@/shared/ui/composites/editor/RichEditor.tsx?raw'; +import source1 from '@/shared/ui/composites/editor/BlockMenu.tsx?raw'; +import source2 from '@/shared/ui/composites/editor/SelectionMenu.tsx?raw'; +import source3 from '@/shared/ui/composites/editor/TableControls.tsx?raw'; +import source4 from '@/shared/ui/composites/editor/MediaView.tsx?raw'; +import source5 from '@/shared/ui/composites/editor/LinkCard.tsx?raw'; +import source6 from '@/shared/ui/composites/editor/schema.ts?raw'; + +규칙 원본: [composed-components](https://github.com/dev-goraebap/dev.goraebap/blob/main/plugins/webcraft/skills/design-system/references/composed-components.md) + +본문 칸을 넓힌 리치 에디터(Tiptap 3)입니다. 읽기와 편집이 같은 에디터이고, 편집할 때 블록 손잡이·블록 메뉴·`/` 명령·선택 서식 도구·표 도구·올리는 칸이 붙습니다. 제목은 화면의 시스템 제목 하나이고 본문 제목은 2단계부터입니다. 올리기와 링크 미리보기는 호스트가 비동기 함수로 넘기며, 견본은 blob 주소와 정해 둔 답을 쓰는 가짜 호스트입니다. + +<SampleGroup group="composites-editor" /> + +## 단축키 + +| 동작 | 키 | +| --- | --- | +| 고른 블록 복제 · 복사 | Ctrl/⌘ + D · Ctrl/⌘ + C | +| 고른 블록 위·아래로 옮기기 | Alt + ↑ · Alt + ↓ | +| 고른 블록 지우기 | Delete | +| 블록 넣기 | `/` 다음 이름, ↑·↓, Enter, Esc | +| 올리는 칸 닫기 | ✕ 또는 Esc | +| 수정 화면 저장 | Ctrl/⌘ + S | + +## 구현 코드 + +<Source name="RichEditor.tsx" code={source0} lang="tsx" /> +<Source name="BlockMenu.tsx" code={source1} lang="tsx" /> +<Source name="SelectionMenu.tsx" code={source2} lang="tsx" /> +<Source name="TableControls.tsx" code={source3} lang="tsx" /> +<Source name="MediaView.tsx" code={source4} lang="tsx" /> +<Source name="LinkCard.tsx" code={source5} lang="tsx" /> +<Source name="schema.ts" code={source6} lang="ts" /> diff --git a/site/content/catalog/playground/documents.mdx b/site/content/catalog/playground/documents.mdx index eab9fbc..e5aa7e5 100644 --- a/site/content/catalog/playground/documents.mdx +++ b/site/content/catalog/playground/documents.mdx @@ -5,13 +5,13 @@ sidebar: --- import source0 from '@samples/playground/library/pages/library/LibraryPage.tsx?raw'; -import source1 from '@samples/playground/documents/DocumentScreen.tsx?raw'; +import source1 from '@/shared/ui/patterns/compose/BodyEditPage.tsx?raw'; -지식 베이스·자료실·파일함처럼 자료와 문서를 모아 골라 들어가 일하는 작업 목록을 만들 때 본뜹니다. 가운데 최대 72rem 컨테이너 안의 문서 카드 그리드·행 목록·컬렉션, 읽은 수와 문서 더 보기 버튼, 작성 페이지, 자동 저장되는 문서 편집을 다룹니다. +지식 베이스·자료실·파일함처럼 자료와 문서를 모아 골라 들어가 일하는 작업 목록을 만들 때 본뜹니다. 가운데 최대 72rem 컨테이너 안의 문서 카드 그리드·행 목록·컬렉션, 읽은 수와 문서 더 보기 버튼, 작성 페이지, 상세의 편집 단추가 여는 수정 화면(제목과 본문만, 머리 줄의 저장·취소·완료와 Ctrl/⌘+S)을 다룹니다. <SampleGroup group="playground-documents" /> ## 구현 코드 <Source name="LibraryPage.tsx" code={source0} lang="tsx" /> -<Source name="DocumentScreen.tsx" code={source1} lang="tsx" /> +<Source name="BodyEditPage.tsx" code={source1} lang="tsx" /> diff --git a/site/samples/composites/Editor.samples.tsx b/site/samples/composites/Editor.samples.tsx new file mode 100644 index 0000000..8852e72 --- /dev/null +++ b/site/samples/composites/Editor.samples.tsx @@ -0,0 +1,52 @@ +import type { Meta, SampleOf } from "../catalog/types"; +import { Button } from "@astryxdesign/core/Button"; +import { Text } from "@astryxdesign/core/Text"; +import { VStack } from "@astryxdesign/core/VStack"; +import type { JSONContent } from "@tiptap/react"; +import { AppDialog } from "@/shared/ui/composites/dialog/AppDialog"; +import { RichBody } from "@/shared/ui/composites/editor/RichBody"; +import { RichDraft } from "@/shared/ui/composites/editor/RichDraft"; +import { useState } from "react"; +import { fakePreview, fakeUpload, fileSample, richSample, sampleLinks } from "./editor-fixtures"; + +/** 쓰는 본문: 모든 블록이 든 견본. 올리기와 링크 미리보기는 가짜 호스트가 맡는다. */ +export function Writing({ value = richSample }: { value?: JSONContent }) { + const [, setValue] = useState(value); + return <VStack width="min(48rem, 100vw - 2rem)" paddingBlock={6} paddingInlineStart={8}> + <RichDraft value={value} onChange={setValue} isPage label="본문" upload={fakeUpload} readPreview={fakePreview} links={sampleLinks} /> + </VStack>; +} + +/** 읽는 본문: 사진·도표는 눌러 크게 보고, 링크 카드는 새 탭 링크, 앱 안의 링크는 그 화면으로 간다(여기서는 간 곳을 보인다). */ +export function Reading({ value = richSample }: { value?: JSONContent }) { + const [went, setWent] = useState(""); + return <VStack width="min(45rem, 100vw - 2rem)" gap={3} paddingBlock={6}> + {went ? <Text type="supporting" color="secondary" role="status">이동: {went}</Text> : null} + <RichBody value={value} readPreview={fakePreview} onNavigate={setWent} /> + </VStack>; +} + +/** 대화상자 안의 본문 칸: 데스크톱 좌우 여백(40px) 안에 블록 손잡이가 들어온다. */ +function InDialog() { + const [isOpen, setOpen] = useState(false); + const [, setValue] = useState<JSONContent>(); + return <> + <Button label="메모 쓰기" onClick={() => setOpen(true)} /> + <AppDialog isOpen={isOpen} onOpenChange={setOpen} title="새 메모" width="36rem" footer={<Button label="저장" variant="primary" onClick={() => setOpen(false)} />}> + <RichDraft onChange={setValue} placeholder="무엇을 남길지 적어 보세요. / 로 블록을 넣습니다." upload={fakeUpload} readPreview={fakePreview} links={sampleLinks} /> + </AppDialog> + </>; +} + +const empty: JSONContent = { type: "doc", content: [{ type: "paragraph" }] }; + +const meta = { title: "Composites (조합 부품)/리치 에디터", id: "composites-editor", layout: "padded", height: "44rem" } satisfies Meta; +export default meta; +type Sample = SampleOf<typeof meta>; + +export const Write: Sample = { name: "쓰기: 표·도표·사진·파일·링크 카드", render: () => <Writing /> }; +export const Read: Sample = { name: "읽기", render: () => <Reading /> }; +export const Blank: Sample = { name: "빈 본문에서 / 명령", height: "26rem", render: () => <Writing value={empty} /> }; +export const Files: Sample = { name: "파일 카드의 종류", height: "34rem", render: () => <Reading value={fileSample} /> }; +export const Dialog: Sample = { name: "대화상자 안의 본문 칸", layout: "centered", height: "30rem", render: () => <InDialog /> }; +export const Phone: Sample = { name: "휴대폰에서 쓰기", device: "phone", render: () => <Writing /> }; diff --git a/site/samples/composites/Editor.test.tsx b/site/samples/composites/Editor.test.tsx new file mode 100644 index 0000000..4b23ac0 --- /dev/null +++ b/site/samples/composites/Editor.test.tsx @@ -0,0 +1,166 @@ +import { test } from "vitest"; +import { expect, userEvent, waitFor, within } from "../catalog/testing"; +import { runSample } from "../catalog/run-sample"; +import type { SampleOf } from "../catalog/types"; +import meta, { Reading, Writing } from "./Editor.samples"; + +type Sample = SampleOf<typeof meta>; +const body = (root: HTMLElement) => root.querySelector<HTMLElement>(".ProseMirror")!; +const topLevel = (root: HTMLElement) => [...body(root).children].map(each => each.textContent ?? ""); +/** 본문 안의 글 한 덩이를 고른다(서식 도구가 뜨게). */ +async function pick(element: Element) { + await userEvent.tripleClick(element); + await waitFor(() => expect(element.ownerDocument.getSelection()?.toString().length).toBeGreaterThan(0)); +} + +const VerifyBlockMenu: Sample = { + name: "블록 메뉴와 단축키 검증", + render: () => <Writing />, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await waitFor(() => expect(page.getAllByRole("button", { name: /번 블록 선택$/ }).length).toBeGreaterThan(5)); + const before = topLevel(canvasElement).filter(text => text.startsWith("이 본문은")).length; + await userEvent.click(page.getByRole("button", { name: "1번 블록 선택" })); + const menu = await page.findByRole("dialog", { name: "블록 메뉴" }); + for (const name of ["색", "종류 바꾸기", "서식 지우기", "복제", "복사", "링크 복사", "위로 옮기기", "아래로 옮기기", "삭제"]) + await expect(within(menu).getByRole("menuitem", { name })).toBeInTheDocument(); + // 메뉴가 열린 채(블록을 고른 채) Ctrl+D는 복제, Alt+↓는 아래로 옮기기, Delete는 지우기. + await userEvent.keyboard("{Control>}d{/Control}"); + await waitFor(() => expect(topLevel(canvasElement).filter(text => text.startsWith("이 본문은")).length).toBe(before + 1)); + await userEvent.keyboard("{Escape}"); + await waitFor(() => expect(page.queryByRole("dialog", { name: "블록 메뉴" })).not.toBeInTheDocument()); + // 복제본(2번)을 아래로 옮기면 다음 제목과 자리를 바꾼다. + await userEvent.click(page.getByRole("button", { name: "2번 블록 선택" })); + await page.findByRole("dialog", { name: "블록 메뉴" }); + await userEvent.keyboard("{Alt>}{ArrowDown}{/Alt}"); + await waitFor(() => expect(topLevel(canvasElement)[1]).toBe("분기 지표")); + await userEvent.click(page.getByRole("button", { name: "3번 블록 선택" })); + await page.findByRole("dialog", { name: "블록 메뉴" }); + await userEvent.keyboard("{Delete}"); + await waitFor(() => expect(topLevel(canvasElement).filter(text => text.startsWith("이 본문은")).length).toBe(before)); + // 하위 메뉴: 종류 바꾸기로 제목 3을 고른다. + await userEvent.click(page.getByRole("button", { name: "1번 블록 선택" })); + const again = await page.findByRole("dialog", { name: "블록 메뉴" }); + await userEvent.click(within(again).getByRole("menuitem", { name: "종류 바꾸기" })); + await userEvent.click(within(await page.findByRole("menu", { name: "종류 바꾸기" })).getByRole("menuitem", { name: "제목 3" })); + await waitFor(() => expect(body(canvasElement).firstElementChild?.tagName).toBe("H3")); + }, +}; + +const VerifyUploadZone: Sample = { + name: "/ 명령과 올리는 칸 열기·닫기·놓기 검증", + render: () => <Writing value={{ type: "doc", content: [{ type: "paragraph" }] }} />, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + const editor = await waitFor(() => { const found = body(canvasElement); expect(found).toBeTruthy(); return found; }); + await userEvent.click(editor); + await userEvent.keyboard("/file"); + const palette = await page.findByRole("dialog", { name: "블록 추가 메뉴" }); + await expect(within(palette).getByRole("button", { name: /이미지 · 동영상/ })).toBeVisible(); + await userEvent.keyboard("{Enter}"); + const zone = await page.findByRole("group", { name: "파일 올리는 칸" }); + // 칸은 화면 표시라 문서에 남지 않는다. ✕로 닫는다. + await expect(editor.querySelector("p")?.textContent).toBe(""); + await userEvent.click(within(zone).getByRole("button", { name: "올리는 칸 닫기" })); + await waitFor(() => expect(page.queryByRole("group", { name: "파일 올리는 칸" })).not.toBeInTheDocument()); + // HTML 칸은 Esc로 닫는다. + await userEvent.keyboard("/html{Enter}"); + const html = await page.findByRole("group", { name: "HTML 파일 올리는 칸" }); + await expect(within(html).getByText("HTML 파일을 눌러서 올리기")).toBeInTheDocument(); + within(html).getAllByRole("button")[0].focus(); + await userEvent.keyboard("{Escape}"); + await waitFor(() => expect(page.queryByRole("group", { name: "HTML 파일 올리는 칸" })).not.toBeInTheDocument()); + // 칸에 파일을 놓으면 올리기가 시작되고 칸이 닫힌 뒤 그 자리에 파일 카드가 들어간다. + await userEvent.keyboard("/file{Enter}"); + const target = await page.findByRole("group", { name: "파일 올리는 칸" }); + const transfer = new DataTransfer(); + transfer.items.add(new File(["안녕"], "회의 메모.pdf", { type: "application/pdf" })); + target.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: transfer })); + await waitFor(() => expect(page.queryByRole("group", { name: "파일 올리는 칸" })).not.toBeInTheDocument()); + await expect(await page.findByRole("region", { name: "파일 업로드" })).toBeVisible(); + await waitFor(() => expect(canvasElement.querySelector('[data-tone="red"]')?.textContent).toContain("회의 메모.pdf"), { timeout: 4000 }); + }, +}; + +const VerifyLinkPanel: Sample = { + name: "선택 서식 도구와 링크 입력 칸 검증", + render: () => <Writing />, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + const heading = await waitFor(() => { const found = body(canvasElement).querySelector("h2"); expect(found).toBeTruthy(); return found!; }); + await pick(heading); + const toolbar = await page.findByRole("toolbar", { name: "선택 텍스트 서식" }); + for (const name of ["블록 종류", "굵게", "기울임", "밑줄", "취소선", "인라인 코드", "링크", "색", "더 보기"]) await expect(within(toolbar).getByRole("button", { name })).toBeInTheDocument(); + await userEvent.click(within(toolbar).getByRole("button", { name: "굵게" })); + await waitFor(() => expect(heading.querySelector("strong")).not.toBeNull()); + // 링크: 문서 이름으로 찾아 고르면 앱 안의 주소로 걸린다. + await userEvent.click(within(toolbar).getByRole("button", { name: "링크" })); + const panel = await page.findByRole("dialog", { name: "링크" }); + await userEvent.type(within(panel).getByRole("textbox", { name: "링크 주소 또는 문서 이름" }), "배포"); + await userEvent.click(await within(panel).findByRole("menuitem", { name: /배포 절차/ })); + await waitFor(() => expect(heading.querySelector("a")?.getAttribute("href")).toBe("/documents/release")); + // 주소는 https://를 붙여 건다. + await pick(body(canvasElement).querySelectorAll("h2")[1]); + await userEvent.click(within(await page.findByRole("toolbar", { name: "선택 텍스트 서식" })).getByRole("button", { name: "링크" })); + const address = within(await page.findByRole("dialog", { name: "링크" })).getByRole("textbox", { name: "링크 주소 또는 문서 이름" }); + await userEvent.type(address, "example.com{Enter}"); + await waitFor(() => expect(body(canvasElement).querySelectorAll("h2")[1].querySelector("a")?.getAttribute("href")).toBe("https://example.com")); + }, +}; + +const VerifyTable: Sample = { + name: "표 손잡이와 표 메뉴 검증", + render: () => <Writing />, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + const table = await waitFor(() => { const found = body(canvasElement).querySelector("table"); expect(found).toBeTruthy(); return found!; }); + const rows = () => table.querySelectorAll("tr").length; + const start = rows(); + await userEvent.click(table.querySelectorAll("td")[0]); + await userEvent.click(await page.findByRole("button", { name: "행 메뉴" })); + const menu = await page.findByRole("menu", { name: "행 메뉴" }); + for (const name of ["위에 행 추가", "아래에 행 추가", "위로 옮기기", "아래로 옮기기", "머리글 행 켜기·끄기", "행 삭제"]) await expect(within(menu).getByRole("menuitem", { name })).toBeInTheDocument(); + await userEvent.click(within(menu).getByRole("menuitem", { name: "아래에 행 추가" })); + await waitFor(() => expect(rows()).toBe(start + 1)); + // 끝에 열을 더하는 + 막대, 열 메뉴의 Esc 닫기. + const columns = table.querySelectorAll("tr")[0].children.length; + await userEvent.click(page.getByRole("button", { name: "끝에 열 추가" })); + await waitFor(() => expect(table.querySelectorAll("tr")[0].children.length).toBe(columns + 1)); + await userEvent.click(table.querySelectorAll("td")[0]); + await userEvent.click(await page.findByRole("button", { name: "열 메뉴" })); + await page.findByRole("menu", { name: "열 메뉴" }); + await userEvent.keyboard("{Escape}"); + await waitFor(() => expect(page.queryByRole("menu", { name: "열 메뉴" })).not.toBeInTheDocument()); + }, +}; + +const VerifyReading: Sample = { + name: "읽는 본문: 앱 안 링크·링크 카드·HTML 크게 보기 검증", + render: () => <Reading />, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + const article = await page.findByRole("article", { name: "본문" }); + await expect(page.queryByRole("button", { name: /번 블록 선택$/ })).not.toBeInTheDocument(); + await userEvent.click(within(article).getByRole("link", { name: "화면 규칙 검토 메모" })); + await expect(await page.findByText("이동: /documents/screen-rules")).toBeVisible(); + // 링크 카드: 미리보기가 오면 제목, 사진이 없으면 빈 사진 칸. 카드 전체가 새 탭 링크다. + const card = await within(article).findByRole("link", { name: /읽기 편한 화면을 만드는 열 가지 기준/ }); + await expect(card).toHaveAttribute("target", "_blank"); + await expect(await within(article).findByText("대표 이미지가 없습니다")).toBeVisible(); + // HTML 임베드: 격리된 틀로 미리 보고, 크게 보기는 창을 거의 채우는 대화상자. blob 주소는 새 탭으로 열지 않는다. + const frame = article.querySelector("iframe")!; + await expect(frame.getAttribute("sandbox")).toBe("allow-scripts allow-modals allow-forms allow-popups"); + await expect(frame.getAttribute("referrerpolicy")).toBe("no-referrer"); + await userEvent.click(within(article).getByRole("button", { name: "분기 지표.html 크게 보기" })); + const viewer = await page.findByRole("dialog", { name: "분기 지표.html 크게 보기" }); + await expect(within(viewer).queryByRole("button", { name: "새 탭에서 열기" })).not.toBeInTheDocument(); + await userEvent.click(within(viewer).getByRole("button", { name: "닫기" })); + await waitFor(() => expect(page.queryByRole("dialog", { name: "분기 지표.html 크게 보기" })).not.toBeInTheDocument()); + }, +}; + +test(VerifyBlockMenu.name, () => runSample(meta, VerifyBlockMenu), 20000); +test(VerifyUploadZone.name, () => runSample(meta, VerifyUploadZone), 20000); +test(VerifyLinkPanel.name, () => runSample(meta, VerifyLinkPanel), 20000); +test(VerifyTable.name, () => runSample(meta, VerifyTable), 20000); +test(VerifyReading.name, () => runSample(meta, VerifyReading), 20000); diff --git a/site/samples/composites/editor-fixtures.ts b/site/samples/composites/editor-fixtures.ts new file mode 100644 index 0000000..df413a9 --- /dev/null +++ b/site/samples/composites/editor-fixtures.ts @@ -0,0 +1,125 @@ +import type { JSONContent } from '@tiptap/react'; +import type { LinkChoice, LinkPreview, ReadLinkPreview, UploadFile } from '@/shared/ui/composites/editor/types'; + +/* + * 리치 에디터 견본의 가짜 호스트. 키트에는 서버가 없으므로 올리기는 고른 파일을 blob 주소로 돌려주고(진행률은 흉내), + * 링크 미리보기는 정해 둔 답을 준다. 사진·HTML·파일은 견본을 열 때 만든 blob 주소다. + */ + +const blob = (text: string, type: string) => URL.createObjectURL(new Blob([text], { type })); +/** 견본 사진: 색 띠와 원 몇 개로 그린 SVG. 색은 hsl로 고른다. */ +const picture = (hue: number, width: number, height: number, label: string) => blob(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}"> +<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="hsl(${hue} 55% 62%)"/><stop offset="1" stop-color="hsl(${hue + 40} 50% 38%)"/></linearGradient></defs> +<rect width="100%" height="100%" fill="url(#g)"/><circle cx="${width * 0.72}" cy="${height * 0.32}" r="${Math.min(width, height) * 0.16}" fill="hsl(${hue + 20} 70% 85% / 0.8)"/> +<path d="M0 ${height * 0.78} Q ${width * 0.3} ${height * 0.55} ${width * 0.55} ${height * 0.75} T ${width} ${height * 0.7} V ${height} H 0 Z" fill="hsl(${hue + 60} 35% 25% / 0.55)"/> +<text x="24" y="${height - 24}" font-family="sans-serif" font-size="${Math.round(height / 14)}" fill="hsl(0 0% 100% / 0.9)">${label}</text></svg>`, 'image/svg+xml'); + +export const pictures = { + harbor: picture(200, 1200, 800, '항구의 오후'), + forest: picture(110, 800, 800, '숲길'), + dusk: picture(20, 800, 800, '해 질 녘'), + card: picture(260, 1200, 630, '대표 이미지'), +}; + +const htmlPage = blob(`<!doctype html><html lang="ko"><meta charset="utf-8"><title>분기 지표 + +

분기 지표 미리보기

올린 HTML 파일은 격리된 틀(sandbox) 안에서만 실행됩니다. 이 틀은 앱의 쿠키와 저장소에 닿지 못합니다.

+

누른 횟수: 0

`, 'text/html'); + +export const files = [ + { name: '분기 보고서.pdf', mime: 'application/pdf', size: 482_000 }, + { name: '요구사항 정의.docx', mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', size: 96_000 }, + { name: '매출 집계.xlsx', mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', size: 210_000 }, + { name: '발표 자료.pptx', mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', size: 2_400_000 }, + { name: '디자인 원본.zip', mime: 'application/zip', size: 8_100_000 }, + { name: '변경 기록.txt', mime: 'text/plain', size: 3_200 }, + { name: 'schema.ts', mime: 'text/plain', size: 5_800 }, +].map(file => ({ ...file, url: blob(`${file.name} 견본 내용`, file.mime) })); + +/** 가짜 올리기: 0.6초 동안 진행률을 올리고 blob 주소를 준다. 사진이면 가로·세로와 대표색을 재서 함께 준다. */ +export const fakeUpload: UploadFile = async (file, progress, signal) => { + for (const percent of [20, 45, 70, 100]) { + await new Promise(resolve => setTimeout(resolve, 150)); + if (signal?.aborted) throw new DOMException('취소됨', 'AbortError'); + progress?.(percent); + } + const url = URL.createObjectURL(file); + const traits = file.type.startsWith('image/') ? await measure(url) : {}; + return { url, name: file.name, mime: file.type || 'application/octet-stream', size: file.size, ...traits }; +}; +async function measure(url: string): Promise<{ width?: number; height?: number; dominantColor?: string }> { + const image = new Image(); + image.src = url; + try { await image.decode(); } catch { return {}; } + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = 16; + const context = canvas.getContext('2d'); + context?.drawImage(image, 0, 0, 16, 16); + const data = context?.getImageData(0, 0, 16, 16).data; + let r = 0, g = 0, b = 0; + if (data) for (let index = 0; index < data.length; index += 4) { r += data[index]; g += data[index + 1]; b += data[index + 2]; } + const hex = (sum: number) => Math.round(sum / 256).toString(16).padStart(2, '0'); + return { width: image.naturalWidth, height: image.naturalHeight, dominantColor: data ? `#${hex(r)}${hex(g)}${hex(b)}` : undefined }; +} + +const previews: Record = { + 'https://example.com/guides/reading': { url: 'https://example.com/guides/reading', site: '예제 가이드', title: '읽기 편한 화면을 만드는 열 가지 기준', + description: '글자 크기와 줄 간격, 한 줄의 길이, 제목의 단계처럼 오래 읽는 화면에서 피로를 줄이는 기준을 모았습니다.', image: pictures.card }, + 'https://example.org/notes': { url: 'https://example.org/notes', site: 'example.org', title: '작업 노트: 표와 목록 사이', + description: '대표 사진을 밝히지 않은 페이지는 같은 자리에 빈 사진 칸을 둡니다.' }, +}; +/** 가짜 링크 미리보기: 0.4초 뒤 정해 둔 답을 준다. 모르는 주소는 실패해 주소만 있는 카드가 된다. */ +export const fakePreview: ReadLinkPreview = async url => { + await new Promise(resolve => setTimeout(resolve, 400)); + const found = previews[url]; + if (!found) throw Error('미리보기를 읽지 못했습니다'); + return found; +}; + +/** 링크 입력 칸에서 이름으로 찾아 걸 문서들(앱 안의 주소). */ +export const sampleLinks: LinkChoice[] = [ + { title: '화면 규칙 검토 메모', href: '/documents/screen-rules', hint: '디자인' }, + { title: '분기 회고', href: '/documents/retro', hint: '회의록' }, + { title: '배포 절차', href: '/documents/release', hint: '운영' }, + { title: '용어 사전', href: '/documents/glossary', hint: '참고' }, +]; + +const text = (value: string, marks?: JSONContent['marks']): JSONContent => ({ type: 'text', text: value, ...(marks ? { marks } : {}) }); +const p = (...content: JSONContent[]): JSONContent => ({ type: 'paragraph', content }); +const h = (level: 2 | 3, value: string): JSONContent => ({ type: 'heading', attrs: { level }, content: [text(value)] }); +const cell = (value: string, isHeader = false): JSONContent => ({ type: isHeader ? 'tableHeader' : 'tableCell', content: [p(text(value))] }); +const image = (src: string, name: string, width: number, pixels: [number, number], color: string): JSONContent => + ({ type: 'media', attrs: { src, name, mime: 'image/svg+xml', width, size: 0, color, pixelWidth: pixels[0], pixelHeight: pixels[1] } }); + +/** 모든 블록이 들어 있는 견본 본문. */ +export const richSample: JSONContent = { type: 'doc', content: [ + p(text('이 본문은 '), text('읽기와 편집이 같은 에디터', [{ type: 'bold' }]), text('입니다. 글을 고르면 서식 도구가, '), text('/', [{ type: 'code' }]), + text('를 치면 블록 목록이 뜹니다. 글줄 왼쪽의 손잡이를 누르면 블록 메뉴가 열립니다. '), + text('화면 규칙 검토 메모', [{ type: 'link', attrs: { href: '/documents/screen-rules' } }]), text('처럼 앱 안의 문서로 가는 링크는 이름표로 보입니다.')), + h(2, '분기 지표'), + p(text('색은 '), text('글자색', [{ type: 'textStyle', attrs: { color: 'var(--editor-ink-blue)' } }]), text('과 '), + text('배경색', [{ type: 'highlight', attrs: { color: 'var(--editor-fill-yellow)' } }]), text('을 고를 수 있고, H'), text('2', [{ type: 'subscript' }]), text('O처럼 첨자도 씁니다.')), + { type: 'table', content: [ + { type: 'tableRow', content: [cell('지표', true), cell('3분기', true), cell('4분기', true)] }, + { type: 'tableRow', content: [cell('활성 사용자'), cell('12,400'), cell('15,100')] }, + { type: 'tableRow', content: [cell('평균 응답 시간'), cell('420ms'), cell('310ms')] }, + ] }, + h(2, '흐름'), + { type: 'mermaidBlock', attrs: { source: 'flowchart LR\n A[요청] --> B{검토}\n B -->|승인| C[배포]\n B -->|보완| A' } }, + h(2, '사진과 파일'), + image(pictures.harbor, '항구의 오후.svg', 100, [1200, 800], '#5f95b8'), + image(pictures.forest, '숲길.svg', 50, [800, 800], '#6aa36a'), + image(pictures.dusk, '해 질 녘.svg', 50, [800, 800], '#c46f3e'), + ...files.slice(0, 3).map(file => ({ type: 'media', attrs: { src: file.url, name: file.name, mime: file.mime, size: file.size, width: 100 } })), + { type: 'media', attrs: { src: htmlPage, name: '분기 지표.html', mime: 'text/html', size: 1_200, width: 100 } }, + h(2, '링크 카드'), + { type: 'webEmbed', attrs: { source: 'https://example.com/guides/reading' } }, + { type: 'webEmbed', attrs: { source: 'https://example.org/notes' } }, + { type: 'taskList', content: [ + { type: 'taskItem', attrs: { checked: true }, content: [p(text('표 모양 맞추기'))] }, + { type: 'taskItem', attrs: { checked: false }, content: [p(text('도표 크게 보기 확인'))] }, + ] }, +] }; + +/** 파일 카드의 종류별 색을 모두 보이는 본문. */ +export const fileSample: JSONContent = { type: 'doc', content: files.map(file => ({ type: 'media', attrs: { src: file.url, name: file.name, mime: file.mime, size: file.size, width: 100 } })) }; diff --git a/site/samples/playground/Documents.samples.tsx b/site/samples/playground/Documents.samples.tsx index 6ea40fc..3a4562c 100644 --- a/site/samples/playground/Documents.samples.tsx +++ b/site/samples/playground/Documents.samples.tsx @@ -1,8 +1,7 @@ import type { Meta, SampleOf } from "../catalog/types"; -import { DocumentScreen, seededBody } from "./documents/DocumentScreen"; import { LibrarySample } from "./library-sample"; -/** 가운데 컨테이너의 문서 카드·행 목록, 컬렉션과 작성·자동 저장 편집을 다룹니다. */ +/** 가운데 컨테이너의 문서 카드·행 목록, 컬렉션과 작성 페이지, 상세의 편집 단추가 여는 수정 화면(제목과 본문만)을 다룹니다. */ const meta = { title: "Playground (플레이그라운드)/문서함", id: "playground-documents", layout: "fullscreen", height: "40rem", device: "desktop", @@ -14,4 +13,5 @@ export const Cards: Sample = { name: "문서 카드", render: () => }; export const Collection: Sample = { name: "컬렉션", render: () => }; export const Compose: Sample = { name: "작성 페이지", render: () => }; -export const Editing: Sample = { name: "문서 편집", render: () => }; +export const Detail: Sample = { name: "자료 상세", render: () => }; +export const Editing: Sample = { name: "수정 화면", render: () => }; diff --git a/site/samples/playground/Documents.test.tsx b/site/samples/playground/Documents.test.tsx index 6430bb5..2fb04ee 100644 --- a/site/samples/playground/Documents.test.tsx +++ b/site/samples/playground/Documents.test.tsx @@ -2,7 +2,6 @@ import { test } from "vitest"; import { expect, userEvent, waitFor, within } from "../catalog/testing"; import { runSample } from "../catalog/run-sample"; import type { SampleOf } from "../catalog/types"; -import { DocumentScreen } from "./documents/DocumentScreen"; import { LibrarySample } from "./library-sample"; import meta from "./Documents.samples"; @@ -39,21 +38,39 @@ const VerifyPhone: Sample = { }, }; -const VerifyAutosave: Sample = { - name: "고친 뒤 자동 저장 검증", - render: () => , +const VerifyEdit: Sample = { + name: "편집 단추·수정 화면·취소 확인·완료 검증", + render: () => , play: async ({ canvasElement }) => { const page = within(canvasElement.ownerDocument.body); - const title = page.getByRole("textbox", { name: "문서 제목" }); - await userEvent.type(title, " 수정"); - await expect(page.getByText("고친 내용 있음")).toBeVisible(); - await waitFor(() => expect(page.getByText("저장됨")).toBeVisible(), { timeout: 4000 }); - await expect(page.getByRole("textbox", { name: "문서 제목" })).toHaveValue("메모 수정"); + // 상세의 편집 단추는 수정 화면(제목과 본문만)을 연다. + await userEvent.click(await page.findByRole("button", { name: "편집" })); + const title = await page.findByRole("textbox", { name: "제목" }); + await expect(page.queryByRole("button", { name: "종류" })).not.toBeInTheDocument(); + await expect(page.getByRole("button", { name: "저장" })).toHaveAttribute("aria-disabled", "true"); + // 고친 뒤 취소하면 먼저 묻고, 계속 작성하면 그대로 남는다. + await userEvent.type(title, " 다시 읽기"); + await expect(page.getByText("저장 안 됨")).toBeVisible(); + await userEvent.click(page.getByRole("button", { name: "취소" })); + const ask = await page.findByRole("alertdialog", { name: "저장하지 않은 내용이 있습니다" }); + await userEvent.click(within(ask).getByRole("button", { name: "계속 작성" })); + await expect(title).toHaveValue("좋은 인터페이스를 만드는 작은 기준들 다시 읽기"); + // Ctrl+S는 제자리에서 저장한다. + title.focus(); + await userEvent.keyboard("{Control>}s{/Control}"); + await waitFor(() => expect(page.getByText("저장됨")).toBeVisible()); + // 완료는 저장하고 상세로 돌아간다. + await userEvent.type(title, "!"); + await userEvent.click(page.getByRole("button", { name: "완료" })); + await expect(await page.findByRole("heading", { level: 1, name: "좋은 인터페이스를 만드는 작은 기준들 다시 읽기!" })).toBeVisible(); + // 고친 것이 없으면 취소는 묻지 않고 돌아간다. + await userEvent.click(page.getByRole("button", { name: "편집" })); + await userEvent.click(await page.findByRole("button", { name: "취소" })); + await expect(await page.findByRole("heading", { level: 1, name: /다시 읽기!/ })).toBeVisible(); + await expect(page.queryByRole("alertdialog")).not.toBeInTheDocument(); }, }; - - const VerifyPageFoot: Sample = { name: "작성 페이지 바닥 고정·동작만 검증", render: () => , @@ -79,5 +96,5 @@ const VerifyPageFoot: Sample = { test(VerifyJourney.name, () => runSample(meta, VerifyJourney)); test(VerifyPhone.name, () => runSample(meta, VerifyPhone)); -test(VerifyAutosave.name, () => runSample(meta, VerifyAutosave), 10000); +test(VerifyEdit.name, () => runSample(meta, VerifyEdit), 15000); test(VerifyPageFoot.name, () => runSample(meta, VerifyPageFoot)); diff --git a/site/samples/playground/Issues.test.tsx b/site/samples/playground/Issues.test.tsx index c8a80c3..3862d1a 100644 --- a/site/samples/playground/Issues.test.tsx +++ b/site/samples/playground/Issues.test.tsx @@ -64,6 +64,27 @@ const VerifyPhone: Sample = { }, }; +const VerifyEdit: Sample = { + name: "편집 단추가 수정 화면을 열고 본문 링크는 제자리에서 가는지 검증", + args: { initialPath: "/issues/ISS-12" }, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + const edit = await page.findByRole("button", { name: "편집" }); + const title = page.getByRole("heading", { level: 1 }).textContent ?? ""; + await userEvent.click(edit); + // 수정 화면은 제목과 본문만 둔다(속성·하위 이슈·활동 없음). + await expect(await page.findByRole("textbox", { name: "제목" })).toHaveValue(title); + await expect(page.getByRole("textbox", { name: "본문" })).toBeVisible(); + await expect(page.queryByRole("region", { name: "하위 이슈" })).not.toBeInTheDocument(); + await expect(page.queryByRole("button", { name: "상태" })).not.toBeInTheDocument(); + // 고친 것이 없으면 취소는 묻지 않고 상세로 돌아간다. + await userEvent.click(page.getByRole("button", { name: "취소" })); + await expect(await page.findByRole("heading", { level: 1, name: title })).toBeVisible(); + await expect(page.queryByRole("alertdialog")).not.toBeInTheDocument(); + }, +}; + test(VerifyCreate.name, () => runSample(meta, VerifyCreate)); +test(VerifyEdit.name, () => runSample(meta, VerifyEdit), 15000); test(VerifyBoard.name, () => runSample(meta, VerifyBoard)); test(VerifyPhone.name, () => runSample(meta, VerifyPhone)); diff --git a/site/samples/playground/PlaygroundFrame.tsx b/site/samples/playground/PlaygroundFrame.tsx index 1906f19..7ec61df 100644 --- a/site/samples/playground/PlaygroundFrame.tsx +++ b/site/samples/playground/PlaygroundFrame.tsx @@ -34,7 +34,7 @@ export function PlaygroundFrame(props: ComponentProps) { icon: 'user' as const, isDisabled: true }]; - return void) { - const [state, setState] = useState("saved"); - const timer = useRef(0); - useEffect(() => () => window.clearTimeout(timer.current), []); - const touch = () => { - setState("dirty"); - window.clearTimeout(timer.current); - timer.current = window.setTimeout(() => { - setState("saving"); - timer.current = window.setTimeout(() => { onSave(); setState("saved"); }, 400); - }, 800); - }; - return { state, touch }; -} - -const label: Record = { saved: "저장됨", dirty: "고친 내용 있음", saving: "저장 중…" }; - -/** - * 문서 편집 화면: 읽는 폭(약 720px) 열에 제목과 본문 칸(리치 에디터)을 종이처럼 둔다. 테두리·서식 도구 막대는 없고 - * 서식은 단축키와 입력 규칙으로 한다. 저장 버튼 대신 자동 저장하고 상태만 보인다. 휴대폰에서는 상세처럼 전체 화면이다. - */ -export function DocumentScreen({ title: initialTitle = "", body: initialBody }: { title?: string; body?: JSONContent }) { - const [title, setTitle] = useState(initialTitle); - const [body, setBody] = useState(initialBody); - const { state, touch } = useAutosave(() => {}); - return }> - {label[state]}}> - - { setTitle(event.target.value); touch(); }} /> - { setBody(value); touch(); }} /> - - - ; -} - -export const seededBody: JSONContent = { type: "doc", content: [ - { type: "paragraph", content: [{ type: "text", text: "이번 분기에 정리한 화면 규칙을 팀과 함께 검토하기 위한 메모입니다. 결정이 필요한 것과 이미 정한 것을 나눠 적습니다." }] }, - { type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "이미 정한 것" }] }, - { type: "bulletList", content: [ - { type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: "다음 화면이 준비될 때까지 지금 화면을 유지한다." }] }] }, - { type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: "필수 값이 비어도 제출 버튼을 막지 않고, 누르면 빠진 항목을 알린다." }] }] }, - ] }, - { type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "더 이야기할 것" }] }, - { type: "paragraph", content: [{ type: "text", text: "목록 옆 미리보기 패널을 어떤 화면까지 넓힐지 정한다." }] }, -] }; diff --git a/site/samples/playground/issues/app/IssuesApp.tsx b/site/samples/playground/issues/app/IssuesApp.tsx index b5a9f62..c4277fa 100644 --- a/site/samples/playground/issues/app/IssuesApp.tsx +++ b/site/samples/playground/issues/app/IssuesApp.tsx @@ -18,6 +18,7 @@ import { IssueApiContext, me, type IssueApi } from '../entities/issue'; import { createIssueApi } from '../mocks/issue-api'; import { IssueCompose } from '../pages/issue-compose/IssueCompose'; import { IssueDetailPage } from '../pages/issue-detail/IssueDetailPage'; +import { IssueEditPage } from '../pages/issue-edit/IssueEditPage'; import { IssuesPage } from '../pages/issues/IssuesPage'; import { validateIssuesSearch } from '../pages/issues/model'; import { useShellLayout } from '../../../catalog/shell-layout'; @@ -58,11 +59,10 @@ function ListRoute() { const panel = useRoutedPanel(); const navigate = useNavigate(); useEffect(() => { if (panel.pendingPhoneHref) void navigate({ href: panel.pendingPhoneHref, replace: true }); }, [panel.pendingPhoneHref, navigate]); - const edit = panel.href?.match(/^\/issues\/([^/]+)\/edit$/)?.[1]; return <> open('/issues/new')} /> - {panel.href === '/issues/new' || edit - ? panel.done(`/issues/${id}`)} /> + {panel.href === '/issues/new' + ? panel.done(`/issues/${id}`)} /> : null} ; } @@ -73,6 +73,13 @@ function DetailRoute() { return void navigate({ href: `/issues/${id}/edit` })} onAddSub={() => void navigate({ href: `/issues/new?parent=${id}` })} />; } +/** 수정 화면: 상세의 편집 단추가 늘 이곳을 연다. 끝나면(완료·취소) 상세로 돌아간다. */ +function EditRoute() { + const { id } = useParams({ strict: false }) as { id: string }; + const navigate = useNavigate(); + return void navigate({ href: `/issues/${id}` })} />; +} + function ComposeRoute() { const { id } = useParams({ strict: false }) as { id?: string }; const { parent } = useSearch({ strict: false }) as { parent?: string }; @@ -101,7 +108,7 @@ export function createIssuesRouter(initialPath: string) { const create = createRoute({ getParentRoute: () => root, path: '/issues/new', component: ComposeRoute, validateSearch: (raw: Record) => ({ parent: typeof raw.parent === 'string' && /^ISS-\d+$/.test(raw.parent) ? raw.parent : undefined }) }); const detail = createRoute({ getParentRoute: () => root, path: '/issues/$id', component: DetailRoute }); - const edit = createRoute({ getParentRoute: () => root, path: '/issues/$id/edit', component: ComposeRoute }); + const edit = createRoute({ getParentRoute: () => root, path: '/issues/$id/edit', component: EditRoute }); return createRouter({ routeTree: root.addChildren([home, list, create, detail, edit]), history: createMemoryHistory({ initialEntries: [initialPath] }), diff --git a/site/samples/playground/issues/pages/issue-detail/IssueDetailPage.tsx b/site/samples/playground/issues/pages/issue-detail/IssueDetailPage.tsx index b568a09..25c4e7e 100644 --- a/site/samples/playground/issues/pages/issue-detail/IssueDetailPage.tsx +++ b/site/samples/playground/issues/pages/issue-detail/IssueDetailPage.tsx @@ -1,6 +1,6 @@ import { RichBody } from '@/shared/ui/composites/editor/RichBody'; +import { fakePreview } from '@samples/composites/editor-fixtures'; import {AlertDialog} from '@/shared/ui/composites/dialog/AlertDialog'; -import {RichDraft} from '@/shared/ui/composites/editor/RichDraft'; import {TextInput} from '@astryxdesign/core/TextInput'; import {useNavigate} from '@tanstack/react-router'; import { ActivityFeed, type ActivityItem } from '@/shared/ui/composites/activity/ActivityFeed'; @@ -16,7 +16,6 @@ import { Text } from '@astryxdesign/core/Text'; import { TextArea } from '@astryxdesign/core/TextArea'; import { useToast } from '@astryxdesign/core/Toast'; import { VStack } from '@astryxdesign/core/VStack'; -import type { JSONContent } from '@tiptap/react'; import { RowMenu } from '@/shared/ui/composites/popup/RowMenu'; import { DatePill, OptionPill, PropertyPill } from '@/shared/ui/composites/property/PropertyPill'; import { PageState } from '@/shared/ui/composites/state/PageState'; @@ -25,20 +24,20 @@ import { DetailLayout } from '@/shared/ui/layouts/detail/DetailLayout'; import { Page } from '@/shared/ui/layouts/page/Page'; import { PageBanner } from '@/shared/ui/layouts/page/PageParts'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useState, type ReactNode } from 'react'; +import { useState } from 'react'; import { me, dateText, issueKeys, issueQueries, NONE, timeText, today, useIssueApi, type Issue, type IssuePatch } from '../../entities/issue'; import { assigneeOptions, Person, priorityOptions, StatusMark, statusOptions } from '../../entities/issue/ui'; import styles from './detail.module.css'; /** * 이슈 상세: 직접 진입할 수 있는 자기 주소의 페이지. 속성은 알약 하나씩 바로 바꾸고(저장 뒤 다시 읽는다) 실패는 머리 아래 줄에 - * 알린다. 본문 수정은 같은 작성 양식을 페이지로 연다. 하위 이슈를 더하면 상위 이슈를 채운 같은 양식으로 간다. + * 알린다. 편집은 늘 수정 화면(…/edit)을 연다(제목과 본문만). 하위 이슈를 더하면 상위 이슈를 채운 같은 양식으로 간다. */ export function IssueDetailPage({ id, onEdit, onAddSub }: { id: string; onEdit: () => void; onAddSub: () => void }) { const api = useIssueApi(); const client = useQueryClient(); const toast = useToast(); - const navigate=useNavigate();const [deleting,setDeleting]=useState(false);const [editing,setEditing]=useState(false); + const navigate=useNavigate();const [deleting,setDeleting]=useState(false); const remove=useMutation({mutationFn:()=>api.remove(id),onSuccess:async()=>{await client.invalidateQueries({queryKey:issueKeys.all});void navigate({href:'/issues'})}}); const query = useQuery(issueQueries.detail(api, id)); const change = useMutation({ @@ -62,13 +61,13 @@ export function IssueDetailPage({ id, onEdit, onAddSub }: { id: string; onEdit: }; return