{props.title}
{props.recommended ? 운영 권장 : null}{props.subtitle}
+{props.description}
+ + +- {props.points.map((point) =>
{point} )}
diff --git a/.github/workflows/ui-deploy.yml b/.github/workflows/ui-deploy.yml
new file mode 100644
index 0000000..5c49a51
--- /dev/null
+++ b/.github/workflows/ui-deploy.yml
@@ -0,0 +1,100 @@
+name: UI Deploy
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - ui/app/**
+
+permissions:
+ contents: read
+ deployments: write
+
+concurrency:
+ group: ui-deploy-main
+ cancel-in-progress: true
+
+jobs:
+ deploy-production:
+ name: test, build and push UI
+ runs-on: ubuntu-24.04-arm
+ environment:
+ name: Production
+ url: https://developers.kr-filter.com
+ env:
+ REGISTRY_HOST: docker-registry.bottle-note.com
+ IMAGE_NAME: profanity-ui
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: '22'
+ cache: npm
+ cache-dependency-path: ui/app/package-lock.json
+
+ - name: Install dependencies
+ working-directory: ui/app
+ run: npm ci
+
+ - name: Run UI CI
+ working-directory: ui/app
+ run: npm run typecheck && npm run build
+
+ - name: Prepare image tag
+ id: image
+ shell: bash
+ run: |
+ set -euo pipefail
+ tag="v0.${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}"
+ echo "tag=$tag" >> "$GITHUB_OUTPUT"
+ echo "image=${REGISTRY_HOST}/${IMAGE_NAME}:${tag}" >> "$GITHUB_OUTPUT"
+
+ - name: Ensure registry secrets are configured
+ shell: bash
+ env:
+ ZOT_REGISTRY_USERNAME: ${{ secrets.ZOT_REGISTRY_USERNAME }}
+ ZOT_REGISTRY_PASSWORD: ${{ secrets.ZOT_REGISTRY_PASSWORD }}
+ run: |
+ set -euo pipefail
+ if [[ -z "$ZOT_REGISTRY_USERNAME" || -z "$ZOT_REGISTRY_PASSWORD" ]]; then
+ echo "Production environment secrets ZOT_REGISTRY_USERNAME and ZOT_REGISTRY_PASSWORD are required." >&2
+ exit 1
+ fi
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY_HOST }}
+ username: ${{ secrets.ZOT_REGISTRY_USERNAME }}
+ password: ${{ secrets.ZOT_REGISTRY_PASSWORD }}
+
+ - name: Build and push UI image
+ uses: docker/build-push-action@v6
+ with:
+ context: ui/app
+ platforms: linux/arm64
+ push: true
+ tags: ${{ steps.image.outputs.image }}
+
+ - name: Write deployment summary
+ shell: bash
+ run: |
+ set -euo pipefail
+ {
+ echo "## UI deployment"
+ echo ""
+ echo "| Field | Value |"
+ echo "| --- | --- |"
+ echo "| Public URL | https://developers.kr-filter.com |"
+ echo "| Image | ${{ steps.image.outputs.image }} |"
+ echo "| Commit | $GITHUB_SHA |"
+ echo "| Rollout | ArgoCD Image Updater |"
+ } >> "$GITHUB_STEP_SUMMARY"
diff --git a/.gitignore b/.gitignore
index ec05b0c..472d739 100644
--- a/.gitignore
+++ b/.gitignore
@@ -60,3 +60,4 @@ application-prod.yml
.omc/
/.env
.DS_Store
+/.playwright-mcp/
diff --git a/README.md b/README.md
index e8d5467..e6b191c 100644
--- a/README.md
+++ b/README.md
@@ -1,190 +1,167 @@
-
-
-# 한국어 비속어 필터 API 서비스
-
-> API 인증 키 발급 후 사용 가능합니다. 문서 링크를 참조해 주세요
->
-> [OpenAPI JSON](https://api.kr-filter.com/openapi.json)
->
-> [Overview](https://api.kr-filter.com/overview.md)
->
-> [테스트 페이지](https://api.kr-filter.com/)
->
-> ~~레거시 API 주소: https://api.profanity.kr-filter.com~~ (지원 종료 예정)
->
-> 헬스 체크
-> - https://api.kr-filter.com/api/v1/ping
-> - https://api.kr-filter.com/api/v1/health
-
-- 기존 `x-api-key` 신규 발급은 중단 예정이며, 이미 발급된 키는 호환성 유지를 위해 유지합니다.
-- Google/GitHub SSO 로그인용 JWT access token과 rotating refresh token을 지원합니다.
-- OAuth2 Client Credentials 기반 외부 API Bearer 인증은 아직 미구현이며, 향후 대시보드의 API 클라이언트 발급 기능과 함께 제공할 예정입니다.
-
-## Overview
-
-이 서비스는 한국어 비속어를 모두 검출하고 필터링할 수 있는 무료 API입니다.
-
-### 주요 특징
-
-- **경량 필터링 엔진**: 정규식과 비속어 데이터베이스를 활용한 효율적인 필터링
-- **고성능 검사**: `아호코라식 알고리즘`을 사용하여 빠르고, 정확한 비속어 검출
-- **다양한 필터링 모드**: 빠른 검사(QUICK), 일반 검사(NORMAL), 대체 검사(FILTER) 지원
-- **KISO 호환성**: [KISO 이용자 보호 시스템 API](https://www.safekiso.com/)와 유사한 스펙으로 구현
-
-### 이용 대상
-
-이 API는 주로 다음과 같은 사용자를 위해 설계되었습니다:
-
-- 포트폴리오나 취미 프로젝트를 개발하는 학생 및 개발자
-- 비영리 서비스를 운영하는 소규모 단체
-- 비용은 최소화하면서 기본적인 비속어 필터링이 필요한 웹사이트/앱
-
-예산 제약 없이 상업적 서비스에 활용하실 경우에는 [KISO 이용자 보호 시스템 API](https://www.safekiso.com/)를 권장합니다(월 약 7만원).
-
-~~이 서비스는 개인 서버로 운영되므로 가용성은 보장되지 않지만,~~ 2025-05 기준 OCP 환경으로 이관하였습니다.
-
-기본적인 비속어 필터링 기능을 무료로 제공하는 데 의의가 있습니다.
-
-## API Guide
-
-### 인증 전환 방향
-
-현재 외부 API 호출은 기존 `x-api-key` 헤더를 사용합니다. 사람의 대시보드 로그인은 Google/GitHub SSO 완료 후 발급되는 `LOGIN_JWT`와 rotating refresh token을 사용하며, 외부 API 인증과 분리되어 있습니다.
-
-OAuth2 Client Credentials의 `/oauth2/token`, `client_id/client_secret`, 외부 API용 Bearer access token은 다음 단계의 범위입니다. 현재 외부 API에 제출된 Bearer token은 지원되지 않는 `OAUTH2_ACCESS_TOKEN` 경계에서 fail-closed 처리하며, 기존 API Key 동작은 유지합니다. 상세 계약은 [Authentication](profanity-api/src/main/resources/openapi/authentication.md)을 참고하세요.
-
-- [ADR 0005. SSO 기반 사용자 계정 모델 도입](docs/adr/0005%20SSO%20기반%20사용자%20계정%20모델%20도입.md)
-- [ADR 0006. OAuth2 Client Credentials 기반 API 인증 전환](docs/adr/0006%20OAuth2%20Client%20Credentials%20기반%20API%20인증%20전환.md)
-
-### 현재 API 호출 방식
-
-- 요청 URL: `POST https://api.kr-filter.com/api/v1/filter`
-- headers
- - `Content-Type: application/json` or `application/x-www-form-urlencoded`
- - `accept: application/json`
- - `x-api-key: {API_KEY}`
- - API_KEY는 제공되는 API_KEY를 사용해주세요.
-- parameters:
- - *`text`: 검증할 문장 (예: "나쁜말")
- - *`mode`: `QUICK`,`NORMAL`,`FILTER` 중 하나 선택
- - `QUICK`: 빠른 검사에 적합합니다.
- - `NORMAL`: 일반적인 검사에 적합합니다.
- - `FILTER`: 일반적인 검사후 비속어를 `*`로 대체합니다.
- - `callbackUrl`: 비동기 처리시 결과를 받을 URL
- - \* 기호가 붙은 파라미터는 필수 입력값입니다.
-
-### Response Code
-
-- 요청에 대한 HTTP Status Code는 대부분 200으로 응답됩니다.
-- 응답 객체에서는 요청에 따라 변동적인 `status.code`에 응답 코드가 포함되어 있습니다.
-- `Status Code`는 KISO 이용자 보호 시스템 API 서비스의 응답 코드를 참조하여 작성되었습니다.
-
-| Status Code | Description | Description |
-|-------------|-----------------------|--------------------------------------------------------------|
-| 2000 | OK | 요청이 정상적으로 처리된 상태를 의미합니다. |
-| 2020 | Accepted | 비동기 요청이 정상적으로 접수된 상태를 의미합니다. |
-| 2021 | Processing | 요청 처리가 진행 중인 상태를 의미합니다. |
-| 4000 | Bad Request | 요청이 비정상적인 경우 입니다. 파라미터 누락,타입 오류등이 있습니다, 상세 내용을 참고하세요. |
-| 4001 | Invalid Callback URL | 콜백 URL 형식이 올바르지 않은 경우 발생합니다. |
-| 4002 | Invalid Tracking ID | Tracking ID가 유효하지 않은 경우 발생합니다. |
-| 4003 | Not Fount Tracking ID | Tracking ID를 찾을 수 없는 경우 발생합니다. |
-| 4004 | Ambiguous Credentials | 다중 또는 중복 인증 정보를 제출한 경우 발생합니다. |
-| 4010 | Unauthorized | 요청을 인증할 API 키 값이 없는 경우 발생하는 오류 입니다. |
-| 4011 | OAuth2 Login Failed | Google/GitHub SSO 로그인에 실패한 경우 발생합니다. |
-| 4012 | Login Code Invalid | 로그인 교환 코드가 잘못됐거나 만료 또는 재사용된 경우 발생합니다. |
-| 4013 | Login Token Invalid | 로그인 access token 검증에 실패한 경우 발생합니다. |
-| 4014 | Login Token Expired | 로그인 access token이 만료된 경우 발생합니다. |
-| 4015 | Refresh Token Invalid | refresh token 또는 session이 잘못됐거나 만료·폐기된 경우 발생합니다. |
-| 4016 | Refresh Token Reused | 이미 소비된 refresh token이 다시 제출된 경우 발생합니다. |
-| 4017 | OAuth2 Token Unsupported | 외부 API용 OAuth2 access token이 아직 지원되지 않는 경우 발생합니다. |
-| 4030 | Forbidden | 서버에서 요청에 API 키값을 인식하였으나 해당 키가 적절한 권한을 가지지 않았다고 판정한 경우 발생합니다. |
-| 4031 | Not Found Client | API Key에 해당하는 클라이언트 정보를 찾을 수 없는 경우 발생합니다. |
-| 4032 | Invalid API Key | API Key가 유효하지 않은 경우 발생합니다. |
-| 4033 | User Inactive | 로그인 사용자가 비활성 상태인 경우 발생합니다. |
-| 4290 | Too Many Requests | 특정 클라이언트가 너무 많은 요청을 단위 시간 안에 보낸 경우에 이 응답이 리턴됩니다. |
-| 5000 | Internal Server Error | 서버 측의 문제로 요청에 대한 처리가 불가능한 경우 오류가 발생하였음을 알리기 위해 본 코드를 사용합니다. |
-| 5030 | Service Unavailable | 서비스 점검 또는 일시 사용 불가 상태를 의미합니다. |
-
-### Usage Guide
-
-#### **응답 예**
+# 한국어 비속어 필터 API
-```json
-{
- "trackingId": "bee20667-aa5a-4d39-94f5-0f2dcbd51cac",
- "status": {
- "code": 2000,
- "message": "Ok",
- "description": "정상적으로 처리 되었습니다.",
- "DetailDescription": ""
- },
- "detected": [
- {
- "length": 1,
- "filteredWord": "나"
- },
- {
- "length": 2,
- "filteredWord": "나쁜"
- },
- {
- "length": 3,
- "filteredWord": "나쁜말"
- },
- {
- "length": 2,
- "filteredWord": "냐쁀"
- }
- ],
- "filtered": "*** 이런 개 ** 짓을 왜 하냐?, **, *",
- "elapsed": "0.00007676 s / 0.07676 ms / 76.758 µs"
-}
+Aho-Corasick 알고리즘을 기반으로 한국어 비속어를 검출하고 마스킹하는 REST API입니다. `QUICK`, `NORMAL`, `FILTER` 모드를 지원하며 API Key 인증으로 사용할 수 있습니다.
+
+## 바로가기
+
+| 구분 | URL |
+|---|---|
+| 개발자 포털 | [https://developers.kr-filter.com](https://developers.kr-filter.com) |
+| API 기준 주소 | `https://api.kr-filter.com` |
+| API 문서 | [https://developers.kr-filter.com/docs](https://developers.kr-filter.com/docs) |
+| OpenAPI JSON | [https://api.kr-filter.com/openapi.json](https://api.kr-filter.com/openapi.json) |
+| API 개요 | [https://api.kr-filter.com/overview.md](https://api.kr-filter.com/overview.md) |
+| LLM 문서 | [https://api.kr-filter.com/llms.txt](https://api.kr-filter.com/llms.txt) |
+| 상태 확인 | [health](https://api.kr-filter.com/api/v1/health) · [ping](https://api.kr-filter.com/api/v1/ping) |
+
+`api.profanity.kr-filter.com`은 레거시 주소이며 신규 연동에서는 사용하지 않습니다.
+
+## 인증 모델
+
+사람의 개발자 포털 로그인과 외부 API 호출 인증은 서로 다른 자격 증명을 사용합니다.
+
+| 용도 | 방식 | 상태 |
+|---|---|---|
+| 개발자 포털 로그인 | Google/GitHub SSO 및 `LOGIN_JWT` | 제공 중 |
+| 외부 API 호출 | `x-api-key: {API_KEY}` | 제공 중 |
+| 외부 API 호출 | OAuth2 Client Credentials Bearer token | 준비 중, 현재 비활성화 |
+
+API Key와 OAuth2 Client Credentials는 모두 SSO 로그인 후 발급하는 정책으로 전환합니다. 다만 현재 운영 API에는 기존 공개 발급 경로가 남아 있으므로, 백엔드 전환이 완료되기 전까지 아래 API 목록의 현재 상태를 기준으로 사용해야 합니다.
+
+외부 API에 아직 지원하지 않는 Bearer token을 보내면 HTTP `401`, business code `4017`로 거부됩니다. 로그인 JWT를 외부 API Key 대신 사용할 수도 없습니다.
+
+자세한 계약은 [Authentication](profanity-api/src/main/resources/openapi/authentication.md)과 다음 ADR을 참고하세요.
+
+- [ADR 0005: SSO 기반 사용자 계정 모델 도입](docs/adr/0005%20SSO%20기반%20사용자%20계정%20모델%20도입.md)
+- [ADR 0006: 로그인 기반 API 자격 증명 발급과 OAuth2 인증 도입](docs/adr/0006%20OAuth2%20Client%20Credentials%20기반%20API%20인증%20전환.md)
+
+## 빠른 시작
+
+```bash
+curl --request POST 'https://api.kr-filter.com/api/v1/filter' \
+ --header 'Content-Type: application/json' \
+ --header 'Accept: application/json' \
+ --header 'x-api-key: YOUR_API_KEY' \
+ --data '{
+ "text": "검사할 문장",
+ "mode": "FILTER"
+ }'
```
-## Examples
+필터링 모드는 다음과 같습니다.
+
+| 모드 | 동작 |
+|---|---|
+| `QUICK` | 첫 번째 매칭을 빠르게 확인합니다. |
+| `NORMAL` | 매칭된 비속어를 모두 반환합니다. |
+| `FILTER` | 매칭된 비속어를 `*`로 마스킹합니다. |
+
+`text`와 `mode`는 필수이며, 비동기 결과가 필요하면 `callbackUrl`을 추가할 수 있습니다. 동일한 `/api/v1/filter` 경로에서 JSON과 `application/x-www-form-urlencoded` 요청을 모두 지원합니다.
-- [cURL Guide](examples/curl.md)
-- [Java Guide](examples/java.md)
-- [JavaScript Guide](examples/javascript.md)
+## API 목록
-## 주의사항
+아래 목록은 현재 운영 OpenAPI와 서버 보안 정책을 기준으로 합니다. 요청·응답 스키마와 실행 가능한 예제는 [개발자 포털 API 문서](https://developers.kr-filter.com/docs)에서 확인할 수 있습니다.
-### 서비스 이용 제한
+### 공개 및 문서
-- **사용 목적**: 이 서비스는 포트폴리오, 학습용 프로젝트, 비영리 서비스를 위해 제공됩니다. 상업적/영리 목적으로 활용하시려면 [KISO 이용자 보호 시스템 API 서비스](https://www.safekiso.com/)를 이용해 주세요.
+| Method | Path | 설명 |
+|---|---|---|
+| `GET` | `/openapi.json` | OpenAPI 문서 |
+| `GET` | `/overview.md` | API 개요, 인증 및 오류 모델 |
+| `GET` | `/llms.txt` | LLM용 문서 인덱스 |
+| `GET` | `/api/v1/health` | 애플리케이션 상태 확인 |
+| `GET` | `/api/v1/ping` | 연결 확인 |
-- **가용성**: 개인 서버로 운영되는 무료 서비스이므로 100% 가용성을 보장하지 않습니다. 서버 장애, 하드웨어 문제, 네트워크 이슈 등으로 일시적 중단이 발생할 수 있습니다.
+### SSO 로그인
-- **성능 제한**: 과도한 API 호출 시 서비스 품질 유지를 위해 요청 제한(rate limiting)이 적용될 수 있습니다.
+| Method | Path | 설명 | 인증 |
+|---|---|---|---|
+| `GET` | `/oauth2/authorization/{google\|github}` | 소셜 로그인 시작 | 공개 |
+| `POST` | `/api/v1/auth/exchange` | 일회용 로그인 코드를 access token으로 교환 | 공개 코드 |
+| `GET` | `/api/v1/auth/csrf` | refresh 요청용 CSRF token 조회 | refresh cookie |
+| `POST` | `/api/v1/auth/refresh` | 로그인 access token 갱신 | refresh cookie와 CSRF |
+| `GET` | `/api/v1/auth/me` | 로그인 사용자 조회 | `LOGIN_JWT` |
-### 기타 고려사항
+로그인 access token은 15분, refresh token은 14일이며 refresh session의 절대 수명은 30일입니다. 브라우저 로그인 흐름은 개발자 포털이 처리하므로 일반 API 사용자가 직접 구현할 필요는 없습니다.
-- **API 변경**: API 스펙은 개선을 위해 변경될 수 있습니다. 변경 시에는 이 문서를 통해 사전 공지할 예정입니다.
+### 비속어 필터
-- **개인정보**: API를 통해 전송되는 텍스트는 비속어 필터링 목적으로만 사용되며, 별도로 저장하지 않습니다. 단, 서비스 개선을 위한 기본적인 사용 통계는 수집될 수 있습니다.
+| Method | Path | 설명 | 인증 |
+|---|---|---|---|
+| `POST` | `/api/v1/filter` | 비속어 필터링 요청 | API Key |
+| `POST` | `/api/v1/filter/advanced` | 단일 단어 기반 고급 마스킹 | API Key |
-- **책임 제한**: 이 API의 결과에 의존하여 발생한 문제(필터링 실패, 잘못된 검출 등)에 대해 개발자는 법적 책임을 지지 않습니다.
+### 클라이언트 및 API Key
-이 서비스는 최대한 KISO 이용자 보호 시스템 API 서비스와 유사한 형태로 발전해 나갈 예정이지만, 무료 서비스로서의 한계가 있음을 양해해 주시기 바랍니다.
+| Method | Path | 설명 | 현재 인증 |
+|---|---|---|---|
+| `GET` | `/api/v1/clients` | 클라이언트 정보 조회 | API Key |
+| `DELETE` | `/api/v1/clients` | 클라이언트 폐기 | API Key |
+| `POST` | `/api/v1/clients/update` | 클라이언트 정보 변경 | API Key |
+| `POST` | `/api/v1/clients/reissue` | API Key 재발급 | API Key |
+| `POST` | `/api/v1/clients/register` | 레거시 신규 클라이언트 등록 | 공개, 로그인 기반 발급으로 전환 예정 |
+| `GET` | `/api/v1/clients/send-email` | 레거시 이메일 인증 코드 발송 | 공개 |
+| `PUT` | `/api/v1/clients/send-email` | 레거시 이메일 인증 코드 검증 | 공개 |
+
+### 관리 API
+
+| Method | Path | 설명 | 인증 |
+|---|---|---|---|
+| `POST` | `/api/v1/word/request` | 비속어 단어 변경 요청 | API Key |
+| `POST` | `/api/v1/word/accept/{requestId}` | 단어 변경 요청 승인 | WRITE 권한 API Key |
+| `GET` | `/api/v1/sync` | 비속어 데이터 수동 동기화 | 관리자 API Key 및 password |
+
+## 응답과 오류
+
+대부분의 비즈니스 결과는 HTTP status와 함께 응답 본문의 `status.code`로도 전달됩니다. 전체 code와 오류 조건은 [Overview의 Error Model](https://api.kr-filter.com/overview.md)에서 관리합니다.
+
+```json
+{
+ "trackingId": "bee20667-aa5a-4d39-94f5-0f2dcbd51cac",
+ "status": {
+ "code": 2000,
+ "message": "Ok"
+ },
+ "detected": [],
+ "filtered": "검사 결과",
+ "elapsed": "0.07676 ms"
+}
+```
+
+## 로컬 개발
+
+요구 환경은 Java 21, Node.js 22, Docker입니다.
+
+```bash
+# 백엔드 검증
+./gradlew staticCheck unitTest supportTest apiE2eTest
+
+# 백엔드 실행
+./gradlew :profanity-api:bootRun
+
+# 프론트엔드 실행
+npm --prefix ui/app ci
+npm --prefix ui/app run dev
+
+# 프론트엔드 검증
+npm --prefix ui/app run typecheck
+npm --prefix ui/app run build
+```
-#### 문의사항은 Issue로 등록하거나 이메일로 문의바랍니다.
+기본 로컬 주소는 API `http://localhost:8080`, UI `http://localhost:5173`입니다.
-[ Post. 비속어 검증 API 서비스 만들기 ](https://deadwhale.me/posts/profanity-filter-api/)
+## 배포
-## 문의 및 연락처
+- 백엔드는 GitHub Release 발행 시 ARM64 이미지를 빌드해 `docker-registry.bottle-note.com/profanity-api`로 push합니다.
+- 프론트엔드는 `main`의 `ui/app/**` 변경 시 CI를 통과한 뒤 ARM64 이미지를 `docker-registry.bottle-note.com/profanity-ui`로 push합니다.
+- ArgoCD Image Updater가 새 semver 이미지를 감지하고 ArgoCD가 K3s 운영 환경에 자동으로 롤아웃합니다.
+- 운영 manifest는 [deploy/overlays/production](deploy/overlays/production)에서 관리합니다.
-문의사항은 Issue로 등록하거나 이메일로 문의바랍니다.
+## 이용 시 주의사항
+- 무료 개인 운영 서비스이므로 100% 가용성을 보장하지 않습니다.
+- 과도한 호출에는 rate limiting이 적용될 수 있습니다.
+- 필터 결과가 모든 문맥과 표현을 완벽하게 판별한다고 보장하지 않습니다.
+- 상업적 서비스에는 운영 요구사항과 결과 정확도를 별도로 검토해야 합니다.
-
-
-
-
- - 📧 rlagusrl928@gmail.com - - |
-
Korean profanity filter API
+한국어 문장의 비속어를 검출하고 필요한 방식으로 마스킹하는 API입니다.
+{authenticated ? "API Key로 바로 연동할 수 있습니다" : "로그인 후 선택할 수 있습니다"}
+시작하기
++ {authenticated + ? "연동 환경에 맞는 자격 증명을 선택하고 API 문서에서 요청 방식을 확인하세요." + : "Google 또는 GitHub 계정으로 로그인한 뒤 자격 증명을 만들고 관리할 수 있습니다."} +
+Sign in
+자격 증명은 Google 또는 GitHub로 로그인한 사용자만 만들고 관리할 수 있습니다.
+{error || (pending ? "로그인 상태를 확인하고 있습니다." : "선택한 계정의 로그인 화면으로 이동합니다.")}
+{authenticated ? "API Key를 만들거나 다음 인증 방식을 미리 확인하세요." : "Google 또는 GitHub 계정으로 로그인한 뒤 만들 수 있습니다."}
+{props.subtitle}
+{props.description}
+ + +SSO에서 확인한 기본 계정 정보입니다.
이 화면은 발급 흐름을 확인하는 프로토타입입니다. API가 연결되기 전에는 자격 증명을 생성하거나 전송하지 않습니다.
+ +{isReference ? OPENAPI_URL : OVERVIEW_URL}
+ {codeLines.join("\n")});
+ continue;
+ }
+
+ if (line.startsWith("|") && lines[index + 1]?.startsWith("|")) {
+ const tableLines: string[] = [];
+ while (index < lines.length && lines[index].startsWith("|")) {
+ tableLines.push(lines[index]);
+ index += 1;
+ }
+ nodes.push(renderTable(tableLines, `table-${index}`));
+ continue;
+ }
+
+ const heading = /^(#{1,4})\s+(.+)$/.exec(line);
+ if (heading) {
+ const level = heading[1].length;
+ const children = renderInline(heading[2]);
+ const baseId = createMarkdownHeadingId(heading[2]);
+ const count = headingIds.get(baseId) ?? 0;
+ headingIds.set(baseId, count + 1);
+ const id = count === 0 ? baseId : `${baseId}-${count}`;
+ if (level === 1) nodes.push({renderInline(paragraph.join(" "))}
); + } + + return nodes; +} + +function renderTable(lines: string[], key: string) { + const rows = lines + .filter((line) => !/^\|\s*-/.test(line)) + .map((line) => line.split("|").slice(1, -1).map((cell) => cell.trim())); + const [head, ...body] = rows; + const hasPlannedOAuth = body.some((row) => row.some((cell) => cell.includes("OAUTH2_ACCESS_TOKEN"))); + return ( +| {renderInline(cell)} | )}
|---|
| {renderInline(cell)} | )} +
{part.slice(1, -1)};
+ if (part.startsWith("**") && part.endsWith("**")) return {part.slice(2, -2)};
+ return OpenAPI<\/p>/, "Selected API group body must not add a custom OpenAPI label above Scalar."); -assert.doesNotMatch(docsPage, /
/, "Register page must not show raw response JSON.");
-assert.match(registerPage, /navigator\.clipboard\.writeText/, "Issued keys must be copyable.");
-
-assert.match(styles, /\.register-page\b/, "Register page shell must be styled.");
-assert.match(styles, /\.register-form\b/, "Register form must be styled.");
-assert.match(styles, /\.register-result\b/, "Register result must be styled.");
diff --git a/ui/sample-app/src/App.tsx b/ui/sample-app/src/App.tsx
deleted file mode 100644
index 64c498f..0000000
--- a/ui/sample-app/src/App.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-import { lazy, Suspense, useCallback, useEffect, useState } from "react";
-
-import { getCurrentPagePath, type PagePath } from "./constants/pagePath";
-import { OPENAPI_DOCUMENT_URL, OVERVIEW_MARKDOWN_PATH, FALLBACK_OVERVIEW_MARKDOWN } from "./docs/constants";
-import { preloadDocsDocuments } from "./docs/hooks";
-import { HomePage } from "./features/home/HomePage";
-import { LoginPage } from "./features/login/LoginPage";
-import { RegisterPage } from "./features/register/RegisterPage";
-import { Navigation } from "./components/Navigation";
-
-function loadDocsPage() {
- const docsPageModule = import("./DocsPage");
- const docsDocuments = preloadDocsDocuments(
- OPENAPI_DOCUMENT_URL,
- OVERVIEW_MARKDOWN_PATH,
- FALLBACK_OVERVIEW_MARKDOWN,
- );
-
- return Promise.all([
- docsPageModule,
- docsDocuments,
- ]).then(([module]) => module);
-}
-
-const DocsPage = lazy(loadDocsPage);
-
-export default function App() {
- const [pagePath, setPagePath] = useState(() => getCurrentPagePath(window.location.pathname));
-
- const handlePopState = useCallback(() => {
- setPagePath(getCurrentPagePath(window.location.pathname));
- }, []);
-
- useEffect(() => {
- window.history.scrollRestoration = "manual";
- window.addEventListener("popstate", handlePopState);
- return () => window.removeEventListener("popstate", handlePopState);
- }, [handlePopState]);
-
- useEffect(() => {
- window.scrollTo({ top: 0, behavior: "auto" });
- }, [pagePath]);
-
- const navigate = useCallback((path: PagePath) => {
- if (window.location.pathname !== path) {
- window.history.pushState({}, "", path);
- }
- setPagePath(path);
- window.scrollTo({ top: 0, behavior: "smooth" });
- }, []);
-
- return (
-
-
- {pagePath.startsWith("/docs") ? (
- }>
-
-
- ) : pagePath === "/register" ? (
-
- ) : pagePath === "/login" ? (
-
- ) : (
-
- )}
-
- );
-}
-
-function DocsFallback() {
- return ;
-}
-
-function DocsLoadingOverlay() {
- return (
-
-
-
-
-
- );
-}
diff --git a/ui/sample-app/src/DocsPage.tsx b/ui/sample-app/src/DocsPage.tsx
deleted file mode 100644
index 572d87e..0000000
--- a/ui/sample-app/src/DocsPage.tsx
+++ /dev/null
@@ -1,267 +0,0 @@
-import { ApiReferenceReact, type AnyApiReferenceConfiguration } from "@scalar/api-reference-react";
-import "@scalar/api-reference-react/style.css";
-import { useEffect, useMemo, useState } from "react";
-
-import { OPENAPI_DOCUMENT_URL, OVERVIEW_MARKDOWN_PATH, FALLBACK_OVERVIEW_MARKDOWN } from "./docs/constants";
-import {
- buildSections,
- buildHashNavigation,
- createOperationAnchor,
- createScalarOperationSlug,
- createTagAnchor,
- getSectionForHash,
- parseMarkdown,
-} from "./docs/utils";
-import { useMarkdownDocument, useOpenApiDocument } from "./docs/hooks";
-
-function getDecodedHash() {
- const rawHash = window.location.hash.replace(/^#/, "");
- if (!rawHash) {
- return "";
- }
- try {
- return decodeURIComponent(rawHash);
- } catch {
- return rawHash;
- }
-}
-
-export default function DocsPage() {
- const [copied, setCopied] = useState(false);
- const [activeHash, setActiveHash] = useState(getDecodedHash);
- const [openSectionSlugs, setOpenSectionSlugs] = useState>(() => new Set());
- const { document, error } = useOpenApiDocument(OPENAPI_DOCUMENT_URL);
- const overview = useMarkdownDocument(OVERVIEW_MARKDOWN_PATH, FALLBACK_OVERVIEW_MARKDOWN);
-
- useEffect(() => {
- const syncHash = () => setActiveHash(getDecodedHash());
- window.addEventListener("hashchange", syncHash);
- window.addEventListener("popstate", syncHash);
- return () => {
- window.removeEventListener("hashchange", syncHash);
- window.removeEventListener("popstate", syncHash);
- };
- }, []);
-
- const sections = useMemo(() => (document ? buildSections(document) : []), [document]);
- const overviewLinks = useMemo(() => buildHashNavigation(overview.content), [overview.content]);
- const selectedSection = useMemo(() => getSectionForHash(activeHash, sections), [activeHash, sections]);
-
- const scalarConfiguration = useMemo(() => {
- if (!document) {
- return null;
- }
-
- return {
- content: document,
- title: document.info?.title ?? "API 문서",
- slug: "docs",
- theme: "none",
- layout: "modern",
- forceDarkModeState: "light",
- defaultOpenAllTags: true,
- defaultOpenFirstTag: false,
- hideDarkModeToggle: true,
- documentDownloadType: "none",
- hideTestRequestButton: true,
- hideClientButton: true,
- hideModels: true,
- hideSearch: true,
- showSidebar: false,
- showDeveloperTools: "never",
- searchHotKey: "k",
- withDefaultFonts: false,
- agent: { disabled: true },
- mcp: { disabled: true },
- generateTagSlug: ({ name }) => slugify(name ?? ""),
- generateOperationSlug: ({ method, path, operationId, summary }) =>
- createScalarOperationSlug({ method, path, operationId, summary }),
- };
- }, [document]);
-
- useEffect(() => {
- if (!selectedSection) {
- return;
- }
- setOpenSectionSlugs((current) => {
- if (current.has(selectedSection.slug)) {
- return current;
- }
- const next = new Set(current);
- next.add(selectedSection.slug);
- return next;
- });
- }, [selectedSection]);
-
- useEffect(() => {
- const targetHash = activeHash || "overview";
- const timers: number[] = [];
- let attempts = 0;
-
- const scrollWhenReady = () => {
- const target = window.document.getElementById(targetHash);
- if (target) {
- target.scrollIntoView({ block: "start" });
- return;
- }
- if (attempts < 30) {
- attempts += 1;
- timers.push(window.setTimeout(scrollWhenReady, 120));
- }
- };
-
- timers.push(window.setTimeout(scrollWhenReady, 0));
- return () => timers.forEach((timer) => window.clearTimeout(timer));
- }, [activeHash, selectedSection?.slug, overview.content]);
-
- const copyOpenApiUrl = async () => {
- if (!navigator.clipboard) {
- return;
- }
- await navigator.clipboard.writeText(OPENAPI_DOCUMENT_URL);
- setCopied(true);
- window.setTimeout(() => setCopied(false), 1400);
- };
-
- if (error) {
- return (
-
- {error}
-
- );
- }
-
- if (!document) {
- return (
-
-
-
- );
- }
-
- const isOverview = !activeHash.startsWith("docs/tag/");
- const getLinkClassName = (anchor: string, extraClassName?: string) =>
- [activeHash === anchor ? "active" : "", extraClassName].filter(Boolean).join(" ") || undefined;
- const toggleOpenApiSection = (sectionSlug: string) => {
- setOpenSectionSlugs((current) => {
- const next = new Set(current);
- if (next.has(sectionSlug)) {
- next.delete(sectionSlug);
- } else {
- next.add(sectionSlug);
- }
- return next;
- });
- };
-
- return (
-
-
-
-
-
- {isOverview ? (overview.source === "local" ? "Markdown" : "fallback") : document.openapi}
-
-
-
-
- {isOverview ? (
-
-
- Overview
- {overview.source === "local" ? overview.url : "fallback"}
-
-
-
- ) : (
-
- {scalarConfiguration ? : null}
-
- )}
-
-
-
- );
-}
-
-function DocsLoadingFrame() {
- return (
-
-
-
- );
-}
-
-function DocsLoadingBar() {
- return (
-
-
-
- );
-}
-
-function MarkdownArticle({ content }: { content: string }) {
- return {parseMarkdown(content)} ;
-}
-
-function slugify(value: string) {
- return value
- .trim()
- .toLowerCase()
- .replace(/&/g, "and")
- .replace(/[^a-z0-9가-힣]+/g, "-")
- .replace(/^-+|-+$/g, "");
-}
diff --git a/ui/sample-app/src/components/Navigation.tsx b/ui/sample-app/src/components/Navigation.tsx
deleted file mode 100644
index dc6326a..0000000
--- a/ui/sample-app/src/components/Navigation.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import type { PagePath } from "../constants/pagePath";
-
-type NavigationProps = {
- onNavigate: (path: PagePath) => void;
- pagePath: PagePath;
-};
-
-export function Navigation({ onNavigate, pagePath }: NavigationProps) {
- return (
-
- {
- event.preventDefault();
- onNavigate("/");
- }}
- >
- 말조심하세욧
-
-
-
- );
-}
diff --git a/ui/sample-app/src/constants/landingContent.ts b/ui/sample-app/src/constants/landingContent.ts
deleted file mode 100644
index a59a14b..0000000
--- a/ui/sample-app/src/constants/landingContent.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-export type IdentityTabOption = {
- id: "free-practical" | "korean-focused" | "developer-integration";
- tabLabel: string;
- label: string;
- intro: string;
- title: string;
- body: string;
- points: readonly string[];
- proofItems: readonly string[];
- visualLabel: string;
- visualTitle: string;
- visualLines: readonly string[];
-};
-
-export type ScenarioStep = {
- id: "scenario-1" | "scenario-2" | "scenario-3";
- label: string;
- title: string;
- body: string;
-};
-
-export const IDENTITY_OPTIONS: readonly IdentityTabOption[] = [
- {
- id: "free-practical",
- tabLabel: "무료/실용",
- label: "작은 서비스",
- intro: "비용 때문에 입력 안전망을 미루지 않게",
- title: "작은 서비스도 한국어 입력을 안전하게 다룰 수 있게",
- body: "포트폴리오, 학습, 비영리 서비스가 댓글과 게시글 저장 전에 기본 비속어 필터링을 붙일 수 있도록 만든 무료 REST API입니다.",
- points: ["무료로 시작", "키 발급 후 호출", "현실적인 첫 안전망"],
- proofItems: ["키 발급 후 바로 호출", "문서 보고 붙이기", "상업용은 KISO 권장"],
- visualLabel: "추천 대상",
- visualTitle: "무료로 시작하는 입력 안전망",
- visualLines: ["학습 서비스", "비영리 프로젝트", "사이드 프로젝트"],
- },
- {
- id: "korean-focused",
- tabLabel: "한국어 중심",
- label: "한국어 필터",
- intro: "영어권 moderation이 놓치는 한국어 입력을 기준으로",
- title: "한국어 비속어 사전을 빠르게 검색합니다",
- body: "Aho-Corasick Trie로 등록된 비속어 목록을 한 번에 매칭하고, 초성/한글/영문이 섞인 사용자 입력에서도 검출 위치를 계산합니다.",
- points: ["빠른 사전 검색", "검출부터 마스킹까지", "단어 목록 동기화"],
- proofItems: ["첫 단어만 빠르게 확인", "전체 검출", "마스킹 문장 반환"],
- visualLabel: "검출 기준",
- visualTitle: "한국어 문장을 기준으로",
- visualLines: ["초성 입력", "한글 문장", "영문 혼합"],
- },
- {
- id: "developer-integration",
- tabLabel: "개발자 연동",
- label: "REST API",
- intro: "서비스 흐름을 크게 바꾸지 않고",
- title: "저장 전에 API 한 번으로 붙입니다",
- body: "댓글, 채팅, 게시글 저장 직전에 필터 API를 호출하고, 검출 결과와 마스킹된 문장, 응답 코드를 확인하면 됩니다.",
- points: ["문장 전달", "동기 또는 콜백 처리", "문서 기반 연동"],
- proofItems: ["필터 API 호출", "문장 전달", "응답 코드 확인", "키로 인증"],
- visualLabel: "연동 흐름",
- visualTitle: "저장 전에 한 번 확인",
- visualLines: ["입력 받기", "필터 호출", "결과 반영"],
- },
-] as const;
-
-export const SCENARIO_STEPS: readonly ScenarioStep[] = [
- {
- id: "scenario-1",
- label: "01",
- title: "사용자가 댓글을 입력",
- body: "서비스는 저장 전에 필터 API를 호출합니다.",
- },
- {
- id: "scenario-2",
- label: "02",
- title: "FILTER 모드로 마스킹",
- body: "비속어 위치를 찾고 노출 가능한 문장으로 바꿉니다.",
- },
- {
- id: "scenario-3",
- label: "03",
- title: "정리된 문장을 저장",
- body: "응답 결과를 댓글, 채팅, 게시글 흐름에 그대로 반영합니다.",
- },
-] as const;
diff --git a/ui/sample-app/src/constants/pagePath.ts b/ui/sample-app/src/constants/pagePath.ts
deleted file mode 100644
index 4c7c00c..0000000
--- a/ui/sample-app/src/constants/pagePath.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-export type PagePath = "/" | "/login" | "/register" | `/docs${string}`;
-
-export const HOME_PATH: PagePath = "/";
-export const DOCS_PREFIX = "/docs";
-export const LOGIN_PATH: PagePath = "/login";
-export const REGISTER_PATH: PagePath = "/register";
-
-export function getCurrentPagePath(pathname: string): PagePath {
- if (pathname.startsWith(DOCS_PREFIX)) {
- return pathname as PagePath;
- }
-
- if (pathname === LOGIN_PATH) {
- return LOGIN_PATH;
- }
-
- if (pathname === REGISTER_PATH) {
- return REGISTER_PATH;
- }
-
- return HOME_PATH;
-}
diff --git a/ui/sample-app/src/demo/ConsoleDemo.tsx b/ui/sample-app/src/demo/ConsoleDemo.tsx
deleted file mode 100644
index d33ec90..0000000
--- a/ui/sample-app/src/demo/ConsoleDemo.tsx
+++ /dev/null
@@ -1,197 +0,0 @@
-import { FormEvent, useMemo, useState } from "react";
-
-import type { ClientRegisterRequest, ConsoleResponse, FilterMode, RequestKind } from "./types";
-
-const API_BASE_URL = "https://api.kr-filter.com";
-
-async function requestJson(endpoint: string, init: RequestInit): Promise {
- const response = await fetch(`${API_BASE_URL}${endpoint}`, {
- ...init,
- headers: {
- "Content-Type": "application/json",
- ...(init.headers ?? {}),
- },
- });
- const contentType = response.headers.get("content-type") ?? "";
- const body = contentType.includes("application/json") ? await response.json() : await response.text();
-
- if (!response.ok) {
- return {
- endpoint,
- error: `HTTP ${response.status}`,
- ...(typeof body === "object" && body !== null ? body : { status: { code: response.status, message: String(body) } }),
- } as TResponse;
- }
-
- return body as TResponse;
-}
-
-export function ConsoleDemo() {
- const [activeRequest, setActiveRequest] = useState("filter");
- const [serviceName, setServiceName] = useState("");
- const [email, setEmail] = useState("");
- const [purpose, setPurpose] = useState("");
- const [apiKey, setApiKey] = useState("");
- const [text, setText] = useState("");
- const [mode, setMode] = useState("FILTER");
- const [isSubmitting, setIsSubmitting] = useState(false);
- const [result, setResult] = useState(null);
-
- const requestPreview = useMemo(() => {
- if (activeRequest === "api-key") {
- return {
- endpoint: "POST /api/v1/clients/register",
- baseUrl: API_BASE_URL,
- body: buildClientRegisterRequest(serviceName, email, purpose),
- };
- }
- return {
- endpoint: "POST /api/v1/filter",
- baseUrl: API_BASE_URL,
- headers: { "x-api-key": apiKey },
- body: { text, mode },
- };
- }, [activeRequest, apiKey, email, mode, purpose, serviceName, text]);
-
- async function issueApiKey(event: FormEvent) {
- event.preventDefault();
- setActiveRequest("api-key");
- setIsSubmitting(true);
- try {
- const response = await requestJson("/api/v1/clients/register", {
- method: "POST",
- body: JSON.stringify(buildClientRegisterRequest(serviceName, email, purpose)),
- });
- setResult(response);
- } catch (error) {
- setResult(toClientError("/api/v1/clients/register", error));
- } finally {
- setIsSubmitting(false);
- }
- }
-
- async function filter(event: FormEvent) {
- event.preventDefault();
- setActiveRequest("filter");
- setIsSubmitting(true);
- try {
- const response = await requestJson("/api/v1/filter", {
- method: "POST",
- headers: { "x-api-key": apiKey },
- body: JSON.stringify({ text, mode }),
- });
- setResult(response);
- } catch (error) {
- setResult(toClientError("/api/v1/filter", error));
- } finally {
- setIsSubmitting(false);
- }
- }
-
- return (
-
-
-
- profanity-filter-api console
- API Key 발급과 필터 요청을 실제 서버로 확인합니다
-
- 입력한 값으로 공개 API 서버에 요청을 보내고, 서버 응답 JSON을 그대로 확인합니다.
-
-
-
-
- API 서버
- {API_BASE_URL}
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-function PanelTitle({ label, title }: { label: string; title: string }) {
- return (
-
- {label}
- {title}
-
- );
-}
-
-function Field({ label, wide, children }: { label: string; wide?: boolean; children: React.ReactNode }) {
- return (
-
- );
-}
-
-function buildClientRegisterRequest(name: string, email: string, issuerInfo: string): ClientRegisterRequest {
- return {
- name,
- email,
- issuerInfo,
- note: "sample-app console",
- };
-}
-
-function toClientError(endpoint: string, error: unknown): ConsoleResponse {
- return {
- endpoint,
- error: error instanceof Error ? error.message : "Unknown client error",
- };
-}
diff --git a/ui/sample-app/src/demo/types.ts b/ui/sample-app/src/demo/types.ts
deleted file mode 100644
index f0014d4..0000000
--- a/ui/sample-app/src/demo/types.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-export type FilterMode = "QUICK" | "NORMAL" | "FILTER";
-export type RequestKind = "api-key" | "filter";
-
-export type ApiStatus = {
- code: number;
- message: string;
- description?: string;
- DetailDescription?: string;
-};
-
-export type FilterResponse = {
- trackingId: string;
- status: ApiStatus;
- detected: Array<{ length: number; filteredWord: string }>;
- filtered: string;
- elapsed: string;
-};
-
-export type ClientRegisterRequest = {
- name: string;
- email: string;
- issuerInfo: string;
- note: string;
-};
-
-export type ClientRegisterResponse = {
- status: ApiStatus;
- data: {
- name: string;
- email: string;
- apiKey: string;
- note: string;
- };
- meta?: Record;
-};
-
-export type ApiFailureResponse = {
- status?: ApiStatus;
- error?: string;
- endpoint?: string;
-};
-
-export type ConsoleResponse = FilterResponse | ClientRegisterResponse | ApiFailureResponse | null;
diff --git a/ui/sample-app/src/docs/constants.ts b/ui/sample-app/src/docs/constants.ts
deleted file mode 100644
index 6afaa9d..0000000
--- a/ui/sample-app/src/docs/constants.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-export const OPENAPI_DOCUMENT_URL = "https://api.kr-filter.com/openapi.json";
-export const OVERVIEW_MARKDOWN_PATH = "https://api.kr-filter.com/overview.md";
-export const HTTP_METHODS = [
- "get",
- "post",
- "put",
- "patch",
- "delete",
- "options",
- "head",
- "trace",
-] as const;
-
-export const FALLBACK_OVERVIEW_MARKDOWN = `# Profanity Filter API
-
-한국어 비속어 필터링 API는 문장 안의 부적절한 표현을 감지하고, 필요한 경우 검출된 단어를 마스킹해 반환합니다.
-
-## 시작하기
-
-1. 클라이언트 등록 API로 API Key를 발급합니다.
-2. 인증이 필요한 요청은 \`x-api-key\` 헤더에 발급받은 API Key를 포함합니다.
-3. \`/api/v1/filter\`에 검사할 \`text\`와 처리 방식인 \`mode\`를 전달합니다.
-
-## 주요 기능
-
-- 문장 안의 한국어와 영어 비속어를 검출합니다.
-- 검출 결과를 목록으로 받거나, 검출된 단어를 \`*\`로 마스킹한 문장을 받을 수 있습니다.
-- 실제 처리 결과는 응답 본문의 \`status.code\`와 \`status.message\`에서 확인합니다.
-`;
diff --git a/ui/sample-app/src/docs/hooks.ts b/ui/sample-app/src/docs/hooks.ts
deleted file mode 100644
index bbf4d4f..0000000
--- a/ui/sample-app/src/docs/hooks.ts
+++ /dev/null
@@ -1,164 +0,0 @@
-import { useEffect, useState } from "react";
-
-import type { MarkdownState, OpenApiDocument, OpenApiDocumentState } from "./types";
-
-type DocumentCacheEntry = {
- promise: Promise;
- status: "pending" | "fulfilled" | "rejected";
- value?: T;
- error?: unknown;
-};
-
-type CachedDocumentResult =
- | { status: "fulfilled"; value: T }
- | { status: "rejected"; error: unknown };
-
-export const documentPromiseCache = new Map>();
-
-function createOpenApiCacheKey(url: string) {
- return `openapi:${url}`;
-}
-
-function createMarkdownCacheKey(url: string, fallback: string) {
- return `markdown:${url}:${fallback}`;
-}
-
-export function getCachedDocumentResult(key: string): CachedDocumentResult | null {
- const cachedDocument = documentPromiseCache.get(key) as DocumentCacheEntry | undefined;
- if (!cachedDocument || cachedDocument.status === "pending") {
- return null;
- }
- if (cachedDocument.status === "fulfilled") {
- return { status: "fulfilled", value: cachedDocument.value as T };
- }
- return { status: "rejected", error: cachedDocument.error };
-}
-
-function loadCachedDocument(key: string, loader: () => Promise): Promise {
- const cachedDocument = documentPromiseCache.get(key);
- if (cachedDocument) {
- return cachedDocument.promise as Promise;
- }
-
- const pendingDocument = {} as DocumentCacheEntry;
- pendingDocument.status = "pending";
- pendingDocument.promise = loader()
- .then((document) => {
- pendingDocument.status = "fulfilled";
- pendingDocument.value = document;
- return document;
- })
- .catch((error: unknown) => {
- pendingDocument.status = "rejected";
- pendingDocument.error = error;
- documentPromiseCache.delete(key);
- throw error;
- });
- documentPromiseCache.set(key, pendingDocument as DocumentCacheEntry);
- return pendingDocument.promise;
-}
-
-async function loadMarkdownDocument(url: string, fallback: string): Promise {
- try {
- const response = await fetch(url);
- if (response.ok) {
- const content = await response.text();
- if (content.trim()) {
- return { content, source: "local", url };
- }
- }
- } catch {
- // API 문서 fetch 실패 시 fallback을 유지해 문서 화면을 계속 표시한다.
- }
- return { content: fallback, source: "fallback", url: "" };
-}
-
-async function loadOpenApiDocument(url: string): Promise {
- const response = await fetch(url);
- if (!response.ok) {
- throw new Error(`OpenAPI 문서를 불러오지 못했습니다. status=${response.status}`);
- }
- return response.json() as Promise;
-}
-
-export function preloadDocsDocuments(openApiUrl: string, markdownUrl: string, markdownFallback: string) {
- return Promise.allSettled([
- loadCachedDocument(createOpenApiCacheKey(openApiUrl), () => loadOpenApiDocument(openApiUrl)),
- loadCachedDocument(createMarkdownCacheKey(markdownUrl, markdownFallback), () =>
- loadMarkdownDocument(markdownUrl, markdownFallback),
- ),
- ]);
-}
-
-export function useMarkdownDocument(url: string, fallback: string): MarkdownState {
- const [state, setState] = useState(() => {
- const cachedDocument = getCachedDocumentResult(createMarkdownCacheKey(url, fallback));
- if (cachedDocument?.status === "fulfilled") {
- return cachedDocument.value;
- }
- return {
- content: fallback,
- source: "fallback",
- url: "",
- };
- });
-
- useEffect(() => {
- let active = true;
-
- loadCachedDocument(createMarkdownCacheKey(url, fallback), () => loadMarkdownDocument(url, fallback)).then((document) => {
- if (active) {
- setState(document);
- }
- });
- return () => {
- active = false;
- };
- }, [fallback, url]);
-
- return state;
-}
-
-export function useOpenApiDocument(url: string): OpenApiDocumentState {
- const [state, setState] = useState(() => {
- const cachedDocument = getCachedDocumentResult(createOpenApiCacheKey(url));
- if (cachedDocument?.status === "fulfilled") {
- return { document: cachedDocument.value, error: "" };
- }
- if (cachedDocument?.status === "rejected") {
- return {
- document: null,
- error: cachedDocument.error instanceof Error ? cachedDocument.error.message : "OpenAPI 문서 로딩 실패",
- };
- }
- return {
- document: null,
- error: "",
- };
- });
-
- useEffect(() => {
- let active = true;
-
- loadCachedDocument(createOpenApiCacheKey(url), () => loadOpenApiDocument(url))
- .then((document) => {
- if (active) {
- setState({ document, error: "" });
- }
- })
- .catch((loadError: unknown) => {
- if (active) {
- setState({
- document: null,
- error: loadError instanceof Error ? loadError.message : "OpenAPI 문서 로딩 실패",
- });
- }
- });
-
- return () => {
- active = false;
- };
- }, [url]);
-
- return state;
-}
diff --git a/ui/sample-app/src/docs/types.ts b/ui/sample-app/src/docs/types.ts
deleted file mode 100644
index a641c48..0000000
--- a/ui/sample-app/src/docs/types.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-export type HttpMethod = "get" | "post" | "put" | "patch" | "delete" | "options" | "head" | "trace";
-
-export type MarkdownState = {
- content: string;
- source: "local" | "fallback";
- url: string;
-};
-
-export type OpenApiDocumentState = {
- document: OpenApiDocument | null;
- error: string;
-};
-
-export type OpenApiDocument = {
- openapi?: string;
- info?: {
- title?: string;
- version?: string;
- summary?: string;
- description?: string;
- };
- tags?: Array<{ name: string; description?: string }>;
- paths?: Record>;
- components?: unknown;
- servers?: unknown;
- security?: unknown;
-};
-
-export type OperationObject = {
- tags?: string[];
- summary?: string;
- description?: string;
- operationId?: string;
- parameters?: unknown;
- requestBody?: unknown;
- responses?: unknown;
-};
-
-export type SectionView = {
- name: string;
- slug: string;
- description?: string;
- operations: OperationView[];
-};
-
-export type OperationView = {
- method: HttpMethod;
- path: string;
- tagName: string;
- summary: string;
- operationId?: string;
-};
-
-export type OverviewLink = {
- title: string;
- anchor: string;
- level: number;
-};
diff --git a/ui/sample-app/src/docs/utils.tsx b/ui/sample-app/src/docs/utils.tsx
deleted file mode 100644
index 05bfe66..0000000
--- a/ui/sample-app/src/docs/utils.tsx
+++ /dev/null
@@ -1,321 +0,0 @@
-import { Fragment, type ReactNode } from "react";
-
-import { HTTP_METHODS } from "./constants";
-import {
- type HttpMethod,
- type OperationObject,
- type OpenApiDocument,
- type OverviewLink,
- type SectionView,
-} from "./types";
-
-type RawOperation = {
- method: string;
- path: string;
- tagName: string;
- summary: string;
- operationId?: string;
-};
-
-export function buildSections(document: OpenApiDocument): SectionView[] {
- const operationsByTag = new Map();
-
- Object.entries(document.paths ?? {}).forEach(([path, methods]) => {
- Object.entries(methods).forEach(([method, operation]) => {
- if (!isHttpMethod(method) || !isOperationObject(operation)) {
- return;
- }
- const tagName = operation.tags?.[0] ?? "API";
- const operations = operationsByTag.get(tagName) ?? [];
- operations.push({
- method,
- path,
- tagName,
- summary: operation.summary ?? `${method.toUpperCase()} ${path}`,
- operationId: operation.operationId,
- });
- operationsByTag.set(tagName, operations);
- });
- });
-
- const declaredTags: NonNullable =
- document.tags ?? Array.from(operationsByTag.keys()).map((name) => ({ name }));
-
- return declaredTags
- .filter((tag) => operationsByTag.has(tag.name))
- .map((tag) => ({
- name: tag.name,
- slug: slugify(tag.name),
- description: tag.description,
- operations: (operationsByTag.get(tag.name) ?? []).map((operation) => ({
- method: operation.method as HttpMethod,
- path: operation.path,
- tagName: operation.tagName,
- summary: operation.summary,
- operationId: operation.operationId,
- })),
- }));
-}
-
-export function buildHashNavigation(content: string): OverviewLink[] {
- return parseMarkdownRootHeadings(content);
-}
-
-export function parseMarkdownRootHeadings(content: string): OverviewLink[] {
- const seen = new Map();
-
- return content
- .replace(/\r\n/g, "\n")
- .split("\n")
- .map((line) => /^(#)\s+(.+)$/.exec(line))
- .filter((match): match is RegExpExecArray => Boolean(match))
- .map((match) => {
- const title = stripInlineMarkdown(match[2]);
- const baseAnchor = createMarkdownHeadingId(title);
- const count = seen.get(baseAnchor) ?? 0;
- seen.set(baseAnchor, count + 1);
- return {
- title,
- anchor: count === 0 ? baseAnchor : `${baseAnchor}-${count}`,
- level: match[1].length,
- };
- });
-}
-
-export function createMarkdownHeadingId(value: string) {
- return `heading-${slugify(stripInlineMarkdown(value))}`;
-}
-
-export function createTagAnchor(section: Pick) {
- return `docs/tag/${slugify(section.name)}`;
-}
-
-export function createOperationAnchor(operation: {
- method?: string;
- path?: string;
- tagName?: string;
- operationId?: string;
- summary?: string;
-}) {
- return `docs/tag/${slugify(operation.tagName ?? "")}/${createScalarOperationSlug(operation)}`;
-}
-
-export function createScalarOperationSlug(operation: {
- method?: string;
- path?: string;
- operationId?: string;
- summary?: string;
-}) {
- return [operation.method?.toLowerCase(), operation.path ?? operation.operationId ?? operation.summary ?? ""]
- .filter(Boolean)
- .join("-")
- .replace(/[{}]/g, "")
- .replace(/[^a-zA-Z0-9가-힣/_-]+/g, "-")
- .replace(/\//g, "-")
- .replace(/-+/g, "-")
- .replace(/^-+|-+$/g, "");
-}
-
-export function getSectionForHash(hash: string, sections: SectionView[]) {
- const [, tagSlug] = /^docs\/tag\/([^/]+)(?:\/.*)?$/.exec(hash) ?? [];
- if (!tagSlug) {
- return null;
- }
- return sections.find((section) => section.slug === tagSlug) ?? null;
-}
-
-export function parseMarkdown(content: string): ReactNode[] {
- const lines = content.replace(/\r\n/g, "\n").split("\n");
- const nodes: ReactNode[] = [];
- let index = 0;
- const headingIds = new Map();
-
- while (index < lines.length) {
- const line = lines[index];
-
- if (!line.trim()) {
- index += 1;
- continue;
- }
-
- if (isMarkdownHorizontalRule(line)) {
- nodes.push(
);
- index += 1;
- continue;
- }
-
- if (line.startsWith("```")) {
- const language = line.replace(/^```/, "").trim();
- const codeLines: string[] = [];
- index += 1;
- while (index < lines.length && !lines[index].startsWith("```")) {
- codeLines.push(lines[index]);
- index += 1;
- }
- index += 1;
- nodes.push(
-
- {codeLines.join("\n")}
-
,
- );
- continue;
- }
-
- if (line.startsWith("|") && lines[index + 1]?.startsWith("|")) {
- const tableLines: string[] = [];
- while (index < lines.length && lines[index].startsWith("|")) {
- tableLines.push(lines[index]);
- index += 1;
- }
- nodes.push(renderTable(tableLines, `table-${index}`));
- continue;
- }
-
- const heading = /^(#{1,4})\s+(.+)$/.exec(line);
- if (heading) {
- const level = heading[1].length;
- const children = renderInline(heading[2]);
- const baseId = createMarkdownHeadingId(heading[2]);
- const idCount = headingIds.get(baseId) ?? 0;
- headingIds.set(baseId, idCount + 1);
- const id = idCount === 0 ? baseId : `${baseId}-${idCount}`;
- if (level === 1) nodes.push({children}
);
- if (level === 2) nodes.push({children}
);
- if (level === 3) nodes.push({children}
);
- if (level === 4) nodes.push({children}
);
- index += 1;
- continue;
- }
-
- if (/^[-*]\s+/.test(line)) {
- const items: string[] = [];
- while (index < lines.length && /^[-*]\s+/.test(lines[index])) {
- items.push(lines[index].replace(/^[-*]\s+/, ""));
- index += 1;
- }
- nodes.push(
-
- {items.map((item, itemIndex) => (
- - {renderInline(item)}
- ))}
-
,
- );
- continue;
- }
-
- if (/^\d+\.\s+/.test(line)) {
- const items: string[] = [];
- while (index < lines.length && /^\d+\.\s+/.test(lines[index])) {
- items.push(lines[index].replace(/^\d+\.\s+/, ""));
- index += 1;
- }
- nodes.push(
-
- {items.map((item, itemIndex) => (
- - {renderInline(item)}
- ))}
-
,
- );
- continue;
- }
-
- const paragraph: string[] = [];
- while (
- index < lines.length &&
- lines[index].trim() &&
- !/^(#{1,4})\s+/.test(lines[index]) &&
- !/^[-*]\s+/.test(lines[index]) &&
- !isMarkdownHorizontalRule(lines[index]) &&
- !/^\d+\.\s+/.test(lines[index]) &&
- !lines[index].startsWith("```") &&
- !lines[index].startsWith("|")
- ) {
- paragraph.push(lines[index].trim());
- index += 1;
- }
- nodes.push({renderInline(paragraph.join(" "))}
);
- }
-
- return nodes;
-}
-
-export function isMarkdownHorizontalRule(line: string) {
- return /^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line);
-}
-
-function renderTable(lines: string[], key: string) {
- const rows = lines
- .filter((line) => !/^\|\s*-.*/.test(line))
- .map((line) =>
- line
- .split("|")
- .slice(1, -1)
- .map((cell) => cell.trim()),
- );
- const [head, ...body] = rows;
-
- return (
-
-
- {head ? (
-
-
- {head.map((cell, cellIndex) => (
- {renderInline(cell)}
- ))}
-
-
- ) : null}
-
- {body.map((row, rowIndex) => (
-
- {row.map((cell, cellIndex) => (
- {renderInline(cell)}
- ))}
-
- ))}
-
-
-
- );
-}
-
-function renderInline(text: string): ReactNode[] {
- const nodes: ReactNode[] = [];
- const parts = text.split(/(`[^`]+`|\*\*[^*]+\*\*)/g).filter(Boolean);
-
- parts.forEach((part, index) => {
- if (part.startsWith("`") && part.endsWith("`")) {
- nodes.push({part.slice(1, -1)});
- return;
- }
- if (part.startsWith("**") && part.endsWith("**")) {
- nodes.push({part.slice(2, -2)});
- return;
- }
- nodes.push({part} );
- });
-
- return nodes;
-}
-
-export function isHttpMethod(method: string): method is HttpMethod {
- return (HTTP_METHODS as readonly string[]).includes(method);
-}
-
-function isOperationObject(value: unknown): value is OperationObject {
- return Boolean(value && typeof value === "object" && ("responses" in (value as object) || "summary" in (value as object)));
-}
-
-function slugify(value: string) {
- return value
- .trim()
- .toLowerCase()
- .replace(/&/g, "and")
- .replace(/[^a-z0-9가-힣]+/g, "-")
- .replace(/^-+|-+$/g, "");
-}
-
-function stripInlineMarkdown(value: string) {
- return value.replace(/[`*_]/g, "").trim();
-}
diff --git a/ui/sample-app/src/features/home/HomePage.tsx b/ui/sample-app/src/features/home/HomePage.tsx
deleted file mode 100644
index dbe242b..0000000
--- a/ui/sample-app/src/features/home/HomePage.tsx
+++ /dev/null
@@ -1,183 +0,0 @@
-import { useState } from "react";
-import type { PagePath } from "../../constants/pagePath";
-import { IDENTITY_OPTIONS, SCENARIO_STEPS } from "../../constants/landingContent";
-
-type HomePageProps = {
- onNavigate: (path: PagePath) => void;
-};
-
-export function HomePage({ onNavigate }: HomePageProps) {
- return (
- <>
-
-
-
-
- >
- );
-}
-
-function Hero({ onNavigate }: HomePageProps) {
- return (
-
-
- profanity-filter application
- 말조심하세욧
- 한국어 비속어 필터 API.
-
-
- );
-}
-
-function StoryBlocks() {
- const [activeIdentityId, setActiveIdentityId] = useState(IDENTITY_OPTIONS[0].id);
- const activeIdentity =
- IDENTITY_OPTIONS.find((option) => option.id === activeIdentityId) ?? IDENTITY_OPTIONS[0];
-
- return (
-
-
- 02
- 한국어 문장을 API로 필터링합니다
-
- {IDENTITY_OPTIONS.map((option) => (
-
- ))}
-
-
-
-
- {activeIdentity.intro}
-
-
- {activeIdentity.label}
- {activeIdentity.title}
- {activeIdentity.body}
-
- {activeIdentity.points.map((point) => (
- {point}
- ))}
-
-
-
-
- {activeIdentity.proofItems.map((item) => (
- {item}
- ))}
-
-
-
-
-
- );
-}
-
-function StartGuide() {
- return (
-
-
- 03
- 사용 시나리오
-
- 댓글, 채팅, 게시글 입력 전에 API를 호출해 비속어를 검출하거나 마스킹합니다.
-
-
-
-
-
-
- comment
- 이 댓글은 욕설을 포함합니다.
-
-
- POST /api/v1/filter
- FILTER
-
-
- response
- 이 댓글은 **을 포함합니다.
-
-
-
-
-
- {SCENARIO_STEPS.map((step) => (
-
- {step.label}
- {step.title}
- {step.body}
-
- ))}
-
-
-
- );
-}
-
-function FooterCta({ onNavigate }: HomePageProps) {
- return (
-
-
-
-
-
-
- );
-}
diff --git a/ui/sample-app/src/features/login/LoginPage.tsx b/ui/sample-app/src/features/login/LoginPage.tsx
deleted file mode 100644
index 8108a1f..0000000
--- a/ui/sample-app/src/features/login/LoginPage.tsx
+++ /dev/null
@@ -1,472 +0,0 @@
-import { useCallback, useLayoutEffect, useRef, useState } from "react";
-
-const DEFAULT_API_BASE_URL = "http://localhost:8080";
-const API_BASE_URL = resolveApiBaseUrl(import.meta.env.VITE_API_BASE_URL);
-
-type LoginProvider = "github" | "google";
-
-type LoginUser = {
- id: string;
- displayName: string;
- email: string;
- avatarUrl: string | null;
-};
-
-type AuthSession = {
- accessToken: string;
- tokenType: "Bearer";
- expiresIn: number;
- user: LoginUser;
-};
-
-type AuthView =
- | { state: "checking"; message: string }
- | { state: "anonymous"; message: string }
- | { state: "error"; message: string }
- | { state: "authenticated"; user: LoginUser; expiresIn: number };
-
-type LoginCallback =
- | { state: "none" }
- | { state: "invalid" }
- | { state: "ready"; code: string };
-
-type CsrfToken = {
- token: string;
- headerName: string;
-};
-
-class AuthRequestError extends Error {
- constructor(readonly status: number) {
- super("Authentication request failed");
- this.name = "AuthRequestError";
- }
-}
-
-export function LoginPage() {
- const [accessToken, setAccessToken] = useState(null);
- const [authView, setAuthView] = useState({
- state: "checking",
- message: "로그인 상태를 확인하고 있습니다.",
- });
- const callbackRef = useRef(null);
- const initializationStartedRef = useRef(false);
- const refreshFlightRef = useRef | null>(null);
-
- if (callbackRef.current === null) {
- callbackRef.current = readLoginCallback();
- }
-
- const verifySession = useCallback(async (session: AuthSession) => {
- setAccessToken(session.accessToken);
- setAuthView({ state: "checking", message: "로그인 정보를 확인하고 있습니다." });
-
- try {
- const user = await fetchCurrentUser(session.accessToken, session.tokenType);
- setAuthView({ state: "authenticated", user, expiresIn: session.expiresIn });
- } catch {
- setAccessToken(null);
- setAuthView({
- state: "error",
- message: "로그인 정보를 확인하지 못했습니다. 다시 로그인해 주세요.",
- });
- }
- }, []);
-
- const refreshSession = useCallback(() => {
- if (refreshFlightRef.current) {
- return refreshFlightRef.current;
- }
-
- const refreshFlight = requestRefreshSession().finally(() => {
- if (refreshFlightRef.current === refreshFlight) {
- refreshFlightRef.current = null;
- }
- });
-
- refreshFlightRef.current = refreshFlight;
- return refreshFlight;
- }, []);
-
- const restoreSession = useCallback(async () => {
- setAuthView({ state: "checking", message: "로그인 상태를 복구하고 있습니다." });
-
- try {
- const session = await refreshSession();
- await verifySession(session);
- } catch (error) {
- setAccessToken(null);
- setAuthView(toRefreshFailureView(error));
- }
- }, [refreshSession, verifySession]);
-
- useLayoutEffect(() => {
- if (initializationStartedRef.current) {
- return;
- }
-
- initializationStartedRef.current = true;
- const callback = callbackRef.current ?? { state: "none" };
- callbackRef.current = { state: "none" };
- removeLoginFragment();
-
- if (callback.state === "invalid") {
- setAuthView({
- state: "error",
- message: "로그인 응답을 확인할 수 없습니다. 소셜 로그인을 다시 시작해 주세요.",
- });
- return;
- }
-
- if (callback.state === "ready") {
- setAuthView({ state: "checking", message: "소셜 로그인을 완료하고 있습니다." });
- void exchangeLoginCode(callback.code)
- .then(verifySession)
- .catch(() => {
- setAccessToken(null);
- setAuthView({
- state: "error",
- message: "로그인을 완료하지 못했습니다. 소셜 로그인을 다시 시작해 주세요.",
- });
- });
- return;
- }
-
- void restoreSession();
- }, [restoreSession, verifySession]);
-
- function startOAuthLogin(provider: LoginProvider) {
- window.location.assign(`${API_BASE_URL}/oauth2/authorization/${provider}`);
- }
-
- async function checkCurrentSession() {
- if (!accessToken) {
- await restoreSession();
- return;
- }
-
- const expiresIn = authView.state === "authenticated" ? authView.expiresIn : 0;
- setAuthView({ state: "checking", message: "로그인 상태를 확인하고 있습니다." });
- try {
- const user = await fetchCurrentUser(accessToken, "Bearer");
- setAuthView({ state: "authenticated", user, expiresIn });
- } catch {
- setAccessToken(null);
- setAuthView({
- state: "anonymous",
- message: "로그인 세션이 만료되었습니다. 다시 로그인해 주세요.",
- });
- }
- }
-
- const isChecking = authView.state === "checking";
-
- return (
-
-
-
-
- OAuth 로그인
- 소셜 계정으로 로그인
-
-
-
-
-
-
-
-
- void checkCurrentSession()} />
-
-
- );
-}
-
-function LoginStatusPanel({ authView, onCheckSession }: { authView: AuthView; onCheckSession: () => void }) {
- if (authView.state === "authenticated") {
- const avatarUrl = toSafeAvatarUrl(authView.user.avatarUrl);
- const expiresInMinutes = Math.max(1, Math.ceil(authView.expiresIn / 60));
-
- return (
-
- );
- }
-
- return (
-
- );
-}
-
-function readLoginCallback(): LoginCallback {
- const hash = window.location.hash.replace(/^#/, "");
- if (!hash) {
- return { state: "none" };
- }
-
- const params = new URLSearchParams(hash);
- const codes = params.getAll("code");
- const hasOnlyCode = Array.from(params.keys()).every((key) => key === "code");
-
- if (!hasOnlyCode || codes.length !== 1 || codes[0].length === 0 || codes[0].length > 4096) {
- return { state: "invalid" };
- }
-
- return { state: "ready", code: codes[0] };
-}
-
-function removeLoginFragment() {
- if (!window.location.hash) {
- return;
- }
-
- const cleanUrl = `${window.location.pathname}${window.location.search}`;
- window.history.replaceState(window.history.state, "", cleanUrl);
-}
-
-async function exchangeLoginCode(code: string): Promise {
- const data = await requestApiData("/api/v1/auth/exchange", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ code }),
- });
-
- return readAuthSession(data);
-}
-
-async function requestRefreshSession(): Promise {
- const csrfData = await requestApiData("/api/v1/auth/csrf", { method: "GET" });
- const csrfToken = readCsrfToken(csrfData);
- const data = await requestApiData("/api/v1/auth/refresh", {
- method: "POST",
- headers: { [csrfToken.headerName]: csrfToken.token },
- });
-
- return readAuthSession(data);
-}
-
-async function fetchCurrentUser(accessToken: string, tokenType: "Bearer"): Promise {
- const data = await requestApiData("/api/v1/auth/me", {
- method: "GET",
- headers: { Authorization: `${tokenType} ${accessToken}` },
- });
-
- const record = toRecord(data);
- return readLoginUser("user" in record ? record.user : record);
-}
-
-async function requestApiData(path: string, init: RequestInit): Promise {
- const response = await fetch(`${API_BASE_URL}${path}`, {
- ...init,
- credentials: "include",
- });
-
- if (!response.ok) {
- throw new AuthRequestError(response.status);
- }
-
- const payload: unknown = await response.json();
- const envelope = toRecord(payload);
- if (!("data" in envelope)) {
- throw new AuthRequestError(response.status);
- }
-
- return envelope.data;
-}
-
-function readAuthSession(value: unknown): AuthSession {
- const record = toRecord(value);
- const accessToken = readRequiredString(record, "accessToken");
- const tokenType = readRequiredString(record, "tokenType");
- const expiresIn = record.expiresIn;
-
- if (
- tokenType.toLowerCase() !== "bearer" ||
- typeof expiresIn !== "number" ||
- !Number.isFinite(expiresIn) ||
- expiresIn <= 0
- ) {
- throw new TypeError("Invalid authentication session");
- }
-
- return {
- accessToken,
- tokenType: "Bearer",
- expiresIn,
- user: readLoginUser(record.user),
- };
-}
-
-function readLoginUser(value: unknown): LoginUser {
- const record = toRecord(value);
- const id = record.id;
-
- if ((typeof id !== "string" && typeof id !== "number") || String(id).length === 0) {
- throw new TypeError("Invalid login user");
- }
-
- return {
- id: String(id),
- displayName: readRequiredString(record, "displayName"),
- email: readRequiredString(record, "email"),
- avatarUrl: readNullableString(record, "avatarUrl"),
- };
-}
-
-function readCsrfToken(value: unknown): CsrfToken {
- const record = toRecord(value);
- const headerName = readRequiredString(record, "headerName");
-
- if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(headerName)) {
- throw new TypeError("Invalid CSRF header name");
- }
-
- return {
- token: readRequiredString(record, "token"),
- headerName,
- };
-}
-
-function readRequiredString(record: Record, key: string): string {
- const value = record[key];
- if (typeof value !== "string" || value.length === 0) {
- throw new TypeError(`Invalid ${key}`);
- }
- return value;
-}
-
-function readNullableString(record: Record, key: string): string | null {
- const value = record[key];
- if (value === null || value === undefined) {
- return null;
- }
- if (typeof value !== "string") {
- throw new TypeError(`Invalid ${key}`);
- }
- return value;
-}
-
-function toRecord(value: unknown): Record {
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
- throw new TypeError("Invalid API response");
- }
- return value as Record;
-}
-
-function toRefreshFailureView(error: unknown): AuthView {
- if (error instanceof AuthRequestError && (error.status === 401 || error.status === 403)) {
- return {
- state: "anonymous",
- message: "로그인이 필요합니다. GitHub 또는 Google 계정으로 시작해 주세요.",
- };
- }
-
- return {
- state: "error",
- message: "로그인 상태를 확인하지 못했습니다. 잠시 후 다시 시도해 주세요.",
- };
-}
-
-function toSafeAvatarUrl(value: string | null): string | null {
- if (!value) {
- return null;
- }
-
- try {
- const url = new URL(value);
- return url.protocol === "https:" || url.protocol === "http:" ? url.toString() : null;
- } catch {
- return null;
- }
-}
-
-function resolveApiBaseUrl(value: string | undefined): string {
- const configuredUrl = value?.trim().replace(/\/+$/, "");
- return configuredUrl || DEFAULT_API_BASE_URL;
-}
-
-function GitHubIcon() {
- return (
-
- );
-}
-
-function GoogleIcon() {
- return (
-
- );
-}
diff --git a/ui/sample-app/src/features/register/RegisterPage.tsx b/ui/sample-app/src/features/register/RegisterPage.tsx
deleted file mode 100644
index b1e2d0c..0000000
--- a/ui/sample-app/src/features/register/RegisterPage.tsx
+++ /dev/null
@@ -1,209 +0,0 @@
-import { useState } from "react";
-import type { FormEvent } from "react";
-
-import type { PagePath } from "../../constants/pagePath";
-
-type RegisterPageProps = {
- onNavigate: (path: PagePath) => void;
-};
-
-type RegisterFormState = {
- name: string;
- email: string;
- issuerInfo: string;
- note: string;
-};
-
-type IssuedClient = RegisterFormState & {
- apiKey: string;
-};
-
-const INITIAL_FORM_STATE: RegisterFormState = {
- name: "",
- email: "",
- issuerInfo: "",
- note: "",
-};
-
-export function RegisterPage({ onNavigate }: RegisterPageProps) {
- const [formState, setFormState] = useState(INITIAL_FORM_STATE);
- const [issuedClient, setIssuedClient] = useState(null);
- const [copyState, setCopyState] = useState<"idle" | "copied">("idle");
- const [emailCodeSent, setEmailCodeSent] = useState(false);
- const [verificationCode, setVerificationCode] = useState("");
- const [emailVerified, setEmailVerified] = useState(false);
-
- const canSendEmailCode = Boolean(formState.email.trim());
- const canVerifyEmail = Boolean(emailCodeSent && verificationCode.trim().length === 6);
- const canIssue = Boolean(
- emailVerified && formState.name.trim() && formState.email.trim() && formState.issuerInfo.trim(),
- );
-
- function updateField(field: keyof RegisterFormState, value: string) {
- setFormState((current) => ({ ...current, [field]: value }));
- setCopyState("idle");
- setIssuedClient(null);
-
- if (field === "email") {
- setEmailCodeSent(false);
- setVerificationCode("");
- setEmailVerified(false);
- }
- }
-
- function handleSubmit(event: FormEvent) {
- event.preventDefault();
- if (!canIssue) {
- return;
- }
-
- setIssuedClient({
- ...formState,
- apiKey: createLocalApiKey(),
- });
- setCopyState("idle");
- }
-
- function sendEmailCode() {
- if (!canSendEmailCode) {
- return;
- }
-
- setEmailCodeSent(true);
- setVerificationCode("");
- setEmailVerified(false);
- }
-
- function verifyEmailCode() {
- if (!canVerifyEmail) {
- return;
- }
-
- setEmailVerified(true);
- }
-
- async function copyIssuedKey() {
- if (!issuedClient) {
- return;
- }
-
- await navigator.clipboard.writeText(issuedClient.apiKey);
- setCopyState("copied");
- }
-
- return (
-
-
- 키 발급
-
-
-
-
-
-
-
-
- );
-}
-
-function createLocalApiKey() {
- const bytes = new Uint8Array(32);
- crypto.getRandomValues(bytes);
-
- return btoa(String.fromCharCode(...bytes))
- .replaceAll("+", "-")
- .replaceAll("/", "_")
- .replaceAll("=", "");
-}
diff --git a/ui/sample-app/src/styles.css b/ui/sample-app/src/styles.css
deleted file mode 100644
index 02b3b17..0000000
--- a/ui/sample-app/src/styles.css
+++ /dev/null
@@ -1,1837 +0,0 @@
-:root {
- font-family:
- Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
- color: var(--pine);
- background: var(--ivory);
- font-synthesis: none;
- text-rendering: optimizeLegibility;
- -webkit-font-smoothing: antialiased;
- --ivory: #f6f1df;
- --pine: #173b2f;
- --sage: #a9b99a;
- --landing-nav-height: 64px;
- --landing-letterbox: 16vw;
- --landing-rail-width: 68vw;
- --landing-section-height: calc(100dvh - var(--landing-nav-height));
-}
-
-* {
- box-sizing: border-box;
-}
-
-html {
- scroll-behavior: smooth;
- scroll-padding-top: var(--landing-nav-height);
-}
-
-body {
- margin: 0;
- background: var(--ivory);
-}
-
-a {
- color: inherit;
- text-decoration: none;
-}
-
-button,
-input,
-select,
-textarea {
- font: inherit;
-}
-
-button {
- border: 0;
- cursor: pointer;
-}
-
-.site-nav {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- z-index: 10;
- display: flex;
- align-items: center;
- justify-content: space-between;
- height: var(--landing-nav-height);
- padding: 0 max(var(--landing-letterbox), calc((100vw - var(--landing-rail-width)) / 2));
- color: var(--ivory);
- background: var(--pine);
- border-bottom: 1px solid color-mix(in srgb, var(--ivory) 18%, transparent);
-}
-
-.brand {
- font-weight: 850;
- letter-spacing: 0;
-}
-
-.site-nav nav {
- display: flex;
- gap: 8px;
-}
-
-.site-nav nav a {
- display: inline-flex;
- align-items: center;
- min-height: 36px;
- padding: 0 12px;
- border-radius: 999px;
- color: color-mix(in srgb, var(--ivory) 82%, transparent);
- font-size: 14px;
- font-weight: 750;
-}
-
-.site-nav nav a:hover,
-.site-nav nav a:focus-visible,
-.site-nav nav a[aria-current="page"] {
- background: var(--ivory);
- color: var(--pine);
- outline: 0;
-}
-
-.landing-hero {
- position: relative;
- min-height: 100dvh;
- display: grid;
- justify-items: center;
- align-content: center;
- overflow: hidden;
- padding: 12dvh 24px 9dvh;
- color: var(--ivory);
- background: var(--pine);
- text-align: center;
-}
-
-.landing-hero h1,
-.landing-hero p,
-.split-section h2,
-.section-heading h2,
-.section-heading p,
-.feature-list h3,
-.feature-list p,
-.story-copy h2,
-.story-intro,
-.story-panel h3,
-.story-panel p,
-.story-visual strong,
-.story-visual i,
-.step-card h3,
-.step-card p,
-.footer-cta h2,
-.footer-cta p {
- word-break: keep-all;
- overflow-wrap: break-word;
-}
-
-.landing-hero h1,
-.split-section h2,
-.section-heading h2,
-.story-copy h2,
-.story-panel h3,
-.story-visual strong,
-.step-card h3,
-.footer-cta h2 {
- text-wrap: balance;
-}
-
-.landing-hero p,
-.section-heading p,
-.feature-list p,
-.story-intro,
-.story-panel p,
-.step-card p,
-.footer-cta p {
- text-wrap: pretty;
-}
-
-.hero-grid {
- position: absolute;
- inset: auto 50% 110px auto;
- width: min(760px, 82vw);
- height: 220px;
- transform: translateX(50%);
- opacity: 0.38;
- background-image: radial-gradient(var(--sage) 1.2px, transparent 1.2px);
- background-size: 14px 14px;
- mask-image: radial-gradient(ellipse at center, black 8%, transparent 70%);
-}
-
-.hero-kicker,
-.section-kicker {
- margin: 0 0 14px;
- color: var(--sage);
- font-size: 14px;
- font-weight: 850;
- letter-spacing: 0;
-}
-
-.landing-hero h1 {
- position: relative;
- margin: 0;
- max-width: 820px;
- font-size: clamp(42px, 8vw, 82px);
- line-height: 1.04;
- letter-spacing: 0;
-}
-
-.hero-description {
- position: relative;
- max-width: 620px;
- margin: 24px 0 0;
- color: color-mix(in srgb, var(--ivory) 74%, transparent);
- font-size: 18px;
- line-height: 1.7;
-}
-
-.hero-actions {
- position: relative;
- display: flex;
- justify-content: center;
- gap: 10px;
- margin-top: 34px;
-}
-
-.primary-link,
-.secondary-link,
-.text-link {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- min-height: 48px;
- border-radius: 999px;
- padding: 0 22px;
- font-weight: 850;
-}
-
-.primary-link {
- background: var(--ivory);
- color: var(--pine);
-}
-
-.secondary-link {
- border: 1px solid color-mix(in srgb, var(--ivory) 44%, transparent);
- color: var(--ivory);
-}
-
-.primary-link:hover,
-.primary-link:focus-visible,
-.secondary-link:hover,
-.secondary-link:focus-visible,
-.text-link:hover,
-.text-link:focus-visible {
- background: var(--sage);
- color: var(--pine);
- outline: 0;
-}
-
-.text-link {
- width: max-content;
- margin-top: 22px;
- border: 1px solid var(--pine);
- background: var(--pine);
- color: var(--ivory);
-}
-
-.split-section,
-.story-section,
-.start-section,
-.footer-cta {
- scroll-margin-top: var(--landing-nav-height);
- width: min(
- var(--landing-rail-width),
- calc(100vw - var(--landing-letterbox) - var(--landing-letterbox))
- );
- margin: 0 auto;
-}
-
-.split-section {
- display: grid;
- grid-template-columns: 0.85fr 1.15fr;
- gap: 72px;
- padding: 128px 0 108px;
-}
-
-.split-section h2,
-.section-heading h2,
-.footer-cta h2 {
- margin: 0;
- font-size: clamp(34px, 5vw, 56px);
- line-height: 1.1;
- letter-spacing: 0;
-}
-
-.feature-list {
- display: grid;
- gap: 0;
- border-top: 1px solid var(--pine);
-}
-
-.feature-list article {
- display: grid;
- grid-template-columns: 64px 1fr;
- gap: 18px;
- padding: 24px 0;
- border-bottom: 1px solid var(--pine);
-}
-
-.feature-list span,
-.step-card span {
- color: color-mix(in srgb, var(--pine) 72%, var(--ivory));
- font-size: 13px;
- font-weight: 850;
-}
-
-.feature-list h3,
-.step-card h3 {
- margin: 0 0 8px;
- font-size: 21px;
- letter-spacing: 0;
-}
-
-.feature-list h3,
-.feature-list p {
- grid-column: 2;
-}
-
-.feature-list p,
-.step-card p,
-.story-panel p {
- margin: 0;
- color: color-mix(in srgb, var(--pine) 76%, var(--ivory));
- line-height: 1.7;
-}
-
-.story-section {
- display: grid;
- grid-template-columns: minmax(240px, 0.32fr) minmax(0, 0.68fr);
- align-items: center;
- gap: clamp(40px, 5vw, 84px);
- min-height: var(--landing-section-height);
- padding: clamp(32px, 5dvh, 56px) 0;
- border-top: 1px solid var(--pine);
-}
-
-.story-copy {
- position: sticky;
- top: calc(var(--landing-nav-height) + 32px);
- align-self: start;
-}
-
-.story-copy h2 {
- margin: 0;
- max-width: 9.5em;
- font-size: clamp(30px, 3.05vw, 43px);
- line-height: 1.14;
- letter-spacing: 0;
-}
-
-.identity-tabs {
- display: grid;
- gap: 8px;
- margin-top: clamp(20px, 3dvh, 28px);
-}
-
-.identity-tabs button {
- width: 100%;
- border: 1px solid var(--pine);
- border-radius: 999px;
- background: transparent;
- padding: 10px 12px;
- color: color-mix(in srgb, var(--pine) 76%, var(--ivory));
- font-size: 14px;
- font-weight: 800;
- text-align: left;
- cursor: pointer;
-}
-
-.identity-tabs button[aria-selected="true"],
-.identity-tabs button:hover,
-.identity-tabs button:focus-visible {
- background: var(--pine);
- color: var(--ivory);
- outline: 0;
-}
-
-.story-track {
- display: grid;
- gap: 72px;
-}
-
-.story-block {
- display: grid;
- align-content: center;
- gap: clamp(12px, 2dvh, 18px);
- opacity: 0.62;
- transform: translateY(18px) scale(0.98);
- transition:
- opacity 260ms ease,
- transform 260ms ease;
-}
-
-.story-block[data-active="true"] {
- opacity: 1;
- transform: translateY(0) scale(1);
-}
-
-.story-intro {
- margin: 0;
- color: color-mix(in srgb, var(--pine) 82%, var(--ivory));
- font-size: clamp(15px, 1.05vw, 17px);
- font-weight: 850;
- line-height: 1.6;
-}
-
-.story-panel {
- display: grid;
- grid-template-columns: minmax(0, 1.05fr) minmax(240px, 0.95fr);
- gap: clamp(18px, 2vw, 28px);
- border: 1px solid var(--pine);
- border-radius: 8px;
- background: var(--ivory);
- padding: clamp(18px, 2vw, 24px);
-}
-
-.story-block[data-active="true"] .story-panel {
- background: var(--sage);
-}
-
-.story-panel > div:first-child {
- display: grid;
- align-content: start;
- gap: clamp(10px, 1.4dvh, 14px);
-}
-
-.story-panel span {
- width: max-content;
- border: 1px solid var(--pine);
- border-radius: 999px;
- padding: 7px 10px;
- font-size: 13px;
- font-weight: 850;
-}
-
-.story-panel h3 {
- margin: 0;
- max-width: 12em;
- font-size: clamp(23px, 2.05vw, 31px);
- line-height: 1.2;
- letter-spacing: 0;
-}
-
-.story-panel p {
- max-width: 28em;
- font-size: clamp(14px, 0.95vw, 16px);
- line-height: 1.72;
-}
-
-.identity-points {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- margin-top: 6px;
-}
-
-.identity-points b {
- border: 1px solid color-mix(in srgb, var(--pine) 30%, transparent);
- border-radius: 999px;
- padding: 7px 9px;
- color: var(--pine);
- font-size: 13px;
- font-weight: 850;
-}
-
-.identity-proof-row {
- grid-column: 1 / -1;
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- align-self: end;
- border-top: 1px solid color-mix(in srgb, var(--pine) 24%, transparent);
- padding-top: clamp(10px, 1.5dvh, 14px);
-}
-
-.identity-proof-row code {
- border: 1px solid color-mix(in srgb, var(--pine) 28%, transparent);
- border-radius: 6px;
- background: color-mix(in srgb, var(--ivory) 42%, var(--sage));
- padding: 7px 9px;
- color: var(--pine);
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
- font-size: 13px;
- font-weight: 850;
-}
-
-.story-visual {
- display: grid;
- align-content: space-between;
- min-height: 100%;
- border-radius: 8px;
- background: var(--pine);
- color: var(--ivory);
- padding: clamp(16px, 1.8vw, 20px);
-}
-
-.story-visual span {
- border-color: color-mix(in srgb, var(--ivory) 42%, transparent);
- color: var(--ivory);
-}
-
-.story-visual strong {
- align-self: center;
- max-width: 8.5em;
- font-size: clamp(26px, 2.55vw, 36px);
- line-height: 1.15;
- letter-spacing: 0;
-}
-
-.story-visual div {
- display: grid;
- border-top: 1px solid color-mix(in srgb, var(--ivory) 34%, transparent);
-}
-
-.story-visual i {
- border-bottom: 1px solid color-mix(in srgb, var(--ivory) 34%, transparent);
- padding: clamp(8px, 1.4dvh, 12px) 0;
- color: color-mix(in srgb, var(--ivory) 78%, transparent);
- font-size: clamp(14px, 0.95vw, 16px);
- font-style: normal;
- font-weight: 750;
-}
-
-.start-section {
- min-height: var(--landing-section-height);
- display: grid;
- align-content: center;
- padding: clamp(32px, 5dvh, 56px) 0;
-}
-
-.section-heading {
- max-width: 720px;
- margin-bottom: clamp(20px, 3dvh, 32px);
-}
-
-.section-heading p:not(.section-kicker) {
- margin: 16px 0 0;
- color: color-mix(in srgb, var(--pine) 76%, var(--ivory));
- font-size: clamp(16px, 1.3vw, 18px);
- line-height: 1.7;
-}
-
-.scenario-layout {
- display: grid;
- grid-template-columns: minmax(0, 1fr) minmax(0, 0.72fr);
- gap: clamp(14px, 1.6vw, 18px);
- align-items: stretch;
-}
-
-.scenario-video {
- display: grid;
- gap: clamp(12px, 1.5dvh, 18px);
- border: 1px solid var(--pine);
- border-radius: 8px;
- background: var(--pine);
- color: var(--ivory);
- padding: clamp(16px, 1.8vw, 22px);
-}
-
-.scenario-screen {
- position: relative;
- min-height: 38dvh;
- display: grid;
- align-content: center;
- gap: clamp(10px, 1.4dvh, 16px);
- overflow: hidden;
- border: 1px solid color-mix(in srgb, var(--ivory) 26%, transparent);
- border-radius: 8px;
- background-image: linear-gradient(
- 90deg,
- color-mix(in srgb, var(--ivory) 9%, transparent) 1px,
- transparent 1px
- );
- background-size: 26px 26px;
- padding: clamp(16px, 2vw, 24px);
-}
-
-.comment-card,
-.api-card,
-.result-card {
- position: relative;
- width: min(440px, 100%);
- border: 1px solid color-mix(in srgb, var(--ivory) 38%, transparent);
- border-radius: 8px;
- background: var(--ivory);
- color: var(--pine);
- padding: clamp(14px, 1.7vw, 18px);
- box-shadow: 0 1.6dvh 0 color-mix(in srgb, var(--sage) 24%, transparent);
- opacity: 0;
- transform: translateY(-18px);
-}
-
-.comment-card {
- animation: scenario-card-one 10s infinite both;
-}
-
-.api-card {
- justify-self: center;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 18px;
- background: var(--sage);
- animation: scenario-card-two 10s infinite both;
-}
-
-.result-card {
- justify-self: end;
- animation: scenario-card-three 10s infinite both;
-}
-
-.comment-card span,
-.api-card span,
-.result-card span {
- display: block;
- margin-bottom: 8px;
- font-size: 12px;
- font-weight: 850;
-}
-
-.comment-card p,
-.result-card p {
- margin: 0;
- font-size: clamp(18px, 3vw, 28px);
- font-weight: 850;
- line-height: 1.35;
-}
-
-.api-card strong {
- font-size: clamp(24px, 4vw, 42px);
- letter-spacing: 0;
-}
-
-.scenario-timeline {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 8px;
-}
-
-.scenario-timeline span {
- height: 6px;
- overflow: hidden;
- border-radius: 999px;
- background: color-mix(in srgb, var(--ivory) 18%, transparent);
-}
-
-.scenario-timeline span::before {
- content: "";
- display: block;
- width: 100%;
- height: 100%;
- transform: translateX(-100%);
- background: var(--sage);
- animation: timeline-one 10s infinite;
-}
-
-.scenario-timeline span:nth-child(2)::before {
- animation-name: timeline-two;
-}
-
-.scenario-timeline span:nth-child(3)::before {
- animation-name: timeline-three;
-}
-
-.scenario-steps {
- display: grid;
- gap: clamp(10px, 1.4dvh, 16px);
-}
-
-.step-grid {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 16px;
-}
-
-.step-card {
- display: grid;
- align-content: start;
- gap: clamp(10px, 1.5dvh, 16px);
- border: 1px solid var(--pine);
- border-radius: 8px;
- background: var(--ivory);
- padding: clamp(16px, 1.8vw, 24px);
-}
-
-.step-card:hover {
- background: var(--sage);
-}
-
-@keyframes timeline-one {
- 0%,
- 4% {
- transform: translateX(-100%);
- }
-
- 10%,
- 88% {
- transform: translateX(0);
- }
-
- 96%,
- 100% {
- transform: translateX(100%);
- }
-}
-
-@keyframes timeline-two {
- 0%,
- 14% {
- transform: translateX(-100%);
- }
-
- 20%,
- 88% {
- transform: translateX(0);
- }
-
- 96%,
- 100% {
- transform: translateX(100%);
- }
-}
-
-@keyframes timeline-three {
- 0%,
- 28% {
- transform: translateX(-100%);
- }
-
- 34%,
- 88% {
- transform: translateX(0);
- }
-
- 96%,
- 100% {
- transform: translateX(100%);
- }
-}
-
-@keyframes scenario-card-one {
- 0%,
- 4% {
- opacity: 0;
- transform: translateY(-18px);
- }
-
- 10%,
- 88% {
- opacity: 1;
- transform: translateY(0);
- }
-
- 96%,
- 100% {
- opacity: 0;
- transform: translateY(16px);
- }
-}
-
-@keyframes scenario-card-two {
- 0%,
- 14% {
- opacity: 0;
- transform: translateY(-18px);
- }
-
- 20%,
- 88% {
- opacity: 1;
- transform: translateY(0);
- }
-
- 96%,
- 100% {
- opacity: 0;
- transform: translateY(16px);
- }
-}
-
-@keyframes scenario-card-three {
- 0%,
- 28% {
- opacity: 0;
- transform: translateY(-18px);
- }
-
- 34%,
- 88% {
- opacity: 1;
- transform: translateY(0);
- }
-
- 96%,
- 100% {
- opacity: 0;
- transform: translateY(16px);
- }
-}
-
-.api-docs-page {
- --docs-header-height: 64px;
- --docs-main-rail-width: 1160px;
- --docs-article-width: 760px;
- min-height: calc(100vh - var(--docs-header-height));
- display: grid;
- grid-template-columns: 280px minmax(0, 1fr);
- padding-top: var(--docs-header-height);
- background: var(--ivory);
- color: var(--pine);
-}
-
-.api-docs-sidebar {
- position: sticky;
- top: var(--docs-header-height);
- height: calc(100vh - var(--docs-header-height));
- align-self: start;
- overflow-y: auto;
- overscroll-behavior: contain;
- border-right: 1px solid color-mix(in srgb, var(--pine) 18%, transparent);
- background: var(--ivory);
- padding: 24px 18px;
-}
-
-.api-docs-sidebar-nav {
- display: grid;
- gap: 18px;
-}
-
-.api-docs-root-group {
- display: grid;
- gap: 8px;
- border-bottom: 1px solid color-mix(in srgb, var(--pine) 14%, transparent);
- padding-bottom: 18px;
-}
-
-.api-docs-root-group:last-child {
- border-bottom: 0;
- padding-bottom: 0;
-}
-
-.api-docs-root-label {
- margin: 0;
- padding: 0 10px;
- color: color-mix(in srgb, var(--pine) 56%, var(--ivory));
- font-size: 12px;
- font-weight: 900;
- letter-spacing: 0;
- line-height: 18px;
-}
-
-.api-docs-sidebar a,
-.api-docs-sidebar button {
- display: flex;
- align-items: center;
- width: 100%;
- min-height: 34px;
- border: 0;
- border-radius: 8px;
- padding: 6px 10px;
- background: transparent;
- font-size: 14px;
- line-height: 20px;
- text-align: left;
- cursor: pointer;
-}
-
-.api-docs-sidebar a,
-.api-docs-sidebar button {
- justify-content: space-between;
- gap: 8px;
- color: color-mix(in srgb, var(--pine) 82%, var(--ivory));
- font: inherit;
- font-weight: 700;
- transition:
- background-color 160ms ease,
- color 160ms ease,
- transform 180ms ease;
-}
-
-.api-docs-sidebar a:hover,
-.api-docs-sidebar a:focus-visible,
-.api-docs-sidebar a.active,
-.api-docs-sidebar button:hover,
-.api-docs-sidebar button:focus-visible,
-.api-docs-sidebar button.active {
- background: color-mix(in srgb, var(--sage) 45%, transparent);
- color: var(--pine);
- outline: 0;
-}
-
-.api-docs-sidebar a.active,
-.api-docs-sidebar button.active {
- font-weight: 850;
- transform: translateX(2px);
-}
-
-.api-docs-section-toggle::after {
- content: "+";
- color: color-mix(in srgb, var(--pine) 48%, var(--ivory));
- font-size: 13px;
- font-weight: 900;
-}
-
-.api-docs-section-toggle[aria-expanded="true"]::after {
- content: "-";
-}
-
-.api-docs-nav-item {
- display: grid;
- gap: 1px;
-}
-
-.api-docs-sidebar-children {
- display: grid;
- gap: 1px;
- margin: 2px 0 8px;
- padding-left: 12px;
-}
-
-.api-docs-sidebar-children a {
- min-height: 30px;
- color: color-mix(in srgb, var(--pine) 64%, var(--ivory));
- font-size: 13px;
- font-weight: 700;
-}
-
-.api-docs-sidebar-children a.nested {
- padding-left: 22px;
- color: color-mix(in srgb, var(--pine) 54%, var(--ivory));
-}
-
-.api-docs-sidebar-children span {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.docs-source-label {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 8px;
- margin: 0 0 18px;
- color: color-mix(in srgb, var(--pine) 70%, var(--ivory));
- font-size: 12px;
- font-weight: 900;
- text-transform: uppercase;
-}
-
-.docs-source-label span {
- border: 1px solid color-mix(in srgb, var(--pine) 18%, transparent);
- border-radius: 999px;
- padding: 4px 8px;
- color: color-mix(in srgb, var(--pine) 68%, var(--ivory));
- text-transform: none;
-}
-
-.api-docs-overview {
- width: min(var(--docs-article-width), 100%);
- min-width: 0;
- max-width: 100%;
- display: grid;
- gap: 18px;
- scroll-margin-top: calc(var(--docs-header-height) + 24px);
-}
-
-.docs-markdown-article {
- width: min(760px, 100%);
- min-width: 0;
- max-width: 100%;
- color: var(--pine);
- word-break: keep-all;
- overflow-wrap: break-word;
-}
-
-.docs-markdown-article h1,
-.docs-markdown-article h2,
-.docs-markdown-article h3,
-.docs-markdown-article h4 {
- margin: 0;
- letter-spacing: 0;
- text-wrap: balance;
-}
-
-.docs-markdown-article [id],
-.api-docs-reference [id] {
- scroll-margin-top: calc(var(--docs-header-height) + 24px);
-}
-
-.docs-markdown-article h1 {
- font-size: clamp(36px, 5vw, 58px);
- line-height: 1.04;
-}
-
-.docs-markdown-article h2 {
- margin-top: 42px;
- font-size: clamp(24px, 2.6vw, 34px);
- line-height: 1.18;
-}
-
-.docs-markdown-article h3 {
- margin-top: 30px;
- font-size: 20px;
- line-height: 1.3;
-}
-
-.docs-markdown-article h4 {
- margin-top: 22px;
- font-size: 16px;
-}
-
-.docs-markdown-article p,
-.docs-markdown-article li,
-.docs-pending-panel p {
- color: color-mix(in srgb, var(--pine) 78%, var(--ivory));
- font-size: 16px;
- line-height: 1.78;
- text-wrap: pretty;
-}
-
-.docs-markdown-article p {
- margin: 16px 0 0;
-}
-
-.docs-markdown-article hr {
- width: 100%;
- height: 1px;
- margin: 42px 0;
- border: 0;
- background: color-mix(in srgb, var(--pine) 18%, transparent);
-}
-
-.docs-markdown-article ul,
-.docs-markdown-article ol {
- display: grid;
- gap: 8px;
- margin: 16px 0 0;
- padding-left: 24px;
-}
-
-.docs-markdown-article code {
- border: 1px solid color-mix(in srgb, var(--pine) 18%, transparent);
- border-radius: 6px;
- background: color-mix(in srgb, var(--sage) 18%, var(--ivory));
- padding: 2px 6px;
- color: var(--pine);
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
- font-size: 0.92em;
-}
-
-.docs-markdown-article pre {
- max-width: 100%;
- overflow-x: auto;
- margin: 18px 0 0;
- border-radius: 8px;
- background: var(--pine);
- padding: 18px;
- color: var(--ivory);
-}
-
-.docs-markdown-article pre code {
- border: 0;
- background: transparent;
- padding: 0;
- color: inherit;
- font-size: 13px;
- line-height: 1.7;
-}
-
-.markdown-table-wrap {
- max-width: 100%;
- overflow-x: auto;
- margin-top: 18px;
-}
-
-.docs-markdown-article table {
- width: 100%;
- min-width: min(640px, 100%);
- border-collapse: collapse;
- font-size: 14px;
-}
-
-.docs-markdown-article th,
-.docs-markdown-article td {
- border-bottom: 1px solid color-mix(in srgb, var(--pine) 16%, transparent);
- padding: 10px;
- text-align: left;
- vertical-align: top;
-}
-
-.docs-markdown-article th {
- color: var(--pine);
- font-weight: 900;
-}
-
-.docs-pending-panel {
- max-width: 760px;
- border: 1px solid color-mix(in srgb, var(--pine) 20%, transparent);
- border-radius: 8px;
- background: color-mix(in srgb, var(--sage) 18%, var(--ivory));
- padding: clamp(22px, 3vw, 34px);
-}
-
-.docs-pending-panel h2 {
- margin: 0;
- font-size: clamp(24px, 2.6vw, 34px);
- line-height: 1.18;
-}
-
-.docs-pending-panel button {
- min-height: 38px;
- margin-top: 18px;
- border-radius: 8px;
- background: var(--pine);
- padding: 0 14px;
- color: var(--ivory);
- font-weight: 850;
-}
-
-.api-docs-content {
- position: relative;
- min-width: 0;
- max-width: 100%;
- padding: 64px 48px 112px;
-}
-
-.api-docs-copy-actions {
- position: sticky;
- top: calc(var(--docs-header-height) + 14px);
- z-index: 3;
- display: flex;
- justify-content: flex-end;
- gap: 8px;
- width: min(var(--docs-main-rail-width), 100%);
- margin-bottom: 32px;
- pointer-events: none;
-}
-
-.api-docs-copy-actions span,
-.api-docs-copy-actions button {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- min-height: 32px;
- border: 1px solid color-mix(in srgb, var(--pine) 28%, transparent);
- border-radius: 8px;
- padding: 0 10px;
- background: var(--ivory);
- color: var(--pine);
- font-size: 12px;
- font-weight: 850;
-}
-
-.api-docs-copy-actions button {
- background: var(--pine);
- color: var(--ivory);
- pointer-events: auto;
-}
-
-.api-docs-copy-actions button:hover,
-.api-docs-copy-actions button:focus-visible {
- background: var(--sage);
- color: var(--pine);
- outline: 0;
-}
-
-.api-docs-main {
- width: min(var(--docs-main-rail-width), 100%);
- min-width: 0;
- max-width: 100%;
-}
-
-.api-docs-reference code {
- border: 1px solid color-mix(in srgb, var(--pine) 20%, transparent);
- border-radius: 6px;
- padding: 2px 6px;
- background: color-mix(in srgb, var(--ivory) 82%, var(--sage));
- color: var(--pine);
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
- font-size: 0.92em;
-}
-
-.api-docs-reference {
- --scalar-custom-header-height: 0px;
- --scalar-sidebar-width: 0px;
- --scalar-content-max-width: var(--docs-main-rail-width);
- --scalar-font: inherit;
- --scalar-font-code: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
- --scalar-heading-1: 36px;
- --scalar-heading-2: 22px;
- --scalar-heading-3: 17px;
- --scalar-paragraph: 15px;
- --scalar-small: 14px;
- --scalar-mini: 13px;
- --scalar-micro: 12px;
- --scalar-radius: 8px;
- --scalar-radius-lg: 10px;
- --scalar-radius-xl: 16px;
- --scalar-color-accent: var(--pine);
- --scalar-link-color: var(--pine);
- --scalar-link-color-hover: color-mix(in srgb, var(--pine) 80%, black);
- --scalar-background-1: var(--ivory);
- --scalar-background-2: var(--ivory);
- --scalar-background-3: color-mix(in srgb, var(--sage) 24%, var(--ivory));
- --scalar-background-4: color-mix(in srgb, var(--sage) 38%, var(--ivory));
- --scalar-background-accent: var(--pine);
- --scalar-color-1: var(--pine);
- --scalar-color-2: color-mix(in srgb, var(--pine) 78%, var(--ivory));
- --scalar-color-3: color-mix(in srgb, var(--pine) 62%, var(--ivory));
- --scalar-border-color: color-mix(in srgb, var(--pine) 20%, transparent);
- --scalar-button-1: var(--pine);
- --scalar-button-1-hover: var(--sage);
- --scalar-button-1-color: var(--ivory);
- width: 100%;
- min-width: 0;
- margin-top: 72px;
- background: var(--ivory);
-}
-
-.api-docs-reference .scalar-api-reference {
- min-height: auto;
-}
-
-.api-docs-reference .references-layout,
-.api-docs-reference .references-rendered {
- background: var(--ivory);
-}
-
-.api-docs-reference .section-container {
- padding-left: 0;
- padding-right: 0;
-}
-
-.docs-loading-overlay {
- position: fixed;
- inset: 0;
- z-index: 80;
- display: grid;
- place-items: center;
- background: color-mix(in srgb, var(--ivory) 86%, transparent);
-}
-
-.docs-loading-bar {
- width: min(220px, 42vw);
- height: 4px;
- overflow: hidden;
- border-radius: 999px;
- background: color-mix(in srgb, var(--pine) 12%, transparent);
-}
-
-.docs-loading-bar span {
- display: block;
- width: 42%;
- height: 100%;
- border-radius: inherit;
- background: var(--pine);
- animation: docs-loading-bar 980ms ease-in-out infinite;
-}
-
-@keyframes docs-loading-bar {
- 0% {
- transform: translateX(-110%);
- }
-
- 50% {
- transform: translateX(80%);
- }
-
- 100% {
- transform: translateX(260%);
- }
-}
-
-.register-page {
- min-height: 100dvh;
- display: grid;
- place-items: center;
- padding: calc(var(--landing-nav-height) + 40px) var(--landing-letterbox) 64px;
- background:
- linear-gradient(90deg, color-mix(in srgb, var(--pine) 5%, transparent) 1px, transparent 1px),
- linear-gradient(180deg, color-mix(in srgb, var(--pine) 5%, transparent) 1px, transparent 1px),
- var(--ivory);
- background-size: 42px 42px;
- color: var(--pine);
-}
-
-.visually-hidden {
- position: absolute;
- width: 1px;
- height: 1px;
- overflow: hidden;
- clip: rect(0 0 0 0);
- white-space: nowrap;
-}
-
-.register-panel {
- width: min(920px, 100%);
- display: grid;
- grid-template-columns: minmax(0, 1fr) minmax(260px, 0.78fr);
- gap: 0;
- align-items: stretch;
- border: 1px solid var(--pine);
- border-radius: 8px;
- background: var(--ivory);
- box-shadow: 18px 18px 0 color-mix(in srgb, var(--sage) 46%, transparent);
-}
-
-.register-form,
-.register-result {
- min-width: 0;
- word-break: keep-all;
- overflow-wrap: break-word;
-}
-
-.register-form {
- display: grid;
- gap: 18px;
- padding: clamp(22px, 3vw, 36px);
-}
-
-.register-verification {
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
- gap: 10px;
- align-items: end;
- border-bottom: 1px solid color-mix(in srgb, var(--pine) 18%, transparent);
- padding-bottom: 18px;
-}
-
-.register-form label {
- display: grid;
- gap: 8px;
- color: var(--pine);
- font-size: 14px;
- font-weight: 850;
-}
-
-.register-form input,
-.register-form textarea {
- width: 100%;
- border: 1px solid color-mix(in srgb, var(--pine) 34%, transparent);
- border-radius: 6px;
- background: color-mix(in srgb, var(--ivory) 86%, white);
- padding: 13px 14px;
- color: var(--pine);
- font-size: 15px;
- line-height: 1.45;
-}
-
-.register-form textarea {
- min-height: 112px;
- resize: vertical;
-}
-
-.register-verification button {
- min-height: 47px;
- border: 1px solid var(--pine);
- border-radius: 8px;
- background: var(--ivory);
- padding: 0 14px;
- color: var(--pine);
- font-size: 14px;
- font-weight: 850;
- white-space: nowrap;
-}
-
-.register-verification button:hover,
-.register-verification button:focus-visible {
- background: var(--sage);
- outline: 0;
-}
-
-.register-verification button:disabled {
- cursor: not-allowed;
- opacity: 0.46;
-}
-
-.register-form input:focus,
-.register-form textarea:focus {
- border-color: var(--pine);
- outline: 3px solid color-mix(in srgb, var(--sage) 38%, transparent);
-}
-
-.register-actions {
- display: flex;
- flex-wrap: wrap;
- gap: 10px;
- padding-top: 8px;
-}
-
-.register-actions .primary-link {
- background: var(--pine);
- color: var(--ivory);
-}
-
-.register-actions .secondary-link {
- border-color: var(--pine);
- background: var(--ivory);
- color: var(--pine);
-}
-
-.register-actions button:disabled {
- cursor: not-allowed;
- opacity: 0.46;
-}
-
-.register-result {
- display: flex;
- flex-direction: column;
- justify-content: space-between;
- gap: 18px;
- border-left: 1px solid var(--pine);
- background: var(--pine);
- padding: clamp(22px, 3vw, 36px);
- color: var(--ivory);
-}
-
-.register-result span {
- width: max-content;
- border: 1px solid color-mix(in srgb, var(--ivory) 34%, transparent);
- border-radius: 999px;
- padding: 7px 10px;
- color: color-mix(in srgb, var(--ivory) 78%, transparent);
- font-size: 13px;
- font-weight: 850;
-}
-
-.register-result output {
- display: block;
- margin: auto 0;
- color: var(--sage);
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
- font-size: clamp(21px, 2.4vw, 30px);
- font-weight: 850;
- line-height: 1.35;
- overflow-wrap: anywhere;
-}
-
-.register-result button {
- min-height: 46px;
- border: 1px solid color-mix(in srgb, var(--ivory) 46%, transparent);
- border-radius: 8px;
- background: transparent;
- color: var(--ivory);
- font-weight: 850;
-}
-
-.register-result button:hover,
-.register-result button:focus-visible {
- background: var(--sage);
- color: var(--pine);
- outline: 0;
-}
-
-.register-result[data-issued="false"] {
- justify-content: center;
- align-items: center;
-}
-
-.register-result[data-issued="false"] span {
- border-color: color-mix(in srgb, var(--ivory) 28%, transparent);
-}
-
-.login-page {
- min-height: 100dvh;
- display: grid;
- place-items: center;
- padding: calc(var(--landing-nav-height) + 40px) var(--landing-letterbox) 64px;
- background:
- linear-gradient(90deg, color-mix(in srgb, var(--pine) 5%, transparent) 1px, transparent 1px),
- linear-gradient(180deg, color-mix(in srgb, var(--pine) 5%, transparent) 1px, transparent 1px),
- var(--ivory);
- background-size: 42px 42px;
- color: var(--pine);
-}
-
-.login-panel {
- width: min(920px, 100%);
- display: grid;
- grid-template-columns: minmax(260px, 0.82fr) minmax(0, 1.18fr);
- border: 1px solid var(--pine);
- border-radius: 8px;
- background: var(--ivory);
- box-shadow: 18px 18px 0 color-mix(in srgb, var(--sage) 46%, transparent);
-}
-
-.login-actions,
-.login-result-panel {
- min-width: 0;
- word-break: keep-all;
- overflow-wrap: break-word;
-}
-
-.login-actions {
- display: grid;
- align-content: space-between;
- gap: 40px;
- padding: clamp(22px, 3vw, 36px);
-}
-
-.login-kicker {
- margin: 0 0 10px;
- color: color-mix(in srgb, var(--pine) 62%, var(--ivory));
- font-size: 13px;
- font-weight: 900;
-}
-
-.login-actions h1 {
- margin: 0;
- max-width: 9em;
- font-size: clamp(30px, 3.6vw, 48px);
- line-height: 1.08;
- letter-spacing: 0;
- text-wrap: balance;
-}
-
-.login-provider-list {
- display: grid;
- gap: 10px;
-}
-
-.login-provider-button {
- display: flex;
- align-items: center;
- justify-content: flex-start;
- gap: 14px;
- min-height: 58px;
- border: 1px solid var(--pine);
- border-radius: 8px;
- background: var(--ivory);
- padding: 0 18px;
- color: var(--pine);
- font-size: 16px;
- font-weight: 900;
-}
-
-.login-provider-button:hover,
-.login-provider-button:focus-visible {
- background: var(--sage);
- outline: 0;
-}
-
-.login-provider-button:disabled {
- cursor: wait;
- opacity: 0.56;
-}
-
-.login-provider-icon {
- width: 24px;
- height: 24px;
- flex: 0 0 auto;
-}
-
-.login-result-panel {
- display: grid;
- grid-template-rows: auto minmax(280px, 1fr);
- gap: 18px;
- border-left: 1px solid var(--pine);
- background: var(--pine);
- padding: clamp(22px, 3vw, 36px);
- color: var(--ivory);
-}
-
-.login-result-panel > span {
- width: max-content;
- border: 1px solid color-mix(in srgb, var(--ivory) 34%, transparent);
- border-radius: 999px;
- padding: 7px 10px;
- color: color-mix(in srgb, var(--ivory) 78%, transparent);
- font-size: 13px;
- font-weight: 850;
-}
-
-.login-status-message,
-.login-user-card {
- min-width: 0;
- max-width: 100%;
- border: 1px solid color-mix(in srgb, var(--ivory) 24%, transparent);
- border-radius: 8px;
- background: color-mix(in srgb, black 20%, var(--pine));
- padding: 18px;
- color: var(--sage);
- line-height: 1.6;
- overflow-wrap: anywhere;
-}
-
-.login-status-message {
- display: grid;
- place-items: center;
- align-content: center;
- gap: 18px;
- color: color-mix(in srgb, var(--ivory) 62%, transparent);
- text-align: center;
-}
-
-.login-status-message p,
-.login-user-card p {
- margin: 0;
-}
-
-.login-status-message[data-state="error"] {
- color: var(--ivory);
-}
-
-.login-user-card {
- display: grid;
- grid-template-columns: auto minmax(0, 1fr);
- align-content: center;
- gap: 18px;
-}
-
-.login-user-avatar {
- width: 64px;
- height: 64px;
- border: 1px solid color-mix(in srgb, var(--ivory) 42%, transparent);
- border-radius: 50%;
- object-fit: cover;
-}
-
-.login-user-avatar-fallback {
- display: grid;
- place-items: center;
- background: var(--sage);
- color: var(--pine);
- font-size: 24px;
- font-weight: 900;
-}
-
-.login-user-name {
- color: var(--ivory);
- font-size: clamp(22px, 2.6vw, 30px);
- font-weight: 900;
- line-height: 1.2;
-}
-
-.login-user-email {
- margin-top: 4px !important;
- color: color-mix(in srgb, var(--ivory) 68%, transparent);
- font-size: 14px;
-}
-
-.login-session-note,
-.login-session-button {
- grid-column: 1 / -1;
-}
-
-.login-session-note {
- border-top: 1px solid color-mix(in srgb, var(--ivory) 18%, transparent);
- padding-top: 16px;
- color: color-mix(in srgb, var(--ivory) 68%, transparent);
- font-size: 13px;
-}
-
-.login-session-button {
- min-height: 46px;
- border: 1px solid color-mix(in srgb, var(--ivory) 46%, transparent);
- border-radius: 8px;
- background: transparent;
- padding: 0 16px;
- color: var(--ivory);
- font-weight: 850;
-}
-
-.login-session-button:hover,
-.login-session-button:focus-visible {
- background: var(--sage);
- color: var(--pine);
- outline: 0;
-}
-
-.footer-cta {
- display: grid;
- justify-items: center;
- padding: 88px 24px 112px;
- border-top: 1px solid var(--pine);
- text-align: center;
-}
-
-.footer-actions {
- display: flex;
- flex-wrap: wrap;
- justify-content: center;
- gap: 10px;
-}
-
-.footer-cta .primary-link {
- background: var(--pine);
- color: var(--ivory);
-}
-
-.footer-cta .secondary-link {
- border-color: var(--pine);
- background: var(--ivory);
- color: var(--pine);
-}
-
-.footer-cta .primary-link:hover,
-.footer-cta .primary-link:focus-visible,
-.footer-cta .secondary-link:hover,
-.footer-cta .secondary-link:focus-visible {
- background: var(--sage);
- color: var(--pine);
- outline: 0;
-}
-
-@media (max-width: 920px) {
- :root {
- --landing-letterbox: 7vw;
- --landing-rail-width: 86vw;
- --landing-section-height: auto;
- }
-
- .site-nav {
- padding: 0 var(--landing-letterbox);
- }
-
- .site-nav nav {
- gap: 2px;
- }
-
- .landing-hero {
- min-height: 100dvh;
- }
-
- .hero-actions,
- .split-section,
- .story-section,
- .story-panel,
- .scenario-layout {
- grid-template-columns: 1fr;
- }
-
- .hero-actions {
- display: grid;
- width: min(320px, 100%);
- }
-
- .split-section,
- .story-section,
- .start-section {
- padding: clamp(56px, 9dvh, 76px) 0;
- }
-
- .story-copy {
- position: static;
- }
-
- .story-track {
- gap: 18px;
- }
-
- .story-block {
- min-height: auto;
- opacity: 1;
- transform: none;
- }
-
- .story-panel {
- min-height: auto;
- }
-
- .story-visual {
- min-height: 28dvh;
- }
-
- .scenario-screen {
- min-height: 42dvh;
- }
-
- .step-grid {
- grid-template-columns: 1fr;
- }
-
- .docs-markdown-article {
- max-width: none;
- }
-
- .register-panel {
- grid-template-columns: 1fr;
- }
-
- .login-panel {
- grid-template-columns: 1fr;
- }
-
- .login-result-panel {
- border-top: 1px solid var(--pine);
- border-left: 0;
- }
-
- .register-result {
- min-height: 240px;
- border-top: 1px solid var(--pine);
- border-left: 0;
- }
-
- .register-verification {
- grid-template-columns: 1fr;
- }
-
- .api-docs-page {
- grid-template-columns: minmax(0, 1fr);
- }
-
- .api-docs-sidebar {
- position: static;
- height: auto;
- max-height: 42dvh;
- border-right: 0;
- border-bottom: 1px solid color-mix(in srgb, var(--pine) 18%, transparent);
- }
-
- .api-docs-content {
- padding: 40px var(--landing-letterbox) 80px;
- }
-
- .api-docs-copy-actions {
- top: calc(var(--docs-header-height) + 10px);
- justify-content: flex-start;
- max-width: 100%;
- }
-}
-
-@media (max-width: 767px) {
- .api-docs-content {
- padding: 24px var(--landing-letterbox) 72px;
- }
-
- .api-docs-copy-actions {
- flex-wrap: wrap;
- }
-}
-
-@media (prefers-reduced-motion: reduce) {
- html {
- scroll-behavior: auto;
- }
-
- .story-block {
- opacity: 1;
- transform: none;
- transition: none;
- }
-
- .scenario-timeline span::before {
- animation: none;
- transform: translateX(0);
- }
-
- .comment-card,
- .api-card,
- .result-card {
- opacity: 1;
- transform: none;
- animation: none;
- }
-}