Update 易瞳语音转录|设计|克隆|降噪 skill in marketplace

Constraint: Public skills are published only by explicit administrator action unless they are tracked third-party market sources.
Confidence: high
Scope-risk: narrow
Directive: Keep private/internal skills out of the public marketplace and preserve normal incremental market Git history.
Tested: Marketplace validation passed.
This commit is contained in:
KeyInfo Bot
2026-06-24 11:41:24 +08:00
parent b5012cedbc
commit 0f46963526
28 changed files with 998 additions and 385 deletions
+18 -18
View File
@@ -160,18 +160,6 @@
}, },
"category": "文档处理" "category": "文档处理"
}, },
{
"name": "eapil-voice",
"source": {
"source": "local",
"path": "./plugins/codex/plugins/eapil-voice"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "多媒体与生成"
},
{ {
"name": "frontend-slides", "name": "frontend-slides",
"source": { "source": {
@@ -269,16 +257,16 @@
"category": "开发工具" "category": "开发工具"
}, },
{ {
"name": "shadcn", "name": "mcp-playwright",
"source": { "source": {
"source": "local", "source": "local",
"path": "./plugins/codex/plugins/shadcn" "path": "./plugins/codex/plugins/mcp-playwright"
}, },
"policy": { "policy": {
"installation": "AVAILABLE", "installation": "AVAILABLE",
"authentication": "ON_INSTALL" "authentication": "ON_INSTALL"
}, },
"category": "开发工具" "category": "MCP"
}, },
{ {
"name": "ui-ux-pro-max", "name": "ui-ux-pro-max",
@@ -292,6 +280,18 @@
}, },
"category": "设计" "category": "设计"
}, },
{
"name": "shadcn",
"source": {
"source": "local",
"path": "./plugins/codex/plugins/shadcn"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "开发工具"
},
{ {
"name": "ppt-master", "name": "ppt-master",
"source": { "source": {
@@ -305,16 +305,16 @@
"category": "文档处理" "category": "文档处理"
}, },
{ {
"name": "mcp-playwright", "name": "eapil-voice",
"source": { "source": {
"source": "local", "source": "local",
"path": "./plugins/codex/plugins/mcp-playwright" "path": "./plugins/codex/plugins/eapil-voice"
}, },
"policy": { "policy": {
"installation": "AVAILABLE", "installation": "AVAILABLE",
"authentication": "ON_INSTALL" "authentication": "ON_INSTALL"
}, },
"category": "MCP" "category": "多媒体与生成"
} }
] ]
} }
+18 -18
View File
@@ -160,18 +160,6 @@
}, },
"category": "文档处理" "category": "文档处理"
}, },
{
"name": "eapil-voice",
"source": {
"source": "local",
"path": "./plugins/eapil-voice"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "多媒体与生成"
},
{ {
"name": "frontend-slides", "name": "frontend-slides",
"source": { "source": {
@@ -269,16 +257,16 @@
"category": "开发工具" "category": "开发工具"
}, },
{ {
"name": "shadcn", "name": "mcp-playwright",
"source": { "source": {
"source": "local", "source": "local",
"path": "./plugins/shadcn" "path": "./plugins/mcp-playwright"
}, },
"policy": { "policy": {
"installation": "AVAILABLE", "installation": "AVAILABLE",
"authentication": "ON_INSTALL" "authentication": "ON_INSTALL"
}, },
"category": "开发工具" "category": "MCP"
}, },
{ {
"name": "ui-ux-pro-max", "name": "ui-ux-pro-max",
@@ -292,6 +280,18 @@
}, },
"category": "设计" "category": "设计"
}, },
{
"name": "shadcn",
"source": {
"source": "local",
"path": "./plugins/shadcn"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "开发工具"
},
{ {
"name": "ppt-master", "name": "ppt-master",
"source": { "source": {
@@ -305,16 +305,16 @@
"category": "文档处理" "category": "文档处理"
}, },
{ {
"name": "mcp-playwright", "name": "eapil-voice",
"source": { "source": {
"source": "local", "source": "local",
"path": "./plugins/mcp-playwright" "path": "./plugins/eapil-voice"
}, },
"policy": { "policy": {
"installation": "AVAILABLE", "installation": "AVAILABLE",
"authentication": "ON_INSTALL" "authentication": "ON_INSTALL"
}, },
"category": "MCP" "category": "多媒体与生成"
} }
] ]
} }
@@ -56,20 +56,32 @@ For Qwen clone, always provide both `text` and `ref_text`.
Use JSON for voice design and synthesis: Use JSON for voice design and synthesis:
```bash ```bash
curl -X POST "$QWEN3_VOICE_API_BASE_URL/v1/voice/design" \ BASE_URL="${QWEN3_VOICE_API_BASE_URL:-https://ades.playones.com}"
curl -X POST "$BASE_URL/v1/voice/design" \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"text":"Hello world","instruct":"Warm, calm, natural narration","language":"auto"}' \ -d '{"provider":"qwen","text":"Hello world","instruct":"Warm, calm, natural narration","language":"auto","temperature":0.9,"max_tokens":4096,"top_k":50,"top_p":1.0,"repetition_penalty":1.05}' \
--output voice_design.wav --output voice_design.wav
``` ```
Use multipart form data for clone, enhancement, denoise, and ASR: Use multipart form data for clone, enhancement, denoise, and ASR:
```bash ```bash
curl -X POST "$QWEN3_VOICE_API_BASE_URL/v1/voice/clone" \ BASE_URL="${QWEN3_VOICE_API_BASE_URL:-https://ades.playones.com}"
curl -X POST "$BASE_URL/v1/voice/clone" \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-F 'file=@reference.wav' \ -F 'file=@reference.wav' \
-F 'provider=qwen' \
-F 'text=This is the cloned sentence.' \ -F 'text=This is the cloned sentence.' \
-F 'ref_text=Transcript of the reference audio.' \ -F 'ref_text=Transcript of the reference audio.' \
-F 'language=auto' \ -F 'language=auto' \
-F 'temperature=0.9' \
-F 'max_tokens=4096' \
-F 'top_k=50' \
-F 'top_p=1.0' \
-F 'repetition_penalty=1.05' \
--output voice_clone.wav --output voice_clone.wav
``` ```
@@ -8,11 +8,7 @@
- Auth header: `Authorization: Bearer <key>` - Auth header: `Authorization: Bearer <key>`
- If no key is available, get one from `https://kinfo.playones.com`. - If no key is available, get one from `https://kinfo.playones.com`.
- Audio-output media type: `audio/wav` - Audio-output media type: `audio/wav`
- Local source checkouts are source references only unless the user explicitly asks to run a local service. Historical local start command: - Local source checkouts are source references only unless the user explicitly asks to run a local service.
```bash
uv run python -m uvicorn fastapi_voice_design_api:app --host 0.0.0.0 --port 8197
```
## Endpoint Summary ## Endpoint Summary
@@ -20,13 +16,27 @@ uv run python -m uvicorn fastapi_voice_design_api:app --host 0.0.0.0 --port 8197
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| Voice design TTS | `POST` | `/v1/voice/design` | JSON | WAV | | Voice design TTS | `POST` | `/v1/voice/design` | JSON | WAV |
| Base TTS synthesis | `POST` | `/v1/voice/synthesize` | JSON | WAV | | Base TTS synthesis | `POST` | `/v1/voice/synthesize` | JSON | WAV |
| Voice clone | `POST` | `/v1/voice/clone` | multipart form or raw audio body plus query fields | WAV | | Voice clone | `POST` | `/v1/voice/clone` | multipart form | WAV |
| Speech enhancement | `POST` | `/v1/audio/enhance` | multipart form or raw audio body | WAV | | Speech enhancement | `POST` | `/v1/audio/enhance` | multipart form | WAV |
| Denoise alias | `POST` | `/v1/audio/denoise` | multipart form or raw audio body | WAV | | Denoise alias | `POST` | `/v1/audio/denoise` | multipart form | WAV |
| ASR transcription | `POST` | `/v1/asr/transcribe` | multipart form or raw audio body | JSON | | ASR transcription | `POST` | `/v1/asr/transcribe` | multipart form | JSON |
| Health | `GET` | `/health` | none | JSON | | Health | `GET` | `/health` | none | JSON |
| Models | `GET` | `/models` | none | JSON | | Models | `GET` | `/models` | none | JSON |
## Parameter Limits
- `provider`: `qwen` or `voxcpm2`
- `text`: 1 to 4096 characters
- `instruct`: 1 to 1024 characters; optional for clone
- `temperature`: 0 to 2
- `max_tokens`: 1 to 8192
- `top_k`: 0 to 1000
- `top_p`: 0 to 1
- `repetition_penalty`: 0.1 to 2
- `cfg_value`: 0 to 10
- `inference_timesteps`: 1 to 200
- Uploaded audio defaults to a 25 MB and 60 second server-side limit, depending on deployment environment variables.
## Voice Design ## Voice Design
`POST /v1/voice/design` `POST /v1/voice/design`
@@ -55,11 +65,18 @@ Qwen example:
```bash ```bash
curl -X POST https://ades.playones.com/v1/voice/design \ curl -X POST https://ades.playones.com/v1/voice/design \
-H 'Authorization: Bearer $OPENAI_API_KEY' \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{ -d '{
"provider": "qwen",
"text": "Hello world", "text": "Hello world",
"instruct": "Warm, calm, natural narration", "instruct": "Warm, calm, natural narration",
"language": "auto" "language": "auto",
"temperature": 0.9,
"max_tokens": 4096,
"top_k": 50,
"top_p": 1.0,
"repetition_penalty": 1.05
}' \ }' \
--output voice_design.wav --output voice_design.wav
``` ```
@@ -68,6 +85,7 @@ VoxCPM2 example:
```bash ```bash
curl -X POST https://ades.playones.com/v1/voice/design \ curl -X POST https://ades.playones.com/v1/voice/design \
-H 'Authorization: Bearer $OPENAI_API_KEY' \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{ -d '{
"text": "Hello world", "text": "Hello world",
@@ -104,11 +122,17 @@ Example:
```bash ```bash
curl -X POST https://ades.playones.com/v1/voice/synthesize \ curl -X POST https://ades.playones.com/v1/voice/synthesize \
-H 'Authorization: Bearer $OPENAI_API_KEY' \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{ -d '{
"text": "Hello world", "text": "Hello world",
"speaker": "Chelsie", "speaker": "Chelsie",
"language": "auto" "language": "auto",
"temperature": 0.9,
"max_tokens": 4096,
"top_k": 50,
"top_p": 1.0,
"repetition_penalty": 1.05
}' \ }' \
--output synthesize.wav --output synthesize.wav
``` ```
@@ -117,11 +141,10 @@ curl -X POST https://ades.playones.com/v1/voice/synthesize \
`POST /v1/voice/clone` `POST /v1/voice/clone`
Use this for cloning from reference audio. Prefer multipart form upload with `file=@reference.wav`. Use this for cloning from reference audio. Send multipart form upload with `file=@reference.wav`.
Audio input: Audio input:
- `file`: reference audio upload - `file`: reference audio upload
- Alternative: raw audio request body with non-JSON `Content-Type`, with text fields in query params
Shared fields: Shared fields:
- `text`: required output text - `text`: required output text
@@ -158,10 +181,17 @@ Qwen clone example:
```bash ```bash
curl -X POST https://ades.playones.com/v1/voice/clone \ curl -X POST https://ades.playones.com/v1/voice/clone \
-H 'Authorization: Bearer $OPENAI_API_KEY' \
-F 'file=@reference.wav' \ -F 'file=@reference.wav' \
-F 'provider=qwen' \
-F 'text=This is the cloned sentence.' \ -F 'text=This is the cloned sentence.' \
-F 'ref_text=This is the transcript of the reference audio.' \ -F 'ref_text=This is the transcript of the reference audio.' \
-F 'language=auto' \ -F 'language=auto' \
-F 'temperature=0.9' \
-F 'max_tokens=4096' \
-F 'top_k=50' \
-F 'top_p=1.0' \
-F 'repetition_penalty=1.05' \
--output voice_clone.wav --output voice_clone.wav
``` ```
@@ -169,10 +199,16 @@ VoxCPM2 controllable clone without transcript:
```bash ```bash
curl -X POST https://ades.playones.com/v1/voice/clone \ curl -X POST https://ades.playones.com/v1/voice/clone \
-H 'Authorization: Bearer $OPENAI_API_KEY' \
-F 'file=@reference.wav' \ -F 'file=@reference.wav' \
-F 'text=This is the cloned sentence.' \ -F 'text=This is the cloned sentence.' \
-F 'instruct=Keep the same speaker identity with a calm delivery.' \ -F 'instruct=Keep the same speaker identity with a calm delivery.' \
-F 'provider=voxcpm2' \ -F 'provider=voxcpm2' \
-F 'cfg_value=2.0' \
-F 'inference_timesteps=10' \
-F 'normalize=false' \
-F 'denoise=false' \
-F 'retry_badcase=true' \
--output voxcpm2_controllable_clone.wav --output voxcpm2_controllable_clone.wav
``` ```
@@ -180,10 +216,16 @@ VoxCPM2 Hi-Fi clone with transcript:
```bash ```bash
curl -X POST https://ades.playones.com/v1/voice/clone \ curl -X POST https://ades.playones.com/v1/voice/clone \
-H 'Authorization: Bearer $OPENAI_API_KEY' \
-F 'file=@reference.wav' \ -F 'file=@reference.wav' \
-F 'text=This is the cloned sentence.' \ -F 'text=This is the cloned sentence.' \
-F 'ref_text=This is the transcript of the reference audio.' \ -F 'ref_text=This is the transcript of the reference audio.' \
-F 'provider=voxcpm2' \ -F 'provider=voxcpm2' \
-F 'cfg_value=2.0' \
-F 'inference_timesteps=10' \
-F 'normalize=false' \
-F 'denoise=false' \
-F 'retry_badcase=true' \
--output voxcpm2_hifi_clone.wav --output voxcpm2_hifi_clone.wav
``` ```
@@ -193,7 +235,7 @@ curl -X POST https://ades.playones.com/v1/voice/clone \
`POST /v1/audio/denoise` `POST /v1/audio/denoise`
These two paths call the same service and return `enhanced.wav`. Use either multipart `file=@noisy.wav` or raw audio body. These two paths call the same service and return `enhanced.wav`. Send multipart form upload with `file=@noisy.wav`.
Optional: Optional:
- `chunked`: form or query bool. Accepted truthy values include `1`, `true`, `yes`, `on`; falsy values include `0`, `false`, `no`, `off`. - `chunked`: form or query bool. Accepted truthy values include `1`, `true`, `yes`, `on`; falsy values include `0`, `false`, `no`, `off`.
@@ -202,10 +244,22 @@ Example:
```bash ```bash
curl -X POST https://ades.playones.com/v1/audio/enhance \ curl -X POST https://ades.playones.com/v1/audio/enhance \
-H 'Authorization: Bearer $OPENAI_API_KEY' \
-F 'file=@noisy.wav' \ -F 'file=@noisy.wav' \
-F 'chunked=false' \
--output enhanced.wav --output enhanced.wav
``` ```
Denoise alias:
```bash
curl -X POST https://ades.playones.com/v1/audio/denoise \
-H 'Authorization: Bearer $OPENAI_API_KEY' \
-F 'file=@noisy.wav' \
-F 'chunked=false' \
--output denoise.wav
```
## ASR Transcription ## ASR Transcription
`POST /v1/asr/transcribe` `POST /v1/asr/transcribe`
@@ -226,7 +280,7 @@ This is not audio generation. It returns JSON:
} }
``` ```
Input is multipart `file=@speech.wav` or raw audio body. Input is multipart form upload with `file=@speech.wav`.
Optional fields can be form or query params: Optional fields can be form or query params:
- `language`: normalized aliases include `zh`, `zh-cn`, `en`, `ja`, `ko`; default is `English` - `language`: normalized aliases include `zh`, `zh-cn`, `en`, `ja`, `ko`; default is `English`
@@ -240,14 +294,20 @@ Example:
```bash ```bash
curl -X POST https://ades.playones.com/v1/asr/transcribe \ curl -X POST https://ades.playones.com/v1/asr/transcribe \
-F 'file=@speech.wav' -H 'Authorization: Bearer $OPENAI_API_KEY' \
-F 'file=@speech.wav' \
-F 'language=auto' \
-F 'temperature=0.0' \
-F 'max_tokens=8192' \
-F 'top_k=0' \
-F 'top_p=1.0'
``` ```
## Health And Models ## Health And Models
```bash ```bash
curl https://ades.playones.com/health curl -H 'Authorization: Bearer $OPENAI_API_KEY' https://ades.playones.com/health
curl https://ades.playones.com/models curl -H 'Authorization: Bearer $OPENAI_API_KEY' https://ades.playones.com/models
``` ```
Model status keys: Model status keys:
@@ -270,6 +330,8 @@ Models are lazy-loaded on first request, so `loaded: false` can be normal before
- `400 denoise=true requires VOXCPM2_LOAD_DENOISER=true`: retry with `denoise=false` or reconfigure server. - `400 denoise=true requires VOXCPM2_LOAD_DENOISER=true`: retry with `denoise=false` or reconfigure server.
- `501 VOXCPM2_BACKEND=vllm_omni is not implemented in this build`: local ARM Mac path supports `official`. - `501 VOXCPM2_BACKEND=vllm_omni is not implemented in this build`: local ARM Mac path supports `official`.
- `503 failed to load ... model`: model package, model id, local weights, or optional `voxcpm` dependency is unavailable. - `503 failed to load ... model`: model package, model id, local weights, or optional `voxcpm` dependency is unavailable.
- `504 Gateway Time-out`: the request passed HTTP validation but generation exceeded the gateway timeout. Retry after model warmup, reduce `max_tokens` for short test requests, or inspect server logs.
- `500 Internal Server Error`: the request reached the application but failed internally. Re-run with `curl -i` and no `--output` to capture the response body; if the body is only `Internal Server Error`, inspect server logs.
## Environment Variables ## Environment Variables
@@ -46,10 +46,19 @@ def log(message: str) -> None:
print(message, flush=True) print(message, flush=True)
def request_headers(token: str | None) -> dict[str, str]: def bearer_token(token: str | None) -> str:
if not token: if not token:
raise SystemExit(KEY_HELP) raise SystemExit(KEY_HELP)
return {"Authorization": f"Bearer {token}"} token = token.strip()
if token.lower().startswith("bearer "):
token = token[7:].strip()
if not token:
raise SystemExit(KEY_HELP)
return token
def request_headers(token: str | None) -> dict[str, str]:
return {"Authorization": f"Bearer {bearer_token(token)}"}
def audio_files(input_dir: Path) -> list[Path]: def audio_files(input_dir: Path) -> list[Path]:
@@ -47,11 +47,20 @@ def bool_value(raw: str | bool | None) -> bool | None:
raise argparse.ArgumentTypeError(f"invalid boolean value: {raw}") raise argparse.ArgumentTypeError(f"invalid boolean value: {raw}")
def request_headers(token: str | None, extra: dict[str, str] | None = None) -> dict[str, str]: def bearer_token(token: str | None) -> str:
if not token: if not token:
raise SystemExit(KEY_HELP) raise SystemExit(KEY_HELP)
token = token.strip()
if token.lower().startswith("bearer "):
token = token[7:].strip()
if not token:
raise SystemExit(KEY_HELP)
return token
def request_headers(token: str | None, extra: dict[str, str] | None = None) -> dict[str, str]:
headers = dict(extra or {}) headers = dict(extra or {})
headers["Authorization"] = f"Bearer {token}" headers["Authorization"] = f"Bearer {bearer_token(token)}"
return headers return headers
@@ -108,10 +117,15 @@ def multipart_body(
def add_line(value: str) -> None: def add_line(value: str) -> None:
chunks.append(value.encode("utf-8")) chunks.append(value.encode("utf-8"))
def field_value(value: object) -> str:
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
for name, value in clean_payload(fields).items(): for name, value in clean_payload(fields).items():
add_line(f"--{boundary}\r\n") add_line(f"--{boundary}\r\n")
add_line(f'Content-Disposition: form-data; name="{name}"\r\n\r\n') add_line(f'Content-Disposition: form-data; name="{name}"\r\n\r\n')
add_line(f"{value}\r\n") add_line(f"{field_value(value)}\r\n")
if file_field and file_path: if file_field and file_path:
path = Path(file_path) path = Path(file_path)
@@ -4,6 +4,11 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![GitHub stars](https://img.shields.io/github/stars/hugohe3/ppt-master.svg)](https://github.com/hugohe3/ppt-master/stargazers) [![GitHub stars](https://img.shields.io/github/stars/hugohe3/ppt-master.svg)](https://github.com/hugohe3/ppt-master/stargazers)
[![AtomGit stars](https://atomgit.com/hugohe3/ppt-master/star/badge.svg)](https://atomgit.com/hugohe3/ppt-master) [![AtomGit stars](https://atomgit.com/hugohe3/ppt-master/star/badge.svg)](https://atomgit.com/hugohe3/ppt-master)
[![The Agentic Leaderboard](https://www.theagenticleaderboard.com/badges/ppt-master.svg)](https://www.theagenticleaderboard.com/agent/?q=ppt-master)
<p align="center">
<a href="https://trendshift.io/repositories/25760?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-25760" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/25760" alt="hugohe3%2Fppt-master | Trendshift" width="250" height="55"/></a>
</p>
English | [中文](./README_CN.md) English | [中文](./README_CN.md)
@@ -2,8 +2,8 @@
"sourceId": "ppt-master", "sourceId": "ppt-master",
"repo": "https://github.com/hugohe3/ppt-master.git", "repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main", "ref": "main",
"commit": "bf9ae01a8df43b1a86f459ed007e84ef465ae257", "commit": "54ef7c733480ca44061fc76d6d29dcf2227ecd67",
"adapter": "claude-skill", "adapter": "claude-skill",
"sourcePath": "skills/ppt-master", "sourcePath": "skills/ppt-master",
"syncedAt": "2026-06-22T16:00:01Z" "syncedAt": "2026-06-23T16:00:00Z"
} }
@@ -310,7 +310,7 @@ Read references/strategist.md
**Eight Confirmations** (full template: `templates/design_spec_reference.md`): **Eight Confirmations** (full template: `templates/design_spec_reference.md`):
**BLOCKING**: present the Eight Confirmations as a single bundled recommendation set and **wait for explicit user confirmation or modification** before outputting Design Specification & Content Outline. This is the single core confirmation point — once confirmed, all subsequent steps proceed automatically. **BLOCKING**: present the Eight Confirmations and **wait for explicit user confirmation or modification** before outputting Design Specification & Content Outline. This is the single core confirmation gate — once the final confirmation lands, all subsequent steps proceed automatically. The default Confirm UI delivers the gate in **two tiers** (anchors → re-derive → realization; see below); the chat fallback mirrors the same two steps.
1. Canvas format 1. Canvas format
2. Page count range 2. Page count range
@@ -321,21 +321,38 @@ Read references/strategist.md
7. Typography plan, including formula rendering policy 7. Typography plan, including formula rendering policy
8. Image usage approach 8. Image usage approach
**Confirm UI Auto-Launch (Mandatory — default visual confirmation surface)**: by default the Eight Confirmations are presented through an interactive local page (color swatches, live font previews, candidate picks); the chat path is the always-valid fallback. Steps: **Confirm UI Auto-Launch (Mandatory — default visual confirmation surface)**: by default the Eight Confirmations are presented through an interactive local page in **two tiers within one browser session** — Tier 1 confirms the *anchors*; the AI then re-derives the realization layer from the **user's actual** anchors; Tier 2 confirms that layer. Color swatches, live font previews, candidate picks; the chat path is the always-valid fallback. The split (full field rules: [`scripts/docs/confirm_ui.md`](scripts/docs/confirm_ui.md)):
1. Write the recommendations to `<project_path>/confirm_ui/recommendations.json` (full schema + field mapping: [`scripts/docs/confirm_ui.md`](scripts/docs/confirm_ui.md)). Two kinds of field: **enumerable** (canvas / mode / visual_style / icons / formula policy / generation mode / delivery purpose; plus image usage with a Custom path; plus AI source only when image usage may include `ai`) — the page lists common options from `confirm_ui/static/catalogs.json`, so you only name the recommended canonical `id` in a `recommend` block (canvas may be a catalog id like `ppt169` or a custom size/prose; style = `mode` + `visual_style`, two independent picks; icon ids are real libraries such as `tabler-outline`, or `emoji` for system emoji; image usage uses `ai` / `web` / `provided` / `placeholder` / `none`, or a custom prose plan when several image sources are mixed; never write bare `"custom"` for image usage — write the actual mixed plan, e.g. "AI cover + user product assets + web industry images"; write `image_ai_path` only when recommending `image_usage: "ai"` or a custom plan that includes AI); **generative** (color, typography, generated-image style) — author **≥3 candidates** each (creative recommendations always offer real choice, never a single silent option — same rule as strategist h.5; fewer than 3 only on the honest-shortfall exception, with a stated reason) (color: user-facing core `palette` with background/secondary_bg/primary/accent/secondary_accent/body_text; typography: CJK + Latin for `heading` and `body` with `css` preview stacks, plus `body_size` as the body baseline shown to the user (**pt** for PPT canvases, px for non-PPT) and `delivery_purpose` (PPT only: `text` / `balanced` / `presentation`, seeds the pt default); when recommending generated images, `image_strategy.candidates` with rendering × palette combinations from strategist h.5). `page_count` / `audience` / `content_divergence` are plain values (free text). Only open fields show a Custom box: `canvas`, `mode`, `visual_style`, `icons`, `image_usage`, and typography custom text. Closed fields (`image_ai_path`, `formula_policy`, `generation_mode`, `refine_spec`, `delivery_purpose`) stay finite. `content_divergence` is a **free-text** field shown under audience in §c — the user states in their own words how closely to follow the source vs how freely to reshape it (blank = balanced; facts stay sourced at every level). Write it as `content_divergence: { "value": "<prose or empty>" }`. It is consumed by Strategist when authoring `§IX`, recorded in `design_spec.md §I`, carries no page-count coupling, and is **not** written to `spec_lock.md`. Set `lang` to the page language; visible candidate text should match `lang`, or provide bilingual `name_zh` / `name_en` and `note_zh` / `note_en` fields. Reuse the same candidate thinking as strategist h.5. | Tier | Confirms | Driven by |
2. Launch the page **in the background and wait for the browser confirmation** (the child server runs detached; the parent command returns after `result.json` is freshly written). **Run this command with a long tool timeout — 600000 ms** — so the `--wait` (≈590 s budget) can complete: |---|---|---|
| **1 — anchors** | canvas · audience + core message + `content_divergence` + `delivery_purpose` *(PPT only — omitted on non-PPT canvases)* (all §c key info) · `mode` + `visual_style` | the source + user intent |
| **2 — realization** (re-derived from Tier 1) | page count · color · typography (font + size) · icons · formula policy · image usage + strategy · generation mode · refine-spec toggle | the confirmed Tier 1 |
> **Why two tiers.** Every realization field is anchored by the same few choices (`visual_style` anchors color / icon / typography / image; `delivery_purpose` sets the body size, page density, **and** the page-count recommendation). Confirming anchors first, then re-deriving, means Tier 2's candidates fit the user's *real* anchors instead of your originals — the coherence reconciliation below is done by construction on this path. Page count is a **derived** field (content volume × `delivery_purpose`), which is why it lives in Tier 2, not up front.
Steps:
> ⛔ **Steps 2 → 3 → 4 are ONE uninterrupted run — do NOT yield to the user mid-flow.** When the tier-1 `--wait` (step 2) returns, the AI **immediately and autonomously** continues to step 3 (re-derive + write Tier 2) and step 4 (`--wait-only`) in the **same turn**: do **not** summarize, ask a question, report progress, or end the turn in between. The browser is sitting on a "deriving…" spinner polling for the Tier 2 you must write next — stopping here strands the page and the user must prod you in chat to finish (a bug, not the intended flow). **The tier-1 confirmation is an intermediate machine handoff, not a stopping point.** The single ⛔ BLOCKING wait is the **final** confirmation at the end of step 4. (Chat-fallback path — only when the page never opened — is the exception: there you do present each tier in chat and wait for a reply.)
1. **Write Tier 1** to `<project_path>/confirm_ui/recommendations.json` with `"tier": 1` and only the anchor fields. Enumerable anchors (`canvas` / `mode` / `visual_style` / `delivery_purpose`) name a recommended canonical `id` in a `recommend` block (the page lists common options from `confirm_ui/static/catalogs.json`); `visual_style` also carries the ≥3-style `visual_style_spectrum` (safe / shifted / bold — same hard rule as h.5). `audience` and `content_divergence` are plain `{ "value": "<free text>" }`. `content_divergence` is the **free-text** field shown under audience in §c — how closely to follow the source vs how freely to reshape it (blank = balanced; facts stay sourced at every level); it is consumed by Strategist when authoring `§IX`, recorded in `design_spec.md §I`, carries no page-count coupling, and is **not** written to `spec_lock.md`. Set `lang` to the page language; visible text matches `lang`, or provide bilingual `name_zh` / `name_en` + `note_zh` / `note_en`.
2. **Launch + wait for Tier 1.** Background launch; the parent returns when the page writes the tier-1 `result.json`. **Long tool timeout — 600000 ms** (the `--wait` ≈590 s budget):
```bash ```bash
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --daemon --wait python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --daemon --wait
``` ```
Page opens at `http://localhost:5050` — the **same port as the Step 6 live preview** (they never run at once: this page shuts down at the end of Step 4, freeing the port). If another project already holds 5050, the launcher **auto-advances to the next free port** (5051, …) and serves this project there — read the actual URL from the launch log and report that. When the user clicks **Confirm**, the command exits 0 and Step 4 reads `result.json` immediately; do not require a second chat confirmation. **Launch or wait failure is non-fatal**: if it fails or times out (flask missing, port blocked, no GUI / remote / web host, browser never confirms in time), do **NOT** troubleshoot. The detached page stays open, so a slow user may confirm after the wait returns — therefore **on any non-zero exit, re-check `<project_path>/confirm_ui/result.json` once (a fresh `status: confirmed`) before** dropping to the chat-summary fallback below. Page opens at `http://localhost:5050` — the **same port as the Step 6 live preview** (they never run at once: this page shuts down at the end of Step 4). If 5050 is held, the launcher **auto-advances** (5051, …) — read the actual URL from the launch log and report it. The page does **not** close after Tier 1: it shows a "deriving…" state and polls for Tier 2. **Launch or wait failure is non-fatal**: if it fails or times out (flask missing, port blocked, no GUI / remote / web host), do **NOT** troubleshoot — **on any non-zero exit, re-check `result.json` once** (a fresh `status: tier1-confirmed`) before dropping to the chat fallback. **On success (exit 0 with a tier-1 result), do not pause or report — go straight to step 3 in the same turn.**
3. **Always also print the eight recommendations as a short summary in chat, with the URL.** This keeps the chat fallback valid whether or not the browser opened. If the page never appears, the user simply confirms or edits in chat as before. 3. **Re-derive Tier 2 from the confirmed anchors, then write it — immediately, same turn (the page is polling for it).** Read the tier-1 `result.json` (`status: tier1-confirmed`). Using the user's **actual** confirmed anchors (not your originals), author the realization candidates and **overwrite** `recommendations.json` with `"tier": 2`: page count (content volume × `delivery_purpose`); color, typography, and generated-image style as **generative ≥3-candidate** fields (creative recommendations always offer real choice — same rule as h.5; fewer than 3 only on the honest-shortfall exception, with a stated reason; color: core `palette` with background/secondary_bg/primary/accent/secondary_accent/body_text; typography: CJK + Latin for `heading` and `body` with `css` preview stacks + `body_size` as the body baseline in **px** (every canvas) — **one fixed value per confirmed `delivery_purpose`** (`text` 20 / `balanced` 24 / `presentation` 32), not a range; images: `image_strategy.candidates` rendering × palette from h.5); enumerable `icons` / `formula_policy` / `generation_mode` (recommended `id`); `image_usage` (`ai` / `web` / `provided` / `placeholder` / `none`, or a custom prose plan when several sources mix — never bare `"custom"`; write `image_ai_path` only when the plan includes AI). The still-open page polls, renders Tier 2, and preserves the user's Tier 1 picks. Closed fields (`image_ai_path`, `formula_policy`, `generation_mode`, `refine_spec`) stay finite; open fields (`icons`, `image_usage`, typography custom text) show a Custom box.
4. This is the ⛔ BLOCKING wait. Preferred page path: the `--wait` command returns after the page writes a fresh `<project_path>/confirm_ui/result.json`; immediately read that file and use its values. On a non-zero exit, re-check `result.json` once (per step 2) — a fresh `status: confirmed` still wins. Chat fallback path: only if no fresh result exists (page didn't open, wait timed out with no confirmation, or the user replies in chat with edits) take the chat values directly. Either path converges. **Typography normalization is mandatory before any spec writing**: for PPT canvases, chat-confirmed `pt` values must be converted immediately to unitless px (`px = pt × 4/3`) so `design_spec.md`, `spec_lock.md`, and SVG all see px only; keep the original pt only as provenance (`body_size_pt` / `sizes_pt`) if useful. A confirmed `result.json` is an explicit user choice: `generation_mode: "split"` means split mode was chosen; `refine_spec: true` means the refine-spec workflow was chosen. 4. **Wait for the final confirmation** — attach to the already-running page, do **not** relaunch (same 600000 ms budget):
5. **Close the confirm page (Mandatory cleanup — every path).** Once you have the confirmed values (page **or** chat), shut the confirm server down before leaving Step 4 so it cannot keep holding port 5050 (which Step 6 live preview reuses): ```bash
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --wait-only
```
This is the ⛔ BLOCKING completion: returns when the page writes the final `result.json` (`status: confirmed`, `stage: final`, carrying all Tier 1 + Tier 2 fields). On a non-zero exit, re-check `result.json` once. Confirmed sizes are **already px** (the system is px-only — no pt anywhere, no conversion): write `result.json` `typography.body_size` / `sizes` into `design_spec.md` / `spec_lock.md` / SVG verbatim. `generation_mode: "split"` / `refine_spec: true` are explicit user choices.
5. **Close the confirm page (Mandatory cleanup — every path).** Shut the server down before leaving Step 4 so it cannot keep holding port 5050 (which Step 6 live preview reuses):
```bash ```bash
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --shutdown python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --shutdown
``` ```
This is **idempotent and required regardless of whether Confirm was clicked**: clicking Confirm already shuts the page down (this is then a no-op), but the chat-fallback path leaves the page running — without this cleanup it would block the live preview launch. Run it after reading the confirmation and before proceeding to Step 5. **Idempotent and required regardless of whether Confirm was clicked**: clicking the final Confirm already shuts the page down (then a no-op); the chat-fallback path leaves it running. Run it after reading the confirmation, before Step 5.
**Always also print each tier's recommendations + URL in chat** as the always-valid fallback. **The chat fallback is two-step too**: if the page never opens or a wait times out with no fresh result, present Tier 1 in chat → get confirmation → re-derive → present Tier 2 → get confirmation → take those values. Either path converges.
**Honoring the confirmation (result.json is authoritative — Mandatory)**: the confirmed values **override your own recommendations** when you write `design_spec.md` / `spec_lock.md`. A user who changed any field changed it on purpose. In particular, map `image_usage` to §VIII `Acquire Via` (its value names differ from §h options — translate): **Honoring the confirmation (result.json is authoritative — Mandatory)**: the confirmed values **override your own recommendations** when you write `design_spec.md` / `spec_lock.md`. A user who changed any field changed it on purpose. In particular, map `image_usage` to §VIII `Acquire Via` (its value names differ from §h options — translate):
@@ -349,6 +366,18 @@ Read references/strategist.md
When the confirmed `image_usage` is not `ai` (and the plan has no AI part), do **NOT** run h.5, do **NOT** write `ai` rows, and do **NOT** generate images in Step 5 — regardless of what you recommended. The same "confirmed value wins" rule applies to every field (color → §III, typography → §IV, etc.). When the confirmed `image_usage` is not `ai` (and the plan has no AI part), do **NOT** run h.5, do **NOT** write `ai` rows, and do **NOT** generate images in Step 5 — regardless of what you recommended. The same "confirmed value wins" rule applies to every field (color → §III, typography → §IV, etc.).
**Upstream override → re-derive untouched downstream (Mandatory — chat-fallback / single-pass path).** On the **two-tier page path this is already handled** (Step 3 re-derives Tier 2 from the user's actual anchors). It still applies whenever anchors and realization are confirmed **together** — the two-step chat fallback collapsed into one bundle, or a legacy single-pass `result.json`. "Confirmed value wins" governs each field's *own* value — never recompute a value the user set (a size, canvas, or palette they edited stays verbatim). But a single-pass `result.json` can carry a changed **anchor** beside downstream fields still holding your original — now incoherent — recommendation (e.g. switched to `dark-tech` while the light palette you proposed is untouched). Before writing the spec, reconcile: when the user changed an anchor, re-derive the downstream fields the user did **not** themselves edit so they realize the new anchor; fields the user pinned stay as confirmed.
| Anchor the user changed | Re-derive (only the downstream fields the user left at your recommendation) |
|---|---|
| `visual_style` (§d Layer 2 — anchors eh) | color neutral tiers (§e), icon library / stroke (§f), typography character (§g), image rendering (§h.5) |
| `mode` (§d Layer 1) | outline structure + register (§IX) |
| `delivery_purpose` (§g) | body baseline + per-page density / rhythm (§6.1) |
| `audience` / core message (§c) | tone across eh, outline emphasis (§IX) |
| `color` HEX (§e) | h.5 palette (re-filter for the new HEX) |
Reconcile **without a new blocking wait** — fold the coherent values into `design_spec.md` / `spec_lock.md` and state the adjustment in the §8 next-step handoff (e.g. "you switched to `dark-tech`; the light palette you had left no longer fit, so background / accent were re-derived — tell me if you wanted the original"). Canvas is the explicit exception: font sizes are deliberately **not** rescaled on a canvas change (see strategist §g).
**Opt-out**: if the user has said they don't want the page (e.g. "不要网页" / "just confirm in chat" / "纯聊天确认"), skip the launch entirely (step 2) and present the Eight Confirmations in chat as before — steps 1, 3, 4 still apply (recommendations summary in chat; wait; take chat values). **Opt-out**: if the user has said they don't want the page (e.g. "不要网页" / "just confirm in chat" / "纯聊天确认"), skip the launch entirely (step 2) and present the Eight Confirmations in chat as before — steps 1, 3, 4 still apply (recommendations summary in chat; wait; take chat values).
The page is a **confirmation surface only** — Strategist still authors every recommendation; the page never generates content. The page is a **confirmation surface only** — Strategist still authors every recommendation; the page never generates content.
@@ -487,7 +516,7 @@ Read references/visual-styles/<locked-style>.md # aesthetic (spec_lock.md `vis
**Live Preview Auto-Startup (Mandatory)**: before the first SVG, automatically start the browser editor in live mode and keep it running continuously through Executor + Step 7 export: **Live Preview Auto-Startup (Mandatory)**: before the first SVG, automatically start the browser editor in live mode and keep it running continuously through Executor + Step 7 export:
```bash ```bash
python3 ${SKILL_DIR}/scripts/svg_editor/server.py <project_path> --live python3 ${SKILL_DIR}/scripts/svg_editor/server.py <project_path> --live --daemon
``` ```
- Start it immediately when Executor begins; `svg_output/` may be empty. Editor opens at `http://localhost:5050`; if another project already holds it, the launcher **auto-advances to the next free port** — read the actual URL from the launch log and report that. - Start it immediately when Executor begins; `svg_output/` may be empty. Editor opens at `http://localhost:5050`; if another project already holds it, the launcher **auto-advances to the next free port** — read the actual URL from the launch log and report that.
- Run it as a long-running side process/session; do not wait for it to exit before generating SVG pages. Do not wait for user confirmation after startup. - Run it as a long-running side process/session; do not wait for it to exit before generating SVG pages. Do not wait for user confirmation after startup.
@@ -62,7 +62,7 @@ Content purpose?
| Story | — | Middle 1500px | Top safe zone 120px, bottom 180px | | Story | — | Middle 1500px | Top safe zone 120px, bottom 180px |
| WeChat Article Header | Center/left-aligned 48-72px | — | Image on right or as background | | WeChat Article Header | Center/left-aligned 48-72px | — | Image on right or as background |
> **Body font baseline scales with canvas and delivery purpose** — a PPT 16:9 baseline confirmed for read-close / business / projection cannot be carried onto tall canvases (Xiaohongshu / Story / A4). Pick the baseline from the confirmed canvas, not the recommended one; see the per-canvas anchors and PPT pt→px normalization in [`strategist.md`](strategist.md) §g "Font Size Ramp". > **Body font baseline scales with canvas and delivery purpose** — a PPT 16:9 baseline confirmed for read-close / business / projection cannot be carried onto tall canvases (Xiaohongshu / Story / A4). Pick the baseline from the confirmed canvas, not the recommended one; see the per-canvas px anchors in [`strategist.md`](strategist.md) §g "Font Size Ramp" (the system is px-only — all sizes are unitless px on every canvas).
## ViewBox Examples ## ViewBox Examples
@@ -41,7 +41,17 @@ Resolve the per-page template SVG via `spec_lock.md page_layouts` (authoritative
**Templates supply structure, not skin (non-mirror)**: a chart or layout template's gradients, drop-shadows, palette, **and font sizes** are placeholder. Inherit its geometry, label / legend placement, and series-encoding logic; re-skin every fill / stroke to the deck's `visual_style` + `spec_lock.colors` — flat styles strip the gradients and shadows, gradient / glass styles repaint their own. Forbidden — shipping a template's default `<linearGradient>` / `cardShadow` / Tailwind fills unchanged. Mirror templates are the exception: §1.1 preserves their visuals verbatim. **Templates supply structure, not skin (non-mirror)**: a chart or layout template's gradients, drop-shadows, palette, **and font sizes** are placeholder. Inherit its geometry, label / legend placement, and series-encoding logic; re-skin every fill / stroke to the deck's `visual_style` + `spec_lock.colors` — flat styles strip the gradients and shadows, gradient / glass styles repaint their own. Forbidden — shipping a template's default `<linearGradient>` / `cardShadow` / Tailwind fills unchanged. Mirror templates are the exception: §1.1 preserves their visuals verbatim.
**Font size is skin, not geometry (non-mirror).** A chart / layout template's hardcoded `font-size` values (often 1116px, sized for the template's own dense placeholder text) are NOT inherited — classify each text into its `spec_lock.md` role and use that role's locked size, exactly as you re-skin color. **Structural roles (page title / body / subtitle / annotation / footnote) hold their one deck-wide size on every page** — the template's placeholder px never overrides it; same-role text drifting page to page is what makes a deck look unprofessional. **Geometry adapts to the type, never the reverse**: when the locked size is larger than the template's placeholder text, widen / heighten the card, open spacing, and recompute child `y` / `dy` to make room — do not shrink the font to fit the inherited container. The Executor renders the page it was given; page count and per-page density are the Strategist's call, fixed at confirmation — do **not** re-paginate, split the page, or drop authored content to cope with size here. Only when a single block still cannot fit after the geometry is fully reflowed may you shrink **that block** as a bounded last resort — and **only body text** is ever shrunk this way. Title, subtitle, annotation / caption, footnote and page number are **locked once set and never adjusted to fit** — their values hold across the whole deck. Step the overflowing body block's `font-size` down by `2.67`px (one 2pt step) at a time, and only if it still overflows step it down again, up to a cumulative floor of **`5.33`px (4pt) below the locked body size** (e.g. `26.67` → no smaller than `21.33`). This is a **local, single-block** reduction — the deck-wide locked body size is unchanged on every other block and page. (The Executor works in unitless px — spec_lock and SVG carry no `pt`; `2.67`px ≈ 2pt, `5.33`px ≈ 4pt.) If the block still overflows at the 4pt floor, surface a `warning:` rather than silently restructure the page. (Mirror templates are the exception: §1.1 preserves their sizes verbatim — there the source deck's typography *is* the spec.) **Font size is skin, not geometry (non-mirror).** A chart / layout template's hardcoded `font-size` values (often 1116px, sized for the template's own dense placeholder text) are NOT inherited — classify each text into its `spec_lock.md` role and use that role's locked size, exactly as you re-skin color. **Structural roles (page title / body / subtitle / annotation / footnote) hold their one deck-wide size on every page** — the template's placeholder px never overrides it; same-role text drifting page to page is what makes a deck look unprofessional.
**Typography execution order (mandatory):**
1. Build a per-page text inventory from `design_spec.md §IX` + the current `notes/<NN>_*.md`.
2. Classify each text item before drawing. **Structural roles** (`title`, `subtitle` / `lead`, `body`, `annotation`, `footnote` / `page_number`) must map to their declared `spec_lock.typography` slot. A **one-off feature element** (a single hero number, an isolated emphasis label) may take an in-ramp intermediate value — the ramp is anchored on `body`, not a closed menu — but a feature size that **recurs** must be promoted to a declared slot. The failure mode this guards against is structural text silently inheriting the template's compact px, not legitimate feature sizing.
3. Copy the role's locked px value into `font-size` verbatim. Do this before placing the text; never start from a template `font-size` and then "adjust".
4. Layout from those locked sizes: compute line-height, wrapped line count, child `y` / `dy`, card padding, card height, column gaps, and available image/chart area from the chosen px values.
5. Only after this reflow may you inspect fit. If fit fails, move / resize containers or simplify local geometry first; do not reduce the role size merely because the inherited template slot was smaller.
**Geometry adapts to the type, never the reverse**: when the locked size is larger than the template's placeholder text, widen / heighten the card, open spacing, and recompute child `y` / `dy` to make room — do not shrink the font to fit the inherited container. A `font-size` change is a layout change: revise line-height and every downstream vertical coordinate that depends on it. For wrapped text, allocate at least the wrapped line count × line-height plus top / bottom padding; fixed `y` stacks copied from a smaller template are invalid once the locked role size is applied. The Executor renders the page it was given; page count and per-page density are the Strategist's call, fixed at confirmation — do **not** re-paginate, split the page, or drop authored content to cope with size here. Only when a single block still cannot fit after the geometry is fully reflowed may you shrink **that block** as a bounded last resort — and **only body text** is ever shrunk this way. Title, subtitle, annotation / caption, footnote and page number are **locked once set and never adjusted to fit** — their values hold across the whole deck. Step the overflowing body block's `font-size` down by `2`px at a time, and only if it still overflows step it down again, up to a cumulative floor of **`4`px below the locked body size** (e.g. `24` → no smaller than `20`). This is a **local, single-block** reduction — the deck-wide locked body size is unchanged on every other block and page. (The Executor works in **unitless px** throughout — spec_lock and SVG carry no `pt`.) If the block still overflows at the floor, surface a `warning:` rather than silently restructure the page. (Mirror templates are the exception: §1.1 preserves their sizes verbatim — there the source deck's typography *is* the spec.)
### 1.1 Mirror-mode templates — reference-style consumption ### 1.1 Mirror-mode templates — reference-style consumption
@@ -109,7 +119,8 @@ Before the first SVG page, output a confirmation listing: canvas dimensions, bod
- Icons MUST come from `icons.inventory`; library MUST equal `icons.library` - Icons MUST come from `icons.inventory`; library MUST equal `icons.library`
- Font family from `typography`: use role override (`title_family` / `body_family` / `emphasis_family` / `code_family`) if declared, else fall back to `font_family` - Font family from `typography`: use role override (`title_family` / `body_family` / `emphasis_family` / `code_family`) if declared, else fall back to `font_family`
- Font sizes follow a **ramp anchored on `typography.body`**, not a closed menu. **Structural roles — page title, body, subtitle, annotation / caption, footnote / page number — render at one consistent size deck-wide, taken from their `spec_lock` slot; never re-pick a structural role's size page by page or carry a template's placeholder px.** This locks the **role**, not every glyph: a page may still carry deliberate typographic hierarchy — a lead-in sentence, an inline emphasis figure, a pull-quote, a kicker, a hero number — but each of those is its **own role / feature element** with its own size, **applied consistently deck-wide** (declare a recurring one as its own `spec_lock` slot). In-band intermediate sizes are for exactly these feature elements. What is banned is the *same* role drifting size to fit a container or by page whim — that scatter is what reads as unprofessional. Sizes outside every band require extending the lock first. - Font sizes follow a **ramp anchored on `typography.body`**, not a closed menu. **Structural roles — page title, body, subtitle, annotation / caption, footnote / page number — render at one consistent size deck-wide, taken from their `spec_lock` slot; never re-pick a structural role's size page by page or carry a template's placeholder px.** This locks the **role**, not every glyph: a page may still carry deliberate typographic hierarchy — a lead-in sentence, an inline emphasis figure, a pull-quote, a kicker, a hero number — but each of those is its **own role / feature element** with its own size, **applied consistently deck-wide** (declare a recurring one as its own `spec_lock` slot). In-band intermediate sizes are for exactly these feature elements. What is banned is the *same* role drifting size to fit a container or by page whim — that scatter is what reads as unprofessional. Sizes outside every band require extending the lock first.
- **Write `font-size` to at most 2 decimals**prefer a whole number; keep the 2-decimal form only for a slot that carries it from `spec_lock` (e.g. `body: 26.67`). Never emit long tails like `20.8026`: the exporter snaps the final size to the nearest 0.5pt, so extra px precision is wasted noise. - **The page's core message is primary — render it ≥ `body`.** The one-idea / key-claim / key-takeaway line a page is built around is its most important text; map it to the locked `lead` or `subtitle` slot (≥ `body`), never to a sub-`body` size. Demoting it below body while data callouts or labels sit larger inverts the hierarchy — the failure this prevents. If no `lead` / `subtitle` slot is locked for a recurring core-message line, surface it (per below) instead of improvising a smaller one. A footnote / page number / source credit uses the locked `footnote` (or `annotation`) slot — never an invented sub-`annotation` size; and the body-shrink last resort (§1.0) bottoms out at `body 4`px, a hard floor never crossed.
- **Write the locked px verbatim; at most 2 decimals.** `font-size` MUST be the exact px from `spec_lock.typography` — if `body` is `24`, write `24`; never substitute a "rounder" or PowerPoint-familiar number (`20` / `18` / `36`). The system is px-only — there is no pt to convert, and a remembered pt-style value written as px renders the whole deck the wrong size. Prefer whole numbers (sizes are clean even px); keep a decimal only for a slot that genuinely carries one in `spec_lock`. Never emit long tails like `20.8026`: the exporter rounds the final size to 1 decimal pt, so extra px precision is wasted noise.
- Images MUST reference files listed under `images`; no invented filenames - Images MUST reference files listed under `images`; no invented filenames
- Formula PNGs are images with `Acquire Via: formula` / `Status: Rendered`; place them only from the listed file path and never recreate the formula as text. - Formula PNGs are images with `Acquire Via: formula` / `Status: Rendered`; place them only from the listed file path and never recreate the formula as text.
@@ -22,7 +22,16 @@ As a top-tier AI presentation strategist, receive source documents, perform cont
🚧 **GATE — Mandatory read first**: `read_file templates/design_spec_reference.md` before any analysis or writing. The design_spec.md output MUST follow that template's 11-section structure exactly. After writing, self-check each section is present: I Project Info → II Canvas → III Visual Theme → IV Typography → V Layout → VI Icon → VII Visualization → VIII Image → IX Outline → X Speaker Notes → XI Tech Constraints. 🚧 **GATE — Mandatory read first**: `read_file templates/design_spec_reference.md` before any analysis or writing. The design_spec.md output MUST follow that template's 11-section structure exactly. After writing, self-check each section is present: I Project Info → II Canvas → III Visual Theme → IV Typography → V Layout → VI Icon → VII Visualization → VIII Image → IX Outline → X Speaker Notes → XI Tech Constraints.
**BLOCKING**: After the read, present professional recommendations for the eight items below as a bundled package and wait for explicit user confirmation. **BLOCKING**: After the read, present professional recommendations for the eight items below and wait for explicit user confirmation.
**Two-tier confirmation (the default Confirm UI flow; chat mirrors it).** The eight items split into a dependency order, confirmed in two passes:
| Tier | Items | Role |
|---|---|---|
| **1 — anchors** | `a` canvas · `c` key info — audience + `content_divergence` + **delivery purpose** *(PPT only)* (promoted out of `g`) · `d` mode + visual_style | confirmed first |
| **2 — realization** (re-derived from the user's *actual* Tier 1) | `b` page count · `e` color · `f` icon · `g` typography (font + size) · `h` image | derived from Tier 1 |
The realization items are anchored by Tier 1 — `visual_style` governs `e` / `f` / `g` / `h` (§d Layer 2), and delivery purpose sets the §g body size (one fixed value per purpose), page density, **and** the `b` page-count recommendation. So author Tier 2 *after* Tier 1 is confirmed, against the user's real choices. **Page count is derived, not an anchor** — it follows content volume × delivery purpose, which is why it is Tier 2. The launch / re-derive / wait mechanics live in [SKILL.md Step 4](../SKILL.md); the item specs below keep their `a``h` letters.
> **Execution discipline**: This is the last BLOCKING checkpoint in the pipeline. After confirmation, complete the Design Spec and proceed to image generation / SVG / post-processing without further pauses. > **Execution discipline**: This is the last BLOCKING checkpoint in the pipeline. After confirmation, complete the Design Spec and proceed to image generation / SVG / post-processing without further pauses.
> >
@@ -36,12 +45,14 @@ Recommend format based on scenario (see [`canvas-formats.md`](canvas-formats.md)
### b. Page Count Confirmation ### b. Page Count Confirmation
Provide specific page count recommendation based on source document content volume **and the deck's delivery purpose** (文字型 packs denser → the same source fits in fewer pages; 展示型 is one-idea-per-page → the same source may need more) — see §6.1 Content Planning Strategy. The user's confirmed count still wins; delivery purpose governs density and per-page treatment within it. **Tier-2 (derived).** Page count is not an anchor — recommend it only after the Tier-1 delivery purpose is confirmed, since the same source yields a different count by purpose. Provide a specific page count recommendation based on source document content volume **and the confirmed delivery purpose** (`text` packs denser → the same source fits in fewer pages; `presentation` is one-idea-per-page → the same source may need more) — see §6.1 Content Planning Strategy. The user's confirmed count still wins; delivery purpose governs density and per-page treatment within it.
### c. Key Information Confirmation ### c. Key Information Confirmation
Confirm target audience, usage occasion, and core message; provide initial assessment based on document nature. Confirm target audience, usage occasion, and core message; provide initial assessment based on document nature.
**Delivery purpose** (PPT only) is confirmed here, beside audience, as part of the key information — the deck's consumption mode: `text` (read-close) / `balanced` (business, default) / `presentation`. It is a Tier-1 anchor: it sets the §g body size to one fixed value per purpose, plus the type character, page density, and the §b page-count recommendation (the size and page count re-derived in Tier 2). Recommend one (`recommend.delivery_purpose`, default `balanced`) and let the user confirm. The fixed body value per purpose lives in §g; the density / treatment side lives in §6.1 — here it is surfaced as a key-information choice, not a separate typography step.
**Material divergence** — a **free-text** intent the user states beside audience (same content-strategy cluster): in their own words, how closely the deck should follow the source vs how freely it may reshape it. This is the user's own call — a free prose field (`content_divergence`), **not** a fixed set of options and **not** something you recommend from analyzing the source. Surface the question (in the confirm UI it is a text box under audience; in chat, ask it plainly); leave it for the user to fill. Blank = a balanced default. **Material divergence** — a **free-text** intent the user states beside audience (same content-strategy cluster): in their own words, how closely the deck should follow the source vs how freely it may reshape it. This is the user's own call — a free prose field (`content_divergence`), **not** a fixed set of options and **not** something you recommend from analyzing the source. Surface the question (in the confirm UI it is a text box under audience; in chat, ask it plainly); leave it for the user to fill. Blank = a balanced default.
Read the user's prose as a point on a spectrum and apply judgment — from *stay close* (track the source's structure and wording, tune only for clarity, no substantive add / drop) through the default *balanced* (re-architect and distill into a narrative under the locked `mode`, keeping all substance) to *free* (regroup, reframe, expand terse points, draw out connections latent in the source, invent section structure and transitions). Read the user's prose as a point on a spectrum and apply judgment — from *stay close* (track the source's structure and wording, tune only for clarity, no substantive add / drop) through the default *balanced* (re-architect and distill into a narrative under the locked `mode`, keeping all substance) to *free* (regroup, reframe, expand terse points, draw out connections latent in the source, invent section structure and transitions).
@@ -219,35 +230,39 @@ See [`../templates/icons/README.md`](../templates/icons/README.md) for the curre
> - **Calligraphic display** — 隶书 LiSu / 华文行楷 STXingkai / 华文新魏 STXinwei (closest safe substitute: `KaiTi` / `FangSong`); cover / section / hero titles only, never body > - **Calligraphic display** — 隶书 LiSu / 华文行楷 STXingkai / 华文新魏 STXinwei (closest safe substitute: `KaiTi` / `FangSong`); cover / section / hero titles only, never body
> - **Brand-specific** — McKinsey Bower, corporate VI typefaces > - **Brand-specific** — McKinsey Bower, corporate VI typefaces
#### Font Size Ramp (confirmation layer in pt → spec layer in px) #### Font Size Ramp (px throughout)
> **Ramp, not a fixed menu.** All sizes derive from the `body` baseline as a ratio. `spec_lock.md typography` declares `body` plus the slots this deck uses (`title` / `subtitle` / `annotation` by default; add `cover_title` / `hero_number` / `subheading` / `lead` / `chart_annotation` as needed). **Structural roles (page title / body / subtitle / annotation / footnote) resolve to one size each and hold it deck-wide** — that consistency is what reads as professional. Picking an intermediate in-band size is for special / feature elements (hero number, display title, one-off emphasis); declare a recurring special size as its own slot so it stays consistent too. > **Ramp, not a fixed menu.** All sizes derive from the `body` baseline as a ratio. `spec_lock.md typography` declares `body` plus the slots this deck uses (`title` / `subtitle` / `annotation` by default; add `cover_title` / `hero_number` / `subheading` / `lead` / `footnote` / `chart_annotation` as the outline demands).
>
> **Mandatory — scan `§IX` before locking and declare a slot for every role that recurs across pages.** Do not ship only the four defaults when the outline plainly carries more. A report / `text`-mode deck almost always recurs a per-page **core-message / lead line** (the one-sentence key-claim / takeaway under the title) and recurring **page numbers / source credits / footnotes** — declare `lead` (in the 1.11.4× lead band, and **always ≥ `body`** — the core message is a primary line, never smaller than body) and `footnote` (keep it readable — **~16px for a standard body, not shrunk smaller**) for them. Leaving these undeclared forces the Executor to improvise an unlocked size, and a core line improvised *below* `body` (with data callouts sitting larger) is exactly the hierarchy inversion this prevents. Recurring chart / figure labels get `chart_annotation` likewise.
>
> **Structural roles (page title / body / subtitle / annotation / footnote) resolve to one size each and hold it deck-wide** — that consistency is what reads as professional. Picking an intermediate in-band size is for special / feature elements (hero number, display title, one-off emphasis); a recurring one is declared as its own slot so it stays consistent too.
> **Unit boundary (HARD rule).** PPT canvases may use **pt only in the confirmation layer** (`recommendations.json`, the Confirm UI, or chat fallback). Before writing `design_spec.md`, normalize the confirmed font sizes to **unitless px** (`px = pt × 4/3`) and keep px as the only design/execution value in `design_spec.md`, `spec_lock.md`, and SVG. Keep original pt only as provenance (`body_size_pt` / `sizes_pt`) when useful. Non-PPT canvases use px everywhere. Never write `pt` / `px` / `em` units into `spec_lock.md` or SVG; those layers carry unitless px numbers only. Geometry — margins, gaps, card sizes — is px everywhere. > **Unit boundary (HARD rule).** The system is **px-only**. `recommendations.json`, the Confirm UI, `result.json`, `design_spec.md`, `spec_lock.md`, and SVG **all carry unitless px** — there is no pt layer anywhere, and no pt→px conversion step. pt exists only as the size PowerPoint happens to show *after export* (`px × 0.75`, rounded to 1 decimal); it is never an input, a confirmation value, or a provenance field (no `body_size_pt` / `sizes_pt`). Never write `pt` / `px` / `em` units; every layer carries bare px numbers. Geometry — margins, gaps, card sizes — is px too. (Beautify reads a source deck's pt at intake but converts to px **before** any recommendation — see [`beautify-pptx.md`](../workflows/beautify-pptx.md); pt never re-enters the contract.)
**Baseline — pick by delivery purpose first, then density.** Delivery purpose is one of the Eight Confirmations (item 7 typography); it is the primary driver because the same canvas reads very differently when read close vs. projected. It is a **deck-wide** axis — beyond the body baseline it also drives page density / count / rhythm; see §6.1 for that side. Here it sets the body baseline: **Baseline — one fixed value per delivery purpose, not a range.** Delivery purpose is a **Tier-1 anchor confirmed in §c key information** (beside audience, not as a separate typography step — see §1 Two-tier confirmation); it is the primary driver because the same canvas reads very differently when read close vs. projected. It is a **deck-wide** axis — beyond the body baseline it also drives page density / count / rhythm; see §6.1 for that side. Here, with the purpose confirmed, it sets the body baseline to a single value:
| Delivery purpose (PPT 16:9) | Body (pt) | Default | Reads as | | Delivery purpose (PPT 16:9) | Body (px) | Reads as |
|---|---|---:|---| |---|---:|---|
| 文字型 · read-close (report, data-dense brief, leave-behind file) | 1418 | 16pt | screen / handout reading at arm's length | | `text` · read-close (report, data-dense brief, leave-behind file) | 20px | screen / handout reading at arm's length |
| 均衡 · business (presented **and** read; roadshow, review) — **default** | 1822 | 20pt | mixed projection + reading | | `balanced` · business (presented **and** read; roadshow, review) — **default** | 24px | mixed projection + reading |
| 展示型 · presentation (projected, sparse; keynote, launch, classroom) | 2228 | 24pt | room projection, glance from the back | | `presentation` (projected, sparse; keynote, launch, classroom) | 32px | room projection, glance from the back |
Within the chosen purpose band, **density** picks the point (chart-heavy / 6+ items → low end · medium → mid · sparse / poster / cover → high end), and **visual style** nudges it (technical / data toward low, corporate / instructional toward mid, editorial / narrative / showcase toward high). The body baseline is **purely a function of delivery purpose** — density and visual style do **not** nudge it within a range. Body size is the reading-distance proxy; density is orthogonal and shows in **how much text per page, page count, and `page_rhythm`** (§6.1), the *other* roles, and decoration — never in growing or shrinking the body baseline. One purpose → one body size, identical across the deck. (The user may still override the value in confirmation; absent an override, this fixed value is the recommendation.)
| Canvas | Height | Body baseline | Unit | | Canvas | Height | Body baseline | Unit |
|---|---|---|---| |---|---|---|---|
| PPT 16:9 / 4:3 | 720 / 768 | 1428 (by delivery purpose above) | **pt** | | PPT 16:9 / 4:3 | 720 / 768 | 20 / 24 / 32 (by delivery purpose) | **px** |
| Xiaohongshu | 1242×1660 | 4055 | px | | Xiaohongshu | 1242×1660 | 4055 | px |
| WeChat / IG 1:1 | 1080×1080 | 2736 | px | | WeChat / IG 1:1 | 1080×1080 | 2736 | px |
| Story / Portrait | 1080×1920 | 4864 | px | | Story / Portrait | 1080×1920 | 4864 | px |
| A4 | 1240×1754 | 4458 | px | | A4 | 1240×1754 | 4458 | px |
> **PPT confirms in pt, specs carry px.** Only the PPT canvases (16:9 / 4:3) expose pt in the confirmation layer because they export to a real PowerPoint slide where pt is meaningful. Social / print canvases (Xiaohongshu / Story / A4, viewed close on phone or in print) have no PPT pt semantics, so author their body baseline directly in px and skip the conversion step. > **Every canvas authors px directly.** PPT and social / print alike — the body baseline is a px number (PPT by delivery purpose above; social / print by the per-canvas value). No pt confirmation, no conversion step on any canvas.
> **Confirmed values win — never recompute over them.** The user's confirmed sizes are authoritative. **Confirm UI path**: take `result.json` `typography.body_size` / `sizes` (already px) **verbatim** — do **not** re-derive from the canvas even if the user changed it. The page deliberately does not auto-rescale font sizes when the canvas changes (it only updates the suggested-range hint), so `result.json` already reflects the user's intent; recomputing here would silently override their choice. **Chat-fallback path** (no `result.json`): size from the confirmed canvas + delivery purpose (convert via `pt_to_px.py` for PPT). The `body_size` in `recommendations.json` is only a stale hint once the canvas changes — use the confirmed value, not the recommendation. > **Confirmed values win — never recompute over them.** The user's confirmed sizes are authoritative. **Confirm UI path**: take `result.json` `typography.body_size` / `sizes` (already px) **verbatim** — do **not** re-derive from the canvas even if the user changed it. The page deliberately does not auto-rescale font sizes when the canvas changes (it only updates the recommended-value hint), so `result.json` already reflects the user's intent; recomputing here would silently override their choice. **Chat-fallback path** (no `result.json`): take the px body baseline for the confirmed canvas + delivery purpose directly from the table above (no conversion). The `body_size` in `recommendations.json` is only a stale hint once the canvas changes — use the confirmed value, not the recommendation.
| Level | Ratio to body | 32px baseline (24pt UI) | 24px baseline (18pt UI) | | Level | Ratio to body | 32px baseline (`presentation`) | 24px baseline (`balanced`) |
|-------|---------------|---------------|---------------| |-------|---------------|---------------|---------------|
| Cover title (hero headline) | 2.5-5x | 80-160px | 60-120px | | Cover title (hero headline) | 2.5-5x | 80-160px | 60-120px |
| Chapter / section opener | 2-2.5x | 64-80px | 48-60px | | Chapter / section opener | 2-2.5x | 64-80px | 48-60px |
@@ -260,17 +275,11 @@ Within the chosen purpose band, **density** picks the point (chart-heavy / 6+ it
| Annotation / caption | 0.7-0.85x | 22-27px | 17-20px | | Annotation / caption | 0.7-0.85x | 22-27px | 17-20px |
| Page number / footnote | 0.5-0.65x | 16-21px | 12-16px | | Page number / footnote | 0.5-0.65x | 16-21px | 12-16px |
> Two baseline columns are illustrative only — for any other normalized `body` px value (21.33 / 26.67 / 29.33 / 32 / ...), multiply the row's ratio. Structural roles (page title / body / subtitle / annotation / footnote) take their locked slot value and stay there on every page — not a per-page pick. In-band freedom without pre-declaring is for special / feature elements (hero number, display title, one-off emphasis); a recurring special size should be declared as its own slot. The subtitle / lead / subheading bands overlap on purpose — pick by role, not size, then hold each at one size deck-wide. Values outside **every** band require lock extension first. > Two baseline columns are illustrative only — for any other `body` px value (20 / 24 / 32 / ...), multiply the row's ratio. Structural roles (page title / body / subtitle / annotation / footnote) take their locked slot value and stay there on every page — not a per-page pick. In-band freedom without pre-declaring is for special / feature elements (hero number, display title, one-off emphasis); a recurring special size should be declared as its own slot. The subtitle / lead / subheading bands overlap on purpose — pick by role, not size, then hold each at one size deck-wide. Values outside **every** band require lock extension first.
> **pt → px: where it happens (Mandatory).** PPT canvases confirm in pt; `design_spec.md` / `spec_lock.md` / SVG carry **unitless px only**. The conversion `px = pt × 4 3` happens **once**, by path. (At export the px is converted back with `× 0.75` and **snapped to the nearest 0.5pt** so PowerPoint always shows a clean integer-or-half-point size; a confirmed integer pt round-trips exactly — e.g. 20pt → 26.67px → 20.0pt.) > **Round recommended sizes to clean even px — don't ship ratio leftovers.** The ratios are a guide; lock each role at a **clean even px**, not the raw product. For `body` 24px that means **title 42 · subtitle 32 · lead 30 · annotation 18 · footnote 16** — never `32.4` / `18.7` / odd tails, which read as unprofessional. Snap the ratio output to the nearest even px (…14, 16, 18, 20, 24, 28, 32, 36, 42, 48…), then lock that. (The Confirm UI already snaps its per-role suggestions this way; match it on the chat-fallback path.)
> - **Confirm UI path** — already done at submit. `result.json` `typography.body_size` / `sizes` are **already px** (`body_size_pt` / `sizes_pt` are kept only as provenance). Use the px directly; do **not** re-convert.
> - **Chat-fallback path** — no `result.json` exists, so you convert, via the deterministic helper (never mental math): > **px is literal — write the locked number verbatim (Mandatory).** `result.json` / `design_spec.md` / `spec_lock.md` / SVG all carry px as-is; there is no conversion anywhere. The size you confirm is the size you write. The Executor's `font-size` MUST be the **exact px from `spec_lock.typography`** — if `body` is `24`, write `24`; never a "rounder" or PowerPoint-familiar number (`20` / `18` / `36`). Writing a remembered pt-style value as px is the silent drift that renders a whole deck the wrong size (e.g. a `24`px body emitted as `20` ships ~17% small); the checker's spec-lock drift guard backstops it, but author it right. Per role: honor any size the user pinned as that slot's locked value; derive the rest from the ramp and snap to clean even px. (At export the px is turned back into pt by `× 0.75`, rounded to 1 decimal — that is the only place pt ever appears, and it is automatic.)
> ```bash
> python3 skills/ppt-master/scripts/pt_to_px.py <body_pt> [<title_pt> <subtitle_pt> <annotation_pt>] # e.g. 20 → 26.67
> ```
> Write its printed px into `design_spec.md §IV` + `spec_lock.md typography`. Honor any per-role size the user pinned as that slot's locked value; derive the rest from the ramp.
>
> **Validation** — before leaving the Strategist: every font-size in `design_spec.md` / `spec_lock.md` is the **px** value (e.g. `26.67`), never the confirmed pt number (`20`) written raw. Copying a pt number in unconverted is the one silent error the unit guard cannot catch — `20` is a valid bare number that renders at 15pt. Convert (via the page or `pt_to_px.py`), don't copy.
> **Hero in single-focus / breathing pages**: when one element *is* the entire page — a large number, a headline, a key phrase — it is the visual subject, not body content. Such heroes may borrow the cover-title band (2.55×); for greater emphasis, declare a hero slot in `spec_lock.md` (e.g., `hero_number` / `hero_headline`) — checker exempts declared slots with no fixed upper limit. The row above "Hero number (consulting KPIs) 1.52×" applies only to numeric KPIs in dashboard/data layouts, not to full-page focal elements. > **Hero in single-focus / breathing pages**: when one element *is* the entire page — a large number, a headline, a key phrase — it is the visual subject, not body content. Such heroes may borrow the cover-title band (2.55×); for greater emphasis, declare a hero slot in `spec_lock.md` (e.g., `hero_number` / `hero_headline`) — checker exempts declared slots with no fixed upper limit. The row above "Hero number (consulting KPIs) 1.52×" applies only to numeric KPIs in dashboard/data layouts, not to full-page focal elements.
@@ -753,15 +762,15 @@ Templates are starting points. The Strategist may adjust based on content and au
Content-outline and speaker-notes strategy follow the deck's locked **mode** — see [`modes/_index.md`](./modes/_index.md) and the locked mode's file. The guidance below applies within any mode: Content-outline and speaker-notes strategy follow the deck's locked **mode** — see [`modes/_index.md`](./modes/_index.md) and the locked mode's file. The guidance below applies within any mode:
**Delivery purpose drives the whole plan, not just type size.** `result.json delivery_purpose` — 文字型 (read-close) / 均衡 (business, default) / 展示型 (presentation), confirmed in item 7 — is a **deck-wide consumption mode**. It seeds the normalized body baseline (§g) **and** governs how content is distributed: **Delivery purpose drives the whole plan, not just type size.** `result.json delivery_purpose` — `text` (read-close) / `balanced` (business, default) / `presentation`, confirmed as a Tier-1 anchor (§1) — is a **deck-wide consumption mode**. It seeds the body baseline (§g) **and** governs how content is distributed:
| Delivery purpose | Per-page density & treatment | §IX content per page | page_rhythm lean | | Delivery purpose | Per-page density & treatment | §IX content per page | page_rhythm lean |
|---|---|---|---| |---|---|---|---|
| 文字型 · read-close | dense — pack more per page, fuller layouts | prose paragraphs, more blocks, tables / fine detail; complete sentences | leans `dense` | | `text` · read-close | dense — pack more per page, fuller layouts | prose paragraphs, more blocks, tables / fine detail; complete sentences | leans `dense` |
| 均衡 · business (default) | balanced | one primary + supporting points; moderate text | mixed | | `balanced` · business (default) | balanced | one primary + supporting points; moderate text | mixed |
| 展示型 · presentation | sparse — one idea per page, generous whitespace | keywords / short phrases, a single core message, large visual; never paragraph dumps | leans `anchor` / `breathing` | | `presentation` | sparse — one idea per page, generous whitespace | keywords / short phrases, a single core message, large visual; never paragraph dumps | leans `anchor` / `breathing` |
This is what makes the axis meaningful: a 展示型 deck and a 文字型 deck built from the **same source** must differ in per-page text volume, layout density, and rhythm — **not only in font size**. Page count (item b) stays the user's call; delivery purpose governs the **density and treatment within it**, and informs the page-count recommendation when the user has not fixed one. Record the chosen purpose in `design_spec.md §I`. The `page_rhythm` leans are a bias, not a quota — the filler-page ban and "rhythm follows narrative" rule still hold. (Preservation paths — beautify / template-fill — keep source structure verbatim: honor purpose only in styling, never to re-paginate.) This is what makes the axis meaningful: a `presentation` deck and a `text` deck built from the **same source** must differ in per-page text volume, layout density, and rhythm — **not only in font size**. Page count (item b) stays the user's call; delivery purpose governs the **density and treatment within it**, and informs the page-count recommendation when the user has not fixed one. Record the chosen purpose in `design_spec.md §I`. The `page_rhythm` leans are a bias, not a quota — the filler-page ban and "rhythm follows narrative" rule still hold. (Preservation paths — beautify / template-fill — keep source structure verbatim: honor purpose only in styling, never to re-paginate.)
**Per-block expression**: phrase each §IX content block in the mode that fits it — prose, bullet, keyword, or any phrasing the content calls for — not a default bullet. Take the cue from the source's texture: a narrative source (article / transcript / talk) leans prose — resist compressing its argument pages into fragments; a data sheet leans bullet/keyword. Write the real sentence into §IX itself, not a skeleton point to expand later. One page mixes modes; let layout pull each (narrative → prose, structural/chart → bullets/keywords). **Per-block expression**: phrase each §IX content block in the mode that fits it — prose, bullet, keyword, or any phrasing the content calls for — not a default bullet. Take the cue from the source's texture: a narrative source (article / transcript / talk) leans prose — resist compressing its argument pages into fragments; a data sheet leans bullet/keyword. Write the real sentence into §IX itself, not a skeleton point to expand later. One page mixes modes; let layout pull each (narrative → prose, structural/chart → bullets/keywords).
@@ -789,7 +798,7 @@ This is what makes the axis meaningful: a 展示型 deck and a 文字型 deck bu
3. Save to: `projects/<project_name>.../design_spec.md` 3. Save to: `projects/<project_name>.../design_spec.md`
4. **Generate execution lock**: read `templates/spec_lock_reference.md` and produce `projects/<project_name>.../spec_lock.md` — a distilled, machine-readable short form of the color / typography / icon / image / **page_rhythm** / **page_layouts** / **page_charts** decisions above. This file is what the Executor re-reads before every page (see [executor-base.md](executor-base.md) §2.1). The values in `spec_lock.md` MUST exactly match the decisions recorded in `design_spec.md`; if they ever diverge, `spec_lock.md` wins and `design_spec.md` should be treated as historical narrative. 4. **Generate execution lock**: read `templates/spec_lock_reference.md` and produce `projects/<project_name>.../spec_lock.md` — a distilled, machine-readable short form of the color / typography / icon / image / **page_rhythm** / **page_layouts** / **page_charts** decisions above. This file is what the Executor re-reads before every page (see [executor-base.md](executor-base.md) §2.1). The values in `spec_lock.md` MUST exactly match the decisions recorded in `design_spec.md`; if they ever diverge, `spec_lock.md` wins and `design_spec.md` should be treated as historical narrative.
- **page_rhythm is mandatory**: Based on the page list in §IX Content Outline, assign each page one of `anchor` / `dense` / `breathing` (see `spec_lock_reference.md` for the full vocabulary). This is what breaks the uniform "every page is a card grid" feel — without it the Executor defaults all pages to `dense`. - **page_rhythm is mandatory**: Based on the page list in §IX Content Outline, assign each page one of `anchor` / `dense` / `breathing` (see `spec_lock_reference.md` for the full vocabulary). This is what breaks the uniform "every page is a card grid" feel — without it the Executor defaults all pages to `dense`.
- **Rhythm follows narrative, not quota**: `breathing` pages mark natural pauses — chapter transitions, standalone emphasis (hero quote / big number), SCQA bridges. Dense decks may legitimately be all `dense`. **Do NOT invent filler pages** ("Thank you", empty dividers) to pad rhythm — every `breathing` page must say something independent. Delivery purpose biases the overall lean (展示型 toward more `anchor` / `breathing`, 文字型 toward `dense`; see §6.1) — a bias, never a quota. - **Rhythm follows narrative, not quota**: `breathing` pages mark natural pauses — chapter transitions, standalone emphasis (hero quote / big number), SCQA bridges. Dense decks may legitimately be all `dense`. **Do NOT invent filler pages** ("Thank you", empty dividers) to pad rhythm — every `breathing` page must say something independent. Delivery purpose biases the overall lean (`presentation` toward more `anchor` / `breathing`, `text` toward `dense`; see §6.1) — a bias, never a quota.
- **Cover impact is mandatory**: Page `P01` is the deck's first visual contract, not a generic title slide. In `design_spec.md §IX`, add a `Cover impact` line for `P01` that names one concrete hook and one concrete composition strategy. Use the source's strongest available signal: a provocative core claim, object / scene metaphor, hero number, founder / product / audience moment, or a distilled conflict. Pair it with one concrete composition strategy — such as `full-bleed image + floating title`, `typographic poster`, `hero object`, `data hook`, `editorial scene`, `high-contrast abstract geometry`, or a fresh composition the deck's subject suggests (these are starting points, not the allowed set). If no external or AI image is available, still specify a native-SVG visual hook; do not fall back to "title + subtitle + decorative background". (Beautify / template-fill keep the source cover verbatim — this rule does not apply on those preservation paths.) - **Cover impact is mandatory**: Page `P01` is the deck's first visual contract, not a generic title slide. In `design_spec.md §IX`, add a `Cover impact` line for `P01` that names one concrete hook and one concrete composition strategy. Use the source's strongest available signal: a provocative core claim, object / scene metaphor, hero number, founder / product / audience moment, or a distilled conflict. Pair it with one concrete composition strategy — such as `full-bleed image + floating title`, `typographic poster`, `hero object`, `data hook`, `editorial scene`, `high-contrast abstract geometry`, or a fresh composition the deck's subject suggests (these are starting points, not the allowed set). If no external or AI image is available, still specify a native-SVG visual hook; do not fall back to "title + subtitle + decorative background". (Beautify / template-fill keep the source cover verbatim — this rule does not apply on those preservation paths.)
- **Cover rhythm lock**: `P01` remains `anchor` in `spec_lock.md page_rhythm`, but its §IX `Cover impact` must prevent content-page patterns. Do not plan multi-card grids, agenda-like bullets, or equal-weight columns on the cover unless a template explicitly requires that structure, or a preservation path (beautify / template-fill) is transcribing the source cover verbatim. - **Cover rhythm lock**: `P01` remains `anchor` in `spec_lock.md page_rhythm`, but its §IX `Cover impact` must prevent content-page patterns. Do not plan multi-card grids, agenda-like bullets, or equal-weight columns on the cover unless a template explicitly requires that structure, or a preservation path (beautify / template-fill) is transcribing the source cover verbatim.
- **Closing impact (only when the deck closes)**: the deck's last page is its final visual contract — the strongest impression after the cover. When the deck genuinely lands on a conclusion / call-to-action / final-takeaway page, give it a `Closing impact` line in §IX: name the one thing the audience should leave with (a distilled takeaway, a forward call, a memorable restatement of the core claim) + one composition that delivers it — never a generic "Thank you" / contact-only slide or a centered-title reprise of the cover. **Do NOT invent a closing page to satisfy this** — the filler-page ban above still holds; apply it only to the page where the deck actually resolves. Same exemptions as the cover: skip on template / beautify / template-fill preservation paths. - **Closing impact (only when the deck closes)**: the deck's last page is its final visual contract — the strongest impression after the cover. When the deck genuinely lands on a conclusion / call-to-action / final-takeaway page, give it a `Closing impact` line in §IX: name the one thing the audience should leave with (a distilled takeaway, a forward call, a memorable restatement of the core claim) + one composition that delivers it — never a generic "Thank you" / contact-only slide or a centered-title reprise of the cover. **Do NOT invent a closing page to satisfy this** — the filler-page ban above still holds; apply it only to the page where the deck actually resolves. Same exemptions as the cover: skip on template / beautify / template-fill preservation paths.
@@ -124,6 +124,84 @@ def _wait_for_result(
time.sleep(0.5) time.sleep(0.5)
def _result_stage(result_file: Path) -> Optional[str]:
"""Return the ``stage`` field of result.json (``tier1`` / ``final``), or None."""
try:
data = json.loads(result_file.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return None
return data.get('stage') if isinstance(data, dict) else None
# Tier-1 anchors. On the Tier-2 page these sections are not rendered (they were
# confirmed in Tier 1), so their values live only in browser STATE — lost on a
# refresh. Folding them from result.json into the served Tier-2 recommendations
# lets the page re-initialize them from the user's actual choices.
_ANCHOR_RECOMMEND_KEYS = ('canvas', 'mode', 'visual_style', 'delivery_purpose')
_ANCHOR_VALUE_KEYS = ('audience', 'content_divergence')
def _merge_confirmed_anchors(data: dict, result_file: Path) -> None:
"""Fold the Tier-1 anchors confirmed in result.json into a Tier-2
recommendations payload, so a refresh / reopen re-initializes them. The
confirmed value is truth — it overrides any stale recommend entry."""
try:
res = json.loads(result_file.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return
if not isinstance(res, dict):
return
recommend = data.setdefault('recommend', {})
if not isinstance(recommend, dict):
recommend = data['recommend'] = {}
for key in _ANCHOR_RECOMMEND_KEYS:
if res.get(key) not in (None, ''):
recommend[key] = res[key]
for key in _ANCHOR_VALUE_KEYS:
if key in res:
data[key] = {'value': res.get(key) or ''}
def _wait_only_for_result(
result_file: Path,
lock_file: Path,
timeout: int,
) -> int:
"""Attach to an already-running confirm server and wait for the **final**
(tier-2) result.json — the second leg of the two-tier flow. No child is
launched here: the page is still open from the first ``--wait`` launch, so
liveness is tracked via the recorded pid, not a ``proc`` handle. Only the
``stage == 'final'`` guard is used (no mtime gate): this run's tier-1 confirm
already left result.json at stage ``tier1``, so any ``final`` is the tier-2
submit, and a mtime gate would race-miss a user who confirms before this wait.
"""
logger.info('waiting for tier-2 browser confirmation...')
deadline = None if timeout <= 0 else time.time() + timeout
while True:
# `stage == 'final'` alone is the signal: this run's tier-1 confirm already
# overwrote result.json to stage 'tier1', so any 'final' here is the tier-2
# submit we are waiting for. Gating on mtime >= started_at would race-miss a
# user who confirms tier 2 before this wait is even issued.
if _result_stage(result_file) == 'final':
logger.info('tier-2 confirmation received: %s', result_file)
return 0
lock = _read_lock(lock_file)
pid = int((lock or {}).get('pid', 0) or 0)
if not pid or not _process_alive(pid):
logger.error('confirm server is no longer running before tier 2 was confirmed')
return 1
if deadline is not None and time.time() >= deadline:
logger.error(
'timed out waiting for tier-2 confirmation — the page may still '
'be open; re-check %s before falling back to chat', result_file,
)
return 124
time.sleep(0.5)
def _shutdown_existing(lock_file: Path) -> int: def _shutdown_existing(lock_file: Path) -> int:
"""Stop a confirm server left running for this project (idempotent). """Stop a confirm server left running for this project (idempotent).
@@ -265,7 +343,9 @@ def create_app(
"""Serve the option universe; canvas is synced live from config.py so """Serve the option universe; canvas is synced live from config.py so
the static catalogs.json copy can never drift from the real formats.""" the static catalogs.json copy can never drift from the real formats."""
try: try:
return jsonify(_build_catalogs()) resp = jsonify(_build_catalogs())
resp.headers['Cache-Control'] = 'no-store'
return resp
except (OSError, json.JSONDecodeError) as exc: except (OSError, json.JSONDecodeError) as exc:
return jsonify({'error': f'invalid catalogs.json: {exc}'}), 500 return jsonify({'error': f'invalid catalogs.json: {exc}'}), 500
@@ -282,7 +362,18 @@ def create_app(
# Report whether a result already exists (re-open after confirm). # Report whether a result already exists (re-open after confirm).
result_file = confirm_dir / RESULT_NAME result_file = confirm_dir / RESULT_NAME
data['_already_confirmed'] = result_file.exists() data['_already_confirmed'] = result_file.exists()
return jsonify(data) # Tier 2 renders only realization sections, so fold the confirmed Tier-1
# anchors from result.json back in — a refresh / reopen then re-inits
# canvas / audience / mode / visual_style / delivery_purpose from the
# user's choices instead of catalog defaults.
if data.get('tier') == 2 and result_file.exists():
_merge_confirmed_anchors(data, result_file)
# The page polls this endpoint after a tier-1 confirm until the AI
# overwrites the file with the re-derived tier-2 recommendations, so it
# must never be served from a cache.
resp = jsonify(data)
resp.headers['Cache-Control'] = 'no-store'
return resp
@app.route('/api/confirm', methods=['POST']) @app.route('/api/confirm', methods=['POST'])
def confirm(): def confirm():
@@ -292,14 +383,19 @@ def create_app(
return jsonify({'error': 'invalid payload'}), 400 return jsonify({'error': 'invalid payload'}), 400
confirm_dir.mkdir(parents=True, exist_ok=True) confirm_dir.mkdir(parents=True, exist_ok=True)
result = dict(payload) result = dict(payload)
result['status'] = 'confirmed' # Two-tier flow: a tier-1 submit records the anchor choices but does NOT
# close the page (the AI re-derives tier 2, the same browser session
# renders it). Only a final submit is a full confirmation. A payload with
# no stage is a single-pass confirmation (chat-opt-out parity).
stage = result.get('stage')
result['status'] = 'tier1-confirmed' if stage == 'tier1' else 'confirmed'
result['confirmed_at'] = time.strftime('%Y-%m-%dT%H:%M:%S') result['confirmed_at'] = time.strftime('%Y-%m-%dT%H:%M:%S')
result_file = confirm_dir / RESULT_NAME result_file = confirm_dir / RESULT_NAME
result_file.write_text( result_file.write_text(
json.dumps(result, ensure_ascii=False, indent=2), json.dumps(result, ensure_ascii=False, indent=2),
encoding='utf-8', encoding='utf-8',
) )
logger.info('confirmation written to %s', result_file) logger.info('%s confirmation written to %s', stage or 'final', result_file)
return jsonify({'status': 'ok'}) return jsonify({'status': 'ok'})
return app return app
@@ -324,6 +420,13 @@ def build_parser() -> argparse.ArgumentParser:
'--wait', action='store_true', '--wait', action='store_true',
help='With --daemon, wait until a fresh result.json is written', help='With --daemon, wait until a fresh result.json is written',
) )
parser.add_argument(
'--wait-only', action='store_true',
help='Do not launch. Attach to the already-running confirm server for '
'this project and wait for the final (tier-2) result.json. Used for '
'the second leg of the two-tier flow after the tier-2 '
'recommendations have been re-derived and written.',
)
parser.add_argument( parser.add_argument(
'--wait-timeout', type=int, default=WAIT_TIMEOUT_DEFAULT, '--wait-timeout', type=int, default=WAIT_TIMEOUT_DEFAULT,
help=f'Seconds the --wait parent blocks before returning (default: {WAIT_TIMEOUT_DEFAULT}; ' help=f'Seconds the --wait parent blocks before returning (default: {WAIT_TIMEOUT_DEFAULT}; '
@@ -362,6 +465,15 @@ def main(argv: Optional[list[str]] = None) -> int:
if args.shutdown: if args.shutdown:
return _shutdown_existing(project_path / LOCK_FILE_NAME) return _shutdown_existing(project_path / LOCK_FILE_NAME)
# Tier-2 wait: attach to the server already serving this project (launched by
# the first --wait) and block until the page writes the final result.json.
if args.wait_only:
return _wait_only_for_result(
project_path / CONFIRM_DIR_NAME / RESULT_NAME,
project_path / LOCK_FILE_NAME,
args.wait_timeout,
)
rec_file = project_path / CONFIRM_DIR_NAME / RECOMMENDATIONS_NAME rec_file = project_path / CONFIRM_DIR_NAME / RECOMMENDATIONS_NAME
if not rec_file.exists(): if not rec_file.exists():
logger.error( logger.error(
@@ -16,6 +16,8 @@
loading: "Loading…", loading: "Loading…",
load_error: "Could not load recommendations.json. The AI must write it before launch.", load_error: "Could not load recommendations.json. The AI must write it before launch.",
btn_confirm: "Confirm", btn_confirm: "Confirm",
btn_next: "Next →",
deriving: "Generating the downstream options from your choices…",
already_confirmed: "Already confirmed once. Re-submitting overwrites the previous choices.", already_confirmed: "Already confirmed once. Re-submitting overwrites the previous choices.",
confirmed_title: "✓ Confirmed", confirmed_title: "✓ Confirmed",
confirmed_hint: "Your choices are saved. You can close this page and return to the chat.", confirmed_hint: "Your choices are saved. You can close this page and return to the chat.",
@@ -55,8 +57,8 @@
font_body_size: "Body baseline size", font_body_size: "Body baseline size",
font_body_size_hint: "All type sizes derive from this body baseline.", font_body_size_hint: "All type sizes derive from this body baseline.",
body_size_hint_canvas: "This canvas suggests ~{lo}{hi}px (scales with canvas height).", body_size_hint_canvas: "This canvas suggests ~{lo}{hi}px (scales with canvas height).",
body_size_hint_pt: "PPT body is confirmed in pt — this purpose suggests ~{lo}{hi}pt (default {def}pt). Saved as px after confirmation.", body_size_hint_purpose: "This delivery purpose recommends {def}px — one fixed size, not a range.",
body_size_hint_oor: "(Current value is outside this range — it is not auto-converted across canvases; check it fits.)", body_size_hint_oor: "(Current value is outside the usual range for this canvas — check the unit is right and that it fits.)",
delivery_purpose: "Delivery purpose", delivery_purpose: "Delivery purpose",
delivery_purpose_hint: "Read-close decks can run smaller; projected decks need larger type.", delivery_purpose_hint: "Read-close decks can run smaller; projected decks need larger type.",
size_override: "Per-role size override:", size_override: "Per-role size override:",
@@ -94,6 +96,8 @@
loading: "加载中…", loading: "加载中…",
load_error: "无法加载推荐文件,需在启动前写入。", load_error: "无法加载推荐文件,需在启动前写入。",
btn_confirm: "确认", btn_confirm: "确认",
btn_next: "下一步 →",
deriving: "正在据你的选择生成下游选项…",
already_confirmed: "已确认过一次,重新提交会覆盖之前的选择。", already_confirmed: "已确认过一次,重新提交会覆盖之前的选择。",
confirmed_title: "✓ 已确认", confirmed_title: "✓ 已确认",
confirmed_hint: "选择已保存,可关闭此页并回到聊天窗口。", confirmed_hint: "选择已保存,可关闭此页并回到聊天窗口。",
@@ -133,8 +137,8 @@
font_body_size: "正文基准字号", font_body_size: "正文基准字号",
font_body_size_hint: "所有字号按这个正文基准推导。", font_body_size_hint: "所有字号按这个正文基准推导。",
body_size_hint_canvas: "当前画布建议 ~{lo}{hi}px(随画布高度缩放)。", body_size_hint_canvas: "当前画布建议 ~{lo}{hi}px(随画布高度缩放)。",
body_size_hint_pt: "PPT 正文确认时用 pt — 该交付目的建议 ~{lo}{hi}pt(默认 {def}pt),确认后保存为 px。", body_size_hint_purpose: "该交付目的推荐 {def}px(单一固定值,非区间)。",
body_size_hint_oor: "(当前数值不在此区间——换画布不会自动换算,请确认是否合适。)", body_size_hint_oor: "(当前数值超出该画布的常用范围——请确认单位无误、是否合适。)",
delivery_purpose: "交付目的", delivery_purpose: "交付目的",
delivery_purpose_hint: "近读型可以小一点;投影型需要更大的字。", delivery_purpose_hint: "近读型可以小一点;投影型需要更大的字。",
size_override: "逐角色字号覆盖:", size_override: "逐角色字号覆盖:",
@@ -247,10 +251,15 @@
return node; return node;
} }
// Section numbers run 1..N within the tier currently rendered; the counter is
// reset at the top of renderForTier. The legacy `num` arg is ignored so each
// tier numbers its own sections cleanly (tier 2 is not a continuation of 1).
var _secCounter = 0;
function section(num, titleKey, noteText) { function section(num, titleKey, noteText) {
_secCounter += 1;
var sec = el("div", "section"); var sec = el("div", "section");
var head = el("div", "section-head"); var head = el("div", "section-head");
head.appendChild(el("span", "section-num", String(num))); head.appendChild(el("span", "section-num", String(_secCounter)));
head.appendChild(el("span", "section-title", t(titleKey))); head.appendChild(el("span", "section-title", t(titleKey)));
if (noteText) head.appendChild(el("span", "section-note", noteText)); if (noteText) head.appendChild(el("span", "section-note", noteText));
sec.appendChild(head); sec.appendChild(head);
@@ -547,6 +556,20 @@
textField(subDiv, function () { return STATE.content_divergence; }, textField(subDiv, function () { return STATE.content_divergence; },
function (v) { STATE.content_divergence = v; }, "placeholder_divergence", false); function (v) { STATE.content_divergence = v; }, "placeholder_divergence", false);
sec.appendChild(subDiv); sec.appendChild(subDiv);
// Delivery purpose (PPT only) lives in the §c key-information confirmation,
// beside audience — it is part of "who / how this deck is consumed". It is a
// Tier-1 anchor: its value sets the body size (one fixed value per purpose), page
// density, and the re-derived Tier-2 page-count recommendation. Non-PPT
// canvases scale the body by canvas height instead, so the axis does not apply.
if (isPptCanvas(STATE.canvas)) {
var purposeField = el("div", "subfield");
purposeField.appendChild(el("div", "subfield-label", t("delivery_purpose")));
enumField(purposeField, CAT.delivery_purpose,
recOrFirst("delivery_purpose", CAT.delivery_purpose),
function () { return STATE.delivery_purpose; },
function (v) { STATE.delivery_purpose = v; });
sec.appendChild(purposeField);
}
host.appendChild(sec); host.appendChild(sec);
} }
@@ -593,11 +616,16 @@
// Per-role size slots the user can edit directly (parallel to color roles). // Per-role size slots the user can edit directly (parallel to color roles).
// Defaults derive from `body` via mid-band ramp ratios (strategist.md §g); // Defaults derive from `body` via mid-band ramp ratios (strategist.md §g);
// values follow body's unit (pt on PPT canvases, px otherwise). // values are px (the system's only unit).
var SIZE_ROLES = ["title", "subtitle", "annotation"]; var SIZE_ROLES = ["title", "subtitle", "annotation"];
var SIZE_RATIO = { title: 1.75, subtitle: 1.35, annotation: 0.78 }; var SIZE_RATIO = { title: 1.75, subtitle: 1.35, annotation: 0.78 };
function deriveSize(role, bodyVal) { function deriveSize(role, bodyVal) {
return Math.round((bodyVal || 0) * (SIZE_RATIO[role] || 1)); var raw = (bodyVal || 0) * (SIZE_RATIO[role] || 1);
// All px. On PPT, snap the recommended role size to a clean even number so
// the user sees conventional sizes (body 24 → title 42, subtitle 32), not
// ratio leftovers. Non-PPT keeps a plain integer — large px, snapping moot.
if (isPptCanvas(STATE.canvas)) return Math.round(raw / 2) * 2;
return Math.round(raw);
} }
// Canvas height (viewBox user units) parsed from a catalog `dim` like // Canvas height (viewBox user units) parsed from a catalog `dim` like
@@ -620,8 +648,8 @@
return isPpt ? { lo: 0.031, hi: 0.047 } : { lo: 0.025, hi: 0.033 }; return isPpt ? { lo: 0.031, hi: 0.047 } : { lo: 0.025, hi: 0.033 };
} }
// PPT canvases (16:9 / 4:3) carry the pt design language + pt→px conversion; // PPT canvases (16:9 / 4:3) take the fixed per-delivery-purpose body px;
// social / print canvases have no PowerPoint pt semantics and stay in px. // social / print canvases scale the body px by canvas height instead.
function isPptCanvas(canvasVal) { function isPptCanvas(canvasVal) {
var dim = null; var dim = null;
(CAT.canvas || []).forEach(function (o) { if (o.id === canvasVal) dim = o.dim; }); (CAT.canvas || []).forEach(function (o) { if (o.id === canvasVal) dim = o.dim; });
@@ -632,16 +660,18 @@
/1024\s*[×xX*]\s*768/.test(raw); /1024\s*[×xX*]\s*768/.test(raw);
} }
// PPT confirmation pt band + default per delivery purpose (see strategist.md §g). // Body baseline in **px** per delivery purpose (see strategist.md §g). The
// The confirmed pt is converted to px (×4/3) before any spec is written. // system is px-only — these are the SVG/execution px values, recalibrated for
function deliveryPtBand(purposeId) { // the 1280×720 PPT canvas. No pt layer, no conversion. `def` is the fixed
if (purposeId === "text") return { lo: 14, hi: 18, def: 16 }; // recommendation; lo/hi are a sanity envelope for the out-of-range flag only.
if (purposeId === "presentation") return { lo: 22, hi: 28, def: 24 }; function deliveryBodyPx(purposeId) {
return { lo: 18, hi: 22, def: 20 }; // balanced — the default if (purposeId === "text") return { lo: 18, hi: 21, def: 20 };
if (purposeId === "presentation") return { lo: 28, hi: 32, def: 32 };
return { lo: 22, hi: 25, def: 24 }; // balanced — the default
} }
function defaultBodySizeForCanvas(canvasVal, purposeId) { function defaultBodySizeForCanvas(canvasVal, purposeId) {
if (isPptCanvas(canvasVal)) return deliveryPtBand(purposeId).def; if (isPptCanvas(canvasVal)) return deliveryBodyPx(purposeId).def;
var h = canvasHeight(canvasVal); var h = canvasHeight(canvasVal);
if (!h) return 40; if (!h) return 40;
var band = bodySizeRatioBand(canvasVal); var band = bodySizeRatioBand(canvasVal);
@@ -652,54 +682,28 @@
return Math.round(value * 100) / 100; return Math.round(value * 100) / 100;
} }
function ptToPx(value) {
return roundSize(value * 4 / 3);
}
function normalizeTypographyForSubmit(payload) { function normalizeTypographyForSubmit(payload) {
if (!payload.typography || typeof payload.typography !== "object") return; if (!payload.typography || typeof payload.typography !== "object") return;
var typ = payload.typography; var typ = payload.typography;
var ppt = isPptCanvas(payload.canvas);
var body = parseFloat(typ.body_size); var body = parseFloat(typ.body_size);
if (!isFinite(body)) { if (!isFinite(body)) {
// Cleared / invalid body field — fall back so role sizes never submit // Cleared / invalid body field — fall back so role sizes never submit
// against an empty anchor, and (on PPT) so the pt→px branch still runs // against an empty anchor.
// instead of leaking pt-valued role sizes through as px.
body = defaultBodySizeForCanvas(payload.canvas, payload.delivery_purpose); body = defaultBodySizeForCanvas(payload.canvas, payload.delivery_purpose);
typ.body_size = body;
}
if (ppt && isFinite(body)) {
typ.body_size_pt = roundSize(body);
typ.body_size = ptToPx(body);
typ.body_size_unit = "px";
typ.body_size_source_unit = "pt";
if (typ.sizes && typeof typ.sizes === "object") {
var sizesPt = {};
var sizesPx = {};
Object.keys(typ.sizes).forEach(function (role) {
var raw = parseFloat(typ.sizes[role]);
if (!isFinite(raw)) return;
sizesPt[role] = roundSize(raw);
sizesPx[role] = ptToPx(raw);
});
typ.sizes_pt = sizesPt;
typ.sizes = sizesPx;
}
return;
}
if (isFinite(body)) {
typ.body_size = roundSize(body);
typ.body_size_unit = "px";
} }
// px is the only unit — round and submit as-is. No pt conversion, no
// body_size_pt / sizes_pt provenance (the system never carries pt).
typ.body_size = roundSize(body);
typ.body_size_unit = "px";
if (typ.sizes && typeof typ.sizes === "object") { if (typ.sizes && typeof typ.sizes === "object") {
Object.keys(typ.sizes).forEach(function (role) { Object.keys(typ.sizes).forEach(function (role) {
var raw = parseFloat(typ.sizes[role]); var raw = parseFloat(typ.sizes[role]);
if (isFinite(raw)) typ.sizes[role] = roundSize(raw); if (isFinite(raw)) typ.sizes[role] = roundSize(raw);
}); });
} }
delete typ.body_size_pt; // delivery_purpose is PPT-only; drop it on non-PPT canvases where it has
delete typ.sizes_pt; // no meaning and was never shown.
delete payload.delivery_purpose; if (!isPptCanvas(payload.canvas)) delete payload.delivery_purpose;
} }
function renderColor(host) { function renderColor(host) {
@@ -903,7 +907,7 @@
top.appendChild(el("span", "font-card-name", localized(c, "name") || (t("option_prefix") + " " + (idx + 1)))); top.appendChild(el("span", "font-card-name", localized(c, "name") || (t("option_prefix") + " " + (idx + 1))));
var meta = t("font_heading") + " " + t("cjk") + ":" + (head.cjk || "—") + " / " + t("latin") + ":" + (head.latin || "—") var meta = t("font_heading") + " " + t("cjk") + ":" + (head.cjk || "—") + " / " + t("latin") + ":" + (head.latin || "—")
+ " · " + t("font_body") + " " + t("cjk") + ":" + (body.cjk || "—") + " / " + t("latin") + ":" + (body.latin || "—"); + " · " + t("font_body") + " " + t("cjk") + ":" + (body.cjk || "—") + " / " + t("latin") + ":" + (body.latin || "—");
if (c.body_size) meta += " · " + t("font_body_size") + ":" + c.body_size + (isPptCanvas(STATE.canvas) ? "pt" : "px"); if (c.body_size) meta += " · " + t("font_body_size") + ":" + c.body_size + "px";
top.appendChild(el("span", "font-card-meta", meta)); top.appendChild(el("span", "font-card-meta", meta));
card.appendChild(top); card.appendChild(top);
var hbox = el("div", "font-sample-heading-box"); fontSample(hbox, head, head.css); card.appendChild(hbox); var hbox = el("div", "font-sample-heading-box"); fontSample(hbox, head, head.css); card.appendChild(hbox);
@@ -946,16 +950,16 @@
var sizeHint = el("div", "toggle-desc"); var sizeHint = el("div", "toggle-desc");
sizeRow.appendChild(sizeHint); sizeRow.appendChild(sizeHint);
// Hint only — the user's value is never overwritten; downstream §g // Hint only — the user's value is never overwritten; downstream §g
// re-derives if ignored. PPT canvases speak pt (range by delivery // re-derives if ignored. PPT body is one fixed px value per delivery
// purpose); non-PPT canvases speak px (≈% of canvas height). // purpose (not a range); non-PPT canvases scale px to canvas height.
// Everything is px — lo/hi are only a sanity envelope for the OOR flag.
refreshBodySizeHint = function () { refreshBodySizeHint = function () {
var txt = t("font_body_size_hint"); var txt = t("font_body_size_hint");
var lo, hi; var lo, hi;
if (isPptCanvas(STATE.canvas)) { if (isPptCanvas(STATE.canvas)) {
var pb = deliveryPtBand(STATE.delivery_purpose); var pb = deliveryBodyPx(STATE.delivery_purpose);
lo = pb.lo; hi = pb.hi; lo = pb.lo; hi = pb.hi;
txt += " " + t("body_size_hint_pt") txt += " " + t("body_size_hint_purpose").replace("{def}", pb.def);
.replace("{lo}", pb.lo).replace("{hi}", pb.hi).replace("{def}", pb.def);
} else { } else {
var h = canvasHeight(STATE.canvas); var h = canvasHeight(STATE.canvas);
var band = bodySizeRatioBand(STATE.canvas); var band = bodySizeRatioBand(STATE.canvas);
@@ -965,9 +969,9 @@
.replace("{lo}", lo).replace("{hi}", hi); .replace("{lo}", lo).replace("{hi}", hi);
} }
} }
// Flag (hint only — never auto-corrected) a value outside the range, // Flag (hint only — never auto-corrected) a value far outside the
// e.g. a pt number kept after switching to a px canvas, so a cross-unit // canvas's usual px range, so an accidental extreme value is visible
// switch is visible instead of silently submitting a mis-unit size. // instead of silently submitting it.
var cur = parseFloat(STATE.typography && STATE.typography.body_size); var cur = parseFloat(STATE.typography && STATE.typography.body_size);
if (isFinite(cur) && isFinite(lo) && isFinite(hi) && (cur < lo || cur > hi)) { if (isFinite(cur) && isFinite(lo) && isFinite(hi) && (cur < lo || cur > hi)) {
txt += " " + t("body_size_hint_oor"); txt += " " + t("body_size_hint_oor");
@@ -977,30 +981,15 @@
refreshBodySizeHint(); refreshBodySizeHint();
sizeField.appendChild(sizeRow); sizeField.appendChild(sizeRow);
// Delivery purpose (PPT only) — informs the recommended pt baseline and the // Delivery purpose is a Tier-1 anchor confirmed inside renderAudience (§c) —
// suggested-range hint. Picking it updates the hint + strategy signal only; // it is set before this Tier-2 section exists, so its value drives the
// it does not rewrite the body field (inputs are independent, no cascade). // body-size hint here via STATE.delivery_purpose (preserved across the
if (isPptCanvas(STATE.canvas)) { // single-session transition). The control itself no longer lives here.
var purposeField = el("div", "subfield");
purposeField.appendChild(el("div", "subfield-label", t("delivery_purpose")));
enumField(purposeField, CAT.delivery_purpose,
recOrFirst("delivery_purpose", CAT.delivery_purpose),
function () { return STATE.delivery_purpose; },
function (v) {
STATE.delivery_purpose = v;
// Purpose only updates the suggested-range hint and the strategy
// signal it carries to result.json — it never rewrites the body
// size the user sees (no input interlinking).
refreshBodySizeHint();
refreshStylePreview();
});
sec.appendChild(purposeField);
}
sec.appendChild(sizeField); sec.appendChild(sizeField);
// Per-role size override (parallel to color's per-role HEX override): the // Per-role size override (parallel to color's per-role HEX override): the
// ramp derives title / subtitle / annotation from body, but the user may // ramp derives title / subtitle / annotation from body, but the user may
// set each explicitly. Values follow body's unit (pt on PPT, px else). // set each explicitly. Values are px (the system's only unit).
var sizeOverride = el("div", "hex-override"); var sizeOverride = el("div", "hex-override");
sizeOverride.appendChild(el("div", "subfield-label", t("size_override"))); sizeOverride.appendChild(el("div", "subfield-label", t("size_override")));
var srow = el("div", "hex-row"); var srow = el("div", "hex-row");
@@ -1032,7 +1021,7 @@
if (!STATE.typography) STATE.typography = { name: "", heading: {}, body: {} }; if (!STATE.typography) STATE.typography = { name: "", heading: {}, body: {} };
if (!STATE.typography.sizes) STATE.typography.sizes = {}; if (!STATE.typography.sizes) STATE.typography.sizes = {};
var bodyVal = parseFloat(STATE.typography.body_size) || var bodyVal = parseFloat(STATE.typography.body_size) ||
(isPptCanvas(STATE.canvas) ? deliveryPtBand(STATE.delivery_purpose).def : 40); (isPptCanvas(STATE.canvas) ? deliveryBodyPx(STATE.delivery_purpose).def : 40);
SIZE_ROLES.forEach(function (role) { SIZE_ROLES.forEach(function (role) {
var cur = STATE.typography.sizes[role]; var cur = STATE.typography.sizes[role];
var hasVal = cur !== undefined && cur !== null && cur !== ""; var hasVal = cur !== undefined && cur !== null && cur !== "";
@@ -1108,10 +1097,9 @@
var acc = hexOr(pal.accent, pri); var acc = hexOr(pal.accent, pri);
var sacc = hexOr(pal.secondary_accent, acc); var sacc = hexOr(pal.secondary_accent, acc);
var txt = hexOr(pal.body_text, "#1d2430"); var txt = hexOr(pal.body_text, "#1d2430");
// body_size is pt on PPT canvases (×4/3 → px) and px elsewhere. // body_size is px everywhere — preview it directly, no conversion.
var rawSize = parseFloat(typ.body_size) || (isPptCanvas(STATE.canvas) ? 20 : 18); var rawSize = parseFloat(typ.body_size) || (isPptCanvas(STATE.canvas) ? 24 : 18);
var bodyPx = Math.max(12, Math.min(34, var bodyPx = Math.max(12, Math.min(34, rawSize));
isPptCanvas(STATE.canvas) ? rawSize * 4 / 3 : rawSize));
var headStack = previewFontStack(head.cjk, head.css); var headStack = previewFontStack(head.cjk, head.css);
var headLatStack = previewFontStack(head.latin, head.css); var headLatStack = previewFontStack(head.latin, head.css);
var bodyStack = previewFontStack(body.cjk, body.css); var bodyStack = previewFontStack(body.cjk, body.css);
@@ -1242,31 +1230,58 @@
host.appendChild(sec); host.appendChild(sec);
} }
function renderAll() { // Stage of the two-tier confirm flow: 1 = anchors, 2 = re-derived realization,
// "all" = legacy single-pass (recommendations.json carried no `tier`).
var STAGE = 1;
function renderForTier(tier) {
var host = document.getElementById("sections"); var host = document.getElementById("sections");
host.innerHTML = ""; host.innerHTML = "";
// Detach the previous preview's repaint closure before the sections _secCounter = 0;
// re-render: color/typography auto-select would otherwise call it and // Detach the previous preview's repaint closures before the sections
// write to now-detached nodes until renderStylePreview remounts it. // re-render: color/typography auto-select would otherwise call them and
// write to now-detached nodes until renderStylePreview remounts them.
refreshStylePreview = function () {}; refreshStylePreview = function () {};
refreshBodySizeHint = function () {}; refreshBodySizeHint = function () {};
refreshSizeInputs = function () {}; refreshSizeInputs = function () {};
renderCanvas(host); if (tier === 1) {
renderPages(host); // Anchors — decided first; Tier 2 is re-derived from these.
renderAudience(host); // Delivery purpose rides inside renderAudience (§c key info).
renderStyle(host); renderCanvas(host);
// Group the preview with the three sections it reflects so its sticky renderAudience(host);
// scope ends when typography scrolls past — it does not linger over the renderStyle(host);
// image / mode / refine sections below. } else {
var styleGroup = el("div", "style-group"); // Tier 2 (realization) or single-pass: page count + visual treatment.
renderStylePreview(styleGroup); // Single-pass also shows the anchors up front on the same page.
renderColor(styleGroup); if (tier === "all") {
renderIcons(styleGroup); renderCanvas(host);
renderTypography(styleGroup); renderAudience(host);
host.appendChild(styleGroup); renderStyle(host);
renderImages(host); }
renderMode(host); renderPages(host);
renderRefine(host); // Group the preview with the three sections it reflects so its sticky
// scope ends when typography scrolls past — it does not linger over the
// image / mode / refine sections below.
var styleGroup = el("div", "style-group");
renderStylePreview(styleGroup);
renderColor(styleGroup);
renderIcons(styleGroup);
renderTypography(styleGroup);
host.appendChild(styleGroup);
renderImages(host);
renderMode(host);
renderRefine(host);
}
updateActionBar(tier);
}
function renderAll() { renderForTier(STAGE); }
function updateActionBar(tier) {
var btn = document.getElementById("btn-confirm");
btn.disabled = false;
// Tier 1 advances to the re-derived Tier 2; Tier 2 / single-pass confirm.
btn.textContent = (tier === 1) ? t("btn_next") : t("btn_confirm");
} }
// ---- state init (once) ---------------------------------------------- // ---- state init (once) ----------------------------------------------
@@ -1279,13 +1294,23 @@
return recOrFirst(field, catList); return recOrFirst(field, catList);
} }
function initState() { function initTier1State() {
STATE.canvas = pick("canvas", CAT.canvas); STATE.canvas = pick("canvas", CAT.canvas);
STATE.page_count = (REC.page_count && REC.page_count.value != null) ? String(REC.page_count.value) : "";
STATE.audience = (REC.audience && REC.audience.value) || ""; STATE.audience = (REC.audience && REC.audience.value) || "";
STATE.content_divergence = (REC.content_divergence && REC.content_divergence.value) || ""; // free text; blank = balanced default STATE.content_divergence = (REC.content_divergence && REC.content_divergence.value) || ""; // free text; blank = balanced default
STATE.mode = pick("mode", CAT.modes); STATE.mode = pick("mode", CAT.modes);
STATE.visual_style = pick("visual_style", CAT.visual_styles); STATE.visual_style = pick("visual_style", CAT.visual_styles);
// Delivery purpose drives the PPT body px baseline; default balanced
// (not the catalog-first id) when the Strategist did not recommend one.
STATE.delivery_purpose = recId("delivery_purpose") || "balanced";
}
// Tier-2 fields are (re-)read from the recommendations. At boot they come from
// whatever recommendations.json carried; after a tier-1 confirm enterTier2()
// calls this again with the re-derived candidates. Tier-1 STATE is preserved
// across the single-session transition — this never resets the anchors.
function initTier2State() {
STATE.page_count = (REC.page_count && REC.page_count.value != null) ? String(REC.page_count.value) : (STATE.page_count || "");
var cc = (REC.color && REC.color.candidates) || []; var cc = (REC.color && REC.color.candidates) || [];
var csel = (REC.color && REC.color.selected) || 0; var csel = (REC.color && REC.color.selected) || 0;
@@ -1311,17 +1336,19 @@
STATE.generation_mode = pick("generation_mode", CAT.generation_mode); STATE.generation_mode = pick("generation_mode", CAT.generation_mode);
STATE.refine_spec = !!((REC.refine_spec && REC.refine_spec.value) || (REC.recommend && REC.recommend.refine_spec)); STATE.refine_spec = !!((REC.refine_spec && REC.refine_spec.value) || (REC.recommend && REC.recommend.refine_spec));
// Delivery purpose drives the PPT confirmation pt baseline; default balanced
// (not the catalog-first id) when the Strategist did not recommend one.
STATE.delivery_purpose = recId("delivery_purpose") || "balanced";
// Guarantee a body baseline even when a candidate omitted body_size, on // Guarantee a body baseline even when a candidate omitted body_size, on
// any canvas (PPT → pt default by purpose, non-PPT → px from canvas height), // any canvas (PPT → px default by purpose, non-PPT → px from canvas height),
// so role sizes never derive from an empty anchor. // so role sizes never derive from an empty anchor.
if (STATE.typography && !STATE.typography.body_size) { if (STATE.typography && !STATE.typography.body_size) {
STATE.typography.body_size = defaultBodySizeForCanvas(STATE.canvas, STATE.delivery_purpose); STATE.typography.body_size = defaultBodySizeForCanvas(STATE.canvas, STATE.delivery_purpose);
} }
} }
function initState() {
initTier1State();
initTier2State();
}
// ---- confirm + close ------------------------------------------------- // ---- confirm + close -------------------------------------------------
function showConfirmedOverlay() { function showConfirmedOverlay() {
var ov = document.getElementById("confirmed-overlay"); var ov = document.getElementById("confirmed-overlay");
@@ -1330,10 +1357,78 @@
ov.style.display = "flex"; ov.style.display = "flex";
} }
// ---- tier-1 submit + re-derive transition ---------------------------
function submitTier1() {
var btn = document.getElementById("btn-confirm");
// Anchors only — the page stays open; the AI re-derives Tier 2 and the
// same browser session renders it (STATE is preserved across the poll).
var payload = {
stage: "tier1",
canvas: STATE.canvas,
audience: STATE.audience,
content_divergence: STATE.content_divergence,
mode: STATE.mode,
visual_style: STATE.visual_style
};
// Delivery purpose is PPT-only and rendered only on PPT canvases (§c).
// On a non-PPT canvas the control is never shown, so STATE holds an unseen
// default — do NOT write it as a confirmed anchor, or it would steer the
// Tier-2 page-count / density re-derivation behind the user's back. (The
// final submit drops it for non-PPT the same way, via normalizeTypographyForSubmit.)
if (isPptCanvas(STATE.canvas)) {
payload.delivery_purpose = STATE.delivery_purpose;
}
btn.disabled = true;
fetch("/api/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
}).then(function (r) {
if (!r.ok) throw new Error("tier1 failed");
showDeriving();
pollForTier2();
}).catch(function () {
btn.disabled = false;
document.getElementById("confirm-status").textContent = t("error_retry");
});
}
function showDeriving() {
document.getElementById("sections").style.display = "none";
document.getElementById("actionbar").style.display = "none";
var l = document.getElementById("loading");
l.textContent = t("deriving");
l.style.display = "block";
}
// Poll the recommendations endpoint (no-store) until the AI overwrites it with
// the re-derived Tier 2, then render Tier 2 in the same session.
function pollForTier2() {
fetch("/api/recommendations", { cache: "no-store" })
.then(function (r) { if (!r.ok) throw new Error("poll failed"); return r.json(); })
.then(function (data) {
if (data && data.tier === 2) { enterTier2(data); }
else { setTimeout(pollForTier2, 1200); }
})
.catch(function () { setTimeout(pollForTier2, 1500); });
}
function enterTier2(data) {
REC = data;
initTier2State(); // re-read realization fields; tier-1 STATE preserved
STAGE = 2;
document.getElementById("loading").style.display = "none";
document.getElementById("sections").style.display = "block";
document.getElementById("actionbar").style.display = "flex";
document.getElementById("confirm-status").textContent = "";
renderForTier(2);
}
function confirm() { function confirm() {
var btn = document.getElementById("btn-confirm"); var btn = document.getElementById("btn-confirm");
var payload = JSON.parse(JSON.stringify(STATE)); var payload = JSON.parse(JSON.stringify(STATE));
normalizeTypographyForSubmit(payload); normalizeTypographyForSubmit(payload);
payload.stage = "final";
var customImagePlan = usesCustomImagePlanValue(payload.image_usage); var customImagePlan = usesCustomImagePlanValue(payload.image_usage);
if (payload.image_usage === "custom" || (customImagePlan && !String(payload.image_usage).trim())) { if (payload.image_usage === "custom" || (customImagePlan && !String(payload.image_usage).trim())) {
document.getElementById("confirm-status").textContent = t("image_usage_custom_required"); document.getElementById("confirm-status").textContent = t("image_usage_custom_required");
@@ -1391,7 +1486,9 @@
refreshLangToggle(toggleBtn); refreshLangToggle(toggleBtn);
if (REC && CAT) renderAll(); // STATE persists → selections preserved if (REC && CAT) renderAll(); // STATE persists → selections preserved
}); });
document.getElementById("btn-confirm").addEventListener("click", confirm); document.getElementById("btn-confirm").addEventListener("click", function () {
if (STAGE === 1) { submitTier1(); } else { confirm(); }
});
Promise.all([ Promise.all([
loadCatalogs(), loadCatalogs(),
@@ -1405,6 +1502,8 @@
if (!hasStored) { LANG = REC.lang; applyStaticTranslations(); refreshLangToggle(toggleBtn); } if (!hasStored) { LANG = REC.lang; applyStaticTranslations(); refreshLangToggle(toggleBtn); }
} }
initState(); initState();
// tier 1 / 2 from the recommendations; absent → legacy single-pass.
STAGE = (REC.tier === 1) ? 1 : (REC.tier === 2 ? 2 : "all");
document.getElementById("loading").style.display = "none"; document.getElementById("loading").style.display = "none";
document.getElementById("sections").style.display = "block"; document.getElementById("sections").style.display = "block";
document.getElementById("actionbar").style.display = "flex"; document.getElementById("actionbar").style.display = "flex";
@@ -455,24 +455,24 @@
"label": "text", "label": "text",
"label_zh": "文字型 · 近读", "label_zh": "文字型 · 近读",
"label_en": "text / read-close", "label_en": "text / read-close",
"desc_zh": "当文件读、近距离看(报告、数据密集 brief、留底材料)。正文可小,1418pt,默认 16pt。", "desc_zh": "当文件读、近距离看(报告、数据密集 brief、留底材料)。正文 20px。",
"desc_en": "Read close as a file (report, data-dense brief, leave-behind). Body can run small, 1418pt, default 16pt." "desc_en": "Read close as a file (report, data-dense brief, leave-behind). Body 20px."
}, },
{ {
"id": "balanced", "id": "balanced",
"label": "balanced", "label": "balanced",
"label_zh": "均衡 · 商务", "label_zh": "均衡 · 商务",
"label_en": "balanced / business", "label_en": "balanced / business",
"desc_zh": "既投影也阅读(路演、业务评审)。默认档,1822pt,默认 20pt。", "desc_zh": "既投影也阅读(路演、业务评审)。默认档,正文 24px。",
"desc_en": "Both projected and read (roadshow, business review). The default tier, 1822pt, default 20pt." "desc_en": "Both projected and read (roadshow, business review). The default tier, body 24px."
}, },
{ {
"id": "presentation", "id": "presentation",
"label": "presentation", "label": "presentation",
"label_zh": "展示型 · 演讲", "label_zh": "展示型 · 演讲",
"label_en": "presentation / keynote", "label_en": "presentation / keynote",
"desc_zh": "投影、远距离扫一眼、内容稀疏(keynote、发布、课堂)。2228pt,默认 24pt。", "desc_zh": "投影、远距离扫一眼、内容稀疏(keynote、发布、课堂)。正文 32px。",
"desc_en": "Projected, glanced from the back, sparse (keynote, launch, classroom). 2228pt, default 24pt." "desc_en": "Projected, glanced from the back, sparse (keynote, launch, classroom). Body 32px."
} }
] ]
} }
@@ -5,7 +5,8 @@
## `confirm_ui/server.py` ## `confirm_ui/server.py`
```bash ```bash
python3 scripts/confirm_ui/server.py <project_path> --daemon --wait python3 scripts/confirm_ui/server.py <project_path> --daemon --wait # launch + wait for Tier 1
python3 scripts/confirm_ui/server.py <project_path> --wait-only # Tier 2: attach to the running page, wait for the final result
python3 scripts/confirm_ui/server.py <project_path> --daemon python3 scripts/confirm_ui/server.py <project_path> --daemon
python3 scripts/confirm_ui/server.py <project_path> --daemon --port 5051 python3 scripts/confirm_ui/server.py <project_path> --daemon --port 5051
python3 scripts/confirm_ui/server.py <project_path> --no-browser python3 scripts/confirm_ui/server.py <project_path> --no-browser
@@ -16,6 +17,7 @@ python3 scripts/confirm_ui/server.py <project_path> --shutdown # Step 4 clean
- Binds `127.0.0.1:5050` by default — or the next free port if another project already holds it (the launch log prints the actual URL) — and auto-opens the browser (suppress with `--no-browser`). `--port <other>` forces a specific port. - Binds `127.0.0.1:5050` by default — or the next free port if another project already holds it (the launch log prints the actual URL) — and auto-opens the browser (suppress with `--no-browser`). `--port <other>` forces a specific port.
- **Shares port 5050 with the live preview server** (`svg_editor/server.py`). The two never run at once: confirm is Step 4, live preview is Step 6, and Step 4 always shuts this server down on exit (see `--shutdown`) so the port is free. One port = one forward rule for the whole pipeline. They still keep **separate processes and locks** (`.confirm_ui.lock` vs `.live_preview.lock`). - **Shares port 5050 with the live preview server** (`svg_editor/server.py`). The two never run at once: confirm is Step 4, live preview is Step 6, and Step 4 always shuts this server down on exit (see `--shutdown`) so the port is free. One port = one forward rule for the whole pipeline. They still keep **separate processes and locks** (`.confirm_ui.lock` vs `.live_preview.lock`).
- `--daemon` starts the Flask process in the background; add `--wait` in the main pipeline so the parent command returns only after the page writes a fresh `result.json`. The `--wait` budget defaults to **590 s** (`--wait-timeout`), kept under the typical 600 s tool ceiling — run the launch with a long tool timeout (≈600000 ms). On timeout the parent returns non-zero but the detached server keeps running, so the caller must re-check `result.json` once before the chat fallback (a slow user may confirm just after the wait returns). - `--daemon` starts the Flask process in the background; add `--wait` in the main pipeline so the parent command returns only after the page writes a fresh `result.json`. The `--wait` budget defaults to **590 s** (`--wait-timeout`), kept under the typical 600 s tool ceiling — run the launch with a long tool timeout (≈600000 ms). On timeout the parent returns non-zero but the detached server keeps running, so the caller must re-check `result.json` once before the chat fallback (a slow user may confirm just after the wait returns).
- `--wait-only` is the **second leg of the two-tier flow**: it does **not** launch a server — it attaches to the page already running from the first `--daemon --wait` and blocks until the page writes the **final** result (`stage: "final"`). It keys on the **stage alone** (no mtime gate): this run's tier-1 confirm already left result.json at `stage: "tier1"`, so any `final` is the tier-2 submit — and an mtime gate would race-miss a user who confirms tier 2 before this wait is issued. Same `--wait-timeout` budget; on timeout / server-gone it returns non-zero and the caller re-checks `result.json` once before the chat fallback.
- `--shutdown` stops a confirm server left running for this project and exits — **idempotent** (a no-op when nothing is running). Tries a graceful `/api/shutdown`, falls back to killing the recorded pid, then clears the lock. SKILL.md Step 4 runs this on every path (page-confirm or chat-fallback) so the page never lingers on the shared port before live preview starts. - `--shutdown` stops a confirm server left running for this project and exits — **idempotent** (a no-op when nothing is running). Tries a graceful `/api/shutdown`, falls back to killing the recorded pid, then clears the lock. SKILL.md Step 4 runs this on every path (page-confirm or chat-fallback) so the page never lingers on the shared port before live preview starts.
- Refuses to start unless `<project_path>/confirm_ui/recommendations.json` exists (except `--shutdown`, which needs no recommendations). - Refuses to start unless `<project_path>/confirm_ui/recommendations.json` exists (except `--shutdown`, which needs no recommendations).
- Per-project lock at `<project_path>/.confirm_ui.lock` — duplicate launches are refused; stale locks (dead pid) are overwritten. - Per-project lock at `<project_path>/.confirm_ui.lock` — duplicate launches are refused; stale locks (dead pid) are overwritten.
@@ -45,6 +47,18 @@ The front-end loads `/api/catalogs` (served by the confirm server) and falls bac
Both files live under `<project_path>/confirm_ui/`. Both files live under `<project_path>/confirm_ui/`.
### Two-tier flow
The page runs as a **two-tier wizard in one browser session**. `recommendations.json` carries a top-level `"tier"`:
| `tier` | Page renders | Button | On submit |
|---|---|---|---|
| `1` | anchors — canvas, audience + `content_divergence` + `delivery_purpose` *(PPT only — omitted on non-PPT canvases, not written to the result)* (all in the §c key-info area), mode + visual_style | **Next** | writes `result.json` `{ stage: "tier1", status: "tier1-confirmed", <anchors> }`; the page does **not** close — it shows a "deriving…" state and polls `GET /api/recommendations` |
| `2` | realization — page count, color, typography, icons, formula, image usage + strategy, generation mode, refine spec | **Confirm** | writes `result.json` `{ stage: "final", status: "confirmed", <all fields> }`, then shuts the page down |
| *(absent)* | legacy single-pass — every section on one page | **Confirm** | single final write (`status: "confirmed"`) — backward-compatible |
The AI launches Tier 1 (`--daemon --wait`), reads the tier-1 result, **re-derives** the realization candidates from the user's actual anchors, overwrites `recommendations.json` with `"tier": 2` (realization fields only — it need not echo the anchors), and re-attaches with `--wait-only`. The page preserves the user's Tier 1 selections across the transition (single JS session). `GET /api/recommendations` is served `no-store` so the poll sees the tier-2 overwrite, and when `tier == 2` the server **folds the confirmed anchors from `result.json` back into the payload** (`recommend.canvas` / `mode` / `visual_style` / `delivery_purpose`, plus `audience` / `content_divergence` values) — so a **page refresh / reopen on Tier 2 re-initializes the anchors from the user's choices** instead of catalog defaults, even though those sections are not rendered on the Tier-2 page.
### Input — `recommendations.json` (written by Strategist before launch) ### Input — `recommendations.json` (written by Strategist before launch)
```json ```json
@@ -115,9 +129,9 @@ Both files live under `<project_path>/confirm_ui/`.
- For a custom image plan, the page treats "may include AI" as true only when the recommendation includes `recommend.image_ai_path` or `image_strategy.candidates`; a custom plan without those signals is handled as non-AI and omits AI controls / fields. - For a custom image plan, the page treats "may include AI" as true only when the recommendation includes `recommend.image_ai_path` or `image_strategy.candidates`; a custom plan without those signals is handled as non-AI and omits AI controls / fields.
- **Color candidates carry the user-facing core `palette`**: `background`, `secondary_bg`, `primary`, `accent`, `secondary_accent`, and `body_text`. The page renders labelled swatches and offers per-role override inputs for precise single-role edits, plus a **Custom color card with a free-text box** (parallel to the custom typography box) — the user can describe the palette in words or paste HEX values instead of filling each role; this writes `color: { "name": "custom", "custom": "<text>" }` to `result.json` for the AI to interpret. Legacy `text` is accepted as an alias for `body_text`, but new files should write `body_text`. Strategist derives secondary text, borders, state colors, and visual-style neutral tiers later when writing `design_spec.md` / `spec_lock.md`; those are not user-facing confirmation choices. - **Color candidates carry the user-facing core `palette`**: `background`, `secondary_bg`, `primary`, `accent`, `secondary_accent`, and `body_text`. The page renders labelled swatches and offers per-role override inputs for precise single-role edits, plus a **Custom color card with a free-text box** (parallel to the custom typography box) — the user can describe the palette in words or paste HEX values instead of filling each role; this writes `color: { "name": "custom", "custom": "<text>" }` to `result.json` for the AI to interpret. Legacy `text` is accepted as an alias for `body_text`, but new files should write `body_text`. Strategist derives secondary text, borders, state colors, and visual-style neutral tiers later when writing `design_spec.md` / `spec_lock.md`; those are not user-facing confirmation choices.
- **Candidate display text may be bilingual**: color / typography candidates can provide `name_zh` / `name_en` and `note_zh` / `note_en`; the page falls back to legacy `name` / `note`. - **Candidate display text may be bilingual**: color / typography candidates can provide `name_zh` / `name_en` and `note_zh` / `note_en`; the page falls back to legacy `name` / `note`.
- **Typography candidates split CJK and Latin** for both `heading` and `body`; `css` is the fallback preview `font-family` stack. The page previews CJK sample text with `cjk + css` and Latin sample text with `latin + css`, so the two script choices are visible independently. Each candidate should also include `body_size` — the body baseline, **shown in pt for PPT canvases (16:9 / 4:3), in px for non-PPT canvases** (Xiaohongshu / Story / A4 have no PowerPoint pt semantics). For PPT, the page shows/edits pt; the initial value comes from the candidate's `body_size` (sized for the recommended delivery purpose: 文字型 ~16 · 均衡 ~20 · 展示型 ~24). On submit, the page normalizes PPT values to px before writing `result.json`: `typography.body_size` is px, `typography.body_size_pt` preserves the original pt for provenance. Chat fallback must produce the same normalized contract before `design_spec.md` / `spec_lock.md` are written. The page exposes `body_size` as an editable numeric field whose hint shows a canvas-appropriate range (PPT 1428pt by delivery purpose, non-PPT ≈2.53.3% of height in px). **Inputs are independent — the hint updates with canvas / delivery purpose but never rewrites a value the user can see.** It also offers a custom typography text box so the user is not limited to the proposed candidates. - **Typography candidates split CJK and Latin** for both `heading` and `body`; `css` is the fallback preview `font-family` stack. The page previews CJK sample text with `cjk + css` and Latin sample text with `latin + css`, so the two script choices are visible independently. Each candidate should also include `body_size` — the body baseline in **px** (the system's only unit, every canvas). The initial value comes from the candidate's `body_size`, sized for the recommended delivery purpose: `text` ~20 · `balanced` ~24 · `presentation` ~32. On submit the page writes `typography.body_size` as px directly — no pt conversion, no `body_size_pt` provenance. The page exposes `body_size` as an editable numeric field whose hint shows the recommended size — **one fixed px per delivery purpose on PPT (`text` 20 · `balanced` 24 · `presentation` 32), not a range** (non-PPT ≈2.53.3% of height in px). The user may still edit it; an out-of-range flag only warns if the value strays far (e.g. a unit mistake). **Inputs are independent — the hint updates with canvas / delivery purpose but never rewrites a value the user can see.** It also offers a custom typography text box so the user is not limited to the proposed candidates.
- **Per-role size override** (parallel to color's per-role HEX override): besides `body_size`, the page exposes **independent** editable inputs for `title` / `subtitle` / `annotation`. Each role is pre-filled **once** with a starting value — the candidate's `typography.sizes[role]` if provided, otherwise a one-time ramp suggestion (`body × ` mid-band ratio) — and then holds its own value. **There is no cross-field cascade**: changing `body_size`, `delivery_purpose`, or canvas updates only the suggested-range hint, never the role values; a re-render preserves exactly what the user sees. The final values are written to `result.json` as `typography.sizes: { "title", "subtitle", "annotation" }` in px; for PPT canvases, `typography.sizes_pt` preserves the original pt values for provenance. Seeding `sizes` in a candidate is optional — omit it and each role gets its one-time ramp suggestion. - **Per-role size override** (parallel to color's per-role HEX override): besides `body_size`, the page exposes **independent** editable inputs for `title` / `subtitle` / `annotation`. Each role is pre-filled **once** with a starting value — the candidate's `typography.sizes[role]` if provided, otherwise a one-time ramp suggestion (`body × ` mid-band ratio) — and then holds its own value. **There is no cross-field cascade**: changing `body_size`, `delivery_purpose`, or canvas updates only the recommended-value hint, never the role values; a re-render preserves exactly what the user sees. The final values are written to `result.json` as `typography.sizes: { "title", "subtitle", "annotation" }` in **px** — every canvas, no pt and no `sizes_pt` provenance. Seeding `sizes` in a candidate is optional — omit it and each role gets its one-time ramp suggestion.
- **`delivery_purpose`** (enumerable, PPT only) is the primary driver of the body baseline: `text` (文字型 · read-close), `balanced` (均衡 · business, the default), `presentation` (展示型 · presentation). It is surfaced inside the typography section (item 7) because it informs the recommended body pt and the suggested-range hint above — but the pick itself does not rewrite the body field (inputs are independent). `recommend.delivery_purpose` pre-selects one (default `balanced`); the user's pick writes back to `result.json.delivery_purpose` as a plain id. Strategist uses it to choose the normalized px body baseline and the within-band density/style nudge (see [strategist.md §g](../../references/strategist.md)). Non-PPT canvases omit it. - **`delivery_purpose`** (enumerable, PPT only) is the primary driver of the body baseline: `text` (read-close), `balanced` (business, the default), `presentation`. It is surfaced in the **§c key-information area** (beside audience, Tier 1) as a consumption-mode choice; the Tier-2 typography section then reads the confirmed value for the recommended body px — the pick itself does not rewrite the body field (inputs are independent). `recommend.delivery_purpose` pre-selects one (default `balanced`); the user's pick writes back to `result.json.delivery_purpose` as a plain id. Strategist uses it to set the px body baseline **one fixed px per purpose (`text` 20 · `balanced` 24 · `presentation` 32), not a range** (see [strategist.md §g](../../references/strategist.md)). Non-PPT canvases omit it.
- **Combined style preview** — a compact live "overall impression" strip sits just above the color section and is **sticky**: it pins under the topbar so it stays visible while the user scrolls through the color / icon / typography sections, keeping the picking controls and their combined effect on screen together. It applies the currently selected color palette **and** typography (heading sample in `primary` over `background`, body sample in `body_text`, an `accent` bar, a `secondary_bg` chip) and repaints on every color / HEX-override / font / `body_size` change. It does not replace the per-candidate swatches or font samples (those stay for picking); it is deliberately an abstract style chip, **not** a slide-layout preview — page layout preview remains the live-preview server's job (Step 6). No schema field; it derives entirely from the existing color + typography selections. - **Combined style preview** — a compact live "overall impression" strip sits just above the color section and is **sticky**: it pins under the topbar so it stays visible while the user scrolls through the color / icon / typography sections, keeping the picking controls and their combined effect on screen together. It applies the currently selected color palette **and** typography (heading sample in `primary` over `background`, body sample in `body_text`, an `accent` bar, a `secondary_bg` chip) and repaints on every color / HEX-override / font / `body_size` change. It does not replace the per-candidate swatches or font samples (those stay for picking); it is deliberately an abstract style chip, **not** a slide-layout preview — page layout preview remains the live-preview server's job (Step 6). No schema field; it derives entirely from the existing color + typography selections.
- **Generated image style candidates** live in `image_strategy.candidates` and are shown only when `image_usage` is `ai` or a custom image plan may include generated images. Each candidate records `rendering`, `palette`, and short `visual` / `color` / `mood` lines from Strategist h.5. The chosen value is written to `result.json.image_strategy`; it is omitted when generated images are not part of the plan. - **Generated image style candidates** live in `image_strategy.candidates` and are shown only when `image_usage` is `ai` or a custom image plan may include generated images. Each candidate records `rendering`, `palette`, and short `visual` / `color` / `mood` lines from Strategist h.5. The chosen value is written to `result.json.image_strategy`; it is omitted when generated images are not part of the plan.
- **`visual_style_spectrum`** (optional) lets the AI surface the deck's aesthetic as a **personality spectrum** instead of one badged style. Each entry is `{ "id", "tag_zh"/"tag_en", "note_zh"/"note_en" }` where `id` is a real `visual_styles` catalog id; the page badges those chips with their temperament `tag` (replacing the single ★) and appends the `note` (a real-world analogy) inline. The full grouped style list and Custom box stay visible below, and `recommend.visual_style` is still the pre-selected default (it should equal the spectrum's safe pick). Author **≥3** spanning safe / shifted / bold (mirrors h.5; honest-shortfall exception applies — fewer only when the constraints genuinely cannot yield 3). The user's pick still writes back to `result.json.visual_style` as a plain id; the spectrum is presentation-only. Omit the field to fall back to the single-recommendation badge. - **`visual_style_spectrum`** (optional) lets the AI surface the deck's aesthetic as a **personality spectrum** instead of one badged style. Each entry is `{ "id", "tag_zh"/"tag_en", "note_zh"/"note_en" }` where `id` is a real `visual_styles` catalog id; the page badges those chips with their temperament `tag` (replacing the single ★) and appends the `note` (a real-world analogy) inline. The full grouped style list and Custom box stay visible below, and `recommend.visual_style` is still the pre-selected default (it should equal the spectrum's safe pick). Author **≥3** spanning safe / shifted / bold (mirrors h.5; honest-shortfall exception applies — fewer only when the constraints genuinely cannot yield 3). The user's pick still writes back to `result.json.visual_style` as a plain id; the spectrum is presentation-only. Omit the field to fall back to the single-recommendation badge.
@@ -137,21 +151,24 @@ Both files live under `<project_path>/confirm_ui/`.
"visual_style": "swiss-minimal", "visual_style": "swiss-minimal",
"color": { "name": "...", "palette": { "background": "#...", "secondary_bg": "#...", "primary": "#...", "accent": "#...", "secondary_accent": "#...", "body_text": "#..." } }, "color": { "name": "...", "palette": { "background": "#...", "secondary_bg": "#...", "primary": "#...", "accent": "#...", "secondary_accent": "#...", "body_text": "#..." } },
"icons": "tabler-outline", "icons": "tabler-outline",
"typography": { "name": "...", "heading": { "cjk": "...", "latin": "...", "css": "..." }, "body": { "cjk": "...", "latin": "...", "css": "..." }, "body_size": 26.67, "body_size_pt": 20, "body_size_unit": "px", "body_size_source_unit": "pt", "sizes": { "title": 46.67, "subtitle": 36, "annotation": 21.33 }, "sizes_pt": { "title": 35, "subtitle": 27, "annotation": 16 } }, "typography": { "name": "...", "heading": { "cjk": "...", "latin": "...", "css": "..." }, "body": { "cjk": "...", "latin": "...", "css": "..." }, "body_size": 24, "body_size_unit": "px", "sizes": { "title": 42, "subtitle": 32, "annotation": 18 } },
"delivery_purpose": "balanced", "delivery_purpose": "balanced",
"formula_policy": "mixed", "formula_policy": "mixed",
"image_usage": "web", "image_usage": "web",
"image_strategy": { "name": "方案 A", "rendering": "vector-illustration", "palette": "cool-corporate", "visual": "...", "color": "...", "mood": "..." }, "image_strategy": { "name": "方案 A", "rendering": "vector-illustration", "palette": "cool-corporate", "visual": "...", "color": "...", "mood": "..." },
"generation_mode": "continuous", "generation_mode": "continuous",
"refine_spec": false, "refine_spec": false,
"stage": "final",
"status": "confirmed", "status": "confirmed",
"confirmed_at": "2026-06-15T11:44:44" "confirmed_at": "2026-06-15T11:44:44"
} }
``` ```
The shape above is the **final** (Tier 2) result, carrying all Tier 1 + Tier 2 fields. The intermediate **Tier 1** write carries only the anchor fields plus `"stage": "tier1"`, `"status": "tier1-confirmed"`; the AI reads it to re-derive Tier 2 and never treats it as the final confirmation. A legacy single-pass write has no `stage` (or `stage: "final"`) and `status: "confirmed"`.
- Any option field may instead hold a **free-text custom string** (the user picked **Custom**); `color` / `typography` custom entries set `name: "custom"`. Image usage custom values must be concrete prose plans, not the literal string `"custom"`. The AI interprets custom text against the canonical references. - Any option field may instead hold a **free-text custom string** (the user picked **Custom**); `color` / `typography` custom entries set `name: "custom"`. Image usage custom values must be concrete prose plans, not the literal string `"custom"`. The AI interprets custom text against the canonical references.
- `image_ai_path` and `image_strategy` are omitted from `result.json` unless `image_usage` is `ai` or a custom image plan that may include generated images. Both are honored downstream as confirmed choices — and the page is only a convenience surface over the **canonical chat channel**: the same choices made in chat are honored identically when no `result.json` exists. `image_ai_path` drives the Step 5 generation path (`image-generator.md` §7 — `host-native` forces the host tool even when `IMAGE_BACKEND` is set); the chosen `image_strategy` candidate is locked verbatim by Strategist h.5 (no re-pick). - `image_ai_path` and `image_strategy` are omitted from `result.json` unless `image_usage` is `ai` or a custom image plan that may include generated images. Both are honored downstream as confirmed choices — and the page is only a convenience surface over the **canonical chat channel**: the same choices made in chat are honored identically when no `result.json` exists. `image_ai_path` drives the Step 5 generation path (`image-generator.md` §7 — `host-native` forces the host tool even when `IMAGE_BACKEND` is set); the chosen `image_strategy` candidate is locked verbatim by Strategist h.5 (no re-pick).
- After the user clicks **Confirm**, the page saves `result.json` and shuts the server down (auto-close). In the default `--daemon --wait` flow, the waiting command returns and the AI reads `result.json` immediately; no second chat confirmation is required. Chat confirmation remains the fallback when the page cannot be used. Either way, Step 4 ends with a `--shutdown` cleanup so a never-confirmed page cannot keep holding port 5050 ahead of the Step 6 live preview. - After the user clicks the **final Confirm** (Tier 2, or single-pass), the page saves `result.json` and shuts the server down (auto-close). A Tier-1 **Next** instead keeps the page open (it polls for the re-derived Tier 2). In the default flow, the first `--daemon --wait` returns on the tier-1 result and the second `--wait-only` returns on the final result; the AI reads each immediately no extra chat confirmation is required. Chat confirmation remains the fallback when the page cannot be used. Either way, Step 4 ends with a `--shutdown` cleanup so a never-confirmed page cannot keep holding port 5050 ahead of the Step 6 live preview.
## Scope ## Scope
@@ -147,6 +147,7 @@ class ProjectManager:
"icons", "icons",
"notes", "notes",
"templates", "templates",
"live_preview",
SOURCE_DIRNAME, SOURCE_DIRNAME,
"analysis", "analysis",
"exports", "exports",
@@ -167,6 +168,7 @@ class ProjectManager:
"- `icons/`: project icon set — selected library icons copied in (via icon_sync.py) plus any custom icons you add; embedded from here at export\n" "- `icons/`: project icon set — selected library icons copied in (via icon_sync.py) plus any custom icons you add; embedded from here at export\n"
"- `notes/`: speaker notes\n" "- `notes/`: speaker notes\n"
"- `templates/`: project templates\n" "- `templates/`: project templates\n"
"- `live_preview/`: browser preview runtime files and history (lock.json, server.log, edits.jsonl, annotations.jsonl)\n"
"- `sources/`: source materials and normalized markdown\n" "- `sources/`: source materials and normalized markdown\n"
"- `analysis/`: machine-extracted intermediate analysis (PPTX intake, image_analysis.csv) — the pipeline's canonical must-read source/asset facts\n" "- `analysis/`: machine-extracted intermediate analysis (PPTX intake, image_analysis.csv) — the pipeline's canonical must-read source/asset facts\n"
"- `exports/`: main native pptx (timestamped); `_svg.pptx` sibling added when exported with `--svg-snapshot`\n" "- `exports/`: main native pptx (timestamped); `_svg.pptx` sibling added when exported with `--svg-snapshot`\n"
@@ -1,94 +0,0 @@
#!/usr/bin/env python3
"""
PPT Master - Point-to-Pixel Font Size Converter
Deterministic pt <-> SVG-px font-size conversion, the single source of truth for
the chat-fallback path of the Eight Confirmations (the Confirm UI does the same
math in app.js at submit time). PPT canvases confirm font sizes in pt; the
execution layer (design_spec.md / spec_lock.md / SVG) carries unitless px only.
Convert here instead of mental arithmetic so the chat path matches the UI path.
px = pt * 4 / 3 (the inverse of the exporter's px * 0.75; canvas 1280px -> 960pt slide)
Rounding matches app.js `roundSize` (2 decimals, round-half-up), so a value
confirmed in pt round-trips back to the same pt at export.
Usage:
python3 scripts/pt_to_px.py <pt> [<pt> ...]
python3 scripts/pt_to_px.py --reverse <px> [<px> ...]
Examples:
python3 scripts/pt_to_px.py 20 # -> 26.67 (write into spec_lock body)
python3 scripts/pt_to_px.py 20 36 27 15 # body title subtitle annotation -> 26.67 48 36 20
python3 scripts/pt_to_px.py --reverse 26.67 # -> 20 (what PowerPoint will display)
Dependencies:
None (only uses standard library)
See references/strategist.md §g (Font Size Ramp) for the pt/px unit boundary.
"""
import sys
import math
import argparse
from typing import Optional
PT_TO_PX = 4 / 3 # px = pt * 4/3
PX_TO_PT = 0.75 # pt = px * 0.75 (mirrors svg_to_pptx FONT_PX_TO_HUNDREDTHS_PT)
def _round_half_up(value: float, decimals: int = 2) -> float:
"""Round half-up to ``decimals`` places, matching app.js ``Math.round``."""
factor = 10 ** decimals
return math.floor(value * factor + 0.5) / factor
def pt_to_px(pt: float) -> float:
"""Convert a pt font size to its unitless SVG px value."""
return _round_half_up(pt * PT_TO_PX)
def px_to_pt(px: float) -> float:
"""Convert an SVG px font size back to the pt PowerPoint will display."""
return _round_half_up(px * PX_TO_PT)
def _fmt(value: float) -> str:
"""Drop a trailing ``.0`` so integers print clean (32, not 32.0)."""
return str(int(value)) if value == int(value) else f"{value:.2f}"
def _parse_size(raw: str) -> float:
"""Accept a bare number or a value with a stray pt/px suffix."""
return float(raw.strip().lower().removesuffix("pt").removesuffix("px").strip())
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Convert pt font sizes to unitless SVG px (or back with --reverse).",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("sizes", nargs="+", help="One or more font sizes to convert.")
parser.add_argument(
"-r", "--reverse", action="store_true",
help="Convert px -> pt instead of pt -> px (debug / verification).",
)
return parser
def main(argv: Optional[list[str]] = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
convert = px_to_pt if args.reverse else pt_to_px
try:
values = [convert(_parse_size(s)) for s in args.sizes]
except ValueError:
print("Error: every size must be a number (optionally suffixed pt/px).",
file=sys.stderr)
return 1
print(" ".join(_fmt(v) for v in values))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -27,6 +27,7 @@ TLS fingerprint handling:
""" """
import argparse import argparse
import codecs
import datetime import datetime
import io import io
import json import json
@@ -70,6 +71,95 @@ def _http_get(url: str, *, headers: dict | None = None, timeout: int | None = No
return requests.get(url, headers=headers, timeout=timeout, return requests.get(url, headers=headers, timeout=timeout,
verify=verify, stream=stream) verify=verify, stream=stream)
def _normalize_charset(charset: str | None) -> str:
"""Return a Python codec name when the declared charset is usable."""
if not charset:
return ""
charset = charset.strip().strip('"').strip("'").lower()
if not charset:
return ""
try:
return codecs.lookup(charset).name
except LookupError:
return ""
def _charset_from_headers(headers: dict) -> str:
content_type = headers.get("Content-Type") or headers.get("content-type") or ""
match = re.search(r"charset\s*=\s*([^;\s]+)", content_type, re.I)
return _normalize_charset(match.group(1)) if match else ""
def _charset_from_html(raw: bytes) -> str:
"""Extract a charset declaration from the first chunk of HTML bytes."""
head = raw[:8192]
patterns = [
rb"<meta[^>]+charset=[\"']?\s*([a-zA-Z0-9_\-]+)",
rb"<meta[^>]+content=[\"'][^\"']*charset=\s*([a-zA-Z0-9_\-]+)",
]
for pattern in patterns:
match = re.search(pattern, head, re.I)
if match:
return _normalize_charset(match.group(1).decode("ascii", "ignore"))
return ""
def _decode_quality_score(text: str) -> int:
"""Score obvious decode artifacts; lower is better."""
mojibake_markers = [
"", "", "Ã", "Â", "â€", "’", "“", "â€\x9d",
"", "", "", "", "", "", "", "", "", "",
]
marker_hits = sum(text.count(marker) for marker in mojibake_markers)
control_hits = sum(1 for ch in text if ord(ch) < 32 and ch not in "\t\n\r")
return marker_hits * 20 + control_hits * 10 + text.count("\ufffd") * 50
def _decode_response_text(response) -> str:
"""Decode HTTP response bytes without letting guessed encodings override declarations."""
raw = response.content
declared = [
_charset_from_headers(response.headers),
_charset_from_html(raw),
]
if raw.startswith(codecs.BOM_UTF8):
declared.insert(0, "utf-8-sig")
seen = set()
declared = [enc for enc in declared if enc and not (enc in seen or seen.add(enc))]
for enc in declared:
try:
return raw.decode(enc)
except UnicodeDecodeError:
continue
candidates = []
for enc in [
getattr(response, "encoding", None),
getattr(response, "apparent_encoding", None),
"utf-8",
"gb18030",
"big5",
]:
enc = _normalize_charset(enc)
if enc and enc not in candidates:
candidates.append(enc)
decoded = []
for enc in candidates:
try:
text = raw.decode(enc)
except UnicodeDecodeError:
continue
decoded.append((_decode_quality_score(text), enc, text))
if decoded:
decoded.sort(key=lambda item: item[0])
return decoded[0][2]
return raw.decode("utf-8", errors="replace")
try: try:
from PIL import Image from PIL import Image
PILLOW_AVAILABLE = True PILLOW_AVAILABLE = True
@@ -132,12 +222,7 @@ def fetch_url(url: str) -> str:
timeout=CONFIG["timeout"], verify=False) timeout=CONFIG["timeout"], verify=False)
response.raise_for_status() response.raise_for_status()
# Enhanced encoding detection (requests handles this well usually, but we force apparent_encoding for Chinese) return _decode_response_text(response)
# curl_cffi exposes the same .apparent_encoding attribute
if hasattr(response, "apparent_encoding") and response.apparent_encoding:
response.encoding = response.apparent_encoding
return response.text
except Exception as e: except Exception as e:
raise Exception(f"Failed to fetch {url}: {str(e)}") raise Exception(f"Failed to fetch {url}: {str(e)}")
@@ -25,9 +25,12 @@ import logging
import os import os
import re import re
import signal import signal
import subprocess
import sys import sys
import threading import threading
import time import time
import urllib.error
import urllib.request
import webbrowser import webbrowser
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from pathlib import Path from pathlib import Path
@@ -37,9 +40,10 @@ from flask import Flask, jsonify, request, send_from_directory
logger = logging.getLogger('svg_editor') logger = logging.getLogger('svg_editor')
# Per-project lock file. Lives at <project_path>/.live_preview.lock and # Per-project runtime files live under <project_path>/live_preview/.
# matches the *.lock entry already in the repo .gitignore. LIVE_PREVIEW_DIR_NAME = 'live_preview'
LOCK_FILE_NAME = '.live_preview.lock' LOCK_FILE_NAME = 'lock.json'
LEGACY_LOCK_FILE_NAME = '.live_preview.lock'
# Local — sys.path injection for sibling module (code-style.md §3) # Local — sys.path injection for sibling module (code-style.md §3)
_SCRIPTS_DIR = Path(__file__).resolve().parent _SCRIPTS_DIR = Path(__file__).resolve().parent
@@ -217,20 +221,29 @@ def _validate_edit_attrs(attrs: dict, existing_attrs: set[str]) -> Optional[str]
return None return None
_EDIT_LOG_NAME = '.live_edits.jsonl' EDIT_LOG_NAME = 'edits.jsonl'
ANNOTATION_LOG_NAME = 'annotations.jsonl'
def _append_live_preview_log(project_path: Path, filename: str, record: dict) -> None:
"""Append one live-preview history record under ``live_preview/``."""
try:
log_dir = _runtime_dir(project_path)
log_dir.mkdir(parents=True, exist_ok=True)
with open(log_dir / filename, 'a', encoding='utf-8') as fh:
fh.write(json.dumps(record, ensure_ascii=False) + '\n')
except OSError as exc:
logger.warning('live preview log append failed: %s', exc)
def _append_edit_log(project_path: Path, record: dict) -> None: def _append_edit_log(project_path: Path, record: dict) -> None:
"""Append one applied edit record (old→new) to the project's history. """Append one applied direct-edit record to the preview history."""
_append_live_preview_log(project_path, EDIT_LOG_NAME, record)
The on-disk JSONL is the durable trail the user can review to see exactly
what changed. Un-applied staged edits stay in memory only. def _append_annotation_log(project_path: Path, record: dict) -> None:
""" """Append one annotation lifecycle record to the preview history."""
try: _append_live_preview_log(project_path, ANNOTATION_LOG_NAME, record)
with open(project_path / _EDIT_LOG_NAME, 'a', encoding='utf-8') as fh:
fh.write(json.dumps(record, ensure_ascii=False) + '\n')
except OSError as exc:
logger.warning('edit log append failed: %s', exc)
def _find_by_id(root: ET.Element, element_id: str) -> Optional[ET.Element]: def _find_by_id(root: ET.Element, element_id: str) -> Optional[ET.Element]:
@@ -780,6 +793,11 @@ def create_app(
if not ok: if not ok:
return jsonify({'error': f'Failed to apply edits in {filename}: {reason}'}), 400 return jsonify({'error': f'Failed to apply edits in {filename}: {reason}'}), 400
old_annotations = {
item['element_id']: item['annotation']
for item in parse_annotations(root)
}
# Clear all existing annotations from the file before writing current state # Clear all existing annotations from the file before writing current state
for elem in root.iter(): for elem in root.iter():
elem.attrib.pop('data-edit-target', None) elem.attrib.pop('data-edit-target', None)
@@ -796,6 +814,21 @@ def create_app(
tree.write(str(svg_file), encoding='UTF-8', xml_declaration=True) tree.write(str(svg_file), encoding='UTF-8', xml_declaration=True)
ts = time.time() ts = time.time()
for element_id, annotation_text in anns.items():
old_text = old_annotations.get(element_id)
action = 'annotation_saved' if old_text is None else 'annotation_updated'
if old_text == annotation_text:
action = 'annotation_saved'
_append_annotation_log(project_path, {
'ts': ts, 'file': filename, 'element_id': element_id,
'action': action, 'old': old_text, 'new': annotation_text,
})
for element_id, old_text in old_annotations.items():
if element_id not in anns:
_append_annotation_log(project_path, {
'ts': ts, 'file': filename, 'element_id': element_id,
'action': 'annotation_removed', 'old': old_text, 'new': None,
})
for edit in edits: for edit in edits:
for chg in edit.get('changes', []): for chg in edit.get('changes', []):
_append_edit_log(project_path, { _append_edit_log(project_path, {
@@ -813,6 +846,53 @@ def create_app(
return app return app
def _runtime_dir(project_path: Path) -> Path:
return project_path / LIVE_PREVIEW_DIR_NAME
def _lock_file(project_path: Path) -> Path:
return _runtime_dir(project_path) / LOCK_FILE_NAME
def _legacy_live_lock(project_path: Path) -> Optional[dict]:
"""Return a live legacy root lock, if one exists."""
legacy_lock = project_path / LEGACY_LOCK_FILE_NAME
existing = _read_lock(legacy_lock)
if existing and _process_alive(int(existing.get('pid', 0))):
return existing
return None
def _wait_for_ready(url: str, proc: subprocess.Popen, timeout: int = 15) -> bool:
"""Wait until the server responds or the child exits."""
deadline = time.time() + timeout
health_url = f'{url}/api/config'
while time.time() < deadline:
if proc.poll() is not None:
return False
try:
with urllib.request.urlopen(health_url, timeout=1) as response:
if response.status == 200:
return True
except (urllib.error.URLError, TimeoutError, OSError):
time.sleep(0.25)
return False
def _open_browser(url: str) -> bool:
"""Best-effort browser launch after the local server is reachable."""
try:
if os.name == 'nt':
os.startfile(url) # type: ignore[attr-defined]
return True
return bool(webbrowser.open(url))
except OSError as exc:
logger.warning('browser auto-open failed: %s', exc)
except webbrowser.Error as exc:
logger.warning('browser auto-open failed: %s', exc)
return False
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description='PPT Master SVG Editor', description='PPT Master SVG Editor',
@@ -821,6 +901,11 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument('project_dir', help='Path to project directory (contains svg_output/)') parser.add_argument('project_dir', help='Path to project directory (contains svg_output/)')
parser.add_argument('--port', type=int, default=5050, help='Port to listen on (default: 5050)') parser.add_argument('--port', type=int, default=5050, help='Port to listen on (default: 5050)')
parser.add_argument('--no-browser', action='store_true', help='Do not auto-open browser') parser.add_argument('--no-browser', action='store_true', help='Do not auto-open browser')
parser.add_argument(
'--daemon',
action='store_true',
help='Start the server in the background and return after it is reachable',
)
parser.add_argument( parser.add_argument(
'--live', '--live',
action='store_true', action='store_true',
@@ -857,6 +942,84 @@ def main(argv: Optional[list[str]] = None) -> int:
logger.error('%s is not a directory', svg_output) logger.error('%s is not a directory', svg_output)
return 1 return 1
legacy_existing = _legacy_live_lock(project_path)
if legacy_existing:
existing_pid = legacy_existing.get('pid', '?')
existing_port = legacy_existing.get('port', '?')
logger.error(
'live preview is already running for this project via legacy lock '
'(pid=%s, port=%s). Open http://localhost:%s, click '
'Exit preview in the browser, or stop pid %s',
existing_pid, existing_port, existing_port, existing_pid,
)
return 1
runtime_dir = _runtime_dir(project_path)
lock_file = _lock_file(project_path)
if args.daemon:
existing = _read_lock(lock_file)
if existing and _process_alive(int(existing.get('pid', 0))):
existing_pid = existing.get('pid', '?')
existing_port = existing.get('port', '?')
logger.error(
'live preview is already running for this project '
'(pid=%s, port=%s). Open http://localhost:%s',
existing_pid, existing_port, existing_port,
)
return 1
try:
runtime_dir.mkdir(parents=True, exist_ok=True)
except OSError as exc:
logger.error('cannot create live preview runtime directory: %s (%s)', runtime_dir, exc)
return 1
log_path = runtime_dir / 'server.log'
port = _find_free_port(args.port)
idle_timeout = args.timeout
if idle_timeout is None:
idle_timeout = 7200 if args.live else 900
cmd = [
sys.executable,
str(Path(__file__).resolve()),
str(project_path),
'--port',
str(port),
'--timeout',
str(idle_timeout),
'--no-browser',
]
if args.live:
cmd.append('--live')
creationflags = 0
popen_kwargs = {}
if os.name == 'nt':
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
else:
popen_kwargs['start_new_session'] = True
try:
with log_path.open('a', encoding='utf-8') as log:
proc = subprocess.Popen(
cmd,
stdout=log,
stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL,
creationflags=creationflags,
**popen_kwargs,
)
except OSError as exc:
logger.error('cannot write live preview log: %s (%s)', log_path, exc)
return 1
url = f'http://localhost:{port}'
if not _wait_for_ready(url, proc):
logger.error('live preview failed to become reachable: %s (log: %s)', url, log_path)
return 1
logger.info('started live preview in background: %s (pid=%s)', url, proc.pid)
logger.info('log: %s', log_path)
if not args.no_browser and not _open_browser(url):
logger.info('browser did not auto-open; open %s manually', url)
return 0
# Pick a free port: another project's preview/confirm server may already # Pick a free port: another project's preview/confirm server may already
# hold the default, so bind the next free one instead of crashing — each # hold the default, so bind the next free one instead of crashing — each
# project then serves its own data on its own port (no cross-project mix-up). # project then serves its own data on its own port (no cross-project mix-up).
@@ -866,7 +1029,11 @@ def main(argv: Optional[list[str]] = None) -> int:
# --live mode (which used to disable idle timeout entirely) combined with # --live mode (which used to disable idle timeout entirely) combined with
# silent restarts; refusing duplicate launches catches the accumulation # silent restarts; refusing duplicate launches catches the accumulation
# at its source. Stale locks (dead pid) are overwritten by _claim_lock. # at its source. Stale locks (dead pid) are overwritten by _claim_lock.
lock_file = project_path / LOCK_FILE_NAME try:
runtime_dir.mkdir(parents=True, exist_ok=True)
except OSError as exc:
logger.error('cannot create live preview runtime directory: %s (%s)', runtime_dir, exc)
return 1
existing = _claim_lock(lock_file, port) existing = _claim_lock(lock_file, port)
if existing: if existing:
existing_pid = existing.get('pid', '?') existing_pid = existing.get('pid', '?')
@@ -911,7 +1078,7 @@ def main(argv: Optional[list[str]] = None) -> int:
url = f'http://localhost:{port}' url = f'http://localhost:{port}'
if not args.no_browser: if not args.no_browser:
webbrowser.open(url) _open_browser(url)
mode = "live preview (auto-startup)" if args.live else "live preview" mode = "live preview (auto-startup)" if args.live else "live preview"
svg_count = len(list(svg_output.glob('*.svg'))) svg_count = len(list(svg_output.glob('*.svg')))
@@ -16,7 +16,7 @@ import json
import html import html
from pathlib import Path from pathlib import Path
from typing import List, Dict, Tuple from typing import List, Dict, Tuple
from collections import defaultdict from collections import Counter, defaultdict
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
try: try:
@@ -833,8 +833,10 @@ class SVGQualityChecker:
else RAMP_MAX_RATIO) else RAMP_MAX_RATIO)
size_drifts = set() size_drifts = set()
used_sizes = []
for m in re.finditer(r'font-size\s*=\s*["\']([^"\']+)["\']', content): for m in re.finditer(r'font-size\s*=\s*["\']([^"\']+)["\']', content):
val = self._normalize_size(m.group(1)) val = self._normalize_size(m.group(1))
used_sizes.append(val)
if not allowed_sizes or val in allowed_sizes: if not allowed_sizes or val in allowed_sizes:
continue continue
# Intermediate values are allowed when they sit inside the ramp # Intermediate values are allowed when they sit inside the ramp
@@ -848,6 +850,10 @@ class SVGQualityChecker:
pass pass
size_drifts.add(val) size_drifts.add(val)
template_size_drift = self._detect_template_size_drift(
used_sizes, allowed_sizes, body_px
)
# Record in run-wide aggregation # Record in run-wide aggregation
fname = svg_path.name fname = svg_path.name
for v in color_drifts: for v in color_drifts:
@@ -870,6 +876,72 @@ class SVGQualityChecker:
f"spec_lock drift: {', '.join(parts)} not in spec_lock.md " f"spec_lock drift: {', '.join(parts)} not in spec_lock.md "
"(see drift summary for details)" "(see drift summary for details)"
) )
if template_size_drift:
result['warnings'].append(template_size_drift)
def _detect_template_size_drift(self, used_sizes, allowed_sizes, body_px):
"""Warn when template-like small sizes bypass the locked type ramp.
The normal drift check deliberately permits in-ramp feature sizes, so
it should not hard-fail valid hero numbers or one-off labels. This
warning targets the common executor failure mode: copying a template's
compact 12/15/16px text stack instead of mapping content roles to
spec_lock typography, then reflowing from those locked px values.
"""
if not allowed_sizes or not body_px or body_px <= 0:
return None
try:
declared_min = min(float(v) for v in allowed_sizes)
except ValueError:
declared_min = None
# Stay narrow on purpose: real decks carry legitimate undeclared
# sub-body sizes (intermediate levels, labels, emphasis) just below the
# locked body, so "any size < body" floods the warning and destroys its
# credibility. Only flag values that read as genuine template leftovers
# — at or below `body * 0.75`, or below the smallest declared slot. This
# under-warns (a stray 15/16 against a body of 18 can slip through) in
# exchange for not crying wolf on valid intermediate type.
template_like_limit = body_px * 0.75
template_like_sub_body = []
for raw in used_sizes:
if raw in allowed_sizes:
continue
try:
size = float(raw)
except (TypeError, ValueError):
continue
below_declared_floor = declared_min is not None and size < declared_min
if size <= template_like_limit or below_declared_floor:
template_like_sub_body.append(raw)
if not template_like_sub_body:
return None
counts = Counter(template_like_sub_body)
distinct = sorted(counts, key=lambda v: float(v))
repeated_total = sum(counts.values())
below_declared_floor = []
if declared_min is not None:
below_declared_floor = [v for v in distinct if float(v) < declared_min]
if len(distinct) < 2 and repeated_total < 4 and not below_declared_floor:
return None
sample = ', '.join(
f"{v}x{counts[v]}" if counts[v] > 1 else v
for v in distinct[:5]
)
more = len(distinct) - 5
suffix = f" (+{more} more)" if more > 0 else ""
return (
"possible template font-size drift: undeclared sub-body size(s) "
f"{sample}{suffix}. Map each text item to a spec_lock typography "
"role first, then reflow card height / y / dy / line-height from "
"the locked px values."
)
def _find_image_sources_manifest(self, svg_path: Path) -> Path | None: def _find_image_sources_manifest(self, svg_path: Path) -> Path | None:
"""Locate image_sources.json for a project SVG. """Locate image_sources.json for a project SVG.
@@ -1007,11 +1007,11 @@ def _build_run_xml(
text_dec = run.get('text_decoration', '') text_dec = run.get('text_decoration', '')
# Snap the exported font size to the nearest 0.5pt so PowerPoint shows a # Exported font size = fs_px * FONT_PX_TO_HUNDREDTHS_PT hundredths-of-pt,
# clean value (integer or half-point), never 15.6pt / 22.67pt. Exact size is # rounded to **one decimal place of pt** (the nearest 10 hundredths). No 0.5pt
# fs_px * FONT_PX_TO_HUNDREDTHS_PT hundredths-of-pt; round that to the # / integer snapping — whatever the px works out to is the size, e.g.
# 50-hundredths (0.5pt) grid. (Line spacing below keeps exact precision.) # 18px -> 13.5pt, 24px -> 18.0pt, 42px -> 31.5pt.
sz = int(fs_px * FONT_PX_TO_HUNDREDTHS_PT / 50 + 0.5) * 50 sz = int(round(fs_px * FONT_PX_TO_HUNDREDTHS_PT / 10.0)) * 10
b_attr = ' b="1"' if fw in ('bold', '600', '700', '800', '900') else '' b_attr = ' b="1"' if fw in ('bold', '600', '700', '800', '900') else ''
i_attr = ' i="1"' if fstyle == 'italic' else '' i_attr = ' i="1"' if fstyle == 'italic' else ''
u_attr = ' u="sng"' if 'underline' in text_dec else '' u_attr = ' u="sng"' if 'underline' in text_dec else ''
@@ -14,7 +14,7 @@
| **Design Style** | {design_style} | | **Design Style** | {design_style} |
| **Target Audience** | [Filled by Strategist] | | **Target Audience** | [Filled by Strategist] |
| **Use Case** | [Filled by Strategist] | | **Use Case** | [Filled by Strategist] |
| **Delivery Purpose** | [文字型 read-close / 均衡 business / 展示型 presentation — confirmed at item 7; a deck-wide consumption mode that drives per-page density, page-count recommendation, page_rhythm lean, and the normalized body baseline. See strategist.md §6.1.] | | **Delivery Purpose** | [`text` read-close / `balanced` business / `presentation` — confirmed at item 7; a deck-wide consumption mode that drives per-page density, page-count recommendation, page_rhythm lean, and the body baseline (px). See strategist.md §6.1.] |
| **Content Strategy** | [Material divergence — the user's free-text intent on how closely to follow the source vs how freely to reshape it (or "balanced default"); facts stay sourced however free. Confirmed at c; consumed when authoring §IX. Not in spec_lock.] | | **Content Strategy** | [Material divergence — the user's free-text intent on how closely to follow the source vs how freely to reshape it (or "balanced default"); facts stay sourced however free. Confirmed at c; consumed when authoring §IX. Not in spec_lock.] |
| **Created Date** | {date_str} | | **Created Date** | {date_str} |
@@ -99,7 +99,7 @@
**Typography direction**: [Fill in one phrase, e.g., "modern CJK sans" / "academic serif" / "brand-specific: McKinsey Bower (requires font install)"] **Typography direction**: [Fill in one phrase, e.g., "modern CJK sans" / "academic serif" / "brand-specific: McKinsey Bower (requires font install)"]
> Step 4 Confirm UI: present **≥3** typography candidates (creative recommendations always offer real choice — same rule as h.5; fewer only on the honest-shortfall exception, with a stated reason), each splitting CJK + Latin for `heading` and `body` (with `css` preview stacks) and declaring `body_size` as the user-facing body baseline (pt for PPT canvases, px for non-PPT), in `confirm_ui/recommendations.json`; the confirmed `result.json` normalizes the execution value to px before this spec is written. Schema: [`scripts/docs/confirm_ui.md`](../scripts/docs/confirm_ui.md). > Step 4 Confirm UI: present **≥3** typography candidates (creative recommendations always offer real choice — same rule as h.5; fewer only on the honest-shortfall exception, with a stated reason), each splitting CJK + Latin for `heading` and `body` (with `css` preview stacks) and declaring `body_size` as the body baseline in **px** (the system's only unit, every canvas), in `confirm_ui/recommendations.json`; the confirmed `result.json` carries px directly — no conversion. Schema: [`scripts/docs/confirm_ui.md`](../scripts/docs/confirm_ui.md).
Two views on the same font decisions — fill both, keep them consistent: Two views on the same font decisions — fill both, keep them consistent:
@@ -131,12 +131,12 @@ Two views on the same font decisions — fill both, keep them consistent:
### Font Size Hierarchy ### Font Size Hierarchy
> **Ramp discipline, not a fixed menu.** `body` is the single anchor; every other size is a ratio of it. Each row below gives the role's allowed ratio band. **Structural roles (page title / body / subtitle / annotation / footnote) resolve to one size each and stay that size deck-wide** — pick the value once, lock it, reuse it on every page; same-role drift is what makes a deck look unprofessional. The in-band freedom to use an intermediate value without pre-declaring is for **special / feature elements** (hero number, cover / section display headline, one-off emphasis); if such a size recurs, declare it as its own slot so it too stays consistent. > **Ramp discipline, not a fixed menu.** `body` is the single anchor; every other size is a ratio of it. Each row below gives the role's allowed ratio band. **Structural roles (page title / body / subtitle / annotation / footnote) resolve to one size each and stay that size deck-wide** — pick the value once, lock it, reuse it on every page; same-role drift is what makes a deck look unprofessional. The in-band freedom to use an intermediate value without pre-declaring is for **special / feature elements** (hero number, cover / section display headline, one-off emphasis); if such a size recurs, declare it as its own slot so it too stays consistent.
> **Unit boundary (HARD rule).** Author this section in **unitless px**. PPT canvases may be confirmed in pt, but that conversion is already resolved before writing this spec (`px = pt × 43`); `design_spec.md`, `spec_lock.md`, and SVG carry px only. Never write `pt`, `px`, `em`, or any unit in `spec_lock.md` or SVG. Geometry (margins / gaps / card sizes) is px everywhere. Non-PPT canvases use px throughout. > **Unit boundary (HARD rule).** Author this section in **unitless px** — the system's only unit, every canvas. There is no pt layer and no conversion: the confirmed value is already px. Never write `pt`, `px`, `em`, or any unit in `spec_lock.md` or SVG. Geometry (margins / gaps / card sizes) is px everywhere.
> **Baseline selection**: drive by **delivery purpose** first (read-close vs. presentation), then content density; visual style only nudges within the chosen band. > **Baseline selection**: **delivery purpose** sets the body baseline to **one fixed value** (not a range); content density and visual style drive page treatment / rhythm / the *other* roles, **not** the body size.
**Baseline (unitless px)**: Body font size = [fill in]. For PPT 16:9, normalize from the confirmed delivery-purpose pt band: **文字型 / read-close** `1418pt``18.6724`, **均衡 / business** `1822pt``2429.33` (default `26.67`), **展示型 / presentation** `2228pt``29.3337.33`. Within the band, density picks the point (6+ items → low · sparse / cover → high) and visual style nudges (technical / data low · corporate / instructional mid · editorial / narrative / showcase high). The user may also pin individual role sizes (`title` / `subtitle` / `annotation`) directly in the Confirm UI — a confirmed per-role value (`result.json typography.sizes`) is already px and becomes the locked slot for that role; the rest derive from the ramp. For non-PPT canvases, author px from the confirmed canvas scale (see [strategist.md §g](../references/strategist.md) per-canvas table). **Baseline (unitless px)**: Body font size = [fill in]. For PPT 16:9, the confirmed delivery-purpose value is **one fixed px per purpose, not a range**: **`text` / read-close** `20`, **`balanced` / business** `24` (default), **`presentation`** `32`. The body baseline is purely a function of delivery purpose — density and visual style drive page treatment / rhythm / the other roles, never the body size. The user may also pin individual role sizes (`title` / `subtitle` / `annotation`) directly in the Confirm UI — a confirmed per-role value (`result.json typography.sizes`) is already px and becomes the locked slot for that role; the rest derive from the ramp. For non-PPT canvases, author px from the confirmed canvas scale (see [strategist.md §g](../references/strategist.md) per-canvas table).
| Purpose | Ratio to body | Example @ body=32 (24pt UI) | Example @ body=24 (18pt UI) | Weight | | Purpose | Ratio to body | Example @ body=32 (`presentation`) | Example @ body=24 (`balanced`) | Weight |
| ------- | ------------- | --------------------------- | ------------------------- | ------ | | ------- | ------------- | --------------------------- | ------------------------- | ------ |
| Cover title (hero headline) | 2.5-5x | 80-160 | 60-120 | Bold / Heavy | | Cover title (hero headline) | 2.5-5x | 80-160 | 60-120 | Bold / Heavy |
| Chapter / section opener | 2-2.5x | 64-80 | 48-60 | Bold | | Chapter / section opener | 2-2.5x | 64-80 | 48-60 | Bold |
@@ -150,7 +150,7 @@ Two views on the same font decisions — fill both, keep them consistent:
| Page number / footnote | 0.5-0.65x | 16-21 | 12-16 | Regular | | Page number / footnote | 0.5-0.65x | 16-21 | 12-16 | Regular |
> **Subtitle / lead-in / subheading bands overlap by design** — choose among them by *role*, not size: `subtitle` sits under a title, `lead` is a lead-in / pull-quote in the body flow, `subheading` labels a block inside the content area. Each is its own slot, declared only when the deck uses it, and then held at one size deck-wide like any structural role. Font stays at the **family** level (no new typeface per role): `subheading` → heading / `title_family`, `lead``body_family` or `emphasis_family` — size + weight carry the hierarchy. > **Subtitle / lead-in / subheading bands overlap by design** — choose among them by *role*, not size: `subtitle` sits under a title, `lead` is a lead-in / pull-quote in the body flow, `subheading` labels a block inside the content area. Each is its own slot, declared only when the deck uses it, and then held at one size deck-wide like any structural role. Font stays at the **family** level (no new typeface per role): `subheading` → heading / `title_family`, `lead``body_family` or `emphasis_family` — size + weight carry the hierarchy.
> The two px columns are illustrations for common baselines. For any other `body` value, multiply by each row's ratio. When the confirmation came from PPT pt, you may record the provenance once (e.g. "confirmed as 20pt") in prose, but the size values in this section and in `spec_lock.md` stay normalized px. The checker (`svg_quality_checker._check_spec_lock_drift`) reads the live `body` (px) from `spec_lock.md` and applies the bands, so no code change is needed for a different baseline. > The two px columns are illustrations for common baselines. For any other `body` value, multiply by each row's ratio. All size values here and in `spec_lock.md` are px (no pt anywhere). The checker (`svg_quality_checker._check_spec_lock_drift`) reads the live `body` (px) from `spec_lock.md` and applies the bands, so no code change is needed for a different baseline.
> Sizes outside **every** band remain forbidden — surface the need and extend `spec_lock.md typography` (e.g., `cover_title: 96`) rather than invent a one-off value. > Sizes outside **every** band remain forbidden — surface the need and extend `spec_lock.md typography` (e.g., `cover_title: 96`) rather than invent a one-off value.
@@ -54,10 +54,11 @@
- body_family: "Microsoft YaHei", "PingFang SC", Arial, sans-serif - body_family: "Microsoft YaHei", "PingFang SC", Arial, sans-serif
- emphasis_family: Georgia, SimSun, serif - emphasis_family: Georgia, SimSun, serif
- code_family: Consolas, "Courier New", monospace - code_family: Consolas, "Courier New", monospace
- body: 26.67 - body: 24
- title: 48 - title: 42
- subtitle: 36 - subtitle: 32
- annotation: 20 - annotation: 18
- footnote: 16
> **All five family lines are listed explicitly** so Strategist considers every role — `code_family` and `emphasis_family` are easily forgotten. In a real `spec_lock.md`: > **All five family lines are listed explicitly** so Strategist considers every role — `code_family` and `emphasis_family` are easily forgotten. In a real `spec_lock.md`:
> - Keep any `*_family` whose role genuinely differs from `font_family`. > - Keep any `*_family` whose role genuinely differs from `font_family`.
@@ -68,9 +69,9 @@
> >
> **Source**: copy verbatim from the *Per-role font stacks* list in `design_spec.md §IV Font Plan`. Stack **order** encodes browser-rendering intent (Latin-led vs. CJK-led) that the breakdown table cannot — strings here must match character-for-character. See `design_spec.md §IV` for the explainer. > **Source**: copy verbatim from the *Per-role font stacks* list in `design_spec.md §IV Font Plan`. Stack **order** encodes browser-rendering intent (Latin-led vs. CJK-led) that the breakdown table cannot — strings here must match character-for-character. See `design_spec.md §IV` for the explainer.
> >
> Sizes (`body` / `title` / etc.) are **unitless px numbers** — the execution unit and the same values recorded in `design_spec.md §IV`. PPT canvases may have been confirmed in pt, but that conversion is resolved before spec writing (`px = pt × 43`; e.g. body 20pt → `26.67`, title 36pt → `48`, subtitle 27pt → `36`, annotation 15pt → `20`). For non-PPT canvases there is no pt layer — the px is authored directly. Do not write `pt` / `px` / `em` or any unit, and **do not copy a confirmed pt number unconverted** (`20` would render as 15pt — the one error the unit guard cannot catch). `body` is the **required baseline anchor** — all other sizes derive as ratios of it (ramp table: `design_spec_reference.md §IV`). > Sizes (`body` / `title` / etc.) are **unitless px numbers** — the execution unit and the same values recorded in `design_spec.md §IV`. The system is px-only on every canvas: there is no pt layer and no conversion — the confirmed value is already px (e.g. `balanced` body `24`, title `42`, subtitle `32`, annotation `18`, footnote `16` — clean even px). Do not write `pt` / `px` / `em` or any unit. `body` is the **required baseline anchor** — all other sizes derive as clean-even ratios of it (ramp table: `design_spec_reference.md §IV`).
> >
> **Size slots are anchors, not a closed menu.** Common slots (`title` / `subtitle` / `annotation`) cover frequent cases. Add role-specific slots (e.g. `cover_title: 88`, `hero_number: 56`, `subheading: 32`, `lead: 32`, `chart_annotation: 18`) when needed — common for cover-heavy decks, consulting-style hero numbers, dense pages. `subheading` and `lead` sit between `subtitle` and `body`; their bands overlap `subtitle`, so pick by role, not size, and declare only the ones the deck uses. **Structural roles (title / body / subtitle / annotation / footnote) render at their locked size on every page — one role, one size, deck-wide.** Intermediate in-band sizes are for special / feature elements only (hero number, display title, one-off emphasis); declare a recurring one as its own slot so it stays consistent too. > **Size slots are anchors, not a closed menu.** Common slots (`title` / `subtitle` / `annotation`) cover frequent cases. Add role-specific slots (e.g. `cover_title: 88`, `hero_number: 56`, `subheading: 32`, `lead: 30`, `footnote: 16`, `chart_annotation: 16`) for the roles the deck actually uses — common for cover-heavy decks, consulting-style hero numbers, dense pages. **Mandatory — scan `§IX` and declare a slot for every role that recurs across pages, not just the four defaults.** A report / `text`-mode deck almost always recurs a per-page **core-message / lead line** and **page numbers / source credits / footnotes** → declare `lead` and `footnote` for them. `subheading` and `lead` sit between `subtitle` and `body` (their bands overlap `subtitle`) — pick by role, not size and the core-message `lead` is a **primary** line, **always ≥ `body`**, never smaller. Leaving a recurring lead / footnote undeclared forces the Executor to improvise an unlocked size (and a core line improvised below `body` inverts the hierarchy). **Structural roles (title / body / subtitle / annotation / footnote) render at their locked size on every page — one role, one size, deck-wide.** Intermediate in-band sizes are for special / feature elements only (hero number, display title, one-off emphasis); declare a recurring one as its own slot so it stays consistent too.
> >
> **⚠️ PPT-safe stack discipline (HARD rule).** PPTX stores one `typeface` per run with no runtime fallback. Every stack MUST end with a cross-platform pre-installed font: `"Microsoft YaHei", sans-serif` / `SimSun, serif` / `Arial, sans-serif` / `"Times New Roman", serif` / `Consolas, "Courier New", monospace`. Non-preinstalled fonts (Inter / Google Fonts / brand typefaces) may lead the stack only when the Design Spec notes the font-install or embedding requirement. > **⚠️ PPT-safe stack discipline (HARD rule).** PPTX stores one `typeface` per run with no runtime fallback. Every stack MUST end with a cross-platform pre-installed font: `"Microsoft YaHei", sans-serif` / `SimSun, serif` / `Arial, sans-serif` / `"Times New Roman", serif` / `Consolas, "Courier New", monospace`. Non-preinstalled fonts (Inter / Google Fonts / brand typefaces) may lead the stack only when the Design Spec notes the font-install or embedding requirement.
> >
@@ -181,8 +181,8 @@ Write `<project_path>/confirm_ui/recommendations.json` and launch the same confi
{ "name_zh": "备选配色 A", "name_en": "Alternative palette A", "palette": { "background": "#...", "secondary_bg": "#...", "primary": "#...", "accent": "#...", "secondary_accent": "#...", "body_text": "#..." } } { "name_zh": "备选配色 A", "name_en": "Alternative palette A", "palette": { "background": "#...", "secondary_bg": "#...", "primary": "#...", "accent": "#...", "secondary_accent": "#...", "body_text": "#..." } }
] }, ] },
"typography": { "selected": 0, "candidates": [ "typography": { "selected": 0, "candidates": [
{ "name_zh": "复刻源 PPT(推荐)", "name_en": "Source replica (recommended)", "heading": { "cjk": "...", "latin": "...", "css": "<PPT-safe stack>" }, "body": { "cjk": "...", "latin": "...", "css": "<PPT-safe stack>" }, "body_size": <source observed size in pt = dominant observed.sizes_pt> }, { "name_zh": "复刻源 PPT(推荐)", "name_en": "Source replica (recommended)", "heading": { "cjk": "...", "latin": "...", "css": "<PPT-safe stack>" }, "body": { "cjk": "...", "latin": "...", "css": "<PPT-safe stack>" }, "body_size": <dominant observed.sizes_pt × 4/3, as px> },
{ "name_zh": "实际字体(observed", "name_en": "Observed fonts", "heading": { "cjk": "...", "latin": "...", "css": "<PPT-safe stack>" }, "body": { "cjk": "...", "latin": "...", "css": "<PPT-safe stack>" }, "body_size": <source observed size in pt> }, { "name_zh": "实际字体(observed", "name_en": "Observed fonts", "heading": { "cjk": "...", "latin": "...", "css": "<PPT-safe stack>" }, "body": { "cjk": "...", "latin": "...", "css": "<PPT-safe stack>" }, "body_size": <dominant observed.sizes_pt × 4/3, as px> },
{ "name_zh": "备选字体 A", "name_en": "Alternative pairing A", "heading": { "cjk": "...", "latin": "...", "css": "<PPT-safe stack>" }, "body": { "cjk": "...", "latin": "...", "css": "<PPT-safe stack>" }, "body_size": <canvas-appropriate baseline> } { "name_zh": "备选字体 A", "name_en": "Alternative pairing A", "heading": { "cjk": "...", "latin": "...", "css": "<PPT-safe stack>" }, "body": { "cjk": "...", "latin": "...", "css": "<PPT-safe stack>" }, "body_size": <canvas-appropriate baseline> }
] } ] }
} }
@@ -191,7 +191,7 @@ Write `<project_path>/confirm_ui/recommendations.json` and launch the same confi
- **Recommend keep, allow override**: pre-fill canvas / mode / visual style / icons / image strategy with the source-faithful default (canvas = Step 3 format, mode = `briefing`, image_usage = `provided` since pictures are reused). Enumerable fields already list every catalog option with the source-faithful one badged, so the user can switch. Beautify's only true non-choices are the frozen text and the strict 1:1 page count (changing those means routing to the main pipeline instead — see CLAUDE.md). The §c material-divergence field is therefore not surfaced here — beautify never reshapes content (text is verbatim). - **Recommend keep, allow override**: pre-fill canvas / mode / visual style / icons / image strategy with the source-faithful default (canvas = Step 3 format, mode = `briefing`, image_usage = `provided` since pictures are reused). Enumerable fields already list every catalog option with the source-faithful one badged, so the user can switch. Beautify's only true non-choices are the frozen text and the strict 1:1 page count (changing those means routing to the main pipeline instead — see CLAUDE.md). The §c material-divergence field is therefore not surfaced here — beautify never reshapes content (text is verbatim).
- **Our recommendation is the pre-selected default = the source replica**: for color and typography, author **several candidates** like the from-scratch flow. The pre-selected default (`selected: 0`, the first card) is what beautify recommends — the candidate that **best replicates the source deck's style** (the truest reading of `theme` / `observed`). Replicate-by-default. - **Our recommendation is the pre-selected default = the source replica**: for color and typography, author **several candidates** like the from-scratch flow. The pre-selected default (`selected: 0`, the first card) is what beautify recommends — the candidate that **best replicates the source deck's style** (the truest reading of `theme` / `observed`). Replicate-by-default.
- **Judge the other alternatives exactly as the from-scratch flow does — fonts as much as colors**: don't invent a beautify-specific rule. Author each non-replica candidate with the **same content-driven judgment the Strategist uses when generating from scratch** (color §e, typography §g), applied to the material this project provides — the source document's content and subject, the company's own theme colors, and any brand signal. Pick the palette **and** the font pairing by what fits *this* deck's content; fonts are chosen by content fit, not just defaulted to a safe face. Reach **≥3 candidates total** (PPT-safe stacks; the same creative-choice rule used elsewhere) so a user who departs from the replica still lands on a considered, content-fitting direction — depart-by-choice. - **Judge the other alternatives exactly as the from-scratch flow does — fonts as much as colors**: don't invent a beautify-specific rule. Author each non-replica candidate with the **same content-driven judgment the Strategist uses when generating from scratch** (color §e, typography §g), applied to the material this project provides — the source document's content and subject, the company's own theme colors, and any brand signal. Pick the palette **and** the font pairing by what fits *this* deck's content; fonts are chosen by content fit, not just defaulted to a safe face. Reach **≥3 candidates total** (PPT-safe stacks; the same creative-choice rule used elsewhere) so a user who departs from the replica still lands on a considered, content-fitting direction — depart-by-choice.
- **`body_size` is the load-bearing field, and the replica follows the source's own size**: seed the replica candidate's `body_size` (pt, because this is the confirmation layer for a PPT canvas) from the source's actual body size — take the dominant `observed.sizes_pt` value (the most frequent run-level size, the **body proxy**) without converting it. The confirm page writes normalized px to `result.json` (`body_size`) and keeps the original pt as `body_size_pt`; chat fallback must do the same normalization before `design_spec.md` / `spec_lock.md`. The "most frequent = body" read is a proxy, not a guarantee — `observed.sizes_pt` counts every explicit run size (titles, captions, footnotes, chart/label text included, no placeholder-type resolution), so a deck dense with small labels can let a caption size outrank true body; cross-check the proxy against the page's actual body blocks and the sanity range below before trusting it, and prefer the size the body paragraphs visibly render at over the raw mode when the two disagree. Fall back to `theme.sizes.body` (the declared placeholder size) when `observed.sizes_pt` is empty, and to a PPT delivery-purpose baseline (read-close 1418pt / balanced 1822pt / presentation 2228pt) only when neither is present. Note `theme.sizes.body` is the master `bodyStyle` **level-1 declared default** — a coarse value that commonly **over-reads** the real body density (decks often render body at a deeper outline level or override it smaller), so when you land on this fallback treat it as an upper-ish guess and run it through the sanity check below, never as a precise body size. `theme.sizes.body_levels` and `layout_sizes_pt` are **reference context, not extra fallback tiers**: consult them to judge a saner body value when the deck is theme-driven (`observed` empty) — e.g. a deeper `body_levels` entry or a `layout_sizes_pt` hint may read truer than level-1 — but do not auto-seed from them; the seed chain stays `observed → theme.sizes.body → delivery-purpose baseline`, and a theme-driven deck whose body size genuinely can't be pinned cleanly is exactly the case the sanity check is for. The canvas hint stays a **sanity range**, not the seed: if the source's own size lands far outside it (a dense source doc reads tiny on a projection canvas), surface that to the user rather than silently snapping — the replica recommendation is the source's size, the user confirms or overrides. Non-replica alternatives may use the delivery-purpose baseline. This is what prevents the deck from exporting at an unintentionally small size while still honoring the source. - **`body_size` is the load-bearing field, and the replica follows the source's own size**: seed the replica candidate's `body_size` from the source's actual body size — take the dominant `observed.sizes_pt` value (the most frequent run-level size, the **body proxy**) and **convert it to px (`× 4/3`)** before seeding, since the system is px-only and the source measures in pt: a source 20pt body becomes `26.67`px, so the replica renders at the source's true size (seeding the bare `20` as px would shrink it ~25% — the pt-as-px trap). Whichever source value you land on below (observed mode, or `theme.sizes.body`) gets the same `× 4/3` conversion. The confirm page (and chat fallback) then writes that px to `result.json` (`body_size`) **directly — no further conversion, no `body_size_pt` provenance** (pt never enters the contract). The "most frequent = body" read is a proxy, not a guarantee — `observed.sizes_pt` counts every explicit run size (titles, captions, footnotes, chart/label text included, no placeholder-type resolution), so a deck dense with small labels can let a caption size outrank true body; cross-check the proxy against the page's actual body blocks and the sanity range below before trusting it, and prefer the size the body paragraphs visibly render at over the raw mode when the two disagree. Fall back to `theme.sizes.body` (the declared placeholder size) when `observed.sizes_pt` is empty, and to a PPT delivery-purpose baseline (`text` 20 / `balanced` 24 / `presentation` 32 px — one fixed value per purpose) only when neither is present. Note `theme.sizes.body` is the master `bodyStyle` **level-1 declared default** — a coarse value that commonly **over-reads** the real body density (decks often render body at a deeper outline level or override it smaller), so when you land on this fallback treat it as an upper-ish guess and run it through the sanity check below, never as a precise body size. `theme.sizes.body_levels` and `layout_sizes_pt` are **reference context, not extra fallback tiers**: consult them to judge a saner body value when the deck is theme-driven (`observed` empty) — e.g. a deeper `body_levels` entry or a `layout_sizes_pt` hint may read truer than level-1 — but do not auto-seed from them; the seed chain stays `observed → theme.sizes.body → delivery-purpose baseline`, and a theme-driven deck whose body size genuinely can't be pinned cleanly is exactly the case the sanity check is for. The canvas hint stays a **sanity range**, not the seed: if the source's own size lands far outside it (a dense source doc reads tiny on a projection canvas), surface that to the user rather than silently snapping — the replica recommendation is the source's size, the user confirms or overrides. Non-replica alternatives may use the delivery-purpose baseline. This is what prevents the deck from exporting at an unintentionally small size while still honoring the source.
```bash ```bash
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --daemon --wait python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --daemon --wait
@@ -29,15 +29,15 @@ description: Start the browser SVG editor when it is not running, and apply subm
**Precondition**: no preview service running on this project. **Precondition**: no preview service running on this project.
```bash ```bash
python3 ${SKILL_DIR}/scripts/svg_editor/server.py <project_path> python3 ${SKILL_DIR}/scripts/svg_editor/server.py <project_path> --daemon
``` ```
(Plain mode — no `--live`. The `--live` flag is reserved for Step 6's auto-startup.) (Plain mode — no `--live`. The `--live` flag is reserved for Step 6's auto-startup.)
The server binds `127.0.0.1:5050`, opens the browser on a local desktop, and edits `<project_path>/svg_output/` in place. After it prints `SVG Editor running at http://localhost:5050`, tell the user in their language, in one short message: The launcher binds `127.0.0.1:5050` (or the next free port), starts the server in the background, writes runtime files under `<project_path>/live_preview/`, opens the browser on a local desktop when possible, and edits `<project_path>/svg_output/` in place. After it prints the running URL, tell the user in their language, in one short message:
- editor is at `http://localhost:5050` - editor is at the URL reported by the launcher, e.g. `http://localhost:5050`
- **Direct edit** (deterministic tweaks — wording, color, coordinates, SVG attributes): select an element → change the controls in the right panel → preview updates immediately, but nothing is written to `svg_output/` until **Apply changes**. `Ctrl+Z` or the **Undo** button drops staged edits step by step; applied changes are logged to `<project>/.live_edits.jsonl`. Re-export stays chat-driven: say "re-export" / "重新导出" to refresh the PPTX. - **Direct edit** (deterministic tweaks — wording, color, coordinates, SVG attributes): select an element → change the controls in the right panel → preview updates immediately, but nothing is written to `svg_output/` until **Apply changes**. `Ctrl+Z` or the **Undo** button drops staged edits step by step; applied changes are logged to `<project>/live_preview/edits.jsonl`. Re-export stays chat-driven: say "re-export" / "重新导出" to refresh the PPTX.
- **Annotate** (changes that need AI judgement / re-layout): select an element → write the instruction → click **Add annotation** to stage it → click **Apply changes** to write annotation markers → return to the chat and say `apply my annotations` (or quote the browser prompt) - **Annotate** (changes that need AI judgement / re-layout): select an element → write the instruction → click **Add annotation** to stage it → click **Apply changes** to write annotation markers → return to the chat and say `apply my annotations` (or quote the browser prompt)
- to skip the editor, just describe the change in chat - to skip the editor, just describe the change in chat
@@ -60,6 +60,7 @@ Triggered by the user signals listed in "When to Run".
3. For each listed annotation: 3. For each listed annotation:
- Edit the targeted element in `<project_path>/svg_output/<file>` per the annotation text. - Edit the targeted element in `<project_path>/svg_output/<file>` per the annotation text.
- Remove `data-edit-target` and `data-edit-annotation` from that element. - Remove `data-edit-target` and `data-edit-annotation` from that element.
- Append one `annotation_applied` JSONL record to `<project_path>/live_preview/annotations.jsonl` with `ts`, `file`, `element_id`, and the original annotation text.
4. Re-export: 4. Re-export:
```bash ```bash
python3 ${SKILL_DIR}/scripts/finalize_svg.py <project_path> python3 ${SKILL_DIR}/scripts/finalize_svg.py <project_path>
@@ -78,13 +79,13 @@ Triggered by the user signals listed in "When to Run".
- **Drag to move**: press and drag an already-selected element on the canvas to reposition it (selection stays a separate click, so the background is never dragged by accident); the whole selection moves together under multi-select. The pointer delta is mapped through each element's own CTM, so moves track the cursor regardless of viewport scale or group transforms. Each release stages one direct edit per moved element (the same `x`/`y`-or-`transform` write the geometry inputs produce), previewed live and written only on **Apply changes**; dragging on empty canvas is still rubber-band selection. A failed stage rolls the canvas back to the pre-drag position. - **Drag to move**: press and drag an already-selected element on the canvas to reposition it (selection stays a separate click, so the background is never dragged by accident); the whole selection moves together under multi-select. The pointer delta is mapped through each element's own CTM, so moves track the cursor regardless of viewport scale or group transforms. Each release stages one direct edit per moved element (the same `x`/`y`-or-`transform` write the geometry inputs produce), previewed live and written only on **Apply changes**; dragging on empty canvas is still rubber-band selection. A failed stage rolls the canvas back to the pre-drag position.
- **Arrow-key nudge**: with one or more elements selected, `↑ ↓ ← →` moves the selection 1px and `Shift + arrow` moves 10px (suppressed while typing in the annotation box). Arrow keys navigate slides only when nothing is selected. Same staging/coalescing path as drag, so a burst of nudges collapses to one undo step. - **Arrow-key nudge**: with one or more elements selected, `↑ ↓ ← →` moves the selection 1px and `Shift + arrow` moves 10px (suppressed while typing in the annotation box). Arrow keys navigate slides only when nothing is selected. Same staging/coalescing path as drag, so a burst of nudges collapses to one undo step.
- **Overlap picker**: right-click anywhere on the canvas to list every selectable element under the pointer (top→bottom), so stacked shapes can be reached without blind cycling. Left-click is unchanged (selects the topmost). Hovering a row highlights that element; clicking it selects it; `Esc` or an outside click closes the list. With exactly one element under the pointer, right-click selects it directly. - **Overlap picker**: right-click anywhere on the canvas to list every selectable element under the pointer (top→bottom), so stacked shapes can be reached without blind cycling. Left-click is unchanged (selects the topmost). Hovering a row highlights that element; clicking it selects it; `Esc` or an outside click closes the list. With exactly one element under the pointer, right-click selects it directly.
- **Undo**: `Ctrl+Z` or the **Undo** button drops the last staged direct edit on the current slide (per-slide LIFO, this session). Consecutive edits to the *same element and same field set* (e.g. nudging one color or coordinate several times) coalesce into a single undo step, keeping the original pre-edit value; switching element or field starts a new step. Applied old→new history is appended to `<project>/.live_edits.jsonl`; un-applied staged edits are in-memory only. - **Undo**: `Ctrl+Z` or the **Undo** button drops the last staged direct edit on the current slide (per-slide LIFO, this session). Consecutive edits to the *same element and same field set* (e.g. nudging one color or coordinate several times) coalesce into a single undo step, keeping the original pre-edit value; switching element or field starts a new step. Applied old→new history is appended to `<project>/live_preview/edits.jsonl`; annotation save/update/remove history is appended to `<project>/live_preview/annotations.jsonl`; un-applied staged edits are in-memory only.
- **Unsaved-work guard**: staged direct edits and annotation changes (added or removed) live in server memory until **Apply changes**; closing the tab triggers the browser's native "leave site?" prompt while any are unapplied, since an idle timeout or process kill would drop them. - **Unsaved-work guard**: staged direct edits and annotation changes (added or removed) live in server memory until **Apply changes**; closing the tab triggers the browser's native "leave site?" prompt while any are unapplied, since an idle timeout or process kill would drop them.
- **Re-export is chat-driven**: applying changes updates `svg_output/` only. Refreshing the PPTX (finalize + svg_to_pptx) stays a chat step — the editor never runs the export pipeline. - **Re-export is chat-driven**: applying changes updates `svg_output/` only. Refreshing the PPTX (finalize + svg_to_pptx) stays a chat step — the editor never runs the export pipeline.
- **Stop conditions**: the service stops when the user clicks **Exit preview** in the browser, asks in chat to stop it, the idle timeout fires, or the process is killed externally. - **Stop conditions**: the service stops when the user clicks **Exit preview** in the browser, asks in chat to stop it, the idle timeout fires, or the process is killed externally.
- **Port**: default `5050`, auto-advancing to the next free port when another project already holds it (report the actual URL from the launch log); force a specific port with `--port <other>`. - **Port**: default `5050`, auto-advancing to the next free port when another project already holds it (report the actual URL from the launch log); force a specific port with `--port <other>`.
- **Idle timeout**: plain mode `900s`, `--live` mode `7200s`; override with `--timeout <seconds>` (`0` disables). - **Idle timeout**: plain mode `900s`, `--live` mode `7200s`; override with `--timeout <seconds>` (`0` disables).
- **Single instance per project**: `<project_path>/.live_preview.lock` records the running pid + port. A second launch against the same project refuses to start and prints the existing URL; stale locks (dead pid) are overwritten on the next launch. Delete the file by hand only if the process is gone but the lock remains (rare — `kill -9` is the only common cause). - **Single instance per project**: `<project_path>/live_preview/lock.json` records the running pid + port. A second launch against the same project refuses to start and prints the existing URL; stale locks (dead pid) are overwritten on the next launch. Legacy root locks at `<project_path>/.live_preview.lock` are still detected when they point to a live process.
- **Transient ids**: each element gets a temporary `_edit_N` id while the editor is running. On save, only annotated elements keep their id; unannotated `_edit_N` ids are stripped before write-back. - **Transient ids**: each element gets a temporary `_edit_N` id while the editor is running. On save, only annotated elements keep their id; unannotated `_edit_N` ids are stripped before write-back.
- **Browser preview**: the server inlines `<use data-icon>` placeholders and serves `images/*` so SVG renders correctly; the on-disk SVG is unchanged by this preview. - **Browser preview**: the server inlines `<use data-icon>` placeholders and serves `images/*` so SVG renders correctly; the on-disk SVG is unchanged by this preview.
@@ -95,9 +96,9 @@ Triggered by the user signals listed in "When to Run".
If the project lives on a remote Linux server, run with `--no-browser`: If the project lives on a remote Linux server, run with `--no-browser`:
```bash ```bash
python3 ${SKILL_DIR}/scripts/svg_editor/server.py <project_path> --no-browser python3 ${SKILL_DIR}/scripts/svg_editor/server.py <project_path> --daemon --no-browser
# or for Step 6's auto-startup on a remote host: # or for Step 6's auto-startup on a remote host:
python3 ${SKILL_DIR}/scripts/svg_editor/server.py <project_path> --live --no-browser python3 ${SKILL_DIR}/scripts/svg_editor/server.py <project_path> --live --daemon --no-browser
``` ```
- **VS Code / Cursor Remote-SSH**: open the **PORTS** panel (`Ctrl+Shift+P``Ports: Focus on Ports View`), click **Forward a Port**, enter `5050`. The workspace remembers it. - **VS Code / Cursor Remote-SSH**: open the **PORTS** panel (`Ctrl+Shift+P``Ports: Focus on Ports View`), click **Forward a Port**, enter `5050`. The workspace remembers it.
@@ -2,8 +2,8 @@
"sourceId": "shadcn", "sourceId": "shadcn",
"repo": "https://github.com/shadcn-ui/ui.git", "repo": "https://github.com/shadcn-ui/ui.git",
"ref": "main", "ref": "main",
"commit": "70a7a0c2a8271ccde9932fb40d5b4adeacb9b131", "commit": "b59f68ecc5216e39d3f492e79b9767388ccd43db",
"adapter": "claude-skill", "adapter": "claude-skill",
"sourcePath": "skills/shadcn", "sourcePath": "skills/shadcn",
"syncedAt": "2026-06-21T16:00:01Z" "syncedAt": "2026-06-23T16:00:00Z"
} }
@@ -2,8 +2,8 @@
"sourceId": "ui-ux-pro-max", "sourceId": "ui-ux-pro-max",
"repo": "https://github.com/nextlevelbuilder/ui-ux-pro-max-skill.git", "repo": "https://github.com/nextlevelbuilder/ui-ux-pro-max-skill.git",
"ref": "main", "ref": "main",
"commit": "9a863a527534eeff97cad45430b8e5678ab45c40", "commit": "1518fec29d19ce905cd0c689255137b9dcab7ccc",
"adapter": "claude-skill", "adapter": "claude-skill",
"sourcePath": ".claude/skills/ui-ux-pro-max", "sourcePath": ".claude/skills/ui-ux-pro-max",
"syncedAt": "2026-06-22T16:00:01Z" "syncedAt": "2026-06-23T16:00:00Z"
} }