diff --git a/.dockerignore b/.dockerignore
index 4401a39..faa33ee 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -22,3 +22,18 @@ coverage
# Local authoring assets are uploaded via APIs, never application build inputs.
/content
+
+# Apply local metadata, secrets and generated-output exclusions at every depth.
+**/.git
+**/.worktrees
+**/.pnpm-store
+**/.turbo
+**/.nx
+**/coverage
+**/artifacts
+**/.pytest_cache
+**/*.egg-info
+**/*.tsbuildinfo
+**/.env
+**/.env.*
+!**/.env.example
diff --git a/.gitea/workflows/app.yml b/.gitea/workflows/app.yml
new file mode 100644
index 0000000..57d2aac
--- /dev/null
+++ b/.gitea/workflows/app.yml
@@ -0,0 +1,79 @@
+name: Unified application CI and deployment
+on:
+ pull_request:
+ branches: [main]
+ paths:
+ - 'apps/waterfall-wiki/**'
+ - 'packages/brand/**'
+ - 'scripts/**'
+ - 'Dockerfile'
+ - '.dockerignore'
+ - 'compose.yml'
+ - 'pnpm-workspace.yaml'
+ - '.gitea/workflows/app.yml'
+ push:
+ branches: ["**"]
+ paths:
+ - 'apps/waterfall-wiki/**'
+ - 'packages/brand/**'
+ - 'scripts/**'
+ - 'Dockerfile'
+ - '.dockerignore'
+ - 'compose.yml'
+ - 'pnpm-workspace.yaml'
+ - '.gitea/workflows/app.yml'
+concurrency:
+ group: unified-app-${{ gitea.event.pull_request.number || gitea.ref }}
+ cancel-in-progress: false
+jobs:
+ static-checks:
+ runs-on: ces-control
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 2
+ - name: Check patch, syntax and source contracts without building
+ run: |
+ git diff --check HEAD^ HEAD
+ for script in scripts/*.sh scripts/preview/*.sh; do bash -n "$script"; done
+ node scripts/check-studio-contracts.mjs
+ node --check scripts/preview/fingerprint.mjs
+ node --check scripts/verify-studio-visual.cjs
+ node --check apps/waterfall-wiki/scripts/infinite-canvas-smoke.cjs
+ node --check apps/waterfall-wiki/scripts/cleanup-ci-bucket.mjs
+ python3 -c 'import ast,pathlib; ast.parse(pathlib.Path("scripts/wiki-image-lifecycle.py").read_text())'
+ verify-and-release:
+ needs: static-checks
+ if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
+ runs-on: ces-control
+ timeout-minutes: 60
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - name: Build and verify the unified application once
+ run: |
+ docker build --target integration -t "waterfall-wiki-tests:${{ gitea.sha }}" .
+ docker build --target runtime -t "waterfall-wiki:${{ gitea.sha }}" .
+ - name: Verify candidate using persistent test infrastructure
+ env:
+ WATERFALL_WIKI_IMAGE: waterfall-wiki:${{ gitea.sha }}
+ WATERFALL_WIKI_TEST_IMAGE: waterfall-wiki-tests:${{ gitea.sha }}
+ GITEA_RUN_ID: ${{ gitea.run_id }}
+ run: bash scripts/integration.sh
+ - name: Deploy unified Web and worker; verify production
+ if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
+ run: bash scripts/deploy.sh "${{ gitea.sha }}"
+ - name: Save production acceptance evidence
+ id: evidence
+ if: always() && gitea.event_name == 'push'
+ uses: actions/upload-artifact@v3
+ with:
+ name: unified-production-acceptance
+ path: artifacts/studio-acceptance/
+ if-no-files-found: ignore
+ retention-days: 7
+ - name: Remove uploaded local evidence
+ if: always() && steps.evidence.outcome == 'success'
+ run: rm -rf artifacts/studio-acceptance
diff --git a/.gitea/workflows/brand.yml b/.gitea/workflows/brand.yml
deleted file mode 100644
index da4a5aa..0000000
--- a/.gitea/workflows/brand.yml
+++ /dev/null
@@ -1,27 +0,0 @@
-name: Endoleap brand checks
-on:
- pull_request:
- branches: [main]
- paths:
- - 'packages/brand/**'
- - 'pnpm-workspace.yaml'
- - 'package.json'
- - 'AGENTS.md'
- - '.gitea/workflows/brand.yml'
- push:
- branches: [main]
- paths:
- - 'packages/brand/**'
- - 'pnpm-workspace.yaml'
- - '.gitea/workflows/brand.yml'
-concurrency:
- group: brand-${{ gitea.event.pull_request.number || gitea.ref }}
- cancel-in-progress: true
-jobs:
- verify:
- runs-on: ces-control
- timeout-minutes: 20
- steps:
- - uses: actions/checkout@v4
- - name: Verify brand assets, Tailwind compilation and typed exports in Docker
- run: docker build -f packages/brand/Dockerfile -t "endoleap-brand-check:${{ gitea.sha }}" .
diff --git a/.gitea/workflows/creative-workspace.yml b/.gitea/workflows/creative-workspace.yml
deleted file mode 100644
index ee90d9a..0000000
--- a/.gitea/workflows/creative-workspace.yml
+++ /dev/null
@@ -1,80 +0,0 @@
-name: creative workspace review checks
-on:
- pull_request:
- branches: [main]
- paths:
- - 'apps/studio/**'
- - 'apps/waterfall-wiki/**'
- - '.gitea/workflows/**'
- - 'scripts/*studio*'
- - 'compose.creative-review.yml'
- - 'scripts/integration.sh'
- - 'scripts/cleanup-integration.sh'
- - 'scripts/test-integration-cleanup.py'
- - 'scripts/cleanup-review-images.sh'
- - 'scripts/test-review-image-cleanup.py'
-concurrency:
- group: creative-workspace-${{ gitea.event.pull_request.number }}
- cancel-in-progress: true
-jobs:
- verify-deployed-canvas:
- runs-on: ces-control
- timeout-minutes: 15
- steps:
- - uses: actions/checkout@v4
- - name: Verify production acceptance against the designated deployed server
- run: |
- node --check scripts/verify-studio-visual.cjs
- node --check apps/studio/scripts/infinite-canvas-smoke.cjs
- bash -n scripts/verify-studio-production.sh
- bash scripts/verify-studio-production.sh
- - name: Save server acceptance evidence
- id: acceptance_evidence
- if: always()
- uses: actions/upload-artifact@v3
- with:
- name: studio-production-acceptance
- path: artifacts/studio-acceptance/
- if-no-files-found: ignore
- retention-days: 7
- - name: Remove temporary acceptance output after upload
- if: always() && steps.acceptance_evidence.outcome == 'success'
- run: rm -rf artifacts/studio-acceptance
- verify:
- runs-on: ces-control
- timeout-minutes: 60
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
- - name: Check patch formatting
- run: |
- git diff --check HEAD^ HEAD
- bash -n scripts/integration.sh scripts/cleanup-integration.sh scripts/verify-studio-production.sh scripts/cleanup-review-images.sh
- node --check scripts/verify-studio-visual.cjs
- python3 scripts/test-integration-cleanup.py
- python3 scripts/test-review-image-cleanup.py
- node --test apps/waterfall-wiki/tests/brand-navigation.test.mjs
- - name: Verify Studio types, tests, build and smoke
- run: docker build -f apps/studio/Dockerfile -t "creative-studio-review:${{ gitea.sha }}" .
- - name: Build Wiki integration and runtime images
- run: |
- docker build --target integration -t "creative-wiki-tests:${{ gitea.sha }}" .
- docker build --target runtime -t "creative-wiki-review:${{ gitea.sha }}" .
- - name: Verify isolated PostgreSQL, S3 and browser integration
- env:
- STUDIO_REVIEW_IMAGE: creative-studio-review:${{ gitea.sha }}
- WATERFALL_WIKI_IMAGE: creative-wiki-review:${{ gitea.sha }}
- WATERFALL_WIKI_TEST_IMAGE: creative-wiki-tests:${{ gitea.sha }}
- GITEA_RUN_ID: ${{ gitea.run_id }}
- run: bash scripts/integration.sh
- - name: Clean this run disposable integration resources even after cancellation
- if: always()
- env:
- GITEA_RUN_ID: ${{ gitea.run_id }}
- run: bash scripts/cleanup-integration.sh
- - name: Remove this run disposable review image tags
- if: always()
- env:
- REVIEW_SHA: ${{ gitea.sha }}
- run: bash scripts/cleanup-review-images.sh
diff --git a/.gitea/workflows/preview.yml b/.gitea/workflows/preview.yml
new file mode 100644
index 0000000..cc2eaab
--- /dev/null
+++ b/.gitea/workflows/preview.yml
@@ -0,0 +1,32 @@
+name: Remote development preview source sync
+on:
+ push:
+ branches: ['**']
+ paths:
+ - 'apps/waterfall-wiki/**'
+ - 'packages/brand/**'
+ - 'pnpm-workspace.yaml'
+ - 'scripts/preview/**'
+ - '.gitea/workflows/preview.yml'
+concurrency:
+ group: development-preview
+ cancel-in-progress: false
+jobs:
+ sync-source:
+ # One preview slot follows one branch. Setup changes use an explicit refresh.
+ if: gitea.ref == format('refs/heads/{0}', vars.PREVIEW_BRANCH || 'codex/unified-web')
+ runs-on: ces-control
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 1
+ - name: Initialize once, otherwise synchronize files only
+ env:
+ SOURCE_SHA: ${{ gitea.sha }}
+ SOURCE_BRANCH: ${{ gitea.ref_name }}
+ run: |
+ if [ ! -f /opt/prompt-preview/ready ]; then
+ bash scripts/preview/init.sh "$PWD" waterfall-wiki-tests:6654a1456bdcef77d83ca84373dc36188e15bd12
+ fi
+ bash scripts/preview/sync.sh "$PWD" "$SOURCE_SHA" "$SOURCE_BRANCH"
diff --git a/.gitea/workflows/python.yml b/.gitea/workflows/python.yml
index bb5b5d6..8cc6ec5 100644
--- a/.gitea/workflows/python.yml
+++ b/.gitea/workflows/python.yml
@@ -1,14 +1,35 @@
name: python CI and deployment
on:
- push:
+ pull_request:
branches: [main]
paths:
+ - '.dockerignore'
+ - 'apps/python/**'
+ - 'scripts/deploy-python.sh'
+ - '.gitea/workflows/python.yml'
+ push:
+ branches: ["**"]
+ paths:
+ - '.dockerignore'
- 'apps/python/**'
- 'scripts/deploy-python.sh'
- '.gitea/workflows/python.yml'
jobs:
+ static-checks:
+ runs-on: ces-control
+ timeout-minutes: 5
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 2
+ - name: Check Python and deployment syntax without executing tests
+ run: |
+ git diff --check HEAD^ HEAD
+ bash -n scripts/deploy-python.sh
+ python3 -c 'import ast,pathlib; [ast.parse(p.read_text(),filename=str(p)) for p in pathlib.Path("apps/python").rglob("*.py")]'
verify-and-deploy:
+ needs: static-checks
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
runs-on: ces-control
timeout-minutes: 45
diff --git a/.gitea/workflows/waterfall-wiki.yml b/.gitea/workflows/waterfall-wiki.yml
deleted file mode 100644
index dba9b29..0000000
--- a/.gitea/workflows/waterfall-wiki.yml
+++ /dev/null
@@ -1,71 +0,0 @@
-name: creative workspace CI and deployment
-on:
- push:
- branches: [main]
- paths:
- - 'apps/waterfall-wiki/**'
- - 'apps/studio/**'
- - 'scripts/*studio*'
- - 'content/studio-examples/**'
- - 'content/studio-tools/layouts-api.md'
- - '.gitea/workflows/waterfall-wiki.yml'
- - 'Dockerfile'
- - 'compose.yml'
- - 'scripts/deploy.sh'
- - 'scripts/wiki-image-lifecycle.py'
- - 'scripts/infra.sh'
- - 'scripts/auth-admin.sh'
- - 'scripts/integration.sh'
- - 'scripts/cleanup-integration.sh'
- - 'scripts/test-integration-cleanup.py'
- - 'pnpm-workspace.yaml'
-
-jobs:
- verify-and-deploy:
- if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
- runs-on: ces-control
- timeout-minutes: 60
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
- - name: Check shared content and documentation contracts
- run: |
- python3 scripts/test-integration-cleanup.py
- bash -n scripts/integration.sh scripts/cleanup-integration.sh
- node scripts/check-studio-contracts.mjs
- node --check scripts/export-studio-series.cjs
- node --check scripts/save-studio-series.mjs
- bash -n scripts/publish-studio-guide.sh scripts/deliver-studio-series.sh
- - name: Build and verify Studio
- run: docker build -f apps/studio/Dockerfile -t "prompt-studio:${{ gitea.sha }}" .
- - name: Build, verify, deploy and retain wiki images
- env:
- RELEASE_SHA: ${{ gitea.sha }}
- run: |
- if [ "$(id -u)" -eq 0 ]; then
- python3 scripts/wiki-image-lifecycle.py ci --sha "$RELEASE_SHA"
- else
- sudo -n python3 scripts/wiki-image-lifecycle.py ci --sha "$RELEASE_SHA"
- fi
- - name: Release Studio after Wiki routes are deployed
- run: bash scripts/deploy-studio.sh "${{ gitea.sha }}"
- - name: Verify the deployed creative workspace
- run: bash scripts/verify-studio-production.sh
- - name: Save server acceptance evidence
- id: acceptance_evidence
- if: always()
- uses: actions/upload-artifact@v3
- with:
- name: studio-production-acceptance
- path: artifacts/studio-acceptance/
- if-no-files-found: ignore
- retention-days: 7
- - name: Remove temporary acceptance output after upload
- if: always() && steps.acceptance_evidence.outcome == 'success'
- run: rm -rf artifacts/studio-acceptance
- - name: Clean this run disposable integration resources even after cancellation
- if: always()
- env:
- GITEA_RUN_ID: ${{ gitea.run_id }}
- run: bash scripts/cleanup-integration.sh
diff --git a/Dockerfile b/Dockerfile
index 245d4e4..d2adf45 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -12,16 +12,21 @@ LABEL io.entroia.wiki.build="true"
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
COPY apps/waterfall-wiki/pnpm-lock.yaml ./
COPY pnpm-workspace.yaml /repo/pnpm-workspace.yaml
-COPY packages/brand/ /repo/packages/brand/
+# Download cache depends on lockfile and workspace policy, not package scripts.
+RUN pnpm fetch --lockfile-dir=/repo/apps/waterfall-wiki --fetch-timeout=300000 --network-concurrency=8
+COPY packages/brand/package.json /repo/packages/brand/package.json
COPY apps/waterfall-wiki/package.json ./
-RUN pnpm --filter @prompt/waterfall-wiki install --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
+RUN pnpm --filter @prompt/waterfall-wiki install --offline --frozen-lockfile
FROM dependencies AS build
# .dockerignore excludes docs and operational notes without extra legacy COPY snapshots.
+# Brand source changes invalidate compilation, not dependency installation.
+COPY packages/brand/ /repo/packages/brand/
COPY apps/waterfall-wiki/ .
RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke
FROM build AS integration
+ENV VERIFY_CANVAS_UI=1
USER node
CMD ["npm", "run", "test:integration"]
diff --git a/README.md b/README.md
index 1b6a690..20720e9 100644
--- a/README.md
+++ b/README.md
@@ -55,7 +55,7 @@ Studio 负责交互式排版与无状态渲染,知识库负责教程、来源
| 路径 | 职责 |
| --- | --- |
| `apps/waterfall-wiki` | Next.js Pages Router 知识库、搜索、标签、媒体 API、认证与后台视频加工 |
-| `apps/studio` | 独立 Next.js 创作工具与封面渲染 API |
+| `apps/waterfall-wiki/src/studio` | 合并后的创作工具、排版渲染与编辑器模块 |
| `apps/python` | 按需运行的 PNG/视频导出 CLI,没有常驻网络服务 |
| `content` | 本地作者资料、教程及排版源图,不打入 Wiki 运行镜像 |
| `scripts` | CI 部署、运维、API 发布与作者工具 |
@@ -71,8 +71,8 @@ Studio 负责交互式排版与无状态渲染,知识库负责教程、来源
pnpm install
pnpm --dir apps/waterfall-wiki typecheck
pnpm --dir apps/waterfall-wiki test
-pnpm --dir apps/studio typecheck
-bash -n scripts/deploy.sh scripts/integration.sh scripts/deploy-studio.sh
+node scripts/check-studio-contracts.mjs
+for script in scripts/*.sh; do bash -n "$script"; done
```
完整集成测试只针对显式隔离的数据库、对象存储和生产容器运行,覆盖存储、视频加工、编辑冲突、真实搜索与引用、标签规则和登录保护。不要把生产存储配置拿来运行集成测试。
@@ -81,14 +81,14 @@ bash -n scripts/deploy.sh scripts/integration.sh scripts/deploy-studio.sh
## 部署与持久化
-Web 应用由 `waterfall-wiki.yml` 统一发布,两个应用的路径都能触发它:先检查并构建 Studio,再完成 Wiki 的类型检查、单元测试、生产构建、冒烟及隔离 PostgreSQL/S3 集成测试并部署 Wiki,随后部署 Studio,最后在指定服务器验收。顺序在同一个 ces-control 作业内保证,避免独立作业互相等待。PR 检查也运行隔离集成测试,但不部署。Python 检查仍由 `python.yml` 承担;文档与作者数据本身不触发应用部署。
+Web 应用由 `.gitea/workflows/app.yml` 统一验证与发布。Wiki 和 Studio 已合并成一个 Next.js 应用,一次编译、一个 Web 运行镜像,Web 与后台 worker 使用同一镜像。PostgreSQL/S3 基础服务长期运行,不在日常发布中重建。工作分支 push/PR 仅做轻量静态检查;只有 main push 才构建、运行测试并更新生产。完整步骤见 [统一部署流程](docs/unified-deployment.md)。
| 服务 | 入口/端口 |
| --- | --- |
| `waterfall-wiki` | 公网 `18131`,也是 Studio 的统一入口 |
| PostgreSQL | `127.0.0.1:18132` |
| 对象存储 | `127.0.0.1:18133` |
-| Studio | `127.0.0.1:18134`;编辑器公网入口为 `/graphic`、`/infinite_canvas`,资源和 API 保留 `/studio` 代理 |
+| 编辑器 | 与知识站共享 `18131`;原生路由 `/graphic`、`/infinite_canvas`,历史 `/studio` 页面与 API 保留兼容 |
| Python | 无网络端口,按需运行 |
生产 Compose 项目仍为 `prompt`,部署根目录仍为 `/opt/prompt`。既有 `prompt_postgres_data`、`prompt_objects`、`prompt_auth_data` 卷与数据库身份保留;应用更名不会重新创建一套空存储。Compose 应用服务改为 `waterfall-wiki` 和 `waterfall-wiki-worker`,镜像参数为 `WATERFALL_WIKI_IMAGE` / `WATERFALL_WIKI_TEST_IMAGE`。
diff --git a/apps/python/Dockerfile b/apps/python/Dockerfile
index 7a025c0..ac6498f 100644
--- a/apps/python/Dockerfile
+++ b/apps/python/Dockerfile
@@ -1,8 +1,22 @@
-FROM python:3.12-slim
+FROM python:3.12-slim AS base
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg fonts-noto-cjk && rm -rf /var/lib/apt/lists/*
+
+FROM base AS dependencies
+ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /app
+RUN python -m venv /opt/venv
+COPY apps/python/pyproject.toml ./
+# Read the pinned requirements from the package metadata before copying source.
+RUN python -c 'import subprocess, tomllib; p = tomllib.load(open("pyproject.toml", "rb")); subprocess.check_call(["python", "-m", "pip", "install", "--no-cache-dir", *p["build-system"]["requires"], *p["project"]["dependencies"]])'
+
+FROM dependencies AS build
COPY apps/python/ .
-RUN pip install --no-cache-dir . && python -m unittest discover -s tests -v
+RUN pip install --no-cache-dir --no-deps --no-build-isolation . && python -m unittest discover -s tests -v
+
+FROM base AS runtime
+WORKDIR /app
+ENV PATH="/opt/venv/bin:$PATH"
+COPY --from=build /opt/venv /opt/venv
RUN useradd --create-home exporter
USER exporter
ENTRYPOINT ["prompt-export"]
diff --git a/apps/studio/Dockerfile b/apps/studio/Dockerfile
deleted file mode 100644
index 37ada5b..0000000
--- a/apps/studio/Dockerfile
+++ /dev/null
@@ -1,23 +0,0 @@
-FROM node:24-bookworm-slim AS build
-WORKDIR /repo/apps/studio
-RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
-COPY apps/studio/pnpm-lock.yaml ./
-COPY pnpm-workspace.yaml /repo/pnpm-workspace.yaml
-COPY packages/brand/ /repo/packages/brand/
-COPY apps/studio/package.json ./
-RUN pnpm --filter @prompt/studio install --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
-COPY apps/studio/ .
-# Compare the two public parameter validators inside the isolated CI build.
-COPY apps/waterfall-wiki/src/lib/layout-parameters.ts /repo/apps/waterfall-wiki/src/lib/layout-parameters.ts
-COPY apps/waterfall-wiki/src/lib/series.ts /repo/apps/waterfall-wiki/src/lib/series.ts
-COPY apps/waterfall-wiki/src/lib/canvas-document.ts /repo/apps/waterfall-wiki/src/lib/canvas-document.ts
-RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke
-FROM node:24-bookworm-slim
-WORKDIR /app/apps/studio
-ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000
-COPY --from=build --chown=node:node /repo/apps/studio/.next/standalone/ /app/
-COPY --from=build --chown=node:node /repo/apps/studio/.next/static/ ./.next/static/
-COPY --from=build --chown=node:node /repo/apps/studio/public/ ./public/
-USER node
-EXPOSE 3000
-CMD ["node", "server.js"]
diff --git a/apps/studio/next-env.d.ts b/apps/studio/next-env.d.ts
deleted file mode 100644
index 15849f6..0000000
--- a/apps/studio/next-env.d.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-///
-///
-import "./.next/types/routes.d.ts";
-import "./.next/types/root-params.d.ts";
-
-// NOTE: This file should not be edited
-// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
diff --git a/apps/studio/next.config.ts b/apps/studio/next.config.ts
deleted file mode 100644
index 67416ad..0000000
--- a/apps/studio/next.config.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-import { resolve } from "node:path";
-import type { NextConfig } from "next";
-const config: NextConfig = { turbopack: { root: resolve(process.cwd(), "../..") }, basePath: "/studio", output: "standalone", outputFileTracingRoot: resolve(process.cwd(), "../.."), poweredByHeader: false };
-export default config;
diff --git a/apps/studio/package.json b/apps/studio/package.json
deleted file mode 100644
index 0b75aab..0000000
--- a/apps/studio/package.json
+++ /dev/null
@@ -1,29 +0,0 @@
-{
- "name": "@prompt/studio",
- "private": true,
- "scripts": {
- "dev": "pnpm brand:prepare && next dev -p 3001",
- "build": "pnpm brand:prepare && next build",
- "start": "next start",
- "typecheck": "next typegen && tsc --noEmit",
- "test": "node --test tests/*.test.mjs",
- "test:smoke": "node scripts/smoke.mjs",
- "brand:prepare": "node ../../packages/brand/scripts/prepare-app.mjs"
- },
- "dependencies": {
- "next": "16.4.2",
- "react": "19.3.1",
- "react-dom": "19.3.1",
- "lucide-react": "1.61.0",
- "@endoleap/brand": "workspace:*"
- },
- "devDependencies": {
- "@tailwindcss/postcss": "4.3.3",
- "@types/node": "24.19.2",
- "@types/react": "19.3.0",
- "@types/react-dom": "19.3.0",
- "tailwindcss": "4.3.3",
- "typescript": "5.9.3"
- },
- "packageManager": "pnpm@11.25.0"
-}
diff --git a/apps/studio/pnpm-lock.yaml b/apps/studio/pnpm-lock.yaml
deleted file mode 100644
index d65df4b..0000000
--- a/apps/studio/pnpm-lock.yaml
+++ /dev/null
@@ -1,1033 +0,0 @@
-lockfileVersion: '9.0'
-
-settings:
- autoInstallPeers: true
- excludeLinksFromLockfile: false
-
-importers:
-
- .:
- dependencies:
- '@endoleap/brand':
- specifier: workspace:*
- version: link:../../packages/brand
- lucide-react:
- specifier: 1.61.0
- version: 1.61.0(@types/react@19.3.0)(react@19.3.1)
- next:
- specifier: 16.4.2
- version: 16.4.2(@types/node@24.19.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
- react:
- specifier: 19.3.1
- version: 19.3.1
- react-dom:
- specifier: 19.3.1
- version: 19.3.1(react@19.3.1)
- devDependencies:
- '@tailwindcss/postcss':
- specifier: 4.3.3
- version: 4.3.3
- '@types/node':
- specifier: 24.19.2
- version: 24.19.2
- '@types/react':
- specifier: 19.3.0
- version: 19.3.0
- '@types/react-dom':
- specifier: 19.3.0
- version: 19.3.0(@types/react@19.3.0)
- tailwindcss:
- specifier: 4.3.3
- version: 4.3.3
- typescript:
- specifier: 5.9.3
- version: 5.9.3
-
-packages:
-
- '@alloc/quick-lru@5.3.0':
- resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==}
- engines: {node: '>=10'}
-
- '@emnapi/runtime@1.11.3':
- resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
-
- '@img/colour@1.1.0':
- resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
- engines: {node: '>=18'}
-
- '@img/sharp-darwin-arm64@0.35.5':
- resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==}
- engines: {node: '>=20.9.0'}
- cpu: [arm64]
- os: [darwin]
-
- '@img/sharp-darwin-x64@0.35.5':
- resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==}
- engines: {node: '>=20.9.0'}
- cpu: [x64]
- os: [darwin]
-
- '@img/sharp-freebsd-wasm32@0.35.5':
- resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==}
- engines: {node: '>=20.9.0'}
- os: [freebsd]
-
- '@img/sharp-libvips-darwin-arm64@1.3.4':
- resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==}
- cpu: [arm64]
- os: [darwin]
-
- '@img/sharp-libvips-darwin-x64@1.3.4':
- resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==}
- cpu: [x64]
- os: [darwin]
-
- '@img/sharp-libvips-linux-arm64@1.3.4':
- resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==}
- cpu: [arm64]
- os: [linux]
- libc: [glibc]
-
- '@img/sharp-libvips-linux-arm@1.3.4':
- resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==}
- cpu: [arm]
- os: [linux]
- libc: [glibc]
-
- '@img/sharp-libvips-linux-ppc64@1.3.4':
- resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==}
- cpu: [ppc64]
- os: [linux]
- libc: [glibc]
-
- '@img/sharp-libvips-linux-riscv64@1.3.4':
- resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==}
- cpu: [riscv64]
- os: [linux]
- libc: [glibc]
-
- '@img/sharp-libvips-linux-s390x@1.3.4':
- resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==}
- cpu: [s390x]
- os: [linux]
- libc: [glibc]
-
- '@img/sharp-libvips-linux-x64@1.3.4':
- resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==}
- cpu: [x64]
- os: [linux]
- libc: [glibc]
-
- '@img/sharp-libvips-linuxmusl-arm64@1.3.4':
- resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==}
- cpu: [arm64]
- os: [linux]
- libc: [musl]
-
- '@img/sharp-libvips-linuxmusl-x64@1.3.4':
- resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==}
- cpu: [x64]
- os: [linux]
- libc: [musl]
-
- '@img/sharp-linux-arm64@0.35.5':
- resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==}
- engines: {node: '>=20.9.0'}
- cpu: [arm64]
- os: [linux]
- libc: [glibc]
-
- '@img/sharp-linux-arm@0.35.5':
- resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==}
- engines: {node: '>=20.9.0'}
- cpu: [arm]
- os: [linux]
- libc: [glibc]
-
- '@img/sharp-linux-ppc64@0.35.5':
- resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==}
- engines: {node: '>=20.9.0'}
- cpu: [ppc64]
- os: [linux]
- libc: [glibc]
-
- '@img/sharp-linux-riscv64@0.35.5':
- resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==}
- engines: {node: '>=20.9.0'}
- cpu: [riscv64]
- os: [linux]
- libc: [glibc]
-
- '@img/sharp-linux-s390x@0.35.5':
- resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==}
- engines: {node: '>=20.9.0'}
- cpu: [s390x]
- os: [linux]
- libc: [glibc]
-
- '@img/sharp-linux-x64@0.35.5':
- resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==}
- engines: {node: '>=20.9.0'}
- cpu: [x64]
- os: [linux]
- libc: [glibc]
-
- '@img/sharp-linuxmusl-arm64@0.35.5':
- resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==}
- engines: {node: '>=20.9.0'}
- cpu: [arm64]
- os: [linux]
- libc: [musl]
-
- '@img/sharp-linuxmusl-x64@0.35.5':
- resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==}
- engines: {node: '>=20.9.0'}
- cpu: [x64]
- os: [linux]
- libc: [musl]
-
- '@img/sharp-wasm32@0.35.5':
- resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==}
- engines: {node: '>=20.9.0'}
-
- '@img/sharp-webcontainers-wasm32@0.35.5':
- resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==}
- engines: {node: '>=20.9.0'}
- cpu: [wasm32]
-
- '@img/sharp-win32-arm64@0.35.5':
- resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==}
- engines: {node: '>=20.9.0'}
- cpu: [arm64]
- os: [win32]
-
- '@img/sharp-win32-ia32@0.35.5':
- resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==}
- engines: {node: ^20.9.0}
- cpu: [ia32]
- os: [win32]
-
- '@img/sharp-win32-x64@0.35.5':
- resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==}
- engines: {node: '>=20.9.0'}
- cpu: [x64]
- os: [win32]
-
- '@jridgewell/gen-mapping@0.3.13':
- resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
-
- '@jridgewell/remapping@2.3.5':
- resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
-
- '@jridgewell/resolve-uri@3.1.2':
- resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
- engines: {node: '>=6.0.0'}
-
- '@jridgewell/sourcemap-codec@1.6.0':
- resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
-
- '@jridgewell/trace-mapping@0.3.31':
- resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
-
- '@next/env@16.4.2':
- resolution: {integrity: sha512-nTUTFH7G5/J/fmpkjVLegOkqyKOP0CEsJcMsyO4UCYeN1su+YMW/MfaSErnJskL7XM+UDK6aTvslxsRCyya/sA==}
-
- '@next/swc-darwin-arm64@16.4.2':
- resolution: {integrity: sha512-ONkhFvZNFWlsEHOM/81AtxdyuJP2KRYKYR0JxPwvcu1AuPMMMgcL+wW2luldJvMtZwKtUxoSkCychW5wIeoYSw==}
- engines: {node: '>= 10'}
- cpu: [arm64]
- os: [darwin]
-
- '@next/swc-darwin-x64@16.4.2':
- resolution: {integrity: sha512-luPCMbOsCoaF+kXJEUjmjmOZg1lC3BflhmUu+ZmW4tBu2vW/4AXak8JBFvePizGkxVmNiAw/r9ra+6UviCWtWQ==}
- engines: {node: '>= 10'}
- cpu: [x64]
- os: [darwin]
-
- '@next/swc-linux-arm64-gnu@16.4.2':
- resolution: {integrity: sha512-xhwFTAncJyaCNy5KqKKuKaIIdCy1/1ZV1s5S1G0hhT7wGMkDTlfJ6aQbfRFnIG/s3/kDrxQ8aycVe+c5QnP8ow==}
- engines: {node: '>= 10'}
- cpu: [arm64]
- os: [linux]
- libc: [glibc]
-
- '@next/swc-linux-arm64-musl@16.4.2':
- resolution: {integrity: sha512-T1PNmfbPcxr5ndtWUL/3wQtLXsz808lq6sc2GrNT5k2oquF7UyqZddHSuAewHVsWsGXe6nsl9HoN1YNriVjuSg==}
- engines: {node: '>= 10'}
- cpu: [arm64]
- os: [linux]
- libc: [musl]
-
- '@next/swc-linux-x64-gnu@16.4.2':
- resolution: {integrity: sha512-N9cDUOCGtkJG5PNKDFZUF3xXndrJJcpLp121uAGjIGvO/U2p9tfdNOM9htj7+5f72l0m8rMw/cvYacUJBQoudg==}
- engines: {node: '>= 10'}
- cpu: [x64]
- os: [linux]
- libc: [glibc]
-
- '@next/swc-linux-x64-musl@16.4.2':
- resolution: {integrity: sha512-yY2uh1YhX6ufuYy2FCbw1dqblbhI57GLYAJfCkqD9auTv6pLrH67Uccjc3QMKBeCYAG6lkgu08ExmX1bbjejjQ==}
- engines: {node: '>= 10'}
- cpu: [x64]
- os: [linux]
- libc: [musl]
-
- '@next/swc-win32-arm64-msvc@16.4.2':
- resolution: {integrity: sha512-j/ArWRIHpMMB6wYLMhr2puTguBt70lNU/Cmjl9p6dTDE8DGknYJMP9PdwPk2dqpCNL/KIX9yfngDvIesxJPmsg==}
- engines: {node: '>= 10'}
- cpu: [arm64]
- os: [win32]
-
- '@next/swc-win32-x64-msvc@16.4.2':
- resolution: {integrity: sha512-AA4edyWHDko3NCSo0x6GomsdgFb0oR1J3MXU1b5xt6FRFg4AucrMPW3S0hCe/9onU0O1BbpGQBsqo1r0Gneu1g==}
- engines: {node: '>= 10'}
- cpu: [x64]
- os: [win32]
-
- '@swc/helpers@0.5.23':
- resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
-
- '@tailwindcss/node@4.3.3':
- resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
-
- '@tailwindcss/oxide-android-arm64@4.3.3':
- resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==}
- engines: {node: '>= 20'}
- cpu: [arm64]
- os: [android]
-
- '@tailwindcss/oxide-darwin-arm64@4.3.3':
- resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==}
- engines: {node: '>= 20'}
- cpu: [arm64]
- os: [darwin]
-
- '@tailwindcss/oxide-darwin-x64@4.3.3':
- resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==}
- engines: {node: '>= 20'}
- cpu: [x64]
- os: [darwin]
-
- '@tailwindcss/oxide-freebsd-x64@4.3.3':
- resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==}
- engines: {node: '>= 20'}
- cpu: [x64]
- os: [freebsd]
-
- '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
- resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==}
- engines: {node: '>= 20'}
- cpu: [arm]
- os: [linux]
-
- '@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
- resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==}
- engines: {node: '>= 20'}
- cpu: [arm64]
- os: [linux]
- libc: [glibc]
-
- '@tailwindcss/oxide-linux-arm64-musl@4.3.3':
- resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==}
- engines: {node: '>= 20'}
- cpu: [arm64]
- os: [linux]
- libc: [musl]
-
- '@tailwindcss/oxide-linux-x64-gnu@4.3.3':
- resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==}
- engines: {node: '>= 20'}
- cpu: [x64]
- os: [linux]
- libc: [glibc]
-
- '@tailwindcss/oxide-linux-x64-musl@4.3.3':
- resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==}
- engines: {node: '>= 20'}
- cpu: [x64]
- os: [linux]
- libc: [musl]
-
- '@tailwindcss/oxide-wasm32-wasi@4.3.3':
- resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==}
- engines: {node: '>=14.0.0'}
- cpu: [wasm32]
- bundledDependencies:
- - '@napi-rs/wasm-runtime'
- - '@emnapi/core'
- - '@emnapi/runtime'
- - '@tybys/wasm-util'
- - '@emnapi/wasi-threads'
- - tslib
-
- '@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
- resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==}
- engines: {node: '>= 20'}
- cpu: [arm64]
- os: [win32]
-
- '@tailwindcss/oxide-win32-x64-msvc@4.3.3':
- resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==}
- engines: {node: '>= 20'}
- cpu: [x64]
- os: [win32]
-
- '@tailwindcss/oxide@4.3.3':
- resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==}
- engines: {node: '>= 20'}
-
- '@tailwindcss/postcss@4.3.3':
- resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==}
-
- '@types/node@24.19.2':
- resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==}
-
- '@types/react-dom@19.3.0':
- resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
- peerDependencies:
- '@types/react': ^19.3.0
-
- '@types/react@19.3.0':
- resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==}
-
- baseline-browser-mapping@2.11.32:
- resolution: {integrity: sha512-UvuxFU9P3hM29LrQdwu0cD6w6/+SFr+UlB9s/knI7+O2rK9xFxKUwSDWxGdbbqQ9/V7BJ5s0NI5nQAE4uJp3gQ==}
- engines: {node: '>=6.0.0'}
- hasBin: true
-
- caniuse-lite@1.0.30001819:
- resolution: {integrity: sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg==}
-
- client-only@0.0.1:
- resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
-
- csstype@3.2.3:
- resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
-
- detect-libc@2.1.2:
- resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
- engines: {node: '>=8'}
-
- enhanced-resolve@5.26.0:
- resolution: {integrity: sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==}
- engines: {node: '>=10.13.0'}
-
- graceful-fs@4.2.11:
- resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
-
- jiti@2.7.0:
- resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
- hasBin: true
-
- lightningcss-android-arm64@1.32.0:
- resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
- engines: {node: '>= 12.0.0'}
- cpu: [arm64]
- os: [android]
-
- lightningcss-darwin-arm64@1.32.0:
- resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
- engines: {node: '>= 12.0.0'}
- cpu: [arm64]
- os: [darwin]
-
- lightningcss-darwin-x64@1.32.0:
- resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
- engines: {node: '>= 12.0.0'}
- cpu: [x64]
- os: [darwin]
-
- lightningcss-freebsd-x64@1.32.0:
- resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
- engines: {node: '>= 12.0.0'}
- cpu: [x64]
- os: [freebsd]
-
- lightningcss-linux-arm-gnueabihf@1.32.0:
- resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
- engines: {node: '>= 12.0.0'}
- cpu: [arm]
- os: [linux]
-
- lightningcss-linux-arm64-gnu@1.32.0:
- resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
- engines: {node: '>= 12.0.0'}
- cpu: [arm64]
- os: [linux]
- libc: [glibc]
-
- lightningcss-linux-arm64-musl@1.32.0:
- resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
- engines: {node: '>= 12.0.0'}
- cpu: [arm64]
- os: [linux]
- libc: [musl]
-
- lightningcss-linux-x64-gnu@1.32.0:
- resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
- engines: {node: '>= 12.0.0'}
- cpu: [x64]
- os: [linux]
- libc: [glibc]
-
- lightningcss-linux-x64-musl@1.32.0:
- resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
- engines: {node: '>= 12.0.0'}
- cpu: [x64]
- os: [linux]
- libc: [musl]
-
- lightningcss-win32-arm64-msvc@1.32.0:
- resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
- engines: {node: '>= 12.0.0'}
- cpu: [arm64]
- os: [win32]
-
- lightningcss-win32-x64-msvc@1.32.0:
- resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
- engines: {node: '>= 12.0.0'}
- cpu: [x64]
- os: [win32]
-
- lightningcss@1.32.0:
- resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
- engines: {node: '>= 12.0.0'}
-
- lucide-react@1.61.0:
- resolution: {integrity: sha512-v2SQPPPNjnr+BlhL9Spuzmo5yR+hLkftfuE4E3jaxdNPgSH0hJKPcLJBP7yAta87jQNPsSERS59PJjCJ/CorBA==}
- peerDependencies:
- '@types/react': '*'
- react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
- peerDependenciesMeta:
- '@types/react':
- optional: true
-
- magic-string@0.30.21:
- resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
-
- nanoid@3.3.20:
- resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==}
- engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
- hasBin: true
-
- next@16.4.2:
- resolution: {integrity: sha512-r+oBJojfefUjOiWkBhXgLS+igxX/7BWyK59uHnyOyplMSLtBZ+QLP3okrdYTbrfrbrmNTVWLBPaHDX9KRaIt3w==}
- engines: {node: '>=20.9.0'}
- hasBin: true
- peerDependencies:
- '@opentelemetry/api': ^1.1.0
- '@playwright/test': ^1.51.1
- babel-plugin-react-compiler: '*'
- react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
- react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
- sass: ^1.3.0
- peerDependenciesMeta:
- '@opentelemetry/api':
- optional: true
- '@playwright/test':
- optional: true
- babel-plugin-react-compiler:
- optional: true
- sass:
- optional: true
-
- picocolors@1.1.1:
- resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
-
- postcss@8.5.23:
- resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
- engines: {node: ^10 || ^12 || >=14}
-
- postcss@8.5.31:
- resolution: {integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==}
- engines: {node: ^10 || ^12 || >=14}
-
- react-dom@19.3.1:
- resolution: {integrity: sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==}
- peerDependencies:
- react: ^19.3.1
-
- react@19.3.1:
- resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==}
- engines: {node: '>=0.10.0'}
-
- scheduler@0.28.0:
- resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==}
-
- semver@7.8.5:
- resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
- engines: {node: '>=10'}
- hasBin: true
-
- sharp@0.35.5:
- resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==}
- engines: {node: '>=20.9.0'}
- peerDependencies:
- '@types/node': '*'
- peerDependenciesMeta:
- '@types/node':
- optional: true
-
- source-map-js@1.2.2:
- resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
- engines: {node: '>=0.10.0'}
-
- styled-jsx@5.1.6:
- resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
- engines: {node: '>= 12.0.0'}
- peerDependencies:
- '@babel/core': '*'
- babel-plugin-macros: '*'
- react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0'
- peerDependenciesMeta:
- '@babel/core':
- optional: true
- babel-plugin-macros:
- optional: true
-
- tailwindcss@4.3.3:
- resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
-
- tapable@2.3.3:
- resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
- engines: {node: '>=6'}
-
- tslib@2.8.1:
- resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
-
- typescript@5.9.3:
- resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
- engines: {node: '>=14.17'}
- hasBin: true
-
- undici-types@7.24.6:
- resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
-
-snapshots:
-
- '@alloc/quick-lru@5.3.0': {}
-
- '@emnapi/runtime@1.11.3':
- dependencies:
- tslib: 2.8.1
- optional: true
-
- '@img/colour@1.1.0':
- optional: true
-
- '@img/sharp-darwin-arm64@0.35.5':
- optionalDependencies:
- '@img/sharp-libvips-darwin-arm64': 1.3.4
- optional: true
-
- '@img/sharp-darwin-x64@0.35.5':
- optionalDependencies:
- '@img/sharp-libvips-darwin-x64': 1.3.4
- optional: true
-
- '@img/sharp-freebsd-wasm32@0.35.5':
- dependencies:
- '@img/sharp-wasm32': 0.35.5
- optional: true
-
- '@img/sharp-libvips-darwin-arm64@1.3.4':
- optional: true
-
- '@img/sharp-libvips-darwin-x64@1.3.4':
- optional: true
-
- '@img/sharp-libvips-linux-arm64@1.3.4':
- optional: true
-
- '@img/sharp-libvips-linux-arm@1.3.4':
- optional: true
-
- '@img/sharp-libvips-linux-ppc64@1.3.4':
- optional: true
-
- '@img/sharp-libvips-linux-riscv64@1.3.4':
- optional: true
-
- '@img/sharp-libvips-linux-s390x@1.3.4':
- optional: true
-
- '@img/sharp-libvips-linux-x64@1.3.4':
- optional: true
-
- '@img/sharp-libvips-linuxmusl-arm64@1.3.4':
- optional: true
-
- '@img/sharp-libvips-linuxmusl-x64@1.3.4':
- optional: true
-
- '@img/sharp-linux-arm64@0.35.5':
- optionalDependencies:
- '@img/sharp-libvips-linux-arm64': 1.3.4
- optional: true
-
- '@img/sharp-linux-arm@0.35.5':
- optionalDependencies:
- '@img/sharp-libvips-linux-arm': 1.3.4
- optional: true
-
- '@img/sharp-linux-ppc64@0.35.5':
- optionalDependencies:
- '@img/sharp-libvips-linux-ppc64': 1.3.4
- optional: true
-
- '@img/sharp-linux-riscv64@0.35.5':
- optionalDependencies:
- '@img/sharp-libvips-linux-riscv64': 1.3.4
- optional: true
-
- '@img/sharp-linux-s390x@0.35.5':
- optionalDependencies:
- '@img/sharp-libvips-linux-s390x': 1.3.4
- optional: true
-
- '@img/sharp-linux-x64@0.35.5':
- optionalDependencies:
- '@img/sharp-libvips-linux-x64': 1.3.4
- optional: true
-
- '@img/sharp-linuxmusl-arm64@0.35.5':
- optionalDependencies:
- '@img/sharp-libvips-linuxmusl-arm64': 1.3.4
- optional: true
-
- '@img/sharp-linuxmusl-x64@0.35.5':
- optionalDependencies:
- '@img/sharp-libvips-linuxmusl-x64': 1.3.4
- optional: true
-
- '@img/sharp-wasm32@0.35.5':
- dependencies:
- '@emnapi/runtime': 1.11.3
- optional: true
-
- '@img/sharp-webcontainers-wasm32@0.35.5':
- dependencies:
- '@img/sharp-wasm32': 0.35.5
- optional: true
-
- '@img/sharp-win32-arm64@0.35.5':
- optional: true
-
- '@img/sharp-win32-ia32@0.35.5':
- optional: true
-
- '@img/sharp-win32-x64@0.35.5':
- optional: true
-
- '@jridgewell/gen-mapping@0.3.13':
- dependencies:
- '@jridgewell/sourcemap-codec': 1.6.0
- '@jridgewell/trace-mapping': 0.3.31
-
- '@jridgewell/remapping@2.3.5':
- dependencies:
- '@jridgewell/gen-mapping': 0.3.13
- '@jridgewell/trace-mapping': 0.3.31
-
- '@jridgewell/resolve-uri@3.1.2': {}
-
- '@jridgewell/sourcemap-codec@1.6.0': {}
-
- '@jridgewell/trace-mapping@0.3.31':
- dependencies:
- '@jridgewell/resolve-uri': 3.1.2
- '@jridgewell/sourcemap-codec': 1.6.0
-
- '@next/env@16.4.2': {}
-
- '@next/swc-darwin-arm64@16.4.2':
- optional: true
-
- '@next/swc-darwin-x64@16.4.2':
- optional: true
-
- '@next/swc-linux-arm64-gnu@16.4.2':
- optional: true
-
- '@next/swc-linux-arm64-musl@16.4.2':
- optional: true
-
- '@next/swc-linux-x64-gnu@16.4.2':
- optional: true
-
- '@next/swc-linux-x64-musl@16.4.2':
- optional: true
-
- '@next/swc-win32-arm64-msvc@16.4.2':
- optional: true
-
- '@next/swc-win32-x64-msvc@16.4.2':
- optional: true
-
- '@swc/helpers@0.5.23':
- dependencies:
- tslib: 2.8.1
-
- '@tailwindcss/node@4.3.3':
- dependencies:
- '@jridgewell/remapping': 2.3.5
- enhanced-resolve: 5.26.0
- jiti: 2.7.0
- lightningcss: 1.32.0
- magic-string: 0.30.21
- source-map-js: 1.2.2
- tailwindcss: 4.3.3
-
- '@tailwindcss/oxide-android-arm64@4.3.3':
- optional: true
-
- '@tailwindcss/oxide-darwin-arm64@4.3.3':
- optional: true
-
- '@tailwindcss/oxide-darwin-x64@4.3.3':
- optional: true
-
- '@tailwindcss/oxide-freebsd-x64@4.3.3':
- optional: true
-
- '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
- optional: true
-
- '@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
- optional: true
-
- '@tailwindcss/oxide-linux-arm64-musl@4.3.3':
- optional: true
-
- '@tailwindcss/oxide-linux-x64-gnu@4.3.3':
- optional: true
-
- '@tailwindcss/oxide-linux-x64-musl@4.3.3':
- optional: true
-
- '@tailwindcss/oxide-wasm32-wasi@4.3.3':
- optional: true
-
- '@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
- optional: true
-
- '@tailwindcss/oxide-win32-x64-msvc@4.3.3':
- optional: true
-
- '@tailwindcss/oxide@4.3.3':
- optionalDependencies:
- '@tailwindcss/oxide-android-arm64': 4.3.3
- '@tailwindcss/oxide-darwin-arm64': 4.3.3
- '@tailwindcss/oxide-darwin-x64': 4.3.3
- '@tailwindcss/oxide-freebsd-x64': 4.3.3
- '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3
- '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3
- '@tailwindcss/oxide-linux-arm64-musl': 4.3.3
- '@tailwindcss/oxide-linux-x64-gnu': 4.3.3
- '@tailwindcss/oxide-linux-x64-musl': 4.3.3
- '@tailwindcss/oxide-wasm32-wasi': 4.3.3
- '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3
- '@tailwindcss/oxide-win32-x64-msvc': 4.3.3
-
- '@tailwindcss/postcss@4.3.3':
- dependencies:
- '@alloc/quick-lru': 5.3.0
- '@tailwindcss/node': 4.3.3
- '@tailwindcss/oxide': 4.3.3
- postcss: 8.5.31
- tailwindcss: 4.3.3
-
- '@types/node@24.19.2':
- dependencies:
- undici-types: 7.24.6
-
- '@types/react-dom@19.3.0(@types/react@19.3.0)':
- dependencies:
- '@types/react': 19.3.0
-
- '@types/react@19.3.0':
- dependencies:
- csstype: 3.2.3
-
- baseline-browser-mapping@2.11.32: {}
-
- caniuse-lite@1.0.30001819: {}
-
- client-only@0.0.1: {}
-
- csstype@3.2.3: {}
-
- detect-libc@2.1.2: {}
-
- enhanced-resolve@5.26.0:
- dependencies:
- graceful-fs: 4.2.11
- tapable: 2.3.3
-
- graceful-fs@4.2.11: {}
-
- jiti@2.7.0: {}
-
- lightningcss-android-arm64@1.32.0:
- optional: true
-
- lightningcss-darwin-arm64@1.32.0:
- optional: true
-
- lightningcss-darwin-x64@1.32.0:
- optional: true
-
- lightningcss-freebsd-x64@1.32.0:
- optional: true
-
- lightningcss-linux-arm-gnueabihf@1.32.0:
- optional: true
-
- lightningcss-linux-arm64-gnu@1.32.0:
- optional: true
-
- lightningcss-linux-arm64-musl@1.32.0:
- optional: true
-
- lightningcss-linux-x64-gnu@1.32.0:
- optional: true
-
- lightningcss-linux-x64-musl@1.32.0:
- optional: true
-
- lightningcss-win32-arm64-msvc@1.32.0:
- optional: true
-
- lightningcss-win32-x64-msvc@1.32.0:
- optional: true
-
- lightningcss@1.32.0:
- dependencies:
- detect-libc: 2.1.2
- optionalDependencies:
- lightningcss-android-arm64: 1.32.0
- lightningcss-darwin-arm64: 1.32.0
- lightningcss-darwin-x64: 1.32.0
- lightningcss-freebsd-x64: 1.32.0
- lightningcss-linux-arm-gnueabihf: 1.32.0
- lightningcss-linux-arm64-gnu: 1.32.0
- lightningcss-linux-arm64-musl: 1.32.0
- lightningcss-linux-x64-gnu: 1.32.0
- lightningcss-linux-x64-musl: 1.32.0
- lightningcss-win32-arm64-msvc: 1.32.0
- lightningcss-win32-x64-msvc: 1.32.0
-
- lucide-react@1.61.0(@types/react@19.3.0)(react@19.3.1):
- dependencies:
- react: 19.3.1
- optionalDependencies:
- '@types/react': 19.3.0
-
- magic-string@0.30.21:
- dependencies:
- '@jridgewell/sourcemap-codec': 1.6.0
-
- nanoid@3.3.20: {}
-
- next@16.4.2(@types/node@24.19.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1):
- dependencies:
- '@next/env': 16.4.2
- '@swc/helpers': 0.5.23
- baseline-browser-mapping: 2.11.32
- caniuse-lite: 1.0.30001819
- postcss: 8.5.23
- react: 19.3.1
- react-dom: 19.3.1(react@19.3.1)
- styled-jsx: 5.1.6(react@19.3.1)
- optionalDependencies:
- '@next/swc-darwin-arm64': 16.4.2
- '@next/swc-darwin-x64': 16.4.2
- '@next/swc-linux-arm64-gnu': 16.4.2
- '@next/swc-linux-arm64-musl': 16.4.2
- '@next/swc-linux-x64-gnu': 16.4.2
- '@next/swc-linux-x64-musl': 16.4.2
- '@next/swc-win32-arm64-msvc': 16.4.2
- '@next/swc-win32-x64-msvc': 16.4.2
- sharp: 0.35.5(@types/node@24.19.2)
- transitivePeerDependencies:
- - '@babel/core'
- - '@types/node'
- - babel-plugin-macros
-
- picocolors@1.1.1: {}
-
- postcss@8.5.23:
- dependencies:
- nanoid: 3.3.20
- picocolors: 1.1.1
- source-map-js: 1.2.2
-
- postcss@8.5.31:
- dependencies:
- nanoid: 3.3.20
- picocolors: 1.1.1
- source-map-js: 1.2.2
-
- react-dom@19.3.1(react@19.3.1):
- dependencies:
- react: 19.3.1
- scheduler: 0.28.0
-
- react@19.3.1: {}
-
- scheduler@0.28.0: {}
-
- semver@7.8.5:
- optional: true
-
- sharp@0.35.5(@types/node@24.19.2):
- dependencies:
- '@img/colour': 1.1.0
- detect-libc: 2.1.2
- semver: 7.8.5
- optionalDependencies:
- '@img/sharp-darwin-arm64': 0.35.5
- '@img/sharp-darwin-x64': 0.35.5
- '@img/sharp-freebsd-wasm32': 0.35.5
- '@img/sharp-libvips-darwin-arm64': 1.3.4
- '@img/sharp-libvips-darwin-x64': 1.3.4
- '@img/sharp-libvips-linux-arm': 1.3.4
- '@img/sharp-libvips-linux-arm64': 1.3.4
- '@img/sharp-libvips-linux-ppc64': 1.3.4
- '@img/sharp-libvips-linux-riscv64': 1.3.4
- '@img/sharp-libvips-linux-s390x': 1.3.4
- '@img/sharp-libvips-linux-x64': 1.3.4
- '@img/sharp-libvips-linuxmusl-arm64': 1.3.4
- '@img/sharp-libvips-linuxmusl-x64': 1.3.4
- '@img/sharp-linux-arm': 0.35.5
- '@img/sharp-linux-arm64': 0.35.5
- '@img/sharp-linux-ppc64': 0.35.5
- '@img/sharp-linux-riscv64': 0.35.5
- '@img/sharp-linux-s390x': 0.35.5
- '@img/sharp-linux-x64': 0.35.5
- '@img/sharp-linuxmusl-arm64': 0.35.5
- '@img/sharp-linuxmusl-x64': 0.35.5
- '@img/sharp-webcontainers-wasm32': 0.35.5
- '@img/sharp-win32-arm64': 0.35.5
- '@img/sharp-win32-ia32': 0.35.5
- '@img/sharp-win32-x64': 0.35.5
- '@types/node': 24.19.2
- optional: true
-
- source-map-js@1.2.2: {}
-
- styled-jsx@5.1.6(react@19.3.1):
- dependencies:
- client-only: 0.0.1
- react: 19.3.1
-
- tailwindcss@4.3.3: {}
-
- tapable@2.3.3: {}
-
- tslib@2.8.1: {}
-
- typescript@5.9.3: {}
-
- undici-types@7.24.6: {}
diff --git a/apps/studio/postcss.config.mjs b/apps/studio/postcss.config.mjs
deleted file mode 100644
index a2dc18a..0000000
--- a/apps/studio/postcss.config.mjs
+++ /dev/null
@@ -1,3 +0,0 @@
-export default {
- plugins: { "@tailwindcss/postcss": {} },
-};
diff --git a/apps/studio/src/lib/canvas-document.ts b/apps/studio/src/lib/canvas-document.ts
deleted file mode 100644
index d170267..0000000
--- a/apps/studio/src/lib/canvas-document.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-export type NodeKind = 'text' | 'image' | 'prompt' | 'script';
-export type TextStyle = {bold?:boolean;italic?:boolean;color?:string;size?:number};
-export type CanvasNode = {
- id:string;kind:NodeKind;title:string;text:string;x:number;y:number;
- image?:string;width?:number;height?:number;style?:TextStyle;
- instruction?:string;
- operation?:'generate'|'upscale'|'script';
-};
-export type CanvasDocument = {version:1;cards:CanvasNode[];links:[string,string][]};
-const kinds:NodeKind[]=['text','image','prompt','script'];
-const object=(v:unknown):v is Record=>!!v&&typeof v==='object'&&!Array.isArray(v);
-const bounded=(v:unknown,min:number,max:number)=>typeof v==='number'&&Number.isFinite(v)&&v>=min&&v<=max;
-const text=(v:unknown,max:number)=>typeof v==='string'&&v.length<=max;
-const image=(v:unknown)=>typeof v==='string'&&v.length<3000000&&/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(v);
-
-export function parseCanvasDocument(value:unknown):CanvasDocument {
- const d=typeof value==='string'?JSON.parse(value):value;
- if(!object(d)||d.version!==1||!Array.isArray(d.cards)||d.cards.length>80||!Array.isArray(d.links)||d.links.length>200)throw Error('画布格式不正确');
- const ids=new Set();
- const cards:CanvasNode[]=d.cards.map((n:unknown)=>{
- if(!object(n)||!text(n.id,100)||!n.id||ids.has(n.id as string)||!kinds.includes(n.kind as NodeKind)||!text(n.title,200)||!text(n.text,10000)||!bounded(n.x,-100000,100000)||!bounded(n.y,-100000,100000))throw Error('节点格式不正确');
- if(n.width!==undefined&&!bounded(n.width,180,1200)||n.height!==undefined&&!bounded(n.height,160,1200)||n.image!==undefined&&!image(n.image))throw Error('节点尺寸或图片不正确');
- if(n.instruction!==undefined&&!text(n.instruction,10000))throw Error('创作要求格式不正确');
- if(n.operation!==undefined&&!['generate','upscale','script'].includes(n.operation as string))throw Error('生成任务类型不正确');
- let style:TextStyle|undefined;
- if(n.style!==undefined){
- if(!object(n.style)||n.style.bold!==undefined&&typeof n.style.bold!=='boolean'||n.style.italic!==undefined&&typeof n.style.italic!=='boolean'||n.style.color!==undefined&&(typeof n.style.color!=='string'||!/^#[a-f\d]{6}$/i.test(n.style.color))||n.style.size!==undefined&&!bounded(n.style.size,10,72))throw Error('文字样式不正确');
- style={...(n.style.bold===undefined?{}:{bold:n.style.bold as boolean}),...(n.style.italic===undefined?{}:{italic:n.style.italic as boolean}),...(n.style.color===undefined?{}:{color:n.style.color as string}),...(n.style.size===undefined?{}:{size:n.style.size as number})};
- }
- ids.add(n.id as string);
- return {id:n.id as string,kind:n.kind as NodeKind,title:n.title as string,text:n.text as string,x:n.x as number,y:n.y as number,...(n.width===undefined?{}:{width:n.width as number}),...(n.height===undefined?{}:{height:n.height as number}),...(n.image===undefined?{}:{image:n.image as string}),...(style?{style}:{}),...(n.instruction===undefined?{}:{instruction:n.instruction as string}),...(n.operation===undefined?{}:{operation:n.operation as CanvasNode['operation']})};
- });
- const pairs=new Set();
- const links:[string,string][]=d.links.map((l:unknown)=>{
- if(!Array.isArray(l)||l.length!==2||l[0]===l[1]||!l.every(id=>typeof id==='string'&&ids.has(id)))throw Error('引用节点不存在');
- const key=JSON.stringify(l);if(pairs.has(key))throw Error('引用重复');pairs.add(key);return [l[0],l[1]];
- });
- const outgoing=new Map(),indegree=new Map(cards.map(n=>[n.id,0]));
- for(const [from,to] of links){outgoing.set(from,[...(outgoing.get(from)||[]),to]);indegree.set(to,indegree.get(to)!+1)}
- const roots=cards.filter(n=>indegree.get(n.id)===0).map(n=>n.id);let visited=0;
- while(roots.length){const id=roots.pop()!;visited++;for(const to of outgoing.get(id)||[]){const count=indegree.get(to)!-1;indegree.set(to,count);if(!count)roots.push(to)}}
- if(visited!==cards.length)throw Error('引用不能形成循环');
- return {version:1,cards,links};
-}
-
-export function referencedNodes(doc:CanvasDocument,target:string):CanvasNode[]{
- return doc.links.filter(([,to])=>to===target).map(([from])=>doc.cards.find(n=>n.id===from)!).filter(Boolean);
-}
-export function connectNodes(doc:CanvasDocument,from:string,to:string):CanvasDocument {
- if(from===to||![from,to].every(id=>doc.cards.some(n=>n.id===id)))throw Error('请选择不同的有效节点');
- if(doc.links.some(([a,b])=>a===from&&b===to))return doc;
- if(doc.links.length>=200)throw Error('最多 200 条引用');
- const seen=new Set();const pending=[to];
- while(pending.length){const id=pending.pop()!;if(id===from)throw Error('引用不能形成循环');if(seen.has(id))continue;seen.add(id);pending.push(...doc.links.filter(([a])=>a===id).map(([,b])=>b));}
- return {...doc,links:[...doc.links,[from,to]]};
-}
-export function removeCanvasNode(doc:CanvasDocument,id:string):CanvasDocument {
- return {...doc,cards:doc.cards.filter(n=>n.id!==id),links:doc.links.filter(l=>!l.includes(id))};
-}
-export function deriveNode(doc:CanvasDocument,source:string,node:CanvasNode):CanvasDocument {
- if(doc.cards.length>=80||doc.cards.some(n=>n.id===node.id))throw Error('节点数量或标识不正确');
- const target=parseCanvasDocument({version:1,cards:[node],links:[]}).cards[0];
- for(let attempt=0;attempt<=doc.cards.length+1;attempt++){
- const blockers=doc.cards.filter(n=>target.xn.x&&target.yn.y);
- if(!blockers.length)return parseCanvasDocument(connectNodes({...doc,cards:[...doc.cards,target]},source,target.id));
- const nextY=Math.max(...blockers.map(n=>n.y+(n.height||350)+64));
- target.y=nextY>100000?-100000:nextY;
- }
- throw Error('请移动现有节点后再创建引用节点');
-}
-export function agentTask(doc:CanvasDocument,id:string){
- const node=doc.cards.find(n=>n.id===id);if(!node)throw Error('目标节点不存在');
- const instruction=node.instruction??node.text;
- if(!instruction.trim()&&node.operation!=='upscale')throw Error('请先填写创作要求');
- const references=referencedNodes(doc,id);
- if(node.operation==='upscale'&&!references.some(n=>n.kind==='image'&&n.image))throw Error('请先引用一张图片');
- return {schema:'studio.canvas-task/1',target:{id:node.id,kind:node.kind,title:node.title},operation:node.operation||'generate',instruction,...(node.image?{sourceImage:node.image}:{}),references:references.map(n=>({id:n.id,kind:n.kind,title:n.title,text:n.text,...(n.image?{image:n.image}:{}),...(n.style?{style:n.style}:{})})),result:{targetId:node.id,allowedFields:node.kind==='image'?['image','text']:['text']}};
-}
diff --git a/apps/studio/src/lib/layout-parameters.ts b/apps/studio/src/lib/layout-parameters.ts
deleted file mode 100644
index f6125b0..0000000
--- a/apps/studio/src/lib/layout-parameters.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-export type Slot={id:string;kind:string;role:string;box:number[];direction:string;text:string|null;reading:string};
-export type Layout={id:string;name:string;width:number;height:number;elements:Slot[]};
-export type TextValue={value:string;color?:string;scale?:number;align?:'start'|'middle'|'end';fontFamily?:'sans'|'serif'|'mono';fontSize?:number;fontWeight?:number;lineHeight?:number;letterSpacing?:number};
-export type ImageValue={assetId?:string;color?:string;fit?:'cover'|'contain';cropX?:number;cropY?:number;zoom?:number};
-export type DecorationValue={color?:string;hidden?:boolean};
-export type LayerValue={x?:number;y?:number;width?:number;height?:number;rotation?:number;opacity?:number;radius?:number;z?:number;hidden?:boolean;locked?:boolean};
-export type AddedSlot=Slot & {shape?:'rectangle'|'ellipse'};
-export type Parameters={texts:Record;images:Record;decorations:Record;paper?:string;layers?:Record;added?:AddedSlot[]};
-export class LayoutParameterError extends Error{}
-export const uuid=(v:unknown):v is string=>typeof v==='string'&&/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v);
-function fail(message:string):never{throw new LayoutParameterError(message)}
-function object(v:unknown):Record{if(!v||typeof v!=='object'||Array.isArray(v))fail('参数必须为 JSON 对象');return v as Record}
-function keys(v:Record,allowed:string[]){if(Object.keys(v).some(k=>!allowed.includes(k)))fail('未知参数或构件 ID')}
-function number(v:unknown,min:number,max:number){if(typeof v!=='number'||!Number.isFinite(v)||vmax)fail(`数值范围 ${min}–${max}`);return v}
-function color(v:unknown){if(typeof v!=='string'||!/^#[a-f0-9]{6}$/i.test(v))fail('颜色使用 #RRGGBB');return v}
-export function parameters(layout:Layout,input:unknown):Parameters{
- const raw=object(input);keys(raw,['texts','images','decorations','paper','layers','added']);const result:Parameters={texts:{},images:{},decorations:{}};
- const added:AddedSlot[]=[];
- if(raw.added!==undefined){if(!Array.isArray(raw.added)||raw.added.length>100)fail('最多新增100个元素');for(const entry of raw.added){const v=object(entry);keys(v,['id','kind','role','box','direction','text','reading','shape']);if(typeof v.id!=='string'||!/^user-[a-zA-Z0-9-]{1,60}$/.test(v.id)||added.some(s=>s.id===v.id))fail('新增元素 ID 无效或重复');if(!['text','image','decoration'].includes(String(v.kind)))fail('新增元素类型无效');if(typeof v.role!=='string'||v.role.length>80)fail('图层名称最多80字');if(!Array.isArray(v.box)||v.box.length!==4)fail('box 为 x,y,width,height');const box=v.box.map((n,i)=>number(n,i<2?-10000:.1,10000));if(v.direction!=='horizontal'||v.reading!=='custom'||(v.text!==null&&(typeof v.text!=='string'||v.text.length>500)))fail('新增元素内容无效');if(v.shape!==undefined&&!['rectangle','ellipse'].includes(String(v.shape)))fail('无效形状');added.push({id:v.id,kind:String(v.kind),role:v.role,box,direction:'horizontal',text:v.text as string|null,reading:'custom',...(v.shape?{shape:v.shape as AddedSlot['shape']}:{})})}result.added=added}
- const elements=[...layout.elements,...added];
- if(raw.layers!==undefined){const entries=object(raw.layers);keys(entries,elements.map(e=>e.id));result.layers={};for(const [id,entry] of Object.entries(entries)){const v=object(entry);keys(v,['x','y','width','height','rotation','opacity','radius','z','hidden','locked']);const layer:Record={};for(const [key,value] of Object.entries(v)){if(key==='hidden'||key==='locked'){if(typeof value!=='boolean')fail('图层开关必须为布尔值');layer[key]=value}else{const range:Record={x:[-10000,10000],y:[-10000,10000],width:[.1,10000],height:[.1,10000],rotation:[-360,360],opacity:[0,1],radius:[0,5000],z:[-1000,1000]};layer[key]=number(value,range[key][0],range[key][1])}}result.layers[id]=layer}}
- if(raw.paper!==undefined)result.paper=color(raw.paper);
- for(const [group,kind] of [['texts','text'],['images','image'],['decorations','decoration']] as const){
- const entries=object(raw[group]??{});keys(entries,elements.filter(e=>e.kind===kind).map(e=>e.id));
- for(const [id,value] of Object.entries(entries)){const v=object(value);
- if(group==='texts'){keys(v,['value','color','scale','align','fontFamily','fontSize','fontWeight','lineHeight','letterSpacing']);if(typeof v.value!=='string'||Array.from(v.value).length>500||v.value.split('\n').length>8)fail('文字最多 500 字 / 8 行');const t:TextValue={value:v.value};if(v.color!==undefined)t.color=color(v.color);if(v.scale!==undefined){if(typeof v.scale!=='number'||!Number.isFinite(v.scale)||v.scale<.25||v.scale>2)fail('scale 范围 0.25–2');t.scale=v.scale}if(v.align!==undefined){if(!['start','middle','end'].includes(String(v.align)))fail('无效 align');t.align=v.align as TextValue['align']}if(v.fontFamily!==undefined){if(!['sans','serif','mono'].includes(String(v.fontFamily)))fail('无效字体');t.fontFamily=v.fontFamily as TextValue['fontFamily']}for(const [key,min,max] of [['fontSize',.1,1000],['fontWeight',100,900],['lineHeight',.5,4],['letterSpacing',-20,100]] as const)if(v[key]!==undefined)t[key]=number(v[key],min,max);result.texts[id]=t;
- }else if(group==='images'){keys(v,['assetId','color','fit','cropX','cropY','zoom']);const image:ImageValue={};if(v.assetId!==undefined){if(!uuid(v.assetId))fail('assetId 必须为已上传图片 UUID');image.assetId=v.assetId}if(v.color!==undefined)image.color=color(v.color);if(v.fit!==undefined){if(v.fit!=='cover'&&v.fit!=='contain')fail('fit 使用 cover 或 contain');image.fit=v.fit}for(const [key,min,max] of [['cropX',0,100],['cropY',0,100],['zoom',1,5]] as const)if(v[key]!==undefined)image[key]=number(v[key],min,max);result.images[id]=image;
- }else{keys(v,['color','hidden']);const d:DecorationValue={};if(v.color!==undefined)d.color=color(v.color);if(v.hidden!==undefined){if(typeof v.hidden!=='boolean')fail('hidden 必须为布尔值');d.hidden=v.hidden}result.decorations[id]=d}
- }
- }
- return result;
-}
diff --git a/apps/studio/src/lib/series.ts b/apps/studio/src/lib/series.ts
deleted file mode 100644
index 214d425..0000000
--- a/apps/studio/src/lib/series.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import {parameters,LayoutParameterError,type Layout,type Parameters,type TextValue,uuid} from './layout-parameters';
-export type Typography=Required>;
-export type Series={version:1;brief:{topic:string;audience:string;intent:string;designIntent:string;imageStrategy:string};style:{version:string;palette:{paper:string;ink:string;muted:string;accent:string};typography:Record<'title'|'body'|'caption',Typography>;spacing:{margin:number;gap:number}};sources:{assetId:string;author:string;license:string;url:string;prompt:string;tool:string}[];pages:{key:string;role:'cover'|'body'|'ending';layoutId:string;intent:string;bindings:Record;parameters:Parameters}[]};
-function fail(message:string):never{throw new LayoutParameterError(message)}
-const obj=(v:unknown)=>{if(!v||typeof v!=='object'||Array.isArray(v))return fail('作品字段必须是对象');return v as Record};
-function exact(v:unknown,allowed:string[]){const o=obj(v);if(Object.keys(o).some(k=>!allowed.includes(k)))fail('作品包含未知字段');return o}
-function str(v:unknown,max=1000){if(typeof v!=='string'||!v.trim()||v.length>max)fail('作品文字缺失或超长');return v as string}
-function num(v:unknown,min:number,max:number){if(typeof v!=='number'||!Number.isFinite(v)||vmax)fail('作品数值超出范围');return v as number}
-/** Closed, bounded content document. The same validator runs in persistence and SSR. */
-export function series(input:unknown,layouts:Layout[]):Series{
- const raw=exact(input,['version','brief','style','sources','pages']);if(raw.version!==1)fail('不支持的作品版本');
- const brief=exact(raw.brief,['topic','audience','intent','designIntent','imageStrategy']);for(const k of ['topic','audience','intent','designIntent','imageStrategy'])str(brief[k],2000);
- const style=exact(raw.style,['version','palette','typography','spacing']);str(style.version,80);
- const palette=exact(style.palette,['paper','ink','muted','accent']);for(const k of ['paper','ink','muted','accent'])if(!/^#[a-f\d]{6}$/i.test(String(palette[k])))fail('作品颜色使用 #RRGGBB');
- const typography=exact(style.typography,['title','body','caption']);
- for(const role of ['title','body','caption']){const t=exact(typography[role],['fontFamily','fontSize','fontWeight','lineHeight','letterSpacing']);if(!['sans','serif','mono'].includes(String(t.fontFamily)))fail('无效作品字体');num(t.fontSize,.1,40);num(t.fontWeight,100,900);num(t.lineHeight,.5,4);num(t.letterSpacing,-5,10)}
- const spacing=exact(style.spacing,['margin','gap']);num(spacing.margin,4,16);num(spacing.gap,1,10);if(Number(spacing.margin)*2+Number(spacing.gap)*4>48)fail('页边距与间距组合过大');
- if(!Array.isArray(raw.sources)||raw.sources.length>100)fail('素材来源最多100条');
- const sources=raw.sources.map(v=>{const s=exact(v,['assetId','author','license','url','prompt','tool']);if(!uuid(str(s.assetId,36)))fail('来源需素材 UUID');for(const k of ['author','license','url','prompt','tool'])str(s[k],4000);if(s.url!=='generated'&&!/^https?:\/\//.test(String(s.url)))fail('来源使用 http(s) 地址或 generated');return s as Series['sources'][number]});
- if(!Array.isArray(raw.pages)||raw.pages.length<3||raw.pages.length>18)fail('作品需要3–18页');
- const seen=new Set();
- const pages=raw.pages.map((entry,index)=>{const p=exact(entry,['key','role','layoutId','intent','bindings','parameters']);const key=str(p.key,60);if(!/^[a-z0-9-]+$/.test(key)||seen.has(key))fail('页面 key 必须唯一');seen.add(key);
- const role=index===0?'cover':index===(raw.pages as unknown[]).length-1?'ending':'body';if(p.role!==role)fail('页序须为封面、正文、结尾');str(p.intent,1000);const layout=layouts.find(l=>l.id===p.layoutId);if(!layout)fail('作品模板不存在');const resolvedLayout=layout!;const params=parameters(resolvedLayout,p.parameters);const bindings=obj(p.bindings);const slots=[...resolvedLayout.elements,...(params.added||[])];for(const [id,token] of Object.entries(bindings)){if(!slots.some(s=>s.id===id&&s.kind==='text')||!['title','body','caption'].includes(String(token)))fail('字体绑定无效');if(!params.texts[id])fail('绑定文字必须提供文案')}
- // Every visible text is authored; template placeholder copy must never leak into a finished series.
- for(const s of slots)if(s.kind==='text'&&!params.layers?.[s.id]?.hidden&&!params.texts[s.id])fail('请填写或隐藏所有文字元素');
- for(const image of Object.values(params.images))if(image.assetId&&!sources.some(s=>s.assetId===image.assetId))fail('每张图片需记录作者、许可和生成过程');
- return {...p,parameters:params,bindings} as Series['pages'][number]});
- return {version:1,brief:brief as Series['brief'],style:style as Series['style'],sources,pages};
-}
-/** Style units use a 120-wide reference canvas; explicit element values always win. */
-export function pageParameters(doc:Series,page:Series['pages'][number],layout:Layout):Parameters{
- const p=page.parameters,s=doc.style,k=layout.width/120;
- const result:Parameters={...p,paper:p.paper||s.palette.paper,texts:{...p.texts},images:{...p.images},decorations:{...p.decorations},layers:{...p.layers}};
- for(const [id,token] of Object.entries(page.bindings)){const t=s.typography[token];result.texts[id]={...t,fontSize:t.fontSize*k,letterSpacing:t.letterSpacing*k,color:token==='caption'?s.palette.muted:s.palette.ink,...p.texts[id]}}
- for(const slot of [...layout.elements,...(p.added||[])]){if(slot.kind==='image')result.images[slot.id]={color:s.palette.accent,...p.images[slot.id]};if(slot.kind==='decoration')result.decorations[slot.id]={color:s.palette.accent,...p.decorations[slot.id]}}
- if(layout.id==='standard-01'){
- const m=s.spacing.margin,g=s.spacing.gap,w=120-2*m;
- const boxes:Record={T03:[m,m,w,4],T01:[m,m+4+g,w,28],I01:[m,m+32+2*g,w,50],T02:[m,m+82+3*g,w,27],D01:[m,160-m,w,.4]};
- for(const [id,[x,y,width,height]] of Object.entries(boxes))result.layers![id]={x,y,width,height,...p.layers?.[id]};
- }
- return result;
-}
diff --git a/apps/studio/src/pages/_app.tsx b/apps/studio/src/pages/_app.tsx
deleted file mode 100644
index 680063e..0000000
--- a/apps/studio/src/pages/_app.tsx
+++ /dev/null
@@ -1,8 +0,0 @@
-import { useEffect } from 'react';
-import { useRouter } from 'next/router';
-import type { AppProps } from "next/app";
-import "../styles/globals.css";
-import "../styles/brand.css";
-export default function App({Component,pageProps}:AppProps){const router = useRouter();
- useEffect(() => { const scope = /^\/(?:graphic|infinite_canvas|layouts|template-layout)(?:\/|$)/.test(router.pathname) ? 'canvas' : 'home'; document.documentElement.dataset.theme = `${scope}-${document.documentElement.dataset.mode === 'dark' ? 'dark' : 'light'}`; }, [router.pathname]);
- return ;}
diff --git a/apps/studio/src/pages/_document.tsx b/apps/studio/src/pages/_document.tsx
deleted file mode 100644
index 68e90da..0000000
--- a/apps/studio/src/pages/_document.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-import { Html, Head, Main, NextScript } from 'next/document';
-const initializeTheme = String.raw`(function(){try{var mode=localStorage.getItem('endoleap.theme')==='dark'?'dark':'light';var canvas=/^\/(?:studio\/)?(?:graphic|infinite_canvas|layouts|template-layout)(?:\/|$)/.test(location.pathname);document.documentElement.dataset.mode=mode;document.documentElement.dataset.theme=(canvas?'canvas':'home')+'-'+mode;}catch(e){}})();`;
-export default function Document() { return ',x:20,y:20,w:300,h:100,fill:'#123456',size:32,hidden:false,locked:false};
const doc={name:'作品',background:'#ffffff',items:[item]};
test('graphic backup restores editable text and ignores unrelated properties',()=>{assert.deepEqual(parseGraphicDocument(JSON.stringify({...doc,extra:'ignored'})),doc)});
diff --git a/apps/studio/tests/layout-images.test.mjs b/apps/waterfall-wiki/tests/studio/layout-images.test.mjs
similarity index 96%
rename from apps/studio/tests/layout-images.test.mjs
rename to apps/waterfall-wiki/tests/studio/layout-images.test.mjs
index a091b3f..873562d 100644
--- a/apps/studio/tests/layout-images.test.mjs
+++ b/apps/waterfall-wiki/tests/studio/layout-images.test.mjs
@@ -1,6 +1,6 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
-import {embedSvgMedia} from '../src/lib/layout-images.ts';
+import {embedSvgMedia} from '../../src/studio/lib/layout-images.ts';
const id='a05dc841-240a-5a3a-b01b-d589fc21cd36';
test('SVG export fetches each internal media once with the caller authentication',async()=>{
const original=globalThis.fetch;let calls=0;
diff --git a/apps/studio/tests/layout-parameters.test.mjs b/apps/waterfall-wiki/tests/studio/layout-parameters.test.mjs
similarity index 70%
rename from apps/studio/tests/layout-parameters.test.mjs
rename to apps/waterfall-wiki/tests/studio/layout-parameters.test.mjs
index 3185fba..05bf05c 100644
--- a/apps/studio/tests/layout-parameters.test.mjs
+++ b/apps/waterfall-wiki/tests/studio/layout-parameters.test.mjs
@@ -1,7 +1,7 @@
import {test} from 'node:test';import assert from 'node:assert/strict';import {readFileSync} from 'node:fs';
-import {parameters} from '../src/lib/layout-parameters.ts';
-const layouts=JSON.parse(readFileSync(new URL('../src/content/templates/layouts.json',import.meta.url)));
+import {parameters} from '../../src/studio/lib/layout-parameters.ts';
+const layouts=JSON.parse(readFileSync(new URL('../../src/studio/content/templates/layouts.json',import.meta.url)));
test('27 layouts preserve unique typed source slots and complete shapes',()=>{assert.equal(layouts.length,27);for(const [i,l] of layouts.entries()){assert.equal(l.id,'ref-'+String(i+1).padStart(2,'0'));assert.equal(new Set(l.elements.map(s=>s.id)).size,l.elements.length);for(const s of l.elements){assert.match(s.id,/^[TID]\d{2}$/);assert.equal(s.kind,({T:'text',I:'image',D:'decoration'})[s.id[0]]);assert.ok(s.box[0]>=0&&s.box[1]>=0&&s.box[0]+s.box[2]<=l.width&&s.box[1]+s.box[3]<=l.height);assert.match(s.raster,/^\/api\/media\/[a-f0-9-]{36}$/);if(s.kind==='image')assert.match(s.shape,/^\/api\/media\/[a-f0-9-]{36}$/)}assert.deepEqual(parameters(l,{}),{texts:{},images:{},decorations:{}})}});
test('parameters reject mismatched slots, unsafe URLs and excessive text',()=>{const l=layouts[0];for(const input of [{texts:{I01:{value:'bad'}}},{images:{I01:{url:'https://evil.test'}}},{texts:{T01:{value:'x',scale:3}}},{texts:{T01:{value:'x'.repeat(501)}}},{paper:'url(x)'},{decorations:{D01:{hidden:'true'}}},null,[]])assert.throws(()=>parameters(l,input));const p=parameters(l,{texts:{T01:{value:'',scale:1.2,align:'middle'}},images:{I01:{color:'#abcdef',fit:'contain'}},decorations:{D01:{hidden:true}}});assert.equal(p.texts.T01.value,'');assert.equal(p.decorations.D01.hidden,true)});
-test('workspace parameters round-trip identically through Studio and Wiki validation',async()=>{const {parameters:wiki}=await import('../../waterfall-wiki/src/lib/layout-parameters.ts');for(const l of layouts){const added={id:'user-example',kind:'text',role:'Added text',box:[3,4,20,12],direction:'horizontal',text:'Hello',reading:'custom'};const input={added:[added],layers:{T01:{x:2,y:4,width:15,height:10,rotation:25,opacity:.7,radius:2,z:10,hidden:false,locked:true},'user-example':{x:8,z:20}},texts:{T01:{value:'Updated',fontFamily:'serif',fontSize:4,fontWeight:700,lineHeight:1.6,letterSpacing:.2},'user-example':{value:'Hello'}},images:{I01:{fit:'cover',cropX:15,cropY:85,zoom:2}}};assert.deepEqual(parameters(l,input),wiki(l,input));assert.deepEqual(parameters(l,parameters(l,input)),parameters(l,input))}});
+test('workspace parameters round-trip identically through Studio and Wiki validation',async()=>{const {parameters:wiki}=await import('../../src/lib/layout-parameters.ts');for(const l of layouts){const added={id:'user-example',kind:'text',role:'Added text',box:[3,4,20,12],direction:'horizontal',text:'Hello',reading:'custom'};const input={added:[added],layers:{T01:{x:2,y:4,width:15,height:10,rotation:25,opacity:.7,radius:2,z:10,hidden:false,locked:true},'user-example':{x:8,z:20}},texts:{T01:{value:'Updated',fontFamily:'serif',fontSize:4,fontWeight:700,lineHeight:1.6,letterSpacing:.2},'user-example':{value:'Hello'}},images:{I01:{fit:'cover',cropX:15,cropY:85,zoom:2}}};assert.deepEqual(parameters(l,input),wiki(l,input));assert.deepEqual(parameters(l,parameters(l,input)),parameters(l,input))}});
test('workspace validation rejects unbounded, mismatched and executable properties',()=>{const l=layouts[0];for(const input of [{layers:{unknown:{x:2}}},{layers:{T01:{rotation:Infinity}}},{layers:{T01:{width:0}}},{layers:{T01:{opacity:2}}},{layers:{T01:{locked:'yes'}}},{texts:{T01:{value:'x',fontFamily:'url(evil)'}}},{images:{I01:{cropX:101}}},{images:{I01:{zoom:0}}},{added:[{id:'T01'}]},{added:Array(101).fill({})},{layers:{T01:{onClick:'evil'}}}])assert.throws(()=>parameters(l,input))});
diff --git a/apps/studio/tests/native-layout.test.mjs b/apps/waterfall-wiki/tests/studio/native-layout.test.mjs
similarity index 91%
rename from apps/studio/tests/native-layout.test.mjs
rename to apps/waterfall-wiki/tests/studio/native-layout.test.mjs
index b4e3f29..578b2a9 100644
--- a/apps/studio/tests/native-layout.test.mjs
+++ b/apps/waterfall-wiki/tests/studio/native-layout.test.mjs
@@ -1,8 +1,8 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
-import {nativeLayout,defaultText,textSize,imageShape,designUrl,newElementId} from '../src/lib/native-layout.ts';
-const layouts=JSON.parse(readFileSync(new URL('../src/content/templates/layouts.json',import.meta.url)));
+import {nativeLayout,defaultText,textSize,imageShape,designUrl,newElementId} from '../../src/studio/lib/native-layout.ts';
+const layouts=JSON.parse(readFileSync(new URL('../../src/studio/content/templates/layouts.json',import.meta.url)));
test('all 27 native pages are independent of source images and supply actual text',()=>{
for(const original of layouts){const layout=nativeLayout(original),serialized=JSON.stringify(layout);assert.ok(!serialized.includes('data:image'));assert.ok(!serialized.includes('raster'));assert.ok(!serialized.includes('source'));assert.ok(serialized.length<7000);
for(const slot of layout.elements.filter(s=>s.kind==='text')){assert.ok(defaultText(layout,slot).length);for(const text of [defaultText(layout,slot),'','新的文字\n第二行','']){const size=textSize(layout,slot,text);assert.ok(Number.isFinite(size)&&size>0)}}
diff --git a/apps/studio/tests/series.test.mjs b/apps/waterfall-wiki/tests/studio/series.test.mjs
similarity index 77%
rename from apps/studio/tests/series.test.mjs
rename to apps/waterfall-wiki/tests/studio/series.test.mjs
index f565f95..c3a226f 100644
--- a/apps/studio/tests/series.test.mjs
+++ b/apps/waterfall-wiki/tests/studio/series.test.mjs
@@ -3,10 +3,9 @@ import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {registerHooks} from 'node:module';
registerHooks({resolve(specifier,context,next){try{return next(specifier,context)}catch(e){if(e.code==='ERR_MODULE_NOT_FOUND'&&specifier.startsWith('.'))return next(specifier+'.ts',context);throw e}}});
-const {series,pageParameters}=await import('../src/lib/series.ts');
-const layout=JSON.parse(readFileSync(new URL('../src/content/standard-layout.json',import.meta.url)));
+const {series,pageParameters}=await import('../../src/studio/lib/series.ts');
+const layout=JSON.parse(readFileSync(new URL('../../src/studio/content/standard-layout.json',import.meta.url)));
// Fixture copied into the CI image; no build is performed by these tests.
-const input=JSON.parse(readFileSync(new URL('../fixtures/autumn-journal.json',import.meta.url)));
+const input=JSON.parse(readFileSync(new URL('../../fixtures/studio/autumn-journal.json',import.meta.url)));
test('a complete three-page document preserves copy and resolves shared visual tokens',()=>{const d=series(input.document,[layout]);assert.equal(d.pages.length,3);for(const page of d.pages){const p=pageParameters(d,page,layout);assert.equal(p.paper,d.style.palette.paper);assert.equal(p.texts.T01.fontFamily,'serif');assert.equal(p.texts.T01.value,page.parameters.texts.T01.value);assert.equal(p.layers.T01.x,10)}const change=structuredClone(d);change.style.palette.ink='#112233';change.style.spacing.margin=8;for(const page of change.pages){const p=pageParameters(change,page,layout);assert.equal(p.texts.T02.color,'#112233');assert.equal(p.layers.T01.x,8)}change.pages[1].parameters.texts.T01.color='#ABCDEF';assert.equal(pageParameters(change,change.pages[1],layout).texts.T01.color,'#ABCDEF')});
test('invalid page order, stale template placeholders, untracked images and unsafe fields fail',()=>{for(const mutate of [d=>d.pages.reverse(),d=>d.pages[1].key=d.pages[0].key,d=>delete d.pages[0].parameters.texts.T02,d=>d.pages[0].parameters.images.I01.assetId='f76134e7-6e4f-4261-b90c-3c523dc16307',d=>d.style.palette.ink='url(evil)',d=>d.style.spacing.gap=10,d=>d.pages[0].bindings.I01='title',d=>d.pages[0].parameters.images.I01.url='https://evil.test']){const d=structuredClone(input.document);mutate(d);assert.throws(()=>series(d,[layout]))}});
-test('persistence and renderer use byte-identical series contracts',()=>assert.equal(readFileSync(new URL('../src/lib/series.ts',import.meta.url),'utf8'),readFileSync(new URL('../../waterfall-wiki/src/lib/series.ts',import.meta.url),'utf8')));
diff --git a/apps/waterfall-wiki/tsconfig.json b/apps/waterfall-wiki/tsconfig.json
index 6d66853..1969bc3 100644
--- a/apps/waterfall-wiki/tsconfig.json
+++ b/apps/waterfall-wiki/tsconfig.json
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
- "lib": ["dom", "dom.iterable", "esnext"],
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -14,8 +18,20 @@
"jsx": "react-jsx",
"incremental": true,
"baseUrl": ".",
- "paths": { "@/*": ["./src/*"] }
+ "paths": {
+ "@/*": [
+ "./src/*"
+ ]
+ },
+ "allowImportingTsExtensions": true
},
- "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
- "exclude": ["node_modules"]
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts"
+ ],
+ "exclude": [
+ "node_modules"
+ ]
}
diff --git a/compose.creative-review.yml b/compose.creative-review.yml
deleted file mode 100644
index c5fbd80..0000000
--- a/compose.creative-review.yml
+++ /dev/null
@@ -1,20 +0,0 @@
-# Added only by isolated CI integration; no production ports or volumes.
-services:
- prompt-studio:
- image: ${STUDIO_REVIEW_IMAGE:?Build the Studio review image first}
- init: true
- environment:
- HOSTNAME: 0.0.0.0
- PORT: "3000"
- STUDIO_WIKI_ORIGIN: http://waterfall-wiki:3000
- security_opt:
- - no-new-privileges:true
- cap_drop: [ALL]
- healthcheck:
- test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/studio/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
- interval: 5s
- timeout: 8s
- retries: 24
- tests:
- environment:
- VERIFY_CANVAS_UI: "1"
diff --git a/docs/docker-build-cache.md b/docs/docker-build-cache.md
new file mode 100644
index 0000000..b238571
--- /dev/null
+++ b/docs/docker-build-cache.md
@@ -0,0 +1,23 @@
+# Docker 构建上下文与缓存
+
+## 入口与忽略规则
+
+所有 CI 构建命令都在仓库根目录使用 `.` 作为 context,包括以 `-f` 选择的子项目 Dockerfile。统一使用根 `.dockerignore`,递归排除依赖目录、Git 元数据、本机环境文件、编译输出、测试证据及缓存;保留 `.env.example` 供 Compose 校验使用。
+
+普通的子目录 `.dockerignore` 不会自动叠加到根 context。当前 runner 使用 legacy builder,不依赖 Dockerfile 专属忽略文件或 BuildKit cache mount。若以后改用子目录 context,必须同时调整共享包与跨应用文件的输入路径,不能只在子目录添加忽略文件。
+
+## 依赖层与源码层
+
+- Wiki 的根 Dockerfile 是实际 CI 入口,`apps/waterfall-wiki/Dockerfile` 是对应副本,两者的功能内容保持同步;根文件额外保留镜像生命周期标签。
+- 统一 Web 应用先根据锁文件与 workspace 配置执行 `pnpm fetch`,再复制清单并 `pnpm install --offline --frozen-lockfile`。修改 scripts 或品牌源码不会使下载层失效;基础镜像、pnpm 版本、平台或配置变化,以及 Docker 缓存清除仍可能要求重新下载。
+- Python 从 `pyproject.toml` 读取固定运行依赖与构建后端要求,在独立依赖阶段安装。源码阶段安装当前包并运行单测,最终镜像仅继承系统运行环境并复制已验证的虚拟环境,不携带应用源码目录和测试目录。虚拟环境仍包含 pip 和构建后端,未声称完全剥离构建工具。
+- 品牌包只作为 workspace 依赖随 Web 应用构建,不提供独立 Dockerfile、镜像构建工作流或常驻服务。专用品牌检查已停止自动运行,保留测试脚本供明确需要时在远程执行。
+- 不因共享包缓存命中而漏掉消费者:品牌变更需触发 统一应用 PR 检查和 main 发布。根忽略规则变化也触发相关检查。
+
+## 验证与限制
+
+构建、打包与产物验证只在已确认的 `ces-control` runner 执行。Web 与 Python 的工作分支 push/PR 仅运行轻量静态检查;类型检查、单元测试、编译、镜像构建和部署都只在 main push 执行。不再单独构建品牌检查镜像。
+
+本次没有升级 Docker builder,也没有承诺消除 legacy builder 的镜像层提交耗时;缓存层调整后的实际收益需比较后续 CI 日志。源码变化仍需构建,当前采用持久测试基础设施;本次应用合并仍保留完整集成验证,按影响范围选择测试留作后续优化。
+
+本机不新增 Dev Containers/Tilt 启动方式;这类方案需要单独确认本地运行和编译授权。
diff --git a/docs/monorepo.md b/docs/monorepo.md
index 25cba82..652fae1 100644
--- a/docs/monorepo.md
+++ b/docs/monorepo.md
@@ -1,20 +1,16 @@
# PROMPT monorepo
- `apps/waterfall-wiki`: existing Pages Router application, media library, PostgreSQL, object storage and video processing worker. `/wiki/Namespace:slug` has matching `/api/wiki/Namespace:slug` Markdown; `/api/wiki-index` discovers pages. Existing `/p/slug` has `/api/wiki-post/slug` and alternate metadata. Main/File/Category/Template/Draft/Help are public content types, not permission boundaries. Categories cross namespaces; template pages currently describe reusable structures rather than a transclusion engine.
-- `apps/studio`: independent Next.js + TypeScript + Tailwind Pages Router. Add ideas in `src/content/ideas.ts`, open `/ideas/slug`, export Markdown or versioned JSON, or print to PDF. Add custom React pages under `src/pages` when a composition needs bespoke rendering.
+- `apps/waterfall-wiki/src/studio`: merged editor modules in the shared Pages Router application. Add ideas in `src/content/ideas.ts`, open `/ideas/slug`, export Markdown or versioned JSON, or print to PDF. Add custom React pages under `src/pages` when a composition needs bespoke rendering.
- `apps/python`: install with `pip install -e apps/python`. Run `prompt-export idea.json --output ./output --font /path/to/cjk.ttf [--video]`. Generates 1080×1440 PNGs and an optional silent MP4 (4 seconds per slide), requiring FFmpeg. JSON describes standard slides; arbitrary React animations need a separate browser recording pipeline. Python Docker image includes CJK fonts and FFmpeg.
## Development
-Node 24, pnpm 11.25.0, Python 3.12+. `pnpm install`; `pnpm dev:waterfall-wiki`; `pnpm dev:studio`. Do not use local preview for acceptance; see root AGENTS.md. Each JS application owns its frozen pnpm lockfile. Production Docker builds install each project independently.
+Node 24, pnpm 11.25.0, Python 3.12+. `pnpm install`; `pnpm dev:waterfall-wiki`; Do not use local preview for acceptance; see root AGENTS.md. Each JS application owns its frozen pnpm lockfile. Production Docker builds install each project independently.
## Releases
-Three path-filtered Gitea workflows run on ces-control. Changes to one app trigger that app only. Workspace configuration changes intentionally verify both JS applications. Documentation changes do not deploy. Each workflow can be manually dispatched for recovery after a failed release. All app content, including Markdown, triggers its workflow.
-
-Wiki keeps `/opt/prompt`, port 18131 and existing persistent volumes. Studio uses its own Compose project, `/opt/prompt-studio`, port 18134, and verifies its AI index before recording the release; port 18134 is bound to loopback so access goes through Wiki authentication. Python publishes `prompt-python:` and the local `latest` alias, used on demand rather than running an empty daemon. No Python network port is exposed. Root Dockerfile is Wiki's build entry; `apps/waterfall-wiki/Dockerfile` mirrors it for discovery.
-
-CI uses root build context and application-scoped COPY instructions. Wiki runs its existing Docker integration suite before deploying. The former Web-only release path is retired because its source-copy and image assumptions predated the monorepo. No `down -v` is used in production.
+当前以 [统一部署流程](unified-deployment.md) 为准。Wiki 和 Studio 已合并为一个 Next.js 应用,`.gitea/workflows/app.yml` 负责候选验证与 main 发布。品牌只作为内部源码包,Python 保留独立工具镜像流程。生产 PostgreSQL/S3 长期运行,日常应用发布不会重建基础服务。
## Explore 页面与轻量图片
@@ -22,7 +18,7 @@ CI uses root build context and application-scoped COPY instructions. Wiki runs i
`/api/preview` 只接受本站静态栅格图或媒体 ID,限制四档尺寸,输出 WebP:列表目标最多 100 KB、详情目标最多 220 KB。原图按需下载。服务端预览使用并发限制、请求合并、磁盘缓存和 ETag;缓存数量有限并定期清理。SVG 图标不经过栅格化。
-Studio 部署到同一 Docker 网络,通过 `http://8.211.154.134:18131/studio` 访问,不依赖额外公网端口。直连端口 `127.0.0.1:18134/studio` 仅服务器本机可用;公网访问统一经过 Wiki 登录保护。
+Studio 部署到同一 Docker 网络,已合并到同一 Web 容器,编辑器使用 `/graphic`、`/infinite_canvas`,历史 `/studio` 路径兼容保留,不再部署 18134 服务。
Docker 在单独的依赖下载层执行 `pnpm fetch`,再离线安装。源码变化复用依赖层,包脚本变化复用已下载的包。CI 不清理 Docker 缓存,不使用 `--no-cache`。
diff --git a/docs/remote-preview.md b/docs/remote-preview.md
new file mode 100644
index 0000000..918256f
--- /dev/null
+++ b/docs/remote-preview.md
@@ -0,0 +1,8 @@
+# 远程开发预览
+预览入口为 http://8.211.154.134:18136/。通过 /api/dev-preview 查看已同步分支和提交。单套预览默认跟随 codex/unified-web,可以通过仓库变量 PREVIEW_BRANCH 切换目标分支。
+
+初始化使用已验证的 CI 工具镜像,单独准备 PostgreSQL、S3、认证和缓存卷,然后启动长期运行的 pnpm dev。应用预览和生产使用不同 Cookie 名称。邮件发送与后台导出当前不在预览范围内。
+
+日常 push 触发浅检出与文件同步。同步按内容比较文件,保留依赖和 Next 缓存目录。源码到达后由 Next.js 处理增量编译。同步日志记录容器 ID、启动时间和同步耗时。
+
+锁文件、启动配置或数据库迁移发生变化时,同步中止并保留当前预览版本。管理员可在明确维护窗口用匹配的新工具镜像更新预览。当前预览和生产构建共享 ces-control runner,生产任务占用 runner 时同步仍可能排队。
diff --git a/docs/unified-deployment.md b/docs/unified-deployment.md
new file mode 100644
index 0000000..6eb6841
--- /dev/null
+++ b/docs/unified-deployment.md
@@ -0,0 +1,35 @@
+# 统一应用的 CI/CD
+
+## 范围
+
+Wiki 与 Studio 合并为 `apps/waterfall-wiki` 内一个 Next.js Pages Router 应用。编辑器组件与内容在 `src/studio`;原生页面为 `/graphic`、`/infinite_canvas`,历史 `/studio/layouts/*`、`/studio/template-layout` 等继续存在。旧 `/studio/api/*` 通过同应用 rewrite 进入 `pages/api/studio-ui`,保留既有 API 地址与权限边界。没有第二个 Next.js 服务或 18134 新部署。
+
+品牌包只作为 workspace 源码依赖,不独立构建、测试或部署。Python workflow 保留。`codex/unified-web` 包含未合并 PR #46 的缓存改动作为依赖,评审时必须包含完整差异;不得先合并已失效的旧品牌检查配置。
+
+## main 更新后
+
+1. `.gitea/workflows/app.yml` 按应用、品牌、脚本、Docker/Compose 配置路径筛选 main push(含 PR 合并)。文档修改不会部署。
+2. runner checkout 本次提交,逐文件检查 Shell 语法与内容约定。
+3. 按锁文件缓存 pnpm 下载,复制清单后离线安装;随后构建 integration 与 runtime 两个目标。Next.js 的类型检查、单元测试、生产编译、冒烟测试只在共享 build 阶段执行;runtime 复用这一层,不再次编译。运行镜像同供 Web 与 worker 使用。
+4. 在锁保护下使用 `prompt-ci-shared` 测试槽位。首次创建专用 PostgreSQL/S3,此后 `--no-recreate` 复用。停止上一轮测试应用,确认数据库容器属于该测试项目后重置测试数据库。本轮使用 `ci-` 对象 bucket。
+5. 启动候选 Web/worker,迁移测试数据库,执行接口、账户、画布、编辑器及浏览器集成验证。失败则停止发布。退出时停止测试应用并删除本轮 bucket,保留基础容器和卷。
+6. 生产部署先验证 `/etc/prompt.env` 和既有 `prompt` 基础服务,绝不自动生成新凭据或重建 PostgreSQL/S3。检查是否被更新的 main 取代并获取部署锁。
+7. 停止应用写入,备份数据库,执行迁移及已有内容初始化,使用 `--no-deps` 更新 Web/worker。
+8. 检查健康、账户、已有内容和完整线上验收。验收处于部署脚本回滚边界内;失败尝试恢复数据库备份和上一版应用。
+9. 验收通过后更新 current 和成功记录,停止有明确 Compose 所属标签的旧 Studio 容器(保留旧镜像)。上传验收证据;上传成功后删除 runner 临时报告。镜像盘点清理不在发布关键路径执行。
+
+工作分支 push 和面向 main 的 PR 只执行 static-checks,不安装依赖、不编译、不构建镜像、不启动测试环境、不运行单测或浏览器检查。只有 main push 通过静态检查后才执行完整构建、测试与部署;Python 使用相同边界。代价是类型、编译和集成错误到合并后才发现,但失败会阻止后续部署。旧配置已经创建的运行不会因本次 YAML 修改自动变成静态检查。发布不自动取消进行中的任务,以免中断数据库迁移;过期版本由部署脚本检查跳过。
+
+## 基础设施初始化与维护
+
+生产首次安装或明确升级基础镜像时,管理员单独配置 `/etc/prompt.env`,执行 `bash scripts/init-infra.sh`。日常发布只检查基础服务;既有 `prompt_postgres_data`、`prompt_objects`、`prompt_auth_data` 卷名与凭据不变,不执行生产 `down -v`。
+
+持久测试凭据仅保存在 `/var/lib/prompt-ci/credentials.env`,创建时权限受 umask 077 限制;不打印或提交。测试基础设施只使用 `prompt-ci-shared` Compose 项目、随机绑定端口和自己的卷,绝不复用生产数据。runner 必须能写入 `/var/lib/prompt-ci` 和 `/var/lock`;当前确认的 runner 为 root host executor,变更运行身份时需重新配置目录权限。
+
+镜像维护继续提供 `wiki-image-lifecycle.py preview/apply`,需在确认无协作构建的维护窗口执行。数据库备份及既有内容导入暂保留在部署流程;这部分尚未按 schema 或内容变更跳过。
+
+## 回滚与验收
+
+首次迁移前保留旧 Studio 容器、镜像及 `/opt/prompt-studio` 元数据。新版本验收失败发生在旧 Studio 停止之前,可恢复旧 Web/worker。若首次迁移成功后人工回滚到双应用版本,还必须重新启动保留的旧 Studio 容器;不能只回滚 Web 镜像。
+
+构建及浏览器验证只在 ces-control 执行。本地只能做语法、引用及差异检查;实际通过状态以 PR 对应提交的远程 CI 为准。PR 等待用户手动合并,不自动部署或合并。
diff --git a/package.json b/package.json
index 598e48b..3af5177 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,6 @@
"packageManager": "pnpm@11.25.0",
"scripts": {
"dev:waterfall-wiki": "pnpm --filter @prompt/waterfall-wiki dev",
- "dev:studio": "pnpm --filter @prompt/studio dev",
"build": "pnpm -r build",
"typecheck": "pnpm -r typecheck",
"test": "pnpm --filter @prompt/waterfall-wiki test",
diff --git a/packages/brand/Dockerfile b/packages/brand/Dockerfile
deleted file mode 100644
index e512558..0000000
--- a/packages/brand/Dockerfile
+++ /dev/null
@@ -1,12 +0,0 @@
-FROM node:24-bookworm-slim
-WORKDIR /repo
-RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
-COPY packages/brand/package.json packages/brand/pnpm-lock.yaml ./
-RUN pnpm install --frozen-lockfile --ignore-scripts
-COPY packages/brand/ ./
-RUN pnpm test && pnpm build && pnpm test:tailwind && pnpm test:types
-RUN apt-get update && apt-get install -y --no-install-recommends python3 python3-venv \
- && rm -rf /var/lib/apt/lists/* \
- && python3 -m venv /opt/brand-python \
- && /opt/brand-python/bin/pip install --only-binary=:all: Pillow==13.0.0 resvg-py==0.5.0
-RUN /opt/brand-python/bin/python tests/assets.py
diff --git a/packages/brand/README.md b/packages/brand/README.md
index e960459..940c54f 100644
--- a/packages/brand/README.md
+++ b/packages/brand/README.md
@@ -32,10 +32,6 @@
可进行的本地轻量检查:`node --test packages/brand/tests/brand.test.mjs`。它只读取源码和资产,检查主题、对比度、SVG 路径及 PNG/ICO 文件头,不编译和输出产物。
-完整验证在已确认的 ces-control Docker runner 执行:
+品牌包不提供独立 Docker 镜像或常驻服务,也不再运行专用品牌 CI 工作流。Wiki 和 Studio 通过 `workspace:*` 使用本包,品牌资源随消费应用的 Docker 构建处理,并由应用类型检查、测试和构建验证接入。
-```sh
-docker build -f packages/brand/Dockerfile -t endoleap-brand-check .
-```
-
-包含真实 Tailwind 编译、TypeScript 导入检查、品牌导出和 72 个 PNG 与 SVG 逐像素比较。不要在本机运行该 Docker 构建或 `build` / `test:tailwind` / `test:types` / `test:assets`。
+原专用工作流中的品牌导出一致性、单独 Tailwind/TypeScript 测试和 72 个 PNG 与 SVG 逐像素比较不再自动运行;相关脚本与测试文件保留,不能将应用检查视为对这些检查的等价替代。需要这些完整检查时,仍须在已确认的远程 runner 执行,不在本机运行 `build` / `test:tailwind` / `test:types` / `test:assets`。
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index a1d6e04..0d9a5e7 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,6 +1,5 @@
packages:
- apps/waterfall-wiki
- - apps/studio
- packages/brand
# Each application owns its lockfile, so releases stay independent.
sharedWorkspaceLockfile: false
diff --git a/scripts/build-studio-layouts.mjs b/scripts/build-studio-layouts.mjs
index 34dbf3d..89d44f7 100644
--- a/scripts/build-studio-layouts.mjs
+++ b/scripts/build-studio-layouts.mjs
@@ -5,7 +5,6 @@ const root=new URL('../',import.meta.url),read=p=>readFile(new URL(p,root)),raw=
const image=async p=>'/api/media/'+managedMedia['content/layout-reference/assets/'+p.replace(/^asset:/,'')].id;
const layouts=[];
for(const m of raw){const elements=[];for(const e of m.elements)elements.push({...e,raster:await image(e.asset),...(e.mask?{shape:await image(e.mask)}:{})});layouts.push({id:m.id,name:m.name,width:m.width,height:m.height,tutorialUrl:'/explore/'+ids[m.id],sourceSha256:m.source.sha256,source:await image(m.reference),background:await image(m.background),elements});}
-await writeFile(new URL('apps/studio/src/content/templates/layouts.json',root),JSON.stringify(layouts));
+await writeFile(new URL('apps/waterfall-wiki/src/studio/content/templates/layouts.json',root),JSON.stringify(layouts));
await writeFile(new URL('apps/waterfall-wiki/src/content/studio-layouts.json',root),JSON.stringify(layouts.map(({source,background,elements,...l})=>({...l,elements:elements.map(({id,kind,role,box,direction,text,reading})=>({id,kind,role,box,direction,text,reading}))}))));
-await writeFile(new URL('apps/waterfall-wiki/src/lib/layout-parameters.ts',root),await read('apps/studio/src/lib/layout-parameters.ts'));
console.log('Packaged '+layouts.length+' corresponding layouts');
diff --git a/scripts/check-studio-contracts.mjs b/scripts/check-studio-contracts.mjs
index e39543f..62732e0 100644
--- a/scripts/check-studio-contracts.mjs
+++ b/scripts/check-studio-contracts.mjs
@@ -2,8 +2,8 @@
import {readFile} from 'node:fs/promises';
import assert from 'node:assert/strict';
const root=new URL('../',import.meta.url),read=path=>readFile(new URL(path,root),'utf8');
-for(const file of ['layout-parameters.ts','series.ts','canvas-document.ts'])assert.equal(await read('apps/studio/src/lib/'+file),await read('apps/waterfall-wiki/src/lib/'+file),file+' contract drift');
-const canonical=await read('content/studio-examples/autumn-journal.json');for(const app of ['studio','waterfall-wiki'])assert.equal(canonical,await read('apps/'+app+'/fixtures/autumn-journal.json'),'Example fixture drift');
+for(const file of ['layout-parameters.ts','series.ts','canvas-document.ts'])assert.equal(await read('apps/waterfall-wiki/src/studio/lib/'+file),`export * from '../../lib/${file}';\n`,file+' must use shared implementation');
+const canonical=await read('content/studio-examples/autumn-journal.json');for(const file of ['apps/waterfall-wiki/fixtures/autumn-journal.json','apps/waterfall-wiki/fixtures/studio/autumn-journal.json'])assert.equal(canonical,await read(file),'Example fixture drift');
const doc=await read('apps/waterfall-wiki/src/lib/studio-design-documentation.ts'),encoded=doc.match(/export const designDocs=(.*);\n/);
assert.ok(encoded);assert.equal(JSON.parse(encoded[1]),(await read('content/studio-tools/layouts-api.md')).replace('{{LAYOUTS}}','读取 /studio/api/layouts 获取全部27套构图。'),'Wiki/agent documentation drift');
console.log('Studio content, validation and documentation contracts agree');
diff --git a/scripts/deploy-studio.sh b/scripts/deploy-studio.sh
deleted file mode 100644
index 9df86c3..0000000
--- a/scripts/deploy-studio.sh
+++ /dev/null
@@ -1,24 +0,0 @@
-#!/usr/bin/env bash
-set -Eeuo pipefail
-if [ "$(id -u)" -ne 0 ]; then exec sudo -n bash "$0" "$@"; fi
-sha=${1:?release SHA required}
-[[ "$sha" =~ ^[0-9a-f]{40}$ ]]
-exec 9>/var/lock/prompt-studio.lock
-flock -w 300 9
-latest=$(git -c safe.directory="$PWD" ls-remote origin refs/heads/main | cut -f1)
-if [ "$latest" != "$sha" ]; then
- git -c safe.directory="$PWD" fetch origin main
- git -c safe.directory="$PWD" diff --quiet "$sha" origin/main -- apps/studio scripts/deploy-studio.sh .gitea/workflows/waterfall-wiki.yml pnpm-workspace.yaml || exit 0
-fi
-mkdir -p /opt/prompt-studio
-previous=$(cat /opt/prompt-studio/image 2>/dev/null || true)
-write_compose(){ printf 'services:\n studio:\n image: %s\n restart: unless-stopped\n ports: ["127.0.0.1:18134:3000"]\n networks:\n waterfall-wiki:\n aliases: [prompt-studio]\nnetworks:\n waterfall-wiki:\n external: true\n name: prompt_default\n' "$1" > /opt/prompt-studio/compose.yml; }
-write_compose "prompt-studio:$sha"
-rollback(){ if [ -n "$previous" ]; then write_compose "$previous"; docker compose -p prompt-studio -f /opt/prompt-studio/compose.yml up -d; fi; }
-trap rollback ERR
-docker compose -p prompt-studio -f /opt/prompt-studio/compose.yml up -d
-for attempt in {1..40}; do
- if curl -fsS http://127.0.0.1:18134/studio/api/health >/dev/null; then printf '%s' "prompt-studio:$sha" > /opt/prompt-studio/image; exit 0; fi
- sleep 2
-done
-false
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
index 023e0c9..cd246a7 100644
--- a/scripts/deploy.sh
+++ b/scripts/deploy.sh
@@ -17,7 +17,7 @@ test "$(git -c safe.directory="$PWD" rev-parse HEAD)" = "$RELEASE_SHA"
LATEST_MAIN=$(git -c safe.directory="$PWD" ls-remote origin refs/heads/main | cut -f1)
if [ "$LATEST_MAIN" != "$RELEASE_SHA" ]; then
git -c safe.directory="$PWD" fetch origin main
- if ! git -c safe.directory="$PWD" diff --quiet "$RELEASE_SHA" origin/main -- apps/waterfall-wiki Dockerfile compose.yml scripts/deploy.sh scripts/wiki-image-lifecycle.py scripts/infra.sh scripts/integration.sh scripts/auth-admin.sh .gitea/workflows/waterfall-wiki.yml pnpm-workspace.yaml; then
+ if ! git -c safe.directory="$PWD" diff --quiet "$RELEASE_SHA" origin/main -- apps/waterfall-wiki packages/brand Dockerfile .dockerignore compose.yml package.json pnpm-workspace.yaml scripts .gitea/workflows/app.yml; then
echo 'Skipping deployment: Wiki has a newer commit'; exit 0
fi
fi
@@ -61,7 +61,7 @@ rollback(){
echo 'Restored the database backup compatible with the previous application'
fi
if [ -n "$PREVIOUS" ] && [ -f "$PREVIOUS/compose.yml" ]; then
- compose_at "$PREVIOUS" up -d --no-build "$PREVIOUS_WEB" "$PREVIOUS_WORKER"
+ compose_at "$PREVIOUS" up -d --no-build --no-deps "$PREVIOUS_WEB" "$PREVIOUS_WORKER"
echo 'Restored the previous Docker application image'
else
compose stop waterfall-wiki waterfall-wiki-worker
@@ -82,7 +82,8 @@ rollback(){
}
trap rollback ERR
# Keep the same prompt_postgres_data and prompt_objects volumes. No production down -v.
-compose up -d --no-build --wait --wait-timeout 120 postgres objects
+# Infrastructure already exists; never recreate it during an app release.
+compose exec -T postgres pg_isready -U prompt -d prompt
install -d -o root -g root -m 0700 /var/lib/prompt/backups
CUTOVER=1
compose stop waterfall-wiki waterfall-wiki-worker
@@ -98,7 +99,7 @@ CUTOVER=1
# One-time migration: finish a current host worker job before switching the web port.
if [ "$LEGACY_WORKER" -eq 1 ] || [ "$LEGACY_WORKER_ENABLED" -eq 1 ]; then systemctl disable --now prompt-worker.service; fi
if [ "$LEGACY_WEB" -eq 1 ] || [ "$LEGACY_WEB_ENABLED" -eq 1 ]; then systemctl disable --now prompt.service; fi
-compose up -d --no-build --wait --wait-timeout 180 waterfall-wiki waterfall-wiki-worker
+compose up -d --no-build --no-deps --wait --wait-timeout 180 waterfall-wiki waterfall-wiki-worker
compose exec -T waterfall-wiki node container-health.mjs
compose exec -T waterfall-wiki node auth-cli.cjs verify
compose exec -T waterfall-wiki node auth-cli.cjs run scripts/import-character-packs.mjs
@@ -123,6 +124,8 @@ compose exec -T waterfall-wiki sh -c 'cat > /tmp/prompt-release-check.mjs && nod
})().catch(error=>{console.error(error.message);process.exit(1)});
NODE
+bash scripts/verify-studio-production.sh
+
ln -sfn "$RELEASE_DIR" "$APP_ROOT/.current.next"
mv -Tf "$APP_ROOT/.current.next" "$APP_ROOT/current"
install -m 0755 scripts/auth-admin.sh "$APP_ROOT/auth"
@@ -134,6 +137,11 @@ done
if command -v systemctl >/dev/null; then
if systemctl is-active --quiet prompt.service || systemctl is-active --quiet prompt-worker.service; then echo 'Legacy host service is unexpectedly running'; false; fi
fi
+if docker inspect prompt-studio-studio-1 >/dev/null 2>&1; then
+ if [ "$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' prompt-studio-studio-1)" = prompt-studio ]; then
+ docker stop prompt-studio-studio-1
+ fi
+fi
echo "Full Docker deployment verified: web+worker+postgres+objects port=$PORT release=$RELEASE_SHA"
# Register only after every deployment check succeeded; ledger failure retains old images.
if ! python3 scripts/wiki-image-lifecycle.py record-success --sha "$RELEASE_SHA"; then
diff --git a/scripts/infra.sh b/scripts/infra.sh
index 70c9721..b752d3e 100644
--- a/scripts/infra.sh
+++ b/scripts/infra.sh
@@ -1,17 +1,9 @@
#!/usr/bin/env bash
set -Eeuo pipefail
-IMAGE="${1:?Application image is required}"
-# Preserve existing credentials and Docker project/volume names during migration.
-if [ ! -f /etc/prompt.env ]; then
- umask 077
- TEMP_ENV=$(mktemp /etc/prompt.env.XXXXXX)
- trap 'rm -f "$TEMP_ENV"' EXIT
- docker run --rm --entrypoint node "$IMAGE" -e "const {randomBytes}=require('crypto');const hex=()=>randomBytes(32).toString('hex');console.log('POSTGRES_PASSWORD='+hex()+'\nS3_ACCESS_KEY='+hex()+'\nS3_SECRET_KEY='+hex()+'\nS3_BUCKET=prompt')" > "$TEMP_ENV"
- mv "$TEMP_ENV" /etc/prompt.env
- trap - EXIT
-fi
-
-# Current public entry uses HTTP. Operators can set true here after enabling HTTPS.
-if ! grep -q '^AUTH_COOKIE_SECURE=' /etc/prompt.env; then
- printf '\nAUTH_COOKIE_SECURE=false\n' >> /etc/prompt.env
-fi
+# Provision credentials and base containers separately; normal releases only verify.
+test -s /etc/prompt.env || { echo 'Missing /etc/prompt.env; initialize infrastructure separately' >&2; exit 1; }
+for service in postgres objects; do
+ id=$(docker compose -p prompt --env-file /etc/prompt.env -f compose.yml ps -q "$service")
+ test -n "$id" || { echo "Infrastructure missing: $service; run scripts/init-infra.sh separately" >&2; exit 1; }
+ test "$(docker inspect --format '{{.State.Running}}' "$id")" = true
+done
diff --git a/scripts/init-infra.sh b/scripts/init-infra.sh
new file mode 100644
index 0000000..8e2045e
--- /dev/null
+++ b/scripts/init-infra.sh
@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+# Explicit operator action, never invoked by the application release workflow.
+if [ "$(id -u)" -ne 0 ]; then exec sudo -n bash "$0" "$@"; fi
+test -s /etc/prompt.env || { echo 'Configure /etc/prompt.env before initializing infrastructure' >&2; exit 1; }
+docker compose -p prompt --env-file /etc/prompt.env -f compose.yml up -d --no-build --wait postgres objects
diff --git a/scripts/integration.sh b/scripts/integration.sh
index 919f839..ce5668d 100644
--- a/scripts/integration.sh
+++ b/scripts/integration.sh
@@ -1,49 +1,49 @@
#!/usr/bin/env bash
set -Eeuo pipefail
-: "${WATERFALL_WIKI_IMAGE:?Build the runtime image first}"
-: "${WATERFALL_WIKI_TEST_IMAGE:?Build the integration image first}"
+: "${WATERFALL_WIKI_IMAGE:?Build runtime image first}"
+: "${WATERFALL_WIKI_TEST_IMAGE:?Build integration image first}"
export GITEA_RUN_ID="${GITEA_RUN_ID:-$$}"
-export COMPOSE_PROJECT_NAME="prompt-ci-$GITEA_RUN_ID"
+[[ "$GITEA_RUN_ID" =~ ^[1-9][0-9]*$ ]]
+# Only one candidate uses the shared test slot at a time. Production is project prompt.
+exec 8>/var/lock/prompt-ci-integration.lock
+flock -w 900 8
+export COMPOSE_PROJECT_NAME=prompt-ci-shared
export PG_PORT=0 S3_PORT=0 WEB_PORT=0 WEB_BIND_ADDRESS=127.0.0.1
-TEST_ENV=$(mktemp)
-chmod 0600 "$TEST_ENV"
-COMPOSE_FILES=(-f compose.yml)
-STUDIO_SERVICES=()
-if [[ -n "${STUDIO_REVIEW_IMAGE:-}" ]]; then
- COMPOSE_FILES+=(-f compose.creative-review.yml)
- STUDIO_SERVICES+=(prompt-studio)
+STATE=/var/lib/prompt-ci
+umask 077
+mkdir -p "$STATE"
+if [ ! -s "$STATE/credentials.env" ]; then
+ docker run --rm --entrypoint node "$WATERFALL_WIKI_IMAGE" -e "const c=require('crypto');const hex=()=>c.randomBytes(24).toString('hex');console.log('POSTGRES_PASSWORD='+hex()+'\nS3_ACCESS_KEY='+hex()+'\nS3_SECRET_KEY='+hex())" > "$STATE/credentials.env.tmp"
+ mv "$STATE/credentials.env.tmp" "$STATE/credentials.env"
fi
-compose(){ docker compose -p "$COMPOSE_PROJECT_NAME" --env-file "$TEST_ENV" "${COMPOSE_FILES[@]}" "$@"; }
+TEST_ENV=$(mktemp)
+cat "$STATE/credentials.env" > "$TEST_ENV"
+printf '\nS3_BUCKET=ci-%s\nAUTH_DISABLED_FOR_TESTS=true\nACCOUNT_PUBLIC_ORIGIN=http://waterfall-wiki:3000\nAUTH_COOKIE_SECURE=false\n' "$GITEA_RUN_ID" >> "$TEST_ENV"
+compose(){ docker compose -p "$COMPOSE_PROJECT_NAME" --env-file "$TEST_ENV" -f compose.yml "$@"; }
cleanup(){
code=$?
- trap - EXIT INT TERM
- if [ "$code" -ne 0 ]; then
- echo 'Runner pressure at integration failure (before disposable stack cleanup)'
- uptime || true
- free -m || true
- df -h / || true
- for resource in cpu memory io; do cat "/proc/pressure/$resource" || true; done
- timeout 10s docker stats --no-stream --format '{{.Name}} cpu={{.CPUPerc}} memory={{.MemUsage}} io={{.BlockIO}} pids={{.PIDs}}' || true
- compose logs --tail 160 waterfall-wiki waterfall-wiki-worker "${STUDIO_SERVICES[@]}" || true
- fi
- cleanup_code=0
- bash scripts/cleanup-integration.sh || cleanup_code=$?
+ trap - EXIT
+ if [ "$code" -ne 0 ]; then compose logs --tail 100 waterfall-wiki waterfall-wiki-worker || true; fi
+ compose stop waterfall-wiki waterfall-wiki-worker || true
+ docker run --rm --network prompt-ci-shared_default --env-file "$TEST_ENV" \
+ --mount "type=bind,src=$PWD/apps/waterfall-wiki/scripts/cleanup-ci-bucket.mjs,dst=/repo/apps/waterfall-wiki/cleanup-ci-bucket.mjs,readonly" \
+ --entrypoint node "$WATERFALL_WIKI_TEST_IMAGE" /repo/apps/waterfall-wiki/cleanup-ci-bucket.mjs || { echo 'CI bucket cleanup failed' >&2; code=1; }
rm -f "$TEST_ENV"
- if [ "$code" -ne 0 ]; then exit "$code"; fi
- exit "$cleanup_code"
+ exit "$code"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
-# Random credentials exist only for this disposable stack; no host Node required.
-docker run --rm --entrypoint node "$WATERFALL_WIKI_IMAGE" -e "const c=require('crypto');const hex=()=>c.randomBytes(24).toString('hex');console.log('POSTGRES_PASSWORD='+hex()+'\nS3_ACCESS_KEY='+hex()+'\nS3_SECRET_KEY='+hex())" > "$TEST_ENV"
-# Explicitly isolate legacy unauthenticated integration coverage; production fails closed.
-printf '\nAUTH_DISABLED_FOR_TESTS=true\n' >> "$TEST_ENV"
-if [[ -n "${STUDIO_REVIEW_IMAGE:-}" ]]; then printf '\nACCOUNT_PUBLIC_ORIGIN=http://waterfall-wiki:3000\nAUTH_COOKIE_SECURE=false\n' >> "$TEST_ENV"; fi
-compose up -d --no-build --wait --wait-timeout 120 postgres objects
+# Bootstrap once, otherwise reuse base containers and their volumes.
+compose up -d --no-build --no-recreate --wait --wait-timeout 120 postgres objects
+compose stop waterfall-wiki waterfall-wiki-worker
+id=$(compose ps -q postgres)
+[[ "$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' "$id")" == prompt-ci-shared ]]
+# Reset only this dedicated test database; no production volume or database is addressed.
+compose exec -T postgres dropdb -U prompt --if-exists --force prompt
+compose exec -T postgres createdb -U prompt prompt
compose run --rm --no-deps migrate
-compose up -d --no-build --wait --wait-timeout 150 waterfall-wiki waterfall-wiki-worker "${STUDIO_SERVICES[@]}"
-# The test runner calls the actual non-root production web/worker containers.
+compose up -d --no-build --no-deps --wait --wait-timeout 150 waterfall-wiki waterfall-wiki-worker
compose run --rm --no-deps tests
compose exec -T waterfall-wiki node container-health.mjs
compose exec -T waterfall-wiki-worker sh -c 'test "$(id -u)" -ne 0 && ffmpeg -version >/dev/null'
@@ -51,8 +51,8 @@ compose exec -T waterfall-wiki-worker sh -c 'test "$(id -u)" -ne 0 && ffmpeg -ve
# Repeat route and login checks with production authentication enabled.
printf '\nAUTH_CONFIG_FILE=/auth/config.json\nAUTH_COOKIE_SECURE=false\n' >> "$TEST_ENV"
compose run --rm --no-deps auth-admin
-compose up -d --no-build --wait --wait-timeout 150 waterfall-wiki
+compose up -d --no-build --no-deps --wait --wait-timeout 150 waterfall-wiki
compose exec -T waterfall-wiki node auth-cli.cjs exercise
compose restart waterfall-wiki
-compose up -d --no-build --wait --wait-timeout 150 waterfall-wiki
+compose up -d --no-build --no-deps --wait --wait-timeout 150 waterfall-wiki
compose exec -T waterfall-wiki node auth-cli.cjs verify
diff --git a/scripts/preview/compose.yml b/scripts/preview/compose.yml
new file mode 100644
index 0000000..070fc2c
--- /dev/null
+++ b/scripts/preview/compose.yml
@@ -0,0 +1,94 @@
+x-preview-env: &preview-env
+ DATABASE_URL: postgresql://prompt:${POSTGRES_PASSWORD:?required}@postgres:5432/prompt
+ S3_ENDPOINT: http://objects:9000
+ S3_ACCESS_KEY: ${S3_ACCESS_KEY:?required}
+ S3_SECRET_KEY: ${S3_SECRET_KEY:?required}
+ S3_BUCKET: prompt-preview
+ AUTH_CONFIG_FILE: /auth/config.json
+ AUTH_COOKIE_SECURE: "false"
+ AUTH_COOKIE_NAME: preview_prompt_session
+ ACCOUNT_COOKIE_NAME: preview_wiki_account
+ ACCOUNT_PUBLIC_ORIGIN: http://8.211.154.134:${PREVIEW_PORT:-18136}
+ STUDIO_WIKI_ORIGIN: http://waterfall-wiki:3000
+ SMTP_HOST: 127.0.0.1
+ SMTP_PORT: "1"
+ SMTP_USERNAME: preview
+ SMTP_PASSWORD: ""
+ NEXT_TELEMETRY_DISABLED: "1"
+ PREVIEW_MODE: "true"
+ COREPACK_HOME: /tools/corepack
+ COREPACK_ENABLE_NETWORK: "0"
+ npm_config_offline: "true"
+services:
+ waterfall-wiki:
+ image: ${PREVIEW_TOOL_IMAGE:?use a verified test image}
+ working_dir: /repo/apps/waterfall-wiki
+ user: "1000:1000"
+ command: ["pnpm", "dev", "--hostname", "0.0.0.0", "--port", "3000"]
+ init: true
+ restart: unless-stopped
+ environment:
+ <<: *preview-env
+ NODE_OPTIONS: --max-old-space-size=1536
+ mem_limit: 2g
+ cpus: 1.0
+ ports: ["0.0.0.0:${PREVIEW_PORT:-18136}:3000"]
+ volumes:
+ - /opt/prompt-preview/source:/repo
+ - dependencies:/repo/apps/waterfall-wiki/node_modules
+ - type: volume
+ source: next-cache
+ target: /repo/apps/waterfall-wiki/.next
+ - tools:/tools:ro
+ - /opt/prompt-preview/status:/preview-status:ro
+ - auth:/auth:ro
+ security_opt: ["no-new-privileges:true"]
+ cap_drop: [ALL]
+ postgres:
+ image: postgres:17-alpine
+ restart: unless-stopped
+ environment:
+ POSTGRES_DB: prompt
+ POSTGRES_USER: prompt
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?required}
+ volumes: ["database:/var/lib/postgresql/data"]
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U prompt -d prompt"]
+ interval: 3s
+ timeout: 3s
+ retries: 30
+ objects:
+ image: rustfs/rustfs:1.0.3
+ restart: unless-stopped
+ environment:
+ RUSTFS_ACCESS_KEY: ${S3_ACCESS_KEY:?required}
+ RUSTFS_SECRET_KEY: ${S3_SECRET_KEY:?required}
+ RUSTFS_VOLUMES: /data
+ RUSTFS_ADDRESS: :9000
+ RUSTFS_CONSOLE_ENABLE: "false"
+ volumes: ["objects:/data"]
+ prepare-tools:
+ image: ${PREVIEW_TOOL_IMAGE:?required}
+ profiles: [setup]
+ user: root
+ volumes: ["tools:/tools", "next-cache:/preview-next"]
+ command: ["/bin/sh", "-ec", "mkdir -p /tools/corepack && cp -a /root/.cache/node/corepack/. /tools/corepack/ && chown -R 1000:1000 /tools /preview-next"]
+ migrate:
+ image: ${PREVIEW_TOOL_IMAGE:?required}
+ profiles: [setup]
+ environment: *preview-env
+ command: ["node", "dist/bootstrap.cjs"]
+ auth-admin:
+ image: ${PREVIEW_TOOL_IMAGE:?required}
+ profiles: [setup]
+ user: root
+ environment: *preview-env
+ volumes: ["auth:/auth"]
+ command: ["node", "dist/auth-cli.cjs", "init"]
+volumes:
+ tools:
+ dependencies:
+ next-cache:
+ auth:
+ database:
+ objects:
diff --git a/scripts/preview/fingerprint.mjs b/scripts/preview/fingerprint.mjs
new file mode 100644
index 0000000..980b2f2
--- /dev/null
+++ b/scripts/preview/fingerprint.mjs
@@ -0,0 +1,13 @@
+// Startup/dependency/schema changes require an explicit preview refresh.
+import {readFile,readdir} from 'node:fs/promises';
+import {resolve} from 'node:path';
+import {createHash} from 'node:crypto';
+const root=resolve(process.argv[2] || '.');
+const hash=createHash('sha256');
+for (const name of ['apps/waterfall-wiki/pnpm-lock.yaml','pnpm-workspace.yaml','apps/waterfall-wiki/next.config.ts','apps/waterfall-wiki/tsconfig.json','apps/waterfall-wiki/postcss.config.mjs']) hash.update(name).update(await readFile(resolve(root,name)));
+for (const name of ['apps/waterfall-wiki/package.json','packages/brand/package.json']) {
+ const p=JSON.parse(await readFile(resolve(root,name),'utf8'));
+ hash.update(name).update(JSON.stringify({dependencies:p.dependencies,devDependencies:p.devDependencies,optionalDependencies:p.optionalDependencies,peerDependencies:p.peerDependencies,engines:p.engines,packageManager:p.packageManager,dev:p.scripts?.dev,prepare:p.scripts?.['brand:prepare'],exports:p.exports}));
+}
+for (const name of (await readdir(resolve(root,'apps/waterfall-wiki/db'))).filter(n=>n.endsWith('.sql')).sort()) hash.update(name).update(await readFile(resolve(root,'apps/waterfall-wiki/db',name)));
+console.log(hash.digest('hex'));
diff --git a/scripts/preview/init.sh b/scripts/preview/init.sh
new file mode 100644
index 0000000..a1b3ece
--- /dev/null
+++ b/scripts/preview/init.sh
@@ -0,0 +1,62 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+SOURCE=$(realpath "${1:?checkout directory required}")
+IMAGE=${2:?verified integration image required}
+PORT=${PREVIEW_PORT:-18136}
+[[ "$IMAGE" =~ ^waterfall-wiki-tests:[0-9a-f]{40}$ ]] || exit 2
+[[ "$PORT" =~ ^[0-9]+$ ]] && (( PORT >= 18136 && PORT <= 65535 )) || exit 2
+command -v rsync >/dev/null
+command -v ss >/dev/null
+docker image inspect "$IMAGE" >/dev/null
+ROOT=/opt/prompt-preview
+exec 7>/var/lock/prompt-preview-init.lock
+flock -w 30 7
+if [ -f "$ROOT/ready" ]; then echo 'Already initialized; use sync.sh, or perform an explicit manual refresh' >&2; exit 1; fi
+if ss -H -ltn "sport = :$PORT" | grep -q .; then
+ project=$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' prompt-preview-waterfall-wiki-1 2>/dev/null || true)
+ [[ "$project" == prompt-preview ]] || { echo "Preview port $PORT is occupied" >&2; exit 1; }
+fi
+# The prebuilt tooling image must match dependency, startup and schema inputs.
+expected=$(node "$SOURCE/scripts/preview/fingerprint.mjs" "$SOURCE")
+actual=$(docker run --rm --entrypoint node \
+ --mount "type=bind,src=$SOURCE/scripts/preview/fingerprint.mjs,dst=/tmp/preview-fingerprint.mjs,readonly" \
+ "$IMAGE" /tmp/preview-fingerprint.mjs /repo)
+[[ "$actual" == "$expected" ]] || { echo 'Tooling image does not match preview dependencies/configuration/schema' >&2; exit 1; }
+install -d -m 0700 "$ROOT"
+install -d -m 0755 "$ROOT/source" "$ROOT/status"
+chown 1000:1000 "$ROOT/source" "$ROOT/status"
+# Create independent preview credentials only once, never copy production secrets.
+if [ ! -s "$ROOT/env" ]; then
+ ROOT="$ROOT" IMAGE="$IMAGE" PORT="$PORT" python3 - <<'PY'
+import os,secrets,pathlib
+p=pathlib.Path(os.environ['ROOT'])/'env'
+with p.open('x') as f:
+ os.chmod(p,0o600)
+ f.write('PREVIEW_TOOL_IMAGE='+os.environ['IMAGE']+'\nPREVIEW_PORT='+os.environ['PORT']+'\n')
+ for key in ['POSTGRES_PASSWORD','S3_ACCESS_KEY','S3_SECRET_KEY']:f.write(key+'='+secrets.token_hex(24)+'\n')
+PY
+fi
+rsync -a --delete --numeric-ids --chown=1000:1000 \
+ --exclude='.git' --exclude='node_modules' --exclude='.next' --exclude='dist' \
+ --exclude='*.tsbuildinfo' --exclude='.env' --exclude='.env.*' --exclude='.gitea' \
+ "$SOURCE/" "$ROOT/source/"
+install -m 0644 "$SOURCE/scripts/preview/compose.yml" "$ROOT/compose.yml"
+compose(){ docker compose -p prompt-preview --env-file "$ROOT/env" -f "$ROOT/compose.yml" "$@"; }
+compose up -d --no-build --no-recreate --wait --wait-timeout 120 postgres objects
+compose run --rm --no-deps prepare-tools
+compose run --rm --no-deps auth-admin
+compose run --rm --no-deps migrate
+# The dependency volume is seeded from the already-verified image, no npm install.
+compose up -d --no-build --no-deps waterfall-wiki
+printf '%s\n' "$expected" > "$ROOT/fingerprint"
+for attempt in {1..90}; do
+ if curl -fsS --max-time 5 "http://127.0.0.1:$PORT/api/health" >/dev/null 2>&1; then
+ touch "$ROOT/ready"
+ echo "Preview initialized on port $PORT; pnpm dev remains running"
+ exit 0
+ fi
+ sleep 2
+done
+compose logs --tail 60 waterfall-wiki
+compose stop waterfall-wiki
+exit 1
diff --git a/scripts/preview/sync.sh b/scripts/preview/sync.sh
new file mode 100644
index 0000000..4d71081
--- /dev/null
+++ b/scripts/preview/sync.sh
@@ -0,0 +1,49 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+SOURCE=$(realpath "${1:?checkout directory required}")
+SHA=${2:?commit SHA required}
+BRANCH=${3:?branch required}
+[[ "$SHA" =~ ^[0-9a-f]{40}$ ]] || exit 2
+ROOT=/opt/prompt-preview
+started=$SECONDS
+exec 8>/var/lock/prompt-preview-sync.lock
+flock -w 30 8
+latest=$(git -C "$SOURCE" ls-remote origin "refs/heads/$BRANCH" | cut -f1)
+test -n "$latest"
+if [ "$latest" != "$SHA" ]; then echo "Skipping superseded preview commit $SHA"; exit 0; fi
+candidate=$(node "$SOURCE/scripts/preview/fingerprint.mjs" "$SOURCE")
+if [ ! -s "$ROOT/fingerprint" ]; then
+ echo 'Preview is not initialized; run scripts/preview/init.sh once with a verified tooling image' >&2
+ exit 1
+fi
+if [ "$(cat "$ROOT/fingerprint")" != "$candidate" ]; then
+ echo 'Preview refresh required: dependencies, startup configuration or database schema changed. No install/restart/migration was performed.' >&2
+ exit 1
+fi
+id=$(docker compose -p prompt-preview --env-file "$ROOT/env" -f "$ROOT/compose.yml" ps -q waterfall-wiki)
+test -n "$id" && test "$(docker inspect --format '{{.State.Running}}' "$id")" = true
+# No git credentials, dependency folders, compiler output or environment files are copied.
+rsync -a --checksum --no-times --delete --numeric-ids --chown=1000:1000 \
+ --exclude='.git' --exclude='node_modules' --exclude='.next' --exclude='dist' \
+ --exclude='*.tsbuildinfo' --exclude='.env' --exclude='.env.*' --exclude='.gitea' \
+ --exclude='/apps/waterfall-wiki/public/brand/' --exclude='/apps/waterfall-wiki/public/favicon.ico' --exclude='/apps/waterfall-wiki/public/apple-touch-icon.png' "$SOURCE/" "$ROOT/source/"
+# Keep generated public marks current using file copies only.
+assets="$ROOT/source/packages/brand/assets"
+public="$ROOT/source/apps/waterfall-wiki/public"
+mkdir -p "$public/brand"
+copy_asset() {
+ cmp -s "$1" "$2" || install -m 0644 "$1" "$2"
+}
+for name in logo-light.svg logo-dark.svg icon-dark.svg; do copy_asset "$assets/svg/$name" "$public/brand/$name"; done
+copy_asset "$assets/icons/favicon.ico" "$public/favicon.ico"
+copy_asset "$assets/icons/apple-touch-icon.png" "$public/apple-touch-icon.png"
+SHA="$SHA" BRANCH="$BRANCH" DEST="$ROOT/status/status.json" python3 - <<'META'
+import os,json,pathlib,datetime
+destination=pathlib.Path(os.environ['DEST'])
+temporary=destination.with_suffix('.tmp')
+temporary.write_text(json.dumps({'sha':os.environ['SHA'],'branch':os.environ['BRANCH'],'syncedAt':datetime.datetime.now(datetime.timezone.utc).isoformat()})+'\n')
+temporary.chmod(0o644)
+temporary.replace(destination)
+META
+container_started=$(docker inspect --format '{{.State.StartedAt}}' "$id")
+echo "Preview synchronized: $BRANCH $SHA (container $id, started $container_started, sync ${SECONDS}s)"
diff --git a/scripts/verify-studio-production.sh b/scripts/verify-studio-production.sh
index 7c803de..23b5d5d 100644
--- a/scripts/verify-studio-production.sh
+++ b/scripts/verify-studio-production.sh
@@ -49,4 +49,4 @@ docker run --rm \
--mount "type=bind,src=$RESULT_DIR,dst=/tmp/studio-library" \
-e NODE_PATH=/repo/apps/waterfall-wiki/node_modules -e CHROMIUM_PATH=/usr/bin/chromium \
-e SCREENSHOT_DIR=/tmp/studio-library/visual \
- --entrypoint node "$TEST_IMAGE" /delivery/apps/studio/scripts/infinite-canvas-smoke.cjs
+ --entrypoint node "$TEST_IMAGE" /delivery/apps/waterfall-wiki/scripts/infinite-canvas-smoke.cjs
diff --git a/scripts/wiki-image-lifecycle.py b/scripts/wiki-image-lifecycle.py
index f181e95..a87b22f 100644
--- a/scripts/wiki-image-lifecycle.py
+++ b/scripts/wiki-image-lifecycle.py
@@ -175,12 +175,6 @@ def ci(root, sha):
code = 0
except subprocess.CalledProcessError as error:
code = error.returncode
- finally:
- try:
- cleanup(root, apply=True, completed_test=sha if code == 0 else None)
- except Exception as error:
- # Housekeeping must not mask the build/deployment result or roll back healthy services.
- print(f"Cleanup failed; images retained for later retry: {error}", file=sys.stderr)
return code